@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,151 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothAdapter
4
+ import android.bluetooth.BluetoothDevice
5
+ import android.bluetooth.BluetoothGatt
6
+ import android.bluetooth.BluetoothProfile
7
+ import android.bluetooth.le.ScanResult
8
+ import android.bluetooth.le.ScanSettings
9
+ import android.os.Looper
10
+ import androidx.test.core.app.ApplicationProvider
11
+ import com.mentra.bluetoothsdk.Bridge
12
+ import com.mentra.bluetoothsdk.DeviceManager
13
+ import com.mentra.bluetoothsdk.DeviceStore
14
+ import com.mentra.bluetoothsdk.utils.DeviceTypes
15
+ import org.junit.Assert.assertEquals
16
+ import org.junit.Assert.assertTrue
17
+ import org.junit.Before
18
+ import org.junit.Test
19
+ import org.junit.runner.RunWith
20
+ import org.robolectric.RobolectricTestRunner
21
+ import org.robolectric.Shadows.shadowOf
22
+ import org.robolectric.annotation.Config
23
+ import org.robolectric.annotation.Implementation
24
+ import org.robolectric.annotation.Implements
25
+ import org.robolectric.annotation.LooperMode
26
+ import org.robolectric.shadows.ShadowBluetoothGatt
27
+
28
+ @RunWith(RobolectricTestRunner::class)
29
+ @Config(sdk = [33], shadows = [G1PairingTest.ShadowLc3Cpp::class, MentraNexPairingTest.ShadowNexGatt::class], instrumentedPackages = ["com.mentra.lc3Lib"])
30
+ @LooperMode(LooperMode.Mode.PAUSED)
31
+ class MentraNexPairingTest {
32
+ // Robolectric implements connection callbacks but not the PHY binder request.
33
+ @Implements(BluetoothGatt::class)
34
+ class ShadowNexGatt : ShadowBluetoothGatt() {
35
+ @Implementation fun setPreferredPhy(txPhy: Int, rxPhy: Int, phyOptions: Int) {}
36
+ }
37
+
38
+ @Before fun setup() {
39
+ Bridge.initialize(ApplicationProvider.getApplicationContext())
40
+ shadowOf(BluetoothAdapter.getDefaultAdapter()).setState(BluetoothAdapter.STATE_ON)
41
+ DeviceStore.set("bluetooth", "pending_device_name", "Nex1-2")
42
+ DeviceStore.set("bluetooth", "pending_device_address", "")
43
+ DeviceStore.set("bluetooth", "device_address", "")
44
+ }
45
+
46
+ @Test fun `fresh pairing selects only the requested Nex`() {
47
+ assertScansSelectedTarget("", "")
48
+ }
49
+
50
+ @Test fun `simulated pairing selects only the requested Nex`() {
51
+ assertScansSelectedTarget("Simulated Glasses", "")
52
+ }
53
+
54
+ @Test fun `a different saved Nex name and address cannot redirect selection`() {
55
+ assertScansSelectedTarget("Nex1-1", "00:00:00:00:00:01")
56
+ }
57
+
58
+ @Test fun `pending address targets the new Nex instead of the saved pair`() {
59
+ assertDirectTarget("Nex1-1", "00:00:00:00:00:01", "00:00:00:00:00:02")
60
+ }
61
+
62
+ @Test fun `saved Nex reconnect retains its address`() {
63
+ assertDirectTarget("Nex1-2", "00:00:00:00:00:02", "")
64
+ }
65
+
66
+ @Test fun `name-only pairing readiness cannot associate the old MAC with the new Nex`() {
67
+ DeviceStore.set("bluetooth", "default_wearable", DeviceTypes.NEX)
68
+ DeviceStore.set("bluetooth", "pending_wearable", DeviceTypes.NEX)
69
+ DeviceStore.set("bluetooth", "device_name", "Nex1-1")
70
+ DeviceStore.set("bluetooth", "device_address", "00:00:00:00:00:01")
71
+ DeviceStore.set("bluetooth", "shouldSendBootingMessage", false)
72
+ DeviceStore.set("glasses", "fullyBooted", false)
73
+ val old = device("00:00:00:00:00:01", "Nex1-1")
74
+ val selected = device("00:00:00:00:00:02", "Nex1-2")
75
+ val singleton = DeviceManager::class.java.getDeclaredField("_instance").apply { isAccessible = true }
76
+ val previous = singleton.get(null)
77
+ val manager = DeviceManager(initializeHardware = false)
78
+ singleton.set(null, manager)
79
+ try {
80
+ var nex = MentraNex()
81
+ manager.sgc = nex
82
+ nex.connectById("Nex1-2")
83
+ val scanner = shadowOf(BluetoothAdapter.getDefaultAdapter().bluetoothLeScanner)
84
+ scanner.scanCallbacks.single().onScanResult(
85
+ ScanSettings.CALLBACK_TYPE_ALL_MATCHES, ScanResult(selected, null, -50, 0),
86
+ )
87
+ shadowOf(Looper.getMainLooper()).idle()
88
+ // Drive the actual ready callback and DeviceManager's identity promotion.
89
+ shadowOf(selected).simulateGattConnectionChange(BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_CONNECTED)
90
+ assertEquals("Nex1-2", DeviceStore.get("bluetooth", "device_name"))
91
+ assertEquals("", DeviceStore.get("bluetooth", "device_address"))
92
+ assertEquals("", DeviceStore.get("bluetooth", "pending_device_name"))
93
+ manager.disconnect()
94
+
95
+ nex = MentraNex()
96
+ manager.sgc = nex
97
+ nex.connectById(DeviceStore.get("bluetooth", "device_name") as String)
98
+ assertTrue(shadowOf(old).bluetoothGatts.isEmpty())
99
+ scanner.scanCallbacks.single().onScanResult(
100
+ ScanSettings.CALLBACK_TYPE_ALL_MATCHES, ScanResult(selected, null, -50, 0),
101
+ )
102
+ shadowOf(Looper.getMainLooper()).idle()
103
+ assertEquals(2, shadowOf(selected).bluetoothGatts.size)
104
+ } finally {
105
+ manager.disconnect()
106
+ manager.cleanup()
107
+ singleton.set(null, previous)
108
+ }
109
+ }
110
+
111
+ private fun assertScansSelectedTarget(savedName: String, savedAddress: String) {
112
+ DeviceStore.set("bluetooth", "device_name", savedName)
113
+ DeviceStore.set("bluetooth", "device_address", savedAddress)
114
+ val old = device("00:00:00:00:00:01", "Nex1-1")
115
+ val prefixCollision = device("00:00:00:00:00:03", "Nex1-23")
116
+ val selected = device("00:00:00:00:00:02", "Nex1-2")
117
+ val nex = MentraNex()
118
+ try {
119
+ nex.connectById("Nex1-2")
120
+ val scanner = shadowOf(BluetoothAdapter.getDefaultAdapter().bluetoothLeScanner)
121
+ val callback = scanner.scanCallbacks.single()
122
+ for (found in listOf(old, prefixCollision, selected)) {
123
+ callback.onScanResult(ScanSettings.CALLBACK_TYPE_ALL_MATCHES, ScanResult(found, null, -50, 0))
124
+ shadowOf(Looper.getMainLooper()).idle()
125
+ }
126
+ assertTrue(shadowOf(old).bluetoothGatts.isEmpty())
127
+ assertTrue(shadowOf(prefixCollision).bluetoothGatts.isEmpty())
128
+ assertEquals(1, shadowOf(selected).bluetoothGatts.size)
129
+ assertEquals(savedName, DeviceStore.get("bluetooth", "device_name"))
130
+ } finally { nex.disconnect() }
131
+ }
132
+
133
+ private fun assertDirectTarget(savedName: String, savedAddress: String, pendingAddress: String) {
134
+ DeviceStore.set("bluetooth", "device_name", savedName)
135
+ DeviceStore.set("bluetooth", "device_address", savedAddress)
136
+ DeviceStore.set("bluetooth", "pending_device_address", pendingAddress)
137
+ val old = device("00:00:00:00:00:01", "Nex1-1")
138
+ val selected = device("00:00:00:00:00:02", "Nex1-2")
139
+ val nex = MentraNex()
140
+ try {
141
+ nex.connectById("Nex1-2")
142
+ shadowOf(Looper.getMainLooper()).idle()
143
+ assertTrue(shadowOf(old).bluetoothGatts.isEmpty())
144
+ assertEquals(1, shadowOf(selected).bluetoothGatts.size)
145
+ assertEquals(savedName, DeviceStore.get("bluetooth", "device_name"))
146
+ } finally { nex.disconnect() }
147
+ }
148
+
149
+ private fun device(address: String, name: String): BluetoothDevice =
150
+ BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address).also { shadowOf(it).setName(name) }
151
+ }
@@ -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
+ }