@mentra/bluetooth-sdk 3.2.1 → 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 +68 -6
- package/android/build.gradle +15 -0
- package/android/lc3Lib/build.gradle +6 -1
- 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 +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -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/DeviceManager.kt +4 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +223 -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/debug/BleTraceLogger.kt +3 -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/G1TextSanitizer.kt +11 -32
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +93 -18
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +3 -1
- 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/NetworkDiagnosticsTest.kt +55 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +20 -7
- package/build/BluetoothSdk.types.d.ts +3 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +2 -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 +4 -1
- package/build/generated/changelogCatalog.d.ts.map +1 -1
- package/build/generated/changelogCatalog.js +5 -1
- 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/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 +8 -4
- package/ios/Source/BluetoothSdkDefaults.swift +2 -2
- package/ios/Source/DeviceManager.swift +6 -3
- package/ios/Source/GeneratedChangelogCatalog.swift +2 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
- package/ios/Source/MentraBluetoothSDK.swift +295 -54
- 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/sgcs/MentraLive.swift +71 -41
- package/ios/Source/sgcs/NimoCanvasCodec.swift +4 -1
- package/ios/Source/sgcs/SGCManager.swift +1 -1
- package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
- package/ios/Source/utils/G1Text.swift +2 -24
- package/ios/Source/utils/G1TextSanitizer.swift +27 -0
- package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
- package/ios/Tests/BluetoothAudioRouteTests.swift +26 -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/G1TextSanitizerTests.swift +32 -0
- package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
- package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
- package/ios/Tests/NimoCanvasCodecTests.swift +17 -4
- package/ios/Tests/VersionInfoCommandTests.swift +142 -1
- package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +1 -0
- package/src/_private/BluetoothSdkModule.ts +2 -1
- package/src/_private/MentraLocalNetworkModule.ts +28 -0
- package/src/_private/nativeLogConsole.ts +19 -0
- package/src/generated/changelogCatalog.ts +5 -1
- package/src/generated/releaseMetadata.ts +6 -6
- 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
|
-
|
|
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)
|
|
@@ -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
|
}
|
|
@@ -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,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(
|
|
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
|
+
}
|