@mentra/bluetooth-sdk 3.2.1-dev.314 → 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 (191) hide show
  1. package/README.md +72 -10
  2. package/android/build.gradle +15 -0
  3. package/android/lc3Lib/build.gradle +6 -1
  4. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  5. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +4 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +8 -2
  11. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +133 -26
  15. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  16. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +3 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  18. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +227 -36
  19. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  22. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  24. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  31. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
  32. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +194 -40
  33. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  34. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  35. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +247 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  37. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  42. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  43. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  45. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  48. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  50. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  51. package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
  52. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
  53. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  54. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  55. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  56. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
  57. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  58. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
  59. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
  62. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  63. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  64. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
  65. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  66. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  67. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  68. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  69. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +233 -0
  70. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  71. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  72. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  73. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  74. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  75. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  76. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  77. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  78. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  79. package/build/BluetoothSdk.types.d.ts +16 -0
  80. package/build/BluetoothSdk.types.d.ts.map +1 -1
  81. package/build/BluetoothSdk.types.js +1 -0
  82. package/build/BluetoothSdk.types.js.map +1 -1
  83. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  84. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  85. package/build/_private/BluetoothSdkModule.js +5 -1
  86. package/build/_private/BluetoothSdkModule.js.map +1 -1
  87. package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
  88. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  89. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  90. package/build/_private/nativeLogConsole.d.ts +19 -0
  91. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  92. package/build/_private/nativeLogConsole.js +12 -0
  93. package/build/_private/nativeLogConsole.js.map +1 -1
  94. package/build/generated/changelogCatalog.d.ts +5 -2
  95. package/build/generated/changelogCatalog.d.ts.map +1 -1
  96. package/build/generated/changelogCatalog.js +6 -2
  97. package/build/generated/changelogCatalog.js.map +1 -1
  98. package/build/generated/releaseMetadata.js +6 -6
  99. package/build/generated/releaseMetadata.js.map +1 -1
  100. package/build/index.d.ts.map +1 -1
  101. package/build/index.js +1 -0
  102. package/build/index.js.map +1 -1
  103. package/build/ota-transport/index.d.ts +4 -0
  104. package/build/ota-transport/index.d.ts.map +1 -1
  105. package/build/ota-transport/index.js +2 -0
  106. package/build/ota-transport/index.js.map +1 -1
  107. package/ios/BluetoothSdkModule.swift +17 -4
  108. package/ios/MentraBluetoothSDK.podspec +3 -2
  109. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  110. package/ios/Source/BluetoothSdkDefaults.swift +7 -2
  111. package/ios/Source/Bridge.swift +15 -2
  112. package/ios/Source/DeviceManager.swift +102 -66
  113. package/ios/Source/DeviceStore.swift +26 -7
  114. package/ios/Source/GeneratedChangelogCatalog.swift +3 -2
  115. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  116. package/ios/Source/MentraBluetoothSDK.swift +313 -54
  117. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  118. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  119. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  120. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  121. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  122. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  123. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  124. package/ios/Source/requests/DisplayRequests.swift +1 -1
  125. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  126. package/ios/Source/sgcs/Ar99.swift +1 -2
  127. package/ios/Source/sgcs/G1.swift +68 -38
  128. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  129. package/ios/Source/sgcs/G2.swift +261 -247
  130. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  131. package/ios/Source/sgcs/Mach1.swift +19 -7
  132. package/ios/Source/sgcs/MentraLive.swift +272 -197
  133. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  134. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  135. package/ios/Source/sgcs/MentraNex.swift +8 -31
  136. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  137. package/ios/Source/sgcs/Nimo.swift +280 -408
  138. package/ios/Source/sgcs/NimoCanvasCodec.swift +272 -0
  139. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  140. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  141. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  142. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  143. package/ios/Source/sgcs/SGCManager.swift +25 -1
  144. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  145. package/ios/Source/status/DeviceStatus.swift +1 -0
  146. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  147. package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
  148. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  149. package/ios/Source/utils/G1Text.swift +2 -24
  150. package/ios/Source/utils/G1TextSanitizer.swift +27 -0
  151. package/ios/Source/utils/G2Text.swift +11 -0
  152. package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
  153. package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
  154. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  155. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  156. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  157. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  158. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  159. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  160. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  161. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  162. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  163. package/ios/Tests/G1PairingTests.swift +162 -0
  164. package/ios/Tests/G1TextSanitizerTests.swift +32 -0
  165. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  166. package/ios/Tests/G2TextTests.swift +10 -0
  167. package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
  168. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  169. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  170. package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
  171. package/ios/Tests/NimoCanvasCodecTests.swift +217 -0
  172. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  173. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  174. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  175. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  176. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  177. package/ios/Tests/VersionInfoCommandTests.swift +194 -0
  178. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  179. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  180. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  181. package/package.json +2 -2
  182. package/plugin/build/withIos.d.ts +2 -0
  183. package/plugin/build/withIos.js +12 -1
  184. package/src/BluetoothSdk.types.ts +15 -0
  185. package/src/_private/BluetoothSdkModule.ts +10 -1
  186. package/src/_private/MentraLocalNetworkModule.ts +28 -0
  187. package/src/_private/nativeLogConsole.ts +19 -0
  188. package/src/generated/changelogCatalog.ts +6 -2
  189. package/src/generated/releaseMetadata.ts +6 -6
  190. package/src/index.ts +1 -0
  191. package/src/ota-transport/index.ts +10 -1
