@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,271 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertNull
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Test
8
+
9
+ class WifiRequestResolutionTest {
10
+ @Test
11
+ fun `future fractional and malformed capabilities are unsupported not legacy`() {
12
+ for (version in listOf(0, 2, -1, 1.5, "1", true)) {
13
+ val capabilities = WifiSessionCapabilities()
14
+ capabilities.reset("sid")
15
+ capabilities.applyVersionInfo1(mapOf("wifiForgetResultVersion" to version, "savedWifiNetworksVersion" to version))
16
+ assertEquals(WifiRequestMode.UNSUPPORTED, capabilities.forgetMode())
17
+ assertEquals(WifiRequestMode.UNSUPPORTED, capabilities.savedNetworksMode())
18
+ }
19
+ val missingSession = WifiSessionCapabilities()
20
+ missingSession.applyVersionInfo1(mapOf("wifiForgetResultVersion" to 1))
21
+ assertEquals(WifiRequestMode.UNSUPPORTED, missingSession.forgetMode())
22
+ }
23
+
24
+ @Test
25
+ fun `raw response rejects partial tuples and coercion before normalization`() {
26
+ val tuple = mapOf<String, Any>("protocol_version" to 1, "requestId" to "request", "sid" to "sid")
27
+ assertTrue(wifiResponseEnvelopeIsValid(tuple, false))
28
+ for (key in tuple.keys) assertFalse(wifiResponseEnvelopeIsValid(tuple - key, true))
29
+ for (version in listOf(0, 2, 1.5, "1", true)) {
30
+ assertFalse(wifiResponseEnvelopeIsValid(tuple + ("protocol_version" to version), true))
31
+ }
32
+ assertFalse(wifiResponseEnvelopeIsValid(tuple + ("connected" to 0), true))
33
+ assertFalse(wifiResponseEnvelopeIsValid(tuple + ("dispatched" to true), true))
34
+ assertTrue(wifiResponseEnvelopeIsValid(mapOf("dispatched" to true), true))
35
+ assertFalse(wifiResponseEnvelopeIsValid(mapOf("requestId" to "", "dispatched" to true), true))
36
+ assertFalse(wifiResponseEnvelopeIsValid(emptyMap(), false))
37
+ }
38
+
39
+ @Test
40
+ fun `semantic results omit transport metadata`() {
41
+ val data = mapOf<String, Any>("requestId" to "request", "sid" to "sid", "protocolVersion" to 1,
42
+ "ssid" to "AP", "outcome" to "confirmed", "networks" to listOf("AP"))
43
+ val forbidden = setOf("mode", "capabilityVersion", "requestId", "sid", "protocolVersion")
44
+ assertTrue(parseWifiForgetResult("request", "sid", "AP", 1, data)!!.toMap().keys.intersect(forbidden).isEmpty())
45
+ assertTrue(parseSavedWifiNetworks("request", "sid", 1, data)!!.toMap().keys.intersect(forbidden).isEmpty())
46
+ assertNull(parseWifiForgetResult("request", "sid", "AP", 1, data + ("protocolVersion" to 1.5)))
47
+ assertNull(parseSavedWifiNetworks("request", "sid", 1, data + ("protocolVersion" to 1.5)))
48
+ }
49
+
50
+ @Test
51
+ fun `capabilities are unknown per session until version info finalizes them`() {
52
+ val capabilities = WifiSessionCapabilities()
53
+ capabilities.reset("sid-1")
54
+
55
+ assertEquals(WifiRequestMode.DISCOVERING, capabilities.forgetMode())
56
+ assertEquals(WifiRequestMode.DISCOVERING, capabilities.savedNetworksMode())
57
+ capabilities.applyVersionInfo1(mapOf("sid" to "sid-1", "wifiForgetResultVersion" to 1))
58
+
59
+ assertEquals(WifiProtocolCapability.Supported(1), capabilities.forgetResult)
60
+ assertEquals(WifiProtocolCapability.Legacy, capabilities.savedNetworks)
61
+ assertEquals(WifiRequestMode.MODERN, capabilities.forgetMode())
62
+ assertEquals(WifiRequestMode.LEGACY, capabilities.savedNetworksMode())
63
+ }
64
+
65
+ @Test
66
+ fun `new session resets capabilities and advances epoch`() {
67
+ val capabilities = WifiSessionCapabilities()
68
+ capabilities.reset("sid-1")
69
+ capabilities.applyVersionInfo1(
70
+ mapOf("wifiForgetResultVersion" to 1, "savedWifiNetworksVersion" to 1)
71
+ )
72
+ val oldEpoch = capabilities.epoch
73
+
74
+ capabilities.reset("sid-2")
75
+
76
+ assertTrue(capabilities.epoch > oldEpoch)
77
+ assertEquals("sid-2", capabilities.sessionId)
78
+ assertEquals(WifiProtocolCapability.Unknown, capabilities.forgetResult)
79
+ assertEquals(WifiProtocolCapability.Unknown, capabilities.savedNetworks)
80
+ }
81
+
82
+ @Test
83
+ fun `saved request snapshot cannot mix mode with a later session`() {
84
+ val capabilities = WifiSessionCapabilities()
85
+ capabilities.reset("sid-1")
86
+ capabilities.applyVersionInfo1(mapOf("savedWifiNetworksVersion" to 1))
87
+ val first = capabilities.savedNetworksRequestSnapshot()
88
+
89
+ capabilities.reset("sid-2")
90
+ val second = capabilities.savedNetworksRequestSnapshot()
91
+
92
+ assertEquals(WifiRequestMode.MODERN, first.mode)
93
+ assertEquals("sid-1", first.sessionId)
94
+ assertEquals(WifiRequestMode.DISCOVERING, second.mode)
95
+ assertEquals("sid-2", second.sessionId)
96
+ assertTrue(second.epoch > first.epoch)
97
+ }
98
+
99
+ @Test
100
+ fun `SSID validation rejects blank input without changing identity`() {
101
+ assertFalse(wifiSsidIsValid(" "))
102
+ assertTrue(wifiSsidIsValid(" Field AP "))
103
+ }
104
+
105
+ @Test
106
+ fun `forget result requires exact id session ssid and protocol`() {
107
+ val exact =
108
+ mapOf<String, Any>(
109
+ "requestId" to "forget-1",
110
+ "sid" to "sid-1",
111
+ "ssid" to " Field AP ",
112
+ "protocolVersion" to 1,
113
+ "outcome" to "dispatched",
114
+ "connected" to false,
115
+ )
116
+
117
+ assertNull(parseWifiForgetResult("other", "sid-1", " Field AP ", 1, exact))
118
+ assertNull(parseWifiForgetResult("forget-1", "sid-1", " Field AP ", 1, exact - "requestId"))
119
+ assertNull(parseWifiForgetResult("forget-1", "other", " Field AP ", 1, exact))
120
+ assertNull(parseWifiForgetResult("forget-1", "sid-1", "Field AP", 1, exact))
121
+ assertNull(parseWifiForgetResult("forget-1", "sid-1", " Field AP ", 2, exact))
122
+ assertEquals(
123
+ WifiForgetOutcome.DISPATCHED,
124
+ parseWifiForgetResult("forget-1", "sid-1", " Field AP ", 1, exact)?.outcome,
125
+ )
126
+ }
127
+
128
+ @Test
129
+ fun `forget parser preserves each honest terminal outcome`() {
130
+ listOf("confirmed", "dispatched", "not_found", "unsupported", "failed").forEach { outcome ->
131
+ val parsed =
132
+ parseWifiForgetResult(
133
+ "forget-1",
134
+ "sid-1",
135
+ "AP",
136
+ 1,
137
+ mapOf(
138
+ "requestId" to "forget-1",
139
+ "sid" to "sid-1",
140
+ "ssid" to "AP",
141
+ "protocolVersion" to 1,
142
+ "outcome" to outcome,
143
+ ),
144
+ )
145
+ assertEquals(outcome, parsed?.outcome?.wireValue)
146
+ }
147
+ }
148
+
149
+ @Test
150
+ fun `missing connectivity snapshot remains unknown`() {
151
+ val parsed =
152
+ parseWifiForgetResult(
153
+ "forget-1",
154
+ "sid-1",
155
+ "AP",
156
+ 1,
157
+ mapOf(
158
+ "requestId" to "forget-1",
159
+ "sid" to "sid-1",
160
+ "ssid" to "AP",
161
+ "protocolVersion" to 1,
162
+ "outcome" to "dispatched",
163
+ ),
164
+ )
165
+
166
+ assertNull(parsed?.connected)
167
+ assertFalse(parsed?.toMap()?.containsKey("connected") ?: true)
168
+ }
169
+
170
+ @Test
171
+ fun `legacy forget is explicitly unverified`() {
172
+ val result =
173
+ legacyWifiForgetResult(
174
+ "AP",
175
+ )
176
+
177
+ assertFalse(result.toMap().keys.any { it in setOf("mode", "capabilityVersion", "requestId", "sid") })
178
+ assertEquals(WifiForgetOutcome.LEGACY_UNVERIFIED, result.outcome)
179
+ assertNull(result.connected)
180
+ assertNull(result.currentSsid)
181
+ assertNull(result.localIp)
182
+ }
183
+
184
+ @Test
185
+ fun `saved list preserves exact identity and requires correlation tuple`() {
186
+ val exact =
187
+ mapOf<String, Any>(
188
+ "requestId" to "saved-1",
189
+ "sid" to "sid-1",
190
+ "protocolVersion" to 1,
191
+ "outcome" to "confirmed",
192
+ "networks" to listOf(" Field AP ", "", "Field AP", " Field AP "),
193
+ )
194
+
195
+ assertNull(parseSavedWifiNetworks("other", "sid-1", 1, exact))
196
+ assertNull(parseSavedWifiNetworks("saved-1", "sid-1", 1, exact - "requestId"))
197
+ assertNull(parseSavedWifiNetworks("saved-1", "other", 1, exact))
198
+ assertNull(parseSavedWifiNetworks("saved-1", "sid-1", 2, exact))
199
+ assertEquals(
200
+ listOf(" Field AP ", "Field AP"),
201
+ parseSavedWifiNetworks("saved-1", "sid-1", 1, exact)?.networks,
202
+ )
203
+
204
+ val failure =
205
+ parseSavedWifiNetworks(
206
+ "saved-1",
207
+ "sid-1",
208
+ 1,
209
+ exact + ("outcome" to "failed") + ("error" to "backend_failed") + ("networks" to emptyList<String>()),
210
+ )
211
+ assertEquals(SavedWifiNetworksOutcome.FAILED, failure?.outcome)
212
+ assertEquals("backend_failed", failure?.error)
213
+ }
214
+
215
+
216
+ @Test
217
+ fun `raw forget event preserves modern and legacy wire truth`() {
218
+ val modern =
219
+ normalizeWifiForgetResultEvent(
220
+ "forget-1",
221
+ "sid-1",
222
+ "AP",
223
+ 1,
224
+ "dispatched",
225
+ null,
226
+ false,
227
+ "",
228
+ "",
229
+ null,
230
+ )
231
+ val legacy =
232
+ normalizeWifiForgetResultEvent(
233
+ "",
234
+ "",
235
+ "AP",
236
+ 0,
237
+ "",
238
+ true,
239
+ false,
240
+ "",
241
+ "",
242
+ null,
243
+ )
244
+ val withoutSnapshot =
245
+ normalizeWifiForgetResultEvent(
246
+ "forget-unknown",
247
+ "sid-1",
248
+ "AP",
249
+ 1,
250
+ "dispatched",
251
+ null,
252
+ null,
253
+ "",
254
+ "",
255
+ null,
256
+ )
257
+
258
+ assertEquals("modern", modern?.get("mode"))
259
+ assertEquals("dispatched", modern?.get("outcome"))
260
+ assertEquals("legacy", legacy?.get("mode"))
261
+ assertEquals(true, legacy?.get("dispatched"))
262
+ assertFalse(legacy?.containsKey("sid") ?: true)
263
+ assertFalse(withoutSnapshot?.containsKey("connected") ?: true)
264
+ assertNull(
265
+ normalizeWifiForgetResultEvent(
266
+ "bad", "", "AP", 0, "", null, false, "", "", null
267
+ )
268
+ )
269
+ }
270
+
271
+ }
@@ -0,0 +1,57 @@
1
+ package com.mentra.bluetoothsdk.services
2
+
3
+ import android.Manifest
4
+ import android.content.ComponentName
5
+ import android.content.Intent
6
+ import android.content.pm.ServiceInfo
7
+ import android.location.LocationManager
8
+ import org.junit.Assert.assertEquals
9
+ import org.junit.Test
10
+ import org.junit.runner.RunWith
11
+ import org.robolectric.Robolectric
12
+ import org.robolectric.RobolectricTestRunner
13
+ import org.robolectric.RuntimeEnvironment
14
+ import org.robolectric.Shadows.shadowOf
15
+ import org.robolectric.annotation.Config
16
+ import org.robolectric.util.ReflectionHelpers
17
+
18
+ @RunWith(RobolectricTestRunner::class)
19
+ @Config(sdk = [33])
20
+ class ForegroundServiceManifestTest {
21
+ @Test
22
+ fun `host manifest controls startup and resume even with all permissions granted`() {
23
+ val application = RuntimeEnvironment.getApplication()
24
+ val component = ComponentName(application, ForegroundService::class.java)
25
+ val info = application.packageManager.getServiceInfo(component, 0)
26
+ ReflectionHelpers.setField(info, "mForegroundServiceType", ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
27
+ shadowOf(application.packageManager).addOrUpdateService(info)
28
+ shadowOf(application).grantPermissions(
29
+ Manifest.permission.BLUETOOTH_CONNECT,
30
+ Manifest.permission.RECORD_AUDIO,
31
+ Manifest.permission.ACCESS_FINE_LOCATION,
32
+ )
33
+ shadowOf(application.getSystemService(LocationManager::class.java)).setLocationEnabled(true)
34
+
35
+ val controller = Robolectric.buildService(ForegroundService::class.java).create()
36
+ val service = controller.get()
37
+ try {
38
+ assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE, service.foregroundServiceType)
39
+ service.onStartCommand(Intent(ForegroundService.ACTION_REFRESH_TYPES), 0, 1)
40
+ assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE, service.foregroundServiceType)
41
+ service.onStartCommand(null, 0, 2)
42
+ assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE, service.foregroundServiceType)
43
+ } finally {
44
+ controller.destroy()
45
+ }
46
+ }
47
+
48
+ @Test
49
+ fun `default library manifest retains the MentraOS startup type`() {
50
+ val controller = Robolectric.buildService(ForegroundService::class.java).create()
51
+ try {
52
+ assertEquals(ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, controller.get().foregroundServiceType)
53
+ } finally {
54
+ controller.destroy()
55
+ }
56
+ }
57
+ }
@@ -6,6 +6,58 @@ import org.junit.Assert.assertTrue
6
6
  import org.junit.Test
