@mentra/bluetooth-sdk 3.2.1-dev.314 → 3.3.0-beta.942
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/README.md +72 -10
- package/android/build.gradle +15 -0
- package/android/lc3Lib/build.gradle +6 -1
- package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
- package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +4 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +8 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +133 -26
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +3 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +227 -36
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +194 -40
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +247 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
- package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +233 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
- package/build/BluetoothSdk.types.d.ts +16 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js +1 -0
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +4 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +5 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
- package/build/_private/MentraLocalNetworkModule.js.map +1 -1
- package/build/_private/nativeLogConsole.d.ts +19 -0
- package/build/_private/nativeLogConsole.d.ts.map +1 -1
- package/build/_private/nativeLogConsole.js +12 -0
- package/build/_private/nativeLogConsole.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +5 -2
- package/build/generated/changelogCatalog.d.ts.map +1 -1
- package/build/generated/changelogCatalog.js +6 -2
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +6 -6
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/ota-transport/index.d.ts +4 -0
- package/build/ota-transport/index.d.ts.map +1 -1
- package/build/ota-transport/index.js +2 -0
- package/build/ota-transport/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +17 -4
- package/ios/MentraBluetoothSDK.podspec +3 -2
- package/ios/Source/BluetoothBackgroundMode.swift +32 -0
- package/ios/Source/BluetoothSdkDefaults.swift +7 -2
- package/ios/Source/Bridge.swift +15 -2
- package/ios/Source/DeviceManager.swift +102 -66
- package/ios/Source/DeviceStore.swift +26 -7
- package/ios/Source/GeneratedChangelogCatalog.swift +3 -2
- package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
- package/ios/Source/MentraBluetoothSDK.swift +313 -54
- package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
- package/ios/Source/internal/BluetoothAvailability.swift +6 -0
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
- package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
- package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
- package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
- package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
- package/ios/Source/requests/DisplayRequests.swift +1 -1
- package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
- package/ios/Source/sgcs/Ar99.swift +1 -2
- package/ios/Source/sgcs/G1.swift +68 -38
- package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
- package/ios/Source/sgcs/G2.swift +261 -247
- package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
- package/ios/Source/sgcs/Mach1.swift +19 -7
- package/ios/Source/sgcs/MentraLive.swift +272 -197
- package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
- package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
- package/ios/Source/sgcs/MentraNex.swift +8 -31
- package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
- package/ios/Source/sgcs/Nimo.swift +280 -408
- package/ios/Source/sgcs/NimoCanvasCodec.swift +272 -0
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
- package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
- package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
- package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
- package/ios/Source/sgcs/SGCManager.swift +25 -1
- package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
- package/ios/Source/status/DeviceStatus.swift +1 -0
- package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
- package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
- package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
- package/ios/Source/utils/G1Text.swift +2 -24
- package/ios/Source/utils/G1TextSanitizer.swift +27 -0
- package/ios/Source/utils/G2Text.swift +11 -0
- package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
- package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
- package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
- package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
- package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
- package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
- package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
- package/ios/Tests/DashboardRenderingTests.swift +31 -0
- package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
- package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
- package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
- package/ios/Tests/G1PairingTests.swift +162 -0
- package/ios/Tests/G1TextSanitizerTests.swift +32 -0
- package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
- package/ios/Tests/G2TextTests.swift +10 -0
- package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
- package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
- package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
- package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
- package/ios/Tests/NimoCanvasCodecTests.swift +217 -0
- package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
- package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
- package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
- package/ios/Tests/NimoDiscoveryTests.swift +79 -0
- package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
- package/ios/Tests/VersionInfoCommandTests.swift +194 -0
- package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
- package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
- package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
- package/package.json +2 -2
- package/plugin/build/withIos.d.ts +2 -0
- package/plugin/build/withIos.js +12 -1
- package/src/BluetoothSdk.types.ts +15 -0
- package/src/_private/BluetoothSdkModule.ts +10 -1
- package/src/_private/MentraLocalNetworkModule.ts +28 -0
- package/src/_private/nativeLogConsole.ts +19 -0
- package/src/generated/changelogCatalog.ts +6 -2
- package/src/generated/releaseMetadata.ts +6 -6
- package/src/index.ts +1 -0
- package/src/ota-transport/index.ts +10 -1
|
@@ -10,9 +10,11 @@ import Foundation
|
|
|
10
10
|
|
|
11
11
|
#if !os(macOS)
|
|
12
12
|
import AVFoundation
|
|
13
|
+
import OSLog
|
|
13
14
|
import UIKit
|
|
14
15
|
|
|
15
16
|
class AudioSessionMonitor {
|
|
17
|
+
private static let readinessLog = Logger(subsystem: "com.mentra.bluetooth-sdk", category: "AudioReadiness")
|
|
16
18
|
/// Singleton instance
|
|
17
19
|
private static var instance: AudioSessionMonitor?
|
|
18
20
|
|
|
@@ -96,15 +98,31 @@ class AudioSessionMonitor {
|
|
|
96
98
|
/// This avoids switching A2DP music playback to HFP microphone mode
|
|
97
99
|
static func isDevicePaired(devicePattern: String) -> Bool {
|
|
98
100
|
let session = AVAudioSession.sharedInstance()
|
|
101
|
+
let outputs = session.currentRoute.outputs
|
|
102
|
+
let route = BluetoothAudioRoute.observe(
|
|
103
|
+
outputs: outputs.map { (name: $0.portName, portType: $0.portType.rawValue) },
|
|
104
|
+
target: devicePattern, isIOSAppOnMac: ProcessInfo.processInfo.isiOSAppOnMac
|
|
105
|
+
)
|
|
106
|
+
let activeTarget = route.matchingOutputCount > 0
|
|
107
|
+
let availableInputs = session.availableInputs
|
|
108
|
+
let bluetoothInput = availableInputs?.first { input in
|
|
109
|
+
input.portType == .bluetoothHFP
|
|
110
|
+
&& input.portName.localizedCaseInsensitiveContains(devicePattern)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// NSLog messages redact the native route checks in retained Mac logs.
|
|
114
|
+
// 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)")
|
|
99
117
|
|
|
100
118
|
// Check if already active (using A2DP for music or HFP for calls)
|
|
101
|
-
if
|
|
119
|
+
if activeTarget {
|
|
102
120
|
Bridge.log("AudioMonitor: Device '\(devicePattern)' already active")
|
|
103
121
|
return true
|
|
104
122
|
}
|
|
105
123
|
|
|
106
124
|
// Try to find in availableInputs (includes paired devices)
|
|
107
|
-
guard let availableInputs
|
|
125
|
+
guard let availableInputs else {
|
|
108
126
|
Bridge.log("AudioMonitor: ❌ availableInputs is nil")
|
|
109
127
|
return false
|
|
110
128
|
}
|
|
@@ -114,11 +132,6 @@ class AudioSessionMonitor {
|
|
|
114
132
|
Bridge.log("AudioMonitor: - \(input.portName) (type: \(input.portType.rawValue))")
|
|
115
133
|
}
|
|
116
134
|
|
|
117
|
-
let bluetoothInput = availableInputs.first { input in
|
|
118
|
-
input.portType == .bluetoothHFP
|
|
119
|
-
&& input.portName.localizedCaseInsensitiveContains(devicePattern)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
135
|
if let btInput = bluetoothInput {
|
|
123
136
|
Bridge.log(
|
|
124
137
|
"AudioMonitor: ✅ Found paired device '\(btInput.portName)' (not activating to preserve A2DP)"
|
|
@@ -291,8 +304,31 @@ class AudioSessionMonitor {
|
|
|
291
304
|
/// AVAudioSession preserves the route name on iOS-on-Mac but reports the
|
|
292
305
|
/// CoreAudio transport "Bluetooth" instead of iPhone's profile-specific port.
|
|
293
306
|
enum BluetoothAudioRoute {
|
|
307
|
+
struct Observation: Equatable {
|
|
308
|
+
let bluetoothOutputCount: Int
|
|
309
|
+
let targetNameOutputCount: Int
|
|
310
|
+
let matchingOutputCount: Int
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
static func observe(outputs: [(name: String, portType: String)], target: String, isIOSAppOnMac: Bool) -> Observation {
|
|
314
|
+
Observation(
|
|
315
|
+
bluetoothOutputCount: outputs.filter { isBluetoothTransport($0.portType, isIOSAppOnMac: isIOSAppOnMac) }.count,
|
|
316
|
+
targetNameOutputCount: outputs.filter { matchesName($0.name, target: target) }.count,
|
|
317
|
+
matchingOutputCount: outputs.filter {
|
|
318
|
+
matches(name: $0.name, portType: $0.portType, target: target, isIOSAppOnMac: isIOSAppOnMac)
|
|
319
|
+
}.count
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
294
323
|
static func matches(name: String, portType: String, target: String, isIOSAppOnMac: Bool) -> Bool {
|
|
295
|
-
|
|
324
|
+
matchesName(name, target: target) && isBluetoothTransport(portType, isIOSAppOnMac: isIOSAppOnMac)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private static func matchesName(_ name: String, target: String) -> Bool {
|
|
328
|
+
!target.isEmpty && name.localizedCaseInsensitiveContains(target)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private static func isBluetoothTransport(_ portType: String, isIOSAppOnMac: Bool) -> Bool {
|
|
296
332
|
return portType == "BluetoothHFP" || portType == "BluetoothA2DPOutput"
|
|
297
333
|
|| (isIOSAppOnMac && portType == "Bluetooth")
|
|
298
334
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Owns a processor on one serial queue. Empty queues schedule no recurring work.
|
|
4
|
+
/// Demand changes invalidate queued audio/results immediately; creation, processing,
|
|
5
|
+
/// and destruction finish in order without blocking the caller (including the UI).
|
|
6
|
+
final class DemandDrivenAudioWorker<Processor> {
|
|
7
|
+
struct Snapshot {
|
|
8
|
+
let active: Bool
|
|
9
|
+
let initialized: Bool
|
|
10
|
+
let processing: Bool
|
|
11
|
+
let queuedBuffers: Int
|
|
12
|
+
let processedBuffers: UInt64
|
|
13
|
+
let droppedBuffers: UInt64
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
private let lock = NSLock()
|
|
17
|
+
private let queue: DispatchQueue
|
|
18
|
+
private let capacity: Int
|
|
19
|
+
private let makeProcessor: () -> Processor?
|
|
20
|
+
private let process: (Processor, Data, UInt64) -> Void
|
|
21
|
+
private var active = false
|
|
22
|
+
private var generation: UInt64 = 0
|
|
23
|
+
private var buffers = [Data]()
|
|
24
|
+
private var scheduled = false
|
|
25
|
+
private var initialized = false
|
|
26
|
+
private var processing = false
|
|
27
|
+
private var processedBuffers: UInt64 = 0
|
|
28
|
+
private var droppedBuffers: UInt64 = 0
|
|
29
|
+
|
|
30
|
+
// Accessed only on queue, including release of the previous processor.
|
|
31
|
+
private var processor: Processor?
|
|
32
|
+
private var processorGeneration: UInt64?
|
|
33
|
+
private var attemptedInitialization = false
|
|
34
|
+
|
|
35
|
+
init(capacity: Int = 100,
|
|
36
|
+
queue: DispatchQueue = DispatchQueue(label: "com.mentra.speech.processor", qos: .userInitiated),
|
|
37
|
+
makeProcessor: @escaping () -> Processor?,
|
|
38
|
+
process: @escaping (Processor, Data, UInt64) -> Void)
|
|
39
|
+
{
|
|
40
|
+
precondition(capacity > 0)
|
|
41
|
+
self.capacity = capacity
|
|
42
|
+
self.queue = queue
|
|
43
|
+
self.makeProcessor = makeProcessor
|
|
44
|
+
self.process = process
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func setActive(_ value: Bool) {
|
|
48
|
+
lock.lock()
|
|
49
|
+
defer { lock.unlock() }
|
|
50
|
+
guard active != value else { return }
|
|
51
|
+
active = value
|
|
52
|
+
invalidateLocked()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Reload a changed model only if there is still local-transcription demand.
|
|
56
|
+
func restart() {
|
|
57
|
+
lock.lock()
|
|
58
|
+
defer { lock.unlock() }
|
|
59
|
+
invalidateLocked()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private func invalidateLocked() {
|
|
63
|
+
generation &+= 1
|
|
64
|
+
buffers.removeAll(keepingCapacity: true)
|
|
65
|
+
initialized = false
|
|
66
|
+
scheduleLocked()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func accept(_ data: Data) {
|
|
70
|
+
guard !data.isEmpty else { return }
|
|
71
|
+
lock.lock()
|
|
72
|
+
defer { lock.unlock() }
|
|
73
|
+
guard active else { return }
|
|
74
|
+
// Bound even audio arriving while model loading or decoding is blocked.
|
|
75
|
+
if buffers.count == capacity {
|
|
76
|
+
buffers.removeFirst()
|
|
77
|
+
droppedBuffers &+= 1
|
|
78
|
+
}
|
|
79
|
+
buffers.append(data)
|
|
80
|
+
scheduleLocked()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func isCurrent(_ token: UInt64) -> Bool {
|
|
84
|
+
lock.lock()
|
|
85
|
+
defer { lock.unlock() }
|
|
86
|
+
return active && generation == token
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func snapshot() -> Snapshot {
|
|
90
|
+
lock.lock()
|
|
91
|
+
defer { lock.unlock() }
|
|
92
|
+
return Snapshot(active: active, initialized: initialized, processing: processing,
|
|
93
|
+
queuedBuffers: buffers.count, processedBuffers: processedBuffers,
|
|
94
|
+
droppedBuffers: droppedBuffers)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private func scheduleLocked() {
|
|
98
|
+
guard !scheduled else { return }
|
|
99
|
+
scheduled = true
|
|
100
|
+
queue.async { self.drain() }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private func drain() {
|
|
104
|
+
while true {
|
|
105
|
+
lock.lock()
|
|
106
|
+
let token = generation
|
|
107
|
+
if processorGeneration != token {
|
|
108
|
+
lock.unlock()
|
|
109
|
+
// ARC destruction stays serialized with inference and model loading.
|
|
110
|
+
processor = nil
|
|
111
|
+
processorGeneration = token
|
|
112
|
+
attemptedInitialization = false
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
guard active else {
|
|
116
|
+
scheduled = false
|
|
117
|
+
lock.unlock()
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
if !attemptedInitialization {
|
|
121
|
+
attemptedInitialization = true
|
|
122
|
+
lock.unlock()
|
|
123
|
+
processor = makeProcessor()
|
|
124
|
+
lock.lock()
|
|
125
|
+
if generation == token { initialized = processor != nil }
|
|
126
|
+
lock.unlock()
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
guard let processor else {
|
|
130
|
+
buffers.removeAll(keepingCapacity: true)
|
|
131
|
+
scheduled = false
|
|
132
|
+
lock.unlock()
|
|
133
|
+
return // Missing model: retry on a demand transition or model restart.
|
|
134
|
+
}
|
|
135
|
+
guard !buffers.isEmpty else {
|
|
136
|
+
scheduled = false
|
|
137
|
+
lock.unlock()
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
let data = buffers.removeFirst()
|
|
141
|
+
processing = true
|
|
142
|
+
lock.unlock()
|
|
143
|
+
process(processor, data, token)
|
|
144
|
+
lock.lock()
|
|
145
|
+
processedBuffers &+= 1
|
|
146
|
+
processing = false
|
|
147
|
+
lock.unlock()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -24,31 +24,9 @@ class G1Text {
|
|
|
24
24
|
|
|
25
25
|
init() {}
|
|
26
26
|
|
|
27
|
-
///
|
|
28
|
-
/// Newer glasses bypass this G1-only transport and keep the original text.
|
|
27
|
+
/// Preserve supported G1 Latin glyphs before BLE encoding.
|
|
29
28
|
static func sanitizeForDisplay(_ text: String) -> String {
|
|
30
|
-
|
|
31
|
-
switch character {
|
|
32
|
-
case "Đ", "Ð": result.append("D")
|
|
33
|
-
case "đ", "ð": result.append("d")
|
|
34
|
-
case "Ł": result.append("L")
|
|
35
|
-
case "ł": result.append("l")
|
|
36
|
-
case "Ø": result.append("O")
|
|
37
|
-
case "ø": result.append("o")
|
|
38
|
-
case "Æ": result.append("AE")
|
|
39
|
-
case "æ": result.append("ae")
|
|
40
|
-
case "Œ": result.append("OE")
|
|
41
|
-
case "œ": result.append("oe")
|
|
42
|
-
case "ẞ": result.append("SS")
|
|
43
|
-
case "ß": result.append("ss")
|
|
44
|
-
case "Þ": result.append("TH")
|
|
45
|
-
case "þ": result.append("th")
|
|
46
|
-
default: result.append(character)
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return expanded
|
|
51
|
-
.folding(options: .diacriticInsensitive, locale: Locale(identifier: "en_US_POSIX"))
|
|
29
|
+
G1TextSanitizer.sanitizeForDisplay(text)
|
|
52
30
|
}
|
|
53
31
|
|
|
54
32
|
// MARK: - Text Wall Methods
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Preserves the embedded G1 1.5.6 Latin glyphs; see notes/g1-character-support.md.
|
|
4
|
+
enum G1TextSanitizer {
|
|
5
|
+
private static let supportedLatin = Set("ÀÂÇÈÉÊËÎÏÔÙÛÜàçèéêëîïôùûüÿŸÄäÖößẞâÁáÍíÑñúÓóÚⁱIJijŠšŽžÃÅÆÌÐÒÕØÝÞãåæìðòõøýþĄąĆćČčĎďĘęĚěĞğŁłŃńŇňŘřŚśŞşŤťŮůŹźŻżĂăıȘșȚțǖǘǚǜǎǐǒǔŐőŰű".unicodeScalars)
|
|
6
|
+
private static let latinClusters = try! NSRegularExpression(pattern: #"\p{Latin}\p{M}*"#)
|
|
7
|
+
|
|
8
|
+
static func sanitizeForDisplay(_ text: String) -> String {
|
|
9
|
+
let output = NSMutableString(string: text)
|
|
10
|
+
let range = NSRange(text.startIndex ..< text.endIndex, in: text)
|
|
11
|
+
for match in latinClusters.matches(in: text, range: range).reversed() {
|
|
12
|
+
let cluster = output.substring(with: match.range)
|
|
13
|
+
let composed = cluster.precomposedStringWithCanonicalMapping
|
|
14
|
+
let scalars = composed.unicodeScalars
|
|
15
|
+
let replacement: String
|
|
16
|
+
if scalars.count == 1, let scalar = scalars.first,
|
|
17
|
+
(0x20 ... 0x7E).contains(scalar.value) || supportedLatin.contains(scalar)
|
|
18
|
+
{
|
|
19
|
+
replacement = composed
|
|
20
|
+
} else {
|
|
21
|
+
replacement = LatinTextSanitizer.sanitizeForDisplay(cluster, preserveOtherScripts: true)
|
|
22
|
+
}
|
|
23
|
+
output.replaceCharacters(in: match.range, with: replacement)
|
|
24
|
+
}
|
|
25
|
+
return output as String
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Empty interior rows shut down some EvenHub firmware pages. Keep the row
|
|
4
|
+
/// without exposing this protocol workaround to miniapps or other devices.
|
|
5
|
+
enum G2Text {
|
|
6
|
+
static func containerContent(_ text: String) -> String {
|
|
7
|
+
if text.isEmpty { return " " }
|
|
8
|
+
return text.components(separatedBy: "\n")
|
|
9
|
+
.map { $0.isEmpty ? "\u{200B}" : $0 }.joined(separator: "\n")
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Converts accented and extended Latin letters to base glyphs at the device boundary.
|
|
4
|
+
enum LatinTextSanitizer {
|
|
5
|
+
private static let latinLettersWithMarks = try! NSRegularExpression(pattern: #"\p{Latin}\p{M}*"#)
|
|
6
|
+
|
|
7
|
+
static func sanitizeForDisplay(_ text: String, preserveOtherScripts: Bool = false) -> String {
|
|
8
|
+
if preserveOtherScripts {
|
|
9
|
+
let output = NSMutableString(string: text)
|
|
10
|
+
let range = NSRange(text.startIndex ..< text.endIndex, in: text)
|
|
11
|
+
for match in latinLettersWithMarks.matches(in: text, range: range).reversed() {
|
|
12
|
+
let cluster = output.substring(with: match.range)
|
|
13
|
+
let expanded = cluster.unicodeScalars.map { expandLatinLetter(Character($0)) }.joined()
|
|
14
|
+
output.replaceCharacters(in: match.range, with: stripDiacritics(expanded))
|
|
15
|
+
}
|
|
16
|
+
return output as String
|
|
17
|
+
}
|
|
18
|
+
// Preserve the established G1 folding behavior.
|
|
19
|
+
let expanded = text.reduce(into: "") { result, character in
|
|
20
|
+
result.append(expandLatinLetter(character))
|
|
21
|
+
}
|
|
22
|
+
return stripDiacritics(expanded)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private static func stripDiacritics(_ text: String) -> String {
|
|
26
|
+
text.folding(options: .diacriticInsensitive, locale: Locale(identifier: "en_US_POSIX"))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private static func expandLatinLetter(_ character: Character) -> String {
|
|
30
|
+
switch character {
|
|
31
|
+
case "Đ", "Ð": return "D"
|
|
32
|
+
case "đ", "ð": return "d"
|
|
33
|
+
case "Ł": return "L"
|
|
34
|
+
case "ł": return "l"
|
|
35
|
+
case "Ø": return "O"
|
|
36
|
+
case "ø": return "o"
|
|
37
|
+
case "Æ": return "AE"
|
|
38
|
+
case "æ": return "ae"
|
|
39
|
+
case "Œ": return "OE"
|
|
40
|
+
case "œ": return "oe"
|
|
41
|
+
case "ẞ": return "SS"
|
|
42
|
+
case "ß": return "ss"
|
|
43
|
+
case "Þ": return "TH"
|
|
44
|
+
case "þ": return "th"
|
|
45
|
+
default: return String(character)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
import XCTest
|
|
3
3
|
|
|
4
4
|
final class BluetoothAudioRouteTests: XCTestCase {
|
|
5
|
+
func testObservationSeparatesAbsentBluetoothFromWrongTarget() {
|
|
6
|
+
let builtIn = BluetoothAudioRoute.observe(
|
|
7
|
+
outputs: [(name: "MacBook Speakers", portType: "Speaker")], target: "03BE", isIOSAppOnMac: true
|
|
8
|
+
)
|
|
9
|
+
let wrongTarget = BluetoothAudioRoute.observe(
|
|
10
|
+
outputs: [(name: "Mentra_Live_023B", portType: "Bluetooth")], target: "03BE", isIOSAppOnMac: true
|
|
11
|
+
)
|
|
12
|
+
XCTAssertEqual(builtIn, .init(bluetoothOutputCount: 0, targetNameOutputCount: 0, matchingOutputCount: 0))
|
|
13
|
+
XCTAssertEqual(wrongTarget, .init(bluetoothOutputCount: 1, targetNameOutputCount: 0, matchingOutputCount: 0))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func testObservationKeepsIdentityAndTransportSeparate() {
|
|
17
|
+
let matching = BluetoothAudioRoute.observe(
|
|
18
|
+
outputs: [(name: "Mentra_Live_03BE", portType: "Bluetooth")], target: "03BE", isIOSAppOnMac: true
|
|
19
|
+
)
|
|
20
|
+
let usb = BluetoothAudioRoute.observe(
|
|
21
|
+
outputs: [(name: "Mentra_Live_03BE", portType: "USBAudio")], target: "03BE", isIOSAppOnMac: true
|
|
22
|
+
)
|
|
23
|
+
let missingTarget = BluetoothAudioRoute.observe(
|
|
24
|
+
outputs: [(name: "Mentra_Live_03BE", portType: "Bluetooth")], target: "", isIOSAppOnMac: true
|
|
25
|
+
)
|
|
26
|
+
XCTAssertEqual(matching, .init(bluetoothOutputCount: 1, targetNameOutputCount: 1, matchingOutputCount: 1))
|
|
27
|
+
XCTAssertEqual(usb, .init(bluetoothOutputCount: 0, targetNameOutputCount: 1, matchingOutputCount: 0))
|
|
28
|
+
XCTAssertEqual(missingTarget, .init(bluetoothOutputCount: 1, targetNameOutputCount: 0, matchingOutputCount: 0))
|
|
29
|
+
}
|
|
30
|
+
|
|
5
31
|
func testRecognizesIPhoneProfiles() {
|
|
6
32
|
for port in ["BluetoothHFP", "BluetoothA2DPOutput"] {
|
|
7
33
|
XCTAssertTrue(BluetoothAudioRoute.matches(
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
final class BluetoothBackgroundModeTests: XCTestCase {
|
|
5
|
+
func testDetectsDeclaredBluetoothCentral() {
|
|
6
|
+
XCTAssertTrue(
|
|
7
|
+
BluetoothBackgroundMode.declaresBluetoothCentral(
|
|
8
|
+
["UIBackgroundModes": ["audio", "bluetooth-central"]]
|
|
9
|
+
)
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
func testReportsMissingOrMalformedBackgroundModes() {
|
|
14
|
+
XCTAssertFalse(BluetoothBackgroundMode.declaresBluetoothCentral(nil))
|
|
15
|
+
XCTAssertFalse(BluetoothBackgroundMode.declaresBluetoothCentral([:]))
|
|
16
|
+
XCTAssertFalse(BluetoothBackgroundMode.declaresBluetoothCentral(["UIBackgroundModes": ["audio"]]))
|
|
17
|
+
XCTAssertFalse(
|
|
18
|
+
BluetoothBackgroundMode.declaresBluetoothCentral(["UIBackgroundModes": "bluetooth-central"])
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func testMacOSHasNoBackgroundModeRequirement() {
|
|
23
|
+
#if os(macOS)
|
|
24
|
+
XCTAssertTrue(BluetoothBackgroundMode.isDeclared(in: nil))
|
|
25
|
+
#endif
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func testWarningPointsToTheDocs() {
|
|
29
|
+
XCTAssertTrue(BluetoothBackgroundMode.missingWarning.contains("bluetooth-central"))
|
|
30
|
+
XCTAssertTrue(BluetoothBackgroundMode.missingWarning.contains("#background-operation"))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -4,8 +4,59 @@ import XCTest
|
|
|
4
4
|
final class BluetoothSdkAnalyticsTrackerTests: XCTestCase {
|
|
5
5
|
private var tracker = BluetoothSdkAnalyticsTracker(simulatedModel: "Simulated Glasses")
|
|
6
6
|
|
|
7
|
-
private func snapshot(
|
|
8
|
-
|
|
7
|
+
private func snapshot(
|
|
8
|
+
connected: Bool,
|
|
9
|
+
model: String = "Mentra Live",
|
|
10
|
+
serial: String = "",
|
|
11
|
+
battery: Int = -1,
|
|
12
|
+
charging: Bool = false
|
|
13
|
+
) -> AnalyticsGlassesSnapshot {
|
|
14
|
+
var snapshot = AnalyticsGlassesSnapshot(connected: connected, fullyBooted: connected, model: model, serialNumber: serial)
|
|
15
|
+
snapshot.batteryLevel = battery
|
|
16
|
+
snapshot.charging = charging
|
|
17
|
+
return snapshot
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func testCountedConnectionReportsItsLengthReasonAndBatteryWhenItEnds() throws {
|
|
21
|
+
tracker.initialize(snapshot(connected: false), reportingDay: 100)
|
|
22
|
+
_ = tracker.observe(snapshot(connected: true), reportingDay: 100, nowMs: 1000)
|
|
23
|
+
_ = tracker.observe(snapshot(connected: true, battery: 80), reportingDay: 100, nowMs: 2000)
|
|
24
|
+
_ = tracker.observe(snapshot(connected: true, battery: 71, charging: true), reportingDay: 100, nowMs: 3000)
|
|
25
|
+
|
|
26
|
+
let ended = tracker.observe(snapshot(connected: false), reportingDay: 100, nowMs: 61000) {
|
|
27
|
+
BluetoothSdkAnalyticsTracker.disconnectUser
|
|
28
|
+
}
|
|
29
|
+
XCTAssertEqual(ended.count, 1)
|
|
30
|
+
let disconnected = try XCTUnwrap(ended.first)
|
|
31
|
+
XCTAssertEqual(disconnected.name, "bluetooth_sdk_glasses_disconnected")
|
|
32
|
+
XCTAssertEqual(disconnected.properties["session_duration_ms"] as? Int64, 60000)
|
|
33
|
+
XCTAssertEqual(disconnected.properties["disconnect_reason"] as? String, "user_disconnect")
|
|
34
|
+
XCTAssertEqual(disconnected.properties["battery_level_at_connect"] as? Int, 80)
|
|
35
|
+
XCTAssertEqual(disconnected.properties["battery_level_at_disconnect"] as? Int, 71)
|
|
36
|
+
XCTAssertEqual(disconnected.properties["charging"] as? Bool, true)
|
|
37
|
+
XCTAssertEqual(disconnected.properties["glasses_model"] as? String, "Mentra Live")
|
|
38
|
+
|
|
39
|
+
// Further disconnected updates do not report the connection again.
|
|
40
|
+
XCTAssertTrue(tracker.observe(snapshot(connected: false), reportingDay: 100, nowMs: 62000).isEmpty)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func testConnectionAlreadyUpWhenTheSdkStartedIsNotReportedAsEnding() {
|
|
44
|
+
tracker.initialize(snapshot(connected: true, serial: "MLAB0001"), reportingDay: 100)
|
|
45
|
+
XCTAssertTrue(tracker.observe(snapshot(connected: false), reportingDay: 100, nowMs: 5000).isEmpty)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func testBatteryFromAPreviousConnectionDoesNotLeakIntoTheNextOne() throws {
|
|
49
|
+
tracker.initialize(snapshot(connected: false), reportingDay: 100)
|
|
50
|
+
_ = tracker.observe(snapshot(connected: true, battery: 90), reportingDay: 100, nowMs: 0)
|
|
51
|
+
_ = tracker.observe(snapshot(connected: false), reportingDay: 100, nowMs: 1000)
|
|
52
|
+
_ = tracker.observe(snapshot(connected: true), reportingDay: 100, nowMs: 2000)
|
|
53
|
+
let ended = tracker.observe(snapshot(connected: false), reportingDay: 100, nowMs: 3000)
|
|
54
|
+
XCTAssertEqual(ended.count, 1)
|
|
55
|
+
let second = try XCTUnwrap(ended.first)
|
|
56
|
+
XCTAssertNil(second.properties["battery_level_at_connect"])
|
|
57
|
+
XCTAssertNil(second.properties["battery_level_at_disconnect"])
|
|
58
|
+
XCTAssertNil(second.properties["charging"])
|
|
59
|
+
XCTAssertEqual(second.properties["disconnect_reason"] as? String, "unknown")
|
|
9
60
|
}
|
|
10
61
|
|
|
11
62
|
func testConnectThenSerialEmitsEachOncePerConnection() {
|
|
@@ -40,7 +91,7 @@ final class BluetoothSdkAnalyticsTrackerTests: XCTestCase {
|
|
|
40
91
|
tracker.initialize(snapshot(connected: false), reportingDay: 100)
|
|
41
92
|
_ = tracker.observe(snapshot(connected: true, model: ""), reportingDay: 100)
|
|
42
93
|
let ended = tracker.observe(snapshot(connected: false, model: ""), reportingDay: 100)
|
|
43
|
-
XCTAssertEqual(ended.map(\.name), ["bluetooth_sdk_glasses_connected"])
|
|
94
|
+
XCTAssertEqual(ended.map(\.name), ["bluetooth_sdk_glasses_connected", "bluetooth_sdk_glasses_disconnected"])
|
|
44
95
|
XCTAssertEqual(ended[0].properties["glasses_model_unresolved"] as? Bool, true)
|
|
45
96
|
XCTAssertNil(ended[0].properties["glasses_model"])
|
|
46
97
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
final class BluetoothSdkUpdateCheckTests: XCTestCase {
|
|
5
|
+
/// Collects values handed to `@Sendable` callbacks from the check's queue.
|
|
6
|
+
private final class Recorder<Value>: @unchecked Sendable {
|
|
7
|
+
private let lock = NSLock()
|
|
8
|
+
private var stored: [Value] = []
|
|
9
|
+
|
|
10
|
+
var values: [Value] {
|
|
11
|
+
lock.lock()
|
|
12
|
+
defer { lock.unlock() }
|
|
13
|
+
return stored
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func append(_ value: Value) {
|
|
17
|
+
lock.lock()
|
|
18
|
+
stored.append(value)
|
|
19
|
+
lock.unlock()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private final class CheckBox: @unchecked Sendable {
|
|
24
|
+
var check: BluetoothSdkUpdateCheck?
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private func result(
|
|
28
|
+
_ status: String,
|
|
29
|
+
latest: String? = "3.2.1",
|
|
30
|
+
minimum: String? = nil
|
|
31
|
+
) -> BluetoothSdkUpdateCheckResult {
|
|
32
|
+
BluetoothSdkUpdateCheckResult(status: status, channel: "latest", latestVersion: latest, minimumSupportedVersion: minimum)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func testParsesTheCoreResponseTreatingNullAndBlankVersionsAsUnknown() {
|
|
36
|
+
let parsed = BluetoothSdkUpdateCheckResult.parse(
|
|
37
|
+
#"{"status":"update_available","channel":"dev","latestVersion":"3.3.0-dev.821","minimumSupportedVersion":null}"#
|
|
38
|
+
)
|
|
39
|
+
XCTAssertEqual(
|
|
40
|
+
parsed,
|
|
41
|
+
BluetoothSdkUpdateCheckResult(status: "update_available", channel: "dev", latestVersion: "3.3.0-dev.821", minimumSupportedVersion: nil)
|
|
42
|
+
)
|
|
43
|
+
XCTAssertNil(BluetoothSdkUpdateCheckResult.parse(#"{"status":"current","latestVersion":""}"#)?.latestVersion)
|
|
44
|
+
XCTAssertNotNil(BluetoothSdkUpdateCheckResult.parse(#"{"status":"current","latestVersion":""}"#))
|
|
45
|
+
XCTAssertNil(BluetoothSdkUpdateCheckResult.parse("<html>502</html>"))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func testBannerNamesInstalledAndLatestVersionsAndTheSurfaceUpdateCommand() throws {
|
|
49
|
+
let reactNative = try XCTUnwrap(BluetoothSdkUpdateNotice.banner(installedVersion: "3.1.0", result: result("update_available"), surface: "react_native"))
|
|
50
|
+
XCTAssertTrue(reactNative.contains("MENTRA BLUETOOTH SDK UPDATE AVAILABLE"))
|
|
51
|
+
XCTAssertTrue(reactNative.contains("Installed: 3.1.0"))
|
|
52
|
+
XCTAssertTrue(reactNative.contains("Latest: 3.2.1"))
|
|
53
|
+
XCTAssertTrue(reactNative.contains("bun add @mentra/bluetooth-sdk@3.2.1"))
|
|
54
|
+
XCTAssertFalse(reactNative.contains("NO LONGER SUPPORTED"))
|
|
55
|
+
XCTAssertTrue(reactNative.hasPrefix("=================================================================="))
|
|
56
|
+
XCTAssertTrue(reactNative.hasSuffix("=================================================================="))
|
|
57
|
+
|
|
58
|
+
let ios = try XCTUnwrap(BluetoothSdkUpdateNotice.banner(installedVersion: "3.1.0", result: result("update_available"), surface: "ios"))
|
|
59
|
+
XCTAssertTrue(ios.contains("MentraBluetoothSDK Swift package at 3.2.1"))
|
|
60
|
+
let android = try XCTUnwrap(BluetoothSdkUpdateNotice.banner(installedVersion: "3.1.0", result: result("update_available"), surface: "android"))
|
|
61
|
+
XCTAssertTrue(android.contains("com.mentraglass:bluetooth-sdk:3.2.1"))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func testUnsupportedVersionGetsTheStrongerBanner() throws {
|
|
65
|
+
let banner = try XCTUnwrap(BluetoothSdkUpdateNotice.banner(
|
|
66
|
+
installedVersion: "3.0.5",
|
|
67
|
+
result: result("unsupported", minimum: "3.1.0"),
|
|
68
|
+
surface: "ios"
|
|
69
|
+
))
|
|
70
|
+
XCTAssertTrue(banner.contains("MENTRA BLUETOOTH SDK 3.0.5 IS NO LONGER SUPPORTED"))
|
|
71
|
+
XCTAssertTrue(banner.contains("known issues"))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func testNoBannerWhenCurrentUnknownOrNoLatestVersionToPointAt() {
|
|
75
|
+
XCTAssertNil(BluetoothSdkUpdateNotice.banner(installedVersion: "3.2.1", result: result("current"), surface: "ios"))
|
|
76
|
+
XCTAssertNil(BluetoothSdkUpdateNotice.banner(installedVersion: "3.2.1", result: result("unknown", latest: nil), surface: "ios"))
|
|
77
|
+
XCTAssertNil(BluetoothSdkUpdateNotice.banner(installedVersion: "3.2.1", result: result("update_available", latest: nil), surface: "ios"))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func testSurfaceFollowsTheIntegration() {
|
|
81
|
+
XCTAssertEqual(BluetoothSdkUpdateCheck.surface(forAnalyticsSurface: "react_native"), "react_native")
|
|
82
|
+
XCTAssertEqual(BluetoothSdkUpdateCheck.surface(forAnalyticsSurface: "ios"), "ios")
|
|
83
|
+
XCTAssertEqual(BluetoothSdkUpdateCheck.surface(forAnalyticsSurface: "macos"), "ios")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func testRequestIdentifiesTheInstallAppAndSdk() throws {
|
|
87
|
+
let check = BluetoothSdkUpdateCheck(surface: "react_native", sdkVersion: "3.1.0", transport: { _, completion in completion(nil) }) { _, _ in }
|
|
88
|
+
let body = check.requestBody()
|
|
89
|
+
let installId = try XCTUnwrap(body["install_id"] as? String)
|
|
90
|
+
XCTAssertEqual(installId, BluetoothSdkInstallId.get())
|
|
91
|
+
XCTAssertTrue(installId.hasPrefix("mentra-bt-sdk-"))
|
|
92
|
+
XCTAssertEqual(body["app_identifier"] as? String, Bundle.main.bundleIdentifier ?? "")
|
|
93
|
+
XCTAssertEqual(body["sdk_version"] as? String, "3.1.0")
|
|
94
|
+
XCTAssertEqual(body["sdk_platform"] as? String, "ios")
|
|
95
|
+
XCTAssertEqual(body["sdk_surface"] as? String, "react_native")
|
|
96
|
+
XCTAssertNotNil(body["app_build_type"])
|
|
97
|
+
XCTAssertNotNil(body["app_install_source"])
|
|
98
|
+
XCTAssertTrue(JSONSerialization.isValidJSONObject(body))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func testOutOfDateSdkPrintsTheBannerOnceAtStartAndStopsOnClose() throws {
|
|
102
|
+
let notices = Recorder<String>()
|
|
103
|
+
let sentBodies = Recorder<Data>()
|
|
104
|
+
let check = BluetoothSdkUpdateCheck(
|
|
105
|
+
surface: "ios",
|
|
106
|
+
sdkVersion: "3.1.0",
|
|
107
|
+
transport: { body, completion in
|
|
108
|
+
sentBodies.append(body)
|
|
109
|
+
completion(Data(#"{"status":"update_available","channel":"latest","latestVersion":"3.2.1","minimumSupportedVersion":null}"#.utf8))
|
|
110
|
+
}
|
|
111
|
+
) { banner, _ in notices.append(banner) }
|
|
112
|
+
check.start()
|
|
113
|
+
BluetoothSdkUpdateCheck.awaitIdle()
|
|
114
|
+
XCTAssertEqual(notices.values.count, 1)
|
|
115
|
+
XCTAssertTrue(notices.values.first?.contains("Latest: 3.2.1") == true)
|
|
116
|
+
let sentBody = try XCTUnwrap(sentBodies.values.first)
|
|
117
|
+
let sent = try XCTUnwrap(JSONSerialization.jsonObject(with: sentBody) as? [String: Any])
|
|
118
|
+
XCTAssertEqual(sent["sdk_version"] as? String, "3.1.0")
|
|
119
|
+
check.stop()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func testFailuresAndUnknownVersionsAreSilent() {
|
|
123
|
+
let notices = Recorder<String>()
|
|
124
|
+
let calls = Recorder<Bool>()
|
|
125
|
+
let checks = [
|
|
126
|
+
BluetoothSdkUpdateCheck(surface: "ios", sdkVersion: "3.1.0", transport: { _, completion in completion(nil) }) { banner, _ in notices.append(banner) },
|
|
127
|
+
BluetoothSdkUpdateCheck(surface: "ios", sdkVersion: "3.1.0", transport: { _, completion in completion(Data("<html>502</html>".utf8)) }) { banner, _ in notices.append(banner) },
|
|
128
|
+
BluetoothSdkUpdateCheck(surface: "ios", sdkVersion: "unknown", transport: { _, completion in calls.append(true); completion(nil) }) { banner, _ in notices.append(banner) },
|
|
129
|
+
BluetoothSdkUpdateCheck(surface: "ios", sdkVersion: nil, transport: { _, completion in calls.append(true); completion(nil) }) { banner, _ in notices.append(banner) },
|
|
130
|
+
]
|
|
131
|
+
checks.forEach { $0.start() }
|
|
132
|
+
BluetoothSdkUpdateCheck.awaitIdle()
|
|
133
|
+
XCTAssertTrue(notices.values.isEmpty)
|
|
134
|
+
XCTAssertTrue(calls.values.isEmpty)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func testCheckStoppedBeforeTheResponseArrivesPrintsNothing() {
|
|
138
|
+
let notices = Recorder<String>()
|
|
139
|
+
let box = CheckBox()
|
|
140
|
+
let check = BluetoothSdkUpdateCheck(
|
|
141
|
+
surface: "ios",
|
|
142
|
+
sdkVersion: "3.1.0",
|
|
143
|
+
transport: { _, completion in
|
|
144
|
+
box.check?.stop()
|
|
145
|
+
completion(Data(#"{"status":"update_available","latestVersion":"3.2.1"}"#.utf8))
|
|
146
|
+
}
|
|
147
|
+
) { banner, _ in notices.append(banner) }
|
|
148
|
+
box.check = check
|
|
149
|
+
check.start()
|
|
150
|
+
BluetoothSdkUpdateCheck.awaitIdle()
|
|
151
|
+
XCTAssertTrue(notices.values.isEmpty)
|
|
152
|
+
}
|
|
153
|
+
}
|