@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.
Files changed (68) hide show
  1. package/MIGRATION.md +61 -0
  2. package/README.md +47 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +8 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +48 -4
  6. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +21 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +2 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +5 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicy.kt +64 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +788 -134
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicy.kt +227 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +19 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicy.kt +94 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachine.kt +411 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateStore.kt +124 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +50 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerOwnerLossTest.kt +146 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusAudioSetupTest.kt +51 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +2 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicyTest.kt +65 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveAudioSetupReadinessTest.kt +231 -0
  23. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicyTest.kt +368 -0
  24. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +20 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicyTest.kt +136 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachineTest.kt +346 -0
  27. package/build/BluetoothSdk.types.d.ts +101 -0
  28. package/build/BluetoothSdk.types.d.ts.map +1 -1
  29. package/build/BluetoothSdk.types.js.map +1 -1
  30. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  31. package/build/_private/BluetoothSdkModule.js +1 -0
  32. package/build/_private/BluetoothSdkModule.js.map +1 -1
  33. package/build/generated/releaseMetadata.js +5 -5
  34. package/build/generated/releaseMetadata.js.map +1 -1
  35. package/build/index.d.ts +1 -1
  36. package/build/index.d.ts.map +1 -1
  37. package/build/index.js +3 -0
  38. package/build/index.js.map +1 -1
  39. package/ios/BluetoothSdkModule.swift +8 -2
  40. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  41. package/ios/Source/Bridge.swift +11 -3
  42. package/ios/Source/DeviceManager.swift +80 -6
  43. package/ios/Source/DeviceStore.swift +27 -0
  44. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  45. package/ios/Source/MentraBluetoothSDK.swift +10 -4
  46. package/ios/Source/errors/BluetoothSdkError.swift +5 -1
  47. package/ios/Source/sgcs/GlassesAudioSetupPolicy.swift +181 -0
  48. package/ios/Source/sgcs/MentraLive.swift +1053 -166
  49. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +260 -1
  50. package/ios/Source/sgcs/MentraLivePairingStateMachine.swift +385 -0
  51. package/ios/Source/sgcs/MentraLivePairingStateStore.swift +121 -0
  52. package/ios/Source/sgcs/SGCManager.swift +7 -0
  53. package/ios/Source/sgcs/Simulated.swift +2 -0
  54. package/ios/Source/status/DeviceStatus.swift +35 -0
  55. package/ios/Source/types/DeviceModels.swift +8 -1
  56. package/ios/Source/utils/AudioSessionMonitor.swift +158 -5
  57. package/ios/Tests/BluetoothAudioRouteTests.swift +82 -0
  58. package/ios/Tests/GlassesAudioSetupPolicyTests.swift +191 -0
  59. package/ios/Tests/MentraLiveAudioSetupTests.swift +215 -0
  60. package/ios/Tests/MentraLiveConnectionOptionsTests.swift +55 -0
  61. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +327 -0
  62. package/ios/Tests/MentraLivePairingStateMachineTests.swift +402 -0
  63. package/ios/Tests/MentraLiveSessionReadinessTests.swift +328 -26
  64. package/package.json +3 -2
  65. package/src/BluetoothSdk.types.ts +97 -0
  66. package/src/_private/BluetoothSdkModule.ts +1 -0
  67. package/src/generated/releaseMetadata.ts +5 -5
  68. 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
+ }
@@ -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
+ }