@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.
- 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
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
final class BluetoothSdkUsageTrackerTests: XCTestCase {
|
|
5
|
+
private var tracker = BluetoothSdkUsageTracker()
|
|
6
|
+
|
|
7
|
+
func testApiPhotoReportsItsRequestShapeLatencyAndOutcome() {
|
|
8
|
+
let ok = tracker.photoSettled(
|
|
9
|
+
size: "medium", mode: "photo", compress: "none", save: true, transferMethod: "auto",
|
|
10
|
+
latencyMs: 812, fileSizeBytes: 204_800, errorCode: nil
|
|
11
|
+
)
|
|
12
|
+
XCTAssertEqual(ok.name, "bluetooth_sdk_photo_taken")
|
|
13
|
+
XCTAssertEqual(ok.properties["trigger"] as? String, "api")
|
|
14
|
+
XCTAssertEqual(ok.properties["success"] as? Bool, true)
|
|
15
|
+
XCTAssertEqual(ok.properties["latency_ms"] as? Int64, 812)
|
|
16
|
+
XCTAssertEqual(ok.properties["photo_size"] as? String, "medium")
|
|
17
|
+
XCTAssertEqual(ok.properties["save_to_gallery"] as? Bool, true)
|
|
18
|
+
XCTAssertEqual(ok.properties["transfer_method"] as? String, "auto")
|
|
19
|
+
XCTAssertEqual(ok.properties["file_size_bytes"] as? Int64, 204_800)
|
|
20
|
+
XCTAssertNil(ok.properties["error_code"])
|
|
21
|
+
|
|
22
|
+
let failed = tracker.photoSettled(
|
|
23
|
+
size: "max", mode: "photo", compress: "none", save: false, transferMethod: "ble",
|
|
24
|
+
latencyMs: 30000, fileSizeBytes: nil, errorCode: "request_timeout"
|
|
25
|
+
)
|
|
26
|
+
XCTAssertEqual(failed.properties["success"] as? Bool, false)
|
|
27
|
+
XCTAssertEqual(failed.properties["error_code"] as? String, "request_timeout")
|
|
28
|
+
XCTAssertNil(failed.properties["file_size_bytes"])
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func testFailedButtonPhotoIsReportedAsButtonTrigger() {
|
|
32
|
+
let event = tracker.buttonPhotoFailed(errorCode: "CAMERA_BUSY")
|
|
33
|
+
XCTAssertEqual(event.properties["trigger"] as? String, "button")
|
|
34
|
+
XCTAssertEqual(event.properties["success"] as? Bool, false)
|
|
35
|
+
XCTAssertEqual(event.properties["error_code"] as? String, "CAMERA_BUSY")
|
|
36
|
+
XCTAssertEqual(tracker.buttonPhotoFailed(errorCode: nil).properties["error_code"] as? String, "photo_request_failed")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func testApiRecordingReportsLengthParametersAndAppStopOnce() throws {
|
|
40
|
+
tracker.videoRequested(requestId: "rec-1", width: 1920, height: 1080, fps: 30, save: true, maxDurationMinutes: 0)
|
|
41
|
+
XCTAssertNil(tracker.videoStatus(requestId: "rec-1", status: "recording_started", success: true, nowMs: 1000))
|
|
42
|
+
// A duplicate start does not move the start time.
|
|
43
|
+
XCTAssertNil(tracker.videoStatus(requestId: "rec-1", status: "recording_started", success: true, nowMs: 2000))
|
|
44
|
+
tracker.videoStopRequested(requestId: "rec-1")
|
|
45
|
+
|
|
46
|
+
let stopped = try XCTUnwrap(tracker.videoStatus(requestId: "rec-1", status: "recording_stopped", success: true, nowMs: 61000))
|
|
47
|
+
XCTAssertEqual(stopped.name, "bluetooth_sdk_video_recorded")
|
|
48
|
+
XCTAssertEqual(stopped.properties["trigger"] as? String, "api")
|
|
49
|
+
XCTAssertEqual(stopped.properties["success"] as? Bool, true)
|
|
50
|
+
XCTAssertEqual(stopped.properties["duration_ms"] as? Int64, 60000)
|
|
51
|
+
XCTAssertEqual(stopped.properties["width"] as? Int, 1920)
|
|
52
|
+
XCTAssertEqual(stopped.properties["fps"] as? Int, 30)
|
|
53
|
+
XCTAssertEqual(stopped.properties["save_to_gallery"] as? Bool, true)
|
|
54
|
+
XCTAssertEqual(stopped.properties["stopped_by_app"] as? Bool, true)
|
|
55
|
+
XCTAssertNil(stopped.properties["max_duration_minutes"])
|
|
56
|
+
|
|
57
|
+
XCTAssertNil(tracker.videoStatus(requestId: "rec-1", status: "recording_stopped", success: true, nowMs: 62000))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func testButtonRecordingIsReportedWithButtonTriggerAndNoAppStop() throws {
|
|
61
|
+
let id = "local_video_1728000000_ab12"
|
|
62
|
+
_ = tracker.videoStatus(requestId: id, status: "recording_started", success: true, nowMs: 0)
|
|
63
|
+
let stopped = try XCTUnwrap(tracker.videoStatus(requestId: id, status: "recording_stopped", success: true, nowMs: 5000))
|
|
64
|
+
XCTAssertEqual(stopped.properties["trigger"] as? String, "button")
|
|
65
|
+
XCTAssertEqual(stopped.properties["duration_ms"] as? Int64, 5000)
|
|
66
|
+
XCTAssertEqual(stopped.properties["stopped_by_app"] as? Bool, false)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func testRecordingFailureIsReportedButNotRecordingIsNotARecording() throws {
|
|
70
|
+
tracker.videoRequested(requestId: "rec-2", width: 0, height: 0, fps: 0, save: false, maxDurationMinutes: 5)
|
|
71
|
+
let failed = try XCTUnwrap(tracker.videoStatus(requestId: "rec-2", status: "battery_low", success: false, nowMs: 0))
|
|
72
|
+
XCTAssertEqual(failed.properties["success"] as? Bool, false)
|
|
73
|
+
XCTAssertEqual(failed.properties["error_code"] as? String, "battery_low")
|
|
74
|
+
XCTAssertEqual(failed.properties["max_duration_minutes"] as? Int, 5)
|
|
75
|
+
XCTAssertNil(failed.properties["duration_ms"])
|
|
76
|
+
XCTAssertNil(failed.properties["width"])
|
|
77
|
+
|
|
78
|
+
XCTAssertNil(tracker.videoStatus(requestId: "rec-3", status: "not_recording", success: false, nowMs: 0))
|
|
79
|
+
XCTAssertNil(tracker.videoStatus(requestId: "", status: "recording_stopped", success: true, nowMs: 0))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func testStreamIsReportedAtStartAndOnceAtItsTerminalStatus() throws {
|
|
83
|
+
XCTAssertNil(tracker.streamRequested(streamId: "s1", streamUrl: "rtmps://live.example.com/app/secret-key", nowMs: 6600))
|
|
84
|
+
// The first live status can arrive before startStream returns.
|
|
85
|
+
XCTAssertNil(stream("streaming", nowMs: 10000))
|
|
86
|
+
let started = tracker.streamStartSettled(
|
|
87
|
+
streamUrl: "rtmps://live.example.com/app/secret-key",
|
|
88
|
+
width: 1280,
|
|
89
|
+
height: 720,
|
|
90
|
+
fps: 30,
|
|
91
|
+
bitrate: 2_000_000,
|
|
92
|
+
captureAudio: true,
|
|
93
|
+
glassesNetwork: "wifi",
|
|
94
|
+
latencyMs: 3400,
|
|
95
|
+
errorCode: nil
|
|
96
|
+
)
|
|
97
|
+
XCTAssertEqual(started.name, "bluetooth_sdk_stream_started")
|
|
98
|
+
XCTAssertEqual(started.properties["success"] as? Bool, true)
|
|
99
|
+
XCTAssertEqual(started.properties["protocol"] as? String, "rtmp")
|
|
100
|
+
XCTAssertEqual(started.properties["glasses_network"] as? String, "wifi")
|
|
101
|
+
XCTAssertFalse(started.properties.values.contains { "\($0)".contains("secret-key") })
|
|
102
|
+
|
|
103
|
+
XCTAssertNil(stream("reconnecting", reconnectAttempt: 1, nowMs: 20000))
|
|
104
|
+
XCTAssertNil(stream("reconnected", reconnectAttempt: 2, nowMs: 21000))
|
|
105
|
+
XCTAssertNil(stream("streaming", bitrate: 1_800_000, droppedFrames: 12, nowMs: 30000))
|
|
106
|
+
// Statuses for another stream id do not touch this one.
|
|
107
|
+
XCTAssertNil(tracker.streamStatus(
|
|
108
|
+
streamId: "other", state: "stopped", terminal: true, reconnectAttempt: nil, bitrate: nil, fps: nil,
|
|
109
|
+
droppedFrames: nil, temperatureC: nil, errorDetails: nil, nowMs: 31000
|
|
110
|
+
))
|
|
111
|
+
tracker.streamStopRequested()
|
|
112
|
+
|
|
113
|
+
let ended = try XCTUnwrap(stream("stopped", terminal: true, nowMs: 70000))
|
|
114
|
+
XCTAssertEqual(ended.name, "bluetooth_sdk_stream_ended")
|
|
115
|
+
XCTAssertEqual(ended.properties["protocol"] as? String, "rtmp")
|
|
116
|
+
XCTAssertEqual(ended.properties["duration_ms"] as? Int64, 60000)
|
|
117
|
+
XCTAssertEqual(ended.properties["end_state"] as? String, "stopped")
|
|
118
|
+
XCTAssertEqual(ended.properties["stopped_by_app"] as? Bool, true)
|
|
119
|
+
XCTAssertEqual(ended.properties["reconnect_attempts"] as? Int, 2)
|
|
120
|
+
XCTAssertEqual(ended.properties["bitrate"] as? Int64, 1_800_000)
|
|
121
|
+
XCTAssertEqual(ended.properties["dropped_frames"] as? Int64, 12)
|
|
122
|
+
|
|
123
|
+
XCTAssertNil(stream("stopped", terminal: true, nowMs: 71000))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func testStreamThatEndsRightAfterGoingLiveIsStillReported() throws {
|
|
127
|
+
_ = tracker.streamRequested(streamId: "s1", streamUrl: "rtmp://host/app", nowMs: 0)
|
|
128
|
+
XCTAssertNil(stream("streaming", nowMs: 1000))
|
|
129
|
+
let ended = try XCTUnwrap(stream("error", terminal: true, nowMs: 1500))
|
|
130
|
+
XCTAssertEqual(ended.properties["duration_ms"] as? Int64, 500)
|
|
131
|
+
XCTAssertEqual(ended.properties["end_state"] as? String, "error")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func testNewStartReportsTheLiveStreamItReplaces() throws {
|
|
135
|
+
_ = tracker.streamRequested(streamId: "s1", streamUrl: "rtmp://host/app", nowMs: 0)
|
|
136
|
+
_ = stream("streaming", nowMs: 1000)
|
|
137
|
+
let replaced = try XCTUnwrap(tracker.streamRequested(streamId: "s2", streamUrl: "srt://host:9000", nowMs: 11000))
|
|
138
|
+
XCTAssertEqual(replaced.name, "bluetooth_sdk_stream_ended")
|
|
139
|
+
XCTAssertEqual(replaced.properties["protocol"] as? String, "rtmp")
|
|
140
|
+
XCTAssertEqual(replaced.properties["end_state"] as? String, "replaced")
|
|
141
|
+
XCTAssertEqual(replaced.properties["duration_ms"] as? Int64, 10000)
|
|
142
|
+
// The replaced stream's own late terminal status is not reported again.
|
|
143
|
+
XCTAssertNil(stream("stopped", terminal: true, nowMs: 12000))
|
|
144
|
+
XCTAssertNil(tracker.streamStatus(
|
|
145
|
+
streamId: "s2", state: "streaming", terminal: nil, reconnectAttempt: nil, bitrate: nil, fps: nil,
|
|
146
|
+
droppedFrames: nil, temperatureC: nil, errorDetails: nil, nowMs: 12000
|
|
147
|
+
))
|
|
148
|
+
let second = try XCTUnwrap(tracker.streamStatus(
|
|
149
|
+
streamId: "s2", state: "stopped", terminal: true, reconnectAttempt: nil, bitrate: nil, fps: nil,
|
|
150
|
+
droppedFrames: nil, temperatureC: nil, errorDetails: nil, nowMs: 20000
|
|
151
|
+
))
|
|
152
|
+
XCTAssertEqual(second.properties["protocol"] as? String, "srt")
|
|
153
|
+
XCTAssertEqual(second.properties["duration_ms"] as? Int64, 8000)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func testOlderGlassesEndAStreamOnStoppedAndErrorDetailsLoseTheirUrls() throws {
|
|
157
|
+
_ = tracker.streamRequested(streamId: "s2", streamUrl: "https://whip.example.com/whip/abc", nowMs: 0)
|
|
158
|
+
XCTAssertNil(tracker.streamStatus(
|
|
159
|
+
streamId: nil, state: "streaming", terminal: nil, reconnectAttempt: nil, bitrate: nil, fps: nil,
|
|
160
|
+
droppedFrames: nil, temperatureC: nil, errorDetails: nil, nowMs: 500
|
|
161
|
+
))
|
|
162
|
+
XCTAssertNil(tracker.streamStatus(
|
|
163
|
+
streamId: nil, state: "error", terminal: nil, reconnectAttempt: nil, bitrate: nil, fps: nil,
|
|
164
|
+
droppedFrames: nil, temperatureC: nil, errorDetails: nil, nowMs: 1000
|
|
165
|
+
))
|
|
166
|
+
let ended = try XCTUnwrap(tracker.streamStatus(
|
|
167
|
+
streamId: nil, state: "reconnect_failed", terminal: nil, reconnectAttempt: nil, bitrate: nil, fps: nil,
|
|
168
|
+
droppedFrames: nil, temperatureC: nil, errorDetails: "connect to https://whip.example.com/whip/abc failed",
|
|
169
|
+
nowMs: 9000
|
|
170
|
+
))
|
|
171
|
+
XCTAssertEqual(ended.properties["protocol"] as? String, "whip")
|
|
172
|
+
XCTAssertEqual(ended.properties["end_state"] as? String, "reconnect_failed")
|
|
173
|
+
XCTAssertEqual(ended.properties["error_code"] as? String, "connect to <url> failed")
|
|
174
|
+
XCTAssertEqual(ended.properties["stopped_by_app"] as? Bool, false)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func testStreamThatNeverWentLiveEndsWithoutASecondEvent() {
|
|
178
|
+
_ = tracker.streamRequested(streamId: "s3", streamUrl: "srt://host:9000", nowMs: 0)
|
|
179
|
+
let failed = tracker.streamStartSettled(
|
|
180
|
+
streamUrl: "srt://host:9000", width: nil, height: nil, fps: nil, bitrate: nil,
|
|
181
|
+
captureAudio: true, glassesNetwork: "none", latencyMs: 30000, errorCode: "stream_start_failed"
|
|
182
|
+
)
|
|
183
|
+
XCTAssertEqual(failed.properties["success"] as? Bool, false)
|
|
184
|
+
XCTAssertEqual(failed.properties["error_code"] as? String, "stream_start_failed")
|
|
185
|
+
XCTAssertEqual(failed.properties["protocol"] as? String, "srt")
|
|
186
|
+
XCTAssertNil(tracker.streamStatus(
|
|
187
|
+
streamId: "s3", state: "stopped", terminal: true, reconnectAttempt: nil, bitrate: nil, fps: nil,
|
|
188
|
+
droppedFrames: nil, temperatureC: nil, errorDetails: nil, nowMs: 1000
|
|
189
|
+
))
|
|
190
|
+
// Nothing live was replaced either.
|
|
191
|
+
XCTAssertNil(tracker.streamRequested(streamId: "s4", streamUrl: "srt://host:9000", nowMs: 2000))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func testStreamProtocolFollowsTheGlassesSchemeMapping() {
|
|
195
|
+
XCTAssertEqual(BluetoothSdkUsageTracker.streamProtocol("rtmp://a"), "rtmp")
|
|
196
|
+
XCTAssertEqual(BluetoothSdkUsageTracker.streamProtocol("RTMPS://a"), "rtmp")
|
|
197
|
+
XCTAssertEqual(BluetoothSdkUsageTracker.streamProtocol("srt://a"), "srt")
|
|
198
|
+
XCTAssertEqual(BluetoothSdkUsageTracker.streamProtocol("http://a"), "whip")
|
|
199
|
+
XCTAssertEqual(BluetoothSdkUsageTracker.streamProtocol("rtmpt://a"), "unknown")
|
|
200
|
+
XCTAssertEqual(BluetoothSdkUsageTracker.streamProtocol("not a url"), "unknown")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
func testMicSessionEndsWhenTheSourceTurnsOffOrChanges() throws {
|
|
204
|
+
XCTAssertNil(tracker.observeMic(currentMic: "", transcribing: false, nowMs: 0))
|
|
205
|
+
XCTAssertNil(tracker.observeMic(currentMic: "glasses", transcribing: false, nowMs: 1000))
|
|
206
|
+
XCTAssertNil(tracker.observeMic(currentMic: "glasses", transcribing: true, nowMs: 2000))
|
|
207
|
+
|
|
208
|
+
let switched = try XCTUnwrap(tracker.observeMic(currentMic: "phone", transcribing: false, nowMs: 11000))
|
|
209
|
+
XCTAssertEqual(switched.name, "bluetooth_sdk_mic_session")
|
|
210
|
+
XCTAssertEqual(switched.properties["mic_source"] as? String, "glasses")
|
|
211
|
+
XCTAssertEqual(switched.properties["duration_ms"] as? Int64, 10000)
|
|
212
|
+
XCTAssertEqual(switched.properties["local_transcription"] as? Bool, true)
|
|
213
|
+
|
|
214
|
+
let off = try XCTUnwrap(tracker.observeMic(currentMic: "", transcribing: false, nowMs: 14000))
|
|
215
|
+
XCTAssertEqual(off.properties["mic_source"] as? String, "phone")
|
|
216
|
+
XCTAssertEqual(off.properties["duration_ms"] as? Int64, 3000)
|
|
217
|
+
XCTAssertEqual(off.properties["local_transcription"] as? Bool, false)
|
|
218
|
+
XCTAssertNil(tracker.observeMic(currentMic: "", transcribing: false, nowMs: 15000))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
func testWifiAndHotspotSetupReportOutcomeAndLatency() {
|
|
222
|
+
let wifi = tracker.wifiSetupSettled(latencyMs: 4000, errorCode: nil)
|
|
223
|
+
XCTAssertEqual(wifi.properties["success"] as? Bool, true)
|
|
224
|
+
XCTAssertEqual(wifi.properties["latency_ms"] as? Int64, 4000)
|
|
225
|
+
XCTAssertEqual(
|
|
226
|
+
tracker.wifiSetupSettled(latencyMs: 15000, errorCode: "connect_timeout").properties["error_code"] as? String,
|
|
227
|
+
"connect_timeout"
|
|
228
|
+
)
|
|
229
|
+
XCTAssertEqual(tracker.hotspotStartSettled(latencyMs: 2500, errorCode: nil).name, "bluetooth_sdk_hotspot_started")
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
func testOtaSessionIsReportedOnceWithItsStartingVersions() throws {
|
|
233
|
+
XCTAssertNil(ota("ota-1", "in_progress", nowMs: 0, fromBuild: "38"))
|
|
234
|
+
XCTAssertNil(ota("ota-1", "step_complete", nowMs: 60000, fromBuild: "39"))
|
|
235
|
+
let done = try XCTUnwrap(ota("ota-1", "complete", nowMs: 300_000, fromBuild: "40"))
|
|
236
|
+
XCTAssertEqual(done.name, "bluetooth_sdk_ota_finished")
|
|
237
|
+
XCTAssertEqual(done.properties["success"] as? Bool, true)
|
|
238
|
+
XCTAssertEqual(done.properties["duration_ms"] as? Int64, 300_000)
|
|
239
|
+
XCTAssertEqual(done.properties["from_build_number"] as? String, "38")
|
|
240
|
+
XCTAssertEqual(done.properties["total_steps"] as? Int, 2)
|
|
241
|
+
XCTAssertNil(done.properties["failed_step_type"])
|
|
242
|
+
XCTAssertNil(ota("ota-1", "complete", nowMs: 301_000, fromBuild: "40"))
|
|
243
|
+
|
|
244
|
+
let failed = try XCTUnwrap(ota(
|
|
245
|
+
"ota-2", "failed", nowMs: 0, fromBuild: "40", stepType: "bes",
|
|
246
|
+
error: "download failed: https://ota.example.com/x.bin"
|
|
247
|
+
))
|
|
248
|
+
XCTAssertEqual(failed.properties["success"] as? Bool, false)
|
|
249
|
+
XCTAssertEqual(failed.properties["failed_step_type"] as? String, "bes")
|
|
250
|
+
XCTAssertEqual(failed.properties["error_message"] as? String, "download failed: <url>")
|
|
251
|
+
XCTAssertNil(failed.properties["duration_ms"])
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
func testRequestTimeoutsAreCappedPerTracker() throws {
|
|
255
|
+
var capped = BluetoothSdkUsageTracker(maxRequestTimeouts: 2)
|
|
256
|
+
let first = try XCTUnwrap(capped.requestTimedOut(operation: "photo request"))
|
|
257
|
+
XCTAssertEqual(first.name, "bluetooth_sdk_request_failed")
|
|
258
|
+
XCTAssertEqual(first.properties["operation"] as? String, "photo request")
|
|
259
|
+
XCTAssertEqual(first.properties["error_code"] as? String, "request_timeout")
|
|
260
|
+
XCTAssertNotNil(capped.requestTimedOut(operation: "start stream"))
|
|
261
|
+
XCTAssertNil(capped.requestTimedOut(operation: "start stream"))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
func testDetailsAreSanitizedAndCapped() {
|
|
265
|
+
XCTAssertNil(BluetoothSdkUsageTracker.sanitizedDetail(" "))
|
|
266
|
+
XCTAssertEqual(BluetoothSdkUsageTracker.sanitizedDetail(String(repeating: "x", count: 500))?.count, 200)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private func stream(
|
|
270
|
+
_ state: String,
|
|
271
|
+
terminal: Bool? = nil,
|
|
272
|
+
reconnectAttempt: Int? = nil,
|
|
273
|
+
bitrate: Int64? = nil,
|
|
274
|
+
droppedFrames: Int64? = nil,
|
|
275
|
+
nowMs: Int64
|
|
276
|
+
) -> AnalyticsEvent? {
|
|
277
|
+
tracker.streamStatus(
|
|
278
|
+
streamId: "s1", state: state, terminal: terminal, reconnectAttempt: reconnectAttempt, bitrate: bitrate,
|
|
279
|
+
fps: nil, droppedFrames: droppedFrames, temperatureC: nil, errorDetails: nil, nowMs: nowMs
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private func ota(
|
|
284
|
+
_ sessionId: String,
|
|
285
|
+
_ status: String,
|
|
286
|
+
nowMs: Int64,
|
|
287
|
+
fromBuild: String,
|
|
288
|
+
stepType: String = "apk",
|
|
289
|
+
error: String? = nil
|
|
290
|
+
) -> AnalyticsEvent? {
|
|
291
|
+
tracker.otaStatus(
|
|
292
|
+
sessionId: sessionId, status: status, stepType: stepType, totalSteps: 2, errorMessage: error,
|
|
293
|
+
fromBuildNumber: fromBuild, fromBesFirmwareVersion: "", fromMtkFirmwareVersion: "", nowMs: nowMs
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
final class G1TextSanitizerTests: XCTestCase {
|
|
5
|
+
func testPreservesSwedishTextAndComposesDecomposedAccents() {
|
|
6
|
+
let text = "“Hallå, hallå”\nÅÄÖ åäö"
|
|
7
|
+
XCTAssertEqual(G1Text.sanitizeForDisplay(text), text)
|
|
8
|
+
XCTAssertEqual(G1Text.sanitizeForDisplay("Halla\u{030A}, halla\u{030A}"), "Hallå, hallå")
|
|
9
|
+
XCTAssertEqual(G1Text.sanitizeForDisplay("Cafe\u{0301}\nĐa Nang"), "Café\nDa Nang")
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
func testFallsBackOnlyForUnmappedLatinAndIsIdempotent() {
|
|
13
|
+
let text = "Tiếng Việt: Đặng, Nguyễn, phở bò; Øresund Łódź Æsir Œuvre Straße Ðingvellir Þingvellir"
|
|
14
|
+
let expected = "Tieng Viet: Dang, Nguyen, pho bò; Øresund Łódź Æsir OEuvre Straße Ðingvellir Þingvellir"
|
|
15
|
+
XCTAssertEqual(G1Text.sanitizeForDisplay(text), expected)
|
|
16
|
+
XCTAssertEqual(G1Text.sanitizeForDisplay(expected), expected)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func testPreservesOtherScriptsAndNimoKeepsItsOwnFallback() {
|
|
20
|
+
let otherScripts = "が カ\u{3099} 한\u{302E} Α\u{0301} مُرَحَّبًا शि ❤️"
|
|
21
|
+
XCTAssertEqual(G1Text.sanitizeForDisplay(otherScripts), otherScripts)
|
|
22
|
+
XCTAssertEqual(LatinTextSanitizer.sanitizeForDisplay("Hallå Æsir Straße", preserveOtherScripts: true),
|
|
23
|
+
"Halla AEsir Strasse")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func testPreservesEveryEmbeddedExtendedLatinGlyphInComposedAndDecomposedForms() {
|
|
27
|
+
let glyphs = "ÀÂÇÈÉÊËÎÏÔÙÛÜàçèéêëîïôùûüÿŸÄäÖößẞâÁáÍíÑñúÓóÚⁱIJijŠšŽžÃÅÆÌÐÒÕØÝÞãåæìðòõøýþĄąĆćČčĎďĘęĚěĞğŁłŃńŇňŘřŚśŞşŤťŮůŹźŻżĂăıȘșȚțǖǘǚǜǎǐǒǔŐőŰű"
|
|
28
|
+
XCTAssertEqual(Array(G1Text.sanitizeForDisplay(glyphs).utf8), Array(glyphs.utf8))
|
|
29
|
+
let decomposed = glyphs.decomposedStringWithCanonicalMapping
|
|
30
|
+
XCTAssertEqual(Array(G1Text.sanitizeForDisplay(decomposed).utf8), Array(glyphs.utf8))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
/// Keep native admission, serialization and fragment packing; replace only the radio writes.
|
|
5
|
+
@MainActor
|
|
6
|
+
private final class IncidentLogTransport: MentraLive {
|
|
7
|
+
var ready = true
|
|
8
|
+
var writes: [(Data, BleWriteTrace?)] = []
|
|
9
|
+
|
|
10
|
+
override var incidentLogTransportReady: Bool {
|
|
11
|
+
ready
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
override func queueSend(_ data: Data, id _: String, trace: BleWriteTrace?) {
|
|
15
|
+
writes.append((data, trace))
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@MainActor
|
|
20
|
+
final class IncidentLogDispatchTests: XCTestCase {
|
|
21
|
+
private let incident = "rep_01M4HBVT88DFSN8TYNZ1WTV4PB"
|
|
22
|
+
private let apiBase = "https://core.dev.us-west-2.mentraglass.com"
|
|
23
|
+
|
|
24
|
+
func testMissingNativeLinkRejectsDespiteCachedConnectedStatus() {
|
|
25
|
+
let live = MentraLive()
|
|
26
|
+
let store = DeviceStore.shared.store
|
|
27
|
+
let saved = store.getCategory("glasses")["connected"]
|
|
28
|
+
store.set("glasses", "connected", true)
|
|
29
|
+
defer {
|
|
30
|
+
if let saved { store.set("glasses", "connected", saved) }
|
|
31
|
+
else { store.remove("glasses", "connected") }
|
|
32
|
+
}
|
|
33
|
+
XCTAssertThrowsError(try live.sendIncidentId(incident, apiBaseUrl: apiBase)) { error in
|
|
34
|
+
XCTAssertEqual((error as? BluetoothSdkError)?.code, "glasses_not_connected")
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func testNoDriverRejectsThroughDeviceManager() {
|
|
39
|
+
let manager = DeviceManager()
|
|
40
|
+
manager.sgc = nil
|
|
41
|
+
XCTAssertThrowsError(try manager.sendIncidentId(incident, apiBaseUrl: apiBase)) { error in
|
|
42
|
+
XCTAssertEqual((error as? BluetoothSdkError)?.code, "glasses_not_connected")
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func testMissingCurrentTokenDoesNotQueueAnUpload() throws {
|
|
47
|
+
try withToken("") {
|
|
48
|
+
let live = IncidentLogTransport()
|
|
49
|
+
XCTAssertThrowsError(try live.sendIncidentId(incident, apiBaseUrl: apiBase)) { error in
|
|
50
|
+
XCTAssertEqual((error as? BluetoothSdkError)?.code, "core_token_unavailable")
|
|
51
|
+
}
|
|
52
|
+
XCTAssertTrue(live.writes.isEmpty)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func testCurrentTokenPrecedesUploadAndNeverAppearsInLogs() throws {
|
|
57
|
+
let token = "synthetic-\(UUID().uuidString)"
|
|
58
|
+
try withToken(token) {
|
|
59
|
+
let live = IncidentLogTransport()
|
|
60
|
+
let manager = DeviceManager()
|
|
61
|
+
manager.sgc = live
|
|
62
|
+
var logs: [String] = []
|
|
63
|
+
let sink = Bridge.addEventSink { event, body in
|
|
64
|
+
if event == "log", let message = body["message"] as? String { logs.append(message) }
|
|
65
|
+
}
|
|
66
|
+
defer { Bridge.removeEventSink(sink) }
|
|
67
|
+
|
|
68
|
+
try manager.sendIncidentId(incident, apiBaseUrl: apiBase)
|
|
69
|
+
|
|
70
|
+
XCTAssertEqual(live.writes.compactMap { $0.1?.commandType }, ["auth_token", "upload_incident_logs"])
|
|
71
|
+
XCTAssertTrue(live.writes.allSatisfy { $0.1?.wakeup == true })
|
|
72
|
+
let wire = live.writes.map { String(decoding: $0.0, as: UTF8.self) }.joined()
|
|
73
|
+
XCTAssertTrue(wire.contains(token), "The current token must reach the glasses")
|
|
74
|
+
XCTAssertTrue(wire.contains(incident))
|
|
75
|
+
XCTAssertFalse(logs.contains { $0.contains(token) }, "Tokens must stay out of native and incident logs")
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func testAllTokenFragmentsPrecedeUpload() throws {
|
|
80
|
+
try withToken(String(repeating: "synthetic", count: 120)) {
|
|
81
|
+
let live = IncidentLogTransport()
|
|
82
|
+
try live.sendIncidentId(incident, apiBaseUrl: apiBase)
|
|
83
|
+
let types = live.writes.compactMap { $0.1?.commandType }
|
|
84
|
+
XCTAssertGreaterThan(types.filter { $0 == "auth_token" }.count, 1)
|
|
85
|
+
XCTAssertEqual(types.last, "upload_incident_logs")
|
|
86
|
+
XCTAssertTrue(types.dropLast().allSatisfy { $0 == "auth_token" })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func testCommandQueueInsertionAndSessionClearAreSynchronous() {
|
|
91
|
+
let queue = MentraLive.CommandQueue()
|
|
92
|
+
let first = MentraLive.PendingMessage(data: Data([1]), id: "auth", retries: 0)
|
|
93
|
+
let second = MentraLive.PendingMessage(data: Data([2]), id: "upload", retries: 0)
|
|
94
|
+
XCTAssertEqual(queue.enqueue(first), 1)
|
|
95
|
+
XCTAssertEqual(queue.enqueue(second), 2)
|
|
96
|
+
XCTAssertEqual(queue.dequeue()?.id, "auth")
|
|
97
|
+
queue.removeAll()
|
|
98
|
+
XCTAssertNil(queue.dequeue())
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private func withToken<T>(_ value: String, body: () throws -> T) rethrows -> T {
|
|
102
|
+
let store = DeviceStore.shared.store
|
|
103
|
+
let saved = store.getCategory("bluetooth")["core_token"]
|
|
104
|
+
store.set("bluetooth", "core_token", value)
|
|
105
|
+
defer {
|
|
106
|
+
if let saved { store.set("bluetooth", "core_token", saved) }
|
|
107
|
+
else { store.remove("bluetooth", "core_token") }
|
|
108
|
+
}
|
|
109
|
+
return try body()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
@testable import MentraBluetoothSDK
|
|
2
|
+
import XCTest
|
|
3
|
+
|
|
4
|
+
@MainActor
|
|
5
|
+
private final class SessionReadinessTransport: MentraLive {
|
|
6
|
+
var commands: [[String: Any]] = []
|
|
7
|
+
|
|
8
|
+
override func sendJson(_ jsonOriginal: [String: Any], wakeUp: Bool, requireAck: Bool) {
|
|
9
|
+
commands.append(jsonOriginal)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@MainActor
|
|
14
|
+
final class MentraLiveSessionReadinessTests: XCTestCase {
|
|
15
|
+
func testChangedSessionPublishesPendingReadinessBeforeItsBuildAndRecoversAtGlassesReady() throws {
|
|
16
|
+
let store = DeviceStore.shared.store
|
|
17
|
+
let saved = ["bluetooth", "glasses"].map { ($0, store.getCategory($0)) }
|
|
18
|
+
let previous = DeviceManager.shared.sgc
|
|
19
|
+
let transport = SessionReadinessTransport()
|
|
20
|
+
let sdk = MentraBluetoothSDK()
|
|
21
|
+
DeviceManager.shared.sgc = transport
|
|
22
|
+
var observations: [(key: String, ready: Bool, connected: Bool)] = []
|
|
23
|
+
let listener = store.addListener { category, changed in
|
|
24
|
+
guard category == "glasses" else { return }
|
|
25
|
+
for key in changed.keys where key == "fullyBooted" || key == "buildNumber" {
|
|
26
|
+
observations.append((key, sdk.glassesStatus.fullyBooted, sdk.glassesStatus.connected))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
var sessionEvents: [Bool] = []
|
|
30
|
+
let sink = Bridge.addEventSink { event, body in
|
|
31
|
+
if event == "glasses_session_changed" {
|
|
32
|
+
sessionEvents.append(sdk.glassesStatus.fullyBooted)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
defer {
|
|
36
|
+
store.removeListener(listener)
|
|
37
|
+
Bridge.removeEventSink(sink)
|
|
38
|
+
sdk.invalidate()
|
|
39
|
+
transport.cleanup()
|
|
40
|
+
DeviceManager.shared.sgc = previous
|
|
41
|
+
for (category, values) in saved {
|
|
42
|
+
for key in store.getCategory(category).keys where values[key] == nil {
|
|
43
|
+
store.remove(category, key)
|
|
44
|
+
}
|
|
45
|
+
for (key, value) in values { store.set(category, key, value) }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func receive(_ value: [String: Any]) throws {
|
|
50
|
+
try transport.processReceivedData(JSONSerialization.data(withJSONObject: value))
|
|
51
|
+
}
|
|
52
|
+
try receive(["type": "glasses_ready", "sid": "original"])
|
|
53
|
+
observations.removeAll()
|
|
54
|
+
transport.commands.removeAll()
|
|
55
|
+
|
|
56
|
+
// The physical BLE link remains live across APK replacement. Its first
|
|
57
|
+
// version chunk can prove the target build before remote wire readiness.
|
|
58
|
+
try receive(["type": "version_info_1", "sid": "replacement", "build_number": "302010070"])
|
|
59
|
+
XCTAssertEqual(observations.map(\.key), ["fullyBooted", "buildNumber"])
|
|
60
|
+
XCTAssertTrue(observations.allSatisfy { !$0.ready && $0.connected })
|
|
61
|
+
XCTAssertEqual(sessionEvents, [false])
|
|
62
|
+
XCTAssertEqual(transport.commands.compactMap { $0["type"] as? String }, ["phone_ready"])
|
|
63
|
+
|
|
64
|
+
// Another chunk from the same pending process must not restart readiness.
|
|
65
|
+
try receive(["type": "version_info_3", "sid": "replacement"])
|
|
66
|
+
XCTAssertEqual(sessionEvents, [false])
|
|
67
|
+
XCTAssertFalse(sdk.glassesStatus.fullyBooted)
|
|
68
|
+
|
|
69
|
+
try receive(["type": "glasses_ready", "sid": "replacement"])
|
|
70
|
+
XCTAssertTrue(sdk.glassesStatus.connected)
|
|
71
|
+
XCTAssertTrue(sdk.glassesStatus.fullyBooted)
|
|
72
|
+
XCTAssertEqual(observations.last?.key, "fullyBooted")
|
|
73
|
+
XCTAssertEqual(observations.last?.ready, true)
|
|
74
|
+
XCTAssertEqual(transport.commands.filter { $0["type"] as? String == "request_version" }.count, 1)
|
|
75
|
+
|
|
76
|
+
observations.removeAll()
|
|
77
|
+
try receive(["type": "version_info_1", "sid": "replacement", "build_number": "302010070"])
|
|
78
|
+
XCTAssertTrue(sdk.glassesStatus.fullyBooted)
|
|
79
|
+
XCTAssertFalse(observations.contains { $0.key == "fullyBooted" })
|
|
80
|
+
XCTAssertEqual(sessionEvents, [false])
|
|
81
|
+
|
|
82
|
+
// A physical teardown before the next logical restart becomes ready must
|
|
83
|
+
// still run disconnect cleanup, even though readiness is already false.
|
|
84
|
+
try receive(["type": "version_info_1", "sid": "third", "build_number": "303000267"])
|
|
85
|
+
store.set("glasses", "headUp", true)
|
|
86
|
+
store.set("glasses", "voiceActivityDetectionEnabled", !BluetoothSdkDefaults.voiceActivityDetectionEnabled)
|
|
87
|
+
XCTAssertFalse(sdk.glassesStatus.fullyBooted)
|
|
88
|
+
XCTAssertTrue(sdk.glassesStatus.connected)
|
|
89
|
+
transport.cleanup()
|
|
90
|
+
XCTAssertFalse(sdk.glassesStatus.connected)
|
|
91
|
+
XCTAssertEqual(store.get("glasses", "headUp") as? Bool, false)
|
|
92
|
+
XCTAssertEqual(store.get("glasses", "voiceActivityDetectionEnabled") as? Bool,
|
|
93
|
+
BluetoothSdkDefaults.voiceActivityDetectionEnabled)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func testPairingYieldDuringPendingReadinessCleansUpAndReclaimsAfterItsWindow() async throws {
|
|
97
|
+
let store = DeviceStore.shared.store
|
|
98
|
+
let saved = ["bluetooth", "glasses"].map { ($0, store.getCategory($0)) }
|
|
99
|
+
let previous = DeviceManager.shared.sgc
|
|
100
|
+
let transport = SessionReadinessTransport()
|
|
101
|
+
let sdk = MentraBluetoothSDK()
|
|
102
|
+
DeviceManager.shared.sgc = transport
|
|
103
|
+
defer {
|
|
104
|
+
sdk.invalidate()
|
|
105
|
+
transport.cleanup()
|
|
106
|
+
DeviceManager.shared.sgc = previous
|
|
107
|
+
for (category, values) in saved {
|
|
108
|
+
for key in store.getCategory(category).keys where values[key] == nil {
|
|
109
|
+
store.remove(category, key)
|
|
110
|
+
}
|
|
111
|
+
for (key, value) in values { store.set(category, key, value) }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func receive(_ value: [String: Any]) throws {
|
|
116
|
+
try transport.processReceivedData(JSONSerialization.data(withJSONObject: value))
|
|
117
|
+
}
|
|
118
|
+
try receive(["type": "glasses_ready", "sid": "original"])
|
|
119
|
+
try receive(["type": "version_info_1", "sid": "replacement", "build_number": "302010070"])
|
|
120
|
+
XCTAssertTrue(sdk.glassesStatus.connected)
|
|
121
|
+
XCTAssertFalse(sdk.glassesStatus.fullyBooted)
|
|
122
|
+
store.set("glasses", "headUp", true)
|
|
123
|
+
store.set("glasses", "voiceActivityDetectionEnabled", !BluetoothSdkDefaults.voiceActivityDetectionEnabled)
|
|
124
|
+
|
|
125
|
+
try receive(["type": "entering_pairing_mode", "window_ms": 5000])
|
|
126
|
+
XCTAssertTrue(transport.isPairingYieldActive())
|
|
127
|
+
XCTAssertFalse(sdk.glassesStatus.connected)
|
|
128
|
+
XCTAssertFalse(sdk.glassesStatus.fullyBooted)
|
|
129
|
+
XCTAssertEqual(store.get("glasses", "headUp") as? Bool, false)
|
|
130
|
+
XCTAssertEqual(store.get("glasses", "voiceActivityDetectionEnabled") as? Bool,
|
|
131
|
+
BluetoothSdkDefaults.voiceActivityDetectionEnabled)
|
|
132
|
+
|
|
133
|
+
// Readiness queued on the relinquished GATT must not close the yield.
|
|
134
|
+
try receive(["type": "glasses_ready", "sid": "replacement"])
|
|
135
|
+
XCTAssertTrue(transport.isPairingYieldActive())
|
|
136
|
+
XCTAssertFalse(sdk.glassesStatus.fullyBooted)
|
|
137
|
+
|
|
138
|
+
// Let the existing minimum yield expire, then accept a fresh readiness.
|
|
139
|
+
try await Task.sleep(nanoseconds: 5_100_000_000)
|
|
140
|
+
XCTAssertFalse(transport.isPairingYieldActive())
|
|
141
|
+
try receive(["type": "glasses_ready", "sid": "reclaimed"])
|
|
142
|
+
XCTAssertTrue(sdk.glassesStatus.connected)
|
|
143
|
+
XCTAssertTrue(sdk.glassesStatus.fullyBooted)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func testInitialVersionChunkDoesNotStartAnExtraReadinessExchange() throws {
|
|
147
|
+
let store = DeviceStore.shared.store
|
|
148
|
+
let saved = store.getCategory("glasses")
|
|
149
|
+
let transport = SessionReadinessTransport()
|
|
150
|
+
defer {
|
|
151
|
+
transport.cleanup()
|
|
152
|
+
for key in store.getCategory("glasses").keys where saved[key] == nil { store.remove("glasses", key) }
|
|
153
|
+
for (key, value) in saved { store.set("glasses", key, value) }
|
|
154
|
+
}
|
|
155
|
+
store.set("glasses", "fullyBooted", false)
|
|
156
|
+
try transport.processReceivedData(JSONSerialization.data(withJSONObject: [
|
|
157
|
+
"type": "version_info_1", "sid": "initial", "build_number": "303000267",
|
|
158
|
+
]))
|
|
159
|
+
XCTAssertTrue(transport.commands.isEmpty)
|
|
160
|
+
XCTAssertEqual(store.get("glasses", "buildNumber") as? String, "303000267")
|
|
161
|
+
XCTAssertEqual(store.get("glasses", "fullyBooted") as? Bool, false)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -5,6 +5,14 @@ import XCTest
|
|
|
5
5
|
import zlib
|
|
6
6
|
|
|
7
7
|
final class NimoCanvasCodecTests: XCTestCase {
|
|
8
|
+
func testLatinFallbackPreservesOtherScriptsAndTheirMarks() throws {
|
|
9
|
+
let otherScripts = "ありがとう が パ カ\u{3099} 한국어 한\u{302E} Α\u{0301} مُرَحَّبًا शि ❤️"
|
|
10
|
+
let label = try NimoCanvasCodec.label("\(otherScripts) café—Æ\u{0301}", 0, 0, 500, 220)
|
|
11
|
+
let expected = Data("\(otherScripts) cafe-AE".utf8)
|
|
12
|
+
XCTAssertEqual(Data(label.payload.dropFirst(14)), expected)
|
|
13
|
+
XCTAssertEqual(label.textBytes, expected.count)
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
private func hex(_ data: Data) -> String {
|
|
9
17
|
data.map { String(format: "%02X", $0) }.joined()
|
|
10
18
|
}
|
|
@@ -107,9 +115,14 @@ final class NimoCanvasCodecTests: XCTestCase {
|
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
func testUtf8RowsAndGeometryValidation() throws {
|
|
110
|
-
|
|
118
|
+
let dashes = try NimoCanvasCodec.label("—éÆß— - –", 0, 0, 100, 20)
|
|
119
|
+
let expectedText = Data("-eAEss- - –".utf8)
|
|
120
|
+
XCTAssertEqual(Data(dashes.payload.dropFirst(14)), expectedText)
|
|
121
|
+
XCTAssertEqual(dashes.textBytes, expectedText.count)
|
|
122
|
+
XCTAssertEqual(Int(dashes.payload[12]), expectedText.count)
|
|
123
|
+
XCTAssertEqual(try NimoCanvasCodec.label("😀界", 0, 0, 100, 20).textBytes, 7)
|
|
111
124
|
XCTAssertThrowsError(try NimoCanvasCodec.label("a\0b", 0, 0, 100, 20))
|
|
112
|
-
XCTAssertThrowsError(try NimoCanvasCodec.label(String(repeating: "
|
|
125
|
+
XCTAssertThrowsError(try NimoCanvasCodec.label(String(repeating: "界", count: 683), 0, 0, 100, 20))
|
|
113
126
|
let rows = try NimoCanvasCodec.textRows("first\n\nthird", 12, 15, 400, 100)
|
|
114
127
|
XCTAssertEqual(rows.count, 2)
|
|
115
128
|
XCTAssertEqual(rows.map { $0.payload[2] }, [15, 55])
|
|
@@ -156,11 +169,11 @@ final class NimoCanvasCodecTests: XCTestCase {
|
|
|
156
169
|
}
|
|
157
170
|
|
|
158
171
|
func testWholeScenePreservesOrderAndIgnoresDiffAnnotations() throws {
|
|
159
|
-
let text = SceneElement(id: "caption", type: "text", x: 10, y: 20, w: 100, h: 40, text: "
|
|
172
|
+
let text = SceneElement(id: "caption", type: "text", x: 10, y: 20, w: 100, h: 40, text: "héllo—Æsir",
|
|
160
173
|
data: nil, border: 2, radius: 3, change: "unchanged", contentHash: "")
|
|
161
174
|
let expected = try NimoCanvasCodec.replace([
|
|
162
175
|
NimoCanvasCodec.rectangle(10, 20, 100, 40, stroke: 2, radius: 3),
|
|
163
|
-
NimoCanvasCodec.label("hello", 10, 20, 100, 20),
|
|
176
|
+
NimoCanvasCodec.label("hello-AEsir", 10, 20, 100, 20),
|
|
164
177
|
])
|
|
165
178
|
XCTAssertEqual(try NimoCanvasCodec.scene([text]) { _, _, _ in XCTFail("Not an image"); return Data() }, expected)
|
|
166
179
|
}
|