@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,121 @@
1
+ import Foundation
2
+
3
+ /// Process-wide owner of the `MentraLivePairingStateMachine`. It outlives each `MentraLive`
4
+ /// instance so a disconnect or SGC replacement keeps the pause and the rejection, persists the
5
+ /// record natively (never through the replayed settings), and publishes
6
+ /// `GlassesStatus.pairingState` plus `pairing_state_changed`. Mirrors Android
7
+ /// `MentraLivePairingStateStore`.
8
+ @MainActor
9
+ final class MentraLivePairingStateStore {
10
+ static let shared = MentraLivePairingStateStore()
11
+
12
+ /// The persisted record lives under one UserDefaults key; unknown inner keys are ignored.
13
+ static let defaultsKey = "mentra_live_pairing_v4"
14
+
15
+ private let defaults: UserDefaults
16
+ private let publishes: Bool
17
+ private var machine = MentraLivePairingStateMachine()
18
+ private var restored = false
19
+
20
+ /// `publishes == false` keeps tests away from DeviceStore and the bridge.
21
+ init(defaults: UserDefaults = .standard, publishes: Bool = true) {
22
+ self.defaults = defaults
23
+ self.publishes = publishes
24
+ }
25
+
26
+ var state: MentraLivePairingState { machine.state }
27
+ var allowsAutomaticReconnect: Bool { machine.allowsAutomaticReconnect }
28
+ var isPaused: Bool { machine.isPaused }
29
+ var isRejected: Bool { machine.state == .rejected }
30
+ var verificationArmed: Bool { machine.verificationArmed }
31
+ /// A window this phone paused for ended and its advertisement has not been read yet.
32
+ var awaitingTakeoverCheck: Bool { machine.awaitingTakeoverCheck }
33
+
34
+ func remainingPauseMs(nowMs: Int = MentraLivePairingStateStore.nowMs()) -> Int {
35
+ machine.remainingPauseMs(nowMs: nowMs)
36
+ }
37
+
38
+ /// Restore once per process. Later calls only republish the status field.
39
+ @discardableResult
40
+ func ensureRestored(savedDeviceId: String, nowMs: Int = MentraLivePairingStateStore.nowMs()) -> MentraLivePairingTransition? {
41
+ if restored {
42
+ publish(emitEvent: false)
43
+ return nil
44
+ }
45
+ restored = true
46
+ var values = defaults.dictionary(forKey: Self.defaultsKey) ?? [:]
47
+ for key in MentraLivePairingStateMachine.legacyKeys {
48
+ if let legacy = defaults.object(forKey: key) { values[key] = legacy }
49
+ }
50
+ let transition = machine.restore(values: values, savedDeviceId: savedDeviceId, nowMs: nowMs)
51
+ for key in MentraLivePairingStateMachine.legacyKeys {
52
+ defaults.removeObject(forKey: key)
53
+ }
54
+ persist()
55
+ publish()
56
+ Bridge.log("PAIRFLOW: pairing_state restore note=\(transition.note) state=\(describe())")
57
+ return transition
58
+ }
59
+
60
+ /// Run one machine input, persist the record, and publish when the public state changed.
61
+ @discardableResult
62
+ func update(
63
+ _ source: String,
64
+ _ input: (inout MentraLivePairingStateMachine) -> MentraLivePairingTransition
65
+ ) -> MentraLivePairingTransition {
66
+ let before = machine.state
67
+ let beforeRecord = machine.persisted() as NSDictionary
68
+ let transition = input(&machine)
69
+ // Duplicate-reporting scans feed every advertisement through here.
70
+ if !beforeRecord.isEqual(to: machine.persisted()) { persist() }
71
+ if machine.state != before { publish() }
72
+ if transition.effect != .none || machine.state != before {
73
+ Bridge.log(
74
+ "PAIRFLOW: pairing_state source=\(source) effect=\(transition.effect) note=\(transition.note) state=\(describe())"
75
+ )
76
+ }
77
+ return transition
78
+ }
79
+
80
+ func describe() -> String {
81
+ switch machine.state {
82
+ case .normal: return "normal"
83
+ case let .pairingMode(windowId, endsAtMs): return "pairing_mode(window=\(windowId), endsAtMs=\(endsAtMs))"
84
+ case .rejected: return "pairing_rejected"
85
+ }
86
+ }
87
+
88
+ /// Drops the in-memory state as a process restart would. Persisted values stay.
89
+ func resetForTest() {
90
+ machine = MentraLivePairingStateMachine()
91
+ restored = false
92
+ }
93
+
94
+ nonisolated static func nowMs() -> Int {
95
+ Int(Date().timeIntervalSince1970 * 1000)
96
+ }
97
+
98
+ private func persist() {
99
+ var stored = defaults.dictionary(forKey: Self.defaultsKey) ?? [:]
100
+ for key in MentraLivePairingStateMachine.keys {
101
+ stored.removeValue(forKey: key)
102
+ }
103
+ for (key, value) in machine.persisted() {
104
+ stored[key] = value
105
+ }
106
+ if stored.isEmpty {
107
+ defaults.removeObject(forKey: Self.defaultsKey)
108
+ } else {
109
+ defaults.set(stored, forKey: Self.defaultsKey)
110
+ }
111
+ }
112
+
113
+ private func publish(emitEvent: Bool = true) {
114
+ guard publishes else { return }
115
+ let map = machine.state.toMap()
116
+ DeviceStore.shared.apply("glasses", "pairingState", map)
117
+ if emitEvent {
118
+ Bridge.sendTypedMessage("pairing_state_changed", body: ["state": map])
119
+ }
120
+ }
121
+ }
@@ -50,6 +50,9 @@ protocol SGCManager {
50
50
 
51
51
  var type: String { get set }
52
52
  var hasMic: Bool { get }
53
+ /// The driver publishes `GlassesStatus.audioSetup` itself. Otherwise the status layer
54
+ /// reports `not_applicable` while connected and `idle` otherwise.
55
+ var publishesAudioSetup: Bool { get }
53
56
 
54
57
  // MARK: - Audio Control
55
58
 
@@ -278,6 +281,10 @@ extension SGCManager {
278
281
  false
279
282
  }
280
283
 
284
+ var publishesAudioSetup: Bool {
285
+ false
286
+ }
287
+
281
288
  func replayStreamControlReady() {}
282
289
 
283
290
  @discardableResult func requestSavedWifiNetworks(requestId _: String, sid _: String) -> Bool {
@@ -18,6 +18,8 @@ class Simulated: SGCManager {
18
18
  BluetoothSdkDefaults.voiceActivityDetectionEnabled
19
19
  )
20
20
  DeviceStore.shared.apply("glasses", "bluetoothClassicConnected", false)
21
+ DeviceStore.shared.apply("glasses", "bluetoothClassicProfilesConnected", false)
22
+ DeviceStore.shared.apply("glasses", "bluetoothAudioRouted", false)
21
23
  }
22
24
 
23
25
  // MARK: - Device Information
@@ -124,6 +124,7 @@ struct GlassesStatus: CustomStringConvertible {
124
124
  "signalStrength": -1,
125
125
  "signalStrengthUpdatedAt": 0,
126
126
  "g2MissingArm": NSNull(),
127
+ "audioSetup": GlassesAudioSetupState.idle.toMap(),
127
128
  ]))
