@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.
- package/README.md +46 -45
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +63 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +5 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +298 -181
- package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +326 -45
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +14 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamControllerProbe.kt +14 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamSessionState.kt +32 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/streaming/StreamControllerProbeTest.kt +24 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/streaming/StreamSessionStateTest.kt +43 -0
- package/build/BluetoothSdk.types.d.ts +68 -2
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +3 -4
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +1 -1
- package/build/generated/changelogCatalog.js +1 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +8 -13
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +63 -0
- package/ios/Source/DeviceManager.swift +6 -3
- package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +372 -220
- package/ios/Source/WifiRequestResolution.swift +251 -0
- package/ios/Source/sgcs/MentraLive.swift +216 -70
- package/ios/Source/sgcs/SGCManager.swift +13 -1
- package/ios/Source/status/DeviceStatus.swift +28 -0
- package/ios/Source/streaming/StreamControllerProbe.swift +18 -0
- package/ios/Source/streaming/StreamModels.swift +12 -0
- package/ios/Source/streaming/StreamSessionState.swift +29 -0
- package/ios/Tests/StreamControllerProbeTests.swift +27 -0
- package/ios/Tests/StreamSessionStateTests.swift +38 -0
- package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +82 -2
- package/src/_private/BluetoothSdkModule.ts +4 -4
- package/src/generated/changelogCatalog.ts +1 -1
- package/src/generated/releaseMetadata.ts +5 -5
- 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
|
+
}
|
package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt
ADDED
|
@@ -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
|
+
}
|