@@ -0,0 +1,666 @@
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 delayedReconnectReplayPreservesNimoDepthEndpoints() {
29
+ for (depth in listOf(0, 10)) {
30
+ withReadyRecordingDevice { manager, device, _ ->
31
+ DeviceStore.set("bluetooth", "dashboard_height", 7)
32
+ DeviceStore.set("bluetooth", "dashboard_depth", depth)
33
+ manager.handleDeviceReady()
34
+ assertTrue(device.positions.isEmpty())
35
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(2, TimeUnit.SECONDS)
36
+ assertEquals(listOf(7 to depth), device.positions)
37
+ }
38
+ }
39
+ }
40
+
41
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
42
+ fun reconnectRestoresUnchangedAudioRequests() {
43
+ for (request in listOf("should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active")) {
44
+ withReadyRecordingDevice { manager, original, _ ->
45
+ for (key in listOf("should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active")) {
46
+ DeviceStore.set("bluetooth", key, key == request)
47
+ }
48
+ DeviceStore.set("bluetooth", "micRanking", listOf("glasses"))
49
+ DeviceStore.set("glasses", "micEnabled", false)
50
+ original.hasMic = true
51
+ manager.setMicState()
52
+ assertEquals(true, DeviceStore.get("bluetooth", "micEnabled"))
53
+ assertEquals(true, DeviceStore.get("glasses", "micEnabled"))
54
+
55
+ repeat(3) {
56
+ manager.disconnect()
57
+ assertEquals(false, DeviceStore.get("bluetooth", "micEnabled"))
58
+ assertEquals(false, DeviceStore.get("glasses", "micEnabled"))
59
+ assertEquals(true, DeviceStore.get("bluetooth", request))
60
+ val replacement = NimoRecordingSGC().apply { hasMic = true }
61
+ manager.sgc = replacement
62
+
63
+ // No test reset of glasses.micEnabled: production teardown must
64
+ // clear it. Replaying the same consumer request is deduplicated.
65
+ DeviceStore.apply("bluetooth", request, true)
66
+ assertEquals(false, DeviceStore.get("bluetooth", "micEnabled"))
67
+ DeviceStore.apply("glasses", "fullyBooted", true)
68
+
69
+ assertEquals("Reconnect must restore $request", true, DeviceStore.get("bluetooth", "micEnabled"))
70
+ assertEquals(listOf(true), replacement.micChanges)
71
+ assertEquals("glasses", manager.activeMicSource())
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
78
+ fun linkDisconnectRetainsIntentForTheSameCommunicator() {
79
+ withReadyRecordingDevice { manager, original, _ ->
80
+ original.hasMic = true
81
+ DeviceStore.set("bluetooth", "micRanking", listOf("glasses"))
82
+ DeviceStore.set("glasses", "micEnabled", false)
83
+ DeviceStore.set("bluetooth", "should_send_lc3", true)
84
+ manager.setMicState()
85
+ assertEquals(listOf(true), original.micChanges)
86
+ DeviceStore.set("glasses", "fullyBooted", true)
87
+ DeviceStore.apply("glasses", "fullyBooted", false)
88
+ assertSame(original, manager.sgc)
89
+ assertEquals(true, DeviceStore.get("bluetooth", "should_send_lc3"))
90
+ assertEquals(true, DeviceStore.get("glasses", "micEnabled"))
91
+ }
92
+ }
93
+
94
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
95
+ fun discardingCommunicatorInvalidatesMicCache() {
96
+ withReadyRecordingDevice { manager, original, _ ->
97
+ original.hasMic = true
98
+ DeviceStore.set("bluetooth", "micRanking", listOf("glasses"))
99
+ DeviceStore.set("glasses", "micEnabled", false)
100
+ DeviceStore.set("bluetooth", "should_send_lc3", true)
101
+ manager.setMicState()
102
+ manager.initSGC(original.type)
103
+ assertEquals(true, DeviceStore.get("glasses", "micEnabled"))
104
+
105
+ // An unsupported model exercises disposal without starting real BLE.
106
+ manager.initSGC("Unavailable test glasses")
107
+ assertNull(manager.sgc)
108
+ assertEquals(false, DeviceStore.get("glasses", "micEnabled"))
109
+ assertEquals(true, DeviceStore.get("bluetooth", "should_send_lc3"))
110
+ }
111
+ }
112
+
113
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
114
+ fun reconnectWithoutAudioRequestsLeavesMicrophoneOff() {
115
+ withReadyRecordingDevice { manager, _, _ ->
116
+ for (key in listOf("should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active")) {
117
+ DeviceStore.set("bluetooth", key, false)
118
+ }
119
+ DeviceStore.set("glasses", "micEnabled", true)
120
+ manager.disconnect()
121
+ assertEquals(false, DeviceStore.get("glasses", "micEnabled"))
122
+ val replacement = NimoRecordingSGC().apply { hasMic = true }
123
+ manager.sgc = replacement
124
+ DeviceStore.apply("glasses", "fullyBooted", true)
125
+ assertEquals(false, DeviceStore.get("bluetooth", "micEnabled"))
126
+ assertEquals("", manager.activeMicSource())
127
+ assertTrue(replacement.micChanges.isEmpty())
128
+ }
129
+ }
130
+
131
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
132
+ fun nimoBrightnessChangesDoNotReplaceTheSelectedScene() {
133
+ for (headUp in listOf(false, true)) for ((key, value) in brightnessChanges()) {
134
+ withSharedRecordingDevice { manager, device ->
135
+ DeviceStore.set("glasses", "headUp", headUp)
136
+ manager.displayEvent(scene("main", "main-app"))
137
+ manager.displayEvent(scene("dashboard", "dashboard-app"))
138
+ device.calls.clear()
139
+
140
+ DeviceStore.apply("bluetooth", key, value)
141
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(1, TimeUnit.SECONDS)
142
+
143
+ assertEquals(listOf(if (key == "brightness") "65:true" else "50:false"), device.brightnessCalls)
144
+ assertTrue("Changing $key must not replace or clear the NIMO canvas: ${device.calls}", device.calls.isEmpty())
145
+ manager.sendCurrentState()
146
+ assertEquals(listOf("scene:${if (headUp) "dashboard-app" else "main-app"}:true"), device.calls)
147
+ }
148
+ }
149
+ }
150
+
151
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
152
+ fun repeatedNimoBrightnessChangesDoNotLeaveTimersThatEraseNewerFrames() {
153
+ withSharedRecordingDevice { manager, device ->
154
+ DeviceStore.set("glasses", "headUp", false)
155
+ manager.displayEvent(scene("main", "old"))
156
+ device.calls.clear()
157
+ DeviceStore.apply("bluetooth", "brightness", 65)
158
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS)
159
+ manager.displayEvent(scene("main", "latest"))
160
+ DeviceStore.apply("bluetooth", "auto_brightness", false)
161
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS)
162
+ DeviceStore.apply("bluetooth", "auto_brightness", true)
163
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(1, TimeUnit.SECONDS)
164
+
165
+ assertEquals(listOf("65:true", "65:false", "65:true"), device.brightnessCalls)
166
+ assertEquals(listOf("scene:latest:true"), device.calls)
167
+ }
168
+ }
169
+
170
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
171
+ fun nimoBrightnessChangesDoNotWakeSuppressedDisplays() {
172
+ for (gate in listOf("screen-disabled", "not-ready", "no-device")) {
173
+ for ((key, value) in brightnessChanges()) withSharedRecordingDevice { manager, device ->
174
+ when (gate) {
175
+ "screen-disabled" -> DeviceStore.set("bluetooth", "screen_disabled", true)
176
+ "not-ready" -> DeviceStore.set("glasses", "fullyBooted", false)
177
+ "no-device" -> manager.sgc = null
178
+ }
179
+ DeviceStore.apply("bluetooth", key, value)
180
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(1, TimeUnit.SECONDS)
181
+ assertTrue("Unexpected display mutation with $gate", device.calls.isEmpty())
182
+ assertEquals(if (gate == "no-device") 0 else 1, device.brightnessCalls.size)
183
+ manager.sgc = device
184
+ }
185
+ }
186
+ }
187
+
188
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
189
+ fun defaultDevicesKeepTheirBrightnessConfirmation() {
190
+ for ((key, value) in brightnessChanges()) {
191
+ withSharedRecordingDevice(RecordingSGC(true)) { _, device ->
192
+ DeviceStore.apply("bluetooth", key, value)
193
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
194
+ val confirmation = if (key == "brightness") "Set brightness to 65%" else "Disabled auto brightness"
195
+ assertEquals(listOf("text:$confirmation"), device.calls)
196
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(799, TimeUnit.MILLISECONDS)
197
+ assertEquals(listOf("text:$confirmation"), device.calls)
198
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(1, TimeUnit.MILLISECONDS)
199
+ assertEquals(listOf("text:$confirmation", "clear"), device.calls)
200
+ }
201
+ }
202
+ }
203
+
204
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
205
+ fun oldBrightnessConfirmationDoesNotClearAReplacementDevice() {
206
+ for ((key, value) in brightnessChanges()) {
207
+ withSharedRecordingDevice(RecordingSGC(true)) { manager, device ->
208
+ DeviceStore.apply("bluetooth", key, value)
209
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
210
+ assertEquals(1, device.calls.size)
211
+ val replacement = NimoRecordingSGC()
212
+ manager.sgc = replacement
213
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(1, TimeUnit.SECONDS)
214
+ assertEquals("The disconnected device must not be cleared either", 1, device.calls.size)
215
+ assertTrue("A previous device's timer must not touch NIMO", replacement.calls.isEmpty())
216
+ manager.sgc = device
217
+ }
218
+ }
219
+ }
220
+
221
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
222
+ fun unchangedBrightnessSettingsDoNotDispatchForEitherPolicy() {
223
+ for (device in listOf(RecordingSGC(true), NimoRecordingSGC())) {
224
+ withSharedRecordingDevice(device) { _, current ->
225
+ DeviceStore.apply("bluetooth", "brightness", 50)
226
+ DeviceStore.apply("bluetooth", "auto_brightness", true)
227
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(1, TimeUnit.SECONDS)
228
+ assertTrue(current.brightnessCalls.isEmpty())
229
+ assertTrue(current.calls.isEmpty())
230
+ }
231
+ }
232
+ }
233
+
234
+ @Test fun nimoOptsOutOfBrightnessConfirmationIndependentlyOfSceneHandoffPolicy() {
235
+ val nimo = Nimo()
236
+ try {
237
+ assertFalse(nimo.showBrightnessConfirmation)
238
+ assertTrue(RecordingSGC(false).showBrightnessConfirmation)
239
+ assertTrue(RecordingSGC(true).showBrightnessConfirmation)
240
+ } finally { nimo.cleanup() }
241
+ }
242
+
243
+ private fun brightnessChanges(): List<Pair<String, Any>> =
244
+ listOf("brightness" to 65, "auto_brightness" to false)
245
+
246
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
247
+ fun nimoReconnectDoesNotEraseReplayedMainSceneAfterReady() {
248
+ assertNimoReconnectPreservesReplayedScene(headUp = false)
249
+ }
250
+
251
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
252
+ fun nimoReconnectDoesNotEraseReplayedDashboardSceneAfterReady() {
253
+ assertNimoReconnectPreservesReplayedScene(headUp = true)
254
+ }
255
+
256
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
257
+ fun nimoReadyWithoutASceneDoesNotScheduleAWelcomeOrClear() {
258
+ withReadyRecordingDevice { manager, _, executor ->
259
+ manager.disconnect()
260
+ val replacement = NimoRecordingSGC()
261
+ manager.sgc = replacement
262
+ DeviceStore.apply("glasses", "fullyBooted", true)
263
+ executor.submit {}.get(5, TimeUnit.SECONDS)
264
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(4, TimeUnit.SECONDS)
265
+ assertTrue(replacement.calls.isEmpty())
266
+ assertEquals(false, DeviceStore.get("bluetooth", "shouldSendBootingMessage"))
267
+ }
268
+ }
269
+
270
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
271
+ fun defaultDeviceReadyKeepsItsThreeSecondWelcome() {
272
+ withReadyRecordingDevice { manager, _, executor ->
273
+ manager.disconnect()
274
+ val replacement = RecordingSGC(true)
275
+ manager.sgc = replacement
276
+ DeviceStore.apply("glasses", "fullyBooted", true)
277
+ executor.submit {}.get(5, TimeUnit.SECONDS)
278
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(4, TimeUnit.SECONDS)
279
+ assertEquals(listOf("text:// MentraOS Connected", "clear"), replacement.calls)
280
+ assertTrue("Welcome must remain for at least three seconds",
281
+ replacement.lastClearNanos - replacement.lastTextNanos >= TimeUnit.SECONDS.toNanos(3))
282
+ assertEquals(false, DeviceStore.get("bluetooth", "shouldSendBootingMessage"))
283
+ }
284
+ }
285
+
286
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
287
+ fun queuedWelcomeDoesNotReachAReplacementCommunicator() {
288
+ assertTrue("The welcome executor must observe communicator replacement writes",
289
+ java.lang.reflect.Modifier.isVolatile(DeviceManager::class.java.getDeclaredField("sgc").modifiers))
290
+ withReadyRecordingDevice { manager, _, executor ->
291
+ manager.disconnect()
292
+ val original = RecordingSGC(true)
293
+ manager.sgc = original
294
+ val executorEntered = CountDownLatch(1)
295
+ val releaseExecutor = CountDownLatch(1)
296
+ executor.execute { executorEntered.countDown(); releaseExecutor.await(5, TimeUnit.SECONDS) }
297
+ assertTrue(executorEntered.await(1, TimeUnit.SECONDS))
298
+ try {
299
+ DeviceStore.apply("glasses", "fullyBooted", true)
300
+ val replacement = NimoRecordingSGC()
301
+ manager.sgc = replacement
302
+ releaseExecutor.countDown()
303
+ executor.submit {}.get(5, TimeUnit.SECONDS)
304
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(4, TimeUnit.SECONDS)
305
+ assertTrue("The retired communicator must not receive a queued welcome", original.calls.isEmpty())
306
+ assertTrue("The replacement must not receive another communicator's welcome", replacement.calls.isEmpty())
307
+ } finally { releaseExecutor.countDown() }
308
+ }
309
+ }
310
+
311
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
312
+ fun welcomeClearDoesNotEraseAReplacementCommunicatorsScene() {
313
+ withReadyRecordingDevice { manager, _, executor ->
314
+ manager.disconnect()
315
+ val original = RecordingSGC(true)
316
+ manager.sgc = original
317
+ DeviceStore.apply("glasses", "fullyBooted", true)
318
+ assertTrue(original.textSent.await(1, TimeUnit.SECONDS))
319
+ val replacement = NimoRecordingSGC()
320
+ manager.sgc = replacement
321
+ manager.displayEvent(scene("main", "replacement-app", replay = true))
322
+ executor.submit {}.get(5, TimeUnit.SECONDS)
323
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(4, TimeUnit.SECONDS)
324
+ assertEquals(listOf("text:// MentraOS Connected"), original.calls)
325
+ assertEquals(listOf("scene:replacement-app:true"), replacement.calls)
326
+ }
327
+ }
328
+
329
+ @Test fun nimoOptsOutOfConnectionConfirmationIndependentlyOfOtherPolicies() {
330
+ val nimo = Nimo()
331
+ try {
332
+ assertFalse(nimo.showConnectionConfirmation)
333
+ assertTrue(RecordingSGC(false).showConnectionConfirmation)
334
+ assertTrue(RecordingSGC(true).showConnectionConfirmation)
335
+ assertTrue(object : RecordingSGC(false) {
336
+ override val showBrightnessConfirmation = false
337
+ }.showConnectionConfirmation)
338
+ } finally { nimo.cleanup() }
339
+ }
340
+
341
+ private fun assertNimoReconnectPreservesReplayedScene(headUp: Boolean) {
342
+ withReadyRecordingDevice { manager, original, executor ->
343
+ DeviceStore.set("glasses", "headUp", headUp)
344
+ manager.displayEvent(scene("main", "main-app"))
345
+ manager.displayEvent(scene("dashboard", "dashboard-app"))
346
+ val selectedView = if (headUp) "dashboard" else "main"
347
+ val selectedApp = "$selectedView-app"
348
+ val selectedScene = "scene:$selectedApp:true"
349
+ assertEquals("scene:$selectedApp:false", original.calls.last())
350
+ original.calls.clear()
351
+
352
+ // Use the public disconnect, not a manually armed boot flag.
353
+ manager.disconnect()
354
+ assertEquals(listOf("clear", "disconnect"), original.calls)
355
+ assertNull(manager.sgc)
356
+ assertEquals(true, DeviceStore.get("bluetooth", "shouldSendBootingMessage"))
357
+ val replacement = NimoRecordingSGC()
358
+ manager.sgc = replacement
359
+ // Disconnect resets headUp; restore the selected orientation
360
+ // before readiness without injecting a new miniapp frame.
361
+ DeviceStore.set("glasses", "headUp", headUp)
362
+ DeviceStore.apply("glasses", "fullyBooted", true)
363
+ // Model LocalDisplayManager's connected-edge replay through
364
+ // its actual native ingress. The miniapp content is unchanged;
365
+ // the host sends a fresh epoch with all elements created.
366
+ manager.displayEvent(scene(selectedView, selectedApp, replay = true, epoch = 2))
367
+
368
+ // The old welcome slept on this real executor. A submitted
369
+ // barrier waits for any such task; paused Main alone cannot.
370
+ executor.submit {}.get(5, TimeUnit.SECONDS)
371
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(4, TimeUnit.SECONDS)
372
+ val readyCalls = replacement.calls.toList()
373
+
374
+ // Positive control: the scene remains retained for later replay.
375
+ replacement.calls.clear()
376
+ manager.sendCurrentState()
377
+ assertEquals(listOf(selectedScene), replacement.calls)
378
+ assertEquals("Reconnect must not erase the host replay: $readyCalls", selectedScene, readyCalls.last())
379
+ }
380
+ }
381
+
382
+ private fun withReadyRecordingDevice(block: (DeviceManager, RecordingSGC, ExecutorService) -> Unit) {
383
+ // Disconnect and readiness also mutate identity/mic state. Restore both
384
+ // categories, including keys that were absent before this test.
385
+ val saved = listOf("bluetooth", "glasses").associateWith { DeviceStore.store.getCategory(it) }
386
+ try {
387
+ withSharedRecordingDevice { manager, original ->
388
+ val executor = DeviceManager::class.java.getDeclaredField("executor").run {
389
+ isAccessible = true
390
+ get(manager) as ExecutorService
391
+ }
392
+ try { block(manager, original, executor) } finally {
393
+ executor.shutdownNow()
394
+ assertTrue(executor.awaitTermination(1, TimeUnit.SECONDS))
395
+ }
396
+ }
397
+ } finally {
398
+ saved.forEach { (category, values) ->
399
+ (DeviceStore.store.getCategory(category).keys - values.keys).forEach {
400
+ DeviceStore.store.remove(category, it)
401
+ }
402
+ values.forEach { (key, value) -> DeviceStore.set(category, key, value) }
403
+ }
404
+ }
405
+ }
406
+
407
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
408
+ fun contextualDashboardToggleReplaysSelectedViewWithoutNewMiniappEvent() {
409
+ withSharedRecordingDevice { manager, device ->
410
+ manager.displayEvent(scene("main", "main-app"))
411
+ manager.displayEvent(scene("dashboard", "dashboard-app"))
412
+ device.calls.clear()
413
+
414
+ // Settings arrive from Expo's worker queue, not necessarily Main.
415
+ val worker = Thread { DeviceStore.apply("bluetooth", "contextual_dashboard", false) }
416
+ worker.start(); worker.join(1000)
417
+ assertFalse(worker.isAlive)
418
+ assertTrue("Dispatch must wait for Main", device.calls.isEmpty())
419
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
420
+ assertEquals(listOf("scene:main-app:true"), device.calls)
421
+
422
+ device.calls.clear()
423
+ DeviceStore.apply("bluetooth", "contextual_dashboard", true)
424
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
425
+ assertEquals(listOf("scene:dashboard-app:true"), device.calls)
426
+ device.calls.clear()
427
+ DeviceStore.apply("bluetooth", "contextual_dashboard", true)
428
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
429
+ assertTrue("An unchanged setting must not redraw", device.calls.isEmpty())
430
+ }
431
+ }
432
+
433
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
434
+ fun contextualDashboardToggleRetainsHeadDownAndDisplaySuppressionGuards() {
435
+ for (gate in listOf("head-down", "screen-disabled", "not-ready", "no-device")) {
436
+ withSharedRecordingDevice { manager, device ->
437
+ manager.displayEvent(scene("main", "main-app"))
438
+ manager.displayEvent(scene("dashboard", "dashboard-app"))
439
+ device.calls.clear()
440
+ when (gate) {
441
+ "head-down" -> DeviceStore.set("glasses", "headUp", false)
442
+ "screen-disabled" -> DeviceStore.set("bluetooth", "screen_disabled", true)
443
+ "not-ready" -> DeviceStore.set("glasses", "fullyBooted", false)
444
+ "no-device" -> manager.sgc = null
445
+ }
446
+ DeviceStore.apply("bluetooth", "contextual_dashboard", false)
447
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
448
+ assertTrue("Unexpected dispatch with $gate", device.calls.isEmpty())
449
+ manager.sgc = device
450
+ }
451
+ }
452
+ }
453
+
454
+ @Test @LooperMode(LooperMode.Mode.PAUSED)
455
+ fun deferredDashboardHandoffRespectsFullFramePolicy() {
456
+ for (requiresClear in listOf(false, true)) {
457
+ withSharedRecordingDevice(RecordingSGC(requiresClear)) { manager, device ->
458
+ manager.displayEvent(scene("dashboard", "dashboard-app"))
459
+ device.calls.clear()
460
+ manager.setDashboardContent("new dashboard")
461
+ assertEquals(requiresClear, device.calls.any { it.startsWith("remove:") })
462
+ assertTrue(device.calls.last().endsWith("new dashboard"))
463
+ }
464
+ }
465
+ }
466
+
467
+ private fun withSharedRecordingDevice(
468
+ device: RecordingSGC = NimoRecordingSGC(),
469
+ block: (DeviceManager, RecordingSGC) -> Unit
470
+ ) {
471
+ val singleton = DeviceManager::class.java.getDeclaredField("_instance").apply { isAccessible = true }
472
+ val previous = singleton.get(null)
473
+ val keys = listOf("glasses" to "fullyBooted", "glasses" to "headUp",
474
+ "bluetooth" to "contextual_dashboard", "bluetooth" to "screen_disabled",
475
+ "bluetooth" to "brightness", "bluetooth" to "auto_brightness")
476
+ val saved = keys.associateWith { DeviceStore.get(it.first, it.second)!! }
477
+ val manager = DeviceManager(initializeHardware = false)
478
+ manager.sgc = device
479
+ singleton.set(null, manager)
480
+ DeviceStore.set("glasses", "fullyBooted", true)
481
+ DeviceStore.set("glasses", "headUp", true)
482
+ DeviceStore.set("bluetooth", "contextual_dashboard", true)
483
+ DeviceStore.set("bluetooth", "screen_disabled", false)
484
+ DeviceStore.set("bluetooth", "brightness", 50)
485
+ DeviceStore.set("bluetooth", "auto_brightness", true)
486
+ try { block(manager, device) } finally {
487
+ DeviceStore.set("glasses", "fullyBooted", false)
488
+ manager.cleanup()
489
+ singleton.set(null, previous)
490
+ saved.forEach { (key, value) -> DeviceStore.set(key.first, key.second, value) }
491
+ }
492
+ }
493
+
494
+ @Test fun actualDisplayEventHandoffsRespectBothViewsAndDevicePolicy() {
495
+ for (clearRequired in listOf(false, true)) for (headUp in listOf(false, true)) {
496
+ for (contextual in listOf(false, true)) for (view in listOf("main", "dashboard")) {
497
+ val manager = DeviceManager(initializeHardware = false)
498
+ val device = RecordingSGC(clearRequired)
499
+ manager.sgc = device
500
+ DeviceStore.set("glasses", "fullyBooted", true)
501
+ DeviceStore.set("glasses", "headUp", headUp)
502
+ DeviceStore.set("bluetooth", "contextual_dashboard", contextual)
503
+ DeviceStore.set("bluetooth", "screen_disabled", false)
504
+ val visible = (view == "dashboard") == (headUp && contextual)
505
+
506
+ manager.displayEvent(legacy(view, "legacy-a"))
507
+ device.calls.clear()
508
+ manager.displayEvent(scene(view, "one"))
509
+ assertEquals(if (!visible) emptyList() else
510
+ (if (clearRequired) listOf("clear") else emptyList()) + "scene:one:false", device.calls)
511
+
512
+ device.calls.clear()
513
+ manager.displayEvent(scene(view, "two"))
514
+ assertEquals(if (!visible) emptyList() else
515
+ (if (clearRequired) listOf("remove:one-label") else emptyList()) + "scene:two:true", device.calls)
516
+
517
+ device.calls.clear()
518
+ manager.displayEvent(legacy(view, "legacy-b"))
519
+ assertEquals(if (!visible) emptyList() else
520
+ (if (clearRequired) listOf("remove:two-label") else emptyList()) + "text:legacy-b", device.calls)
521
+
522
+ // Hidden events update their real replay slot, not the visible display.
523
+ device.calls.clear()
524
+ DeviceStore.set("glasses", "headUp", view == "dashboard")
525
+ DeviceStore.set("bluetooth", "contextual_dashboard", true)
526
+ manager.sendCurrentState()
527
+ assertEquals(listOf("text:legacy-b"), device.calls)
528
+ DeviceStore.set("glasses", "fullyBooted", false)
529
+ manager.cleanup()
530
+ }
531
+ }
532
+ }
533
+
534
+ @Test fun suppressedDisplaysNeitherClearNorDispatchButRetainSceneReplay() {
535
+ for (suppression in listOf("screen", "boot", "simulated")) {
536
+ val manager = DeviceManager(initializeHardware = false)
537
+ val device = RecordingSGC(true)
538
+ manager.sgc = device
539
+ DeviceStore.set("glasses", "fullyBooted", suppression != "boot")
540
+ DeviceStore.set("glasses", "headUp", false)
541
+ DeviceStore.set("bluetooth", "contextual_dashboard", true)
542
+ DeviceStore.set("bluetooth", "screen_disabled", suppression == "screen")
543
+ if (suppression == "simulated") device.type = DeviceTypes.SIMULATED
544
+ manager.displayEvent(legacy("main", "legacy"))
545
+ manager.displayEvent(scene("main", "one"))
546
+ manager.displayEvent(scene("main", "two"))
547
+ assertTrue(device.calls.isEmpty())
548
+ DeviceStore.set("glasses", "fullyBooted", true)
549
+ DeviceStore.set("bluetooth", "screen_disabled", false)
550
+ device.type = "Clear-required glasses"
551
+ manager.sendCurrentState()
552
+ assertEquals(listOf("scene:two:true"), device.calls)
553
+ DeviceStore.set("glasses", "fullyBooted", false)
554
+ manager.cleanup()
555
+ }
556
+ }
557
+
558
+ @Test fun clearViewDoesNotSweepSceneTwiceAndOtherDeviceDisconnectStaysUnchanged() {
559
+ val manager = DeviceManager(initializeHardware = false)
560
+ val device = RecordingSGC(true)
561
+ manager.sgc = device
562
+ DeviceStore.set("glasses", "fullyBooted", true)
563
+ DeviceStore.set("glasses", "headUp", false)
564
+ DeviceStore.set("bluetooth", "screen_disabled", false)
565
+ manager.displayEvent(scene("main", "one"))
566
+ device.calls.clear()
567
+ manager.displayEvent(mapOf("view" to "main", "layout" to mapOf("layoutType" to "clear_view")))
568
+ assertEquals(listOf("clear"), device.calls)
569
+ DeviceStore.set("glasses", "fullyBooted", false)
570
+ device.calls.clear()
571
+ manager.disconnect()
572
+ assertEquals(listOf("clear", "disconnect"), device.calls)
573
+ assertNull(manager.sgc)
574
+ manager.cleanup()
575
+ }
576
+
577
+ @Test fun ordinaryNimoDisconnectStopsItsEncoderThread() {
578
+ val manager = DeviceManager(initializeHardware = false)
579
+ val before = Thread.getAllStackTraces().keys
580
+ val nimo = Nimo()
581
+ manager.sgc = nimo
582
+ val encoder = Thread.getAllStackTraces().keys.single { it.name == "NimoCanvasEncoder" && it !in before }
583
+ try {
584
+ DeviceStore.set("glasses", "fullyBooted", false)
585
+ manager.disconnect()
586
+ encoder.join(1000)
587
+ assertFalse("Discarded communicator left an encoder thread alive", encoder.isAlive)
588
+ assertNull(manager.sgc)
589
+ } finally { nimo.cleanup(); manager.cleanup() }
590
+ }
591
+
592
+ private fun legacy(view: String, text: String): Map<String, Any> =
593
+ mapOf("view" to view, "layout" to mapOf("layoutType" to "text_wall", "text" to text))
594
+
595
+ private fun scene(view: String, app: String, replay: Boolean = false, epoch: Int = 1): Map<String, Any> = mapOf("view" to view, "scene" to mapOf(
596
+ "appId" to app, "sceneEpoch" to epoch, "replay" to replay, "elements" to listOf(mapOf(
597
+ "id" to "$app-label", "type" to "text", "text" to app, "change" to "created",
598
+ "box" to mapOf("x" to 0, "y" to 0, "w" to 100, "h" to 20)))))
599
+
600
+ private class NimoRecordingSGC : RecordingSGC(false) {
601
+ override val showBrightnessConfirmation = false
602
+ override val showConnectionConfirmation = false
603
+ }
604
+
605
+ private open class RecordingSGC(override val sceneHandoffRequiresClear: Boolean) : SGCManager() {
606
+ val micChanges = mutableListOf<Boolean>()
607
+ val calls = CopyOnWriteArrayList<String>()
608
+ val brightnessCalls = mutableListOf<String>()
609
+ val positions = mutableListOf<Pair<Int, Int>>()
610
+ val textSent = CountDownLatch(1)
611
+ var lastTextNanos = 0L
612
+ var lastClearNanos = 0L
613
+ init { type = if (sceneHandoffRequiresClear) "Clear-required glasses" else DeviceTypes.NIMO }
614
+ override fun clearDisplay() { lastClearNanos = System.nanoTime(); calls += "clear" }
615
+ override fun clearSceneElements(elementIds: List<String>) { calls += "remove:${elementIds.joinToString()}" }
616
+ override fun applySceneFrame(frame: SceneFrame) { calls += "scene:${frame.appId}:${frame.replay}" }
617
+ override fun sendTextWall(text: String) { lastTextNanos = System.nanoTime(); calls += "text:$text"; textSent.countDown() }
618
+ override fun disconnect() { calls += "disconnect" }
619
+ override fun cleanup() { calls += "cleanup" }
620
+ override fun setMicEnabled(enabled: Boolean) {
621
+ micChanges += enabled
622
+ DeviceStore.set("glasses", "micEnabled", enabled)
623
+ }
624
+ override fun sortMicRanking(list: MutableList<String>) = list
625
+ override fun requestPhoto(request: PhotoRequest) {}
626
+ override fun startStream(message: MutableMap<String, Any>) {}
627
+ override fun stopStream() {}
628
+ override fun sendStreamKeepAlive(message: MutableMap<String, Any>) {}
629
+ override fun startVideoRecording(requestId: String, save: Boolean, sound: Boolean) {}
630
+ override fun stopVideoRecording(requestId: String) {}
631
+ override fun sendButtonPhotoSettings() {}
632
+ override fun sendButtonVideoRecordingSettings() {}
633
+ override fun sendButtonMaxRecordingTime() {}
634
+ override fun sendCameraFovSetting() {}
635
+ override fun setBrightness(level: Int, autoMode: Boolean) { brightnessCalls += "$level:$autoMode" }
636
+ override fun sendText(text: String) {}
637
+ override fun sendDoubleTextWall(top: String, bottom: String) {}
638
+ override fun displayBitmap(base64ImageData: String, x: Int?, y: Int?, width: Int?, height: Int?) = true
639
+ override fun showDashboard() {}
640
+ override fun setDashboardPosition(height: Int, depth: Int) { positions += height to depth }
641
+ override fun setHeadUpAngle(angle: Int) {}
642
+ override fun getBatteryStatus() {}
643
+ override fun setSilentMode(enabled: Boolean) {}
644
+ override fun exit() {}
645
+ override fun sendShutdown() {}
646
+ override fun sendReboot() {}
647
+ override fun sendRgbLedControl(requestId: String, packageName: String?, action: String, color: String?, onDurationMs: Int, offDurationMs: Int, count: Int) {}
648
+ override fun forget() {}
649
+ override fun findCompatibleDevices() {}
650
+ override fun stopScan() {}
651
+ override fun connectById(id: String) {}
652
+ override fun getConnectedBluetoothName() = ""
653
+ override fun ping() {}
654
+ override fun dbg1() {}
655
+ override fun dbg2() {}
656
+ override fun requestWifiScan(scanId: String?) {}
657
+ override fun sendWifiCredentials(ssid: String, password: String) {}
658
+ override fun forgetWifiNetwork(ssid: String) {}
659
+ override fun sendHotspotState(enabled: Boolean) {}
660
+ override fun sendUserEmailToGlasses(email: String) {}
661
+ override fun sendIncidentId(incidentId: String, apiBaseUrl: String?) {}
662
+ override fun queryGalleryStatus() {}
663
+ override fun sendGalleryMode() {}
664
+ override fun requestVersionInfo() {}
665
+ }
666
+ }