@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.
- package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
- package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +37 -12
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +30 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +3 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +257 -395
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +225 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +168 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
- package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +557 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +308 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +270 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +194 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +199 -0
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/MentraBluetoothSDK.podspec +3 -2
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +23 -10
- package/ios/Source/DeviceStore.swift +19 -7
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/sgcs/G2.swift +2 -2
- package/ios/Source/sgcs/Nimo.swift +182 -371
- package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +219 -0
- package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
- package/ios/Source/sgcs/NimoCanvasSession.swift +150 -0
- package/ios/Source/sgcs/SGCManager.swift +16 -0
- package/ios/Source/utils/G2Text.swift +11 -0
- package/ios/Tests/DashboardRenderingTests.swift +31 -0
- package/ios/Tests/G2TextTests.swift +10 -0
- package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
- package/ios/Tests/NimoCanvasSessionTests.swift +112 -0
- package/ios/Tests/NimoCanvasTransportTests.swift +161 -0
- package/package.json +2 -2
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.*
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
class NimoCanvasCodecTest {
|
|
7
|
+
@Test fun imageSourcesAcceptRawAndMatchingDataUris() {
|
|
8
|
+
// ImageIO/BitmapFactory validate the complete raster; this boundary validates file kind and size.
|
|
9
|
+
val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 13, 10, 26, 10) + ByteArray(24)
|
|
10
|
+
val bmp = byteArrayOf(0x42, 0x4D) + ByteArray(56)
|
|
11
|
+
val jpeg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte()) + ByteArray(24)
|
|
12
|
+
for ((mime, bytes) in listOf("png" to png, "bmp" to bmp, "jpeg" to jpeg)) {
|
|
13
|
+
val raw = java.util.Base64.getEncoder().encodeToString(bytes)
|
|
14
|
+
assertArrayEquals(bytes, NimoCanvasCodec.imageBytes(raw))
|
|
15
|
+
assertArrayEquals(bytes, NimoCanvasCodec.imageBytes("data:image/$mime;base64,$raw"))
|
|
16
|
+
val wrongMime = if (mime == "png") "bmp" else "png"
|
|
17
|
+
assertThrows(IllegalArgumentException::class.java) {
|
|
18
|
+
NimoCanvasCodec.imageBytes("data:image/$wrongMime;base64,$raw")
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Test fun unsupportedAndOversizedImageSourcesFailBeforeRasterDecoding() {
|
|
24
|
+
val encoder = java.util.Base64.getEncoder()
|
|
25
|
+
assertThrows(IllegalStateException::class.java) { NimoCanvasCodec.imageBytes("data:image/gif;base64,AAAA") }
|
|
26
|
+
assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.imageBytes(encoder.encodeToString(ByteArray(54))) }
|
|
27
|
+
assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.imageBytes("a".repeat(2_800_001)) }
|
|
28
|
+
assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.imageBytes(encoder.encodeToString(ByteArray(2_000_001))) }
|
|
29
|
+
assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.imageBytes("not valid base64!") }
|
|
30
|
+
assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.imageBytes("Qk0") }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@Test fun guideGoldenPacket() {
|
|
34
|
+
val frame = NimoCanvasCodec.replace(listOf(
|
|
35
|
+
NimoCanvasCodec.rectangle(20, 20, 120, 50, 2, 8),
|
|
36
|
+
NimoCanvasCodec.label("Hello Glass", 160, 30, 300, 40, font = 2)
|
|
37
|
+
))
|
|
38
|
+
val actual = NimoCanvasCodec.frames(4, frame, 512).single()
|
|
39
|
+
val expected = "BF003600F72D000007043200FD000000020001010C0014001400780032000208FF00031900A0001E002C012800000002FF0B0048656C6C6F20476C617373"
|
|
40
|
+
assertEquals(expected, actual.joinToString("") { "%02X".format(it.toInt() and 255) })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@Test fun fourTonePolarityAndOddPadding() {
|
|
44
|
+
assertArrayEquals(byteArrayOf(0xE4.toByte(), 0x3F),
|
|
45
|
+
NimoCanvasCodec.pack2(byteArrayOf(0, 85, 170.toByte(), 255.toByte(), 255.toByte())))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@Test fun launchAndExitGoldenPackets() {
|
|
49
|
+
fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02X".format(it.toInt() and 255) }
|
|
50
|
+
assertEquals("BF000600F5E4000007010200FD00", hex(NimoCanvasCodec.frames(1, writeCapacity = 512).single()))
|
|
51
|
+
assertEquals("BF00050086E4000007030100FD", hex(NimoCanvasCodec.frames(3, writeCapacity = 512).single()))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@Test fun primitivesValidateGeometryAndUseCompactCircle() {
|
|
55
|
+
assertEquals(10, NimoCanvasCodec.line(0, 0, 499, 219).payload.size)
|
|
56
|
+
assertEquals(12, NimoCanvasCodec.rectangle(0, 0, 500, 220).payload.size)
|
|
57
|
+
assertEquals(9, NimoCanvasCodec.circle(10, 10, 10).payload.size)
|
|
58
|
+
assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.line(0, 0, 500, 219) }
|
|
59
|
+
assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.rectangle(Int.MAX_VALUE, 0, 10, 1) }
|
|
60
|
+
assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.rectangle(0, 0, 501, 220) }
|
|
61
|
+
assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.circle(0, 10, 10) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@Test fun labelUtf8LengthAndErrors() {
|
|
65
|
+
val label = NimoCanvasCodec.label("😀é", 0, 0, 100, 20)
|
|
66
|
+
assertEquals(6, label.textBytes)
|
|
67
|
+
assertEquals(6, label.payload[12].toInt())
|
|
68
|
+
assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.label("a\u0000b", 0, 0, 100, 20) }
|
|
69
|
+
assertThrows(java.nio.charset.CharacterCodingException::class.java) { NimoCanvasCodec.label("\uD800", 0, 0, 100, 20) }
|
|
70
|
+
assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.label("é".repeat(1025), 0, 0, 100, 20) }
|
|
71
|
+
assertEquals(2048, NimoCanvasCodec.label("é".repeat(1024), 0, 0, 100, 20).textBytes)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@Test fun totalBudgetsAndClear() {
|
|
75
|
+
assertArrayEquals(byteArrayOf(0, 0, 1), NimoCanvasCodec.replace(emptyList()))
|
|
76
|
+
val line = NimoCanvasCodec.line(0, 0, 1, 1)
|
|
77
|
+
NimoCanvasCodec.replace(List(64) { line })
|
|
78
|
+
assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.replace(List(65) { line }) }
|
|
79
|
+
val text = NimoCanvasCodec.label("a".repeat(2048), 0, 0, 500, 220)
|
|
80
|
+
NimoCanvasCodec.replace(List(4) { text })
|
|
81
|
+
assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.replace(List(5) { text }) }
|
|
82
|
+
val image = NimoCanvasCodec.bitmap(0, 0, 500, 220, ByteArray(110000))
|
|
83
|
+
NimoCanvasCodec.replace(listOf(image))
|
|
84
|
+
assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.replace(listOf(image, image)) }
|
|
85
|
+
assertThrows(IllegalArgumentException::class.java) {
|
|
86
|
+
NimoCanvasCodec.replace(listOf(NimoCanvasCodec.Object(4, ByteArray(12278))))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@Test fun hostFrameReservationBoundsNativeEncoding() {
|
|
91
|
+
val random = java.util.Random(42)
|
|
92
|
+
for (size in listOf(1, 5, 80, 160, 200)) {
|
|
93
|
+
val gray = ByteArray(size * size).also { random.nextBytes(it) }
|
|
94
|
+
val image = NimoCanvasCodec.bitmap(0, 0, size, size, gray)
|
|
95
|
+
val packed = (gray.size + 3) / 4
|
|
96
|
+
val reserved = 32 + packed + (packed + 126) / 127
|
|
97
|
+
assertTrue(3 + image.payload.size <= reserved)
|
|
98
|
+
}
|
|
99
|
+
// These are the retained prefixes of the host's over-budget regression scenes.
|
|
100
|
+
val rows = NimoCanvasCodec.textRows(List(11) { "row" }.joinToString("\n"), 0, 0, 500, 220)
|
|
101
|
+
assertTrue(NimoCanvasCodec.replace(List(5) { rows }.flatten()).size <= 12280)
|
|
102
|
+
val images = listOf(200, 80).map { size ->
|
|
103
|
+
NimoCanvasCodec.bitmap(0, 0, size, size, ByteArray(size * size).also { random.nextBytes(it) })
|
|
104
|
+
}
|
|
105
|
+
assertTrue(NimoCanvasCodec.replace(images).size <= 12280)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@Test fun explicitNewlinesRetainRowsWithoutRewrapping() {
|
|
109
|
+
val rows = NimoCanvasCodec.textRows("first\n\nthird", 12, 15, 400, 100)
|
|
110
|
+
assertEquals(2, rows.size)
|
|
111
|
+
assertEquals(15, rows[0].payload[2].toInt())
|
|
112
|
+
assertEquals(55, rows[1].payload[2].toInt())
|
|
113
|
+
assertEquals(20, rows[1].payload[6].toInt())
|
|
114
|
+
val single = NimoCanvasCodec.textRows("single", 12, 15, 400, 100).single()
|
|
115
|
+
assertEquals(20, single.payload[6].toInt())
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@Test fun rleBoundaryAndLiteralRoundtrip() {
|
|
119
|
+
fun decode(data: ByteArray): ByteArray {
|
|
120
|
+
val out = java.io.ByteArrayOutputStream(); var i = 0
|
|
121
|
+
while (i < data.size) {
|
|
122
|
+
val tag = data[i++].toInt() and 255
|
|
123
|
+
val count = tag and 127
|
|
124
|
+
assertTrue(count > 0)
|
|
125
|
+
if (tag and 128 != 0) { out.write(data, i, count); i += count }
|
|
126
|
+
else { repeat(count) { out.write(data[i].toInt() and 255) }; i++ }
|
|
127
|
+
}
|
|
128
|
+
return out.toByteArray()
|
|
129
|
+
}
|
|
130
|
+
for (size in listOf(1, 2, 3, 126, 127, 128, 254, 27500)) {
|
|
131
|
+
for (input in listOf(ByteArray(size) { 33 }, ByteArray(size) { (it % 251).toByte() })) {
|
|
132
|
+
assertArrayEquals(input, decode(NimoCanvasCodec.rle(input)))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
assertArrayEquals(byteArrayOf(3, 1, 0x82.toByte(), 2, 3), NimoCanvasCodec.rle(byteArrayOf(1, 1, 1, 2, 3)))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@Test fun bitmapIsCompleteCompressed2BppAndUsesExactOddSize() {
|
|
139
|
+
val bitmap = NimoCanvasCodec.bitmap(0, 0, 5, 1, byteArrayOf(0, 85, 170.toByte(), 255.toByte(), 255.toByte()))
|
|
140
|
+
assertEquals(1, bitmap.payload[10].toInt()) // total_chunks
|
|
141
|
+
assertEquals(2, bitmap.payload[19].toInt()) // image bpp
|
|
142
|
+
assertEquals(3, bitmap.payload[20].toInt()) // RLE is smallest here
|
|
143
|
+
assertEquals(2, bitmap.payload[21].toInt()) // original_size ceil(5/4)
|
|
144
|
+
assertArrayEquals(byteArrayOf(0x82.toByte(), 0xE4.toByte(), 0x3F), bitmap.payload.copyOfRange(29, bitmap.payload.size))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@Test fun fragmentationAtNegotiatedCapacityReassemblesWholeAppStream() {
|
|
148
|
+
val content = NimoCanvasCodec.replace(listOf(NimoCanvasCodec.label("X".repeat(2048), 0, 0, 500, 220)))
|
|
149
|
+
val baseline = NimoCanvasCodec.frames(4, content, 512).flatMap { it.drop(8) }.toByteArray()
|
|
150
|
+
for (capacity in listOf(20, 64, 185, 244, 512)) {
|
|
151
|
+
val frames = NimoCanvasCodec.frames(4, content, capacity)
|
|
152
|
+
frames.forEachIndexed { index, packet ->
|
|
153
|
+
assertTrue(packet.size <= capacity)
|
|
154
|
+
assertEquals(0, packet[1].toInt())
|
|
155
|
+
val expectedIndex = if (index == frames.lastIndex) 0 else index + 1
|
|
156
|
+
assertEquals(expectedIndex, (packet[6].toInt() and 255) or ((packet[7].toInt() and 255) shl 8))
|
|
157
|
+
assertEquals(NimoCanvasCodec.crc16(packet.copyOfRange(8, packet.size)),
|
|
158
|
+
(packet[4].toInt() and 255) or ((packet[5].toInt() and 255) shl 8))
|
|
159
|
+
}
|
|
160
|
+
assertArrayEquals(baseline, frames.flatMap { it.drop(8) }.toByteArray())
|
|
161
|
+
}
|
|
162
|
+
assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.frames(4, content, 19) }
|
|
163
|
+
assertThrows(IllegalArgumentException::class.java) { NimoCanvasCodec.frames(4, ByteArray(12281), 512) }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
@Test fun strictResponseParserAcceptsStatusOnlyErrors() {
|
|
167
|
+
fun packet(hex: String) = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
|
168
|
+
val good = packet("BF000900D87900000704050000FD000000")
|
|
169
|
+
assertArrayEquals(byteArrayOf(0, 0xFD.toByte(), 0, 0, 0), NimoCanvasCodec.response(good)!!.second)
|
|
170
|
+
assertNull(NimoCanvasCodec.response(good + 0))
|
|
171
|
+
assertNull(NimoCanvasCodec.response(good.copyOf().also { it[10] = 4 }))
|
|
172
|
+
assertNull(NimoCanvasCodec.response(good.copyOf().also { it[1] = 2 }))
|
|
173
|
+
val body = byteArrayOf(7, 4, 1, 0, 7)
|
|
174
|
+
val crc = NimoCanvasCodec.crc16(body)
|
|
175
|
+
val statusOnly = byteArrayOf(0xBF.toByte(), 0, 5, 0, crc.toByte(), (crc shr 8).toByte(), 0, 0) + body
|
|
176
|
+
assertArrayEquals(byteArrayOf(7), NimoCanvasCodec.response(statusOnly)!!.second)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@Test fun sceneCompilerKeepsGeometryOrderAndBorderBeforeText() {
|
|
180
|
+
val text = NimoCanvasCodec.Element("text", 10, 20, 100, 40, "hello", border = 2, radius = 3)
|
|
181
|
+
val rectangle = NimoCanvasCodec.Element("rect", 100, 100, 50, 20)
|
|
182
|
+
val actual = NimoCanvasCodec.scene(listOf(text, rectangle)) { _, _, _ -> error("Not an image") }
|
|
183
|
+
val expected = NimoCanvasCodec.replace(listOf(
|
|
184
|
+
NimoCanvasCodec.rectangle(10, 20, 100, 40, 2, 3),
|
|
185
|
+
NimoCanvasCodec.label("hello", 10, 20, 100, 20),
|
|
186
|
+
NimoCanvasCodec.rectangle(100, 100, 50, 20, 1, 0)
|
|
187
|
+
))
|
|
188
|
+
assertArrayEquals(expected, actual)
|
|
189
|
+
assertFalse(actual.contentEquals(NimoCanvasCodec.scene(listOf(rectangle, text)) { _, _, _ -> error("Not an image") }))
|
|
190
|
+
assertFalse(actual.contentEquals(NimoCanvasCodec.scene(listOf(text.copy(x = 11), rectangle)) { _, _, _ -> error("Not an image") }))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
@Test fun scenePreflightsInvalidImageBudgetBeforeDecodingAnyPng() {
|
|
194
|
+
val image = NimoCanvasCodec.Element("image", 0, 0, 500, 220, data = "unused")
|
|
195
|
+
var decoded = 0
|
|
196
|
+
assertThrows(IllegalArgumentException::class.java) {
|
|
197
|
+
NimoCanvasCodec.scene(listOf(image, image)) { _, _, _ -> decoded++; ByteArray(110000) }
|
|
198
|
+
}
|
|
199
|
+
assertEquals(0, decoded)
|
|
200
|
+
assertThrows(IllegalArgumentException::class.java) {
|
|
201
|
+
NimoCanvasCodec.scene(listOf(image.copy(type = "html"))) { _, _, _ -> decoded++; ByteArray(110000) }
|
|
202
|
+
}
|
|
203
|
+
assertEquals(0, decoded)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
@Test fun zlibCompressesDenseRepeatingImagesAndInflatesToPackedPixels() {
|
|
207
|
+
val gray = ByteArray(32768) { (((it / 17) xor (it / 43)) % 4 * 85).toByte() }
|
|
208
|
+
val bitmap = NimoCanvasCodec.bitmap(0, 0, 256, 128, gray)
|
|
209
|
+
assertEquals(7, bitmap.payload[20].toInt())
|
|
210
|
+
val inflater = java.util.zip.Inflater()
|
|
211
|
+
try {
|
|
212
|
+
inflater.setInput(bitmap.payload.copyOfRange(29, bitmap.payload.size))
|
|
213
|
+
val output = ByteArray((gray.size + 3) / 4)
|
|
214
|
+
assertEquals(output.size, inflater.inflate(output))
|
|
215
|
+
assertTrue(inflater.finished())
|
|
216
|
+
assertArrayEquals(NimoCanvasCodec.pack2(gray), output)
|
|
217
|
+
} finally { inflater.end() }
|
|
218
|
+
NimoCanvasCodec.replace(listOf(bitmap))
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
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 bothNotificationDescriptorsGateCharacteristicWrites() {
|
|
55
|
+
val f = Fixture()
|
|
56
|
+
val rx = Any(); val mic = Any()
|
|
57
|
+
val subscribed = mutableListOf<String>()
|
|
58
|
+
f.queue.subscribe(f.connection, rx, { subscribed += "rx"; true }) {
|
|
59
|
+
f.queue.subscribe(f.connection, mic, { subscribed += "mic"; true }) {}
|
|
60
|
+
}
|
|
61
|
+
f.canvas.offer(byteArrayOf(0, 0, 1), "one"); f.canvas.readiness(true)
|
|
62
|
+
assertTrue(f.writes.isEmpty())
|
|
63
|
+
f.queue.descriptorWritten(f.connection, Any(), true)
|
|
64
|
+
assertTrue(f.writes.isEmpty())
|
|
65
|
+
f.queue.descriptorWritten(f.connection, rx, true)
|
|
66
|
+
assertEquals(listOf("rx", "mic"), subscribed)
|
|
67
|
+
assertTrue(f.writes.isEmpty())
|
|
68
|
+
f.queue.descriptorWritten(f.connection, mic, true)
|
|
69
|
+
assertEquals(1, f.writes.size)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@Test fun entireFragmentChainRemainsContiguousAmongOtherCommands() {
|
|
73
|
+
val f = Fixture()
|
|
74
|
+
val frame = ByteArray(85) { it.toByte() }
|
|
75
|
+
f.launch(frame)
|
|
76
|
+
val update = NimoCanvasCodec.frames(4, frame, 20)
|
|
77
|
+
val other = byteArrayOf(42)
|
|
78
|
+
f.queue.enqueue(f.connection, f.characteristic, listOf(other))
|
|
79
|
+
repeat(update.size) { f.written() }
|
|
80
|
+
assertEquals(update.size + 2, f.writes.size)
|
|
81
|
+
update.forEachIndexed { i, bytes -> assertArrayEquals(bytes, f.writes[i + 1]) }
|
|
82
|
+
assertArrayEquals(other, f.writes.last())
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Test fun earlyAndDuplicateAcksCannotReleaseAnUnfinishedChain() {
|
|
86
|
+
val f = Fixture()
|
|
87
|
+
val a = ByteArray(60) { it.toByte() }; val b = byteArrayOf(0, 0, 1)
|
|
88
|
+
f.launch(a)
|
|
89
|
+
f.canvas.offer(b, "one:1")
|
|
90
|
+
f.ack(4) // Before the final fragment has started: stale/uncorrelated.
|
|
91
|
+
val fragments = NimoCanvasCodec.frames(4, a, 20).size
|
|
92
|
+
repeat(fragments - 1) { f.written() }
|
|
93
|
+
val before = f.writes.size
|
|
94
|
+
f.ack(4) // Final fragment started, but its callback has not completed.
|
|
95
|
+
f.ack(4, 6) // Duplicate must not replace the first valid early ACK.
|
|
96
|
+
assertEquals(before, f.writes.size)
|
|
97
|
+
f.queue.written(f.connection, f.characteristic, true)
|
|
98
|
+
f.queue.written(f.connection, f.characteristic, true) // Duplicate callback within pacing window.
|
|
99
|
+
assertEquals(before, f.writes.size)
|
|
100
|
+
f.clock.advance(5)
|
|
101
|
+
assertEquals(before + 1, f.writes.size)
|
|
102
|
+
assertTrue(f.rejections.isEmpty())
|
|
103
|
+
f.finishUpdate(b)
|
|
104
|
+
f.ack(4) // Late duplicate with no business command in flight.
|
|
105
|
+
assertTrue(f.failures.isEmpty())
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@Test fun finalCallbackFailureAndMissingCallbackNeverReleaseNextFragment() {
|
|
109
|
+
for (missing in listOf(false, true)) {
|
|
110
|
+
val f = Fixture(); f.launch(ByteArray(60))
|
|
111
|
+
val before = f.writes.size
|
|
112
|
+
if (missing) f.clock.advance(3_000) else f.written(false)
|
|
113
|
+
assertEquals(before, f.writes.size)
|
|
114
|
+
assertEquals(1, f.failures.size)
|
|
115
|
+
f.queue.written(f.connection, f.characteristic, true)
|
|
116
|
+
f.clock.advance(5)
|
|
117
|
+
assertEquals(before, f.writes.size)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@Test fun writeStartRetriesAreBoundedAndDescriptorFailuresReset() {
|
|
122
|
+
val f = Fixture(); f.rejectWrites = true
|
|
123
|
+
f.canvas.offer(byteArrayOf(0, 0, 1), "one"); f.canvas.readiness(true)
|
|
124
|
+
f.clock.advance(200)
|
|
125
|
+
assertEquals(3, f.writes.size)
|
|
126
|
+
assertEquals(1, f.failures.size)
|
|
127
|
+
for (failure in listOf("start", "callback", "missing")) {
|
|
128
|
+
val link = Fixture(); val descriptor = Any()
|
|
129
|
+
link.queue.subscribe(link.connection, descriptor, { failure != "start" }) { fail("Must not become ready") }
|
|
130
|
+
if (failure == "callback") link.queue.descriptorWritten(link.connection, descriptor, false)
|
|
131
|
+
if (failure == "missing") link.clock.advance(5_000)
|
|
132
|
+
assertEquals(1, link.failures.size)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@Test fun ackDeadlineResetsAndReplacedConnectionCallbacksAreIgnored() {
|
|
137
|
+
val f = Fixture(); f.launch()
|
|
138
|
+
f.written() // Update is transported, but no business ACK.
|
|
139
|
+
f.clock.advance(45_000)
|
|
140
|
+
assertEquals(1, f.failures.size)
|
|
141
|
+
f.ack(4)
|
|
142
|
+
val old = f.connection
|
|
143
|
+
f.connection = Any(); f.queue.connected(f.connection)
|
|
144
|
+
f.canvas.readiness(true)
|
|
145
|
+
val before = f.writes.size
|
|
146
|
+
f.queue.written(old, f.characteristic, true)
|
|
147
|
+
f.clock.advance(5)
|
|
148
|
+
assertEquals(before, f.writes.size)
|
|
149
|
+
f.written(); f.ack(1); f.finishUpdate(byteArrayOf(0, 0, 1))
|
|
150
|
+
assertEquals(1, f.failures.size)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@Test fun rejectedUpdateImmediatelyDrainsDifferentQueuedScene() {
|
|
154
|
+
val f = Fixture(); f.launch()
|
|
155
|
+
f.written()
|
|
156
|
+
val b = byteArrayOf(1, 0, 1)
|
|
157
|
+
f.canvas.offer(b, "one:1")
|
|
158
|
+
val before = f.writes.size
|
|
159
|
+
f.ack(4, 6)
|
|
160
|
+
assertEquals(listOf(6), f.rejections)
|
|
161
|
+
assertEquals(before + 1, f.writes.size)
|
|
162
|
+
assertArrayEquals(NimoCanvasCodec.frames(4, b, 20).first(), f.writes.last())
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
@Test fun confirmedReadinessRelaunchesAfterNotReadyAndPreservesLatestScene() {
|
|
166
|
+
val f = Fixture(); f.launch(); f.written()
|
|
167
|
+
f.ack(4, 7)
|
|
168
|
+
val b = byteArrayOf(1, 0, 1)
|
|
169
|
+
f.canvas.offer(b, "one:1")
|
|
170
|
+
val before = f.writes.size
|
|
171
|
+
f.canvas.readiness(true)
|
|
172
|
+
assertEquals(before, f.writes.size)
|
|
173
|
+
f.canvas.confirmedReadiness(true)
|
|
174
|
+
assertEquals(before + 1, f.writes.size)
|
|
175
|
+
assertArrayEquals(NimoCanvasCodec.frames(1, writeCapacity = 20).single(), f.writes.last())
|
|
176
|
+
f.written(); f.ack(1)
|
|
177
|
+
assertArrayEquals(NimoCanvasCodec.frames(4, b, 20).single(), f.writes.last())
|
|
178
|
+
f.finishUpdate(b)
|
|
179
|
+
assertEquals(listOf(7), f.rejections)
|
|
180
|
+
assertTrue(f.failures.isEmpty())
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
@Test fun newerSceneSurvivesExitReportAndWaitsForExitAck() {
|
|
184
|
+
val f = Fixture(); f.launch(); f.finishUpdate(byteArrayOf(0, 0, 1))
|
|
185
|
+
f.canvas.exit()
|
|
186
|
+
f.canvas.offer(byteArrayOf(1, 0, 1), "two:1")
|
|
187
|
+
assertFalse(f.canvas.nativeApp(0xFD, false))
|
|
188
|
+
f.written()
|
|
189
|
+
val before = f.writes.size
|
|
190
|
+
f.ack(3)
|
|
191
|
+
assertEquals(before + 1, f.writes.size)
|
|
192
|
+
assertArrayEquals(NimoCanvasCodec.frames(1, writeCapacity = 20).single(), f.writes.last())
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@Test fun actualNativeTakeoverDuringExitInvalidatesEncodingAndDoesNotRelaunch() {
|
|
196
|
+
val f = Fixture(); f.launch(); f.finishUpdate(byteArrayOf(0, 0, 1))
|
|
197
|
+
f.canvas.exit()
|
|
198
|
+
f.canvas.offer(byteArrayOf(1, 0, 1), "two:1")
|
|
199
|
+
assertTrue(f.canvas.nativeApp(0, true))
|
|
200
|
+
f.written()
|
|
201
|
+
val before = f.writes.size
|
|
202
|
+
f.ack(3)
|
|
203
|
+
assertEquals(before, f.writes.size)
|
|
204
|
+
assertTrue(f.failures.isEmpty())
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
@Test fun diagnosticHoldWaitsForCurrentBusinessAckAndThenResumesLatestScene() {
|
|
208
|
+
val f = Fixture()
|
|
209
|
+
val initial = byteArrayOf(0, 0, 1)
|
|
210
|
+
val latest = byteArrayOf(2, 0, 1)
|
|
211
|
+
f.launch(initial)
|
|
212
|
+
var ready = 0
|
|
213
|
+
f.canvas.hold { ready++ }
|
|
214
|
+
f.canvas.offer(byteArrayOf(1, 0, 1), "one:1")
|
|
215
|
+
f.canvas.offer(latest, "one:1")
|
|
216
|
+
|
|
217
|
+
assertEquals(0, ready)
|
|
218
|
+
f.finishUpdate(initial)
|
|
219
|
+
assertEquals(1, ready)
|
|
220
|
+
val beforeRelease = f.writes.size
|
|
221
|
+
|
|
222
|
+
f.canvas.releaseHold()
|
|
223
|
+
|
|
224
|
+
assertEquals(beforeRelease + 1, f.writes.size)
|
|
225
|
+
assertArrayEquals(NimoCanvasCodec.frames(4, latest, 20).first(), f.writes.last())
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
@Test fun scopeTakeoverPreemptsHoldWithoutFlushingHeldScene() {
|
|
229
|
+
val f = Fixture()
|
|
230
|
+
val initial = byteArrayOf(0, 0, 1)
|
|
231
|
+
val held = byteArrayOf(1, 0, 1)
|
|
232
|
+
val takeover = byteArrayOf(2, 0, 1)
|
|
233
|
+
f.launch(initial); f.finishUpdate(initial)
|
|
234
|
+
var ready = 0
|
|
235
|
+
f.canvas.hold { ready++ }
|
|
236
|
+
f.canvas.offer(held, "one:1")
|
|
237
|
+
val beforeTakeover = f.writes.size
|
|
238
|
+
|
|
239
|
+
f.canvas.offer(takeover, "two:1")
|
|
240
|
+
|
|
241
|
+
assertEquals(1, ready)
|
|
242
|
+
assertEquals(1, f.holdPreemptions)
|
|
243
|
+
assertEquals(beforeTakeover + 1, f.writes.size)
|
|
244
|
+
assertArrayEquals(NimoCanvasCodec.frames(4, takeover, 20).first(), f.writes.last())
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
@Test fun firstScopePreemptsHoldAcquiredBeforeAnyCanvasScene() {
|
|
248
|
+
val f = Fixture()
|
|
249
|
+
var ready = 0
|
|
250
|
+
f.canvas.readiness(true)
|
|
251
|
+
f.canvas.hold { ready++ }
|
|
252
|
+
assertEquals(1, ready)
|
|
253
|
+
|
|
254
|
+
f.canvas.offer(byteArrayOf(0, 0, 1), "first:1")
|
|
255
|
+
|
|
256
|
+
assertEquals(1, f.holdPreemptions)
|
|
257
|
+
assertEquals(1, f.writes.size)
|
|
258
|
+
assertArrayEquals(NimoCanvasCodec.frames(1, writeCapacity = 20).single(), f.writes.single())
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
@Test fun reentrantCompletionCannotReleaseNewConnectionWrite() {
|
|
262
|
+
val f = Fixture(); val old = f.connection
|
|
263
|
+
f.queue.enqueue(old, f.characteristic, listOf(byteArrayOf(1)), completed = {
|
|
264
|
+
f.queue.reset()
|
|
265
|
+
f.connection = Any(); f.queue.connected(f.connection)
|
|
266
|
+
f.queue.enqueue(f.connection, f.characteristic, listOf(byteArrayOf(2), byteArrayOf(3)))
|
|
267
|
+
})
|
|
268
|
+
f.written()
|
|
269
|
+
assertEquals(2, f.writes.size)
|
|
270
|
+
f.queue.written(old, f.characteristic, true)
|
|
271
|
+
f.clock.advance(5)
|
|
272
|
+
assertEquals(2, f.writes.size)
|
|
273
|
+
f.written()
|
|
274
|
+
assertEquals(3, f.writes.size)
|
|
275
|
+
assertArrayEquals(byteArrayOf(3), f.writes.last())
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
@Test fun staleEncodeAfterNewSceneExitDisconnectOrDisposalCannotDeliver() {
|
|
279
|
+
for (invalidate in listOf("new", "exit", "disconnect", "dispose")) {
|
|
280
|
+
val main = Clock(); val worker = Clock()
|
|
281
|
+
val delivered = mutableListOf<Int>(); var releases = 0; var stopped = 0
|
|
282
|
+
val encoder = NimoCanvasEncoder(main, worker, { stopped++ }, { throw it })
|
|
283
|
+
encoder.submit({ byteArrayOf(1) }, { releases++ }) { delivered += it[0].toInt() }
|
|
284
|
+
worker.advance(0) // Completed encoding is now pending on Main.
|
|
285
|
+
when (invalidate) {
|
|
286
|
+
"new" -> encoder.submit({ byteArrayOf(2) }, { releases++ }) { delivered += it[0].toInt() }
|
|
287
|
+
"dispose" -> encoder.close()
|
|
288
|
+
else -> encoder.invalidate()
|
|
289
|
+
}
|
|
290
|
+
worker.advance(0); main.advance(0)
|
|
291
|
+
assertEquals(if (invalidate == "new") listOf(2) else emptyList(), delivered)
|
|
292
|
+
encoder.close(); encoder.close()
|
|
293
|
+
assertEquals(1, stopped)
|
|
294
|
+
assertEquals(if (invalidate == "new") 2 else 1, releases)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
@Test fun canceledAndDisposedBitmapSourcesAreReleasedExactlyOnce() {
|
|
299
|
+
val main = Clock(); val worker = Clock(); var encoded = 0; var released = 0
|
|
300
|
+
val encoder = NimoCanvasEncoder(main, worker, {}, { throw it })
|
|
301
|
+
encoder.submit({ encoded++; byteArrayOf(1) }, { released++ }) { fail("Canceled work delivered") }
|
|
302
|
+
encoder.invalidate(); worker.advance(0); main.advance(0)
|
|
303
|
+
assertEquals(0, encoded); assertEquals(1, released)
|
|
304
|
+
encoder.close()
|
|
305
|
+
assertFalse(encoder.submit({ encoded++; byteArrayOf(2) }, { released++ }) {})
|
|
306
|
+
assertEquals(0, encoded); assertEquals(2, released)
|
|
307
|
+
}
|
|
308
|
+
}
|