7
7
 
8
8
  class ForegroundServiceTypeTest {
9
+ @Test
10
+ fun `connected-device-only host bootstraps without dataSync`() {
11
+ assertEquals(
12
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
13
+ ForegroundService.bootstrapServiceType(ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE),
14
+ )
15
+ }
16
+
17
+ @Test
18
+ fun `permissions cannot enable types excluded by the host manifest`() {
19
+ assertEquals(
20
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
21
+ ForegroundService.preferredServiceType(
22
+ hasConnectedDeviceAccess = true,
23
+ hasMicrophoneAccess = true,
24
+ hasLocationAccess = true,
25
+ declaredTypes = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
26
+ ),
27
+ )
28
+ }
29
+
30
+ @Test
31
+ fun `restricted host fallback never adds undeclared dataSync`() {
32
+ assertEquals(
33
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
34
+ ForegroundService.preferredServiceType(
35
+ hasConnectedDeviceAccess = false,
36
+ hasMicrophoneAccess = false,
37
+ hasLocationAccess = false,
38
+ declaredTypes = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
39
+ ),
40
+ )
41
+ }
42
+
43
+ @Test
44
+ fun `MentraOS retains all eligible long-running types`() {
45
+ assertEquals(
46
+ ForegroundService.DEFAULT_SERVICE_TYPES and
47
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC.inv(),
48
+ ForegroundService.preferredServiceType(
49
+ hasConnectedDeviceAccess = true,
50
+ hasMicrophoneAccess = true,
51
+ hasLocationAccess = true,
52
+ ),
53
+ )
54
+ }
55
+
56
+ @Test(expected = IllegalArgumentException::class)
57
+ fun `host without a valid bootstrap type fails explicitly`() {
58
+ ForegroundService.bootstrapServiceType(ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
59
+ }
60
+
9
61
  @Test
10
62
  fun `bootstrap starts as dataSync`() {
11
63
  assertEquals(
@@ -0,0 +1,131 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class ClassicAudioConnectionTrackerTest {
7
+ @Test
8
+ fun `available connected profile is sufficient even if the other query fails or never answers`() {
9
+ assertThat(readyClassicAudioSnapshot(mapOf(ClassicAudioProfile.A2DP to true)))
10
+ .containsExactly(ClassicAudioProfile.A2DP)
11
+ assertThat(readyClassicAudioSnapshot(mapOf(ClassicAudioProfile.HEADSET to true)))
12
+ .containsExactly(ClassicAudioProfile.HEADSET)
13
+ }
14
+
15
+ @Test
16
+ fun `missing profile never fabricates a disconnect during a profile switch`() {
17
+ assertThat(readyClassicAudioSnapshot(mapOf(ClassicAudioProfile.A2DP to false))).isNull()
18
+ assertThat(readyClassicAudioSnapshot(mapOf(ClassicAudioProfile.A2DP to false,
19
+ ClassicAudioProfile.HEADSET to false))).isEmpty()
20
+ }
21
+
22
+ private val address = "AA:BB:CC:DD:EE:FF"
23
+
24
+ private fun snapshot(tracker: ClassicAudioConnectionTracker, vararg profiles: ClassicAudioProfile) {
25
+ val ticket = tracker.beginSnapshot(address)!!
26
+ assertThat(tracker.applySnapshot(ticket, address, profiles.toSet())).isTrue()
27
+ }
28
+
29
+ @Test
30
+ fun `publishes complete A2DP and HFP snapshots without intermediate false negative`() {
31
+ val changes = mutableListOf<Boolean>()
32
+ val tracker = ClassicAudioConnectionTracker(changes::add)
33
+ tracker.setTarget(address)
34
+ snapshot(tracker, ClassicAudioProfile.A2DP)
35
+ snapshot(tracker, ClassicAudioProfile.HEADSET)
36
+ snapshot(tracker)
37
+ assertThat(changes).containsExactly(true, false)
38
+ }
39
+
40
+ @Test
41
+ fun `address comparison is case insensitive`() {
42
+ val tracker = ClassicAudioConnectionTracker {}
43
+ tracker.setTarget(address.lowercase())
44
+ snapshot(tracker, ClassicAudioProfile.A2DP)
45
+ assertThat(tracker.connected).isTrue()
46
+ }
47
+
48
+ @Test
49
+ fun `another device cannot request a snapshot`() {
50
+ val tracker = ClassicAudioConnectionTracker {}
51
+ tracker.setTarget(address)
52
+ assertThat(tracker.beginSnapshot("11:22:33:44:55:66")).isNull()
53
+ }
54
+
55
+ @Test
56
+ fun `late result for previous device cannot replace current profiles`() {
57
+ val tracker = ClassicAudioConnectionTracker {}
58
+ tracker.setTarget(address)
59
+ val ticket = tracker.beginSnapshot(address)!!
60
+ tracker.setTarget("11:22:33:44:55:66")
61
+ assertThat(tracker.applySnapshot(ticket, address, setOf(ClassicAudioProfile.A2DP))).isFalse()
62
+ assertThat(tracker.connected).isFalse()
63
+ }
64
+
65
+ @Test
66
+ fun `old response cannot resurrect reconnected session with same MAC`() {
67
+ val tracker = ClassicAudioConnectionTracker {}
68
+ tracker.setTarget(address)
69
+ val ticket = tracker.beginSnapshot(address)!!
70
+ tracker.invalidate(address)
71
+ tracker.setTarget(address)
72
+ snapshot(tracker)
73
+ assertThat(tracker.applySnapshot(ticket, address, setOf(ClassicAudioProfile.A2DP))).isFalse()
74
+ assertThat(tracker.connected).isFalse()
75
+ }
76
+
77
+ @Test
78
+ fun `newer query invalidates in flight snapshot even before its response arrives`() {
79
+ val tracker = ClassicAudioConnectionTracker {}
80
+ tracker.setTarget(address)
81
+ val first = tracker.beginSnapshot(address)!!
82
+ val second = tracker.beginSnapshot(address)!!
83
+ assertThat(tracker.applySnapshot(first, address, setOf(ClassicAudioProfile.A2DP))).isFalse()
84
+ assertThat(tracker.applySnapshot(second, address, emptySet())).isTrue()
85
+ }
86
+
87
+ @Test
88
+ fun `late disconnect snapshot cannot overwrite newer connected snapshot`() {
89
+ val tracker = ClassicAudioConnectionTracker {}
90
+ tracker.setTarget(address)
91
+ val old = tracker.beginSnapshot(address)!!
92
+ snapshot(tracker, ClassicAudioProfile.HEADSET)
93
+ assertThat(tracker.applySnapshot(old, address, emptySet())).isFalse()
94
+ assertThat(tracker.connected).isTrue()
95
+ }
96
+
97
+ @Test
98
+ fun `clear invalidates queries but retains target for retry`() {
99
+ val tracker = ClassicAudioConnectionTracker {}
100
+ tracker.setTarget(address)
101
+ val old = tracker.beginSnapshot(address)!!
102
+ assertThat(tracker.clear(address)).isTrue()
103
+ assertThat(tracker.applySnapshot(old, address, setOf(ClassicAudioProfile.A2DP))).isFalse()
104
+ snapshot(tracker, ClassicAudioProfile.A2DP)
105
+ assertThat(tracker.connected).isTrue()
106
+ }
107
+
108
+ @Test
109
+ fun `reset clears truth and prevents pending results from applying`() {
110
+ val changes = mutableListOf<Boolean>()
111
+ val tracker = ClassicAudioConnectionTracker(changes::add)
112
+ tracker.setTarget(address)
113
+ snapshot(tracker, ClassicAudioProfile.A2DP)
114
+ val ticket = tracker.beginSnapshot(address)!!
115
+ tracker.reset()
116
+ assertThat(tracker.applySnapshot(ticket, address, setOf(ClassicAudioProfile.A2DP))).isFalse()
117
+ assertThat(tracker.beginSnapshot(address)).isNull()
118
+ assertThat(changes).containsExactly(true, false)
119
+ }
120
+
121
+ @Test
122
+ fun `stale snapshot cannot emit audio routing notifications`() {
123
+ val tracker = ClassicAudioConnectionTracker {}
124
+ tracker.setTarget(address)
125
+ val old = tracker.beginSnapshot(address)!!
126
+ tracker.setTarget(address)
127
+ var notifications = 0
128
+ tracker.applySnapshot(old, address, setOf(ClassicAudioProfile.A2DP)) { notifications++ }
129
+ assertThat(notifications).isZero()
130
+ }
131
+ }
@@ -0,0 +1,94 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothAdapter
4
+ import android.bluetooth.BluetoothGatt
5
+ import android.bluetooth.BluetoothGattCallback
6
+ import android.bluetooth.BluetoothGattCharacteristic
7
+ import android.bluetooth.BluetoothGattDescriptor
8
+ import java.util.UUID
9
+ import org.junit.Assert.assertArrayEquals
10
+ import org.junit.Assert.assertEquals
11
+ import org.junit.Test
12
+ import org.junit.runner.RunWith
13
+ import org.robolectric.RobolectricTestRunner
14
+ import org.robolectric.RuntimeEnvironment
15
+ import org.robolectric.annotation.Config
16
+
17
+ @RunWith(RobolectricTestRunner::class)
18
+ @Config(sdk = [28])
19
+ class MentraLiveGattCallbackTest {
20
+ private fun gatt(): BluetoothGatt =
21
+ BluetoothAdapter.getDefaultAdapter().getRemoteDevice("AA:BB:CC:DD:EE:FF")
22
+ .connectGatt(RuntimeEnvironment.getApplication(), false, object : BluetoothGattCallback() {})
23
+
24
+ private class RecordingCallback(
25
+ enqueue: (() -> Unit) -> Unit,
26
+ isCurrent: (BluetoothGatt) -> Boolean = { true },
27
+ ) : SerializedGattCallback(enqueue, isCurrent) {
28
+ val events = mutableListOf<String>()
29
+ val packets = mutableListOf<ByteArray>()
30
+ override fun handleConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { events.add("connection") }
31
+ override fun handleServicesDiscovered(gatt: BluetoothGatt, status: Int) { events.add("services") }
32
+ override fun handlePhyUpdate(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) { events.add("phyUpdate") }
33
+ override fun handlePhyRead(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) { events.add("phyRead") }
34
+ override fun handleCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { events.add("read") }
35
+ override fun handleReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) { events.add("rssi") }
36
+ override fun handleCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { events.add("write") }
37
+ override fun handleCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, data: ByteArray) { packets.add(data) }
38
+ override fun handleDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { events.add("descriptor") }
39
+ override fun handleMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { events.add("mtu") }
40
+ }
41
+
42
+ @Test
43
+ fun `all callbacks defer effects until the lifecycle owner executes them`() {
44
+ val queue = ArrayDeque<() -> Unit>()
45
+ val callback = RecordingCallback({ work -> queue.addLast(work) })
46
+ val gatt = gatt()
47
+ val characteristic = BluetoothGattCharacteristic(UUID.randomUUID(), 0, 0)
48
+ val descriptor = BluetoothGattDescriptor(UUID.randomUUID(), 0)
49
+ callback.onConnectionStateChange(gatt, 0, 2)
50
+ callback.onServicesDiscovered(gatt, 0)
51
+ callback.onPhyUpdate(gatt, 1, 1, 0)
52
+ callback.onPhyRead(gatt, 1, 1, 0)
53
+ callback.onCharacteristicRead(gatt, characteristic, 0)
54
+ callback.onReadRemoteRssi(gatt, -50, 0)
55
+ callback.onCharacteristicWrite(gatt, characteristic, 0)
56
+ callback.onDescriptorWrite(gatt, descriptor, 0)
57
+ callback.onMtuChanged(gatt, 512, 0)
58
+ assertEquals(emptyList<String>(), callback.events)
59
+ while (queue.isNotEmpty()) queue.removeFirst().invoke()
60
+ assertEquals(listOf("connection", "services", "phyUpdate", "phyRead", "read", "rssi", "write", "descriptor", "mtu"), callback.events)
61
+ }
62
+
63
+ @Test
64
+ @Suppress("DEPRECATION")
65
+ fun `queued notifications retain their own bytes when Android reuses characteristic`() {
66
+ val queue = ArrayDeque<() -> Unit>()
67
+ val callback = RecordingCallback({ work -> queue.addLast(work) })
68
+ val gatt = gatt()
69
+ val characteristic = BluetoothGattCharacteristic(UUID.randomUUID(), 0, 0)
70
+ val buffer = byteArrayOf(1, 2)
71
+ characteristic.value = buffer
72
+ callback.onCharacteristicChanged(gatt, characteristic)
73
+ buffer[0] = 3
74
+ callback.onCharacteristicChanged(gatt, characteristic)
75
+ buffer[0] = 4
76
+ while (queue.isNotEmpty()) queue.removeFirst().invoke()
77
+ assertArrayEquals(byteArrayOf(1, 2), callback.packets[0])
78
+ assertArrayEquals(byteArrayOf(3, 2), callback.packets[1])
79
+ }
80
+
81
+ @Test
82
+ fun `replacement invalidates callbacks already waiting on the lifecycle queue`() {
83
+ val queue = ArrayDeque<() -> Unit>()
84
+ var epoch = 1
85
+ val callback = RecordingCallback({ work -> queue.addLast(work) }, { epoch == 1 })
86
+ val gatt = gatt()
87
+ callback.onConnectionStateChange(gatt, 0, 0)
88
+ callback.onServicesDiscovered(gatt, 0)
89
+ callback.onMtuChanged(gatt, 512, 0)
90
+ epoch = 2
91
+ while (queue.isNotEmpty()) queue.removeFirst().invoke()
92
+ assertEquals(emptyList<String>(), callback.events)
93
+ }
94
+ }