128
129
  }
129
130
 
@@ -151,6 +152,14 @@ struct GlassesStatus: CustomStringConvertible {
151
152
  boolValue(values, "bluetoothClassicConnected") ?? false
152
153
  }
153
154
 
155
+ var bluetoothClassicProfilesConnected: Bool {
156
+ boolValue(values, "bluetoothClassicProfilesConnected") ?? false
157
+ }
158
+
159
+ var bluetoothAudioRouted: Bool {
160
+ boolValue(values, "bluetoothAudioRouted") ?? false
161
+ }
162
+
154
163
  var signalStrength: Int {
155
164
  intValue(values["signalStrength"]) ?? -1
156
165
  }
@@ -302,6 +311,16 @@ struct GlassesStatus: CustomStringConvertible {
302
311
  intValue(values["ringSignalStrength"]) ?? -1
303
312
  }
304
313
 
314
+ /// Mentra Live `MentraLivePairingState` (`{state, windowId?, endsAtMs?}`); nil for other glasses.
315
+ var pairingState: [String: Any]? {
316
+ values["pairingState"] as? [String: Any]
317
+ }
318
+
319
+ /// `GlassesAudioSetupState` (`{state, action?}`). Every model publishes it.
320
+ var audioSetup: [String: Any]? {
321
+ values["audioSetup"] as? [String: Any]
322
+ }
323
+
305
324
  var description: String {
306
325
  values.description
307
326
  }
