@mentra/bluetooth-sdk 3.2.0-dev.195 → 3.2.0-dev.203

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +64 -45
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -1
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +77 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +12 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +318 -36
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +626 -120
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +76 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +16 -3
  18. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoBridgeTest.kt +33 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +169 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
  23. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt +57 -0
  24. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +52 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +94 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycleTest.kt +112 -0
  28. package/build/BluetoothSdk.types.d.ts +62 -1
  29. package/build/BluetoothSdk.types.d.ts.map +1 -1
  30. package/build/BluetoothSdk.types.js.map +1 -1
  31. package/build/_private/BluetoothSdkModule.d.ts +3 -2
  32. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  33. package/build/_private/BluetoothSdkModule.js.map +1 -1
  34. package/build/generated/changelogCatalog.d.ts +1 -1
  35. package/build/generated/changelogCatalog.js +1 -1
  36. package/build/generated/changelogCatalog.js.map +1 -1
  37. package/build/generated/releaseMetadata.js +5 -5
  38. package/build/generated/releaseMetadata.js.map +1 -1
  39. package/build/index.d.ts +1 -1
  40. package/build/index.d.ts.map +1 -1
  41. package/build/index.js +3 -0
  42. package/build/index.js.map +1 -1
  43. package/ios/BluetoothSdkModule.swift +8 -2
  44. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  45. package/ios/Source/Bridge.swift +78 -1
  46. package/ios/Source/DeviceManager.swift +13 -6
  47. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  48. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  49. package/ios/Source/MentraBluetoothSDK.swift +397 -99
  50. package/ios/Source/WifiRequestResolution.swift +251 -0
  51. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
  52. package/ios/Source/sgcs/MentraLive.swift +179 -37
  53. package/ios/Source/sgcs/SGCManager.swift +13 -1
  54. package/ios/Source/status/DeviceStatus.swift +30 -3
  55. package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
  56. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
  57. package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
  58. package/package.json +1 -1
  59. package/src/BluetoothSdk.types.ts +76 -1
  60. package/src/_private/BluetoothSdkModule.ts +4 -1
  61. package/src/generated/changelogCatalog.ts +1 -1
  62. package/src/generated/releaseMetadata.ts +5 -5
  63. package/src/index.ts +11 -0
