@mentra/bluetooth-sdk 3.2.1-beta.319 → 3.2.1-beta.354

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 (83) hide show
  1. package/README.md +1 -1
  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 +4 -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 +67 -12
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  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/services/Foreground.kt +33 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +64 -14
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +73 -3
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +257 -395
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +225 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +168 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  24. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +557 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  29. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +308 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +270 -0
  34. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  35. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  36. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +194 -0
  37. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +199 -0
  38. package/build/BluetoothSdk.types.d.ts +11 -0
  39. package/build/BluetoothSdk.types.d.ts.map +1 -1
  40. package/build/BluetoothSdk.types.js.map +1 -1
  41. package/build/_private/BluetoothSdkModule.d.ts +2 -0
  42. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  43. package/build/_private/BluetoothSdkModule.js +3 -0
  44. package/build/_private/BluetoothSdkModule.js.map +1 -1
  45. package/build/generated/changelogCatalog.d.ts +1 -1
  46. package/build/generated/changelogCatalog.js +1 -1
  47. package/build/generated/changelogCatalog.js.map +1 -1
  48. package/build/generated/releaseMetadata.js +5 -5
  49. package/build/generated/releaseMetadata.js.map +1 -1
  50. package/build/index.d.ts.map +1 -1
  51. package/build/index.js +1 -0
  52. package/build/index.js.map +1 -1
  53. package/ios/BluetoothSdkModule.swift +5 -0
  54. package/ios/MentraBluetoothSDK.podspec +3 -2
  55. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  56. package/ios/Source/Bridge.swift +15 -2
  57. package/ios/Source/DeviceManager.swift +23 -10
  58. package/ios/Source/DeviceStore.swift +26 -7
  59. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  60. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  61. package/ios/Source/MentraBluetoothSDK.swift +4 -0
  62. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  63. package/ios/Source/sgcs/G2.swift +2 -2
  64. package/ios/Source/sgcs/MentraLive.swift +66 -3
  65. package/ios/Source/sgcs/Nimo.swift +182 -371
  66. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  67. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +219 -0
  68. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  69. package/ios/Source/sgcs/NimoCanvasSession.swift +150 -0
  70. package/ios/Source/sgcs/SGCManager.swift +24 -0
  71. package/ios/Source/utils/G2Text.swift +11 -0
  72. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  73. package/ios/Tests/G2TextTests.swift +10 -0
  74. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  75. package/ios/Tests/NimoCanvasSessionTests.swift +112 -0
  76. package/ios/Tests/NimoCanvasTransportTests.swift +161 -0
  77. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  78. package/package.json +2 -2
  79. package/src/BluetoothSdk.types.ts +11 -0
  80. package/src/_private/BluetoothSdkModule.ts +6 -0
  81. package/src/generated/changelogCatalog.ts +1 -1
  82. package/src/generated/releaseMetadata.ts +5 -5
  83. package/src/index.ts +1 -0
