@mentra/bluetooth-sdk 3.4.0-dev.996 → 3.4.0-dev.999

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,346 @@
1
+ package com.mentra.bluetoothsdk.sgcs
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 MentraLivePairingStateMachineTest {
10
+ private val t0 = 1_700_000_000_000L
11
+ private val device = "MENTRA_LIVE_BLE_ABCD"
12
+
13
+ private fun enter(
14
+ machine: MentraLivePairingStateMachine,
15
+ windowId: Int? = 7,
16
+ windowMs: Long? = 120_000L,
17
+ ownerRetained: Boolean = true,
18
+ explicit: Boolean = false,
19
+ now: Long = t0,
20
+ ) = machine.onEnteringPairingMode(windowMs, windowId, ownerRetained, explicit, device, now)
21
+
22
+ private fun pausedMachine(windowId: Int = 7): MentraLivePairingStateMachine =
23
+ MentraLivePairingStateMachine().also { enter(it, windowId = windowId) }
24
+
25
+ private fun reject(machine: MentraLivePairingStateMachine, now: Long, explicit: Boolean = false) =
26
+ machine.onAuthRejection(secureFirmware = true, explicitAttempt = explicit, nowMs = now)
27
+
28
+ @Test
29
+ fun v4NoticePausesForTheWindowPlusMarginWithoutA300sClamp() {
30
+ val machine = MentraLivePairingStateMachine()
31
+ val transition = enter(machine)
32
+ assertEquals(MentraLivePairingEffect.PAUSE, transition.effect)
33
+ assertEquals(MentraLivePairingState.PairingMode(7, t0 + 150_000L), machine.state)
34
+ assertFalse(machine.allowsAutomaticReconnect)
35
+ assertEquals(150_000L, machine.remainingPauseMs(t0))
36
+
37
+ val long = MentraLivePairingStateMachine()
38
+ enter(long, windowMs = 900_000L)
39
+ assertEquals(MentraLivePairingState.PairingMode(7, t0 + 930_000L), long.state)
40
+ }
41
+
42
+ @Test
43
+ fun publicStateMapsMatchTheTypeScriptContract() {
44
+ assertEquals(mapOf("state" to "normal"), MentraLivePairingState.Normal.toMap())
45
+ assertEquals(mapOf("state" to "pairing_rejected"), MentraLivePairingState.Rejected.toMap())
46
+ assertEquals(
47
+ mapOf("state" to "pairing_mode", "windowId" to 7, "endsAtMs" to t0),
48
+ MentraLivePairingState.PairingMode(7, t0).toMap(),
49
+ )
50
+ }
51
+
52
+ @Test
53
+ fun aDuplicateNoticeForTheSameWindowIsIgnored() {
54
+ val machine = pausedMachine()
55
+ val again = enter(machine, now = t0 + 5_000L)
56
+ assertEquals(MentraLivePairingEffect.NONE, again.effect)
57
+ assertEquals(MentraLivePairingState.PairingMode(7, t0 + 150_000L), machine.state)
58
+ }
59
+
60
+ @Test
61
+ fun anExplicitAttemptIsNeverPausedByTheNotice() {
62
+ val machine = MentraLivePairingStateMachine()
63
+ assertEquals(MentraLivePairingEffect.NONE, enter(machine, explicit = true).effect)
64
+ assertEquals(MentraLivePairingState.Normal, machine.state)
65
+ }
66
+
67
+ @Test
68
+ fun flagOffFirmwareForgetsThisPhoneOnEntry() {
69
+ val machine = MentraLivePairingStateMachine()
70
+ val transition = enter(machine, windowId = null, ownerRetained = false)
71
+ assertEquals(MentraLivePairingEffect.REJECT, transition.effect)
72
+ assertEquals(MentraLivePairingState.Rejected, machine.state)
73
+
74
+ val pairing = MentraLivePairingStateMachine()
75
+ enter(pairing, windowId = null, ownerRetained = false, explicit = true)
76
+ assertEquals(MentraLivePairingState.Normal, pairing.state)
77
+ }
78
+
79
+ @Test
80
+ fun pairingModeEndedResumesEarlyForTheMatchingWindowOnly() {
81
+ val machine = pausedMachine()
82
+ assertEquals(MentraLivePairingEffect.NONE, machine.onPairingModeEnded("timeout", 8).effect)
83
+ assertTrue(machine.isPaused)
84
+ val ended = machine.onPairingModeEnded("paired", 7)
85
+ assertEquals(MentraLivePairingEffect.RESUME, ended.effect)
86
+ assertEquals(MentraLivePairingState.Normal, machine.state)
87
+ // The connected phone was admitted, so there is nothing to verify.
88
+ assertNull(machine.endedWindowId)
89
+ }
90
+
91
+ @Test
92
+ fun aClearedAdvertisementFlagResumesEarlyAfterTheGrace() {
93
+ val machine = pausedMachine()
94
+ // An advertisement from before the window opened can still be in flight.
95
+ val early = machine.onAdvertisement(false, false, 6, t0 + 1_000L)
96
+ assertEquals(MentraLivePairingEffect.NONE, early.effect)
97
+ assertTrue(machine.isPaused)
98
+ assertEquals(MentraLivePairingEffect.NONE, machine.onAdvertisement(true, false, 7, t0 + 20_000L).effect)
99
+ val cleared = machine.onAdvertisement(false, false, 7, t0 + 60_000L)
100
+ assertEquals(MentraLivePairingEffect.RESUME, cleared.effect)
101
+ assertEquals(MentraLivePairingState.Normal, machine.state)
102
+ assertEquals(7, machine.endedWindowId)
103
+ }
104
+
105
+ @Test
106
+ fun theDeadlineResumes() {
107
+ val machine = pausedMachine()
108
+ assertEquals(MentraLivePairingEffect.NONE, machine.onTick(t0 + 149_999L).effect)
109
+ assertEquals(MentraLivePairingEffect.RESUME, machine.onTick(t0 + 150_000L).effect)
110
+ assertTrue(machine.allowsAutomaticReconnect)
111
+ assertTrue(machine.awaitingTakeoverCheck)
112
+ }
113
+
114
+ @Test
115
+ fun ownerChangedForTheEndedWindowRejectsOnTheFirstFailedVerification() {
116
+ val machine = pausedMachine()
117
+ machine.onTick(t0 + 150_000L)
118
+ machine.onAdvertisement(false, ownerChanged = true, generation = 7, nowMs = t0 + 151_000L)
119
+ assertTrue(machine.verificationArmed)
120
+ assertFalse(machine.awaitingTakeoverCheck)
121
+ val verdict = reject(machine, t0 + 152_000L)
122
+ assertEquals(MentraLivePairingEffect.REJECT, verdict.effect)
123
+ assertEquals(MentraLivePairingState.Rejected, machine.state)
124
+ }
125
+
126
+ @Test
127
+ fun ownerChangedCarriedOnTheFlagClearingAdvertisementArmsTheVerification() {
128
+ val machine = pausedMachine()
129
+ val cleared = machine.onAdvertisement(false, ownerChanged = true, generation = 7, nowMs = t0 + 60_000L)
130
+ assertEquals(MentraLivePairingEffect.RESUME, cleared.effect)
131
+ assertTrue(machine.verificationArmed)
132
+ }
133
+
134
+ @Test
135
+ fun aSpoofedOwnerChangedBitFollowedByASuccessfulReconnectDoesNotStrandThePhone() {
136
+ val machine = pausedMachine()
137
+ machine.onTick(t0 + 150_000L)
138
+ machine.onAdvertisement(false, ownerChanged = true, generation = 7, nowMs = t0 + 151_000L)
139
+ assertTrue(machine.verificationArmed)
140
+ machine.onSessionReady(explicitAttempt = false)
141
+ assertEquals(MentraLivePairingState.Normal, machine.state)
142
+ assertFalse(machine.verificationArmed)
143
+ // The same bit again arms nothing: exactly one verification per window.
144
+ machine.onAdvertisement(false, ownerChanged = true, generation = 7, nowMs = t0 + 200_000L)
145
+ assertFalse(machine.verificationArmed)
146
+ assertEquals(MentraLivePairingEffect.NONE, reject(machine, t0 + 201_000L).effect)
147
+ assertEquals(MentraLivePairingState.Normal, machine.state)
148
+ }
149
+
150
+ @Test
151
+ fun ownerChangedForAnotherWindowOrStillInPairingModeArmsNothing() {
152
+ val other = pausedMachine()
153
+ other.onTick(t0 + 150_000L)
154
+ other.onAdvertisement(false, ownerChanged = true, generation = 9, nowMs = t0 + 151_000L)
155
+ assertFalse(other.verificationArmed)
156
+
157
+ val stillPairing = pausedMachine()
158
+ stillPairing.onAdvertisement(true, ownerChanged = true, generation = 7, nowMs = t0 + 60_000L)
159
+ assertFalse(stillPairing.verificationArmed)
160
+
161
+ // Without a window this phone paused for, the bit is not this phone's business.
162
+ val idle = MentraLivePairingStateMachine()
163
+ idle.onAdvertisement(false, ownerChanged = true, generation = 7, nowMs = t0)
164
+ assertFalse(idle.verificationArmed)
165
+ }
166
+
167
+ @Test
168
+ fun aVerificationThatFailsWithoutAnAuthVerdictFallsBackToTheStreak() {
169
+ val machine = pausedMachine()
170
+ machine.onTick(t0 + 150_000L)
171
+ machine.onAdvertisement(false, ownerChanged = true, generation = 7, nowMs = t0 + 151_000L)
172
+ machine.onConnectAttemptFailed()
173
+ assertFalse(machine.verificationArmed)
174
+ assertEquals(MentraLivePairingEffect.NONE, reject(machine, t0 + 160_000L).effect)
175
+ assertEquals(MentraLivePairingState.Normal, machine.state)
176
+ }
177
+
178
+ @Test
179
+ fun withoutTheBitThreeRejectionsSpanningTheMinimumReject() {
180
+ val machine = MentraLivePairingStateMachine()
181
+ assertEquals(MentraLivePairingEffect.NONE, reject(machine, t0).effect)
182
+ assertEquals(MentraLivePairingEffect.NONE, reject(machine, t0 + 20_000L).effect)
183
+ val third = reject(machine, t0 + MentraLiveOwnerLossPolicy.MIN_REJECTION_SPAN_MS)
184
+ assertEquals(MentraLivePairingEffect.REJECT, third.effect)
185
+ assertEquals(MentraLivePairingState.Rejected, machine.state)
186
+ }
187
+
188
+ @Test
189
+ fun rejectionsDuringThePauseOrWhileThePairingAdvertIsFreshNeverCount() {
190
+ val machine = pausedMachine()
191
+ // The intentional disconnect and every 0x14/auth refusal inside the window.
192
+ var now = t0
193
+ repeat(6) {
194
+ assertEquals(MentraLivePairingEffect.NONE, reject(machine, now).effect)
195
+ now += 20_000L
196
+ }
197
+ assertTrue(machine.isPaused)
198
+
199
+ val advertising = MentraLivePairingStateMachine()
200
+ repeat(6) { i ->
201
+ val at = t0 + i * 20_000L
202
+ advertising.onAdvertisement(true, false, 3, at)
203
+ assertEquals(MentraLivePairingEffect.NONE, reject(advertising, at + 1_000L).effect)
204
+ }
205
+ assertEquals(MentraLivePairingState.Normal, advertising.state)
206
+ // The streak starts fresh once pairing mode is no longer advertised.
207
+ advertising.onAdvertisement(false, false, 3, t0 + 200_000L)
208
+ assertEquals(MentraLivePairingEffect.NONE, reject(advertising, t0 + 200_500L).effect)
209
+ assertEquals(MentraLivePairingState.Normal, advertising.state)
210
+ }
211
+
212
+ @Test
213
+ fun legacyFirmwareAndExplicitAttemptsNeverCount() {
214
+ val machine = MentraLivePairingStateMachine()
215
+ var now = t0
216
+ repeat(6) {
217
+ machine.onAuthRejection(secureFirmware = false, explicitAttempt = false, nowMs = now)
218
+ reject(machine, now + 1, explicit = true)
219
+ now += 20_000L
220
+ }
221
+ assertEquals(MentraLivePairingState.Normal, machine.state)
222
+ }
223
+
224
+ @Test
225
+ fun onlyAnExplicitPairOrAForgetClearsARejection() {
226
+ val machine = MentraLivePairingStateMachine()
227
+ enter(machine, windowId = null, ownerRetained = false)
228
+ machine.onSessionReady(explicitAttempt = false)
229
+ assertEquals(MentraLivePairingState.Rejected, machine.state)
230
+ machine.onPairingModeEnded("timeout", 1)
231
+ machine.onTick(t0 + 1_000_000L)
232
+ machine.onAdvertisement(false, false, 1, t0 + 1_000_000L)
233
+ assertEquals(MentraLivePairingState.Rejected, machine.state)
234
+ val paired = machine.onSessionReady(explicitAttempt = true)
235
+ assertTrue(paired.stateChanged)
236
+ assertEquals(MentraLivePairingState.Normal, machine.state)
237
+
238
+ val forgotten = MentraLivePairingStateMachine()
239
+ enter(forgotten, windowId = null, ownerRetained = false)
240
+ forgotten.onForget()
241
+ assertEquals(MentraLivePairingState.Normal, forgotten.state)
242
+ assertEquals("", forgotten.deviceId)
243
+ }
244
+
245
+ @Test
246
+ fun aPauseSurvivesARestartWithTheSameWindowId() {
247
+ val machine = pausedMachine(windowId = 42)
248
+ val persisted = machine.persisted()
249
+ val restored = MentraLivePairingStateMachine()
250
+ val transition = restored.restore(persisted, device, t0 + 30_000L)
251
+ assertEquals(MentraLivePairingEffect.PAUSE, transition.effect)
252
+ assertEquals(MentraLivePairingState.PairingMode(42, t0 + 150_000L), restored.state)
253
+ assertEquals(120_000L, restored.remainingPauseMs(t0 + 30_000L))
254
+ }
255
+
256
+ @Test
257
+ fun aPauseThatEndedWhileTheAppWasAwayStillVerifiesTheTakeover() {
258
+ val persisted = pausedMachine(windowId = 42).persisted()
259
+ val restored = MentraLivePairingStateMachine()
260
+ val transition = restored.restore(persisted, device, t0 + 400_000L)
261
+ assertEquals(MentraLivePairingEffect.NONE, transition.effect)
262
+ assertEquals(MentraLivePairingState.Normal, restored.state)
263
+ assertEquals(42, restored.endedWindowId)
264
+ restored.onAdvertisement(false, ownerChanged = true, generation = 42, nowMs = t0 + 401_000L)
265
+ assertTrue(restored.verificationArmed)
266
+ }
267
+
268
+ @Test
269
+ fun anArmedVerificationIsReArmedAfterARestart() {
270
+ val machine = pausedMachine(windowId = 42)
271
+ machine.onTick(t0 + 150_000L)
272
+ machine.onAdvertisement(false, ownerChanged = true, generation = 42, nowMs = t0 + 151_000L)
273
+ assertTrue(machine.verificationArmed)
274
+ val restored = MentraLivePairingStateMachine()
275
+ restored.restore(machine.persisted(), device, t0 + 160_000L)
276
+ assertFalse(restored.verificationArmed)
277
+ restored.onAdvertisement(false, ownerChanged = true, generation = 42, nowMs = t0 + 161_000L)
278
+ assertTrue(restored.verificationArmed)
279
+ }
280
+
281
+ @Test
282
+ fun anImplausibleRestoredDeadlineDoesNotPause() {
283
+ val persisted = pausedMachine().persisted().toMutableMap()
284
+ persisted[MentraLivePairingStateMachine.KEY_ENDS_AT_MS] = t0 + 10_000_000L
285
+ val restored = MentraLivePairingStateMachine()
286
+ restored.restore(persisted, device, t0)
287
+ assertEquals(MentraLivePairingState.Normal, restored.state)
288
+ }
289
+
290
+ @Test
291
+ fun aRejectionSurvivesARestart() {
292
+ val machine = MentraLivePairingStateMachine()
293
+ enter(machine, windowId = null, ownerRetained = false)
294
+ val restored = MentraLivePairingStateMachine()
295
+ val transition = restored.restore(machine.persisted(), device, t0 + 86_400_000L)
296
+ assertEquals(MentraLivePairingEffect.REJECT, transition.effect)
297
+ assertEquals(MentraLivePairingState.Rejected, restored.state)
298
+ }
299
+
300
+ @Test
301
+ fun restoreIgnoresUnknownKeysAndMalformedValues() {
302
+ val values =
303
+ mapOf(
304
+ MentraLivePairingStateMachine.KEY_DEVICE_ID to device,
305
+ MentraLivePairingStateMachine.KEY_STATE to "pairing_mode",
306
+ MentraLivePairingStateMachine.KEY_WINDOW_ID to "not a number",
307
+ MentraLivePairingStateMachine.KEY_ENDS_AT_MS to t0 + 60_000L,
308
+ "pairing_v9_future_key" to "whatever",
309
+ "unrelated" to 12,
310
+ )
311
+ val restored = MentraLivePairingStateMachine()
312
+ restored.restore(values, device, t0)
313
+ assertEquals(MentraLivePairingState.Normal, restored.state)
314
+
315
+ val future = MentraLivePairingStateMachine()
316
+ future.restore(mapOf(MentraLivePairingStateMachine.KEY_STATE to "pairing_v9_state"), device, t0)
317
+ assertEquals(MentraLivePairingState.Normal, future.state)
318
+ }
319
+
320
+ @Test
321
+ fun aRecordForOtherGlassesDoesNotApply() {
322
+ val persisted = pausedMachine().persisted()
323
+ val restored = MentraLivePairingStateMachine()
324
+ restored.restore(persisted, "MENTRA_LIVE_BLE_OTHER", t0 + 1_000L)
325
+ assertEquals(MentraLivePairingState.Normal, restored.state)
326
+ assertEquals("MENTRA_LIVE_BLE_OTHER", restored.deviceId)
327
+
328
+ val bound = MentraLivePairingStateMachine()
329
+ enter(bound, windowId = null, ownerRetained = false)
330
+ assertTrue(bound.bindDevice("MENTRA_LIVE_BLE_OTHER").stateChanged)
331
+ assertEquals(MentraLivePairingState.Normal, bound.state)
332
+ }
333
+
334
+ @Test
335
+ fun theLegacyStandDownPhaseMigratesToRejected() {
336
+ val restored = MentraLivePairingStateMachine()
337
+ val transition =
338
+ restored.restore(
339
+ mapOf(MentraLivePairingStateMachine.LEGACY_REJECTION_PHASE_KEY to "waiting"),
340
+ device,
341
+ t0,
342
+ )
343
+ assertEquals(MentraLivePairingEffect.REJECT, transition.effect)
344
+ assertEquals(MentraLivePairingState.Rejected, restored.state)
345
+ }
346
+ }
@@ -725,9 +725,72 @@ export type StreamKeepAliveRequest = {
725
725
  streamId: string;
726
726
  ackId: string;
727
727
  };
