@mentra/bluetooth-sdk 3.3.0-dev.316 → 3.3.0-dev.320

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 (46) hide show
  1. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +37 -12
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +30 -13
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +3 -2
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +257 -395
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +225 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +168 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  14. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +557 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +308 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +270 -0
  21. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  22. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  23. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +194 -0
  24. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +199 -0
  25. package/build/generated/releaseMetadata.js +5 -5
  26. package/build/generated/releaseMetadata.js.map +1 -1
  27. package/ios/MentraBluetoothSDK.podspec +3 -2
  28. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  29. package/ios/Source/DeviceManager.swift +23 -10
  30. package/ios/Source/DeviceStore.swift +19 -7
  31. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  32. package/ios/Source/sgcs/G2.swift +2 -2
  33. package/ios/Source/sgcs/Nimo.swift +182 -371
  34. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  35. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +219 -0
  36. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  37. package/ios/Source/sgcs/NimoCanvasSession.swift +150 -0
  38. package/ios/Source/sgcs/SGCManager.swift +16 -0
  39. package/ios/Source/utils/G2Text.swift +11 -0
  40. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  41. package/ios/Tests/G2TextTests.swift +10 -0
  42. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  43. package/ios/Tests/NimoCanvasSessionTests.swift +112 -0
  44. package/ios/Tests/NimoCanvasTransportTests.swift +161 -0
  45. package/package.json +2 -2
  46. package/src/generated/releaseMetadata.ts +5 -5
