@mentra/bluetooth-sdk 3.2.1-dev.314 → 3.2.1

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 (133) hide show
  1. package/README.md +4 -4
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  3. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +129 -25
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -2
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  13. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +101 -22
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  29. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  31. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  39. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  48. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  50. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  51. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  52. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  53. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  54. package/build/BluetoothSdk.types.d.ts +13 -0
  55. package/build/BluetoothSdk.types.d.ts.map +1 -1
  56. package/build/BluetoothSdk.types.js +1 -0
  57. package/build/BluetoothSdk.types.js.map +1 -1
  58. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  59. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  60. package/build/_private/BluetoothSdkModule.js +3 -0
  61. package/build/_private/BluetoothSdkModule.js.map +1 -1
  62. package/build/generated/changelogCatalog.d.ts +2 -2
  63. package/build/generated/changelogCatalog.js +2 -2
  64. package/build/generated/changelogCatalog.js.map +1 -1
  65. package/build/generated/releaseMetadata.js +5 -5
  66. package/build/generated/releaseMetadata.js.map +1 -1
  67. package/build/index.d.ts.map +1 -1
  68. package/build/index.js +1 -0
  69. package/build/index.js.map +1 -1
  70. package/ios/BluetoothSdkModule.swift +9 -0
  71. package/ios/MentraBluetoothSDK.podspec +3 -2
  72. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  73. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  74. package/ios/Source/Bridge.swift +15 -2
  75. package/ios/Source/DeviceManager.swift +96 -63
  76. package/ios/Source/DeviceStore.swift +26 -7
  77. package/ios/Source/GeneratedChangelogCatalog.swift +2 -2
  78. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  79. package/ios/Source/MentraBluetoothSDK.swift +18 -0
  80. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  81. package/ios/Source/requests/DisplayRequests.swift +1 -1
  82. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  83. package/ios/Source/sgcs/Ar99.swift +1 -2
  84. package/ios/Source/sgcs/G1.swift +68 -38
  85. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  86. package/ios/Source/sgcs/G2.swift +261 -247
  87. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  88. package/ios/Source/sgcs/Mach1.swift +19 -7
  89. package/ios/Source/sgcs/MentraLive.swift +201 -156
  90. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  91. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  92. package/ios/Source/sgcs/MentraNex.swift +8 -31
  93. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  94. package/ios/Source/sgcs/Nimo.swift +280 -408
  95. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  96. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  97. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  98. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  99. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  100. package/ios/Source/sgcs/SGCManager.swift +24 -0
  101. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  102. package/ios/Source/status/DeviceStatus.swift +1 -0
  103. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  104. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  105. package/ios/Source/utils/G2Text.swift +11 -0
  106. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  107. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  108. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  109. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  110. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  111. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  112. package/ios/Tests/G1PairingTests.swift +162 -0
  113. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  114. package/ios/Tests/G2TextTests.swift +10 -0
  115. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  116. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  117. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  118. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  119. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  120. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  121. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  122. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  123. package/ios/Tests/VersionInfoCommandTests.swift +53 -0
  124. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  125. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  126. package/package.json +2 -2
  127. package/plugin/build/withIos.d.ts +2 -0
  128. package/plugin/build/withIos.js +12 -1
  129. package/src/BluetoothSdk.types.ts +14 -0
  130. package/src/_private/BluetoothSdkModule.ts +8 -0
  131. package/src/generated/changelogCatalog.ts +2 -2
  132. package/src/generated/releaseMetadata.ts +5 -5
  133. package/src/index.ts +1 -0
