@mentra/bluetooth-sdk 3.4.0-dev.993 → 3.4.0-dev.998
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/MIGRATION.md +61 -0
- package/README.md +47 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +8 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +48 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +21 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +5 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicy.kt +64 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +788 -134
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicy.kt +227 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +19 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicy.kt +94 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachine.kt +411 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateStore.kt +124 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +50 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerOwnerLossTest.kt +146 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusAudioSetupTest.kt +51 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +2 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicyTest.kt +65 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveAudioSetupReadinessTest.kt +231 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicyTest.kt +368 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicyTest.kt +136 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachineTest.kt +346 -0
- package/build/BluetoothSdk.types.d.ts +101 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +1 -0
- package/build/_private/BluetoothSdkModule.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 -2
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +11 -3
- package/ios/Source/DeviceManager.swift +80 -6
- package/ios/Source/DeviceStore.swift +27 -0
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +10 -4
- package/ios/Source/errors/BluetoothSdkError.swift +5 -1
- package/ios/Source/sgcs/GlassesAudioSetupPolicy.swift +181 -0
- package/ios/Source/sgcs/MentraLive.swift +1053 -166
- package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +260 -1
- package/ios/Source/sgcs/MentraLivePairingStateMachine.swift +385 -0
- package/ios/Source/sgcs/MentraLivePairingStateStore.swift +121 -0
- package/ios/Source/sgcs/SGCManager.swift +7 -0
- package/ios/Source/sgcs/Simulated.swift +2 -0
- package/ios/Source/status/DeviceStatus.swift +35 -0
- package/ios/Source/types/DeviceModels.swift +8 -1
- package/ios/Source/utils/AudioSessionMonitor.swift +158 -5
- package/ios/Tests/BluetoothAudioRouteTests.swift +82 -0
- package/ios/Tests/GlassesAudioSetupPolicyTests.swift +191 -0
- package/ios/Tests/MentraLiveAudioSetupTests.swift +215 -0
- package/ios/Tests/MentraLiveConnectionOptionsTests.swift +55 -0
- package/ios/Tests/MentraLivePairingAdvertisementTests.swift +327 -0
- package/ios/Tests/MentraLivePairingStateMachineTests.swift +402 -0
- package/ios/Tests/MentraLiveSessionReadinessTests.swift +328 -26
- package/package.json +3 -2
- package/src/BluetoothSdk.types.ts +97 -0
- package/src/_private/BluetoothSdkModule.ts +1 -0
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +10 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import androidx.test.core.app.ApplicationProvider
|
|
5
|
+
import com.mentra.bluetoothsdk.sgcs.MentraLivePairingState
|
|
6
|
+
import com.mentra.bluetoothsdk.sgcs.MentraLivePairingStateMachine
|
|
7
|
+
import com.mentra.bluetoothsdk.sgcs.MentraLivePairingStateStore
|
|
8
|
+
import org.junit.After
|
|
9
|
+
import org.junit.Assert.assertEquals
|
|
10
|
+
import org.junit.Assert.assertFalse
|
|
11
|
+
import org.junit.Assert.assertTrue
|
|
12
|
+
import org.junit.Before
|
|
13
|
+
import org.junit.Test
|
|
14
|
+
import org.junit.runner.RunWith
|
|
15
|
+
import org.robolectric.RobolectricTestRunner
|
|
16
|
+
import org.robolectric.annotation.Config
|
|
17
|
+
import org.robolectric.annotation.LooperMode
|
|
18
|
+
|
|
19
|
+
@RunWith(RobolectricTestRunner::class)
|
|
20
|
+
@Config(sdk = [33])
|
|
21
|
+
@LooperMode(LooperMode.Mode.PAUSED)
|
|
22
|
+
class DeviceManagerOwnerLossTest {
|
|
23
|
+
private val device = "MENTRA_LIVE_BLE_ABCD"
|
|
24
|
+
private lateinit var savedBluetooth: Map<String, Any>
|
|
25
|
+
private lateinit var savedGlasses: Map<String, Any>
|
|
26
|
+
|
|
27
|
+
@Before fun setUp() {
|
|
28
|
+
Bridge.initialize(ApplicationProvider.getApplicationContext())
|
|
29
|
+
savedBluetooth = DeviceStore.store.getCategory("bluetooth")
|
|
30
|
+
savedGlasses = DeviceStore.store.getCategory("glasses")
|
|
31
|
+
prefs().edit().clear().commit()
|
|
32
|
+
MentraLivePairingStateStore.resetForTest()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@After fun tearDown() {
|
|
36
|
+
MentraLivePairingStateStore.resetForTest()
|
|
37
|
+
prefs().edit().clear().commit()
|
|
38
|
+
restore("bluetooth", savedBluetooth)
|
|
39
|
+
restore("glasses", savedGlasses)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Test fun `forgetting glasses clears the saved secure firmware flag`() {
|
|
43
|
+
val manager = DeviceManager(initializeHardware = false)
|
|
44
|
+
try {
|
|
45
|
+
DeviceStore.set("bluetooth", "mentra_live_secure_firmware", true)
|
|
46
|
+
manager.clearSecureFirmware()
|
|
47
|
+
assertFalse(DeviceStore.store.get("bluetooth", "mentra_live_secure_firmware") as Boolean)
|
|
48
|
+
} finally {
|
|
49
|
+
manager.cleanup()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@Test fun `bluetooth-on still reaches the manager while rejected so Pair Again stays possible`() {
|
|
54
|
+
val manager = DeviceManager(initializeHardware = false)
|
|
55
|
+
try {
|
|
56
|
+
DeviceStore.set("bluetooth", "default_wearable", "Mentra Live")
|
|
57
|
+
DeviceStore.set("bluetooth", "device_name", device)
|
|
58
|
+
rejectViaFlagOffNotice()
|
|
59
|
+
assertTrue(manager.shouldReconnectAfterBluetoothOn())
|
|
60
|
+
} finally {
|
|
61
|
+
manager.cleanup()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@Test fun `a pause and its window id survive a process restart`() {
|
|
66
|
+
MentraLivePairingStateStore.ensureRestored(device)
|
|
67
|
+
val now = System.currentTimeMillis()
|
|
68
|
+
MentraLivePairingStateStore.update("test") {
|
|
69
|
+
it.onEnteringPairingMode(120_000L, 99, true, false, device, now)
|
|
70
|
+
}
|
|
71
|
+
assertTrue(MentraLivePairingStateStore.isPaused)
|
|
72
|
+
|
|
73
|
+
MentraLivePairingStateStore.resetForTest()
|
|
74
|
+
assertEquals(MentraLivePairingState.Normal, MentraLivePairingStateStore.state)
|
|
75
|
+
MentraLivePairingStateStore.ensureRestored(device)
|
|
76
|
+
|
|
77
|
+
val state = MentraLivePairingStateStore.state as MentraLivePairingState.PairingMode
|
|
78
|
+
assertEquals(99, state.windowId)
|
|
79
|
+
assertEquals(now + 150_000L, state.endsAtMs)
|
|
80
|
+
@Suppress("UNCHECKED_CAST")
|
|
81
|
+
val published = DeviceStore.store.get("glasses", "pairingState") as Map<String, Any>
|
|
82
|
+
assertEquals("pairing_mode", published["state"])
|
|
83
|
+
assertEquals(99, published["windowId"])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@Test fun `a rejection survives a restart and is not touched by the replayed owner-lost setting`() {
|
|
87
|
+
MentraLivePairingStateStore.ensureRestored(device)
|
|
88
|
+
rejectViaFlagOffNotice()
|
|
89
|
+
// The old app replays mentra_live_owner_lost=false; that must not clear the native state.
|
|
90
|
+
DeviceStore.set("bluetooth", "mentra_live_owner_lost", false)
|
|
91
|
+
MentraLivePairingStateStore.resetForTest()
|
|
92
|
+
MentraLivePairingStateStore.ensureRestored(device)
|
|
93
|
+
assertTrue(MentraLivePairingStateStore.isRejected)
|
|
94
|
+
assertFalse(MentraLivePairingStateStore.allowsAutomaticReconnect)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@Test fun `forget clears pairing_rejected and the pre-v4 owner-lost setting`() {
|
|
98
|
+
val manager = DeviceManager(initializeHardware = false)
|
|
99
|
+
try {
|
|
100
|
+
MentraLivePairingStateStore.ensureRestored(device)
|
|
101
|
+
rejectViaFlagOffNotice()
|
|
102
|
+
DeviceStore.set("bluetooth", "mentra_live_owner_lost", true)
|
|
103
|
+
manager.clearPairingStateOnForget()
|
|
104
|
+
assertEquals(MentraLivePairingState.Normal, MentraLivePairingStateStore.state)
|
|
105
|
+
assertFalse(DeviceStore.store.get("bluetooth", "mentra_live_owner_lost") as Boolean)
|
|
106
|
+
MentraLivePairingStateStore.resetForTest()
|
|
107
|
+
MentraLivePairingStateStore.ensureRestored(device)
|
|
108
|
+
assertEquals(MentraLivePairingState.Normal, MentraLivePairingStateStore.state)
|
|
109
|
+
} finally {
|
|
110
|
+
manager.cleanup()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@Test fun `rollback tolerance - legacy keys migrate and unknown keys are ignored`() {
|
|
115
|
+
prefs()
|
|
116
|
+
.edit()
|
|
117
|
+
.putString(MentraLivePairingStateMachine.LEGACY_REJECTION_PHASE_KEY, "inside")
|
|
118
|
+
.putLong("pairing_yield_until_wall_ms", System.currentTimeMillis() + 60_000L)
|
|
119
|
+
.putString("pairing_v9_unknown", "x")
|
|
120
|
+
.commit()
|
|
121
|
+
MentraLivePairingStateStore.ensureRestored(device)
|
|
122
|
+
assertTrue(MentraLivePairingStateStore.isRejected)
|
|
123
|
+
// Legacy keys are removed after migration so an older build cannot re-apply them.
|
|
124
|
+
assertFalse(prefs().contains(MentraLivePairingStateMachine.LEGACY_REJECTION_PHASE_KEY))
|
|
125
|
+
assertFalse(prefs().contains("pairing_yield_until_wall_ms"))
|
|
126
|
+
assertTrue(prefs().contains("pairing_v9_unknown"))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private fun rejectViaFlagOffNotice() {
|
|
130
|
+
MentraLivePairingStateStore.update("test") {
|
|
131
|
+
it.onEnteringPairingMode(120_000L, null, false, false, device, System.currentTimeMillis())
|
|
132
|
+
}
|
|
133
|
+
assertTrue(MentraLivePairingStateStore.isRejected)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private fun prefs() =
|
|
137
|
+
ApplicationProvider.getApplicationContext<Context>()
|
|
138
|
+
.getSharedPreferences("mentra_live_owner", Context.MODE_PRIVATE)
|
|
139
|
+
|
|
140
|
+
private fun restore(category: String, saved: Map<String, Any>) {
|
|
141
|
+
(DeviceStore.store.getCategory(category).keys - saved.keys).forEach {
|
|
142
|
+
DeviceStore.store.remove(category, it)
|
|
143
|
+
}
|
|
144
|
+
saved.forEach { (key, value) -> DeviceStore.set(category, key, value) }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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.Test
|
|
7
|
+
|
|
8
|
+
class GlassesStatusAudioSetupTest {
|
|
9
|
+
private val notApplicable = mapOf("state" to "not_applicable")
|
|
10
|
+
private val idle = mapOf("state" to "idle")
|
|
11
|
+
|
|
12
|
+
@Test
|
|
13
|
+
fun `a ready snapshot carries not_applicable`() {
|
|
14
|
+
val status = GlassesStatus.fromMap(mapOf("fullyBooted" to true, "connected" to true))
|
|
15
|
+
assertEquals(notApplicable, status.audioSetup)
|
|
16
|
+
assertEquals(notApplicable, status.toMap()["audioSetup"])
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Test
|
|
20
|
+
fun `a snapshot without a ready session carries idle`() {
|
|
21
|
+
assertEquals(idle, GlassesStatus.fromMap(mapOf("fullyBooted" to false)).toMap()["audioSetup"])
|
|
22
|
+
// Connected but not yet ready (for example parked for the owner commit) is still idle.
|
|
23
|
+
assertEquals(
|
|
24
|
+
idle,
|
|
25
|
+
GlassesStatus.fromMap(mapOf("fullyBooted" to false, "connected" to true)).toMap()["audioSetup"],
|
|
26
|
+
)
|
|
27
|
+
assertEquals(idle, GlassesStatus.fromMap(emptyMap()).toMap()["audioSetup"])
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Test
|
|
31
|
+
fun `audioSetup follows readiness, never a stored copy`() {
|
|
32
|
+
// Even if something wrote a stale value, the snapshot reports the readiness rule.
|
|
33
|
+
val status = GlassesStatus.fromMap(mapOf("fullyBooted" to true, "audioSetup" to idle))
|
|
34
|
+
assertEquals(notApplicable, status.toMap()["audioSetup"])
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Test
|
|
38
|
+
fun `an update that marks the glasses ready carries not_applicable in the same update`() {
|
|
39
|
+
assertEquals(notApplicable, GlassesStatusUpdate(fullyBooted = true).toMap()["audioSetup"])
|
|
40
|
+
assertEquals(idle, GlassesStatusUpdate(fullyBooted = false).toMap()["audioSetup"])
|
|
41
|
+
assertFalse(GlassesStatusUpdate(batteryLevel = 50).toMap().containsKey("audioSetup"))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@Test
|
|
45
|
+
fun `update maps keep only a known audio setup state`() {
|
|
46
|
+
val needsUser = mapOf("state" to "needs_user", "action" to "connect_in_settings")
|
|
47
|
+
assertEquals(needsUser, GlassesStatusUpdate.fromMap(mapOf("audioSetup" to needsUser)).audioSetup)
|
|
48
|
+
assertNull(GlassesStatusUpdate.fromMap(mapOf("audioSetup" to mapOf("state" to "bogus"))).audioSetup)
|
|
49
|
+
assertNull(GlassesStatusUpdate.fromMap(mapOf("audioSetup" to "connected")).audioSetup)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -10,5 +10,7 @@ class GlassesStatusClassicAudioTest {
|
|
|
10
10
|
GlassesStatus.fromMap(mapOf("bluetoothClassicConnected" to true)).toMap()
|
|
11
11
|
|
|
12
12
|
assertThat(serialized["bluetoothClassicConnected"]).isEqualTo(true)
|
|
13
|
+
assertThat(serialized["bluetoothClassicProfilesConnected"]).isEqualTo(true)
|
|
14
|
+
assertThat(serialized["bluetoothAudioRouted"]).isEqualTo(true)
|
|
13
15
|
}
|
|
14
16
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import org.json.JSONObject
|
|
4
|
+
import org.junit.Assert.assertEquals
|
|
5
|
+
import org.junit.Assert.assertNull
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
|
|
8
|
+
class GlassesAudioSetupPolicyTest {
|
|
9
|
+
@Test
|
|
10
|
+
fun `android needs no user step on any model - not_applicable when ready, idle otherwise`() {
|
|
11
|
+
assertEquals(GlassesAudioSetupState.NOT_APPLICABLE, GlassesAudioSetupPolicy.state(ready = true))
|
|
12
|
+
assertEquals(GlassesAudioSetupState.IDLE, GlassesAudioSetupPolicy.state(ready = false))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@Test
|
|
16
|
+
fun `states encode as the shared map shape`() {
|
|
17
|
+
assertEquals(mapOf("state" to "idle"), GlassesAudioSetupState.IDLE.toMap())
|
|
18
|
+
assertEquals(mapOf("state" to "not_applicable"), GlassesAudioSetupState.NOT_APPLICABLE.toMap())
|
|
19
|
+
assertEquals(mapOf("state" to "connecting"), GlassesAudioSetupState.CONNECTING.toMap())
|
|
20
|
+
assertEquals(mapOf("state" to "connected"), GlassesAudioSetupState.CONNECTED.toMap())
|
|
21
|
+
assertEquals(
|
|
22
|
+
mapOf("state" to "needs_user", "action" to "connect_in_settings"),
|
|
23
|
+
GlassesAudioSetupState.NEEDS_USER.toMap(),
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Test
|
|
28
|
+
fun `every state round-trips and unknown values are rejected`() {
|
|
29
|
+
for (state in GlassesAudioSetupState.values()) {
|
|
30
|
+
assertEquals(state, GlassesAudioSetupState.fromMap(state.toMap()))
|
|
31
|
+
}
|
|
32
|
+
assertNull(GlassesAudioSetupState.fromMap(mapOf("state" to "paired")))
|
|
33
|
+
assertNull(GlassesAudioSetupState.fromMap(mapOf("action" to "connect_in_settings")))
|
|
34
|
+
assertNull(GlassesAudioSetupState.fromMap("not_applicable"))
|
|
35
|
+
assertNull(GlassesAudioSetupState.fromMap(null))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@Test
|
|
39
|
+
fun `audio_setup_changed fires only when readiness changes the state`() {
|
|
40
|
+
assertEquals(GlassesAudioSetupState.NOT_APPLICABLE, GlassesAudioSetupPolicy.change(wasReady = false, ready = true))
|
|
41
|
+
assertEquals(GlassesAudioSetupState.IDLE, GlassesAudioSetupPolicy.change(wasReady = true, ready = false))
|
|
42
|
+
assertNull(GlassesAudioSetupPolicy.change(wasReady = true, ready = true))
|
|
43
|
+
assertNull(GlassesAudioSetupPolicy.change(wasReady = false, ready = false))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Test
|
|
47
|
+
fun `classic_link reads both links and tolerates missing or odd fields`() {
|
|
48
|
+
assertEquals(
|
|
49
|
+
MentraLiveClassicLink(a2dp = true, hfp = false),
|
|
50
|
+
MentraLiveClassicLink.parse(JSONObject("""{"type":"classic_link","a2dp":true,"hfp":false}""")),
|
|
51
|
+
)
|
|
52
|
+
assertEquals(
|
|
53
|
+
MentraLiveClassicLink(a2dp = false, hfp = true),
|
|
54
|
+
MentraLiveClassicLink.parse(JSONObject("""{"type":"classic_link","a2dp":false,"hfp":true}""")),
|
|
55
|
+
)
|
|
56
|
+
assertEquals(
|
|
57
|
+
MentraLiveClassicLink(a2dp = false, hfp = false),
|
|
58
|
+
MentraLiveClassicLink.parse(JSONObject("""{"type":"classic_link"}""")),
|
|
59
|
+
)
|
|
60
|
+
assertEquals(
|
|
61
|
+
MentraLiveClassicLink(a2dp = false, hfp = false),
|
|
62
|
+
MentraLiveClassicLink.parse(JSONObject("""{"type":"classic_link","a2dp":"up","hfp":null}""")),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
}
|
package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveAudioSetupReadinessTest.kt
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import android.bluetooth.BluetoothAdapter
|
|
4
|
+
import android.bluetooth.BluetoothDevice
|
|
5
|
+
import android.bluetooth.BluetoothGattCallback
|
|
6
|
+
import android.bluetooth.BluetoothGattCharacteristic
|
|
7
|
+
import android.os.Looper
|
|
8
|
+
import androidx.test.core.app.ApplicationProvider
|
|
9
|
+
import com.mentra.bluetoothsdk.Bridge
|
|
10
|
+
import com.mentra.bluetoothsdk.DeviceManager
|
|
11
|
+
import com.mentra.bluetoothsdk.DeviceStore
|
|
12
|
+
import com.mentra.bluetoothsdk.GlassesStatus
|
|
13
|
+
import java.time.Duration
|
|
14
|
+
import java.util.UUID
|
|
15
|
+
import java.util.concurrent.CopyOnWriteArrayList
|
|
16
|
+
import org.json.JSONObject
|
|
17
|
+
import org.junit.Assert.assertEquals
|
|
18
|
+
import org.junit.Assert.assertFalse
|
|
19
|
+
import org.junit.Assert.assertNotNull
|
|
20
|
+
import org.junit.Assert.assertNull
|
|
21
|
+
import org.junit.Assert.assertTrue
|
|
22
|
+
import org.junit.Test
|
|
23
|
+
import org.junit.runner.RunWith
|
|
24
|
+
import org.robolectric.RobolectricTestRunner
|
|
25
|
+
import org.robolectric.Shadows.shadowOf
|
|
26
|
+
import org.robolectric.annotation.Config
|
|
27
|
+
import org.robolectric.annotation.LooperMode
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Drives [MentraLive]'s real BLE message handling and the shared status layer: audioSetup in
|
|
31
|
+
* the ready snapshot, the unknown-capability readiness wait, the selected-row secure rule, the
|
|
32
|
+
* secure firmware refresh, and the `classic_link` message.
|
|
33
|
+
*/
|
|
34
|
+
@RunWith(RobolectricTestRunner::class)
|
|
35
|
+
@Config(sdk = [33], shadows = [G1PairingTest.ShadowLc3Cpp::class], instrumentedPackages = ["com.mentra.lc3Lib"])
|
|
36
|
+
@LooperMode(LooperMode.Mode.PAUSED)
|
|
37
|
+
class MentraLiveAudioSetupReadinessTest {
|
|
38
|
+
private val targetName = "MENTRA_LIVE_BLE_AUDIOSETUP"
|
|
39
|
+
private val targetAddress = "AA:BB:CC:DD:EE:42"
|
|
40
|
+
|
|
41
|
+
@Test fun `readiness and audioSetup arrive in one snapshot and audio_setup_changed fires once`() =
|
|
42
|
+
withLive(selectedCapability = false) { live, events ->
|
|
43
|
+
val snapshots = CopyOnWriteArrayList<Map<String, Any>>()
|
|
44
|
+
val listener =
|
|
45
|
+
DeviceStore.store.addListener { category, _ ->
|
|
46
|
+
if (category == "glasses") snapshots.add(glassesSnapshot())
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
message(live, """{"type":"glasses_ready"}""")
|
|
50
|
+
} finally {
|
|
51
|
+
DeviceStore.store.removeListener(listener)
|
|
52
|
+
}
|
|
53
|
+
assertTrue(ready())
|
|
54
|
+
val readySnapshots = snapshots.filter { it["connection"].let { c -> (c as Map<*, *>)["fullyBooted"] == true } }
|
|
55
|
+
assertTrue(readySnapshots.isNotEmpty())
|
|
56
|
+
// Never "ready" with a stale idle.
|
|
57
|
+
readySnapshots.forEach { assertEquals(mapOf("state" to "not_applicable"), it["audioSetup"]) }
|
|
58
|
+
assertEquals(listOf(mapOf("state" to "not_applicable")), audioSetupEvents(events))
|
|
59
|
+
|
|
60
|
+
// A repeated ready report is not a change.
|
|
61
|
+
message(live, """{"type":"glasses_ready"}""")
|
|
62
|
+
assertEquals(1, audioSetupEvents(events).size)
|
|
63
|
+
|
|
64
|
+
DeviceStore.apply("glasses", "fullyBooted", false)
|
|
65
|
+
assertEquals(mapOf("state" to "idle"), glassesSnapshot()["audioSetup"])
|
|
66
|
+
assertEquals(
|
|
67
|
+
listOf(mapOf("state" to "not_applicable"), mapOf("state" to "idle")),
|
|
68
|
+
audioSetupEvents(events),
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@Test fun `unknown capability without pairing_info is reported ready after 5 s as legacy`() =
|
|
73
|
+
withLive(selectedCapability = "") { live, _ ->
|
|
74
|
+
message(live, """{"type":"glasses_ready"}""")
|
|
75
|
+
assertFalse(ready())
|
|
76
|
+
assertEquals(mapOf("state" to "idle"), glassesSnapshot()["audioSetup"])
|
|
77
|
+
idle(MentraLivePairingReadinessPolicy.UNKNOWN_CAPABILITY_PAIRING_INFO_WAIT_MS - 100)
|
|
78
|
+
assertFalse(ready())
|
|
79
|
+
idle(200)
|
|
80
|
+
assertTrue(ready())
|
|
81
|
+
assertEquals(mapOf("state" to "not_applicable"), glassesSnapshot()["audioSetup"])
|
|
82
|
+
assertNull(get(live, "secureBondWaitRunnable"))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Test fun `unknown capability with a secure pairing_info follows the owner commit rule`() =
|
|
86
|
+
withLive(selectedCapability = "") { live, _ ->
|
|
87
|
+
message(live, """{"type":"glasses_ready"}""")
|
|
88
|
+
message(live, pairingInfo(secure = true, ownerCommitted = false))
|
|
89
|
+
assertFalse(ready())
|
|
90
|
+
// The secure rule runs its own deadline instead of the 5 s legacy fallback.
|
|
91
|
+
assertNotNull(get(live, "secureBondWaitRunnable"))
|
|
92
|
+
idle(MentraLivePairingReadinessPolicy.UNKNOWN_CAPABILITY_PAIRING_INFO_WAIT_MS + 1_000)
|
|
93
|
+
assertFalse(ready())
|
|
94
|
+
|
|
95
|
+
shadowOf(device()).setBondState(BluetoothDevice.BOND_BONDED)
|
|
96
|
+
message(live, pairingInfo(secure = true, ownerCommitted = true))
|
|
97
|
+
assertTrue(ready())
|
|
98
|
+
assertNull(get(live, "secureBondWaitRunnable"))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Test fun `unknown capability with a legacy pairing_info is ready at once`() =
|
|
102
|
+
withLive(selectedCapability = "") { live, _ ->
|
|
103
|
+
message(live, """{"type":"glasses_ready"}""")
|
|
104
|
+
assertFalse(ready())
|
|
105
|
+
message(live, pairingInfo(secure = false, ownerCommitted = false))
|
|
106
|
+
assertTrue(ready())
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@Test fun `a legacy row is not secure even if its address advertised the trailer before`() =
|
|
110
|
+
withLive(selectedCapability = false) { live, _ ->
|
|
111
|
+
trailerMemory().noteSecureTrailer(targetAddress)
|
|
112
|
+
try {
|
|
113
|
+
message(live, """{"type":"glasses_ready"}""")
|
|
114
|
+
assertTrue(ready())
|
|
115
|
+
assertNull(get(live, "secureBondWaitRunnable"))
|
|
116
|
+
} finally {
|
|
117
|
+
trailerMemory().onScanStart()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@Test fun `a ready session without pairing_info clears the secure firmware memory after 15 s`() =
|
|
122
|
+
withLive(selectedCapability = false) { live, _ ->
|
|
123
|
+
DeviceStore.set("bluetooth", "mentra_live_secure_firmware", true)
|
|
124
|
+
message(live, """{"type":"glasses_ready"}""")
|
|
125
|
+
idle(MentraLivePairingReadinessPolicy.SECURE_FIRMWARE_REFRESH_MS - 100)
|
|
126
|
+
assertEquals(true, DeviceStore.get("bluetooth", "mentra_live_secure_firmware"))
|
|
127
|
+
idle(200)
|
|
128
|
+
assertEquals(false, DeviceStore.get("bluetooth", "mentra_live_secure_firmware"))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@Test fun `a secure pairing_info keeps the secure firmware memory`() =
|
|
132
|
+
withLive(selectedCapability = true) { live, _ ->
|
|
133
|
+
DeviceStore.set("bluetooth", "mentra_live_secure_firmware", false)
|
|
134
|
+
message(live, pairingInfo(secure = true, ownerCommitted = false))
|
|
135
|
+
message(live, """{"type":"glasses_ready"}""")
|
|
136
|
+
idle(MentraLivePairingReadinessPolicy.SECURE_FIRMWARE_REFRESH_MS + 1_000)
|
|
137
|
+
assertEquals(true, DeviceStore.get("bluetooth", "mentra_live_secure_firmware"))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@Test fun `classic_link is accepted and logged`() =
|
|
141
|
+
withLive(selectedCapability = false) { live, events ->
|
|
142
|
+
message(live, """{"type":"classic_link","a2dp":true,"hfp":false}""")
|
|
143
|
+
message(live, """{"type":"classic_link"}""")
|
|
144
|
+
val logs = events.filter { it.first == "log" }.map { it.second["message"].toString() }
|
|
145
|
+
assertTrue(logs.any { it.contains("LIVE: classic_link a2dp=true hfp=false") })
|
|
146
|
+
assertTrue(logs.any { it.contains("LIVE: classic_link a2dp=false hfp=false") })
|
|
147
|
+
assertFalse(ready())
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private fun withLive(
|
|
151
|
+
selectedCapability: Any,
|
|
152
|
+
body: (MentraLive, MutableList<Pair<String, Map<String, Any>>>) -> Unit,
|
|
153
|
+
) {
|
|
154
|
+
Bridge.initialize(ApplicationProvider.getApplicationContext())
|
|
155
|
+
val saved = listOf("bluetooth", "glasses").associateWith { DeviceStore.store.getCategory(it) }
|
|
156
|
+
val singleton = DeviceManager::class.java.getDeclaredField("_instance").apply { isAccessible = true }
|
|
157
|
+
val previous = singleton.get(null)
|
|
158
|
+
val manager = DeviceManager(initializeHardware = false)
|
|
159
|
+
singleton.set(null, manager)
|
|
160
|
+
MentraLivePairingStateStore.resetForTest()
|
|
161
|
+
DeviceStore.set("glasses", "fullyBooted", false)
|
|
162
|
+
DeviceStore.set("bluetooth", "pending_device_name", targetName)
|
|
163
|
+
DeviceStore.set("bluetooth", "pending_device_address", targetAddress)
|
|
164
|
+
DeviceStore.set("bluetooth", "pending_device_secure_pairing_capable", selectedCapability)
|
|
165
|
+
val events = CopyOnWriteArrayList<Pair<String, Map<String, Any>>>()
|
|
166
|
+
val sink = Bridge.addEventSink { type, body -> events.add(type to HashMap(body)) }
|
|
167
|
+
val live = MentraLive()
|
|
168
|
+
try {
|
|
169
|
+
link(live)
|
|
170
|
+
body(live, events)
|
|
171
|
+
} finally {
|
|
172
|
+
Bridge.removeEventSink(sink)
|
|
173
|
+
live.destroy()
|
|
174
|
+
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(2))
|
|
175
|
+
manager.cleanup()
|
|
176
|
+
singleton.set(null, previous)
|
|
177
|
+
MentraLivePairingStateStore.resetForTest()
|
|
178
|
+
saved.forEach { (category, values) ->
|
|
179
|
+
(DeviceStore.store.getCategory(category).keys - values.keys).forEach {
|
|
180
|
+
DeviceStore.store.remove(category, it)
|
|
181
|
+
}
|
|
182
|
+
values.forEach { (key, value) -> DeviceStore.set(category, key, value) }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private fun device(): BluetoothDevice = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(targetAddress)
|
|
188
|
+
|
|
189
|
+
/** A live GATT link to the pairing target; CTKD is marked started so no Classic work runs. */
|
|
190
|
+
private fun link(live: MentraLive) {
|
|
191
|
+
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
|
192
|
+
val gatt = device().connectGatt(context, false, object : BluetoothGattCallback() {})
|
|
193
|
+
set(live, "isConnected", true)
|
|
194
|
+
set(live, "bluetoothGatt", gatt)
|
|
195
|
+
set(live, "connectedDevice", device())
|
|
196
|
+
set(live, "txCharacteristic", BluetoothGattCharacteristic(UUID.randomUUID(), 0, 0))
|
|
197
|
+
set(live, "ctkdInitiatedThisGattSession", true)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private fun message(live: MentraLive, json: String) {
|
|
201
|
+
MentraLive::class.java.getDeclaredMethod("processJsonMessage", JSONObject::class.java)
|
|
202
|
+
.apply { isAccessible = true }
|
|
203
|
+
.invoke(live, JSONObject(json))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private fun pairingInfo(secure: Boolean, ownerCommitted: Boolean): String =
|
|
207
|
+
"""{"type":"pairing_info","secure_pairing_capable":$secure,"owner_committed":$ownerCommitted}"""
|
|
208
|
+
|
|
209
|
+
private fun ready(): Boolean = DeviceStore.get("glasses", "fullyBooted") == true
|
|
210
|
+
|
|
211
|
+
/** What `glasses_status` sends to JavaScript for the current store. */
|
|
212
|
+
private fun glassesSnapshot(): Map<String, Any> =
|
|
213
|
+
GlassesStatus.fromMap(DeviceStore.store.getCategory("glasses")).toMap()
|
|
214
|
+
|
|
215
|
+
private fun audioSetupEvents(events: List<Pair<String, Map<String, Any>>>): List<Any?> =
|
|
216
|
+
events.filter { it.first == "audio_setup_changed" }.map { it.second["state"] }
|
|
217
|
+
|
|
218
|
+
private fun idle(ms: Long) = shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(ms))
|
|
219
|
+
|
|
220
|
+
private fun trailerMemory(): MentraLiveSecureTrailerScanMemory =
|
|
221
|
+
MentraLive::class.java.getDeclaredField("secureTrailerScanMemory")
|
|
222
|
+
.apply { isAccessible = true }
|
|
223
|
+
.get(null) as MentraLiveSecureTrailerScanMemory
|
|
224
|
+
|
|
225
|
+
private fun get(live: MentraLive, name: String): Any? =
|
|
226
|
+
MentraLive::class.java.getDeclaredField(name).apply { isAccessible = true }.get(live)
|
|
227
|
+
|
|
228
|
+
private fun set(live: MentraLive, name: String, value: Any?) {
|
|
229
|
+
MentraLive::class.java.getDeclaredField(name).apply { isAccessible = true }.set(live, value)
|
|
230
|
+
}
|
|
231
|
+
}
|