@mentra/bluetooth-sdk 3.2.0-dev.200 → 3.2.0-dev.206

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 (60) hide show
  1. package/README.md +46 -45
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -9
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +63 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +5 -4
  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 +298 -181
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +326 -45
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +14 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamControllerProbe.kt +14 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamSessionState.kt +32 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/streaming/StreamControllerProbeTest.kt +24 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/streaming/StreamSessionStateTest.kt +43 -0
  23. package/build/BluetoothSdk.types.d.ts +68 -2
  24. package/build/BluetoothSdk.types.d.ts.map +1 -1
  25. package/build/BluetoothSdk.types.js.map +1 -1
  26. package/build/_private/BluetoothSdkModule.d.ts +3 -4
  27. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  28. package/build/_private/BluetoothSdkModule.js.map +1 -1
  29. package/build/generated/changelogCatalog.d.ts +1 -1
  30. package/build/generated/changelogCatalog.js +1 -1
  31. package/build/generated/changelogCatalog.js.map +1 -1
  32. package/build/generated/releaseMetadata.js +5 -5
  33. package/build/generated/releaseMetadata.js.map +1 -1
  34. package/build/index.d.ts +1 -1
  35. package/build/index.d.ts.map +1 -1
  36. package/build/index.js +3 -0
  37. package/build/index.js.map +1 -1
  38. package/ios/BluetoothSdkModule.swift +8 -13
  39. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  40. package/ios/Source/Bridge.swift +63 -0
  41. package/ios/Source/DeviceManager.swift +6 -3
  42. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  43. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  44. package/ios/Source/MentraBluetoothSDK.swift +372 -220
  45. package/ios/Source/WifiRequestResolution.swift +251 -0
  46. package/ios/Source/sgcs/MentraLive.swift +216 -70
  47. package/ios/Source/sgcs/SGCManager.swift +13 -1
  48. package/ios/Source/status/DeviceStatus.swift +28 -0
  49. package/ios/Source/streaming/StreamControllerProbe.swift +18 -0
  50. package/ios/Source/streaming/StreamModels.swift +12 -0
  51. package/ios/Source/streaming/StreamSessionState.swift +29 -0
  52. package/ios/Tests/StreamControllerProbeTests.swift +27 -0
  53. package/ios/Tests/StreamSessionStateTests.swift +38 -0
  54. package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
  55. package/package.json +1 -1
  56. package/src/BluetoothSdk.types.ts +82 -2
  57. package/src/_private/BluetoothSdkModule.ts +4 -4
  58. package/src/generated/changelogCatalog.ts +1 -1
  59. package/src/generated/releaseMetadata.ts +5 -5
  60. package/src/index.ts +11 -0