@@ -0,0 +1,348 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.*
4
+ import org.junit.Test
5
+
6
+ class NimoCanvasCoordinatorTest {
7
+ private class Clock : NimoScheduler {
8
+ private data class Job(val time: Long, val task: () -> Unit, var canceled: Boolean = false)
9
+ private var now = 0L
10
+ private val jobs = mutableListOf<Job>()
11
+ override fun post(delayMs: Long, task: () -> Unit): () -> Unit {
12
+ val job = Job(now + delayMs, task)
13
+ jobs += job
14
+ return { job.canceled = true }
15
+ }
16
+ fun advance(ms: Long) {
17
+ val end = now + ms
18
+ while (true) {
19
+ val job = jobs.filter { it.time <= end }.minByOrNull { it.time } ?: break
20
+ jobs.remove(job); now = job.time
21
+ if (!job.canceled) job.task()
22
+ }
23
+ now = end
24
+ }
25
+ }
26
+
27
+ private class Fixture {
28
+ val clock = Clock()
29
+ var connection: Any = Any()
30
+ val characteristic = Any()
31
+ val writes = mutableListOf<ByteArray>()
32
+ val failures = mutableListOf<String>()
33
+ val rejections = mutableListOf<Int>()
34
+ var holdPreemptions = 0
35
+ var rejectWrites = false
36
+ val queue = NimoGattQueue<Any>(clock, { _, bytes -> writes += bytes; !rejectWrites }, { failures += it })
37
+ val canvas = NimoCanvasCoordinator(clock, { 20 },
38
+ { frames, started, completed -> queue.enqueue(connection, characteristic, frames, started, completed) },
39
+ { queue.reset(); failures += it }, { rejections += it }, { holdPreemptions++ })
40
+ init { queue.connected(connection) }
41
+ fun written(success: Boolean = true) {
42
+ queue.written(connection, characteristic, success)
43
+ clock.advance(5)
44
+ }
45
+ fun ack(key: Int, status: Int = 0) = canvas.response(key, if (status != 0) byteArrayOf(status.toByte())
46
+ else if (key == 4) byteArrayOf(0, 0xFD.toByte(), 0, 0, 0) else byteArrayOf(0, 0xFD.toByte()))
47
+ fun launch(frame: ByteArray = byteArrayOf(0, 0, 1)) {
48
+ canvas.offer(frame, "one:1"); canvas.readiness(true)
49
+ written(); ack(1)
50
+ }
51
+ fun finishUpdate(frame: ByteArray) { repeat(NimoCanvasCodec.frames(4, frame, 20).size) { written() }; ack(4) }
52
+ }
53
+
54
+ @Test fun notReadyRetriesLaunchPromptlyButStopsAfterThreeProbes() {
55
+ val f = Fixture(); f.canvas.activate(); f.canvas.readiness(true)
56
+ repeat(4) { attempt ->
57
+ f.written(); f.ack(1, 7)
58
+ val before = f.writes.size
59
+ f.clock.advance(999)
60
+ assertEquals(before, f.writes.size)
61
+ f.clock.advance(1)
62
+ assertEquals(before + if (attempt < 3) 1 else 0, f.writes.size)
63
+ }
64
+ f.clock.advance(60_000)
65
+ assertEquals(4, f.writes.size)
66
+ assertTrue(f.failures.isEmpty())
67
+ }
68
+
69
+ @Test fun pendingReadinessRetryIsCancelledOnDisconnectExitAndKnownReadinessLoss() {
70
+ for (stop in listOf("disconnect", "exit", "notReady")) {
71
+ val f = Fixture(); f.canvas.activate(); f.canvas.readiness(true)
72
+ f.written(); f.ack(1, 7)
73
+ when (stop) {
74
+ "disconnect" -> f.canvas.disconnected()
75
+ "exit" -> f.canvas.exit()
76
+ else -> f.canvas.readiness(false)
77
+ }
78
+ f.clock.advance(2_000)
79
+ assertEquals(1, f.writes.size)
80
+ assertTrue(f.failures.isEmpty())
81
+ }
82
+ }
83
+
84
+ @Test fun readinessHeartbeatCancelsPendingProbeAndDoesNotDuplicateLaunch() {
85
+ val f = Fixture(); f.canvas.activate(); f.canvas.readiness(true)
86
+ f.written(); f.ack(1, 7)
87
+ f.canvas.confirmedReadiness(true)
88
+ f.clock.advance(1_000)
89
+ assertEquals(2, f.writes.size)
90
+ f.written(); f.ack(1); f.finishUpdate(byteArrayOf(0, 0, 1))
91
+ assertTrue(f.failures.isEmpty())
92
+ }
93
+
94
+ @Test fun bothNotificationDescriptorsGateCharacteristicWrites() {
95
+ val f = Fixture()
96
+ val rx = Any(); val mic = Any()
97
+ val subscribed = mutableListOf<String>()
98
+ f.queue.subscribe(f.connection, rx, { subscribed += "rx"; true }) {
99
+ f.queue.subscribe(f.connection, mic, { subscribed += "mic"; true }) {}
100
+ }
101
+ f.canvas.offer(byteArrayOf(0, 0, 1), "one"); f.canvas.readiness(true)
102
+ assertTrue(f.writes.isEmpty())
103
+ f.queue.descriptorWritten(f.connection, Any(), true)
104
+ assertTrue(f.writes.isEmpty())
105
+ f.queue.descriptorWritten(f.connection, rx, true)
106
+ assertEquals(listOf("rx", "mic"), subscribed)
107
+ assertTrue(f.writes.isEmpty())
108
+ f.queue.descriptorWritten(f.connection, mic, true)
109
+ assertEquals(1, f.writes.size)
110
+ }
111
+
112
+ @Test fun entireFragmentChainRemainsContiguousAmongOtherCommands() {
113
+ val f = Fixture()
114
+ val frame = ByteArray(85) { it.toByte() }
115
+ f.launch(frame)
116
+ val update = NimoCanvasCodec.frames(4, frame, 20)
117
+ val other = byteArrayOf(42)
118
+ f.queue.enqueue(f.connection, f.characteristic, listOf(other))
119
+ repeat(update.size) { f.written() }
120
+ assertEquals(update.size + 2, f.writes.size)
121
+ update.forEachIndexed { i, bytes -> assertArrayEquals(bytes, f.writes[i + 1]) }
122
+ assertArrayEquals(other, f.writes.last())
123
+ }
124
+
125
+ @Test fun earlyAndDuplicateAcksCannotReleaseAnUnfinishedChain() {
126
+ val f = Fixture()
127
+ val a = ByteArray(60) { it.toByte() }; val b = byteArrayOf(0, 0, 1)
128
+ f.launch(a)
129
+ f.canvas.offer(b, "one:1")
130
+ f.ack(4) // Before the final fragment has started: stale/uncorrelated.
131
+ val fragments = NimoCanvasCodec.frames(4, a, 20).size
132
+ repeat(fragments - 1) { f.written() }
133
+ val before = f.writes.size
134
+ f.ack(4) // Final fragment started, but its callback has not completed.
135
+ f.ack(4, 6) // Duplicate must not replace the first valid early ACK.
136
+ assertEquals(before, f.writes.size)
137
+ f.queue.written(f.connection, f.characteristic, true)
138
+ f.queue.written(f.connection, f.characteristic, true) // Duplicate callback within pacing window.
139
+ assertEquals(before, f.writes.size)
140
+ f.clock.advance(5)
141
+ assertEquals(before + 1, f.writes.size)
142
+ assertTrue(f.rejections.isEmpty())
143
+ f.finishUpdate(b)
144
+ f.ack(4) // Late duplicate with no business command in flight.
145
+ assertTrue(f.failures.isEmpty())
146
+ }
147
+
148
+ @Test fun finalCallbackFailureAndMissingCallbackNeverReleaseNextFragment() {
149
+ for (missing in listOf(false, true)) {
150
+ val f = Fixture(); f.launch(ByteArray(60))
151
+ val before = f.writes.size
152
+ if (missing) f.clock.advance(3_000) else f.written(false)
153
+ assertEquals(before, f.writes.size)
154
+ assertEquals(1, f.failures.size)
155
+ f.queue.written(f.connection, f.characteristic, true)
156
+ f.clock.advance(5)
157
+ assertEquals(before, f.writes.size)
158
+ }
159
+ }
160
+
161
+ @Test fun writeStartRetriesAreBoundedAndDescriptorFailuresReset() {
162
+ val f = Fixture(); f.rejectWrites = true
163
+ f.canvas.offer(byteArrayOf(0, 0, 1), "one"); f.canvas.readiness(true)
164
+ f.clock.advance(200)
165
+ assertEquals(3, f.writes.size)
166
+ assertEquals(1, f.failures.size)
167
+ for (failure in listOf("start", "callback", "missing")) {
168
+ val link = Fixture(); val descriptor = Any()
169
+ link.queue.subscribe(link.connection, descriptor, { failure != "start" }) { fail("Must not become ready") }
170
+ if (failure == "callback") link.queue.descriptorWritten(link.connection, descriptor, false)
171
+ if (failure == "missing") link.clock.advance(5_000)
172
+ assertEquals(1, link.failures.size)
173
+ }
174
+ }
175
+
176
+ @Test fun ackDeadlineResetsAndReplacedConnectionCallbacksAreIgnored() {
177
+ val f = Fixture(); f.launch()
178
+ f.written() // Update is transported, but no business ACK.
179
+ f.clock.advance(45_000)
180
+ assertEquals(1, f.failures.size)
181
+ f.ack(4)
182
+ val old = f.connection
183
+ f.connection = Any(); f.queue.connected(f.connection)
184
+ f.canvas.readiness(true)
185
+ val before = f.writes.size
186
+ f.queue.written(old, f.characteristic, true)
187
+ f.clock.advance(5)
188
+ assertEquals(before, f.writes.size)
189
+ f.written(); f.ack(1); f.finishUpdate(byteArrayOf(0, 0, 1))
190
+ assertEquals(1, f.failures.size)
191
+ }
192
+
193
+ @Test fun rejectedUpdateImmediatelyDrainsDifferentQueuedScene() {
194
+ val f = Fixture(); f.launch()
195
+ f.written()
196
+ val b = byteArrayOf(1, 0, 1)
197
+ f.canvas.offer(b, "one:1")
198
+ val before = f.writes.size
199
+ f.ack(4, 6)
200
+ assertEquals(listOf(6), f.rejections)
201
+ assertEquals(before + 1, f.writes.size)
202
+ assertArrayEquals(NimoCanvasCodec.frames(4, b, 20).first(), f.writes.last())
203
+ }
204
+
205
+ @Test fun confirmedReadinessRelaunchesAfterNotReadyAndPreservesLatestScene() {
206
+ val f = Fixture(); f.launch(); f.written()
207
+ f.ack(4, 7)
208
+ val b = byteArrayOf(1, 0, 1)
209
+ f.canvas.offer(b, "one:1")
210
+ val before = f.writes.size
211
+ f.canvas.readiness(true)
212
+ assertEquals(before, f.writes.size)
213
+ f.canvas.confirmedReadiness(true)
214
+ assertEquals(before + 1, f.writes.size)
215
+ assertArrayEquals(NimoCanvasCodec.frames(1, writeCapacity = 20).single(), f.writes.last())
216
+ f.written(); f.ack(1)
217
+ assertArrayEquals(NimoCanvasCodec.frames(4, b, 20).single(), f.writes.last())
218
+ f.finishUpdate(b)
219
+ assertEquals(listOf(7), f.rejections)
220
+ assertTrue(f.failures.isEmpty())
221
+ }
222
+
223
+ @Test fun newerSceneSurvivesExitReportAndWaitsForExitAck() {
224
+ val f = Fixture(); f.launch(); f.finishUpdate(byteArrayOf(0, 0, 1))
225
+ f.canvas.exit()
226
+ f.canvas.offer(byteArrayOf(1, 0, 1), "two:1")
227
+ f.canvas.nativeApp(0xFD, false)
228
+ f.written()
229
+ val before = f.writes.size
230
+ f.ack(3)
231
+ assertEquals(before + 1, f.writes.size)
232
+ assertArrayEquals(NimoCanvasCodec.frames(1, writeCapacity = 20).single(), f.writes.last())
233
+ }
234
+
235
+ @Test fun nativeTakeoverDuringExitRetainsNewerSceneUntilExitAck() {
236
+ val f = Fixture(); f.launch(); f.finishUpdate(byteArrayOf(0, 0, 1))
237
+ f.canvas.exit()
238
+ f.canvas.offer(byteArrayOf(1, 0, 1), "two:1")
239
+ f.canvas.nativeApp(0, true)
240
+ f.written()
241
+ val before = f.writes.size
242
+ f.ack(3)
243
+ assertEquals(before + 1, f.writes.size)
244
+ assertTrue(f.failures.isEmpty())
245
+ }
246
+
247
+ @Test fun diagnosticHoldWaitsForCurrentBusinessAckAndThenResumesLatestScene() {
248
+ val f = Fixture()
249
+ val initial = byteArrayOf(0, 0, 1)
250
+ val latest = byteArrayOf(2, 0, 1)
251
+ f.launch(initial)
252
+ var ready = 0
253
+ f.canvas.hold { ready++ }
254
+ f.canvas.offer(byteArrayOf(1, 0, 1), "one:1")
255
+ f.canvas.offer(latest, "one:1")
256
+
257
+ assertEquals(0, ready)
258
+ f.finishUpdate(initial)
259
+ assertEquals(1, ready)
260
+ val beforeRelease = f.writes.size
261
+
262
+ f.canvas.releaseHold()
263
+
264
+ assertEquals(beforeRelease + 1, f.writes.size)
265
+ assertArrayEquals(NimoCanvasCodec.frames(4, latest, 20).first(), f.writes.last())
266
+ }
267
+
268
+ @Test fun scopeTakeoverPreemptsHoldWithoutFlushingHeldScene() {
269
+ val f = Fixture()
270
+ val initial = byteArrayOf(0, 0, 1)
271
+ val held = byteArrayOf(1, 0, 1)
272
+ val takeover = byteArrayOf(2, 0, 1)
273
+ f.launch(initial); f.finishUpdate(initial)
274
+ var ready = 0
275
+ f.canvas.hold { ready++ }
276
+ f.canvas.offer(held, "one:1")
277
+ val beforeTakeover = f.writes.size
278
+
279
+ f.canvas.offer(takeover, "two:1")
280
+
281
+ assertEquals(1, ready)
282
+ assertEquals(1, f.holdPreemptions)
283
+ assertEquals(beforeTakeover + 1, f.writes.size)
284
+ assertArrayEquals(NimoCanvasCodec.frames(4, takeover, 20).first(), f.writes.last())
285
+ }
286
+
287
+ @Test fun firstScopePreemptsHoldAcquiredBeforeAnyCanvasScene() {
288
+ val f = Fixture()
289
+ var ready = 0
290
+ f.canvas.readiness(true)
291
+ f.canvas.hold { ready++ }
292
+ assertEquals(1, ready)
293
+
294
+ f.canvas.offer(byteArrayOf(0, 0, 1), "first:1")
295
+
296
+ assertEquals(1, f.holdPreemptions)
297
+ assertEquals(1, f.writes.size)
298
+ assertArrayEquals(NimoCanvasCodec.frames(1, writeCapacity = 20).single(), f.writes.single())
299
+ }
300
+
301
+ @Test fun reentrantCompletionCannotReleaseNewConnectionWrite() {
302
+ val f = Fixture(); val old = f.connection
303
+ f.queue.enqueue(old, f.characteristic, listOf(byteArrayOf(1)), completed = {
304
+ f.queue.reset()
305
+ f.connection = Any(); f.queue.connected(f.connection)
306
+ f.queue.enqueue(f.connection, f.characteristic, listOf(byteArrayOf(2), byteArrayOf(3)))
307
+ })
308
+ f.written()
309
+ assertEquals(2, f.writes.size)
310
+ f.queue.written(old, f.characteristic, true)
311
+ f.clock.advance(5)
312
+ assertEquals(2, f.writes.size)
313
+ f.written()
314
+ assertEquals(3, f.writes.size)
315
+ assertArrayEquals(byteArrayOf(3), f.writes.last())
316
+ }
317
+
318
+ @Test fun staleEncodeAfterNewSceneExitDisconnectOrDisposalCannotDeliver() {
319
+ for (invalidate in listOf("new", "exit", "disconnect", "dispose")) {
320
+ val main = Clock(); val worker = Clock()
321
+ val delivered = mutableListOf<Int>(); var releases = 0; var stopped = 0
322
+ val encoder = NimoCanvasEncoder(main, worker, { stopped++ }, { throw it })
323
+ encoder.submit({ byteArrayOf(1) }, { releases++ }) { delivered += it[0].toInt() }
324
+ worker.advance(0) // Completed encoding is now pending on Main.
325
+ when (invalidate) {
326
+ "new" -> encoder.submit({ byteArrayOf(2) }, { releases++ }) { delivered += it[0].toInt() }
327
+ "dispose" -> encoder.close()
328
+ else -> encoder.invalidate()
329
+ }
330
+ worker.advance(0); main.advance(0)
331
+ assertEquals(if (invalidate == "new") listOf(2) else emptyList(), delivered)
332
+ encoder.close(); encoder.close()
333
+ assertEquals(1, stopped)
334
+ assertEquals(if (invalidate == "new") 2 else 1, releases)
335
+ }
336
+ }
337
+
338
+ @Test fun canceledAndDisposedBitmapSourcesAreReleasedExactlyOnce() {
339
+ val main = Clock(); val worker = Clock(); var encoded = 0; var released = 0
340
+ val encoder = NimoCanvasEncoder(main, worker, {}, { throw it })
341
+ encoder.submit({ encoded++; byteArrayOf(1) }, { released++ }) { fail("Canceled work delivered") }
342
+ encoder.invalidate(); worker.advance(0); main.advance(0)
343
+ assertEquals(0, encoded); assertEquals(1, released)
344
+ encoder.close()
345
+ assertFalse(encoder.submit({ encoded++; byteArrayOf(2) }, { released++ }) {})
346
+ assertEquals(0, encoded); assertEquals(2, released)
347
+ }
348
+ }
@@ -0,0 +1,339 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import org.junit.Assert.*
4
+ import org.junit.Test
5
+
6
+ class NimoCanvasSessionTest {
7
+ private val frameA = byteArrayOf(0, 0, 1)
8
+ private val frameB = byteArrayOf(1, 0, 1, 1)
9
+ private val frameC = byteArrayOf(2, 0, 1, 2)
10
+ private fun send(actions: List<NimoCanvasSession.Action>): NimoCanvasSession.Action.Send =
11
+ actions.single() as NimoCanvasSession.Action.Send
12
+ private fun success(session: NimoCanvasSession, key: Int) = session.response(key,
13
+ if (key == 4) byteArrayOf(0, 0xFD.toByte(), 0, 0, 0) else byteArrayOf(0, 0xFD.toByte()))
14
+ private fun launched(session: NimoCanvasSession): NimoCanvasSession.Action.Send {
15
+ assertTrue(session.offer(frameA, "one:1").isEmpty())
16
+ assertEquals(1, send(session.readiness(true)).key)
17
+ return send(success(session, 1))
18
+ }
19
+
20
+ @Test fun launchAckPrecedesUpdateAndLatestOfferWins() {
21
+ val session = NimoCanvasSession()
22
+ session.readiness(true)
23
+ assertEquals(1, send(session.offer(frameA, "one:1")).key)
24
+ assertTrue(session.offer(frameB, "one:1").isEmpty())
25
+ assertTrue(session.offer(frameC, "one:1").isEmpty())
26
+ val update = send(success(session, 1))
27
+ assertEquals(4, update.key)
28
+ assertArrayEquals(frameC, update.frame)
29
+ }
30
+
31
+ @Test fun inflightUpdatesCoalesceAndDedupUsesActualBytes() {
32
+ val session = NimoCanvasSession()
33
+ launched(session)
34
+ session.offer(frameB, "one:1")
35
+ session.offer(frameC, "one:1")
36
+ assertArrayEquals(frameC, send(success(session, 4)).frame)
37
+ assertTrue(success(session, 4).isEmpty())
38
+ assertTrue(session.offer(frameC.copyOf(), "one:1").isEmpty())
39
+ assertArrayEquals(frameB, send(session.offer(frameB, "one:1")).frame)
40
+ }
41
+
42
+ @Test fun dedupDoesNotOverrideReversionWhileUpdateIsInFlight() {
43
+ val session = NimoCanvasSession()
44
+ launched(session); success(session, 4)
45
+ session.offer(frameB, "one:1")
46
+ session.offer(frameA, "one:1")
47
+ assertArrayEquals(frameA, send(success(session, 4)).frame)
48
+ }
49
+
50
+ @Test fun replayEpochAndReconnectForceAcceptedFrameAgain() {
51
+ val session = NimoCanvasSession()
52
+ launched(session); success(session, 4)
53
+ assertEquals(4, send(session.offer(frameA, "one:1", force = true)).key)
54
+ success(session, 4)
55
+ assertEquals(4, send(session.offer(frameA, "one:2")).key)
56
+ success(session, 4)
57
+ session.disconnected()
58
+ assertEquals(1, send(session.readiness(true)).key)
59
+ assertEquals(4, send(success(session, 1)).key)
60
+ }
61
+
62
+ @Test fun malformedSuccessAndWrongCommandCannotAcknowledgeLaunch() {
63
+ val session = NimoCanvasSession()
64
+ session.offer(frameA, "one:1"); val launch = send(session.readiness(true))
65
+ assertTrue(session.response(1, byteArrayOf(0)).isEmpty())
66
+ assertTrue(session.response(1, byteArrayOf(0, 0x04)).isEmpty())
67
+ assertTrue(session.response(4, byteArrayOf(0, 0xFD.toByte(), 0, 0, 0)).isEmpty())
68
+ assertTrue(session.timeout(launch.ticket).single() is NimoCanvasSession.Action.Reconnect)
69
+ }
70
+
71
+ @Test fun timeoutRequiresFreshTransportBeforeLateAckCanDoAnything() {
72
+ val session = NimoCanvasSession()
73
+ val update = launched(session)
74
+ assertTrue(session.timeout(update.ticket).single() is NimoCanvasSession.Action.Reconnect)
75
+ assertTrue(success(session, 4).isEmpty())
76
+ assertTrue(session.offer(frameB, "one:1").isEmpty())
77
+ session.disconnected()
78
+ assertEquals(1, send(session.readiness(true)).key)
79
+ assertTrue(session.timeout(update.ticket).isEmpty())
80
+ assertArrayEquals(frameB, send(success(session, 1)).frame)
81
+ }
82
+
83
+ @Test fun unsupportedAndInvalidDoNotRetryUnchangedFrame() {
84
+ for (status in listOf(1, 5, 6)) {
85
+ val session = NimoCanvasSession()
86
+ launched(session)
87
+ assertTrue(session.response(4, byteArrayOf(status.toByte())).single() is NimoCanvasSession.Action.Rejected)
88
+ assertTrue(session.offer(frameA, "one:1").isEmpty())
89
+ assertTrue(session.offer(frameA, "one:2", force = true).isEmpty())
90
+ assertArrayEquals(frameB, send(session.offer(frameB, "one:1")).frame)
91
+ }
92
+ }
93
+
94
+ @Test fun failedLaunchDoesNotRetryIdenticalLaunchForChangingCaptions() {
95
+ for (status in listOf(1, 5, 6)) {
96
+ val session = NimoCanvasSession()
97
+ session.offer(frameA, "one:1"); session.readiness(true)
98
+ assertTrue(session.response(1, byteArrayOf(status.toByte())).single() is NimoCanvasSession.Action.Rejected)
99
+ assertTrue(session.offer(frameB, "one:1").isEmpty())
100
+ assertTrue(session.offer(frameC, "one:2", true).isEmpty())
101
+ session.disconnected()
102
+ assertEquals(1, send(session.readiness(true)).key)
103
+ }
104
+ }
105
+
106
+ @Test fun readinessErrorWaitsForExplicitReadySignal() {
107
+ val session = NimoCanvasSession()
108
+ launched(session)
109
+ assertTrue(session.response(4, byteArrayOf(7)).single() is NimoCanvasSession.Action.Rejected)
110
+ assertTrue(session.offer(frameB, "one:1").isEmpty())
111
+ assertTrue(session.readiness(true).isEmpty())
112
+ assertTrue(session.readiness(false).isEmpty())
113
+ assertEquals(1, send(session.readiness(true)).key)
114
+ assertArrayEquals(frameB, send(success(session, 1)).frame)
115
+ }
116
+
117
+ @Test fun freshConfirmedReadinessReleasesNotReadyWithoutFalseEdge() {
118
+ for (key in listOf(1, 4)) {
119
+ val session = NimoCanvasSession()
120
+ session.offer(frameA, "one:1"); session.readiness(true)
121
+ if (key == 4) success(session, 1)
122
+ assertTrue(session.response(key, byteArrayOf(7)).single() is NimoCanvasSession.Action.Rejected)
123
+ assertTrue(session.offer(frameB, "one:1").isEmpty())
124
+ assertTrue("cached true must not release status 7", session.readiness(true).isEmpty())
125
+ assertEquals(1, send(session.confirmedReadiness(true)).key)
126
+ assertArrayEquals(frameB, send(success(session, 1)).frame)
127
+ }
128
+ }
129
+
130
+ @Test fun confirmedNotReadyStaysBlockedUntilConfirmedReady() {
131
+ val session = NimoCanvasSession()
132
+ launched(session)
133
+ session.response(4, byteArrayOf(7))
134
+ assertTrue(session.confirmedReadiness(false).isEmpty())
135
+ assertTrue(session.offer(frameB, "one:1").isEmpty())
136
+ assertEquals(1, send(session.confirmedReadiness(true)).key)
137
+ assertArrayEquals(frameB, send(success(session, 1)).frame)
138
+ }
139
+
140
+ @Test fun repeatedReadyButRejectedLaunchHasBoundedRecovery() {
141
+ val session = NimoCanvasSession()
142
+ session.offer(frameA, "one:1"); session.readiness(true)
143
+ repeat(3) {
144
+ session.response(1, byteArrayOf(7))
145
+ assertEquals(1, send(session.confirmedReadiness(true)).key)
146
+ assertTrue("duplicate heartbeat cannot overlap Launch", session.confirmedReadiness(true).isEmpty())
147
+ }
148
+ session.response(1, byteArrayOf(7))
149
+ repeat(5) { assertTrue("ready-only recovery must stop after three retries", session.confirmedReadiness(true).isEmpty()) }
150
+ session.offer(frameB, "one:2", true)
151
+ assertTrue(session.confirmedReadiness(true).isEmpty())
152
+ session.readiness(false)
153
+ assertEquals(1, send(session.confirmedReadiness(true)).key)
154
+ assertArrayEquals(frameB, send(success(session, 1)).frame)
155
+ success(session, 4)
156
+ session.offer(frameC, "one:2")
157
+ session.response(4, byteArrayOf(7))
158
+ assertEquals(1, send(session.confirmedReadiness(true)).key)
159
+ }
160
+
161
+ @Test fun confirmedReadyDoesNotUnlockRejectedLaunchOrDeviceLock() {
162
+ for (status in listOf(1, 5, 6, 8)) {
163
+ val session = NimoCanvasSession()
164
+ session.offer(frameA, "one:1"); session.readiness(true)
165
+ session.response(1, byteArrayOf(status.toByte()))
166
+ assertTrue(session.offer(frameB, "one:2", true).isEmpty())
167
+ assertTrue(session.confirmedReadiness(false).isEmpty())
168
+ assertTrue(session.confirmedReadiness(true).isEmpty())
169
+ }
170
+ }
171
+
172
+ @Test fun deviceLockedStopsAllCanvasUntilNewTransport() {
173
+ val session = NimoCanvasSession()
174
+ launched(session)
175
+ session.response(4, byteArrayOf(8))
176
+ assertTrue(session.readiness(true).isEmpty())
177
+ assertTrue(session.confirmedReadiness(true).isEmpty())
178
+ assertTrue(session.offer(frameB, "one:2", true).isEmpty())
179
+ session.disconnected()
180
+ assertEquals(1, send(session.readiness(true)).key)
181
+ }
182
+
183
+ @Test fun statusTwoResetsTransportInsteadOfBlindRetry() {
184
+ val session = NimoCanvasSession()
185
+ launched(session)
186
+ val actions = session.response(4, byteArrayOf(2))
187
+ assertEquals(2, actions.size)
188
+ assertTrue(actions.last() is NimoCanvasSession.Action.Reconnect)
189
+ assertTrue(session.offer(frameB, "one:1").isEmpty())
190
+ }
191
+
192
+ @Test fun explicitExitWaitsForCurrentCommandAndDoesNotReplayOldFrame() {
193
+ val session = NimoCanvasSession()
194
+ launched(session)
195
+ assertTrue(session.exit().isEmpty())
196
+ assertEquals(3, send(success(session, 4)).key)
197
+ assertTrue(session.nativeApp(0xFD, false).isEmpty())
198
+ assertTrue(success(session, 3).isEmpty())
199
+ session.disconnected()
200
+ assertTrue(session.readiness(true).isEmpty())
201
+ }
202
+
203
+ @Test fun newSceneDuringExitLaunchesOnlyAfterExitAck() {
204
+ val session = NimoCanvasSession()
205
+ launched(session); success(session, 4)
206
+ assertEquals(3, send(session.exit()).key)
207
+ assertTrue(session.offer(frameB, "two:1").isEmpty())
208
+ assertTrue(session.nativeApp(0xFD, false).isEmpty())
209
+ assertEquals(1, send(success(session, 3)).key)
210
+ assertArrayEquals(frameB, send(success(session, 1)).frame)
211
+ }
212
+
213
+ @Test fun nativeReportDuringExitPreservesNewerHostScene() {
214
+ val session = NimoCanvasSession()
215
+ launched(session); success(session, 4)
216
+ assertEquals(3, send(session.exit()).key)
217
+ session.offer(frameB, "two:1")
218
+ assertTrue(session.nativeApp(0, true).isEmpty())
219
+ assertEquals(1, send(success(session, 3)).key)
220
+ assertArrayEquals(frameB, send(success(session, 1)).frame)
221
+ }
222
+
223
+ @Test fun nativeTakeoverRestoresLatestSceneWithoutAnotherHostOffer() {
224
+ for (app in listOf(0, 0xFD)) {
225
+ val session = NimoCanvasSession()
226
+ launched(session); success(session, 4)
227
+ assertEquals(1, send(session.nativeApp(app, app != 0xFD)).key)
228
+ assertArrayEquals(frameA, send(success(session, 1)).frame)
229
+ }
230
+ }
231
+
232
+ @Test fun nativeTakeoverWaitsForCurrentAckInsteadOfOverlappingCommands() {
233
+ val session = NimoCanvasSession(); launched(session)
234
+ assertTrue(session.nativeApp(0, true).isEmpty())
235
+ session.offer(frameB, "one:1")
236
+ assertEquals(1, send(success(session, 4)).key)
237
+ assertTrue(session.nativeApp(0, true).isEmpty()) // Startup report during Launch.
238
+ assertArrayEquals(frameB, send(success(session, 1)).frame)
239
+ }
240
+
241
+ @Test fun takeoverDuringUpdateReplaysIdenticalFrameAfterRelaunch() {
242
+ val session = NimoCanvasSession(); launched(session)
243
+ assertTrue(session.nativeApp(0, true).isEmpty())
244
+ // The old Update ACK must not deduplicate the same scene on a new canvas.
245
+ assertEquals(1, send(success(session, 4)).key)
246
+ assertArrayEquals(frameA, send(success(session, 1)).frame)
247
+ assertTrue(success(session, 4).isEmpty())
248
+ assertTrue(session.offer(frameA, "one:1").isEmpty())
249
+ }
250
+
251
+ @Test fun startupStockUiDoesNotDiscardRetainedSceneBeforeReadiness() {
252
+ val session = NimoCanvasSession(); launched(session); success(session, 4)
253
+ session.disconnected()
254
+ assertTrue(session.nativeApp(0, true).isEmpty())
255
+ session.activate()
256
+ assertEquals(1, send(session.readiness(true)).key)
257
+ assertArrayEquals(frameA, send(success(session, 1)).frame)
258
+ }
259
+
260
+ @Test fun explicitExitStillReturnsControlToStockUiUntilNextConnection() {
261
+ val session = NimoCanvasSession(); launched(session); success(session, 4)
262
+ assertEquals(3, send(session.exit()).key)
263
+ assertTrue(session.nativeApp(0, true).isEmpty())
264
+ assertTrue(success(session, 3).isEmpty())
265
+ assertTrue(session.nativeApp(0, true).isEmpty())
266
+ session.disconnected(); session.activate()
267
+ assertEquals(1, send(session.readiness(true)).key)
268
+ assertArrayEquals(byteArrayOf(0, 0, 1), send(success(session, 1)).frame)
269
+ }
270
+
271
+ @Test fun activationLaunchesBlankCanvasWithoutAnyMiniapp() {
272
+ val session = NimoCanvasSession()
273
+ assertTrue(session.activate().isEmpty())
274
+ assertEquals(1, send(session.readiness(true)).key)
275
+ assertArrayEquals(byteArrayOf(0, 0, 1), send(success(session, 1)).frame)
276
+ }
277
+
278
+ @Test fun successfulUpdateRestoresProbeBudgetForNextRecovery() {
279
+ val session = NimoCanvasSession(); session.activate(); session.readiness(true)
280
+ repeat(2) { episode ->
281
+ if (episode > 0) assertEquals(1, send(session.nativeApp(0, true)).key)
282
+ repeat(3) {
283
+ session.response(1, byteArrayOf(7))
284
+ assertEquals(1, send(session.retryNotReady()).key)
285
+ }
286
+ assertEquals(4, send(success(session, 1)).key)
287
+ assertTrue(success(session, 4).isEmpty())
288
+ }
289
+ }
290
+
291
+ @Test fun notReadyProbesAreBoundedAndFreshHeartbeatStillRecovers() {
292
+ val session = NimoCanvasSession(); session.activate(); session.readiness(true)
293
+ repeat(3) {
294
+ session.response(1, byteArrayOf(7))
295
+ assertEquals(1, send(session.retryNotReady()).key)
296
+ }
297
+ session.response(1, byteArrayOf(7))
298
+ repeat(5) { assertTrue(session.retryNotReady().isEmpty()) }
299
+ assertEquals(1, send(session.confirmedReadiness(true)).key)
300
+ assertEquals(4, send(success(session, 1)).key)
301
+ }
302
+
303
+ @Test fun lostReadinessDuringCommandStillResets() {
304
+ val session = NimoCanvasSession(); launched(session)
305
+ assertTrue(session.readiness(false).single() is NimoCanvasSession.Action.Reconnect)
306
+ assertTrue(session.retryNotReady().isEmpty())
307
+ }
308
+
309
+ @Test fun offersCopyMutableInputAndRejectOversizedFrame() {
310
+ val session = NimoCanvasSession()
311
+ val input = frameA.copyOf(); session.offer(input, "one:1"); input[0] = 99
312
+ session.readiness(true)
313
+ assertArrayEquals(frameA, send(success(session, 1)).frame)
314
+ assertThrows(IllegalArgumentException::class.java) { session.offer(ByteArray(12281), "one:1") }
315
+ }
316
+
317
+ @Test fun diagnosticHoldRetainsOnlyLatestSceneUntilReleased() {
318
+ val session = NimoCanvasSession()
319
+ launched(session); success(session, 4)
320
+
321
+ assertTrue(session.hold(true).isEmpty())
322
+ assertTrue(session.offer(frameB, "two:1").isEmpty())
323
+ assertTrue(session.offer(frameC, "two:2", force = true).isEmpty())
324
+
325
+ val update = send(session.hold(false))
326
+ assertEquals(4, update.key)
327
+ assertArrayEquals(frameC, update.frame)
328
+ }
329
+
330
+ @Test fun preemptedHoldDoesNotFlushStaleSceneBeforeExit() {
331
+ val session = NimoCanvasSession()
332
+ launched(session); success(session, 4)
333
+ session.hold(true)
334
+ session.offer(frameB, "two:1")
335
+
336
+ assertTrue(session.hold(false, resume = false).isEmpty())
337
+ assertEquals(3, send(session.exit()).key)
338
+ }
339
+ }