@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,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
|
|
101
|
-
let 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 =
|
|
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
|
|
116
|
-
readinessLog.notice("Pair audio: iosOnMac=\(ProcessInfo.processInfo.isiOSAppOnMac, privacy: .public) targetPresent=\(!devicePattern.isEmpty, privacy: .public) category=\(
|
|
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
|
}
|