@@ -313,8 +313,10 @@ abstract class SGCManager {
313
313
 
314
314
  // Network Management
315
315
  abstract fun requestWifiScan(scanId: String?)
316
+ open fun requestSavedWifiNetworks(requestId: String, sid: String): Boolean = false
316
317
  abstract fun sendWifiCredentials(ssid: String, password: String)
317
318
  abstract fun forgetWifiNetwork(ssid: String)
319
+ open fun forgetWifiNetwork(ssid: String, requestId: String?, sid: String?): Boolean = false
318
320
  abstract fun sendHotspotState(enabled: Boolean)
319
321
 
320
322
  /** Set glasses system clock (Mentra Live and G2; no-op on other devices). */
@@ -189,6 +189,10 @@ data class VersionInfoResult(
189
189
  val appVersion: String,
190
190
  val packageName: String,
191
191
  val hotspotOtaVersion: Int,
192
+ val wifiForgetResultVersion: Int? = null,
193
+ val savedWifiNetworksVersion: Int? = null,
194
+ val versionInfoType: String? = null,
195
+ val sid: String? = null,
192
196
  ) {
193
197
  internal fun toMap(): Map<String, Any> =
194
198
  buildMap {
@@ -206,6 +210,10 @@ data class VersionInfoResult(
206
210
  put("packageName", packageName)
207
211
  }
208
212
  put("hotspotOtaVersion", hotspotOtaVersion)
213
+ wifiForgetResultVersion?.let { put("wifiForgetResultVersion", it) }
214
+ savedWifiNetworksVersion?.let { put("savedWifiNetworksVersion", it) }
215
+ versionInfoType?.let { put("versionInfoType", it) }
216
+ sid?.let { put("sid", it) }
209
217
  }
210
218
 
211
219
  companion object {
@@ -236,6 +244,12 @@ data class VersionInfoResult(
236
244
  packageName = stringValue(values, "packageName", "package_name") ?: "",
237
245
  hotspotOtaVersion =
238
246
  numberValue(values, "hotspotOtaVersion", "hotspot_ota_version") ?: 0,
247
+ wifiForgetResultVersion =
248
+ numberValue(values, "wifiForgetResultVersion", "wifi_forget_result_version"),
249
+ savedWifiNetworksVersion =
250
+ numberValue(values, "savedWifiNetworksVersion", "saved_wifi_networks_version"),
251
+ versionInfoType = stringValue(values, "versionInfoType", "version_info_type"),
252
+ sid = stringValue(values, "sid"),
239
253
  )
240
254
  }
241
255
  }
@@ -0,0 +1,14 @@
1
+ package com.mentra.bluetoothsdk.streaming
2
+
3
+ /** Process-scoped native ownership survives BLE reconnects, not app termination. */
4
+ internal object StreamControllerProbe {
5
+ val controllerId: String = java.util.UUID.randomUUID().toString()
6
+
7
+ fun response(values: Map<String, Any?>): Map<String, Any>? {
8
+ if (values["protocolVersion"] != 1 || values["controllerId"] != controllerId) return null
9
+ val streamId = (values["streamId"] as? String)?.takeIf { it.isNotEmpty() } ?: return null
10
+ val probeId = (values["probeId"] as? String)?.takeIf { it.isNotEmpty() } ?: return null
11
+ return mapOf("type" to "stream_controller_response", "protocolVersion" to 1,
12
+ "controllerId" to controllerId, "streamId" to streamId, "probeId" to probeId)
13
+ }
14
+ }
@@ -481,6 +481,14 @@ data class StreamStatusEvent(
481
481
  val status: StreamStatus,
482
482
  val stats: StreamLiveStats? = null,
483
483
  ) {
484
+ var processSessionId: String? = null
485
+ private set
486
+ var revision: Long? = null
487
+ private set
488
+ var terminal: Boolean? = null
489
+ private set
490
+ var errorDetails: String? = null
491
+ private set
484
492
  // True when the glasses will retry the failed publisher themselves
485
493
  // (emitting side lands in PR #3488); absent on older firmware and on
486
494
  // events not parsed from a glasses status map. Carried here instead of
@@ -493,6 +501,10 @@ data class StreamStatusEvent(
493
501
  stats = StreamLiveStats.fromMap(stringMapValue(values["stats"])),
494
502
  ) {
495
503
  willRetry = boolValue(values, "willRetry")
504
+ processSessionId = stringValue(values, "sid")
505
+ revision = longValue(values, "revision")
506
+ terminal = boolValue(values, "terminal")
507
+ errorDetails = stringValue(values, "errorDetails")
496
508
  }
497
509
 
498
510
  val state: StreamState get() = status.state
@@ -501,6 +513,10 @@ data class StreamStatusEvent(
501
513
  val values: Map<String, Any>
502
514
  get() = buildMap {
503
515
  putAll(status.toEventMap())
516
+ processSessionId?.let { put("sid", it) }
517
+ revision?.let { put("revision", it) }
518
+ terminal?.let { put("terminal", it) }
519
+ errorDetails?.let { put("errorDetails", it) }
504
520
  stats?.let { put("stats", it.toMap()) }
505
521
  willRetry?.let { put("willRetry", it) }
506
522
  }
@@ -0,0 +1,32 @@
1
+ package com.mentra.bluetoothsdk.streaming
2
+
3
+ /** Observes glasses-owned streaming; delivery gaps never imply publisher failure. */
4
+ internal class StreamSessionState {
5
+ @Volatile var processSessionId: String? = null
6
+ private set
7
+ @Volatile var currentStreamId: String? = null
8
+ private set
9
+ @Volatile var supported = false
10
+ private set
11
+ private var revision = -1L
12
+
13
+ @Synchronized
14
+ fun ready(sessionId: String?, controlVersion: Int?) {
15
+ supported = controlVersion == 1 && !sessionId.isNullOrEmpty()
16
+ if (processSessionId != sessionId) {
17
+ currentStreamId = null
18
+ revision = -1
19
+ }
20
+ processSessionId = sessionId
21
+ }
22
+
23
+ @Synchronized
24
+ fun accept(values: Map<String, Any?>): Boolean {
25
+ if (!supported || values["sid"] != processSessionId) return false
26
+ val incomingRevision = (values["revision"] as? Number)?.toLong() ?: return false
27
+ if (incomingRevision < revision) return false
28
+ revision = incomingRevision
29
+ currentStreamId = if (values["terminal"] == true) null else values["streamId"] as? String
30
+ return true
31
+ }
32
+ }
@@ -0,0 +1,14 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class GlassesStatusClassicAudioTest {
7
+ @Test
8
+ fun `serializes connected Classic audio state for React Native`() {
9
+ val serialized =
10
+ GlassesStatus.fromMap(mapOf("bluetoothClassicConnected" to true)).toMap()
11
+
12
+ assertThat(serialized["bluetoothClassicConnected"]).isEqualTo(true)
13
+ }
14
+ }
@@ -0,0 +1,16 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+
7
+ class MessageAckPolicyTest {
8
+ @Test
9
+ fun `every non-ack message id is acknowledged regardless of type`() {
10
+ assertEquals(17L, incomingGlassesMessageAckId("wifi_forget_result", 17L))
11
+ assertEquals(18L, incomingGlassesMessageAckId("saved_wifi_networks", 18L))
12
+ assertEquals(19L, incomingGlassesMessageAckId("future_terminal_type", 19L))
13
+ assertNull(incomingGlassesMessageAckId("msg_ack", 20L))
14
+ assertNull(incomingGlassesMessageAckId("wifi_forget_result", null))
15
+ }
16
+ }
@@ -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,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,24 @@
1
+ package com.mentra.bluetoothsdk.streaming
2
+
3
+ import org.junit.Assert.*
4
+ import org.junit.Test
5
+
6
+ class StreamControllerProbeTest {
7
+ private fun probe() = mapOf<String, Any>("protocolVersion" to 1,
8
+ "controllerId" to StreamControllerProbe.controllerId, "streamId" to "stream", "probeId" to "nonce")
9
+
10
+ @Test fun answersCurrentProcessChallengeWithoutJsOrTimer() {
11
+ val response = StreamControllerProbe.response(probe())!!
12
+ assertEquals("stream_controller_response", response["type"])
13
+ assertEquals("nonce", response["probeId"])
14
+ assertEquals("stream", response["streamId"])
15
+ }
16
+
17
+ @Test fun rejectsPreviousProcessAndMalformedChallenges() {
18
+ assertNull(StreamControllerProbe.response(probe() + ("controllerId" to "previous-process")))
19
+ assertNull(StreamControllerProbe.response(probe() + ("protocolVersion" to 2)))
20
+ assertNull(StreamControllerProbe.response(probe() + ("protocolVersion" to true)))
21
+ assertNull(StreamControllerProbe.response(probe() + ("probeId" to "")))
22
+ assertNull(StreamControllerProbe.response(probe() - "streamId"))
23
+ }
24
+ }
@@ -0,0 +1,43 @@
1
+ package com.mentra.bluetoothsdk.streaming
2
+
3
+ import org.junit.Assert.*
4
+ import org.junit.Test
5
+
6
+ class StreamSessionStateTest {
7
+ @Test fun readinessRequiresKnownProtocolAndProcessIdentity() {
8
+ val state = StreamSessionState()
9
+ state.ready("asg", null)
10
+ assertFalse(state.supported)
11
+ state.ready("asg", 2)
12
+ assertFalse(state.supported)
13
+ state.ready("", 1)
14
+ assertFalse(state.supported)
15
+ state.ready("asg", 1)
16
+ assertTrue(state.supported)
17
+ }
18
+
19
+ @Test fun reconnectReconcilesTerminalStateWithoutHeartbeatTimeouts() {
20
+ val state = StreamSessionState()
21
+ state.ready("asg", 1)
22
+ assertTrue(state.accept(event("asg", 1, false)))
23
+ state.ready("asg", 1)
24
+ assertEquals("stream", state.currentStreamId)
25
+ assertTrue(state.accept(event("asg", 3, true)))
26
+ assertNull(state.currentStreamId)
27
+ assertFalse(state.accept(event("asg", 2, false)))
28
+ assertNull(state.currentStreamId)
29
+ }
30
+
31
+ @Test fun restartRejectsOldStatusAndAcceptsNewStoppedSnapshot() {
32
+ val state = StreamSessionState()
33
+ state.ready("old", 1)
34
+ assertTrue(state.accept(event("old", 8, false)))
35
+ state.ready("new", 1)
36
+ assertFalse(state.accept(event("old", 9, false)))
37
+ assertTrue(state.accept(mapOf("sid" to "new", "revision" to 0, "terminal" to true)))
38
+ assertNull(state.currentStreamId)
39
+ }
40
+
41
+ private fun event(sid: String, revision: Int, terminal: Boolean) =
42
+ mapOf("sid" to sid, "revision" to revision, "streamId" to "stream", "terminal" to terminal)
43
+ }