@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,263 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
class BluetoothSdkUsageTrackerTest {
|
|
7
|
+
private val tracker = BluetoothSdkUsageTracker()
|
|
8
|
+
|
|
9
|
+
@Test
|
|
10
|
+
fun `an API photo reports its request shape, latency and outcome`() {
|
|
11
|
+
val ok = tracker.photoSettled("medium", "photo", "none", save = true, transferMethod = "auto", latencyMs = 812, fileSizeBytes = 204_800, errorCode = null)
|
|
12
|
+
assertThat(ok.name).isEqualTo("bluetooth_sdk_photo_taken")
|
|
13
|
+
assertThat(ok.properties)
|
|
14
|
+
.containsEntry("trigger", "api")
|
|
15
|
+
.containsEntry("success", true)
|
|
16
|
+
.containsEntry("latency_ms", 812L)
|
|
17
|
+
.containsEntry("photo_size", "medium")
|
|
18
|
+
.containsEntry("save_to_gallery", true)
|
|
19
|
+
.containsEntry("transfer_method", "auto")
|
|
20
|
+
.containsEntry("file_size_bytes", 204_800L)
|
|
21
|
+
.doesNotContainKey("error_code")
|
|
22
|
+
|
|
23
|
+
val failed = tracker.photoSettled("max", "photo", "none", save = false, transferMethod = "ble", latencyMs = 30_000, fileSizeBytes = null, errorCode = "request_timeout")
|
|
24
|
+
assertThat(failed.properties)
|
|
25
|
+
.containsEntry("success", false)
|
|
26
|
+
.containsEntry("error_code", "request_timeout")
|
|
27
|
+
.doesNotContainKey("file_size_bytes")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Test
|
|
31
|
+
fun `a failed button photo is reported as a button trigger`() {
|
|
32
|
+
val event = tracker.buttonPhotoFailed("CAMERA_BUSY")
|
|
33
|
+
assertThat(event.properties).containsEntry("trigger", "button").containsEntry("success", false).containsEntry("error_code", "CAMERA_BUSY")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Test
|
|
37
|
+
fun `an API recording reports its length, parameters and app stop once`() {
|
|
38
|
+
tracker.videoRequested("rec-1", width = 1920, height = 1080, fps = 30, save = true, maxDurationMinutes = 0)
|
|
39
|
+
assertThat(tracker.videoStatus("rec-1", "recording_started", success = true, nowMs = 1_000)).isNull()
|
|
40
|
+
// A duplicate start does not move the start time.
|
|
41
|
+
assertThat(tracker.videoStatus("rec-1", "recording_started", success = true, nowMs = 2_000)).isNull()
|
|
42
|
+
tracker.videoStopRequested("rec-1")
|
|
43
|
+
|
|
44
|
+
val stopped = tracker.videoStatus("rec-1", "recording_stopped", success = true, nowMs = 61_000)!!
|
|
45
|
+
assertThat(stopped.name).isEqualTo("bluetooth_sdk_video_recorded")
|
|
46
|
+
assertThat(stopped.properties)
|
|
47
|
+
.containsEntry("trigger", "api")
|
|
48
|
+
.containsEntry("success", true)
|
|
49
|
+
.containsEntry("duration_ms", 60_000L)
|
|
50
|
+
.containsEntry("width", 1920)
|
|
51
|
+
.containsEntry("fps", 30)
|
|
52
|
+
.containsEntry("save_to_gallery", true)
|
|
53
|
+
.containsEntry("stopped_by_app", true)
|
|
54
|
+
.doesNotContainKey("max_duration_minutes")
|
|
55
|
+
|
|
56
|
+
assertThat(tracker.videoStatus("rec-1", "recording_stopped", success = true, nowMs = 62_000)).isNull()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@Test
|
|
60
|
+
fun `a button recording is reported with a button trigger and no app stop`() {
|
|
61
|
+
val id = "local_video_1728000000_ab12"
|
|
62
|
+
tracker.videoStatus(id, "recording_started", success = true, nowMs = 0)
|
|
63
|
+
val stopped = tracker.videoStatus(id, "recording_stopped", success = true, nowMs = 5_000)!!
|
|
64
|
+
assertThat(stopped.properties)
|
|
65
|
+
.containsEntry("trigger", "button")
|
|
66
|
+
.containsEntry("duration_ms", 5_000L)
|
|
67
|
+
.containsEntry("stopped_by_app", false)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@Test
|
|
71
|
+
fun `a recording failure is reported, but not_recording is not a recording`() {
|
|
72
|
+
tracker.videoRequested("rec-2", 0, 0, 0, save = false, maxDurationMinutes = 5)
|
|
73
|
+
val failed = tracker.videoStatus("rec-2", "battery_low", success = false, nowMs = 0)!!
|
|
74
|
+
assertThat(failed.properties)
|
|
75
|
+
.containsEntry("success", false)
|
|
76
|
+
.containsEntry("error_code", "battery_low")
|
|
77
|
+
.containsEntry("max_duration_minutes", 5)
|
|
78
|
+
.doesNotContainKey("duration_ms")
|
|
79
|
+
.doesNotContainKey("width")
|
|
80
|
+
|
|
81
|
+
assertThat(tracker.videoStatus("rec-3", "not_recording", success = false, nowMs = 0)).isNull()
|
|
82
|
+
assertThat(tracker.videoStatus("", "recording_stopped", success = true, nowMs = 0)).isNull()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Test
|
|
86
|
+
fun `a stream is reported at start and once at its terminal status`() {
|
|
87
|
+
assertThat(tracker.streamRequested("s1", "rtmps://live.example.com/app/secret-key", nowMs = 6_600)).isNull()
|
|
88
|
+
// The first live status can arrive before startStream returns.
|
|
89
|
+
assertThat(stream("streaming", nowMs = 10_000)).isNull()
|
|
90
|
+
val started =
|
|
91
|
+
tracker.streamStartSettled(
|
|
92
|
+
streamUrl = "rtmps://live.example.com/app/secret-key",
|
|
93
|
+
width = 1280,
|
|
94
|
+
height = 720,
|
|
95
|
+
fps = 30,
|
|
96
|
+
bitrate = 2_000_000,
|
|
97
|
+
captureAudio = true,
|
|
98
|
+
glassesNetwork = "wifi",
|
|
99
|
+
latencyMs = 3_400,
|
|
100
|
+
errorCode = null,
|
|
101
|
+
)
|
|
102
|
+
assertThat(started.name).isEqualTo("bluetooth_sdk_stream_started")
|
|
103
|
+
assertThat(started.properties)
|
|
104
|
+
.containsEntry("success", true)
|
|
105
|
+
.containsEntry("protocol", "rtmp")
|
|
106
|
+
.containsEntry("glasses_network", "wifi")
|
|
107
|
+
assertThat(started.properties.values.map { it.toString() }).noneMatch { it.contains("secret-key") }
|
|
108
|
+
|
|
109
|
+
assertThat(stream("reconnecting", reconnectAttempt = 1, nowMs = 20_000)).isNull()
|
|
110
|
+
assertThat(stream("reconnected", reconnectAttempt = 2, nowMs = 21_000)).isNull()
|
|
111
|
+
assertThat(stream("streaming", bitrate = 1_800_000, droppedFrames = 12, nowMs = 30_000)).isNull()
|
|
112
|
+
// Statuses for another stream id do not touch this one.
|
|
113
|
+
assertThat(tracker.streamStatus("other", "stopped", true, null, null, null, null, null, null, 31_000)).isNull()
|
|
114
|
+
tracker.streamStopRequested()
|
|
115
|
+
|
|
116
|
+
val ended = stream("stopped", terminal = true, nowMs = 70_000)!!
|
|
117
|
+
assertThat(ended.name).isEqualTo("bluetooth_sdk_stream_ended")
|
|
118
|
+
assertThat(ended.properties)
|
|
119
|
+
.containsEntry("protocol", "rtmp")
|
|
120
|
+
.containsEntry("duration_ms", 60_000L)
|
|
121
|
+
.containsEntry("end_state", "stopped")
|
|
122
|
+
.containsEntry("stopped_by_app", true)
|
|
123
|
+
.containsEntry("reconnect_attempts", 2)
|
|
124
|
+
.containsEntry("bitrate", 1_800_000L)
|
|
125
|
+
.containsEntry("dropped_frames", 12L)
|
|
126
|
+
|
|
127
|
+
assertThat(stream("stopped", terminal = true, nowMs = 71_000)).isNull()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@Test
|
|
131
|
+
fun `a stream that ends right after going live is still reported`() {
|
|
132
|
+
tracker.streamRequested("s1", "rtmp://host/app", nowMs = 0)
|
|
133
|
+
assertThat(stream("streaming", nowMs = 1_000)).isNull()
|
|
134
|
+
val ended = stream("error", terminal = true, nowMs = 1_500)!!
|
|
135
|
+
assertThat(ended.properties).containsEntry("duration_ms", 500L).containsEntry("end_state", "error")
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@Test
|
|
139
|
+
fun `a new start reports the live stream it replaces`() {
|
|
140
|
+
tracker.streamRequested("s1", "rtmp://host/app", nowMs = 0)
|
|
141
|
+
stream("streaming", nowMs = 1_000)
|
|
142
|
+
val replaced = tracker.streamRequested("s2", "srt://host:9000", nowMs = 11_000)!!
|
|
143
|
+
assertThat(replaced.name).isEqualTo("bluetooth_sdk_stream_ended")
|
|
144
|
+
assertThat(replaced.properties)
|
|
145
|
+
.containsEntry("protocol", "rtmp")
|
|
146
|
+
.containsEntry("end_state", "replaced")
|
|
147
|
+
.containsEntry("duration_ms", 10_000L)
|
|
148
|
+
// The replaced stream's own late terminal status is not reported again.
|
|
149
|
+
assertThat(stream("stopped", terminal = true, nowMs = 12_000)).isNull()
|
|
150
|
+
assertThat(tracker.streamStatus("s2", "streaming", null, null, null, null, null, null, null, 12_000)).isNull()
|
|
151
|
+
val second = tracker.streamStatus("s2", "stopped", true, null, null, null, null, null, null, 20_000)!!
|
|
152
|
+
assertThat(second.properties).containsEntry("protocol", "srt").containsEntry("duration_ms", 8_000L)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@Test
|
|
156
|
+
fun `older glasses without a terminal flag end a stream on stopped, and error details lose their URLs`() {
|
|
157
|
+
tracker.streamRequested("s2", "https://whip.example.com/whip/abc", nowMs = 0)
|
|
158
|
+
assertThat(tracker.streamStatus(null, "streaming", null, null, null, null, null, null, null, 500)).isNull()
|
|
159
|
+
assertThat(tracker.streamStatus(null, "error", null, null, null, null, null, null, null, 1_000)).isNull()
|
|
160
|
+
val ended =
|
|
161
|
+
tracker.streamStatus(null, "reconnect_failed", null, null, null, null, null, null, "connect to https://whip.example.com/whip/abc failed", 9_000)!!
|
|
162
|
+
assertThat(ended.properties)
|
|
163
|
+
.containsEntry("protocol", "whip")
|
|
164
|
+
.containsEntry("end_state", "reconnect_failed")
|
|
165
|
+
.containsEntry("error_code", "connect to <url> failed")
|
|
166
|
+
.containsEntry("stopped_by_app", false)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@Test
|
|
170
|
+
fun `a stream that never went live ends without a second event`() {
|
|
171
|
+
tracker.streamRequested("s3", "srt://host:9000", nowMs = 0)
|
|
172
|
+
val failed = tracker.streamStartSettled("srt://host:9000", null, null, null, null, true, "none", 30_000, "stream_start_failed")
|
|
173
|
+
assertThat(failed.properties).containsEntry("success", false).containsEntry("error_code", "stream_start_failed").containsEntry("protocol", "srt")
|
|
174
|
+
assertThat(tracker.streamStatus("s3", "stopped", true, null, null, null, null, null, null, 1_000)).isNull()
|
|
175
|
+
// Nothing live was replaced either.
|
|
176
|
+
assertThat(tracker.streamRequested("s4", "srt://host:9000", nowMs = 2_000)).isNull()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@Test
|
|
180
|
+
fun `stream protocol follows the glasses scheme mapping`() {
|
|
181
|
+
assertThat(BluetoothSdkUsageTracker.streamProtocol("rtmp://a")).isEqualTo("rtmp")
|
|
182
|
+
assertThat(BluetoothSdkUsageTracker.streamProtocol("RTMPS://a")).isEqualTo("rtmp")
|
|
183
|
+
assertThat(BluetoothSdkUsageTracker.streamProtocol("srt://a")).isEqualTo("srt")
|
|
184
|
+
assertThat(BluetoothSdkUsageTracker.streamProtocol("http://a")).isEqualTo("whip")
|
|
185
|
+
assertThat(BluetoothSdkUsageTracker.streamProtocol("rtmpt://a")).isEqualTo("unknown")
|
|
186
|
+
assertThat(BluetoothSdkUsageTracker.streamProtocol("not a url")).isEqualTo("unknown")
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
@Test
|
|
190
|
+
fun `a mic session ends when the source turns off or changes`() {
|
|
191
|
+
assertThat(tracker.observeMic("", transcribing = false, nowMs = 0)).isNull()
|
|
192
|
+
assertThat(tracker.observeMic("glasses", transcribing = false, nowMs = 1_000)).isNull()
|
|
193
|
+
assertThat(tracker.observeMic("glasses", transcribing = true, nowMs = 2_000)).isNull()
|
|
194
|
+
|
|
195
|
+
val switched = tracker.observeMic("phone", transcribing = false, nowMs = 11_000)!!
|
|
196
|
+
assertThat(switched.name).isEqualTo("bluetooth_sdk_mic_session")
|
|
197
|
+
assertThat(switched.properties)
|
|
198
|
+
.containsEntry("mic_source", "glasses")
|
|
199
|
+
.containsEntry("duration_ms", 10_000L)
|
|
200
|
+
.containsEntry("local_transcription", true)
|
|
201
|
+
|
|
202
|
+
val off = tracker.observeMic("", transcribing = false, nowMs = 14_000)!!
|
|
203
|
+
assertThat(off.properties).containsEntry("mic_source", "phone").containsEntry("duration_ms", 3_000L).containsEntry("local_transcription", false)
|
|
204
|
+
assertThat(tracker.observeMic("", transcribing = false, nowMs = 15_000)).isNull()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
@Test
|
|
208
|
+
fun `wifi and hotspot setup report outcome and latency`() {
|
|
209
|
+
assertThat(tracker.wifiSetupSettled(4_000, null).properties).containsEntry("success", true).containsEntry("latency_ms", 4_000L)
|
|
210
|
+
assertThat(tracker.wifiSetupSettled(15_000, "connect_timeout").properties).containsEntry("error_code", "connect_timeout")
|
|
211
|
+
assertThat(tracker.hotspotStartSettled(2_500, null).name).isEqualTo("bluetooth_sdk_hotspot_started")
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
@Test
|
|
215
|
+
fun `an OTA session is reported once with its starting versions`() {
|
|
216
|
+
assertThat(ota("ota-1", "in_progress", nowMs = 0, fromBuild = "38")).isNull()
|
|
217
|
+
assertThat(ota("ota-1", "step_complete", nowMs = 60_000, fromBuild = "39")).isNull()
|
|
218
|
+
val done = ota("ota-1", "complete", nowMs = 300_000, fromBuild = "40")!!
|
|
219
|
+
assertThat(done.name).isEqualTo("bluetooth_sdk_ota_finished")
|
|
220
|
+
assertThat(done.properties)
|
|
221
|
+
.containsEntry("success", true)
|
|
222
|
+
.containsEntry("duration_ms", 300_000L)
|
|
223
|
+
.containsEntry("from_build_number", "38")
|
|
224
|
+
.containsEntry("total_steps", 2)
|
|
225
|
+
.doesNotContainKey("failed_step_type")
|
|
226
|
+
assertThat(ota("ota-1", "complete", nowMs = 301_000, fromBuild = "40")).isNull()
|
|
227
|
+
|
|
228
|
+
val failed = ota("ota-2", "failed", nowMs = 0, fromBuild = "40", stepType = "bes", error = "download failed: https://ota.example.com/x.bin")!!
|
|
229
|
+
assertThat(failed.properties)
|
|
230
|
+
.containsEntry("success", false)
|
|
231
|
+
.containsEntry("failed_step_type", "bes")
|
|
232
|
+
.containsEntry("error_message", "download failed: <url>")
|
|
233
|
+
.doesNotContainKey("duration_ms")
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
@Test
|
|
237
|
+
fun `request timeouts are capped per tracker`() {
|
|
238
|
+
val capped = BluetoothSdkUsageTracker(maxRequestTimeouts = 2)
|
|
239
|
+
assertThat(capped.requestTimedOut("photo request")!!.properties)
|
|
240
|
+
.containsEntry("operation", "photo request")
|
|
241
|
+
.containsEntry("error_code", "request_timeout")
|
|
242
|
+
assertThat(capped.requestTimedOut("start stream")).isNotNull()
|
|
243
|
+
assertThat(capped.requestTimedOut("start stream")).isNull()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private fun stream(
|
|
247
|
+
state: String,
|
|
248
|
+
terminal: Boolean? = null,
|
|
249
|
+
reconnectAttempt: Int? = null,
|
|
250
|
+
bitrate: Long? = null,
|
|
251
|
+
droppedFrames: Long? = null,
|
|
252
|
+
nowMs: Long,
|
|
253
|
+
) = tracker.streamStatus("s1", state, terminal, reconnectAttempt, bitrate, null, droppedFrames, null, null, nowMs)
|
|
254
|
+
|
|
255
|
+
private fun ota(
|
|
256
|
+
sessionId: String,
|
|
257
|
+
status: String,
|
|
258
|
+
nowMs: Long,
|
|
259
|
+
fromBuild: String,
|
|
260
|
+
stepType: String = "apk",
|
|
261
|
+
error: String? = null,
|
|
262
|
+
) = tracker.otaStatus(sessionId, status, stepType, totalSteps = 2, errorMessage = error, fromBuildNumber = fromBuild, fromBesFirmwareVersion = "", fromMtkFirmwareVersion = "", nowMs = nowMs)
|
|
263
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertFalse
|
|
5
|
+
import org.junit.Assert.assertNull
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
|
|
8
|
+
class NetworkDiagnosticsTest {
|
|
9
|
+
private val validatedInternet = mapOf("internet" to true, "validated" to true)
|
|
10
|
+
private val localOnly = mapOf("internet" to false, "validated" to false)
|
|
11
|
+
|
|
12
|
+
@Test
|
|
13
|
+
fun `distinguishes a process bound to glasses from a validated active network`() {
|
|
14
|
+
val snapshot = networkDiagnostics(123L, "private-glasses", "private-cellular", "private-glasses") {
|
|
15
|
+
if (it == "private-cellular") validatedInternet else localOnly
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
assertEquals(123L, snapshot["sampledAtMs"])
|
|
19
|
+
assertEquals(false, snapshot["processBoundMatchesActive"])
|
|
20
|
+
assertEquals(true, snapshot["processBoundMatchesGlassesLocal"])
|
|
21
|
+
assertEquals(false, snapshot["activeNetworkMatchesGlassesLocal"])
|
|
22
|
+
assertEquals(validatedInternet, observation(snapshot, "activeNetwork")["capabilities"])
|
|
23
|
+
assertFalse(snapshot.toString().contains("private-"))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Test
|
|
27
|
+
fun `keeps an unvalidated active network distinct from a disappeared network`() {
|
|
28
|
+
val unvalidated = networkDiagnostics(124L, null, "default", null) { localOnly }
|
|
29
|
+
assertEquals(localOnly, observation(unvalidated, "activeNetwork")["capabilities"])
|
|
30
|
+
val disappeared = networkDiagnostics(125L, null, "default", null) { null }
|
|
31
|
+
assertEquals(true, observation(disappeared, "activeNetwork")["present"])
|
|
32
|
+
assertNull(observation(disappeared, "activeNetwork")["capabilities"])
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Test
|
|
36
|
+
fun `no network does not imply equal routes or attempt a capability read`() {
|
|
37
|
+
var read = false
|
|
38
|
+
val snapshot = networkDiagnostics<String>(126L, null, null, null) {
|
|
39
|
+
read = true
|
|
40
|
+
validatedInternet
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
assertFalse(read)
|
|
44
|
+
listOf("processBound", "activeNetwork", "glassesLocal").forEach {
|
|
45
|
+
assertEquals(false, observation(snapshot, it)["present"])
|
|
46
|
+
assertNull(observation(snapshot, it)["capabilities"])
|
|
47
|
+
}
|
|
48
|
+
assertEquals(false, snapshot["processBoundMatchesActive"])
|
|
49
|
+
assertEquals(false, snapshot["processBoundMatchesGlassesLocal"])
|
|
50
|
+
assertEquals(false, snapshot["activeNetworkMatchesGlassesLocal"])
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@Suppress("UNCHECKED_CAST")
|
|
54
|
+
private fun observation(snapshot: Map<String, Any?>, name: String) = snapshot[name] as Map<String, Any?>
|
|
55
|
+
}
|
|
@@ -37,7 +37,11 @@ class ScanCompletionTest {
|
|
|
37
37
|
error("Legacy callbacks must not query diagnostics")
|
|
38
38
|
}
|
|
39
39
|
assertThat(completed).isTrue()
|
|
40
|
-
|
|
40
|
+
// Instance methods only: Kotlin 2.2+ compiles bodies to Java default
|
|
41
|
+
// methods plus static `access$...$jd` bridges, which nothing implements.
|
|
42
|
+
assertThat(ScanCallback::class.java.declaredMethods
|
|
43
|
+
.filterNot { java.lang.reflect.Modifier.isStatic(it.modifiers) }
|
|
44
|
+
.map { it.name })
|
|
41
45
|
.containsExactlyInAnyOrder("onResults", "onComplete", "onError")
|
|
42
46
|
}
|
|
43
47
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.debug
|
|
2
|
+
|
|
3
|
+
import java.util.UUID
|
|
4
|
+
import org.json.JSONArray
|
|
5
|
+
import org.json.JSONObject
|
|
6
|
+
import org.junit.Assert.assertEquals
|
|
7
|
+
import org.junit.Assert.assertFalse
|
|
8
|
+
import org.junit.Test
|
|
9
|
+
import org.junit.runner.RunWith
|
|
10
|
+
import org.robolectric.RobolectricTestRunner
|
|
11
|
+
import org.robolectric.annotation.Config
|
|
12
|
+
import org.robolectric.shadows.ShadowLog
|
|
13
|
+
|
|
14
|
+
@RunWith(RobolectricTestRunner::class)
|
|
15
|
+
@Config(sdk = [28])
|
|
16
|
+
class BleTraceLoggerTest {
|
|
17
|
+
@Test
|
|
18
|
+
fun wifiScanPreservesBooleanSecurityWhileRedactingCredentials() {
|
|
19
|
+
val secret = UUID.randomUUID().toString()
|
|
20
|
+
val networks = JSONArray()
|
|
21
|
+
for (secured in listOf(true, false)) {
|
|
22
|
+
networks.put(
|
|
23
|
+
JSONObject()
|
|
24
|
+
.put("ssid", if (secured) "secured-fixture" else "open-fixture")
|
|
25
|
+
.put("requiresPassword", secured)
|
|
26
|
+
.put("signalStrength", -45)
|
|
27
|
+
.put("password", secret)
|
|
28
|
+
.put("token", secret)
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
ShadowLog.clear()
|
|
32
|
+
BleTraceLogger.logJson(
|
|
33
|
+
"glasses_to_phone",
|
|
34
|
+
"sdk_ble_event",
|
|
35
|
+
JSONObject().put("type", "wifi_scan_result").put("networks_neo", networks),
|
|
36
|
+
)
|
|
37
|
+
val message = ShadowLog.getLogsForTag("MentraBleTrace").single().msg
|
|
38
|
+
assertFalse(message.contains(secret))
|
|
39
|
+
val actual = JSONObject(message.substringAfter("payload=")).getJSONArray("networks_neo")
|
|
40
|
+
for ((index, secured) in listOf(true, false).withIndex()) {
|
|
41
|
+
val item = actual.getJSONObject(index)
|
|
42
|
+
assertEquals(secured, item.get("requiresPassword"))
|
|
43
|
+
assertEquals(-45, item.getInt("signalStrength"))
|
|
44
|
+
assertEquals("<redacted>", item.getString("password"))
|
|
45
|
+
assertEquals("<redacted>", item.getString("token"))
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@Test
|
|
50
|
+
fun exceptionDoesNotExposeNonBooleanOrOtherPasswordFields() {
|
|
51
|
+
val secret = UUID.randomUUID().toString()
|
|
52
|
+
ShadowLog.clear()
|
|
53
|
+
BleTraceLogger.logJson(
|
|
54
|
+
"glasses_to_phone",
|
|
55
|
+
"sdk_ble_event",
|
|
56
|
+
JSONObject()
|
|
57
|
+
.put("type", "wifi_scan_result")
|
|
58
|
+
.put("requiresPassword", secret)
|
|
59
|
+
.put("password", true)
|
|
60
|
+
.put("RequiresPassword", false),
|
|
61
|
+
)
|
|
62
|
+
val message = ShadowLog.getLogsForTag("MentraBleTrace").single().msg
|
|
63
|
+
assertFalse(message.contains(secret))
|
|
64
|
+
val actual = JSONObject(message.substringAfter("payload="))
|
|
65
|
+
for (key in listOf("requiresPassword", "password", "RequiresPassword")) {
|
|
66
|
+
assertEquals("<redacted>", actual.getString(key))
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import android.bluetooth.BluetoothAdapter
|
|
4
|
+
import android.bluetooth.BluetoothDevice
|
|
5
|
+
import android.os.Handler
|
|
6
|
+
import android.os.Looper
|
|
7
|
+
import android.os.ParcelUuid
|
|
8
|
+
import java.time.Duration
|
|
9
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
10
|
+
import org.junit.Test
|
|
11
|
+
import org.junit.runner.RunWith
|
|
12
|
+
import org.robolectric.RobolectricTestRunner
|
|
13
|
+
import org.robolectric.Shadows.shadowOf
|
|
14
|
+
import org.robolectric.annotation.Config
|
|
15
|
+
import org.robolectric.annotation.LooperMode
|
|
16
|
+
|
|
17
|
+
@RunWith(RobolectricTestRunner::class)
|
|
18
|
+
@Config(sdk = [33])
|
|
19
|
+
@LooperMode(LooperMode.Mode.PAUSED)
|
|
20
|
+
class ClassicAudioServiceDiscoveryTest {
|
|
21
|
+
private val target = BluetoothAdapter.getDefaultAdapter().getRemoteDevice("AA:BB:CC:DD:EE:FF")
|
|
22
|
+
private val sink = ParcelUuid.fromString("0000110b-0000-1000-8000-00805f9b34fb")
|
|
23
|
+
private val zero = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
|
|
24
|
+
private val messages = mutableListOf<String>()
|
|
25
|
+
private val discovery = ClassicAudioServiceDiscovery(Handler(Looper.getMainLooper()), 30000, messages::add)
|
|
26
|
+
private var connects = 0
|
|
27
|
+
|
|
28
|
+
private fun request(device: BluetoothDevice = target) {
|
|
29
|
+
discovery.connectWhenDiscovered(device) { connects++ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@Test fun `bonded Samsung zero UUID inventory waits for real Audio Sink discovery`() {
|
|
33
|
+
shadowOf(target).setBondState(BluetoothDevice.BOND_BONDED)
|
|
34
|
+
shadowOf(target).setUuids(arrayOf(zero))
|
|
35
|
+
shadowOf(target).setFetchUuidsWithSdpResult(true)
|
|
36
|
+
request()
|
|
37
|
+
assertThat(connects).isZero()
|
|
38
|
+
assertThat(shadowOf(target).fetchUuidsWithSdpCount).isEqualTo(1)
|
|
39
|
+
shadowOf(target).setUuids(arrayOf(sink))
|
|
40
|
+
discovery.onUuids(target)
|
|
41
|
+
discovery.onUuids(target)
|
|
42
|
+
assertThat(connects).isEqualTo(1)
|
|
43
|
+
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(30))
|
|
44
|
+
assertThat(messages.none { it.contains("timed out") }).isTrue()
|
|
45
|
+
assertThat(target.bondState).isEqualTo(BluetoothDevice.BOND_BONDED)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@Test fun `valid cached Audio Sink needs no refresh`() {
|
|
49
|
+
shadowOf(target).setUuids(arrayOf(sink))
|
|
50
|
+
request()
|
|
51
|
+
assertThat(connects).isEqualTo(1)
|
|
52
|
+
assertThat(shadowOf(target).fetchUuidsWithSdpCount).isZero()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Test fun `early empty UUID broadcast preserves the wait for the actual SDP result`() {
|
|
56
|
+
shadowOf(target).setUuids(arrayOf(zero))
|
|
57
|
+
shadowOf(target).setFetchUuidsWithSdpResult(true)
|
|
58
|
+
request()
|
|
59
|
+
discovery.onUuids(target)
|
|
60
|
+
assertThat(connects).isZero()
|
|
61
|
+
shadowOf(target).setUuids(arrayOf(sink))
|
|
62
|
+
discovery.onUuids(target)
|
|
63
|
+
assertThat(connects).isEqualTo(1)
|
|
64
|
+
assertThat(shadowOf(target).fetchUuidsWithSdpCount).isEqualTo(1)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@Test fun `unrelated device and duplicate requests cannot complete or multiply discovery`() {
|
|
68
|
+
shadowOf(target).setFetchUuidsWithSdpResult(true)
|
|
69
|
+
request()
|
|
70
|
+
request()
|
|
71
|
+
val other = BluetoothAdapter.getDefaultAdapter().getRemoteDevice("11:22:33:44:55:66")
|
|
72
|
+
shadowOf(other).setUuids(arrayOf(sink))
|
|
73
|
+
discovery.onUuids(other)
|
|
74
|
+
assertThat(connects).isZero()
|
|
75
|
+
assertThat(shadowOf(target).fetchUuidsWithSdpCount).isEqualTo(1)
|
|
76
|
+
shadowOf(target).setUuids(arrayOf(sink))
|
|
77
|
+
discovery.onUuids(target)
|
|
78
|
+
assertThat(connects).isEqualTo(1)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@Test fun `rejected SDP request does not connect on a later unsolicited event`() {
|
|
82
|
+
shadowOf(target).setFetchUuidsWithSdpResult(false)
|
|
83
|
+
request()
|
|
84
|
+
shadowOf(target).setUuids(arrayOf(sink))
|
|
85
|
+
discovery.onUuids(target)
|
|
86
|
+
assertThat(connects).isZero()
|
|
87
|
+
assertThat(messages.last()).contains("rejected")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@Test fun `missing UUID broadcast times out without connecting on a late event`() {
|
|
91
|
+
shadowOf(target).setFetchUuidsWithSdpResult(true)
|
|
92
|
+
request()
|
|
93
|
+
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(20))
|
|
94
|
+
discovery.onUuids(target)
|
|
95
|
+
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(10))
|
|
96
|
+
shadowOf(target).setUuids(arrayOf(sink))
|
|
97
|
+
discovery.onUuids(target)
|
|
98
|
+
assertThat(connects).isZero()
|
|
99
|
+
assertThat(messages.last()).contains("timed out")
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@Test fun `teardown cancellation invalidates callback and removes its timeout`() {
|
|
103
|
+
shadowOf(target).setFetchUuidsWithSdpResult(true)
|
|
104
|
+
request()
|
|
105
|
+
discovery.cancel()
|
|
106
|
+
shadowOf(target).setUuids(arrayOf(sink))
|
|
107
|
+
discovery.onUuids(target)
|
|
108
|
+
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(30))
|
|
109
|
+
assertThat(connects).isZero()
|
|
110
|
+
assertThat(messages.none { it.contains("timed out") }).isTrue()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@Test fun `previous session timeout cannot cancel rediscovery for the same glasses`() {
|
|
114
|
+
shadowOf(target).setFetchUuidsWithSdpResult(true)
|
|
115
|
+
request()
|
|
116
|
+
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(20))
|
|
117
|
+
discovery.cancel()
|
|
118
|
+
request()
|
|
119
|
+
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(10))
|
|
120
|
+
shadowOf(target).setUuids(arrayOf(sink))
|
|
121
|
+
discovery.onUuids(target)
|
|
122
|
+
assertThat(connects).isEqualTo(1)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -8,19 +8,19 @@ class G1TextSanitizerTest {
|
|
|
8
8
|
@Test
|
|
9
9
|
fun `strips Vietnamese diacritics unsupported by G1`() {
|
|
10
10
|
assertThat(sanitizeG1DisplayText("Tiếng Việt: Đặng, Nguyễn, phở bò"))
|
|
11
|
-
.isEqualTo("Tieng Viet: Dang, Nguyen, pho
|
|
11
|
+
.isEqualTo("Tieng Viet: Dang, Nguyen, pho bò")
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
@Test
|
|
15
15
|
fun `handles decomposed marks and preserves layout`() {
|
|
16
16
|
assertThat(sanitizeG1DisplayText("Cafe\u0301\nĐa Nang"))
|
|
17
|
-
.isEqualTo("
|
|
17
|
+
.isEqualTo("Café\nDa Nang")
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
@Test
|
|
21
|
-
fun `
|
|
21
|
+
fun `preserves mapped letters and expands only unmapped letters`() {
|
|
22
22
|
assertThat(sanitizeG1DisplayText("Øresund Łódź Æsir Œuvre Straße Ðingvellir Þingvellir"))
|
|
23
|
-
.isEqualTo("
|
|
23
|
+
.isEqualTo("Øresund Łódź Æsir OEuvre Straße Ðingvellir Þingvellir")
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
@Test
|
|
@@ -28,4 +28,29 @@ class G1TextSanitizerTest {
|
|
|
28
28
|
assertThat(sanitizeG1DisplayText("Hello, world! 123"))
|
|
29
29
|
.isEqualTo("Hello, world! 123")
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
@Test
|
|
33
|
+
fun `preserves Swedish accents case punctuation and spacing`() {
|
|
34
|
+
assertThat(sanitizeG1DisplayText("“Hallå, hallå”\nÅÄÖ åäö"))
|
|
35
|
+
.isEqualTo("“Hallå, hallå”\nÅÄÖ åäö")
|
|
36
|
+
assertThat(sanitizeG1DisplayText("Halla\u030A, halla\u030A"))
|
|
37
|
+
.isEqualTo("Hallå, hallå")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Test
|
|
41
|
+
fun `preserves other scripts and is idempotent`() {
|
|
42
|
+
val otherScripts = "が カ\u3099 한\u302E Α\u0301 مُرَحَّبًا शि ❤️"
|
|
43
|
+
assertThat(sanitizeG1DisplayText(otherScripts)).isEqualTo(otherScripts)
|
|
44
|
+
val normalized = sanitizeG1DisplayText("Hallå Łódź Đặng Œuvre")
|
|
45
|
+
assertThat(normalized).isEqualTo("Hallå Łódź Dang OEuvre")
|
|
46
|
+
assertThat(sanitizeG1DisplayText(normalized)).isEqualTo(normalized)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@Test
|
|
50
|
+
fun `preserves every embedded extended Latin glyph in composed and decomposed forms`() {
|
|
51
|
+
val glyphs = "ÀÂÇÈÉÊËÎÏÔÙÛÜàçèéêëîïôùûüÿŸÄäÖößẞâÁáÍíÑñúÓóÚⁱIJijŠšŽžÃÅÆÌÐÒÕØÝÞãåæìðòõøýþĄąĆćČčĎďĘęĚěĞğŁłŃńŇňŘřŚśŞşŤťŮůŹźŻżĂăıȘșȚțǖǘǚǜǎǐǒǔŐőŰű"
|
|
52
|
+
assertThat(sanitizeG1DisplayText(glyphs)).isEqualTo(glyphs)
|
|
53
|
+
val decomposed = java.text.Normalizer.normalize(glyphs, java.text.Normalizer.Form.NFD)
|
|
54
|
+
assertThat(sanitizeG1DisplayText(decomposed)).isEqualTo(glyphs)
|
|
55
|
+
}
|
|
31
56
|
}
|