@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.
Files changed (191) hide show
  1. package/README.md +72 -10
  2. package/android/build.gradle +15 -0
  3. package/android/lc3Lib/build.gradle +6 -1
  4. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  5. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +4 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +8 -2
  11. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +133 -26
  15. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  16. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +3 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  18. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +227 -36
  19. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  22. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  24. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  31. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
  32. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +194 -40
  33. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  34. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  35. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +247 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  37. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  42. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  43. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  45. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  48. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  50. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  51. package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
  52. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
  53. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  54. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  55. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  56. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
  57. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  58. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
  59. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
  62. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  63. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  64. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
  65. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  66. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  67. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  68. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  69. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +233 -0
  70. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  71. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  72. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  73. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  74. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  75. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  76. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  77. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  78. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  79. package/build/BluetoothSdk.types.d.ts +16 -0
  80. package/build/BluetoothSdk.types.d.ts.map +1 -1
  81. package/build/BluetoothSdk.types.js +1 -0
  82. package/build/BluetoothSdk.types.js.map +1 -1
  83. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  84. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  85. package/build/_private/BluetoothSdkModule.js +5 -1
  86. package/build/_private/BluetoothSdkModule.js.map +1 -1
  87. package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
  88. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  89. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  90. package/build/_private/nativeLogConsole.d.ts +19 -0
  91. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  92. package/build/_private/nativeLogConsole.js +12 -0
  93. package/build/_private/nativeLogConsole.js.map +1 -1
  94. package/build/generated/changelogCatalog.d.ts +5 -2
  95. package/build/generated/changelogCatalog.d.ts.map +1 -1
  96. package/build/generated/changelogCatalog.js +6 -2
  97. package/build/generated/changelogCatalog.js.map +1 -1
  98. package/build/generated/releaseMetadata.js +6 -6
  99. package/build/generated/releaseMetadata.js.map +1 -1
  100. package/build/index.d.ts.map +1 -1
  101. package/build/index.js +1 -0
  102. package/build/index.js.map +1 -1
  103. package/build/ota-transport/index.d.ts +4 -0
  104. package/build/ota-transport/index.d.ts.map +1 -1
  105. package/build/ota-transport/index.js +2 -0
  106. package/build/ota-transport/index.js.map +1 -1
  107. package/ios/BluetoothSdkModule.swift +17 -4
  108. package/ios/MentraBluetoothSDK.podspec +3 -2
  109. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  110. package/ios/Source/BluetoothSdkDefaults.swift +7 -2
  111. package/ios/Source/Bridge.swift +15 -2
  112. package/ios/Source/DeviceManager.swift +102 -66
  113. package/ios/Source/DeviceStore.swift +26 -7
  114. package/ios/Source/GeneratedChangelogCatalog.swift +3 -2
  115. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  116. package/ios/Source/MentraBluetoothSDK.swift +313 -54
  117. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  118. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  119. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  120. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  121. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  122. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  123. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  124. package/ios/Source/requests/DisplayRequests.swift +1 -1
  125. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  126. package/ios/Source/sgcs/Ar99.swift +1 -2
  127. package/ios/Source/sgcs/G1.swift +68 -38
  128. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  129. package/ios/Source/sgcs/G2.swift +261 -247
  130. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  131. package/ios/Source/sgcs/Mach1.swift +19 -7
  132. package/ios/Source/sgcs/MentraLive.swift +272 -197
  133. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  134. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  135. package/ios/Source/sgcs/MentraNex.swift +8 -31
  136. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  137. package/ios/Source/sgcs/Nimo.swift +280 -408
  138. package/ios/Source/sgcs/NimoCanvasCodec.swift +272 -0
  139. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  140. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  141. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  142. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  143. package/ios/Source/sgcs/SGCManager.swift +25 -1
  144. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  145. package/ios/Source/status/DeviceStatus.swift +1 -0
  146. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  147. package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
  148. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  149. package/ios/Source/utils/G1Text.swift +2 -24
  150. package/ios/Source/utils/G1TextSanitizer.swift +27 -0
  151. package/ios/Source/utils/G2Text.swift +11 -0
  152. package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
  153. package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
  154. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  155. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  156. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  157. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  158. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  159. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  160. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  161. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  162. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  163. package/ios/Tests/G1PairingTests.swift +162 -0
  164. package/ios/Tests/G1TextSanitizerTests.swift +32 -0
  165. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  166. package/ios/Tests/G2TextTests.swift +10 -0
  167. package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
  168. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  169. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  170. package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
  171. package/ios/Tests/NimoCanvasCodecTests.swift +217 -0
  172. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  173. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  174. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  175. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  176. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  177. package/ios/Tests/VersionInfoCommandTests.swift +194 -0
  178. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  179. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  180. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  181. package/package.json +2 -2
  182. package/plugin/build/withIos.d.ts +2 -0
  183. package/plugin/build/withIos.js +12 -1
  184. package/src/BluetoothSdk.types.ts +15 -0
  185. package/src/_private/BluetoothSdkModule.ts +10 -1
  186. package/src/_private/MentraLocalNetworkModule.ts +28 -0
  187. package/src/_private/nativeLogConsole.ts +19 -0
  188. package/src/generated/changelogCatalog.ts +6 -2
  189. package/src/generated/releaseMetadata.ts +6 -6
  190. package/src/index.ts +1 -0
  191. 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 isAudioDeviceConnected(devicePattern: devicePattern) {
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 = session.availableInputs else {
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
- guard !target.isEmpty, name.localizedCaseInsensitiveContains(target) else { return false }
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
- /// Converts text to the base Latin glyphs available in G1 firmware.
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
- let expanded = text.reduce(into: "") { result, character in
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(connected: Bool, model: String = "Mentra Live", serial: String = "") -> AnalyticsGlassesSnapshot {
8
- AnalyticsGlassesSnapshot(connected: connected, fullyBooted: connected, model: model, serialNumber: serial)
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
+ }