@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,191 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
/// Table tests for the iOS audio-setup rules (Android reports `not_applicable` while ready).
|
|
5
|
+
final class GlassesAudioSetupPolicyTests: XCTestCase {
|
|
6
|
+
private typealias Policy = GlassesAudioSetupPolicy
|
|
7
|
+
private let t0 = 1_000_000
|
|
8
|
+
|
|
9
|
+
private func inputs(
|
|
10
|
+
ready: Bool = true,
|
|
11
|
+
report: (a2dp: Bool, hfp: Bool)? = nil,
|
|
12
|
+
phone: Bool = false,
|
|
13
|
+
phase: GlassesAudioSetupPhase = .reconnect,
|
|
14
|
+
startedAt: Int? = nil
|
|
15
|
+
) -> GlassesAudioSetupInputs {
|
|
16
|
+
GlassesAudioSetupInputs(
|
|
17
|
+
sessionReady: ready,
|
|
18
|
+
glassesReportsClassicLink: report != nil,
|
|
19
|
+
glassesA2dp: report?.a2dp ?? false,
|
|
20
|
+
glassesHfp: report?.hfp ?? false,
|
|
21
|
+
phoneAudioUp: phone,
|
|
22
|
+
phase: phase,
|
|
23
|
+
phaseStartedAtMs: startedAt ?? t0
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private func state(_ inputs: GlassesAudioSetupInputs, at offsetMs: Int) -> GlassesAudioSetupState {
|
|
28
|
+
Policy.decide(inputs, nowMs: t0 + offsetMs).state
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func testConstantsMatchTheContract() {
|
|
32
|
+
XCTAssertEqual(Policy.pairingAutoAudioWaitMs, 8000)
|
|
33
|
+
XCTAssertEqual(Policy.reconnectAudioGraceMs, 10000)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func testNoSessionIsIdle() {
|
|
37
|
+
XCTAssertEqual(
|
|
38
|
+
Policy.decide(inputs(ready: false, report: (true, true), phone: true), nowMs: t0),
|
|
39
|
+
GlassesAudioSetupDecision(state: .idle, nextDeadlineMs: nil)
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func testPairingWithAutoAudioWaitsEightSecondsThenNeedsTheUser() {
|
|
44
|
+
let pairing = inputs(phase: .pairing(autoAudio: true))
|
|
45
|
+
XCTAssertEqual(
|
|
46
|
+
Policy.decide(pairing, nowMs: t0),
|
|
47
|
+
GlassesAudioSetupDecision(state: .connecting, nextDeadlineMs: t0 + 8000)
|
|
48
|
+
)
|
|
49
|
+
XCTAssertEqual(state(pairing, at: 7999), .connecting)
|
|
50
|
+
XCTAssertEqual(state(pairing, at: 8000), .needsUserConnectInSettings)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func testPairingWithoutAutoAudioNeedsTheUserAtOnce() {
|
|
54
|
+
XCTAssertEqual(
|
|
55
|
+
Policy.decide(inputs(phase: .pairing(autoAudio: false)), nowMs: t0),
|
|
56
|
+
GlassesAudioSetupDecision(state: .needsUserConnectInSettings, nextDeadlineMs: nil)
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func testReconnectWaitsTenSecondsThenNeedsTheUser() {
|
|
61
|
+
let reconnect = inputs(phase: .reconnect)
|
|
62
|
+
XCTAssertEqual(
|
|
63
|
+
Policy.decide(reconnect, nowMs: t0 + 1000),
|
|
64
|
+
GlassesAudioSetupDecision(state: .connecting, nextDeadlineMs: t0 + 10000)
|
|
65
|
+
)
|
|
66
|
+
XCTAssertEqual(state(reconnect, at: 9999), .connecting)
|
|
67
|
+
XCTAssertEqual(state(reconnect, at: 10000), .needsUserConnectInSettings)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func testEitherAudioSourceIsEnoughInEveryPhase() {
|
|
71
|
+
for phase in [GlassesAudioSetupPhase.pairing(autoAudio: true), .pairing(autoAudio: false), .reconnect] {
|
|
72
|
+
for offset in [0, 30000] {
|
|
73
|
+
XCTAssertEqual(state(inputs(phone: true, phase: phase), at: offset), .connected, "phone \(phase)")
|
|
74
|
+
XCTAssertEqual(state(inputs(report: (true, false), phase: phase), at: offset), .connected, "a2dp \(phase)")
|
|
75
|
+
XCTAssertEqual(state(inputs(report: (false, true), phase: phase), at: offset), .connected, "hfp \(phase)")
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
XCTAssertEqual(Policy.decide(inputs(phone: true), nowMs: t0).nextDeadlineMs, nil)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func testAGlassesReportOfBothLinksDownNeverShortensTheWait() {
|
|
82
|
+
let down = inputs(report: (false, false), phase: .pairing(autoAudio: true))
|
|
83
|
+
XCTAssertEqual(state(down, at: 1), .connecting)
|
|
84
|
+
XCTAssertEqual(Policy.decide(down, nowMs: t0 + 1).nextDeadlineMs, t0 + 8000)
|
|
85
|
+
XCTAssertEqual(state(inputs(report: (false, false), phase: .reconnect), at: 9000), .connecting)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func testABothDownReportDoesNotVetoThePhone() {
|
|
89
|
+
XCTAssertEqual(state(inputs(report: (false, false), phone: true), at: 0), .connected)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func testTheGlassesReportArrivingBeforeThePhoneSignals() {
|
|
93
|
+
var tracker = GlassesAudioSetupTracker()
|
|
94
|
+
tracker.startPhase(.pairing(autoAudio: true), nowMs: t0)
|
|
95
|
+
tracker.classicLink(a2dp: true, hfp: true)
|
|
96
|
+
XCTAssertEqual(tracker.evaluate(sessionReady: true, phoneAudioUp: false, nowMs: t0 + 100).state, .connected)
|
|
97
|
+
// The phone's route catches up later: still connected, nothing to re-arm.
|
|
98
|
+
XCTAssertEqual(
|
|
99
|
+
tracker.evaluate(sessionReady: true, phoneAudioUp: true, nowMs: t0 + 900),
|
|
100
|
+
GlassesAudioSetupDecision(state: .connected, nextDeadlineMs: nil)
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func testTheGlassesReportArrivingAfterThePhoneSignals() {
|
|
105
|
+
var tracker = GlassesAudioSetupTracker()
|
|
106
|
+
tracker.startPhase(.reconnect, nowMs: t0)
|
|
107
|
+
XCTAssertEqual(tracker.evaluate(sessionReady: true, phoneAudioUp: true, nowMs: t0 + 100).state, .connected)
|
|
108
|
+
// The glasses then report both down (for example A2DP closing while HFP reconnects).
|
|
109
|
+
// The phone still sees audio, so the state stays connected.
|
|
110
|
+
tracker.classicLink(a2dp: false, hfp: false)
|
|
111
|
+
XCTAssertEqual(tracker.evaluate(sessionReady: true, phoneAudioUp: true, nowMs: t0 + 200).state, .connected)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func testAReportAfterNeedsUserMovesToConnected() {
|
|
115
|
+
var tracker = GlassesAudioSetupTracker()
|
|
116
|
+
tracker.startPhase(.pairing(autoAudio: false), nowMs: t0)
|
|
117
|
+
XCTAssertEqual(tracker.evaluate(sessionReady: true, phoneAudioUp: false, nowMs: t0).state, .needsUserConnectInSettings)
|
|
118
|
+
tracker.classicLink(a2dp: false, hfp: true)
|
|
119
|
+
XCTAssertEqual(tracker.evaluate(sessionReady: true, phoneAudioUp: false, nowMs: t0 + 60000).state, .connected)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func testLosingAudioAfterConnectedStartsTheReconnectWaitAgain() {
|
|
123
|
+
var tracker = GlassesAudioSetupTracker()
|
|
124
|
+
tracker.startPhase(.pairing(autoAudio: false), nowMs: t0)
|
|
125
|
+
XCTAssertEqual(tracker.evaluate(sessionReady: true, phoneAudioUp: true, nowMs: t0 + 500).state, .connected)
|
|
126
|
+
|
|
127
|
+
let lostAt = t0 + 60000
|
|
128
|
+
XCTAssertEqual(
|
|
129
|
+
tracker.evaluate(sessionReady: true, phoneAudioUp: false, nowMs: lostAt),
|
|
130
|
+
GlassesAudioSetupDecision(state: .connecting, nextDeadlineMs: lostAt + 10000)
|
|
131
|
+
)
|
|
132
|
+
XCTAssertEqual(tracker.phase, .reconnect)
|
|
133
|
+
XCTAssertEqual(tracker.phaseStartedAtMs, lostAt)
|
|
134
|
+
XCTAssertEqual(tracker.evaluate(sessionReady: true, phoneAudioUp: false, nowMs: lostAt + 9999).state, .connecting)
|
|
135
|
+
XCTAssertEqual(
|
|
136
|
+
tracker.evaluate(sessionReady: true, phoneAudioUp: false, nowMs: lostAt + 10000).state,
|
|
137
|
+
.needsUserConnectInSettings
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func testTheGlassesReportingAudioDownAfterConnectedStartsTheReconnectWait() {
|
|
142
|
+
var tracker = GlassesAudioSetupTracker()
|
|
143
|
+
tracker.startPhase(.reconnect, nowMs: t0)
|
|
144
|
+
tracker.classicLink(a2dp: true, hfp: true)
|
|
145
|
+
XCTAssertEqual(tracker.evaluate(sessionReady: true, phoneAudioUp: false, nowMs: t0).state, .connected)
|
|
146
|
+
tracker.classicLink(a2dp: false, hfp: false)
|
|
147
|
+
XCTAssertEqual(
|
|
148
|
+
tracker.evaluate(sessionReady: true, phoneAudioUp: false, nowMs: t0 + 30000),
|
|
149
|
+
GlassesAudioSetupDecision(state: .connecting, nextDeadlineMs: t0 + 40000)
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
func testASessionDropIsIdleAndForgetsTheGlassesReport() {
|
|
154
|
+
var tracker = GlassesAudioSetupTracker()
|
|
155
|
+
tracker.startPhase(.reconnect, nowMs: t0)
|
|
156
|
+
tracker.classicLink(a2dp: true, hfp: false)
|
|
157
|
+
XCTAssertEqual(tracker.evaluate(sessionReady: true, phoneAudioUp: false, nowMs: t0).state, .connected)
|
|
158
|
+
|
|
159
|
+
tracker.endSession()
|
|
160
|
+
XCTAssertEqual(
|
|
161
|
+
tracker.evaluate(sessionReady: false, phoneAudioUp: false, nowMs: t0 + 1000),
|
|
162
|
+
GlassesAudioSetupDecision(state: .idle, nextDeadlineMs: nil)
|
|
163
|
+
)
|
|
164
|
+
XCTAssertFalse(tracker.glassesReportsClassicLink)
|
|
165
|
+
|
|
166
|
+
// The next session has not reported its link yet.
|
|
167
|
+
tracker.startPhase(.reconnect, nowMs: t0 + 2000)
|
|
168
|
+
XCTAssertEqual(tracker.evaluate(sessionReady: true, phoneAudioUp: false, nowMs: t0 + 2000).state, .connecting)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func testMapEncodingMatchesTheContract() {
|
|
172
|
+
XCTAssertEqual(GlassesAudioSetupState.idle.toMap() as NSDictionary, ["state": "idle"])
|
|
173
|
+
XCTAssertEqual(GlassesAudioSetupState.notApplicable.toMap() as NSDictionary, ["state": "not_applicable"])
|
|
174
|
+
XCTAssertEqual(GlassesAudioSetupState.connecting.toMap() as NSDictionary, ["state": "connecting"])
|
|
175
|
+
XCTAssertEqual(GlassesAudioSetupState.connected.toMap() as NSDictionary, ["state": "connected"])
|
|
176
|
+
XCTAssertEqual(
|
|
177
|
+
GlassesAudioSetupState.needsUserConnectInSettings.toMap() as NSDictionary,
|
|
178
|
+
["state": "needs_user", "action": "connect_in_settings"]
|
|
179
|
+
)
|
|
180
|
+
for state in [GlassesAudioSetupState.idle, .notApplicable, .connecting, .connected, .needsUserConnectInSettings] {
|
|
181
|
+
XCTAssertEqual(GlassesAudioSetupState(map: state.toMap()), state)
|
|
182
|
+
}
|
|
183
|
+
XCTAssertNil(GlassesAudioSetupState(map: ["state": "unknown"]))
|
|
184
|
+
XCTAssertNil(GlassesAudioSetupState(map: nil))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
func testModelsWithoutADriverRuleAreNotApplicableWhileReady() {
|
|
188
|
+
XCTAssertEqual(GlassesAudioSetupState.modelDefault(ready: true), .notApplicable)
|
|
189
|
+
XCTAssertEqual(GlassesAudioSetupState.modelDefault(ready: false), .idle)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
@MainActor
|
|
5
|
+
private final class AudioSetupTransport: MentraLive {
|
|
6
|
+
var commands: [[String: Any]] = []
|
|
7
|
+
|
|
8
|
+
override func sendJson(_ jsonOriginal: [String: Any], wakeUp _: Bool, requireAck _: Bool) {
|
|
9
|
+
commands.append(jsonOriginal)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// `GlassesStatus.audioSetup` wiring: the Mentra Live driver on iOS and the shared default for
|
|
14
|
+
/// every other model.
|
|
15
|
+
@MainActor
|
|
16
|
+
final class MentraLiveAudioSetupTests: XCTestCase {
|
|
17
|
+
private let phoneAudioKeys = ["bluetoothClassicConnected", "bluetoothClassicProfilesConnected", "bluetoothAudioRouted"]
|
|
18
|
+
|
|
19
|
+
private func withTransport(
|
|
20
|
+
_ body: (AudioSetupTransport, ObservableStore, _ clock: (Int) -> Void) throws -> Void
|
|
21
|
+
) rethrows {
|
|
22
|
+
let store = DeviceStore.shared.store
|
|
23
|
+
let saved = ["bluetooth", "glasses"].map { ($0, store.getCategory($0)) }
|
|
24
|
+
let previous = DeviceManager.shared.sgc
|
|
25
|
+
UserDefaults.standard.removeObject(forKey: MentraLivePairingStateStore.defaultsKey)
|
|
26
|
+
MentraLivePairingStateStore.shared.resetForTest()
|
|
27
|
+
store.set("glasses", "fullyBooted", false)
|
|
28
|
+
for key in phoneAudioKeys {
|
|
29
|
+
store.set("glasses", key, false)
|
|
30
|
+
}
|
|
31
|
+
store.set("bluetooth", "pending_device_name", "")
|
|
32
|
+
store.set("bluetooth", "pending_device_address", "")
|
|
33
|
+
store.set("bluetooth", "pending_device_secure_pairing_capable", "")
|
|
34
|
+
store.set("glasses", "audioSetup", GlassesAudioSetupState.idle.toMap())
|
|
35
|
+
let transport = AudioSetupTransport()
|
|
36
|
+
DeviceManager.shared.sgc = transport
|
|
37
|
+
var nowMs = 1_000_000
|
|
38
|
+
transport.audioSetupNowMs = { nowMs }
|
|
39
|
+
defer {
|
|
40
|
+
transport.cleanup()
|
|
41
|
+
DeviceManager.shared.sgc = previous
|
|
42
|
+
UserDefaults.standard.removeObject(forKey: MentraLivePairingStateStore.defaultsKey)
|
|
43
|
+
MentraLivePairingStateStore.shared.resetForTest()
|
|
44
|
+
for (category, values) in saved {
|
|
45
|
+
for key in store.getCategory(category).keys where values[key] == nil {
|
|
46
|
+
store.remove(category, key)
|
|
47
|
+
}
|
|
48
|
+
for (key, value) in values {
|
|
49
|
+
store.set(category, key, value)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
try body(transport, store) { nowMs = $0 }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private func receive(_ transport: MentraLive, _ value: [String: Any]) throws {
|
|
57
|
+
try transport.processReceivedData(JSONSerialization.data(withJSONObject: value))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private func audioSetup(_ store: ObservableStore) -> GlassesAudioSetupState? {
|
|
61
|
+
GlassesAudioSetupState(map: store.get("glasses", "audioSetup"))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// The audioSetup visible at the moment `fullyBooted` flips to true.
|
|
65
|
+
private func audioSetupWhenReady(_ store: ObservableStore, _ action: () throws -> Void) rethrows -> GlassesAudioSetupState? {
|
|
66
|
+
var seen: GlassesAudioSetupState?
|
|
67
|
+
let listener = store.addListener { category, changes in
|
|
68
|
+
guard category == "glasses", changes["fullyBooted"] as? Bool == true else { return }
|
|
69
|
+
seen = GlassesAudioSetupState(map: store.get("glasses", "audioSetup"))
|
|
70
|
+
}
|
|
71
|
+
defer { store.removeListener(listener) }
|
|
72
|
+
try action()
|
|
73
|
+
return seen
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func testAReconnectReportsConnectingWithReadinessThenFollowsTheGlassesReport() throws {
|
|
77
|
+
try withTransport { transport, store, clock in
|
|
78
|
+
var events: [[String: Any]] = []
|
|
79
|
+
let sink = Bridge.addEventSink { event, body in
|
|
80
|
+
if event == "audio_setup_changed", let state = body["state"] as? [String: Any] { events.append(state) }
|
|
81
|
+
}
|
|
82
|
+
defer { Bridge.removeEventSink(sink) }
|
|
83
|
+
|
|
84
|
+
clock(1_000_000)
|
|
85
|
+
let atReady = try audioSetupWhenReady(store) {
|
|
86
|
+
try receive(transport, ["type": "glasses_ready", "sid": "reconnect"])
|
|
87
|
+
}
|
|
88
|
+
XCTAssertEqual(atReady, .connecting, "the ready update never carries a stale idle")
|
|
89
|
+
XCTAssertEqual(events.last as NSDictionary?, ["state": "connecting"])
|
|
90
|
+
|
|
91
|
+
clock(1_002_000)
|
|
92
|
+
try receive(transport, ["type": "classic_link", "a2dp": true, "hfp": false])
|
|
93
|
+
XCTAssertEqual(audioSetup(store), .connected)
|
|
94
|
+
XCTAssertEqual(events.last as NSDictionary?, ["state": "connected"])
|
|
95
|
+
let eventCount = events.count
|
|
96
|
+
|
|
97
|
+
// The same report again is not a change.
|
|
98
|
+
try receive(transport, ["type": "classic_link", "a2dp": true, "hfp": false])
|
|
99
|
+
XCTAssertEqual(events.count, eventCount)
|
|
100
|
+
|
|
101
|
+
// Audio lost after connected: the reconnect wait starts again from now.
|
|
102
|
+
clock(1_060_000)
|
|
103
|
+
try receive(transport, ["type": "classic_link", "a2dp": false, "hfp": false])
|
|
104
|
+
XCTAssertEqual(audioSetup(store), .connecting)
|
|
105
|
+
clock(1_069_999)
|
|
106
|
+
try receive(transport, ["type": "classic_link", "a2dp": false, "hfp": false])
|
|
107
|
+
XCTAssertEqual(audioSetup(store), .connecting)
|
|
108
|
+
|
|
109
|
+
// The deadline timer gives up at the end of the wait.
|
|
110
|
+
clock(1_069_950)
|
|
111
|
+
try receive(transport, ["type": "classic_link", "a2dp": false, "hfp": false])
|
|
112
|
+
clock(1_070_000)
|
|
113
|
+
let deadline = Date().addingTimeInterval(2)
|
|
114
|
+
while audioSetup(store) != .needsUserConnectInSettings, Date() < deadline {
|
|
115
|
+
RunLoop.main.run(until: Date().addingTimeInterval(0.01))
|
|
116
|
+
}
|
|
117
|
+
XCTAssertEqual(audioSetup(store), .needsUserConnectInSettings)
|
|
118
|
+
XCTAssertEqual(events.last as NSDictionary?, ["state": "needs_user", "action": "connect_in_settings"])
|
|
119
|
+
|
|
120
|
+
// The phone's own signals are enough on their own.
|
|
121
|
+
store.set("glasses", "bluetoothAudioRouted", true)
|
|
122
|
+
XCTAssertEqual(audioSetup(store), .connected)
|
|
123
|
+
|
|
124
|
+
// Session drop.
|
|
125
|
+
transport.cleanup()
|
|
126
|
+
XCTAssertEqual(audioSetup(store), .idle)
|
|
127
|
+
XCTAssertEqual(events.last as NSDictionary?, ["state": "idle"])
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func testAnExplicitPairOnOldFirmwareNeedsTheUserInTheReadyUpdate() throws {
|
|
132
|
+
try withTransport { transport, store, _ in
|
|
133
|
+
store.set("bluetooth", "pending_device_name", "MENTRA_LIVE_BLE_ABCD")
|
|
134
|
+
store.set("bluetooth", "pending_device_secure_pairing_capable", false)
|
|
135
|
+
let atReady = try audioSetupWhenReady(store) {
|
|
136
|
+
try receive(transport, ["type": "glasses_ready", "sid": "legacy"])
|
|
137
|
+
}
|
|
138
|
+
XCTAssertEqual(atReady, .needsUserConnectInSettings)
|
|
139
|
+
XCTAssertEqual(audioSetup(store), .needsUserConnectInSettings)
|
|
140
|
+
|
|
141
|
+
store.set("glasses", "bluetoothClassicConnected", true)
|
|
142
|
+
XCTAssertEqual(audioSetup(store), .connected)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func testAnExplicitSecurePairWithAutoAudioWaitsForAudio() throws {
|
|
147
|
+
try withTransport { transport, store, clock in
|
|
148
|
+
store.set("bluetooth", "pending_device_name", "MENTRA_LIVE_BLE_ABCD")
|
|
149
|
+
store.set("bluetooth", "pending_device_secure_pairing_capable", true)
|
|
150
|
+
clock(2_000_000)
|
|
151
|
+
try receive(transport, ["type": "glasses_ready", "sid": "secure"])
|
|
152
|
+
XCTAssertEqual(audioSetup(store), .idle, "not ready before the owner commit")
|
|
153
|
+
let atReady = try audioSetupWhenReady(store) {
|
|
154
|
+
try receive(transport, [
|
|
155
|
+
"type": "pairing_info", "secure_pairing_capable": true, "owner_committed": true,
|
|
156
|
+
"auto_audio": true, "protocol_version": 4,
|
|
157
|
+
])
|
|
158
|
+
}
|
|
159
|
+
XCTAssertEqual(atReady, .connecting)
|
|
160
|
+
// A both-down report does not shorten the 8 s wait.
|
|
161
|
+
clock(2_001_000)
|
|
162
|
+
try receive(transport, ["type": "classic_link", "a2dp": false, "hfp": false])
|
|
163
|
+
XCTAssertEqual(audioSetup(store), .connecting)
|
|
164
|
+
clock(2_008_000)
|
|
165
|
+
try receive(transport, ["type": "classic_link", "a2dp": false, "hfp": false])
|
|
166
|
+
XCTAssertEqual(audioSetup(store), .needsUserConnectInSettings)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func testReadinessLostIsIdle() throws {
|
|
171
|
+
try withTransport { transport, store, _ in
|
|
172
|
+
try receive(transport, ["type": "glasses_ready", "sid": "first"])
|
|
173
|
+
XCTAssertEqual(audioSetup(store), .connecting)
|
|
174
|
+
// sr_hrt ready=0 and ASG restarts clear readiness on a live link.
|
|
175
|
+
DeviceStore.shared.set("glasses", "fullyBooted", false)
|
|
176
|
+
XCTAssertEqual(audioSetup(store), .idle)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
func testModelsWithoutADriverRuleAreNotApplicableInTheReadyUpdate() {
|
|
181
|
+
let store = DeviceStore.shared.store
|
|
182
|
+
let saved = store.getCategory("glasses")
|
|
183
|
+
let previous = DeviceManager.shared.sgc
|
|
184
|
+
DeviceManager.shared.sgc = nil
|
|
185
|
+
defer {
|
|
186
|
+
DeviceManager.shared.sgc = previous
|
|
187
|
+
for key in store.getCategory("glasses").keys where saved[key] == nil {
|
|
188
|
+
store.remove("glasses", key)
|
|
189
|
+
}
|
|
190
|
+
for (key, value) in saved {
|
|
191
|
+
store.set("glasses", key, value)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
store.set("glasses", "fullyBooted", false)
|
|
195
|
+
store.set("glasses", "audioSetup", GlassesAudioSetupState.idle.toMap())
|
|
196
|
+
var events: [[String: Any]] = []
|
|
197
|
+
let sink = Bridge.addEventSink { event, body in
|
|
198
|
+
if event == "audio_setup_changed", let state = body["state"] as? [String: Any] { events.append(state) }
|
|
199
|
+
}
|
|
200
|
+
defer { Bridge.removeEventSink(sink) }
|
|
201
|
+
|
|
202
|
+
let atReady = audioSetupWhenReady(store) {
|
|
203
|
+
DeviceStore.shared.apply("glasses", "fullyBooted", true)
|
|
204
|
+
}
|
|
205
|
+
XCTAssertEqual(atReady, .notApplicable)
|
|
206
|
+
XCTAssertEqual(events as [NSDictionary], [["state": "not_applicable"]])
|
|
207
|
+
let snapshot = GlassesStatus(values: store.getCategory("glasses"))
|
|
208
|
+
XCTAssertEqual(snapshot.audioSetup as NSDictionary?, ["state": "not_applicable"])
|
|
209
|
+
XCTAssertEqual(snapshot.dictionary["audioSetup"] as? NSDictionary, ["state": "not_applicable"])
|
|
210
|
+
|
|
211
|
+
DeviceStore.shared.apply("glasses", "fullyBooted", false)
|
|
212
|
+
XCTAssertEqual(audioSetup(store), .idle)
|
|
213
|
+
XCTAssertEqual(events.count, 2)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -11,20 +11,75 @@ final class MentraLiveConnectionOptionsTests: XCTestCase {
|
|
|
11
11
|
XCTAssertFalse(ConnectOptions(requiresAncs: false).requiresAncs)
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
func testConnectOptionsEnableTransportBridgingByDefault() {
|
|
15
|
+
XCTAssertTrue(ConnectOptions().enableTransportBridging)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func testConnectOptionsCanEnableTransportBridging() {
|
|
19
|
+
XCTAssertTrue(ConnectOptions(enableTransportBridging: true).enableTransportBridging)
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
#if os(macOS)
|
|
15
23
|
func testCoreBluetoothOptionsOmitAncsOnMacOS() {
|
|
16
24
|
XCTAssertNil(MentraLiveConnectionOptions.coreBluetoothOptions(requiresAncs: true))
|
|
17
25
|
}
|
|
26
|
+
|
|
27
|
+
func testCoreBluetoothOptionsOmitTransportBridgingOnMacOS() {
|
|
28
|
+
XCTAssertNil(MentraLiveConnectionOptions.coreBluetoothOptions(
|
|
29
|
+
requiresAncs: true,
|
|
30
|
+
enableTransportBridging: true
|
|
31
|
+
))
|
|
32
|
+
}
|
|
18
33
|
#else
|
|
19
34
|
func testCoreBluetoothOptionsRequireAncsWhenEnabled() {
|
|
20
35
|
let options = MentraLiveConnectionOptions.coreBluetoothOptions(requiresAncs: true)
|
|
21
36
|
|
|
37
|
+
XCTAssertEqual(options?.count, 1)
|
|
38
|
+
XCTAssertEqual(options?[CBConnectPeripheralOptionRequiresANCS] as? Bool, true)
|
|
39
|
+
XCTAssertNil(options?[CBConnectPeripheralOptionEnableTransportBridgingKey])
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func testCoreBluetoothOptionsKeepAncsWhenTransportBridgingIsExplicitlyOff() {
|
|
43
|
+
let options = MentraLiveConnectionOptions.coreBluetoothOptions(
|
|
44
|
+
requiresAncs: true,
|
|
45
|
+
enableTransportBridging: false
|
|
46
|
+
)
|
|
47
|
+
|
|
22
48
|
XCTAssertEqual(options?.count, 1)
|
|
23
49
|
XCTAssertEqual(options?[CBConnectPeripheralOptionRequiresANCS] as? Bool, true)
|
|
24
50
|
}
|
|
51
|
+
|
|
52
|
+
func testCoreBluetoothOptionsContainBothWhenAncsAndTransportBridgingAreEnabled() {
|
|
53
|
+
let options = MentraLiveConnectionOptions.coreBluetoothOptions(
|
|
54
|
+
requiresAncs: true,
|
|
55
|
+
enableTransportBridging: true
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
XCTAssertEqual(options?.count, 2)
|
|
59
|
+
XCTAssertEqual(options?[CBConnectPeripheralOptionRequiresANCS] as? Bool, true)
|
|
60
|
+
XCTAssertEqual(options?[CBConnectPeripheralOptionEnableTransportBridgingKey] as? Bool, true)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func testCoreBluetoothOptionsContainOnlyTransportBridgingWhenAncsIsDisabled() {
|
|
64
|
+
let options = MentraLiveConnectionOptions.coreBluetoothOptions(
|
|
65
|
+
requiresAncs: false,
|
|
66
|
+
enableTransportBridging: true
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
XCTAssertEqual(options?.count, 1)
|
|
70
|
+
XCTAssertEqual(options?[CBConnectPeripheralOptionEnableTransportBridgingKey] as? Bool, true)
|
|
71
|
+
XCTAssertNil(options?[CBConnectPeripheralOptionRequiresANCS])
|
|
72
|
+
}
|
|
25
73
|
#endif
|
|
26
74
|
|
|
27
75
|
func testCoreBluetoothOptionsAreOmittedWhenAncsIsDisabled() {
|
|
28
76
|
XCTAssertNil(MentraLiveConnectionOptions.coreBluetoothOptions(requiresAncs: false))
|
|
29
77
|
}
|
|
78
|
+
|
|
79
|
+
func testCoreBluetoothOptionsAreOmittedWhenNeitherOptionIsSet() {
|
|
80
|
+
XCTAssertNil(MentraLiveConnectionOptions.coreBluetoothOptions(
|
|
81
|
+
requiresAncs: false,
|
|
82
|
+
enableTransportBridging: false
|
|
83
|
+
))
|
|
84
|
+
}
|
|
30
85
|
}
|