@mentra/bluetooth-sdk 3.4.0-dev.996 → 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
package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachineTest.kt
ADDED
|
@@ -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. */
|