@@ -0,0 +1,246 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ internal fun wifiSsidIsValid(ssid: String): Boolean = ssid.trim().isNotEmpty()
4
+
5
+ /** Validate raw fields before JSON convenience getters can coerce numbers or erase presence. */
6
+ internal fun wifiResponseEnvelopeIsValid(values: Map<String, Any>, allowLegacy: Boolean): Boolean {
7
+ if (values.containsKey("connected") && values["connected"] !is Boolean) return false
8
+ if (values.containsKey("dispatched") && values["dispatched"] !is Boolean) return false
9
+ val hasTuple = listOf("protocol_version", "requestId", "sid").any(values::containsKey)
10
+ if (!hasTuple) return allowLegacy && !values.containsKey("outcome")
11
+ return (values["protocol_version"] as? Number)?.toDouble() == 1.0 &&
12
+ (values["requestId"] as? String)?.isNotBlank() == true &&
13
+ (values["sid"] as? String)?.isNotBlank() == true &&
14
+ !values.containsKey("dispatched")
15
+ }
16
+
17
+ internal const val WIFI_CAPABILITY_NEGOTIATION_TIMEOUT_CODE = "capability_negotiation_timeout"
18
+
19
+ internal sealed class WifiProtocolCapability {
20
+ data object Unknown : WifiProtocolCapability()
21
+
22
+ data class Supported(val version: Int) : WifiProtocolCapability()
23
+
24
+ data object Unsupported : WifiProtocolCapability()
25
+
26
+ data object Legacy : WifiProtocolCapability()
27
+ }
28
+
29
+ internal enum class WifiRequestMode {
30
+ DISCOVERING,
31
+ MODERN,
32
+ LEGACY,
33
+ UNSUPPORTED,
34
+ }
35
+
36
+ internal data class WifiSessionRequestSnapshot(
37
+ val mode: WifiRequestMode,
38
+ val sessionId: String,
39
+ val epoch: Long,
40
+ )
41
+
42
+ internal class WifiSessionCapabilities {
43
+ var sessionId: String = ""
44
+ private set
45
+ var epoch: Long = 0
46
+ private set
47
+ var forgetResult: WifiProtocolCapability = WifiProtocolCapability.Unknown
48
+ private set
49
+ var savedNetworks: WifiProtocolCapability = WifiProtocolCapability.Unknown
50
+ private set
51
+
52
+ fun reset(sessionId: String = "") {
53
+ epoch += 1
54
+ this.sessionId = sessionId
55
+ forgetResult = WifiProtocolCapability.Unknown
56
+ savedNetworks = WifiProtocolCapability.Unknown
57
+ }
58
+
59
+ fun applyVersionInfo1(values: Map<String, Any>) {
60
+ (values["sid"] as? String)?.takeIf { it.isNotEmpty() }?.let { sessionId = it }
61
+ forgetResult = capability(values["wifiForgetResultVersion"])
62
+ savedNetworks = capability(values["savedWifiNetworksVersion"])
63
+ }
64
+
65
+ fun forgetMode(): WifiRequestMode = requestMode(forgetResult)
66
+
67
+ fun savedNetworksMode(): WifiRequestMode = requestMode(savedNetworks)
68
+
69
+ fun savedNetworksRequestSnapshot(): WifiSessionRequestSnapshot =
70
+ WifiSessionRequestSnapshot(savedNetworksMode(), sessionId, epoch)
71
+
72
+ private fun capability(raw: Any?): WifiProtocolCapability {
73
+ if (raw == null) return WifiProtocolCapability.Legacy
74
+ return if (raw is Number && raw.toDouble() == 1.0 && sessionId.isNotEmpty()) {
75
+ WifiProtocolCapability.Supported(1)
76
+ } else WifiProtocolCapability.Unsupported
77
+ }
78
+
79
+ private fun requestMode(capability: WifiProtocolCapability): WifiRequestMode =
80
+ when (capability) {
81
+ WifiProtocolCapability.Unknown -> WifiRequestMode.DISCOVERING
82
+ is WifiProtocolCapability.Supported -> WifiRequestMode.MODERN
83
+ WifiProtocolCapability.Legacy -> WifiRequestMode.LEGACY
84
+ WifiProtocolCapability.Unsupported -> WifiRequestMode.UNSUPPORTED
85
+ }
86
+ }
87
+
88
+ enum class WifiForgetOutcome(val wireValue: String) {
89
+ CONFIRMED("confirmed"),
90
+ DISPATCHED("dispatched"),
91
+ NOT_FOUND("not_found"),
92
+ UNSUPPORTED("unsupported"),
93
+ FAILED("failed"),
94
+ LEGACY_UNVERIFIED("legacy_unverified");
95
+
96
+ companion object {
97
+ internal fun fromWire(value: String): WifiForgetOutcome? =
98
+ entries.find { it.wireValue == value }
99
+ }
100
+ }
101
+
102
+ internal fun normalizeWifiForgetResultEvent(
103
+ requestId: String,
104
+ sid: String,
105
+ ssid: String,
106
+ protocolVersion: Int,
107
+ outcome: String,
108
+ legacyDispatched: Boolean?,
109
+ connected: Boolean?,
110
+ currentSsid: String,
111
+ localIp: String,
112
+ error: String?,
113
+ ): Map<String, Any>? {
114
+ if (!wifiSsidIsValid(ssid)) return null
115
+ val modernOutcome = WifiForgetOutcome.fromWire(outcome)
116
+ if (requestId.isNotEmpty() && sid.isNotEmpty() && protocolVersion == 1 &&
117
+ modernOutcome != null && modernOutcome != WifiForgetOutcome.LEGACY_UNVERIFIED
118
+ ) {
119
+ return buildMap {
120
+ put("mode", "modern")
121
+ put("requestId", requestId)
122
+ put("sid", sid)
123
+ put("ssid", ssid)
124
+ put("protocolVersion", protocolVersion)
125
+ put("outcome", modernOutcome.wireValue)
126
+ connected?.let { put("connected", it) }
127
+ if (currentSsid.isNotEmpty()) put("currentSsid", currentSsid)
128
+ if (localIp.isNotEmpty()) put("localIp", localIp)
129
+ error?.let { put("error", it) }
130
+ }
131
+ }
132
+ if (requestId.isEmpty() && sid.isEmpty() && protocolVersion == 0 && outcome.isEmpty() && legacyDispatched != null) {
133
+ return buildMap {
134
+ put("mode", "legacy")
135
+ put("ssid", ssid)
136
+ put("dispatched", legacyDispatched)
137
+ connected?.let { put("connected", it) }
138
+ if (currentSsid.isNotEmpty()) put("currentSsid", currentSsid)
139
+ if (localIp.isNotEmpty()) put("localIp", localIp)
140
+ error?.let { put("error", it) }
141
+ }
142
+ }
143
+ return null
144
+ }
145
+
146
+ data class WifiForgetResult(
147
+ val ssid: String,
148
+ val outcome: WifiForgetOutcome,
149
+ val connected: Boolean?,
150
+ val currentSsid: String?,
151
+ val localIp: String?,
152
+ val error: String? = null,
153
+ ) {
154
+ internal fun toMap(): Map<String, Any> =
155
+ buildMap {
156
+ put("ssid", ssid)
157
+ put("outcome", outcome.wireValue)
158
+ connected?.let { put("connected", it) }
159
+ currentSsid?.let { put("currentSsid", it) }
160
+ localIp?.let { put("localIp", it) }
161
+ error?.let { put("error", it) }
162
+ }
163
+ }
164
+
165
+ enum class SavedWifiNetworksOutcome(val wireValue: String) {
166
+ CONFIRMED("confirmed"),
167
+ UNSUPPORTED("unsupported"),
168
+ FAILED("failed");
169
+
170
+ companion object {
171
+ internal fun fromWire(value: String): SavedWifiNetworksOutcome? =
172
+ entries.find { it.wireValue == value }
173
+ }
174
+ }
175
+
176
+ data class SavedWifiNetworksResult(
177
+ val outcome: SavedWifiNetworksOutcome,
178
+ val networks: List<String>,
179
+ val error: String? = null,
180
+ ) {
181
+ internal fun toMap(): Map<String, Any> =
182
+ buildMap {
183
+ put("outcome", outcome.wireValue)
184
+ put("networks", networks)
185
+ error?.let { put("error", it) }
186
+ }
187
+ }
188
+
189
+ internal fun parseWifiForgetResult(
190
+ expectedRequestId: String,
191
+ expectedSid: String,
192
+ expectedSsid: String,
193
+ capabilityVersion: Int,
194
+ data: Map<String, Any>,
195
+ ): WifiForgetResult? {
196
+ if (capabilityVersion != 1 || expectedRequestId.isEmpty() || expectedSid.isEmpty()) return null
197
+ if (data["requestId"] as? String != expectedRequestId) return null
198
+ if (data["sid"] as? String != expectedSid) return null
199
+ if (data["ssid"] as? String != expectedSsid) return null
200
+ if ((data["protocolVersion"] as? Number)?.toDouble() != 1.0) return null
201
+ val outcome = WifiForgetOutcome.fromWire(data["outcome"] as? String ?: return null) ?: return null
202
+ if (outcome == WifiForgetOutcome.LEGACY_UNVERIFIED) return null
203
+ return WifiForgetResult(
204
+ ssid = expectedSsid,
205
+ outcome = outcome,
206
+ connected = data["connected"] as? Boolean,
207
+ currentSsid = data["currentSsid"] as? String,
208
+ localIp = data["localIp"] as? String,
209
+ error = (data["error"] as? String)?.takeIf { it.isNotEmpty() },
210
+ )
211
+ }
212
+
213
+ internal fun parseSavedWifiNetworks(
214
+ expectedRequestId: String,
215
+ expectedSid: String,
216
+ capabilityVersion: Int,
217
+ data: Map<String, Any>,
218
+ ): SavedWifiNetworksResult? {
219
+ if (capabilityVersion != 1 || expectedRequestId.isEmpty() || expectedSid.isEmpty()) return null
220
+ if (data["requestId"] as? String != expectedRequestId) return null
221
+ if (data["sid"] as? String != expectedSid) return null
222
+ if ((data["protocolVersion"] as? Number)?.toDouble() != 1.0) return null
223
+ val outcome =
224
+ SavedWifiNetworksOutcome.fromWire(data["outcome"] as? String ?: return null) ?: return null
225
+ val rawNetworks = data["networks"] as? List<*> ?: return null
226
+ if (rawNetworks.any { it !is String }) return null
227
+ if (outcome != SavedWifiNetworksOutcome.CONFIRMED && rawNetworks.isNotEmpty()) return null
228
+ val networks = rawNetworks.map { it as String }.filter { it.trim().isNotEmpty() }.distinct()
229
+ return SavedWifiNetworksResult(
230
+ outcome = outcome,
231
+ networks = networks,
232
+ error = (data["error"] as? String)?.takeIf { it.isNotEmpty() },
233
+ )
234
+ }
235
+
236
+ internal fun legacyWifiForgetResult(
237
+ ssid: String,
238
+ ): WifiForgetResult {
239
+ return WifiForgetResult(
240
+ ssid = ssid,
241
+ outcome = WifiForgetOutcome.LEGACY_UNVERIFIED,
242
+ connected = null,
243
+ currentSsid = null,
244
+ localIp = null,
245
+ )
246
+ }
@@ -3,6 +3,7 @@ package com.mentra.bluetoothsdk.services
3
3
  import android.app.NotificationChannel