@@ -688,6 +707,14 @@ struct GlassesStatusUpdate: CustomStringConvertible {
688
707
  optionalBoolValue(values, "bluetoothClassicConnected")
689
708
  }
690
709
 
710
+ var bluetoothClassicProfilesConnected: Bool? {
711
+ optionalBoolValue(values, "bluetoothClassicProfilesConnected")
712
+ }
713
+
714
+ var bluetoothAudioRouted: Bool? {
715
+ optionalBoolValue(values, "bluetoothAudioRouted")
716
+ }
717
+
691
718
  var signalStrength: Int? {
692
719
  optionalIntValue(values, "signalStrength")
693
720
  }
@@ -848,6 +875,14 @@ struct GlassesStatusUpdate: CustomStringConvertible {
848
875
  optionalIntValue(values, "ringSignalStrength")
849
876
  }
850
877
 
878
+ var pairingState: [String: Any]? {
879
+ values["pairingState"] as? [String: Any]
880
+ }
881
+
882
+ var audioSetup: [String: Any]? {
883
+ values["audioSetup"] as? [String: Any]
884
+ }
885
+
851
886
  var description: String {
852
887
  values.description
853
888
  }
@@ -172,14 +172,21 @@ public struct ConnectOptions {
172
172
  /// Mentra Live on iOS only. Whether CoreBluetooth should require ANCS
173
173
  /// authorization while establishing the connection.
174
174
  public let requiresAncs: Bool
175
+ /// Mentra Live on iOS only. Ask CoreBluetooth to bring up the Classic
176
+ /// profiles (A2DP/HFP) after the BLE connection. On by default: iOS derives
177
+ /// the Classic key at the BLE bond but never starts Classic, and the glasses
178
+ /// commit `no_classic`, so pairing waits forever for an audio route without it.
179
+ public let enableTransportBridging: Bool
175
180
 
176
181
  public init(
177
182
  saveAsDefault: Bool = true,
178
183
  cancelExistingConnectionAttempt: Bool = true,
179
- requiresAncs: Bool = true
184
+ requiresAncs: Bool = true,
185
+ enableTransportBridging: Bool = true
180
186
  ) {
181
187
  self.saveAsDefault = saveAsDefault
182
188
  self.cancelExistingConnectionAttempt = cancelExistingConnectionAttempt
183
189
  self.requiresAncs = requiresAncs
190
+ self.enableTransportBridging = enableTransportBridging
184
191
  }
185
192
  }