728
+ /**
729
+ * Stable machine-readable `pair_failure` causes. Unknown native codes still typecheck.
730
+ * `ios_forget_pairing`: iOS holds a stale Bluetooth pairing for these glasses during an
731
+ * explicit attempt; the user must remove it in Settings > Bluetooth before pairing again.
732
+ */
733
+ export type PairFailureCode = "ios_forget_pairing" | string;
728
734
  export type PairFailureEvent = {
729
735
  type: "pair_failure";
730
736
  error: string;
737
+ /** Stable cause, when the SDK can classify the failure. */
738
+ code?: PairFailureCode;
739
+ };
740
+ /**
741
+ * Mentra Live pairing state, owned by the SDK.
742
+ * - `normal`: no pairing mode in effect; automatic reconnect is allowed.
743
+ * - `pairing_mode`: the glasses opened a pairing window. Automatic reconnect is paused until
744
+ * `endsAtMs` or until the glasses report the window ended. `windowId` identifies the
745
+ * window and survives a cold restart.
746
+ * - `pairing_rejected`: the glasses no longer accept this phone (another phone paired, or repeated
747
+ * authentication rejections on secure-pairing firmware). Reconnect has stopped. Render Pair Again.
748
+ */
749
+ export type MentraLivePairingState = {
750
+ state: "normal";
751
+ } | {
752
+ state: "pairing_mode";
753
+ windowId: number;
754
+ endsAtMs: number;
755
+ } | {
756
+ state: "pairing_rejected";
757
+ };
758
+ export type PairingStateChangedEvent = {
759
+ state: MentraLivePairingState;
760
+ };
761
+ /**
762
+ * Whether the user has to do anything to get glasses audio working, decided by the SDK for
763
+ * every glasses model. Render it; do not add your own audio timers or platform checks.
764
+ *
765
+ * - `idle`: no glasses session.
766
+ * - `not_applicable`: no user step for audio. Android attaches Classic audio from the BLE bond,
767
+ * and glasses without Classic audio have nothing to set up.
768
+ * - `connecting`: the glasses are connected, audio is not, and the SDK still expects audio to
769
+ * come up on its own (for example iPhone audio derived from the BLE pair).
770
+ * - `connected`: Classic audio (A2DP or HFP) to the glasses is up.
771
+ * - `needs_user`: audio did not come up on its own. Ask the user to connect the glasses in the
772
+ * system Bluetooth settings (iPhone: Settings > Bluetooth). The state moves to `connected`
773
+ * as soon as audio comes up.
774
+ */
775
+ export type GlassesAudioSetupState = {
776
+ state: "idle";
777
+ } | {
778
+ state: "not_applicable";
779
+ } | {
780
+ state: "connecting";
781
+ } | {
782
+ state: "connected";
783
+ } | {
784
+ state: "needs_user";
785
+ action: "connect_in_settings";
786
+ };
787
+ export type AudioSetupChangedEvent = {
788
+ state: GlassesAudioSetupState;
789
+ };
790
+ export type PairingModeEndedReason = "paired" | "cancelled" | "timeout" | string;
791
+ export type PairingModeEndedEvent = {
792
+ reason: PairingModeEndedReason;
793
+ window_id?: number;
731
794
  };
