@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,402 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
/// Mirrors Android `MentraLivePairingStateMachineTest` input for input.
|
|
5
|
+
final class MentraLivePairingStateMachineTests: XCTestCase {
|
|
6
|
+
private typealias Machine = MentraLivePairingStateMachine
|
|
7
|
+
private let t0 = 1_700_000_000_000
|
|
8
|
+
private let device = "MENTRA_LIVE_BLE_ABCD"
|
|
9
|
+
|
|
10
|
+
@discardableResult
|
|
11
|
+
private func enter(
|
|
12
|
+
_ machine: inout Machine,
|
|
13
|
+
windowId: Int? = 7,
|
|
14
|
+
windowMs: Int? = 120_000,
|
|
15
|
+
ownerRetained: Bool = true,
|
|
16
|
+
explicit: Bool = false,
|
|
17
|
+
now: Int? = nil
|
|
18
|
+
) -> MentraLivePairingTransition {
|
|
19
|
+
machine.onEnteringPairingMode(
|
|
20
|
+
windowMs: windowMs,
|
|
21
|
+
windowId: windowId,
|
|
22
|
+
ownerRetained: ownerRetained,
|
|
23
|
+
explicitAttempt: explicit,
|
|
24
|
+
deviceId: device,
|
|
25
|
+
nowMs: now ?? t0
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private func pausedMachine(windowId: Int = 7) -> Machine {
|
|
30
|
+
var machine = Machine()
|
|
31
|
+
enter(&machine, windowId: windowId)
|
|
32
|
+
return machine
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private func reject(_ machine: inout Machine, _ now: Int, explicit: Bool = false) -> MentraLivePairingTransition {
|
|
36
|
+
machine.onAuthRejection(secureFirmware: true, explicitAttempt: explicit, nowMs: now)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func testV4NoticePausesForTheWindowPlusMarginWithoutA300sClamp() {
|
|
40
|
+
var machine = Machine()
|
|
41
|
+
XCTAssertEqual(enter(&machine).effect, .pause)
|
|
42
|
+
XCTAssertEqual(machine.state, .pairingMode(windowId: 7, endsAtMs: t0 + 150_000))
|
|
43
|
+
XCTAssertFalse(machine.allowsAutomaticReconnect)
|
|
44
|
+
XCTAssertEqual(machine.remainingPauseMs(nowMs: t0), 150_000)
|
|
45
|
+
|
|
46
|
+
var long = Machine()
|
|
47
|
+
enter(&long, windowMs: 900_000)
|
|
48
|
+
XCTAssertEqual(long.state, .pairingMode(windowId: 7, endsAtMs: t0 + 930_000))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func testPublicStateMapsMatchTheTypeScriptContract() {
|
|
52
|
+
XCTAssertEqual(MentraLivePairingState.normal.toMap() as NSDictionary, ["state": "normal"])
|
|
53
|
+
XCTAssertEqual(MentraLivePairingState.rejected.toMap() as NSDictionary, ["state": "pairing_rejected"])
|
|
54
|
+
XCTAssertEqual(
|
|
55
|
+
MentraLivePairingState.pairingMode(windowId: 7, endsAtMs: t0).toMap() as NSDictionary,
|
|
56
|
+
["state": "pairing_mode", "windowId": 7, "endsAtMs": t0]
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func testADuplicateNoticeForTheSameWindowIsIgnored() {
|
|
61
|
+
var machine = pausedMachine()
|
|
62
|
+
XCTAssertEqual(enter(&machine, now: t0 + 5_000).effect, MentraLivePairingEffect.none)
|
|
63
|
+
XCTAssertEqual(machine.state, .pairingMode(windowId: 7, endsAtMs: t0 + 150_000))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func testAnExplicitAttemptIsNeverPausedByTheNotice() {
|
|
67
|
+
var machine = Machine()
|
|
68
|
+
XCTAssertEqual(enter(&machine, explicit: true).effect, MentraLivePairingEffect.none)
|
|
69
|
+
XCTAssertEqual(machine.state, .normal)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func testFlagOffFirmwareForgetsThisPhoneOnEntry() {
|
|
73
|
+
var machine = Machine()
|
|
74
|
+
XCTAssertEqual(enter(&machine, windowId: nil, ownerRetained: false).effect, .reject)
|
|
75
|
+
XCTAssertEqual(machine.state, .rejected)
|
|
76
|
+
|
|
77
|
+
var pairing = Machine()
|
|
78
|
+
enter(&pairing, windowId: nil, ownerRetained: false, explicit: true)
|
|
79
|
+
XCTAssertEqual(pairing.state, .normal)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func testPairingModeEndedResumesEarlyForTheMatchingWindowOnly() {
|
|
83
|
+
var machine = pausedMachine()
|
|
84
|
+
XCTAssertEqual(machine.onPairingModeEnded(reason: "timeout", windowId: 8).effect, MentraLivePairingEffect.none)
|
|
85
|
+
XCTAssertTrue(machine.isPaused)
|
|
86
|
+
XCTAssertEqual(machine.onPairingModeEnded(reason: "paired", windowId: 7).effect, .resume)
|
|
87
|
+
XCTAssertEqual(machine.state, .normal)
|
|
88
|
+
// The connected phone was admitted, so there is nothing to verify.
|
|
89
|
+
XCTAssertNil(machine.endedWindowId)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func testAClearedAdvertisementFlagResumesEarlyAfterTheGrace() {
|
|
93
|
+
var machine = pausedMachine()
|
|
94
|
+
// An advertisement from before the window opened can still be in flight.
|
|
95
|
+
let early = machine.onAdvertisement(pairingMode: false, ownerChanged: false, generation: 6, nowMs: t0 + 1_000)
|
|
96
|
+
XCTAssertEqual(early.effect, MentraLivePairingEffect.none)
|
|
97
|
+
XCTAssertTrue(machine.isPaused)
|
|
98
|
+
XCTAssertEqual(
|
|
99
|
+
machine.onAdvertisement(pairingMode: true, ownerChanged: false, generation: 7, nowMs: t0 + 20_000).effect,
|
|
100
|
+
MentraLivePairingEffect.none
|
|
101
|
+
)
|
|
102
|
+
let cleared = machine.onAdvertisement(pairingMode: false, ownerChanged: false, generation: 7, nowMs: t0 + 60_000)
|
|
103
|
+
XCTAssertEqual(cleared.effect, .resume)
|
|
104
|
+
XCTAssertEqual(machine.state, .normal)
|
|
105
|
+
XCTAssertEqual(machine.endedWindowId, 7)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func testTheDeadlineResumes() {
|
|
109
|
+
var machine = pausedMachine()
|
|
110
|
+
XCTAssertEqual(machine.onTick(nowMs: t0 + 149_999).effect, MentraLivePairingEffect.none)
|
|
111
|
+
XCTAssertEqual(machine.onTick(nowMs: t0 + 150_000).effect, .resume)
|
|
112
|
+
XCTAssertTrue(machine.allowsAutomaticReconnect)
|
|
113
|
+
XCTAssertTrue(machine.awaitingTakeoverCheck)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func testOwnerChangedForTheEndedWindowRejectsOnTheFirstFailedVerification() {
|
|
117
|
+
var machine = pausedMachine()
|
|
118
|
+
machine.onTick(nowMs: t0 + 150_000)
|
|
119
|
+
machine.onAdvertisement(pairingMode: false, ownerChanged: true, generation: 7, nowMs: t0 + 151_000)
|
|
120
|
+
XCTAssertTrue(machine.verificationArmed)
|
|
121
|
+
XCTAssertFalse(machine.awaitingTakeoverCheck)
|
|
122
|
+
XCTAssertEqual(reject(&machine, t0 + 152_000).effect, .reject)
|
|
123
|
+
XCTAssertEqual(machine.state, .rejected)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func testOwnerChangedCarriedOnTheFlagClearingAdvertisementArmsTheVerification() {
|
|
127
|
+
var machine = pausedMachine()
|
|
128
|
+
let cleared = machine.onAdvertisement(pairingMode: false, ownerChanged: true, generation: 7, nowMs: t0 + 60_000)
|
|
129
|
+
XCTAssertEqual(cleared.effect, .resume)
|
|
130
|
+
XCTAssertTrue(machine.verificationArmed)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func testASpoofedOwnerChangedBitFollowedByASuccessfulReconnectDoesNotStrandThePhone() {
|
|
134
|
+
var machine = pausedMachine()
|
|
135
|
+
machine.onTick(nowMs: t0 + 150_000)
|
|
136
|
+
machine.onAdvertisement(pairingMode: false, ownerChanged: true, generation: 7, nowMs: t0 + 151_000)
|
|
137
|
+
XCTAssertTrue(machine.verificationArmed)
|
|
138
|
+
machine.onSessionReady(explicitAttempt: false)
|
|
139
|
+
XCTAssertEqual(machine.state, .normal)
|
|
140
|
+
XCTAssertFalse(machine.verificationArmed)
|
|
141
|
+
// The same bit again arms nothing: exactly one verification per window.
|
|
142
|
+
machine.onAdvertisement(pairingMode: false, ownerChanged: true, generation: 7, nowMs: t0 + 200_000)
|
|
143
|
+
XCTAssertFalse(machine.verificationArmed)
|
|
144
|
+
XCTAssertEqual(reject(&machine, t0 + 201_000).effect, MentraLivePairingEffect.none)
|
|
145
|
+
XCTAssertEqual(machine.state, .normal)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func testOwnerChangedForAnotherWindowOrStillInPairingModeArmsNothing() {
|
|
149
|
+
var other = pausedMachine()
|
|
150
|
+
other.onTick(nowMs: t0 + 150_000)
|
|
151
|
+
other.onAdvertisement(pairingMode: false, ownerChanged: true, generation: 9, nowMs: t0 + 151_000)
|
|
152
|
+
XCTAssertFalse(other.verificationArmed)
|
|
153
|
+
|
|
154
|
+
var stillPairing = pausedMachine()
|
|
155
|
+
stillPairing.onAdvertisement(pairingMode: true, ownerChanged: true, generation: 7, nowMs: t0 + 60_000)
|
|
156
|
+
XCTAssertFalse(stillPairing.verificationArmed)
|
|
157
|
+
|
|
158
|
+
// Without a window this phone paused for, the bit is not this phone's business.
|
|
159
|
+
var idle = Machine()
|
|
160
|
+
idle.onAdvertisement(pairingMode: false, ownerChanged: true, generation: 7, nowMs: t0)
|
|
161
|
+
XCTAssertFalse(idle.verificationArmed)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func testAVerificationThatFailsWithoutAnAuthVerdictFallsBackToTheStreak() {
|
|
165
|
+
var machine = pausedMachine()
|
|
166
|
+
machine.onTick(nowMs: t0 + 150_000)
|
|
167
|
+
machine.onAdvertisement(pairingMode: false, ownerChanged: true, generation: 7, nowMs: t0 + 151_000)
|
|
168
|
+
machine.onConnectAttemptFailed()
|
|
169
|
+
XCTAssertFalse(machine.verificationArmed)
|
|
170
|
+
XCTAssertEqual(reject(&machine, t0 + 160_000).effect, MentraLivePairingEffect.none)
|
|
171
|
+
XCTAssertEqual(machine.state, .normal)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func testWithoutTheBitThreeRejectionsSpanningTheMinimumReject() {
|
|
175
|
+
var machine = Machine()
|
|
176
|
+
XCTAssertEqual(reject(&machine, t0).effect, MentraLivePairingEffect.none)
|
|
177
|
+
XCTAssertEqual(reject(&machine, t0 + 20_000).effect, MentraLivePairingEffect.none)
|
|
178
|
+
XCTAssertEqual(reject(&machine, t0 + MentraLiveOwnerRejectionStreak.minSpanMs).effect, .reject)
|
|
179
|
+
XCTAssertEqual(machine.state, .rejected)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func testRejectionsDuringThePauseOrWhileThePairingAdvertIsFreshNeverCount() {
|
|
183
|
+
var machine = pausedMachine()
|
|
184
|
+
// The intentional disconnect and every 0x14/auth refusal inside the window.
|
|
185
|
+
var now = t0
|
|
186
|
+
for _ in 0 ..< 6 {
|
|
187
|
+
XCTAssertEqual(reject(&machine, now).effect, MentraLivePairingEffect.none)
|
|
188
|
+
now += 20_000
|
|
189
|
+
}
|
|
190
|
+
XCTAssertTrue(machine.isPaused)
|
|
191
|
+
|
|
192
|
+
var advertising = Machine()
|
|
193
|
+
for i in 0 ..< 6 {
|
|
194
|
+
let at = t0 + i * 20_000
|
|
195
|
+
advertising.onAdvertisement(pairingMode: true, ownerChanged: false, generation: 3, nowMs: at)
|
|
196
|
+
XCTAssertEqual(reject(&advertising, at + 1_000).effect, MentraLivePairingEffect.none)
|
|
197
|
+
}
|
|
198
|
+
XCTAssertEqual(advertising.state, .normal)
|
|
199
|
+
// The streak starts fresh once pairing mode is no longer advertised.
|
|
200
|
+
advertising.onAdvertisement(pairingMode: false, ownerChanged: false, generation: 3, nowMs: t0 + 200_000)
|
|
201
|
+
XCTAssertEqual(reject(&advertising, t0 + 200_500).effect, MentraLivePairingEffect.none)
|
|
202
|
+
XCTAssertEqual(advertising.state, .normal)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
func testLegacyFirmwareAndExplicitAttemptsNeverCount() {
|
|
206
|
+
var machine = Machine()
|
|
207
|
+
var now = t0
|
|
208
|
+
for _ in 0 ..< 6 {
|
|
209
|
+
machine.onAuthRejection(secureFirmware: false, explicitAttempt: false, nowMs: now)
|
|
210
|
+
_ = reject(&machine, now + 1, explicit: true)
|
|
211
|
+
now += 20_000
|
|
212
|
+
}
|
|
213
|
+
XCTAssertEqual(machine.state, .normal)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
func testOnlyAnExplicitPairOrAForgetClearsARejection() {
|
|
217
|
+
var machine = Machine()
|
|
218
|
+
enter(&machine, windowId: nil, ownerRetained: false)
|
|
219
|
+
machine.onSessionReady(explicitAttempt: false)
|
|
220
|
+
XCTAssertEqual(machine.state, .rejected)
|
|
221
|
+
machine.onPairingModeEnded(reason: "timeout", windowId: 1)
|
|
222
|
+
machine.onTick(nowMs: t0 + 1_000_000)
|
|
223
|
+
machine.onAdvertisement(pairingMode: false, ownerChanged: false, generation: 1, nowMs: t0 + 1_000_000)
|
|
224
|
+
// A later pairing mode does not lift it either.
|
|
225
|
+
enter(&machine, windowId: nil, ownerRetained: false, now: t0 + 2_000_000)
|
|
226
|
+
XCTAssertEqual(machine.state, .rejected)
|
|
227
|
+
XCTAssertTrue(machine.onSessionReady(explicitAttempt: true).stateChanged)
|
|
228
|
+
XCTAssertEqual(machine.state, .normal)
|
|
229
|
+
|
|
230
|
+
var forgotten = Machine()
|
|
231
|
+
enter(&forgotten, windowId: nil, ownerRetained: false)
|
|
232
|
+
forgotten.onForget()
|
|
233
|
+
XCTAssertEqual(forgotten.state, .normal)
|
|
234
|
+
XCTAssertEqual(forgotten.deviceId, "")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
func testAPauseSurvivesARestartWithTheSameWindowId() {
|
|
238
|
+
let persisted = pausedMachine(windowId: 42).persisted()
|
|
239
|
+
var restored = Machine()
|
|
240
|
+
XCTAssertEqual(restored.restore(values: persisted, savedDeviceId: device, nowMs: t0 + 30_000).effect, .pause)
|
|
241
|
+
XCTAssertEqual(restored.state, .pairingMode(windowId: 42, endsAtMs: t0 + 150_000))
|
|
242
|
+
XCTAssertEqual(restored.remainingPauseMs(nowMs: t0 + 30_000), 120_000)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
func testAPauseThatEndedWhileTheAppWasAwayStillVerifiesTheTakeover() {
|
|
246
|
+
let persisted = pausedMachine(windowId: 42).persisted()
|
|
247
|
+
var restored = Machine()
|
|
248
|
+
let transition = restored.restore(values: persisted, savedDeviceId: device, nowMs: t0 + 400_000)
|
|
249
|
+
XCTAssertEqual(transition.effect, MentraLivePairingEffect.none)
|
|
250
|
+
XCTAssertEqual(restored.state, .normal)
|
|
251
|
+
XCTAssertEqual(restored.endedWindowId, 42)
|
|
252
|
+
restored.onAdvertisement(pairingMode: false, ownerChanged: true, generation: 42, nowMs: t0 + 401_000)
|
|
253
|
+
XCTAssertTrue(restored.verificationArmed)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
func testAnArmedVerificationIsReArmedAfterARestart() {
|
|
257
|
+
var machine = pausedMachine(windowId: 42)
|
|
258
|
+
machine.onTick(nowMs: t0 + 150_000)
|
|
259
|
+
machine.onAdvertisement(pairingMode: false, ownerChanged: true, generation: 42, nowMs: t0 + 151_000)
|
|
260
|
+
XCTAssertTrue(machine.verificationArmed)
|
|
261
|
+
var restored = Machine()
|
|
262
|
+
restored.restore(values: machine.persisted(), savedDeviceId: device, nowMs: t0 + 160_000)
|
|
263
|
+
XCTAssertFalse(restored.verificationArmed)
|
|
264
|
+
restored.onAdvertisement(pairingMode: false, ownerChanged: true, generation: 42, nowMs: t0 + 161_000)
|
|
265
|
+
XCTAssertTrue(restored.verificationArmed)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
func testAnImplausibleRestoredDeadlineDoesNotPause() {
|
|
269
|
+
var persisted = pausedMachine().persisted()
|
|
270
|
+
persisted[Machine.keyEndsAtMs] = t0 + 10_000_000
|
|
271
|
+
var restored = Machine()
|
|
272
|
+
restored.restore(values: persisted, savedDeviceId: device, nowMs: t0)
|
|
273
|
+
XCTAssertEqual(restored.state, .normal)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
func testARejectionSurvivesARestart() {
|
|
277
|
+
var machine = Machine()
|
|
278
|
+
enter(&machine, windowId: nil, ownerRetained: false)
|
|
279
|
+
var restored = Machine()
|
|
280
|
+
let transition = restored.restore(values: machine.persisted(), savedDeviceId: device, nowMs: t0 + 86_400_000)
|
|
281
|
+
XCTAssertEqual(transition.effect, .reject)
|
|
282
|
+
XCTAssertEqual(restored.state, .rejected)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
func testRestoreIgnoresUnknownKeysAndMalformedValues() {
|
|
286
|
+
let values: [String: Any] = [
|
|
287
|
+
Machine.keyDeviceId: device,
|
|
288
|
+
Machine.keyState: "pairing_mode",
|
|
289
|
+
Machine.keyWindowId: "not a number",
|
|
290
|
+
Machine.keyEndsAtMs: t0 + 60_000,
|
|
291
|
+
"pairing_v9_future_key": "whatever",
|
|
292
|
+
"unrelated": 12,
|
|
293
|
+
]
|
|
294
|
+
var restored = Machine()
|
|
295
|
+
restored.restore(values: values, savedDeviceId: device, nowMs: t0)
|
|
296
|
+
XCTAssertEqual(restored.state, .normal)
|
|
297
|
+
|
|
298
|
+
var future = Machine()
|
|
299
|
+
future.restore(values: [Machine.keyState: "pairing_v9_state"], savedDeviceId: device, nowMs: t0)
|
|
300
|
+
XCTAssertEqual(future.state, .normal)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
func testARecordForOtherGlassesDoesNotApply() {
|
|
304
|
+
let persisted = pausedMachine().persisted()
|
|
305
|
+
var restored = Machine()
|
|
306
|
+
restored.restore(values: persisted, savedDeviceId: "MENTRA_LIVE_BLE_OTHER", nowMs: t0 + 1_000)
|
|
307
|
+
XCTAssertEqual(restored.state, .normal)
|
|
308
|
+
XCTAssertEqual(restored.deviceId, "MENTRA_LIVE_BLE_OTHER")
|
|
309
|
+
|
|
310
|
+
var bound = Machine()
|
|
311
|
+
enter(&bound, windowId: nil, ownerRetained: false)
|
|
312
|
+
XCTAssertTrue(bound.bindDevice("MENTRA_LIVE_BLE_OTHER").stateChanged)
|
|
313
|
+
XCTAssertEqual(bound.state, .normal)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
func testTheLegacyStandDownPhaseMigratesToRejected() {
|
|
317
|
+
var restored = Machine()
|
|
318
|
+
let transition = restored.restore(
|
|
319
|
+
values: [Machine.legacyRejectionPhaseKey: "waiting"],
|
|
320
|
+
savedDeviceId: device,
|
|
321
|
+
nowMs: t0
|
|
322
|
+
)
|
|
323
|
+
XCTAssertEqual(transition.effect, .reject)
|
|
324
|
+
XCTAssertEqual(restored.state, .rejected)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/// The persisted record through real UserDefaults, as a process restart sees it.
|
|
329
|
+
@MainActor
|
|
330
|
+
final class MentraLivePairingStateStoreTests: XCTestCase {
|
|
331
|
+
private var suiteName = ""
|
|
332
|
+
private var defaults: UserDefaults!
|
|
333
|
+
private let device = "MENTRA_LIVE_BLE_ABCD"
|
|
334
|
+
|
|
335
|
+
override func setUp() async throws {
|
|
336
|
+
suiteName = "MentraLivePairingStateStoreTests.\(UUID().uuidString)"
|
|
337
|
+
defaults = UserDefaults(suiteName: suiteName)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
override func tearDown() async throws {
|
|
341
|
+
defaults.removePersistentDomain(forName: suiteName)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private func restartedStore(nowMs: Int) -> (MentraLivePairingStateStore, MentraLivePairingTransition?) {
|
|
345
|
+
let store = MentraLivePairingStateStore(defaults: defaults, publishes: false)
|
|
346
|
+
let transition = store.ensureRestored(savedDeviceId: device, nowMs: nowMs)
|
|
347
|
+
return (store, transition)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
func testAPauseAndItsWindowIdSurviveARestart() {
|
|
351
|
+
let now = MentraLivePairingStateStore.nowMs()
|
|
352
|
+
let (first, _) = restartedStore(nowMs: now)
|
|
353
|
+
first.update("test") {
|
|
354
|
+
$0.onEnteringPairingMode(
|
|
355
|
+
windowMs: 120_000, windowId: 42, ownerRetained: true, explicitAttempt: false,
|
|
356
|
+
deviceId: self.device, nowMs: now
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let (second, transition) = restartedStore(nowMs: now + 10_000)
|
|
361
|
+
XCTAssertEqual(transition?.effect, .pause)
|
|
362
|
+
XCTAssertEqual(second.state, .pairingMode(windowId: 42, endsAtMs: now + 150_000))
|
|
363
|
+
XCTAssertEqual(second.remainingPauseMs(nowMs: now + 10_000), 140_000)
|
|
364
|
+
// A second restore in the same process does not re-run.
|
|
365
|
+
XCTAssertNil(second.ensureRestored(savedDeviceId: device, nowMs: now + 10_000))
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
func testARejectionSurvivesARestartAndOnlyForgetClearsIt() {
|
|
369
|
+
let now = MentraLivePairingStateStore.nowMs()
|
|
370
|
+
let (first, _) = restartedStore(nowMs: now)
|
|
371
|
+
first.update("test") {
|
|
372
|
+
$0.onEnteringPairingMode(
|
|
373
|
+
windowMs: nil, windowId: nil, ownerRetained: false, explicitAttempt: false,
|
|
374
|
+
deviceId: self.device, nowMs: now
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
let (second, transition) = restartedStore(nowMs: now + 86_400_000)
|
|
378
|
+
XCTAssertEqual(transition?.effect, .reject)
|
|
379
|
+
XCTAssertTrue(second.isRejected)
|
|
380
|
+
second.update("forget") { $0.onForget() }
|
|
381
|
+
let (third, _) = restartedStore(nowMs: now + 86_400_001)
|
|
382
|
+
XCTAssertEqual(third.state, .normal)
|
|
383
|
+
XCTAssertNil(defaults.dictionary(forKey: MentraLivePairingStateStore.defaultsKey))
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
func testRollbackToleranceKeepsUnknownKeysAndMigratesTheLegacyStandDown() {
|
|
387
|
+
defaults.set(["pairing_v9_future_key": "kept"], forKey: MentraLivePairingStateStore.defaultsKey)
|
|
388
|
+
defaults.set("waiting", forKey: MentraLivePairingStateMachine.legacyRejectionPhaseKey)
|
|
389
|
+
defaults.set(5, forKey: "mentra_live_previous_owner_rejection_generation")
|
|
390
|
+
defaults.set(123, forKey: "mentra_live_pairing_yield_until_wall_ms")
|
|
391
|
+
|
|
392
|
+
let (store, transition) = restartedStore(nowMs: MentraLivePairingStateStore.nowMs())
|
|
393
|
+
XCTAssertEqual(transition?.effect, .reject)
|
|
394
|
+
XCTAssertTrue(store.isRejected)
|
|
395
|
+
for key in MentraLivePairingStateMachine.legacyKeys {
|
|
396
|
+
XCTAssertNil(defaults.object(forKey: key), key)
|
|
397
|
+
}
|
|
398
|
+
let record = defaults.dictionary(forKey: MentraLivePairingStateStore.defaultsKey)
|
|
399
|
+
XCTAssertEqual(record?["pairing_v9_future_key"] as? String, "kept")
|
|
400
|
+
XCTAssertEqual(record?[MentraLivePairingStateMachine.keyState] as? String, "pairing_rejected")
|
|
401
|
+
}
|
|
402
|
+
}
|