@@ -22,6 +22,14 @@ class AudioSessionMonitor {
22
22
  static var isMonitoring = false
23
23
  static var devicePattern: String?
24
24
  static var callback: ((Bool, String?) -> Void)?
25
+ /// Last pair-audio decision line. The check runs on a timer; log only changes.
26
+ private static var lastPairFlow = ""
27
+ /// SoloAmbient hides paired HFP inputs. One short category probe per second is enough.
28
+ private static var lastBluetoothProbe: (at: Date, outputs: [AVAudioSessionPortDescription], inputs: [AVAudioSessionPortDescription]?)?
29
+ /// The glasses sent `classic_link` in the current BLE session. Their report replaces the
30
+ /// category probe, which can see a route that is not the glasses'. Old firmware never sends
31
+ /// it and keeps the probe.
32
+ static var glassesReportClassicLink = false
25
33
 
26
34
  private init() {
27
35
  Bridge.log("AudioMonitor: Initialized")
@@ -93,18 +101,134 @@ class AudioSessionMonitor {
93
101
  return false
94
102
  }
95
103
 
104
+ private struct BluetoothSessionSnapshot {
105
+ let categoryLabel: String
106
+ let outputs: [AVAudioSessionPortDescription]
107
+ let availableInputs: [AVAudioSessionPortDescription]?
108
+ }
109
+
110
+ /// The default SoloAmbient session lists only the built-in mic, so glasses that are
111
+ /// already connected in Settings look absent and pairing waits out the auto-audio timer.
112
+ /// Read HFP inputs with a category that can see them, then put the previous category back.
113
+ /// Does not activate the session or select an input.
114
+ private static func bluetoothSessionSnapshot() -> BluetoothSessionSnapshot {
115
+ if !Thread.isMainThread {
116
+ return DispatchQueue.main.sync { bluetoothSessionSnapshot() }
117
+ }
118
+ let session = AVAudioSession.sharedInstance()
119
+ if categoryShowsBluetoothInputs(session.category) {
120
+ return BluetoothSessionSnapshot(
121
+ categoryLabel: session.category.rawValue,
122
+ outputs: session.currentRoute.outputs,
123
+ availableInputs: session.availableInputs
124
+ )
125
+ }
126
+ if glassesReportClassicLink {
127
+ // Passive route observation only: never switch the category.
128
+ return BluetoothSessionSnapshot(
129
+ categoryLabel: "passive:\(session.category.rawValue)",
130
+ outputs: session.currentRoute.outputs,
131
+ availableInputs: session.availableInputs
132
+ )
133
+ }
134
+ if let lastBluetoothProbe, Date().timeIntervalSince(lastBluetoothProbe.at) < 1 {
135
+ return BluetoothSessionSnapshot(
136
+ categoryLabel: "probed:\(AVAudioSession.Category.playAndRecord.rawValue)",
137
+ outputs: lastBluetoothProbe.outputs,
138
+ availableInputs: lastBluetoothProbe.inputs
139
+ )
140
+ }
141
+ let savedCategory = session.category
142
+ let savedMode = session.mode
143
+ let savedOptions = session.categoryOptions
144
+ do {
145
+ try session.setCategory(
146
+ .playAndRecord, mode: .default, options: [.allowBluetooth, .allowBluetoothA2DP]
147
+ )
148
+ } catch {
149
+ return BluetoothSessionSnapshot(
150
+ categoryLabel: session.category.rawValue,
151
+ outputs: session.currentRoute.outputs,
152
+ availableInputs: session.availableInputs
153
+ )
154
+ }
155
+ let outputs = session.currentRoute.outputs
156
+ let inputs = session.availableInputs
157
+ lastBluetoothProbe = (Date(), outputs, inputs)
158
+ try? session.setCategory(savedCategory, mode: savedMode, options: savedOptions)
159
+ return BluetoothSessionSnapshot(
160
+ categoryLabel: "probed:\(AVAudioSession.Category.playAndRecord.rawValue)",
161
+ outputs: outputs,
162
+ availableInputs: inputs
163
+ )
164
+ }
165
+
166
+ private static func categoryShowsBluetoothInputs(_ category: AVAudioSession.Category) -> Bool {
167
+ category == .playAndRecord || category == .record || category == .multiRoute
168
+ }
169
+
170
+ /// Glasses' Classic audio facts without selecting an input or activating the session.
171
+ /// `profilesConnected` matches `isDevicePaired`; `audioRouted` is the stricter
172
+ /// "audio is flowing to the glasses" signal.
173
+ static func observeProfiles(devicePattern: String) -> BluetoothAudioRoute.ProfileObservation {
174
+ let snapshot = bluetoothSessionSnapshot()
175
+ let profiles = profileObservation(
176
+ devicePattern: devicePattern, outputs: snapshot.outputs, availableInputs: snapshot.availableInputs
177
+ )
178
+ logPairFlow(
179
+ category: snapshot.categoryLabel, devicePattern: devicePattern, outputs: snapshot.outputs,
180
+ availableInputs: snapshot.availableInputs, profiles: profiles
181
+ )
182
+ return profiles
183
+ }
184
+
185
+ private static func profileObservation(
186
+ devicePattern: String,
187
+ outputs: [AVAudioSessionPortDescription],
188
+ availableInputs: [AVAudioSessionPortDescription]?
189
+ ) -> BluetoothAudioRoute.ProfileObservation {
190
+ BluetoothAudioRoute.observeProfiles(
191
+ outputs: outputs.map { (name: $0.portName, portType: $0.portType.rawValue) },
192
+ inputs: availableInputs?.map { (name: $0.portName, portType: $0.portType.rawValue) },
193
+ target: devicePattern, isIOSAppOnMac: ProcessInfo.processInfo.isiOSAppOnMac
194
+ )
195
+ }
196
+
197
+ /// One PAIRFLOW line per transition of category, inputs availability, ports, and both facts.
198
+ /// Port names are the glasses' advertised name or other audio devices; no credentials are logged.
199
+ private static func logPairFlow(
200
+ category: String,
201
+ devicePattern: String,
202
+ outputs: [AVAudioSessionPortDescription],
203
+ availableInputs: [AVAudioSessionPortDescription]?,
204
+ profiles: BluetoothAudioRoute.ProfileObservation
205
+ ) {
206
+ let decision = profiles.audioRouted
207
+ ? "advance_active_output"
208
+ : (profiles.profilesConnected ? "advance_hfp_input" : "wait_no_route")
209
+ let outputNames = outputs.map { "\($0.portType.rawValue):\($0.portName)" }.joined(separator: ",")
210
+ let inputNames = (availableInputs ?? []).map { "\($0.portType.rawValue):\($0.portName)" }.joined(separator: ",")
211
+ let pairFlow =
212
+ "PAIRFLOW: pair_audio decision=\(decision) pattern=\(devicePattern) category=\(category) inputsNil=\(availableInputs == nil) outputs=[\(outputNames)] inputs=[\(inputNames)] profilesConnected=\(profiles.profilesConnected) audioRouted=\(profiles.audioRouted)"
213
+ if pairFlow != lastPairFlow {
214
+ lastPairFlow = pairFlow
215
+ Bridge.log(pairFlow)
216
+ }
217
+ }
218
+
96
219
  /// Check if a Bluetooth device matching the pattern is paired (appears in availableInputs)
97
220
  /// Returns true if device is found (paired), WITHOUT activating it
98
221
  /// This avoids switching A2DP music playback to HFP microphone mode
99
222
  static func isDevicePaired(devicePattern: String) -> Bool {
100
- let session = AVAudioSession.sharedInstance()
101
- let outputs = session.currentRoute.outputs
223
+ let snapshot = bluetoothSessionSnapshot()
224
+ let outputs = snapshot.outputs
102
225
  let route = BluetoothAudioRoute.observe(
103
226
  outputs: outputs.map { (name: $0.portName, portType: $0.portType.rawValue) },
104
227
  target: devicePattern, isIOSAppOnMac: ProcessInfo.processInfo.isiOSAppOnMac
105
228
  )
106
229
  let activeTarget = route.matchingOutputCount > 0
107
- let availableInputs = session.availableInputs
230
+ let availableInputs = snapshot.availableInputs
231
+ let profiles = profileObservation(devicePattern: devicePattern, outputs: outputs, availableInputs: availableInputs)
108
232
  let bluetoothInput = availableInputs?.first { input in
109
233
  input.portType == .bluetoothHFP
110
234
  && input.portName.localizedCaseInsensitiveContains(devicePattern)
@@ -112,8 +236,12 @@ class AudioSessionMonitor {
112
236
 
113
237
  // NSLog messages redact the native route checks in retained Mac logs.
114
238
  // Keep categorical evidence at notice level without exposing names or
115
- // selecting an input, activating the session, or changing audio routing.
116
- readinessLog.notice("Pair audio: iosOnMac=\(ProcessInfo.processInfo.isiOSAppOnMac, privacy: .public) targetPresent=\(!devicePattern.isEmpty, privacy: .public) category=\(session.category.rawValue, privacy: .public) outputs=\(outputs.count, privacy: .public) bluetoothOutputs=\(route.bluetoothOutputCount, privacy: .public) targetNameOutputs=\(route.targetNameOutputCount, privacy: .public) inputs=\(availableInputs?.count ?? -1, privacy: .public) activeTarget=\(activeTarget, privacy: .public) hfpTarget=\(bluetoothInput != nil, privacy: .public)")
239
+ // selecting an input or activating the session.
240
+ readinessLog.notice("Pair audio: iosOnMac=\(ProcessInfo.processInfo.isiOSAppOnMac, privacy: .public) targetPresent=\(!devicePattern.isEmpty, privacy: .public) category=\(snapshot.categoryLabel, privacy: .public) outputs=\(outputs.count, privacy: .public) bluetoothOutputs=\(route.bluetoothOutputCount, privacy: .public) targetNameOutputs=\(route.targetNameOutputCount, privacy: .public) inputs=\(availableInputs?.count ?? -1, privacy: .public) activeTarget=\(activeTarget, privacy: .public) hfpTarget=\(bluetoothInput != nil, privacy: .public)")
241
+ logPairFlow(
242
+ category: snapshot.categoryLabel, devicePattern: devicePattern, outputs: outputs,
243
+ availableInputs: availableInputs, profiles: profiles
244
+ )
117
245
 
118
246
  // Check if already active (using A2DP for music or HFP for calls)
119
247
  if activeTarget {
@@ -320,6 +448,31 @@ enum BluetoothAudioRoute {
320
448
  )
321
449
  }
322
450
 
451
+ /// Separate facts about the glasses' Classic audio link. Neither implies a bond:
452
+ /// a bonded device with no connected audio profile is invisible to AVAudioSession.
453
+ struct ProfileObservation: Equatable {
454
+ /// An HFP input is available, or a matching Bluetooth output is active.
455
+ let profilesConnected: Bool
456
+ /// A matching Bluetooth output is the active route (A2DP or HFP).
457
+ let audioRouted: Bool
458
+ }
459
+
460
+ /// `inputs` is nil when `AVAudioSession.availableInputs` is nil (it depends on the session category).
461
+ static func observeProfiles(
462
+ outputs: [(name: String, portType: String)],
463
+ inputs: [(name: String, portType: String)]?,
464
+ target: String,
465
+ isIOSAppOnMac: Bool
466
+ ) -> ProfileObservation {
467
+ let audioRouted = outputs.contains {
468
+ matches(name: $0.name, portType: $0.portType, target: target, isIOSAppOnMac: isIOSAppOnMac)
469
+ }
470
+ let hfpInput = inputs?.contains {
471
+ $0.portType == "BluetoothHFP" && matchesName($0.name, target: target)
472
+ } ?? false
473
+ return ProfileObservation(profilesConnected: audioRouted || hfpInput, audioRouted: audioRouted)
474
+ }
475
+
323
476
  static func matches(name: String, portType: String, target: String, isIOSAppOnMac: Bool) -> Bool {
324
477
  matchesName(name, target: target) && isBluetoothTransport(portType, isIOSAppOnMac: isIOSAppOnMac)
325
478
  }
@@ -60,4 +60,86 @@ final class BluetoothAudioRouteTests: XCTestCase {
60
60
  ))
61
61
  }
62
62
  }
63
+
64
+ // MARK: - Profile observation
65
+
66
+ private func profiles(
67
+ outputs: [(name: String, portType: String)] = [],
68
+ inputs: [(name: String, portType: String)]? = [],
69
+ isIOSAppOnMac: Bool = false
70
+ ) -> BluetoothAudioRoute.ProfileObservation {
71
+ BluetoothAudioRoute.observeProfiles(
72
+ outputs: outputs, inputs: inputs, target: "03BE", isIOSAppOnMac: isIOSAppOnMac
73
+ )
74
+ }
75
+
76
+ func testActiveA2dpOutputIsConnectedAndRouted() {
77
+ let result = profiles(outputs: [(name: "Mentra_Live_03BE", portType: "BluetoothA2DPOutput")], inputs: nil)
78
+ XCTAssertEqual(result, .init(profilesConnected: true, audioRouted: true))
79
+ }
80
+
81
+ func testHfpInputOnlyIsConnectedButNotRouted() {
82
+ let result = profiles(
83
+ outputs: [(name: "Speaker", portType: "Speaker")],
84
+ inputs: [(name: "Mentra_Live_03BE", portType: "BluetoothHFP")]
85
+ )
86
+ XCTAssertEqual(result, .init(profilesConnected: true, audioRouted: false))
87
+ }
88
+
89
+ func testNoMatchIsNeitherConnectedNorRouted() {
90
+ let result = profiles(
91
+ outputs: [(name: "Speaker", portType: "Speaker")],
92
+ inputs: [(name: "iPhone Microphone", portType: "MicrophoneBuiltIn")]
93
+ )
94
+ XCTAssertEqual(result, .init(profilesConnected: false, audioRouted: false))
95
+ XCTAssertEqual(profiles(), .init(profilesConnected: false, audioRouted: false))
96
+ }
97
+
98
+ func testNilInputsDoNotHideActiveOutputAndAreNotAMatch() {
99
+ XCTAssertEqual(
100
+ profiles(outputs: [(name: "Speaker", portType: "Speaker")], inputs: nil),
101
+ .init(profilesConnected: false, audioRouted: false)
102
+ )
103
+ XCTAssertEqual(
104
+ profiles(outputs: [(name: "Mentra_Live_03BE", portType: "BluetoothHFP")], inputs: nil),
105
+ .init(profilesConnected: true, audioRouted: true)
106
+ )
107
+ }
108
+
109
+ func testOtherDevicesDoNotMatch() {
110
+ let result = profiles(
111
+ outputs: [(name: "Mentra_Live_023B", portType: "BluetoothA2DPOutput")],
112
+ inputs: [(name: "Mentra_Live_023B", portType: "BluetoothHFP")]
113
+ )
114
+ XCTAssertEqual(result, .init(profilesConnected: false, audioRouted: false))
115
+ }
116
+
117
+ func testIdentityAndTransportBothRequiredForInputsAndOutputs() {
118
+ let result = profiles(
119
+ outputs: [(name: "Mentra_Live_03BE", portType: "USBAudio")],
120
+ inputs: [(name: "Mentra_Live_03BE", portType: "BluetoothA2DPOutput")]
121
+ )
122
+ XCTAssertEqual(result, .init(profilesConnected: false, audioRouted: false))
123
+ }
124
+
125
+ func testIOSOnMacBluetoothPortIsRoutedOnlyOnMac() {
126
+ let output = [(name: "Mentra_Live_03BE", portType: "Bluetooth")]
127
+ XCTAssertEqual(
128
+ profiles(outputs: output, isIOSAppOnMac: true),
129
+ .init(profilesConnected: true, audioRouted: true)
130
+ )
131
+ XCTAssertEqual(
132
+ profiles(outputs: output, isIOSAppOnMac: false),
133
+ .init(profilesConnected: false, audioRouted: false)
134
+ )
135
+ }
136
+
137
+ func testEmptyTargetNeverMatches() {
138
+ let result = BluetoothAudioRoute.observeProfiles(
139
+ outputs: [(name: "Mentra_Live_03BE", portType: "BluetoothA2DPOutput")],
140
+ inputs: [(name: "Mentra_Live_03BE", portType: "BluetoothHFP")],
141
+ target: "", isIOSAppOnMac: false
142
+ )
143
+ XCTAssertEqual(result, .init(profilesConnected: false, audioRouted: false))
144
+ }
63
145
  }