@mentra/bluetooth-sdk 3.2.1-beta.315 → 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.
- package/README.md +1 -1
- 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/BluetoothSdkDefaults.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +67 -12
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +64 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +73 -3
- 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 +11 -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/VersionInfoResponseAccumulatorTest.kt +71 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -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/BluetoothSdk.types.d.ts +11 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +2 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +3 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +1 -1
- package/build/generated/changelogCatalog.js +1 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +5 -0
- package/ios/MentraBluetoothSDK.podspec +3 -2
- package/ios/Source/BluetoothSdkDefaults.swift +6 -1
- package/ios/Source/Bridge.swift +15 -2
- package/ios/Source/DeviceManager.swift +23 -10
- package/ios/Source/DeviceStore.swift +26 -7
- package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +4 -0
- package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
- package/ios/Source/sgcs/G2.swift +2 -2
- package/ios/Source/sgcs/MentraLive.swift +66 -3
- 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 +24 -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/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +11 -0
- package/src/_private/BluetoothSdkModule.ts +6 -0
- package/src/generated/changelogCatalog.ts +1 -1
- package/src/generated/releaseMetadata.ts +5 -5
- 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
|
+
}
|