4
4
  import android.app.NotificationManager
5
5
  import android.app.Service
6
+ import android.content.ComponentName
6
7
  import android.content.Intent
7
8
  import android.content.pm.PackageManager
8
9
  import android.content.pm.ServiceInfo
@@ -21,14 +22,31 @@ class ForegroundService : Service() {
21
22
  const val ACTION_REFRESH_TYPES =
22
23
  "com.mentra.bluetoothsdk.services.action.REFRESH_FOREGROUND_SERVICE_TYPES"
23
24
 
24
- internal fun bootstrapServiceType(): Int =
25
- ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
25
+ internal const val DEFAULT_SERVICE_TYPES =
26
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or
27
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or
28
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION or
29
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or
30
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
31
+
32
+ internal fun bootstrapServiceType(declaredTypes: Int = DEFAULT_SERVICE_TYPES): Int {
33
+ // Both types can start without a while-in-use permission. The SDK declares
34
+ // CHANGE_WIFI_STATE, which satisfies connectedDevice's prerequisite.
35
+ if (declaredTypes and ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC != 0) {
36
+ return ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
37
+ }
38
+ require(declaredTypes and ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE != 0) {
39
+ "The SDK foreground service must declare connectedDevice or dataSync"
40
+ }
41
+ return ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
42
+ }
26
43
 
27
44
  internal fun preferredServiceType(
28
45
  hasConnectedDeviceAccess: Boolean,
29
46
  hasMicrophoneAccess: Boolean,
30
47
  hasLocationAccess: Boolean,
31
48
  includeMediaPlayback: Boolean = true,
49
+ declaredTypes: Int = DEFAULT_SERVICE_TYPES,
32
50
  ): Int {
33
51
  var serviceType = 0
34
52
 
@@ -47,21 +65,31 @@ class ForegroundService : Service() {
47
65
  serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
48
66
  }
49
67
 
50
- return if (serviceType == 0) bootstrapServiceType() else serviceType
68
+ serviceType = serviceType and declaredTypes
69
+ return if (serviceType == 0) bootstrapServiceType(declaredTypes) else serviceType
51
70
  }
52
71
  }
