@mentra/bluetooth-sdk 3.2.1 → 3.3.0-beta.954

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 (95) hide show
  1. package/README.md +68 -6
  2. package/android/build.gradle +15 -0
  3. package/android/lc3Lib/build.gradle +6 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -2
  9. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +4 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -1
  13. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  14. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +223 -36
  15. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +93 -18
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +3 -1
  24. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  25. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
  29. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
  32. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +20 -7
  36. package/build/BluetoothSdk.types.d.ts +3 -0
  37. package/build/BluetoothSdk.types.d.ts.map +1 -1
  38. package/build/BluetoothSdk.types.js.map +1 -1
  39. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  40. package/build/_private/BluetoothSdkModule.js +2 -1
  41. package/build/_private/BluetoothSdkModule.js.map +1 -1
  42. package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
  43. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  44. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  45. package/build/_private/nativeLogConsole.d.ts +19 -0
  46. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  47. package/build/_private/nativeLogConsole.js +12 -0
  48. package/build/_private/nativeLogConsole.js.map +1 -1
  49. package/build/generated/changelogCatalog.d.ts +4 -1
  50. package/build/generated/changelogCatalog.d.ts.map +1 -1
  51. package/build/generated/changelogCatalog.js +5 -1
  52. package/build/generated/changelogCatalog.js.map +1 -1
  53. package/build/generated/releaseMetadata.js +6 -6
  54. package/build/generated/releaseMetadata.js.map +1 -1
  55. package/build/ota-transport/index.d.ts +4 -0
  56. package/build/ota-transport/index.d.ts.map +1 -1
  57. package/build/ota-transport/index.js +2 -0
  58. package/build/ota-transport/index.js.map +1 -1
  59. package/ios/BluetoothSdkModule.swift +8 -4
  60. package/ios/Source/BluetoothSdkDefaults.swift +2 -2
  61. package/ios/Source/DeviceManager.swift +6 -3
  62. package/ios/Source/GeneratedChangelogCatalog.swift +2 -1
  63. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  64. package/ios/Source/MentraBluetoothSDK.swift +295 -54
  65. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  66. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  67. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  68. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  69. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  70. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  71. package/ios/Source/sgcs/MentraLive.swift +71 -41
  72. package/ios/Source/sgcs/NimoCanvasCodec.swift +4 -1
  73. package/ios/Source/sgcs/SGCManager.swift +1 -1
  74. package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
  75. package/ios/Source/utils/G1Text.swift +2 -24
  76. package/ios/Source/utils/G1TextSanitizer.swift +27 -0
  77. package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
  78. package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
  79. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  80. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  81. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  82. package/ios/Tests/G1TextSanitizerTests.swift +32 -0
  83. package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
  84. package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
  85. package/ios/Tests/NimoCanvasCodecTests.swift +17 -4
  86. package/ios/Tests/VersionInfoCommandTests.swift +142 -1
  87. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  88. package/package.json +2 -2
  89. package/src/BluetoothSdk.types.ts +1 -0
  90. package/src/_private/BluetoothSdkModule.ts +2 -1
  91. package/src/_private/MentraLocalNetworkModule.ts +28 -0
  92. package/src/_private/nativeLogConsole.ts +19 -0
  93. package/src/generated/changelogCatalog.ts +5 -1
  94. package/src/generated/releaseMetadata.ts +6 -6
  95. package/src/ota-transport/index.ts +10 -1
@@ -77,7 +77,10 @@ enum NimoCanvasCodec {
77
77
  {
78
78
  try region(x, y, w, h)
79
79
  try require((0 ... 3).contains(font) && (0 ... 20).contains(language) && (0 ... 255).contains(intensity))
80
- let utf8 = Data(text.utf8)
80
+ // Apply the shared Latin fallback and NIMO's dash fix before counting wire bytes.
81
+ let displayText = LatinTextSanitizer.sanitizeForDisplay(text, preserveOtherScripts: true)
82
+ .replacingOccurrences(of: "\u{2014}", with: "-")
83
+ let utf8 = Data(displayText.utf8)
81
84
  try require((1 ... 2048).contains(utf8.count) && !utf8.contains(0), "Label exceeds 2048 UTF-8 bytes or contains NUL")
82
85
  return Object(type: 3, payload: words(x, y, w, h, language) + bytes(font, intensity) + words(utf8.count) + utf8,
83
86
  textBytes: utf8.count)
@@ -217,7 +217,7 @@ protocol SGCManager {
217
217
 
218
218
  // MARK: - Incident Reporting
219
219
 
220
- func sendIncidentId(_ incidentId: String, apiBaseUrl: String?)
220
+ func sendIncidentId(_ incidentId: String, apiBaseUrl: String?) throws
221
221
 
222
222
  // MARK: - Gallery
223
223
 
@@ -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
  }
@@ -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,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(
@@ -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
+ }