@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.
Files changed (95) hide show
  1. package/README.md +68 -6
  2. package/android/build.gradle +15 -0
  3. package/android/lc3Lib/build.gradle +6 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -2
  9. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +4 -1
  12. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -1
  13. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  14. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +223 -36
  15. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +93 -18
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +3 -1
  24. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  25. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
  29. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
  32. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +20 -7
  36. package/build/BluetoothSdk.types.d.ts +3 -0
  37. package/build/BluetoothSdk.types.d.ts.map +1 -1
  38. package/build/BluetoothSdk.types.js.map +1 -1
  39. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  40. package/build/_private/BluetoothSdkModule.js +2 -1
  41. package/build/_private/BluetoothSdkModule.js.map +1 -1
  42. package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
  43. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  44. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  45. package/build/_private/nativeLogConsole.d.ts +19 -0
  46. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  47. package/build/_private/nativeLogConsole.js +12 -0
  48. package/build/_private/nativeLogConsole.js.map +1 -1
  49. package/build/generated/changelogCatalog.d.ts +4 -1
  50. package/build/generated/changelogCatalog.d.ts.map +1 -1
  51. package/build/generated/changelogCatalog.js +5 -1
  52. package/build/generated/changelogCatalog.js.map +1 -1
  53. package/build/generated/releaseMetadata.js +6 -6
  54. package/build/generated/releaseMetadata.js.map +1 -1
  55. package/build/ota-transport/index.d.ts +4 -0
  56. package/build/ota-transport/index.d.ts.map +1 -1
  57. package/build/ota-transport/index.js +2 -0
  58. package/build/ota-transport/index.js.map +1 -1
  59. package/ios/BluetoothSdkModule.swift +8 -4
  60. package/ios/Source/BluetoothSdkDefaults.swift +2 -2
  61. package/ios/Source/DeviceManager.swift +6 -3
  62. package/ios/Source/GeneratedChangelogCatalog.swift +2 -1
  63. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  64. package/ios/Source/MentraBluetoothSDK.swift +295 -54
  65. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  66. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  67. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  68. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  69. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  70. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  71. package/ios/Source/sgcs/MentraLive.swift +71 -41
  72. package/ios/Source/sgcs/NimoCanvasCodec.swift +4 -1
  73. package/ios/Source/sgcs/SGCManager.swift +1 -1
  74. package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
  75. package/ios/Source/utils/G1Text.swift +2 -24
  76. package/ios/Source/utils/G1TextSanitizer.swift +27 -0
  77. package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
  78. package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
  79. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  80. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  81. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  82. package/ios/Tests/G1TextSanitizerTests.swift +32 -0
  83. package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
  84. package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
  85. package/ios/Tests/NimoCanvasCodecTests.swift +17 -4
  86. package/ios/Tests/VersionInfoCommandTests.swift +142 -1
  87. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  88. package/package.json +2 -2
  89. package/src/BluetoothSdk.types.ts +1 -0
  90. package/src/_private/BluetoothSdkModule.ts +2 -1
  91. package/src/_private/MentraLocalNetworkModule.ts +28 -0
  92. package/src/_private/nativeLogConsole.ts +19 -0
  93. package/src/generated/changelogCatalog.ts +5 -1
  94. package/src/generated/releaseMetadata.ts +6 -6
  95. package/src/ota-transport/index.ts +10 -1
@@ -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
- assertThat(ScanCallback::class.java.declaredMethods.map { it.name })
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
+ }
@@ -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 bo")
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("Cafe\nDa Nang")
17
+ .isEqualTo("Café\nDa Nang")
18
18
  }
19
19
 
20
20
  @Test
21
- fun `expands Latin letters that do not decompose into combining marks`() {
21
+ fun `preserves mapped letters and expands only unmapped letters`() {
22
22
  assertThat(sanitizeG1DisplayText("Øresund Łódź Æsir Œuvre Straße Ðingvellir Þingvellir"))
23
- .isEqualTo("Oresund Lodz AEsir OEuvre Strasse Dingvellir THingvellir")
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
  }