732
795
  export type PairingInfoEvent = {
733
796
  had_previous_bond: boolean;
@@ -735,11 +798,25 @@ export type PairingInfoEvent = {
735
798
  classic_bond_ready?: boolean;
736
799
  secure_pairing_capable?: boolean;
737
800
  protocol_version?: number;
801
+ /** Present when the glasses build includes ownership transfer. */
802
+ ownership_transfer?: boolean;
803
+ /** True when the glasses derive Classic audio from the LE pair (CTKD), so audio should connect on its own. */
804
+ auto_audio?: boolean;
805
+ /** True only after a BLE bond committed the owner and the window closed. */
806
+ owner_committed?: boolean;
738
807
  };
739
808
  export type EnteringPairingModeEvent = {
740
809
  window_ms: number;
810
+ /** Identifies this pairing window. Also carried in `pairing_mode_ended` and the advertisement. */
811
+ window_id?: number;
741
812
  reason?: string;
813
+ /** The glasses kept their bonds when the window opened; they change only if another phone pairs. */
814
+ owner_retained?: boolean;
742
815
  };
816
+ /**
817
+ * @deprecated No longer emitted. Observe `pairing_state_changed` (or `GlassesStatus.pairingState`)
818
+ * and render Pair Again for `pairing_rejected`.
819
+ */
743
820
  export type OwnerReplacedEvent = {
744
821
  reason: string;
745
822
  };
@@ -1000,6 +1077,10 @@ export type BluetoothSdkModuleEvents = {
1000
1077
  pair_failure: (event: PairFailureEvent) => void;
1001
1078
  pairing_info: (event: PairingInfoEvent) => void;
1002
1079
  entering_pairing_mode: (event: EnteringPairingModeEvent) => void;
1080
+ pairing_mode_ended: (event: PairingModeEndedEvent) => void;
1081
+ pairing_state_changed: (event: PairingStateChangedEvent) => void;
1082
+ audio_setup_changed: (event: AudioSetupChangedEvent) => void;
1083
+ /** @deprecated No longer emitted. Use `pairing_state_changed`. */
1003
1084
  owner_replaced: (event: OwnerReplacedEvent) => void;
1004
1085
  audio_pairing_needed: (event: AudioPairingNeededEvent) => void;
1005
1086
  audio_connected: (event: AudioConnectedEvent) => void;
@@ -1140,6 +1221,10 @@ export type BluetoothSdkEventMap = {
1140
1221
  pair_failure: PairFailureEvent;
1141
1222
  pairing_info: PairingInfoEvent;
1142
1223
  entering_pairing_mode: EnteringPairingModeEvent;
1224
+ pairing_mode_ended: PairingModeEndedEvent;
1225
+ pairing_state_changed: PairingStateChangedEvent;
1226
+ audio_setup_changed: AudioSetupChangedEvent;
1227
+ /** @deprecated No longer emitted. Use `pairing_state_changed`. */
1143
1228
  owner_replaced: OwnerReplacedEvent;
1144
1229
  audio_pairing_needed: AudioPairingNeededEvent;
1145
1230
  audio_connected: AudioConnectedEvent;
@@ -1357,6 +1442,10 @@ export interface GlassesStatus {
1357
1442
  micEnabled: boolean;
1358
1443
  voiceActivityDetectionEnabled: boolean;
1359
1444
  bluetoothClassicConnected: boolean;
1445
+ /** iOS: an HFP input is available or a matching Bluetooth output is active. Android: same as `bluetoothClassicConnected`. */
1446
+ bluetoothClassicProfilesConnected: boolean;
1447
+ /** iOS: a matching Bluetooth output is the active audio route. Android: same as `bluetoothClassicConnected`. */
1448
+ bluetoothAudioRouted: boolean;
1360
1449
  signalStrength: number;
1361
1450
  /** Milliseconds since epoch when signalStrength was last refreshed by the phone BLE stack. */
1362
1451
  signalStrengthUpdatedAt: number;
@@ -1370,6 +1459,10 @@ export interface GlassesStatus {
1370
1459
  leftMacAddress: string;
1371
1460
  rightMacAddress: string;
1372
1461
  buildNumber: string;
1462
+ /** Mentra Live pairing state owned by the SDK. Absent on other models. */
1463
+ pairingState?: MentraLivePairingState;
1464
+ /** Audio setup state owned by the SDK, on every model. See `GlassesAudioSetupState`. */
1465
+ audioSetup?: GlassesAudioSetupState;
1373
1466
  /** Glasses System.currentTimeMillis() from last version_info (clock skew detection). */
1374
1467
  systemTimeMs?: number;
1375
1468
  otaVersionUrl: string;
@@ -1454,6 +1547,14 @@ export interface ConnectOptions {
1454
1547
  * Android accepts this option as a no-op.
1455
1548
  */
1456
1549
  requiresAncs?: boolean;
1550
+ /**
1551
+ * iOS Mentra Live only. When true, CoreBluetooth is asked to bring up the
1552
+ * Classic profiles (A2DP/HFP) after the BLE connection
1553
+ * (CBConnectPeripheralOptionEnableTransportBridgingKey). Defaults to true:
1554
+ * without it the pairing screen waits for an audio route that never appears.
1555
+ * Android accepts this option as a no-op.
1556
+ */
1557
+ enableTransportBridging?: boolean;
1457
1558
  }
1458
1559
  export type ScanResultsCallback = (devices: Device[]) => void;
1459
1560
  /** Advisory for an empty scan. System connection state does not identify an owning app. */