@@ -0,0 +1,51 @@
1
+ package com.mentra.bluetoothsdk.services
2
+
3
+ import com.mentra.bluetoothsdk.utils.DeviceTypes
4
+ import org.junit.Assert.*
5
+ import org.junit.Before
6
+ import org.junit.Test
7
+ import org.junit.runner.RunWith
8
+ import org.robolectric.RobolectricTestRunner
9
+ import org.robolectric.RuntimeEnvironment
10
+ import org.robolectric.annotation.Config
11
+
12
+ @RunWith(RobolectricTestRunner::class)
13
+ @Config(sdk = [28])
14
+ class G2ConnectionRecoveryTest {
15
+ private fun recovery() = G2ConnectionRecovery(RuntimeEnvironment.getApplication())
16
+ private fun settings() = mapOf<String, Any>(
17
+ "default_wearable" to DeviceTypes.G2, "device_name" to "S200-test",
18
+ "brightness" to 75, "auto_brightness" to false,
19
+ "core_token" to "must-not-persist", "micEnabled" to true,
20
+ "searching" to true, "pending_device_name" to "unpaired",
21
+ )
22
+
23
+ @Before fun reset() { recovery().clear() }
24
+
25
+ @Test fun `a fresh recovery owner restores identity and settings but not runtime or auth state`() {
26
+ recovery().save(settings())
27
+ val restored = recovery().read()!!
28
+ assertEquals("S200-test", restored["device_name"])
29
+ assertEquals(75, restored["brightness"])
30
+ assertEquals(false, restored["auto_brightness"])
31
+ assertFalse(restored.containsKey("core_token"))
32
+ assertFalse(restored.containsKey("micEnabled"))
33
+ assertFalse(restored.containsKey("searching"))
34
+ assertFalse(restored.containsKey("pending_device_name"))
35
+ }
36
+
37
+ @Test fun `explicit disconnect clears recovery across owner recreation`() {
38
+ recovery().save(settings())
39
+ recovery().clear()
40
+ assertNull(recovery().read())
41
+ }
42
+
43
+ @Test fun `forget or switching models cannot resurrect old G2 pairing`() {
44
+ recovery().save(settings())
45
+ recovery().save(settings() + ("device_name" to ""))
46
+ assertNull(recovery().read())
47
+ recovery().save(settings())
48
+ recovery().save(settings() + ("default_wearable" to DeviceTypes.G1))
49
+ assertNull(recovery().read())
50
+ }
51
+ }
@@ -0,0 +1,120 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.os.Looper
4
+ import android.bluetooth.BluetoothGatt
5
+ import android.bluetooth.BluetoothGattCharacteristic
6
+ import java.util.UUID
7
+ import java.util.concurrent.ExecutorService
8
+ import java.util.concurrent.TimeUnit
9
+ import org.junit.Assert.assertTrue
10
+ import org.robolectric.annotation.Implementation
11
+ import org.robolectric.annotation.Implements
12
+ import org.robolectric.shadow.api.Shadow
13
+ import com.mentra.bluetoothsdk.Bridge
14
+ import org.junit.Assert.assertEquals
15
+ import org.junit.Test
16
+ import org.junit.runner.RunWith
17
+ import org.robolectric.RobolectricTestRunner
18
+ import org.robolectric.RuntimeEnvironment
19
+ import org.robolectric.Shadows.shadowOf
20
+ import org.robolectric.annotation.Config
21
+ import org.robolectric.annotation.LooperMode
22
+
23
+ @RunWith(RobolectricTestRunner::class)
24
+ @Config(sdk = [28], shadows = [RecordingGatt::class])
25
+ @LooperMode(LooperMode.Mode.PAUSED)
26
+ class G2DisplayThreadTest {
27
+ @Test fun `worker clear cannot remove containers under the main-thread reconcile loop`() {
28
+ Bridge.initialize(RuntimeEnvironment.getApplication())
29
+ val g2 = G2()
30
+ val add = G2::class.java.declaredMethods.single { it.name == "addTextContainer" }
31
+ add.isAccessible = true
32
+ add.invoke(g2, 0, 0, 576, 288, "captions", 0, 0, 0, 0)
33
+ add.invoke(g2, 10, 10, 100, 40, "HUD", 0, 0, 0, 0)
34
+ val field = G2::class.java.getDeclaredField("textContainers").apply { isAccessible = true }
35
+ val containers = field.get(g2) as List<*>
36
+ // This is the heartbeat's captured index range at the crash site.
37
+ val indices = containers.indices
38
+ val worker = Thread { g2.clearDisplay() }
39
+ worker.start()
40
+ worker.join()
41
+ assertEquals(2, containers.size)
42
+ indices.forEach { containers[it] } // crashed here when clear ran on the worker
43
+ shadowOf(Looper.getMainLooper()).idle()
44
+ assertEquals(1, containers.size) // only the positioned HUD husk is purged
45
+ }
46
+
47
+ @Test fun `closing positioned captions clears the live page without waiting for new speech`() {
48
+ Bridge.initialize(RuntimeEnvironment.getApplication())
49
+ val g2 = G2()
50
+ val add = G2::class.java.declaredMethods.single { it.name == "addTextContainer" }
51
+ add.isAccessible = true
52
+ add.invoke(g2, 10, 10, 500, 100, "old captions", 0, 0, 0, 0)
53
+ val page = G2::class.java.getDeclaredField("pageCreated").apply { isAccessible = true }
54
+ page.setBoolean(g2, true)
55
+
56
+ g2.clearDisplay()
57
+ shadowOf(Looper.getMainLooper()).idle()
58
+
59
+ val field = G2::class.java.getDeclaredField("textContainers").apply { isAccessible = true }
60
+ assertEquals(0, (field.get(g2) as List<*>).size)
61
+ // No shutdown or deferred recreation: the replacement page is already live,
62
+ // even if the user remains silent and no new caption frame ever arrives.
63
+ assertEquals(true, page.getBoolean(g2))
64
+ g2.clearDisplay()
65
+ shadowOf(Looper.getMainLooper()).idle()
66
+ assertEquals(true, page.getBoolean(g2))
67
+ }
68
+
69
+
70
+ @Test fun `clear transmits an erase before dropping the old caption container and retries busy writes`() {
71
+ Bridge.initialize(RuntimeEnvironment.getApplication())
72
+ val g2 = G2()
73
+ val gatt = Shadow.newInstanceOf(BluetoothGatt::class.java)
74
+ val recorder = Shadow.extract<RecordingGatt>(gatt)
75
+ recorder.rejectWrites = 1
76
+ G2::class.java.getDeclaredField("rightGatt").apply { isAccessible = true }.set(g2, gatt)
77
+ G2::class.java.getDeclaredField("rightWriteChar").apply { isAccessible = true }.set(
78
+ g2, BluetoothGattCharacteristic(UUID.randomUUID(), 4, 16)
79
+ )
80
+ val add = G2::class.java.declaredMethods.single { it.name == "addTextContainer" }
81
+ add.isAccessible = true
82
+ add.invoke(g2, 10, 10, 500, 100, "old captions", 0, 0, 0, 0)
83
+ G2::class.java.getDeclaredField("pageCreated").apply { isAccessible = true }.setBoolean(g2, true)
84
+
85
+ g2.clearDisplay()
86
+ shadowOf(Looper.getMainLooper()).idle()
87
+ val executor = G2::class.java.getDeclaredField("bleWriteExecutor").apply { isAccessible = true }
88
+ .get(g2) as ExecutorService
89
+ executor.submit {}.get(5, TimeUnit.SECONDS)
90
+
91
+ // Transport header is eight bytes; protobuf field 1 is the EvenHub command.
92
+ val commands = recorder.accepted.map { it[9].toInt() }
93
+ assertEquals(5, commands.first()) // UPDATE_TEXT_DATA, before REBUILD_PAGE (7)
94
+ assertTrue(commands.indexOf(7) > 0)
95
+ assertTrue(String(recorder.accepted.first(), Charsets.UTF_8).contains(" ".repeat(12)))
96
+ assertTrue(recorder.attempted[0].contentEquals(recorder.attempted[1]))
97
+ assertEquals(recorder.accepted.size + 1, recorder.attempted.size)
98
+ }
99
+
100
+ }
101
+
102
+
103
+ @Implements(BluetoothGatt::class)
104
+ class RecordingGatt {
105
+ var rejectWrites = 0
106
+ val attempted = mutableListOf<ByteArray>()
107
+ val accepted = mutableListOf<ByteArray>()
108
+
109
+ @Implementation
110
+ fun writeCharacteristic(characteristic: BluetoothGattCharacteristic): Boolean {
111
+ val packet = characteristic.value.copyOf()
112
+ attempted.add(packet)
113
+ if (rejectWrites > 0) {
114
+ rejectWrites--
115
+ return false
116
+ }
117
+ accepted.add(packet)
118
+ return true
119
+ }
120
+ }
@@ -0,0 +1,105 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.graphics.Bitmap
4
+ import androidx.test.core.app.ApplicationProvider
5
+ import com.mentra.bluetoothsdk.Bridge
6
+ import java.io.ByteArrayOutputStream
7
+ import java.util.Base64
8
+ import org.junit.After
9
+ import org.junit.Assert.*
10
+ import org.junit.Before
11
+ import org.junit.Test
12
+ import org.junit.runner.RunWith
13
+ import org.robolectric.RobolectricTestRunner
14
+ import org.robolectric.annotation.Config
15
+ import org.robolectric.annotation.GraphicsMode
16
+
17
+ @RunWith(RobolectricTestRunner::class)
18
+ @Config(sdk = [33])
19
+ @GraphicsMode(GraphicsMode.Mode.NATIVE)
20
+ class NimoBitmapAcceptanceTest {
21
+ private lateinit var nimo: Nimo
22
+
23
+ @Before fun setup() {
24
+ Bridge.initialize(ApplicationProvider.getApplicationContext())
25
+ nimo = Nimo()
26
+ }
27
+
28
+ @After fun dispose() { nimo.cleanup() }
29
+
30
+ @Test fun malformedInputsAreRejectedBeforeReturning() {
31
+ val invalid = listOf(
32
+ "not base64!",
33
+ "data:image/jpeg;base64,${png()}",
34
+ "data:image/bmp;base64,${png()}",
35
+ Base64.getEncoder().encodeToString(byteArrayOf(0x89.toByte(), 80, 78, 71, 13, 10, 26, 10) + ByteArray(40)),
36
+ Base64.getEncoder().encodeToString(byteArrayOf(66, 77) + ByteArray(60)),
37
+ "A".repeat(NimoCanvasCodec.MAX_IMAGE_BASE64_CHARS + 1)
38
+ )
39
+ for (input in invalid) {
40
+ assertFalse("Malformed bitmap was accepted: ${input.take(50)}", nimo.displayBitmap(input))
41
+ }
42
+ }
43
+
44
+ @Test fun validPngAcceptsNullableGeometryAndRejectsOutOfBoundsGeometry() {
45
+ val image = png()
46
+ assertTrue(nimo.displayBitmap(image))
47
+ assertTrue(nimo.displayBitmap(image, 499, 219, null, null))
48
+ assertFalse(nimo.displayBitmap(image, 500, 0, null, null))
49
+ assertFalse(nimo.displayBitmap(image, 0, 0, 0, 1))
50
+ assertFalse(nimo.displayBitmap(image, -1, 0, 10, 10))
51
+ }
52
+
53
+ @Test fun jpegPixelsAreDecodedForRawAndDataUriInputs() {
54
+ val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888)
55
+ val bytes = ByteArrayOutputStream()
56
+ try {
57
+ bitmap.eraseColor(android.graphics.Color.WHITE)
58
+ assertTrue(bitmap.compress(Bitmap.CompressFormat.JPEG, 100, bytes))
59
+ } finally { bitmap.recycle() }
60
+ val raw = Base64.getEncoder().encodeToString(bytes.toByteArray())
61
+ val decode = Nimo::class.java.getDeclaredMethod(
62
+ "decodeCanvasImage", String::class.java, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType
63
+ ).apply { isAccessible = true }
64
+ for (input in listOf(raw, "data:image/jpeg;base64,$raw")) {
65
+ assertTrue(nimo.displayBitmap(input))
66
+ assertArrayEquals(ByteArray(4) { 255.toByte() }, decode.invoke(nimo, input, 2, 2) as ByteArray)
67
+ }
68
+ assertFalse(nimo.displayBitmap("data:image/png;base64,$raw"))
69
+ }
70
+
71
+ @Test fun sourceRasterBudgetsRejectOversizeAndAcceptBoundaries() {
72
+ for ((width, height) in listOf(4097 to 1, 1 to 4097, 2049 to 2000)) {
73
+ assertFalse("Oversize source $width x $height", nimo.displayBitmap(png(width, height)))
74
+ }
75
+ for ((width, height) in listOf(4096 to 1, 1 to 4096, 2000 to 2000)) {
76
+ assertTrue("Valid boundary $width x $height", nimo.displayBitmap(png(width, height)))
77
+ }
78
+ }
79
+
80
+ @Test fun coloredRasterQuantizationMatchesIosAtEveryTwoBitBoundary() {
81
+ // Identical opaque 8x1 24-bit BMP in NimoCanvasSceneTests; no scaling.
82
+ val bmp = byteArrayOf(
83
+ 66, 77, 78, 0, 0, 0, 0, 0, 0, 0, 54, 0, 0, 0,
84
+ 40, 0, 0, 0, 8, 0, 0, 0, 1, 0, 0, 0, 1, 0, 24, 0,
85
+ 0, 0, 0, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
86
+ 0, 72, 0, 0, 73, 0, 0, 217.toByte(), 0, 0, 218.toByte(), 0,
87
+ 0, 231.toByte(), 255.toByte(), 0, 232.toByte(), 255.toByte(), 0, 0, 0, -1, -1, -1
88
+ )
89
+ val decode = Nimo::class.java.getDeclaredMethod(
90
+ "decodeCanvasImage", String::class.java, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType
91
+ ).apply { isAccessible = true }
92
+ val gray = decode.invoke(nimo, Base64.getEncoder().encodeToString(bmp), 8, 1) as ByteArray
93
+ assertArrayEquals(byteArrayOf(42, 43, 127, 128.toByte(), 212.toByte(), 213.toByte(), 0, -1), gray)
94
+ assertArrayEquals(byteArrayOf(0xE9.toByte(), 0x4C), NimoCanvasCodec.pack2(gray))
95
+ }
96
+
97
+ private fun png(width: Int = 2, height: Int = 2): String {
98
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
99
+ return try {
100
+ val bytes = ByteArrayOutputStream()
101
+ assertTrue(bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes))
102
+ Base64.getEncoder().encodeToString(bytes.toByteArray())
103
+ } finally { bitmap.recycle() }
104
+ }
105
+ }
@@ -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
+ }