@@ -0,0 +1,557 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import androidx.test.core.app.ApplicationProvider
4
+ import android.os.Looper
5
+ import com.mentra.bluetoothsdk.sgcs.Nimo
6
+ import com.mentra.bluetoothsdk.sgcs.SGCManager
7
+ import com.mentra.bluetoothsdk.sgcs.SceneFrame
8
+ import com.mentra.bluetoothsdk.utils.DeviceTypes
9
+ import org.junit.Assert.*
10
+ import org.junit.Before
11
+ import org.junit.Test
12
+ import org.junit.runner.RunWith
13
+ import org.robolectric.RobolectricTestRunner
14
+ import org.robolectric.Shadows
15
+ import org.robolectric.annotation.Config
16
+ import org.robolectric.annotation.LooperMode
17
+ import java.util.concurrent.CopyOnWriteArrayList
18
+ import java.util.concurrent.CountDownLatch
19
+ import java.util.concurrent.ExecutorService
20
+ import java.util.concurrent.TimeUnit
21
+
22
+ @RunWith(RobolectricTestRunner::class)
23
+ @Config(sdk = [33])
24
+ class DeviceManagerSceneHandoffTest {
25
+ @Before fun setup() { Bridge.initialize(ApplicationProvider.getApplicationContext()) }
26
+
27
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
28
+ fun nimoBrightnessChangesDoNotReplaceTheSelectedScene() {
29
+ for (headUp in listOf(false, true)) for ((key, value) in brightnessChanges()) {
30
+ withSharedRecordingDevice { manager, device ->
31
+ DeviceStore.set("glasses", "headUp", headUp)
32
+ manager.displayEvent(scene("main", "main-app"))
33
+ manager.displayEvent(scene("dashboard", "dashboard-app"))
34
+ device.calls.clear()
35
+
36
+ DeviceStore.apply("bluetooth", key, value)
37
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(1, TimeUnit.SECONDS)
38
+
39
+ assertEquals(listOf(if (key == "brightness") "65:true" else "50:false"), device.brightnessCalls)
40
+ assertTrue("Changing $key must not replace or clear the NIMO canvas: ${device.calls}", device.calls.isEmpty())
41
+ manager.sendCurrentState()
42
+ assertEquals(listOf("scene:${if (headUp) "dashboard-app" else "main-app"}:true"), device.calls)
43
+ }
44
+ }
45
+ }
46
+
47
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
48
+ fun repeatedNimoBrightnessChangesDoNotLeaveTimersThatEraseNewerFrames() {
49
+ withSharedRecordingDevice { manager, device ->
50
+ DeviceStore.set("glasses", "headUp", false)
51
+ manager.displayEvent(scene("main", "old"))
52
+ device.calls.clear()
53
+ DeviceStore.apply("bluetooth", "brightness", 65)
54
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS)
55
+ manager.displayEvent(scene("main", "latest"))
56
+ DeviceStore.apply("bluetooth", "auto_brightness", false)
57
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS)
58
+ DeviceStore.apply("bluetooth", "auto_brightness", true)
59
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(1, TimeUnit.SECONDS)
60
+
61
+ assertEquals(listOf("65:true", "65:false", "65:true"), device.brightnessCalls)
62
+ assertEquals(listOf("scene:latest:true"), device.calls)
63
+ }
64
+ }
65
+
66
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
67
+ fun nimoBrightnessChangesDoNotWakeSuppressedDisplays() {
68
+ for (gate in listOf("screen-disabled", "not-ready", "no-device")) {
69
+ for ((key, value) in brightnessChanges()) withSharedRecordingDevice { manager, device ->
70
+ when (gate) {
71
+ "screen-disabled" -> DeviceStore.set("bluetooth", "screen_disabled", true)
72
+ "not-ready" -> DeviceStore.set("glasses", "fullyBooted", false)
73
+ "no-device" -> manager.sgc = null
74
+ }
75
+ DeviceStore.apply("bluetooth", key, value)
76
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(1, TimeUnit.SECONDS)
77
+ assertTrue("Unexpected display mutation with $gate", device.calls.isEmpty())
78
+ assertEquals(if (gate == "no-device") 0 else 1, device.brightnessCalls.size)
79
+ manager.sgc = device
80
+ }
81
+ }
82
+ }
83
+
84
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
85
+ fun defaultDevicesKeepTheirBrightnessConfirmation() {
86
+ for ((key, value) in brightnessChanges()) {
87
+ withSharedRecordingDevice(RecordingSGC(true)) { _, device ->
88
+ DeviceStore.apply("bluetooth", key, value)
89
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
90
+ val confirmation = if (key == "brightness") "Set brightness to 65%" else "Disabled auto brightness"
91
+ assertEquals(listOf("text:$confirmation"), device.calls)
92
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(799, TimeUnit.MILLISECONDS)
93
+ assertEquals(listOf("text:$confirmation"), device.calls)
94
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(1, TimeUnit.MILLISECONDS)
95
+ assertEquals(listOf("text:$confirmation", "clear"), device.calls)
96
+ }
97
+ }
98
+ }
99
+
100
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
101
+ fun oldBrightnessConfirmationDoesNotClearAReplacementDevice() {
102
+ for ((key, value) in brightnessChanges()) {
103
+ withSharedRecordingDevice(RecordingSGC(true)) { manager, device ->
104
+ DeviceStore.apply("bluetooth", key, value)
105
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
106
+ assertEquals(1, device.calls.size)
107
+ val replacement = NimoRecordingSGC()
108
+ manager.sgc = replacement
109
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(1, TimeUnit.SECONDS)
110
+ assertEquals("The disconnected device must not be cleared either", 1, device.calls.size)
111
+ assertTrue("A previous device's timer must not touch NIMO", replacement.calls.isEmpty())
112
+ manager.sgc = device
113
+ }
114
+ }
115
+ }
116
+
117
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
118
+ fun unchangedBrightnessSettingsDoNotDispatchForEitherPolicy() {
119
+ for (device in listOf(RecordingSGC(true), NimoRecordingSGC())) {
120
+ withSharedRecordingDevice(device) { _, current ->
121
+ DeviceStore.apply("bluetooth", "brightness", 50)
122
+ DeviceStore.apply("bluetooth", "auto_brightness", true)
123
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(1, TimeUnit.SECONDS)
124
+ assertTrue(current.brightnessCalls.isEmpty())
125
+ assertTrue(current.calls.isEmpty())
126
+ }
127
+ }
128
+ }
129
+
130
+ @Test fun nimoOptsOutOfBrightnessConfirmationIndependentlyOfSceneHandoffPolicy() {
131
+ val nimo = Nimo()
132
+ try {
133
+ assertFalse(nimo.showBrightnessConfirmation)
134
+ assertTrue(RecordingSGC(false).showBrightnessConfirmation)
135
+ assertTrue(RecordingSGC(true).showBrightnessConfirmation)
136
+ } finally { nimo.cleanup() }
137
+ }
138
+
139
+ private fun brightnessChanges(): List<Pair<String, Any>> =
140
+ listOf("brightness" to 65, "auto_brightness" to false)
141
+
142
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
143
+ fun nimoReconnectDoesNotEraseReplayedMainSceneAfterReady() {
144
+ assertNimoReconnectPreservesReplayedScene(headUp = false)
145
+ }
146
+
147
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
148
+ fun nimoReconnectDoesNotEraseReplayedDashboardSceneAfterReady() {
149
+ assertNimoReconnectPreservesReplayedScene(headUp = true)
150
+ }
151
+
152
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
153
+ fun nimoReadyWithoutASceneDoesNotScheduleAWelcomeOrClear() {
154
+ withReadyRecordingDevice { manager, _, executor ->
155
+ manager.disconnect()
156
+ val replacement = NimoRecordingSGC()
157
+ manager.sgc = replacement
158
+ DeviceStore.apply("glasses", "fullyBooted", true)
159
+ executor.submit {}.get(5, TimeUnit.SECONDS)
160
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(4, TimeUnit.SECONDS)
161
+ assertTrue(replacement.calls.isEmpty())
162
+ assertEquals(false, DeviceStore.get("bluetooth", "shouldSendBootingMessage"))
163
+ }
164
+ }
165
+
166
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
167
+ fun defaultDeviceReadyKeepsItsThreeSecondWelcome() {
168
+ withReadyRecordingDevice { manager, _, executor ->
169
+ manager.disconnect()
170
+ val replacement = RecordingSGC(true)
171
+ manager.sgc = replacement
172
+ DeviceStore.apply("glasses", "fullyBooted", true)
173
+ executor.submit {}.get(5, TimeUnit.SECONDS)
174
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(4, TimeUnit.SECONDS)
175
+ assertEquals(listOf("text:// MentraOS Connected", "clear"), replacement.calls)
176
+ assertTrue("Welcome must remain for at least three seconds",
177
+ replacement.lastClearNanos - replacement.lastTextNanos >= TimeUnit.SECONDS.toNanos(3))
178
+ assertEquals(false, DeviceStore.get("bluetooth", "shouldSendBootingMessage"))
179
+ }
180
+ }
181
+
182
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
183
+ fun queuedWelcomeDoesNotReachAReplacementCommunicator() {
184
+ assertTrue("The welcome executor must observe communicator replacement writes",
185
+ java.lang.reflect.Modifier.isVolatile(DeviceManager::class.java.getDeclaredField("sgc").modifiers))
186
+ withReadyRecordingDevice { manager, _, executor ->
187
+ manager.disconnect()
188
+ val original = RecordingSGC(true)
189
+ manager.sgc = original
190
+ val executorEntered = CountDownLatch(1)
191
+ val releaseExecutor = CountDownLatch(1)
192
+ executor.execute { executorEntered.countDown(); releaseExecutor.await(5, TimeUnit.SECONDS) }
193
+ assertTrue(executorEntered.await(1, TimeUnit.SECONDS))
194
+ try {
195
+ DeviceStore.apply("glasses", "fullyBooted", true)
196
+ val replacement = NimoRecordingSGC()
197
+ manager.sgc = replacement
198
+ releaseExecutor.countDown()
199
+ executor.submit {}.get(5, TimeUnit.SECONDS)
200
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(4, TimeUnit.SECONDS)
201
+ assertTrue("The retired communicator must not receive a queued welcome", original.calls.isEmpty())
202
+ assertTrue("The replacement must not receive another communicator's welcome", replacement.calls.isEmpty())
203
+ } finally { releaseExecutor.countDown() }
204
+ }
205
+ }
206
+
207
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
208
+ fun welcomeClearDoesNotEraseAReplacementCommunicatorsScene() {
209
+ withReadyRecordingDevice { manager, _, executor ->
210
+ manager.disconnect()
211
+ val original = RecordingSGC(true)
212
+ manager.sgc = original
213
+ DeviceStore.apply("glasses", "fullyBooted", true)
214
+ assertTrue(original.textSent.await(1, TimeUnit.SECONDS))
215
+ val replacement = NimoRecordingSGC()
216
+ manager.sgc = replacement
217
+ manager.displayEvent(scene("main", "replacement-app", replay = true))
218
+ executor.submit {}.get(5, TimeUnit.SECONDS)
219
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(4, TimeUnit.SECONDS)
220
+ assertEquals(listOf("text:// MentraOS Connected"), original.calls)
221
+ assertEquals(listOf("scene:replacement-app:true"), replacement.calls)
222
+ }
223
+ }
224
+
225
+ @Test fun nimoOptsOutOfConnectionConfirmationIndependentlyOfOtherPolicies() {
226
+ val nimo = Nimo()
227
+ try {
228
+ assertFalse(nimo.showConnectionConfirmation)
229
+ assertTrue(RecordingSGC(false).showConnectionConfirmation)
230
+ assertTrue(RecordingSGC(true).showConnectionConfirmation)
231
+ assertTrue(object : RecordingSGC(false) {
232
+ override val showBrightnessConfirmation = false
233
+ }.showConnectionConfirmation)
234
+ } finally { nimo.cleanup() }
235
+ }
236
+
237
+ private fun assertNimoReconnectPreservesReplayedScene(headUp: Boolean) {
238
+ withReadyRecordingDevice { manager, original, executor ->
239
+ DeviceStore.set("glasses", "headUp", headUp)
240
+ manager.displayEvent(scene("main", "main-app"))
241
+ manager.displayEvent(scene("dashboard", "dashboard-app"))
242
+ val selectedView = if (headUp) "dashboard" else "main"
243
+ val selectedApp = "$selectedView-app"
244
+ val selectedScene = "scene:$selectedApp:true"
245
+ assertEquals("scene:$selectedApp:false", original.calls.last())
246
+ original.calls.clear()
247
+
248
+ // Use the public disconnect, not a manually armed boot flag.
249
+ manager.disconnect()
250
+ assertEquals(listOf("clear", "disconnect"), original.calls)
251
+ assertNull(manager.sgc)
252
+ assertEquals(true, DeviceStore.get("bluetooth", "shouldSendBootingMessage"))
253
+ val replacement = NimoRecordingSGC()
254
+ manager.sgc = replacement
255
+ // Disconnect resets headUp; restore the selected orientation
256
+ // before readiness without injecting a new miniapp frame.
257
+ DeviceStore.set("glasses", "headUp", headUp)
258
+ DeviceStore.apply("glasses", "fullyBooted", true)
259
+ // Model LocalDisplayManager's connected-edge replay through
260
+ // its actual native ingress. The miniapp content is unchanged;
261
+ // the host sends a fresh epoch with all elements created.
262
+ manager.displayEvent(scene(selectedView, selectedApp, replay = true, epoch = 2))
263
+
264
+ // The old welcome slept on this real executor. A submitted
265
+ // barrier waits for any such task; paused Main alone cannot.
266
+ executor.submit {}.get(5, TimeUnit.SECONDS)
267
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(4, TimeUnit.SECONDS)
268
+ val readyCalls = replacement.calls.toList()
269
+
270
+ // Positive control: the scene remains retained for later replay.
271
+ replacement.calls.clear()
272
+ manager.sendCurrentState()
273
+ assertEquals(listOf(selectedScene), replacement.calls)
274
+ assertEquals("Reconnect must not erase the host replay: $readyCalls", selectedScene, readyCalls.last())
275
+ }
276
+ }
277
+
278
+ private fun withReadyRecordingDevice(block: (DeviceManager, RecordingSGC, ExecutorService) -> Unit) {
279
+ // Disconnect and readiness also mutate identity/mic state. Restore both
280
+ // categories, including keys that were absent before this test.
281
+ val saved = listOf("bluetooth", "glasses").associateWith { DeviceStore.store.getCategory(it) }
282
+ try {
283
+ withSharedRecordingDevice { manager, original ->
284
+ val executor = DeviceManager::class.java.getDeclaredField("executor").run {
285
+ isAccessible = true
286
+ get(manager) as ExecutorService
287
+ }
288
+ try { block(manager, original, executor) } finally {
289
+ executor.shutdownNow()
290
+ assertTrue(executor.awaitTermination(1, TimeUnit.SECONDS))
291
+ }
292
+ }
293
+ } finally {
294
+ saved.forEach { (category, values) ->
295
+ (DeviceStore.store.getCategory(category).keys - values.keys).forEach {
296
+ DeviceStore.store.remove(category, it)
297
+ }
298
+ values.forEach { (key, value) -> DeviceStore.set(category, key, value) }
299
+ }
300
+ }
301
+ }
302
+
303
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
304
+ fun contextualDashboardToggleReplaysSelectedViewWithoutNewMiniappEvent() {
305
+ withSharedRecordingDevice { manager, device ->
306
+ manager.displayEvent(scene("main", "main-app"))
307
+ manager.displayEvent(scene("dashboard", "dashboard-app"))
308
+ device.calls.clear()
309
+
310
+ // Settings arrive from Expo's worker queue, not necessarily Main.
311
+ val worker = Thread { DeviceStore.apply("bluetooth", "contextual_dashboard", false) }
312
+ worker.start(); worker.join(1000)
313
+ assertFalse(worker.isAlive)
314
+ assertTrue("Dispatch must wait for Main", device.calls.isEmpty())
315
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
316
+ assertEquals(listOf("scene:main-app:true"), device.calls)
317
+
318
+ device.calls.clear()
319
+ DeviceStore.apply("bluetooth", "contextual_dashboard", true)
320
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
321
+ assertEquals(listOf("scene:dashboard-app:true"), device.calls)
322
+ device.calls.clear()
323
+ DeviceStore.apply("bluetooth", "contextual_dashboard", true)
324
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
325
+ assertTrue("An unchanged setting must not redraw", device.calls.isEmpty())
326
+ }
327
+ }
328
+
329
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
330
+ fun contextualDashboardToggleRetainsHeadDownAndDisplaySuppressionGuards() {
331
+ for (gate in listOf("head-down", "screen-disabled", "not-ready", "no-device")) {
332
+ withSharedRecordingDevice { manager, device ->
333
+ manager.displayEvent(scene("main", "main-app"))
334
+ manager.displayEvent(scene("dashboard", "dashboard-app"))
335
+ device.calls.clear()
336
+ when (gate) {
337
+ "head-down" -> DeviceStore.set("glasses", "headUp", false)
338
+ "screen-disabled" -> DeviceStore.set("bluetooth", "screen_disabled", true)
339
+ "not-ready" -> DeviceStore.set("glasses", "fullyBooted", false)
340
+ "no-device" -> manager.sgc = null
341
+ }
342
+ DeviceStore.apply("bluetooth", "contextual_dashboard", false)
343
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
344
+ assertTrue("Unexpected dispatch with $gate", device.calls.isEmpty())
345
+ manager.sgc = device
346
+ }
347
+ }
348
+ }
349
+
350
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
351
+ fun deferredDashboardHandoffRespectsFullFramePolicy() {
352
+ for (requiresClear in listOf(false, true)) {
353
+ withSharedRecordingDevice(RecordingSGC(requiresClear)) { manager, device ->
354
+ manager.displayEvent(scene("dashboard", "dashboard-app"))
355
+ device.calls.clear()
356
+ manager.setDashboardContent("new dashboard")
357
+ assertEquals(requiresClear, device.calls.any { it.startsWith("remove:") })
358
+ assertTrue(device.calls.last().endsWith("new dashboard"))
359
+ }
360
+ }
361
+ }
362
+
363
+ private fun withSharedRecordingDevice(
364
+ device: RecordingSGC = NimoRecordingSGC(),
365
+ block: (DeviceManager, RecordingSGC) -> Unit
366
+ ) {
367
+ val singleton = DeviceManager::class.java.getDeclaredField("_instance").apply { isAccessible = true }
368
+ val previous = singleton.get(null)
369
+ val keys = listOf("glasses" to "fullyBooted", "glasses" to "headUp",
370
+ "bluetooth" to "contextual_dashboard", "bluetooth" to "screen_disabled",
371
+ "bluetooth" to "brightness", "bluetooth" to "auto_brightness")
372
+ val saved = keys.associateWith { DeviceStore.get(it.first, it.second)!! }
373
+ val manager = DeviceManager(initializeHardware = false)
374
+ manager.sgc = device
375
+ singleton.set(null, manager)
376
+ DeviceStore.set("glasses", "fullyBooted", true)
377
+ DeviceStore.set("glasses", "headUp", true)
378
+ DeviceStore.set("bluetooth", "contextual_dashboard", true)
379
+ DeviceStore.set("bluetooth", "screen_disabled", false)
380
+ DeviceStore.set("bluetooth", "brightness", 50)
381
+ DeviceStore.set("bluetooth", "auto_brightness", true)
382
+ try { block(manager, device) } finally {
383
+ DeviceStore.set("glasses", "fullyBooted", false)
384
+ manager.cleanup()
385
+ singleton.set(null, previous)
386
+ saved.forEach { (key, value) -> DeviceStore.set(key.first, key.second, value) }
387
+ }
388
+ }
389
+
390
+ @Test fun actualDisplayEventHandoffsRespectBothViewsAndDevicePolicy() {
391
+ for (clearRequired in listOf(false, true)) for (headUp in listOf(false, true)) {
392
+ for (contextual in listOf(false, true)) for (view in listOf("main", "dashboard")) {
393
+ val manager = DeviceManager(initializeHardware = false)
394
+ val device = RecordingSGC(clearRequired)
395
+ manager.sgc = device
396
+ DeviceStore.set("glasses", "fullyBooted", true)
397
+ DeviceStore.set("glasses", "headUp", headUp)
398
+ DeviceStore.set("bluetooth", "contextual_dashboard", contextual)
399
+ DeviceStore.set("bluetooth", "screen_disabled", false)
400
+ val visible = (view == "dashboard") == (headUp && contextual)
401
+
402
+ manager.displayEvent(legacy(view, "legacy-a"))
403
+ device.calls.clear()
404
+ manager.displayEvent(scene(view, "one"))
405
+ assertEquals(if (!visible) emptyList() else
406
+ (if (clearRequired) listOf("clear") else emptyList()) + "scene:one:false", device.calls)
407
+
408
+ device.calls.clear()
409
+ manager.displayEvent(scene(view, "two"))
410
+ assertEquals(if (!visible) emptyList() else
411
+ (if (clearRequired) listOf("remove:one-label") else emptyList()) + "scene:two:true", device.calls)
412
+
413
+ device.calls.clear()
414
+ manager.displayEvent(legacy(view, "legacy-b"))
415
+ assertEquals(if (!visible) emptyList() else
416
+ (if (clearRequired) listOf("remove:two-label") else emptyList()) + "text:legacy-b", device.calls)
417
+
418
+ // Hidden events update their real replay slot, not the visible display.
419
+ device.calls.clear()
420
+ DeviceStore.set("glasses", "headUp", view == "dashboard")
421
+ DeviceStore.set("bluetooth", "contextual_dashboard", true)
422
+ manager.sendCurrentState()
423
+ assertEquals(listOf("text:legacy-b"), device.calls)
424
+ DeviceStore.set("glasses", "fullyBooted", false)
425
+ manager.cleanup()
426
+ }
427
+ }
428
+ }
429
+
430
+ @Test fun suppressedDisplaysNeitherClearNorDispatchButRetainSceneReplay() {
431
+ for (suppression in listOf("screen", "boot", "simulated")) {
432
+ val manager = DeviceManager(initializeHardware = false)
433
+ val device = RecordingSGC(true)
434
+ manager.sgc = device
435
+ DeviceStore.set("glasses", "fullyBooted", suppression != "boot")
436
+ DeviceStore.set("glasses", "headUp", false)
437
+ DeviceStore.set("bluetooth", "contextual_dashboard", true)
438
+ DeviceStore.set("bluetooth", "screen_disabled", suppression == "screen")
439
+ if (suppression == "simulated") device.type = DeviceTypes.SIMULATED
440
+ manager.displayEvent(legacy("main", "legacy"))
441
+ manager.displayEvent(scene("main", "one"))
442
+ manager.displayEvent(scene("main", "two"))
443
+ assertTrue(device.calls.isEmpty())
444
+ DeviceStore.set("glasses", "fullyBooted", true)
445
+ DeviceStore.set("bluetooth", "screen_disabled", false)
446
+ device.type = "Clear-required glasses"
447
+ manager.sendCurrentState()
448
+ assertEquals(listOf("scene:two:true"), device.calls)
449
+ DeviceStore.set("glasses", "fullyBooted", false)
450
+ manager.cleanup()
451
+ }
452
+ }
453
+
454
+ @Test fun clearViewDoesNotSweepSceneTwiceAndOtherDeviceDisconnectStaysUnchanged() {
455
+ val manager = DeviceManager(initializeHardware = false)
456
+ val device = RecordingSGC(true)
457
+ manager.sgc = device
458
+ DeviceStore.set("glasses", "fullyBooted", true)
459
+ DeviceStore.set("glasses", "headUp", false)
460
+ DeviceStore.set("bluetooth", "screen_disabled", false)
461
+ manager.displayEvent(scene("main", "one"))
462
+ device.calls.clear()
463
+ manager.displayEvent(mapOf("view" to "main", "layout" to mapOf("layoutType" to "clear_view")))
464
+ assertEquals(listOf("clear"), device.calls)
465
+ DeviceStore.set("glasses", "fullyBooted", false)
466
+ device.calls.clear()
467
+ manager.disconnect()
468
+ assertEquals(listOf("clear", "disconnect"), device.calls)
469
+ assertNull(manager.sgc)
470
+ manager.cleanup()
471
+ }
472
+
473
+ @Test fun ordinaryNimoDisconnectStopsItsEncoderThread() {
474
+ val manager = DeviceManager(initializeHardware = false)
475
+ val before = Thread.getAllStackTraces().keys
476
+ val nimo = Nimo()
477
+ manager.sgc = nimo
478
+ val encoder = Thread.getAllStackTraces().keys.single { it.name == "NimoCanvasEncoder" && it !in before }
479
+ try {
480
+ DeviceStore.set("glasses", "fullyBooted", false)
481
+ manager.disconnect()
482
+ encoder.join(1000)
483
+ assertFalse("Discarded communicator left an encoder thread alive", encoder.isAlive)
484
+ assertNull(manager.sgc)
485
+ } finally { nimo.cleanup(); manager.cleanup() }
486
+ }
487
+
488
+ private fun legacy(view: String, text: String): Map<String, Any> =
489
+ mapOf("view" to view, "layout" to mapOf("layoutType" to "text_wall", "text" to text))
490
+
491
+ private fun scene(view: String, app: String, replay: Boolean = false, epoch: Int = 1): Map<String, Any> = mapOf("view" to view, "scene" to mapOf(
492
+ "appId" to app, "sceneEpoch" to epoch, "replay" to replay, "elements" to listOf(mapOf(
493
+ "id" to "$app-label", "type" to "text", "text" to app, "change" to "created",
494
+ "box" to mapOf("x" to 0, "y" to 0, "w" to 100, "h" to 20)))))
495
+
496
+ private class NimoRecordingSGC : RecordingSGC(false) {
497
+ override val showBrightnessConfirmation = false
498
+ override val showConnectionConfirmation = false
499
+ }
500
+
501
+ private open class RecordingSGC(override val sceneHandoffRequiresClear: Boolean) : SGCManager() {
502
+ val calls = CopyOnWriteArrayList<String>()
503
+ val brightnessCalls = mutableListOf<String>()
504
+ val textSent = CountDownLatch(1)
505
+ var lastTextNanos = 0L
506
+ var lastClearNanos = 0L
507
+ init { type = if (sceneHandoffRequiresClear) "Clear-required glasses" else DeviceTypes.NIMO }
508
+ override fun clearDisplay() { lastClearNanos = System.nanoTime(); calls += "clear" }
509
+ override fun clearSceneElements(elementIds: List<String>) { calls += "remove:${elementIds.joinToString()}" }
510
+ override fun applySceneFrame(frame: SceneFrame) { calls += "scene:${frame.appId}:${frame.replay}" }
511
+ override fun sendTextWall(text: String) { lastTextNanos = System.nanoTime(); calls += "text:$text"; textSent.countDown() }
512
+ override fun disconnect() { calls += "disconnect" }
513
+ override fun cleanup() { calls += "cleanup" }
514
+ override fun setMicEnabled(enabled: Boolean) {}
515
+ override fun sortMicRanking(list: MutableList<String>) = list
516
+ override fun requestPhoto(request: PhotoRequest) {}
517
+ override fun startStream(message: MutableMap<String, Any>) {}
518
+ override fun stopStream() {}
519
+ override fun sendStreamKeepAlive(message: MutableMap<String, Any>) {}
520
+ override fun startVideoRecording(requestId: String, save: Boolean, sound: Boolean) {}
521
+ override fun stopVideoRecording(requestId: String) {}
522
+ override fun sendButtonPhotoSettings() {}
523
+ override fun sendButtonVideoRecordingSettings() {}
524
+ override fun sendButtonMaxRecordingTime() {}
525
+ override fun sendCameraFovSetting() {}
526
+ override fun setBrightness(level: Int, autoMode: Boolean) { brightnessCalls += "$level:$autoMode" }
527
+ override fun sendText(text: String) {}
528
+ override fun sendDoubleTextWall(top: String, bottom: String) {}
529
+ override fun displayBitmap(base64ImageData: String, x: Int?, y: Int?, width: Int?, height: Int?) = true
530
+ override fun showDashboard() {}
531
+ override fun setDashboardPosition(height: Int, depth: Int) {}
532
+ override fun setHeadUpAngle(angle: Int) {}
533
+ override fun getBatteryStatus() {}
534
+ override fun setSilentMode(enabled: Boolean) {}
535
+ override fun exit() {}
536
+ override fun sendShutdown() {}
537
+ override fun sendReboot() {}
538
+ override fun sendRgbLedControl(requestId: String, packageName: String?, action: String, color: String?, onDurationMs: Int, offDurationMs: Int, count: Int) {}
539
+ override fun forget() {}
540
+ override fun findCompatibleDevices() {}
541
+ override fun stopScan() {}
542
+ override fun connectById(id: String) {}
543
+ override fun getConnectedBluetoothName() = ""
544
+ override fun ping() {}
545
+ override fun dbg1() {}
546
+ override fun dbg2() {}
547
+ override fun requestWifiScan(scanId: String?) {}
548
+ override fun sendWifiCredentials(ssid: String, password: String) {}
549
+ override fun forgetWifiNetwork(ssid: String) {}
550
+ override fun sendHotspotState(enabled: Boolean) {}
551
+ override fun sendUserEmailToGlasses(email: String) {}
552
+ override fun sendIncidentId(incidentId: String, apiBaseUrl: String?) {}
553
+ override fun queryGalleryStatus() {}
554
+ override fun sendGalleryMode() {}
555
+ override fun requestVersionInfo() {}
556
+ }
557
+ }
@@ -0,0 +1,13 @@
1
+ package com.mentra.bluetoothsdk
2
+
3
+ import com.mentra.bluetoothsdk.utils.G2Text
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Test
6
+
7
+ class G2TextTest {
8
+ @Test fun blankRowsAreEncodedOnlyAtTheDeviceBoundary() {
9
+ assertEquals("hello\n\u200B\nworld\n\u200B", G2Text.containerContent("hello\n\nworld\n"))
10
+ assertEquals(" ", G2Text.containerContent(""))
11
+ assertEquals("hello\nworld", G2Text.containerContent("hello\nworld"))
12
+ }
13
+ }
@@ -0,0 +1,105 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.graphics.Bitmap
4
+ import androidx.test.core.app.ApplicationProvider
5
+ import com.mentra.bluetoothsdk.Bridge
6
+ import java.io.ByteArrayOutputStream
7
+ import java.util.Base64
8
+ import org.junit.After
9
+ import org.junit.Assert.*
10
+ import org.junit.Before
11
+ import org.junit.Test
12
+ import org.junit.runner.RunWith
13
+ import org.robolectric.RobolectricTestRunner
14
+ import org.robolectric.annotation.Config
15
+ import org.robolectric.annotation.GraphicsMode
16
+
17
+ @RunWith(RobolectricTestRunner::class)
18
+ @Config(sdk = [33])
19
+ @GraphicsMode(GraphicsMode.Mode.NATIVE)
20
+ class NimoBitmapAcceptanceTest {
21
+ private lateinit var nimo: Nimo
22
+
23
+ @Before fun setup() {
24
+ Bridge.initialize(ApplicationProvider.getApplicationContext())
25
+ nimo = Nimo()
26
+ }
27
+
28
+ @After fun dispose() { nimo.cleanup() }
29
+
30
+ @Test fun malformedInputsAreRejectedBeforeReturning() {
31
+ val invalid = listOf(
32
+ "not base64!",
33
+ "data:image/jpeg;base64,${png()}",
34
+ "data:image/bmp;base64,${png()}",
35
+ Base64.getEncoder().encodeToString(byteArrayOf(0x89.toByte(), 80, 78, 71, 13, 10, 26, 10) + ByteArray(40)),
36
+ Base64.getEncoder().encodeToString(byteArrayOf(66, 77) + ByteArray(60)),
37
+ "A".repeat(NimoCanvasCodec.MAX_IMAGE_BASE64_CHARS + 1)
38
+ )
39
+ for (input in invalid) {
40
+ assertFalse("Malformed bitmap was accepted: ${input.take(50)}", nimo.displayBitmap(input))
41
+ }
42
+ }
43
+
44
+ @Test fun validPngAcceptsNullableGeometryAndRejectsOutOfBoundsGeometry() {
45
+ val image = png()
46
+ assertTrue(nimo.displayBitmap(image))
47
+ assertTrue(nimo.displayBitmap(image, 499, 219, null, null))
48
+ assertFalse(nimo.displayBitmap(image, 500, 0, null, null))
49
+ assertFalse(nimo.displayBitmap(image, 0, 0, 0, 1))
50
+ assertFalse(nimo.displayBitmap(image, -1, 0, 10, 10))
51
+ }
52
+
53
+ @Test fun jpegPixelsAreDecodedForRawAndDataUriInputs() {
54
+ val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888)
55
+ val bytes = ByteArrayOutputStream()
56
+ try {
57
+ bitmap.eraseColor(android.graphics.Color.WHITE)
58
+ assertTrue(bitmap.compress(Bitmap.CompressFormat.JPEG, 100, bytes))
59
+ } finally { bitmap.recycle() }
60
+ val raw = Base64.getEncoder().encodeToString(bytes.toByteArray())
61
+ val decode = Nimo::class.java.getDeclaredMethod(
62
+ "decodeCanvasImage", String::class.java, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType
63
+ ).apply { isAccessible = true }
64
+ for (input in listOf(raw, "data:image/jpeg;base64,$raw")) {
65
+ assertTrue(nimo.displayBitmap(input))
66
+ assertArrayEquals(ByteArray(4) { 255.toByte() }, decode.invoke(nimo, input, 2, 2) as ByteArray)
67
+ }
68
+ assertFalse(nimo.displayBitmap("data:image/png;base64,$raw"))
69
+ }
70
+
71
+ @Test fun sourceRasterBudgetsRejectOversizeAndAcceptBoundaries() {
72
+ for ((width, height) in listOf(4097 to 1, 1 to 4097, 2049 to 2000)) {
73
+ assertFalse("Oversize source $width x $height", nimo.displayBitmap(png(width, height)))
74
+ }
75
+ for ((width, height) in listOf(4096 to 1, 1 to 4096, 2000 to 2000)) {
76
+ assertTrue("Valid boundary $width x $height", nimo.displayBitmap(png(width, height)))
77
+ }
78
+ }
79
+
80
+ @Test fun coloredRasterQuantizationMatchesIosAtEveryTwoBitBoundary() {
81
+ // Identical opaque 8x1 24-bit BMP in NimoCanvasSceneTests; no scaling.
82
+ val bmp = byteArrayOf(
83
+ 66, 77, 78, 0, 0, 0, 0, 0, 0, 0, 54, 0, 0, 0,
84
+ 40, 0, 0, 0, 8, 0, 0, 0, 1, 0, 0, 0, 1, 0, 24, 0,
85
+ 0, 0, 0, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
86
+ 0, 72, 0, 0, 73, 0, 0, 217.toByte(), 0, 0, 218.toByte(), 0,
87
+ 0, 231.toByte(), 255.toByte(), 0, 232.toByte(), 255.toByte(), 0, 0, 0, -1, -1, -1
88
+ )
89
+ val decode = Nimo::class.java.getDeclaredMethod(
90
+ "decodeCanvasImage", String::class.java, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType
91
+ ).apply { isAccessible = true }
92
+ val gray = decode.invoke(nimo, Base64.getEncoder().encodeToString(bmp), 8, 1) as ByteArray
93
+ assertArrayEquals(byteArrayOf(42, 43, 127, 128.toByte(), 212.toByte(), 213.toByte(), 0, -1), gray)
94
+ assertArrayEquals(byteArrayOf(0xE9.toByte(), 0x4C), NimoCanvasCodec.pack2(gray))
95
+ }
96
+
97
+ private fun png(width: Int = 2, height: Int = 2): String {
98
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
99
+ return try {
100
+ val bytes = ByteArrayOutputStream()
101
+ assertTrue(bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes))
102
+ Base64.getEncoder().encodeToString(bytes.toByteArray())
103
+ } finally { bitmap.recycle() }
104
+ }
105
+ }