@mentra/bluetooth-sdk 3.4.0-dev.993 → 3.4.0-dev.998

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