53
72
 
54
73
  private var locationTypeRequested = false
55
74
 
75
+ private val declaredServiceTypes: Int by lazy {
76
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
77
+ packageManager.getServiceInfo(ComponentName(this, ForegroundService::class.java), 0)
78
+ .foregroundServiceType
79
+ } else {
80
+ DEFAULT_SERVICE_TYPES
81
+ }
82
+ }
83
+
56
84
  override fun onCreate() {
57
85
  super.onCreate()
58
86
  Bridge.log("ForegroundService: onCreate() called")
59
87
  BleTraceLogger.logLifecycle(this, "ForegroundService", "service_create")
60
- // Enter the foreground immediately with a type that has no runtime
61
- // prerequisites. onStartCommand() replaces this bootstrap type with the
88
+ // Enter the foreground immediately using a type allowed by the host manifest.
89
+ // onStartCommand() replaces this bootstrap type with the
62
90
  // eligible long-running types, deliberately omitting dataSync so Android 15's
63
91
  // six-hour dataSync timer no longer applies.
64
- startForegroundWithType(bootstrapServiceType())
92
+ startForegroundWithType(bootstrapServiceType(declaredServiceTypes))
65
93
  }
66
94
 
67
95
  override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -114,12 +142,13 @@ class ForegroundService : Service() {
114
142
  return 0 // No service types before Android Q
115
143
  }
116
144
 
117
- // Audio prompts can be initiated by glasses while the host Activity is
118
- // backgrounded. mediaPlayback has no runtime prerequisite, so keep it
119
- // active on the existing Mentra service for the entire connected
120
- // session rather than trying to launch a second service after a wake
121
- // phrase arrives.
122
- Bridge.log("ForegroundService: Added mediaPlayback (supports background audio)")
145
+ // MentraOS uses background audio prompts; Bluetooth-only hosts can omit
146
+ // this type from their merged manifest.
147
+ val includeMediaPlayback =
148
+ declaredServiceTypes and ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK != 0
149
+ if (includeMediaPlayback) {
150
+ Bridge.log("ForegroundService: Added mediaPlayback (supports background audio)")
151
+ }
123
152
 
124
153
  // Check Bluetooth permissions
125
154
  val hasBluetoothPermission =
@@ -168,13 +197,14 @@ class ForegroundService : Service() {
168
197
 
169
198
  // Check microphone permission
170
199
  val hasMicPermission =
171
- ContextCompat.checkSelfPermission(this, android.Manifest.permission.RECORD_AUDIO) ==
172
- PackageManager.PERMISSION_GRANTED
200
+ declaredServiceTypes and ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE != 0 &&
201
+ ContextCompat.checkSelfPermission(this, android.Manifest.permission.RECORD_AUDIO) ==
202
+ PackageManager.PERMISSION_GRANTED
173
203
 
174
204
  if (hasMicPermission) {
175
205
  Bridge.log("ForegroundService: Added microphone (has RECORD_AUDIO permission)")
176
206
  } else {
177
- Bridge.log("ForegroundService: No microphone permission")
207
+ Bridge.log("ForegroundService: Microphone type disabled or permission not granted")
178
208
  }
179
209
 
180
210
  val hasLocationPermission =
@@ -189,7 +219,9 @@ class ForegroundService : Service() {
189
219
  val locationManager = getSystemService(LocationManager::class.java)
190
220
  val isLocationEnabled = locationManager?.isLocationEnabled == true
191
221
 
192
- val hasLocationAccess = locationTypeRequested && hasLocationPermission && isLocationEnabled
222
+ val hasLocationAccess =
223
+ declaredServiceTypes and ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION != 0 &&
224
+ locationTypeRequested && hasLocationPermission && isLocationEnabled
193
225
  if (hasLocationAccess) {
194
226
  Bridge.log("ForegroundService: Added location (has foreground location permission)")
195
227
  } else {
@@ -203,6 +235,8 @@ class ForegroundService : Service() {
203
235
  hasConnectedDeviceAccess = hasConnectedDeviceAccess,
204
236
  hasMicrophoneAccess = hasMicPermission,
205
237
  hasLocationAccess = hasLocationAccess,
238
+ includeMediaPlayback = includeMediaPlayback,
239
+ declaredTypes = declaredServiceTypes,
206
240
  )
207
241
  }
208
242
 
@@ -0,0 +1,104 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ internal enum class ClassicAudioProfile {
4
+ A2DP,
5
+ HEADSET,
6
+ }
7
+
8
+ /** A confirmed connection is sufficient; declaring disconnection requires both profile reads. */
9
+ internal fun readyClassicAudioSnapshot(states: Map<ClassicAudioProfile, Boolean>): Set<ClassicAudioProfile>? {
10
+ val connected = states.filterValues { it }.keys
11
+ return if (connected.isNotEmpty() || states.size == ClassicAudioProfile.entries.size) connected else null
12
+ }
13
+
14
+ /**
15
+ * Tracks the Android Classic audio profiles for one dual-mode glasses device.
16
+ *
17
+ * Android reports A2DP and HFP independently. Keeping both states prevents an A2DP disconnect from
18
+ * publishing a false negative while HFP is still connected. Snapshot tickets reject obsolete
19
+ * queries, including a previous session with the same MAC address. Broadcast payloads are not truth.
20
+ */
21
+ internal class ClassicAudioConnectionTracker(
22
+ private val onConnectedChanged: (Boolean) -> Unit,
23
+ ) {
24
+ private var targetAddress: String? = null
25
+ private var revision = 0L
26
+ private val connectedProfiles = mutableSetOf<ClassicAudioProfile>()
27
+
28
+ @get:Synchronized
29
+ val connected: Boolean
30
+ get() = connectedProfiles.isNotEmpty()
31
+
32
+ @Synchronized
33
+ fun setTarget(address: String) {
34
+ val normalizedAddress = address.normalizedBluetoothAddress()
35
+ revision++
36
+ if (targetAddress == normalizedAddress) return
37
+
38
+ targetAddress = normalizedAddress
39
+ clearConnectedProfiles()
40
+ }
41
+
42
+ @Synchronized
43
+ fun beginSnapshot(address: String): Long? {
44
+ if (targetAddress != address.normalizedBluetoothAddress()) return null
45
+ return ++revision
46
+ }
47
+
48
+ /** Both profile queries must belong to the newest request for this session. */
49
+ @Synchronized
50
+ fun applySnapshot(
51
+ ticket: Long,
52
+ address: String,
53
+ profiles: Set<ClassicAudioProfile>,
54
+ onAccepted: () -> Unit = {},
55
+ ): Boolean {
56
+ if (ticket != revision || targetAddress != address.normalizedBluetoothAddress()) return false
57
+ val wasConnected = this.connected
58
+ connectedProfiles.clear()
59
+ connectedProfiles.addAll(profiles)
60
+ publishIfChanged(wasConnected)
61
+ // Audio routing notifications must not race a target invalidation after validation.
62
+ onAccepted()
63
+ return true
64
+ }
65
+
66
+ @Synchronized
67
+ fun clear(address: String): Boolean {
68
+ if (targetAddress != address.normalizedBluetoothAddress()) return false
69
+ revision++
70
+ clearConnectedProfiles()
71
+ return true
72
+ }
73
+
74
+ @Synchronized
75
+ fun invalidate(address: String): Boolean {
76
+ if (targetAddress != address.normalizedBluetoothAddress()) return false
77
+
78
+ targetAddress = null
79
+ revision++
80
+ clearConnectedProfiles()
81
+ return true
82
+ }
83
+
84
+ @Synchronized
85
+ fun reset() {
86
+ targetAddress = null
87
+ revision++
88
+ clearConnectedProfiles()
89
+ }
90
+
91
+ private fun clearConnectedProfiles() {
92
+ val wasConnected = connected
93
+ connectedProfiles.clear()
94
+ publishIfChanged(wasConnected)
95
+ }
96
+
97
+ private fun publishIfChanged(wasConnected: Boolean) {
98
+ if (wasConnected != connected) {
99
+ onConnectedChanged(connected)
100
+ }
101
+ }
102
+ }
103
+
104
+ private fun String.normalizedBluetoothAddress(): String = trim().uppercase()