@glassly/bluetooth-sdk 0.1.0-dev.98 → 0.2.8-dev.444
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 +47 -6
- package/android/build.gradle +7 -7
- package/android/lc3Lib/build.gradle +2 -2
- package/android/src/main/assets/G2Firmware/README.md +27 -2
- package/android/src/main/assets/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
- package/android/src/main/assets/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +38 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +50 -14
- package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +18 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +139 -10
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +28 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +199 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +10 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +4 -4
- package/android/src/main/java/com/glassly/bluetoothsdk/GlassesNotifications.kt +155 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +1075 -257
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2BleLink.kt +119 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +29 -28
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +138 -33
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +673 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +235 -162
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransport.kt +402 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnostics.kt +84 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +9 -9
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Keyboard.kt +689 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardBridge.kt +452 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardHid.kt +208 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFile.kt +181 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2RingBattery.kt +20 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Sensors.kt +67 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2TouchInput.kt +39 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +5 -5
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +14 -14
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/{GlasslyBle.java → NexProto.java} +6462 -7618
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIds.kt +58 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +77 -2
- package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +55 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +24 -149
- package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/G2BleLinkTest.kt +70 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +45 -56
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +78 -5
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +489 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +100 -43
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransportTest.kt +252 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnosticsTest.kt +64 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardTest.kt +271 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFileTest.kt +204 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2RingBatteryTest.kt +56 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2SensorsTest.kt +56 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2TouchInputTest.kt +60 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NexProtoDescriptorTest.kt +32 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIdsTest.kt +58 -0
- package/build/BluetoothSdk.types.d.ts +209 -20
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +1 -1
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +30 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +28 -1
- package/build/generated/changelogCatalog.d.ts.map +1 -1
- package/build/generated/changelogCatalog.js +37 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +4 -4
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +14 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +102 -13
- package/ios/GlasslyBluetoothSDK.podspec +1 -1
- package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +8 -3
- package/ios/Source/BluetoothSDK.swift +53 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +13 -0
- package/ios/Source/DeviceManager.swift +151 -33
- package/ios/Source/DeviceStore.swift +28 -14
- package/ios/Source/DisplayPacer.swift +180 -0
- package/ios/Source/GeneratedChangelogCatalog.swift +10 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +4 -4
- package/ios/Source/GlassesNotifications.swift +181 -0
- package/ios/Source/ObservableStore.swift +14 -2
- package/ios/Source/Resources/G2Firmware/README.md +27 -2
- package/ios/Source/Resources/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
- package/ios/Source/Resources/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
- package/ios/Source/controllers/R1.swift +0 -1
- package/ios/Source/internal/BluetoothAvailability.swift +31 -0
- package/ios/Source/sgcs/G2.swift +1434 -504
- package/ios/Source/sgcs/G2AncsRelay.swift +268 -0
- package/ios/Source/sgcs/G2BleLink.swift +102 -0
- package/ios/Source/sgcs/G2CfwClassification.swift +45 -27
- package/ios/Source/sgcs/G2CfwRenderer.swift +166 -33
- package/ios/Source/sgcs/G2CfwScene.swift +779 -0
- package/ios/Source/sgcs/G2CfwTextureCache.swift +244 -117
- package/ios/Source/sgcs/G2CfwTransport.swift +485 -0
- package/ios/Source/sgcs/G2DisplayDiagnostics.swift +154 -0
- package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
- package/ios/Source/sgcs/G2Flasher.swift +9 -9
- package/ios/Source/sgcs/G2Keyboard.swift +780 -0
- package/ios/Source/sgcs/G2KeyboardBridge.swift +438 -0
- package/ios/Source/sgcs/G2KeyboardHid.swift +290 -0
- package/ios/Source/sgcs/G2NotificationTuning.swift +24 -0
- package/ios/Source/sgcs/G2RingBattery.swift +22 -0
- package/ios/Source/sgcs/G2Sensors.swift +75 -0
- package/ios/Source/sgcs/G2TouchInput.swift +51 -0
- package/ios/Source/sgcs/G2WritePacing.swift +18 -0
- package/ios/Source/sgcs/MentraNex.swift +44 -44
- package/ios/Source/sgcs/SGCManager.swift +120 -13
- package/ios/Source/sgcs/{glassly_ble.pb.swift → nex_ble.pb.swift} +405 -465
- package/ios/Source/speakerid/SpeakerIdEngine.swift +2 -2
- package/ios/Source/status/DeviceStatus.swift +27 -0
- package/ios/Source/stt/SherpaOnnxTranscriber.swift +15 -14
- package/ios/Source/utils/Enums.swift +0 -1
- package/ios/Source/utils/G1Text.swift +0 -1
- package/ios/Source/utils/G2NotifyFile.swift +20 -10
- package/ios/Source/utils/JSCExperiment.swift +6 -6
- package/ios/Source/utils/Models.swift +0 -1
- package/ios/Tests/BluetoothSDKTests/BluetoothAvailabilityTests.swift +29 -0
- package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
- package/ios/Tests/BluetoothSDKTests/G2AncsRelayTests.swift +186 -0
- package/ios/Tests/BluetoothSDKTests/G2BleLinkTests.swift +52 -0
- package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +46 -51
- package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +164 -5
- package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +497 -0
- package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +108 -44
- package/ios/Tests/BluetoothSDKTests/G2CfwTransportTests.swift +259 -0
- package/ios/Tests/BluetoothSDKTests/G2DisplayDiagnosticsTests.swift +89 -0
- package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +3 -2
- package/ios/Tests/BluetoothSDKTests/G2ImageAckTests.swift +104 -0
- package/ios/Tests/BluetoothSDKTests/G2KeyboardTests.swift +274 -0
- package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +21 -5
- package/ios/Tests/BluetoothSDKTests/G2RingBatteryTests.swift +56 -0
- package/ios/Tests/BluetoothSDKTests/G2SensorsTests.swift +56 -0
- package/ios/Tests/BluetoothSDKTests/G2TouchInputTests.swift +71 -0
- package/ios/Tests/BluetoothSDKTests/G2WritePacingTests.swift +35 -0
- package/ios/Tests/BluetoothSDKTests/GlassesNotificationTests.swift +95 -0
- package/package.json +1 -1
- package/plugin/build/withAndroid.js +2 -2
- package/plugin/build/withIos.js +1 -1
- package/scripts/gen-nex-proto.sh +12 -0
- package/scripts/inject-ios-sdk-version.mjs +1 -1
- package/src/BluetoothSdk.types.ts +214 -19
- package/src/_internal.ts +1 -1
- package/src/_private/BluetoothSdkModule.ts +40 -0
- package/src/generated/changelogCatalog.ts +37 -1
- package/src/generated/releaseMetadata.ts +4 -4
- package/src/index.ts +24 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
package com.glassly.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import com.glassly.bluetoothsdk.GlassesStatus
|
|
4
|
+
import com.glassly.bluetoothsdk.GlassesStatusUpdate
|
|
5
|
+
import kotlinx.coroutines.CoroutineScope
|
|
6
|
+
import kotlinx.coroutines.runBlocking
|
|
7
|
+
import org.junit.Assert.assertEquals
|
|
8
|
+
import org.junit.Assert.assertFalse
|
|
9
|
+
import org.junit.Assert.assertNull
|
|
10
|
+
import org.junit.Assert.assertThrows
|
|
11
|
+
import org.junit.Assert.assertTrue
|
|
12
|
+
import org.junit.Test
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Pins the keyboard bridge wire format and HID decoding shared with g2flash's
|
|
16
|
+
* patches/keyboard_core.h and demos/keyboard.test.ts (same vectors).
|
|
17
|
+
*/
|
|
18
|
+
class G2KeyboardTest {
|
|
19
|
+
private val boot = bytes(
|
|
20
|
+
0x05, 1, 0x09, 6, 0xA1, 1, 0x05, 7, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0, 0x25, 1, 0x75, 1, 0x95, 8, 0x81, 2,
|
|
21
|
+
0x75, 8, 0x95, 1, 0x81, 1, 0x95, 6, 0x75, 8, 0x15, 0, 0x25, 0x65, 0x05, 7, 0x19, 0, 0x29, 0x65, 0x81, 0, 0xC0,
|
|
22
|
+
)
|
|
23
|
+
private val numbered = boot.copyOfRange(0, 6) + bytes(0x85, 1) + boot.copyOfRange(6, boot.size - 1) + bytes(
|
|
24
|
+
0x85, 2, 0x05, 0x0C, 0x15, 0, 0x25, 1, 0x75, 1, 0x95, 3, 0x09, 0xE9, 0x09, 0xEA, 0x09, 0xE2, 0x81, 2,
|
|
25
|
+
0x75, 5, 0x95, 1, 0x81, 1, 0xC0,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
|
|
29
|
+
private fun le16(v: Int) = G2KeyboardProtocol.le16(v)
|
|
30
|
+
private fun le32(v: Long) = bytes((v and 0xFF).toInt(), ((v shr 8) and 0xFF).toInt(), ((v shr 16) and 0xFF).toInt(), ((v shr 24) and 0xFF).toInt())
|
|
31
|
+
|
|
32
|
+
private fun frame(
|
|
33
|
+
kind: Int, seq: Long, epoch: Int, request: Int, code: Int, handle: Int, data: ByteArray,
|
|
34
|
+
offset: Int = 0, total: Int = data.size,
|
|
35
|
+
): ByteArray =
|
|
36
|
+
bytes(75, 66, 1, kind) + le32(seq) + le16(epoch) + le16(request) + le16(code) + le16(handle) +
|
|
37
|
+
le16(total) + le16(offset) + le32(1234) + data
|
|
38
|
+
|
|
39
|
+
private fun usages(keys: List<G2KeyboardHid.Usage>) = keys.map { listOf(it.page, it.usage) }
|
|
40
|
+
private fun events(keys: List<G2KeyboardHid.KeyEvent>) = keys.map { listOf(it.page, it.usage, if (it.down) 1 else 0) }
|
|
41
|
+
|
|
42
|
+
@Test
|
|
43
|
+
fun bootArrayModifiersReleaseRepeatsAndRollover() {
|
|
44
|
+
val r = G2KeyboardHid.parseReportMap(boot).getValue(0)
|
|
45
|
+
val keys = G2KeyboardHid.HeldKeys()
|
|
46
|
+
assertEquals(listOf(listOf(7, 0xE1, 1), listOf(7, 4, 1)), events(keys.update(12, r, bytes(2, 0, 4, 0, 0, 0, 0, 0))))
|
|
47
|
+
assertEquals(emptyList<List<Int>>(), events(keys.update(12, r, bytes(2, 0, 4, 0, 0, 0, 0, 0))))
|
|
48
|
+
val rollover = assertThrows(IllegalArgumentException::class.java) { keys.update(12, r, bytes(0, 0, 1, 1, 1, 1, 1, 1)) }
|
|
49
|
+
assertTrue(rollover.message!!.contains("rollover"))
|
|
50
|
+
assertEquals(listOf(false, false), keys.reset().map { it.down })
|
|
51
|
+
val length = assertThrows(IllegalArgumentException::class.java) { G2KeyboardHid.reportKeys(r, bytes(0)) }
|
|
52
|
+
assertTrue(length.message!!.contains("length"))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Test
|
|
56
|
+
fun hogpReportIdsAreOutOfBandWithConsumerKeys() {
|
|
57
|
+
val maps = G2KeyboardHid.parseReportMap(numbered)
|
|
58
|
+
assertEquals(listOf(listOf(7, 5)), usages(G2KeyboardHid.reportKeys(maps.getValue(1), bytes(0, 0, 5, 0, 0, 0, 0, 0))))
|
|
59
|
+
assertEquals(listOf(listOf(12, 0xE9), listOf(12, 0xE2)), usages(G2KeyboardHid.reportKeys(maps.getValue(2), bytes(5))))
|
|
60
|
+
assertThrows(IllegalArgumentException::class.java) { G2KeyboardHid.reportKeys(maps.getValue(2), bytes(2, 5)) }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@Test
|
|
64
|
+
fun nkroBitmapWithUnalignedFieldsAndReportUnion() {
|
|
65
|
+
val r = G2KeyboardHid.parseReportMap(bytes(5, 7, 0x19, 4, 0x29, 19, 0x15, 0, 0x25, 1, 0x75, 1, 0x95, 16, 0x81, 2)).getValue(0)
|
|
66
|
+
assertEquals(listOf(listOf(7, 4), listOf(7, 11), listOf(7, 19)), usages(G2KeyboardHid.reportKeys(r, bytes(0x81, 0x80))))
|
|
67
|
+
val keys = G2KeyboardHid.HeldKeys()
|
|
68
|
+
assertEquals(1, keys.update(10, r, bytes(1, 0)).size)
|
|
69
|
+
assertEquals(emptyList<G2KeyboardHid.KeyEvent>(), keys.update(11, r, bytes(1, 0)))
|
|
70
|
+
assertEquals(emptyList<G2KeyboardHid.KeyEvent>(), keys.update(10, r, bytes(0, 0)))
|
|
71
|
+
assertEquals(listOf(listOf(7, 4, 0)), events(keys.update(11, r, bytes(0, 0))))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@Test
|
|
75
|
+
fun arrayValuesIndexUsagesRelativeToLogicalMinimum() {
|
|
76
|
+
val r = G2KeyboardHid.parseReportMap(bytes(5, 7, 0x19, 4, 0x29, 6, 0x15, 1, 0x25, 3, 0x75, 8, 0x95, 1, 0x81, 0)).getValue(0)
|
|
77
|
+
assertEquals(listOf(listOf(7, 5)), usages(G2KeyboardHid.reportKeys(r, bytes(2))))
|
|
78
|
+
assertEquals(emptyList<G2KeyboardHid.Usage>(), G2KeyboardHid.reportKeys(r, bytes(0)))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@Test
|
|
82
|
+
fun descriptorBoundsAndMalformedItems() {
|
|
83
|
+
val malformed = listOf(
|
|
84
|
+
bytes(), ByteArray(513), bytes(0xFE), bytes(0x75), bytes(0xB4), bytes(0xC0), bytes(0x85, 0),
|
|
85
|
+
bytes(0x75, 33, 0x95, 1, 0x81, 2), bytes(0x75, 32, 0x96, 0, 2, 0x81, 2), bytes(0xA1, 1),
|
|
86
|
+
)
|
|
87
|
+
for (data in malformed) assertThrows(IllegalArgumentException::class.java) { G2KeyboardHid.parseReportMap(data) }
|
|
88
|
+
// Deterministic malformed corpus; parsing must be bounded and never hang.
|
|
89
|
+
var seed = 1L
|
|
90
|
+
for (i in 0 until 1000) {
|
|
91
|
+
val data = ByteArray(i % 128 + 1) {
|
|
92
|
+
seed = (seed * 1664525L + 1013904223L) and 0xFFFF_FFFFL
|
|
93
|
+
(seed shr 24).toByte()
|
|
94
|
+
}
|
|
95
|
+
runCatching { G2KeyboardHid.parseReportMap(data) }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@Test
|
|
100
|
+
fun wireControlHasTheDedicatedFieldAndEpoch() {
|
|
101
|
+
val packet = G2KeyboardProtocol.controlPacket(G2KeyboardProtocol.Op.CONNECT, 0x1234, 0x5678, bytes(1, 2, 3, 4, 5, 6, 7), magic = 5)
|
|
102
|
+
assertEquals(
|
|
103
|
+
listOf(8, 2, 16, 5, 0x22, 2, 8, 1, 0x92, 8, 15, 75, 66, 1, 4, 0x34, 0x12, 0x78, 0x56, 1, 2, 3, 4, 5, 6, 7),
|
|
104
|
+
packet.map { it.toInt() and 0xFF },
|
|
105
|
+
)
|
|
106
|
+
// Magic ids above 127 take two varint bytes.
|
|
107
|
+
val query = G2KeyboardProtocol.controlPacket(G2KeyboardProtocol.Op.QUERY, 1, 0, magic = 200)
|
|
108
|
+
assertEquals(listOf(8, 2, 16, 0xC8, 0x01), query.take(5).map { it.toInt() and 0xFF })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@Test
|
|
112
|
+
fun fragmentsDuplicatesGapsEpochsAndSequenceWrap() {
|
|
113
|
+
val stream = G2KeyboardProtocol.RecordAssembler()
|
|
114
|
+
val data = ByteArray(200) { it.toByte() }
|
|
115
|
+
assertNull(stream.push(frame(3, 0xFFFF_FFFFL, 1, 0, 13, 33, data.copyOfRange(0, 96), 0, 200)))
|
|
116
|
+
assertNull(stream.push(frame(3, 0xFFFF_FFFFL, 1, 0, 13, 33, data.copyOfRange(96, 192), 96, 200)))
|
|
117
|
+
assertTrue(data.contentEquals(stream.push(frame(3, 0xFFFF_FFFFL, 1, 0, 13, 33, data.copyOfRange(192, 200), 192, 200))!!.data))
|
|
118
|
+
val status = frame(1, 0, 1, 0, 0, 0, ByteArray(10))
|
|
119
|
+
assertEquals(0L, stream.push(status)!!.sequence)
|
|
120
|
+
assertNull(stream.push(status))
|
|
121
|
+
assertEquals(1, stream.gaps)
|
|
122
|
+
stream.push(frame(1, 2, 1, 0, 0, 0, ByteArray(10)))
|
|
123
|
+
assertEquals(2, stream.gaps)
|
|
124
|
+
assertNull(stream.push(status))
|
|
125
|
+
assertEquals(2, stream.gaps)
|
|
126
|
+
stream.push(frame(1, 3, 2, 0, 0, 0, ByteArray(10)))
|
|
127
|
+
assertEquals(3, stream.gaps)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@Test
|
|
131
|
+
fun mismatchedFragmentsNeverEmitPartialInput() {
|
|
132
|
+
val stream = G2KeyboardProtocol.RecordAssembler()
|
|
133
|
+
stream.push(frame(3, 1, 1, 0, 13, 10, ByteArray(96), 0, 100))
|
|
134
|
+
assertNull(stream.push(frame(3, 1, 1, 0, 13, 11, ByteArray(4), 96, 100)))
|
|
135
|
+
assertEquals(2, stream.gaps)
|
|
136
|
+
assertNull(stream.push(frame(3, 1, 1, 0, 13, 10, ByteArray(4), 96, 100)))
|
|
137
|
+
assertNull(stream.push(ByteArray(513)))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@Test
|
|
141
|
+
fun advertParsingNamesHidDevicesAndReversesAddresses() {
|
|
142
|
+
val body = bytes(1, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0xC4, 0, 12, 3, 3, 0x12, 0x18, 5, 9, 0x4B, 0x65, 0x79, 0x73)
|
|
143
|
+
val advert = G2KeyboardProtocol.Advert.parse(body)!!
|
|
144
|
+
assertEquals("aa:bb:cc:dd:ee:ff", advert.addressText)
|
|
145
|
+
assertEquals(-60, advert.rssi)
|
|
146
|
+
assertTrue(advert.hid)
|
|
147
|
+
assertEquals("Keys", advert.name)
|
|
148
|
+
assertEquals(advert.address, G2KeyboardProtocol.addressFromText(advert.addressText)!!.map { it.toInt() and 0xFF })
|
|
149
|
+
assertNull(G2KeyboardProtocol.addressFromText("aa:bb"))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
@Test
|
|
153
|
+
fun advertAppearanceMarksHidAndScanResponseMerges() {
|
|
154
|
+
// ADV_IND with only a keyboard appearance (0x03C1), no 0x1812 service list.
|
|
155
|
+
val first = G2KeyboardProtocol.Advert.parse(bytes(1, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0xC4, 0, 4, 3, 0x19, 0xC1, 0x03))!!
|
|
156
|
+
assertTrue(first.hid)
|
|
157
|
+
assertEquals("", first.name)
|
|
158
|
+
// SCAN_RSP for the same address carries the name and a better RSSI.
|
|
159
|
+
val second = G2KeyboardProtocol.Advert.parse(bytes(1, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0xCE, 4, 6, 5, 9, 0x4B, 0x65, 0x79, 0x73))!!
|
|
160
|
+
assertFalse(second.hid)
|
|
161
|
+
val merged = first.merging(second)
|
|
162
|
+
assertEquals("Keys", merged.name)
|
|
163
|
+
assertTrue(merged.hid)
|
|
164
|
+
assertEquals(-50, merged.rssi)
|
|
165
|
+
assertEquals(first.address, merged.address)
|
|
166
|
+
// A non-HID appearance (generic phone) does not qualify.
|
|
167
|
+
assertFalse(G2KeyboardProtocol.Advert.parse(bytes(1, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0xC4, 0, 4, 3, 0x19, 0x40, 0x00))!!.hid)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@Test
|
|
171
|
+
fun keyboardStatusSurvivesSnapshotAndPartialStatusBridge() {
|
|
172
|
+
val values = mapOf<String, Any>(
|
|
173
|
+
"keyboardBridge" to true, "keyboardConnected" to true, "keyboardName" to "Bluetooth Keyboard",
|
|
174
|
+
"keyboardSaved" to true, "keyboardState" to "connected", "keyboardError" to "",
|
|
175
|
+
)
|
|
176
|
+
val snapshot = GlassesStatus.fromMap(values).toMap()
|
|
177
|
+
for ((key, value) in values) assertEquals(value, snapshot[key])
|
|
178
|
+
assertEquals(values, GlassesStatusUpdate.fromMap(values).toMap())
|
|
179
|
+
assertEquals("idle", GlassesStatus.fromMap(emptyMap()).keyboardState)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@Test
|
|
183
|
+
fun fullSimulatedHogpDiscoveryTypingAndLossRecovery() = runBlocking {
|
|
184
|
+
class Attr(val uuid: Int, val value: ByteArray)
|
|
185
|
+
val attrs = LinkedHashMap<Int, Attr>()
|
|
186
|
+
fun add(h: Int, uuid: Int, value: ByteArray) { attrs[h] = Attr(uuid, value) }
|
|
187
|
+
val mockMap = numbered.copyOfRange(0, numbered.size - 1) + bytes(0x85, 3, 0x05, 6, 0x09, 0x20, 0x15, 0, 0x25, 100, 0x75, 8, 0x95, 1, 0x81, 2, 0xC0)
|
|
188
|
+
add(1, 0x2800, bytes(1, 0x18)); add(2, 0x2803, bytes(0x20, 3, 0, 5, 0x2A)); add(3, 0x2A05, bytes()); add(4, 0x2902, bytes(0, 0))
|
|
189
|
+
add(20, 0x2800, bytes(0x12, 0x18))
|
|
190
|
+
add(21, 0x2803, bytes(2, 22, 0, 0x4B, 0x2A)); add(22, 0x2A4B, mockMap); add(23, 0x2907, bytes(0x19, 0x2A))
|
|
191
|
+
add(24, 0x2803, bytes(6, 25, 0, 0x4E, 0x2A)); add(25, 0x2A4E, bytes(1))
|
|
192
|
+
add(26, 0x2803, bytes(0x12, 27, 0, 0x4D, 0x2A)); add(27, 0x2A4D, bytes()); add(28, 0x2902, bytes(0, 0)); add(29, 0x2908, bytes(1, 1))
|
|
193
|
+
add(30, 0x2803, bytes(0x12, 31, 0, 0x4D, 0x2A)); add(31, 0x2A4D, bytes()); add(32, 0x2902, bytes(0, 0)); add(33, 0x2908, bytes(2, 1))
|
|
194
|
+
add(40, 0x2800, bytes(0x0F, 0x18)); add(41, 0x2803, bytes(0x12, 42, 0, 0x19, 0x2A)); add(42, 0x2A19, bytes(100)); add(43, 0x2902, bytes(0, 0)); add(44, 0x2908, bytes(3, 1))
|
|
195
|
+
|
|
196
|
+
var seq = 0L
|
|
197
|
+
var epoch = 0
|
|
198
|
+
val writes = ArrayList<Int>()
|
|
199
|
+
var watches = emptyList<Int>()
|
|
200
|
+
val typed = ArrayList<List<Int>>()
|
|
201
|
+
lateinit var client: G2KeyboardClient
|
|
202
|
+
fun emit(kind: Int, request: Int, code: Int, handle: Int, data: ByteArray) {
|
|
203
|
+
seq += 1
|
|
204
|
+
client.accept(frame(kind, seq, epoch, request, code, handle, data))
|
|
205
|
+
}
|
|
206
|
+
val scope = CoroutineScope(coroutineContext)
|
|
207
|
+
client = G2KeyboardClient(scope, { pb, _ ->
|
|
208
|
+
// Field 130 (tag 0x92 0x08) follows a magic varint of one or two bytes.
|
|
209
|
+
val tag = (0 until pb.size - 1).first { pb[it] == 0x92.toByte() && pb[it + 1] == 0x08.toByte() }
|
|
210
|
+
val body = pb.copyOfRange(tag + 3, pb.size)
|
|
211
|
+
val op = body[3].toInt()
|
|
212
|
+
val req = G2KeyboardProtocol.u16(body, 4)
|
|
213
|
+
val h = if (body.size >= 10) G2KeyboardProtocol.u16(body, 8) else 0
|
|
214
|
+
when (op) {
|
|
215
|
+
1 -> emit(1, req, 0, 0, bytes(1, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
|
216
|
+
4 -> { epoch += 1; emit(1, req, 0, 0, bytes(1, 3, 1, 1, 0, 0, 0, 0, 0, 0)) }
|
|
217
|
+
9 -> {
|
|
218
|
+
val batch = attrs.keys.sorted().filter { it >= h }.take(4)
|
|
219
|
+
val payload = if (batch.isEmpty()) bytes() else bytes(1) + batch.fold(ByteArray(0)) { acc, handle -> acc + le16(handle) + le16(attrs.getValue(handle).uuid) }
|
|
220
|
+
emit(2, req, if (batch.isEmpty()) (10 shl 8) or 2 else 2, h, payload)
|
|
221
|
+
}
|
|
222
|
+
10 -> {
|
|
223
|
+
val data = attrs.getValue(h).value
|
|
224
|
+
val offset = G2KeyboardProtocol.u16(body, 10)
|
|
225
|
+
val status = if (offset >= data.size) (7 shl 8) or 6 else if (offset > 0) 6 else 5
|
|
226
|
+
emit(2, req, status, h, data.copyOfRange(minOf(offset, data.size), minOf(offset + 20, data.size)))
|
|
227
|
+
}
|
|
228
|
+
11 -> { writes.add(h); emit(2, req, if (body[10].toInt() != 0) 10 else 9, h, bytes()) }
|
|
229
|
+
12 -> {
|
|
230
|
+
val count = body[8].toInt()
|
|
231
|
+
watches = (0 until count).map { G2KeyboardProtocol.u16(body, 9 + 2 * it) }
|
|
232
|
+
emit(1, req, 0, 0, bytes(1, 3, 1, 1, 0, 0, 0, 0, 0, 0))
|
|
233
|
+
}
|
|
234
|
+
else -> throw AssertionError("Unexpected op $op")
|
|
235
|
+
}
|
|
236
|
+
}) { event ->
|
|
237
|
+
if (event is G2KeyboardClient.Event.Keys) typed.addAll(event.events.map { listOf(it.usage, if (it.down) 1 else 0) })
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
client.enable()
|
|
241
|
+
client.connect(1, ByteArray(6))
|
|
242
|
+
val config = client.configure()
|
|
243
|
+
assertEquals(listOf(27, 31, 42, 3), watches)
|
|
244
|
+
assertEquals(listOf(25, 4, 28, 32, 43), writes)
|
|
245
|
+
assertEquals(listOf(1, 2, 3), config.reports.map { it.id })
|
|
246
|
+
|
|
247
|
+
emit(3, 0, 13, 27, bytes(0, 0, 4, 0, 0, 0, 0, 0))
|
|
248
|
+
emit(3, 0, 13, 31, bytes(1))
|
|
249
|
+
assertEquals(listOf(listOf(4, 1), listOf(0xE9, 1)), typed)
|
|
250
|
+
seq += 1 // release lost in the firmware queue or phone notification path
|
|
251
|
+
emit(1, 0, 0, 0, bytes(1, 3, 1, 1, 0, 0, 0, 0, 0, 0))
|
|
252
|
+
assertEquals(listOf(0, 0), typed.takeLast(2).map { it[1] })
|
|
253
|
+
|
|
254
|
+
val restored = G2KeyboardClient(scope, { _, _ -> }) { event ->
|
|
255
|
+
if (event is G2KeyboardClient.Event.Keys) typed.addAll(event.events.map { listOf(it.usage, if (it.down) 1 else 0) })
|
|
256
|
+
}
|
|
257
|
+
restored.restore(config)
|
|
258
|
+
seq += 1
|
|
259
|
+
restored.accept(frame(3, seq, epoch, 0, 13, 27, bytes(0, 0, 5, 0, 0, 0, 0, 0)))
|
|
260
|
+
assertEquals(listOf(5, 1), typed.last())
|
|
261
|
+
restored.lostPhoneLink()
|
|
262
|
+
assertEquals(listOf(5, 0), typed.last())
|
|
263
|
+
|
|
264
|
+
emit(3, 0, 14, 3, bytes(1, 0, 255, 255)) // Service Changed invalidates the mapping
|
|
265
|
+
val count = typed.size
|
|
266
|
+
emit(3, 0, 13, 27, bytes(0, 0, 6, 0, 0, 0, 0, 0))
|
|
267
|
+
assertEquals(count, typed.size)
|
|
268
|
+
client.close()
|
|
269
|
+
restored.close()
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
package com.glassly.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import com.glassly.bluetoothsdk.AllowedApp
|
|
4
|
+
import com.glassly.bluetoothsdk.PhoneNotificationUpload
|
|
5
|
+
import org.junit.Assert.assertEquals
|
|
6
|
+
import org.junit.Assert.assertFalse
|
|
7
|
+
import org.junit.Assert.assertNotEquals
|
|
8
|
+
import org.junit.Assert.assertTrue
|
|
9
|
+
import org.junit.Test
|
|
10
|
+
|
|
11
|
+
class G2NotificationFileTest {
|
|
12
|
+
private fun readLittleEndian(bytes: ByteArray, offset: Int): Long {
|
|
13
|
+
var value = 0L
|
|
14
|
+
for (i in 0 until 4) {
|
|
15
|
+
value = value or ((bytes[offset + i].toLong() and 0xFF) shl (8 * i))
|
|
16
|
+
}
|
|
17
|
+
return value
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@Test
|
|
21
|
+
fun `header is 93 bytes with a command byte then file type and size`() {
|
|
22
|
+
val content = "hello glasses".toByteArray(Charsets.UTF_8)
|
|
23
|
+
val header =
|
|
24
|
+
G2NotificationFile.fileCheckHeader(
|
|
25
|
+
G2NotificationFile.FILENAME,
|
|
26
|
+
content,
|
|
27
|
+
G2NotificationFile.FILE_TYPE_NOTIFICATION
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
assertEquals(93, header.size)
|
|
31
|
+
assertEquals(0, header[0].toInt()) // SEND_START
|
|
32
|
+
assertEquals(1L, readLittleEndian(header, 1)) // ANDROID_MSG_JSON_NOTIFICATION
|
|
33
|
+
assertEquals(content.size.toLong(), readLittleEndian(header, 5))
|
|
34
|
+
// The docs' shifted view of the same bytes.
|
|
35
|
+
assertEquals(0x100L, readLittleEndian(header, 0))
|
|
36
|
+
assertEquals((content.size * 256).toLong(), readLittleEndian(header, 4))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Test
|
|
40
|
+
fun `whitelist header carries file type zero`() {
|
|
41
|
+
val header =
|
|
42
|
+
G2NotificationFile.fileCheckHeader(
|
|
43
|
+
G2NotificationFile.FILENAME,
|
|
44
|
+
"{}".toByteArray(Charsets.UTF_8),
|
|
45
|
+
G2NotificationFile.FILE_TYPE_WHITELIST
|
|
46
|
+
)
|
|
47
|
+
assertEquals(0L, readLittleEndian(header, 1))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Test
|
|
51
|
+
fun `header checksum and extra reconstruct the crc`() {
|
|
52
|
+
val content = "the quick brown fox".toByteArray(Charsets.UTF_8)
|
|
53
|
+
val header =
|
|
54
|
+
G2NotificationFile.fileCheckHeader(
|
|
55
|
+
G2NotificationFile.FILENAME,
|
|
56
|
+
content,
|
|
57
|
+
G2NotificationFile.FILE_TYPE_NOTIFICATION
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
val checksum = readLittleEndian(header, 8).toInt()
|
|
61
|
+
val extra = header[12].toInt() and 0xFF
|
|
62
|
+
val reconstructed = (extra shl 24) or (checksum ushr 8)
|
|
63
|
+
|
|
64
|
+
assertEquals(G2NotificationFile.crc32c(content), reconstructed)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@Test
|
|
68
|
+
fun `header carries a nul-padded filename`() {
|
|
69
|
+
val header =
|
|
70
|
+
G2NotificationFile.fileCheckHeader(
|
|
71
|
+
G2NotificationFile.FILENAME,
|
|
72
|
+
ByteArray(0),
|
|
73
|
+
G2NotificationFile.FILE_TYPE_WHITELIST
|
|
74
|
+
)
|
|
75
|
+
val nameBytes = G2NotificationFile.FILENAME.toByteArray(Charsets.UTF_8)
|
|
76
|
+
|
|
77
|
+
assertEquals(
|
|
78
|
+
G2NotificationFile.FILENAME,
|
|
79
|
+
String(header, 13, nameBytes.size, Charsets.UTF_8)
|
|
80
|
+
)
|
|
81
|
+
for (i in (13 + nameBytes.size) until 93) {
|
|
82
|
+
assertEquals(0, header[i].toInt())
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@Test
|
|
87
|
+
fun `crc is deterministic and content sensitive`() {
|
|
88
|
+
val a = "alpha".toByteArray(Charsets.UTF_8)
|
|
89
|
+
val b = "alphb".toByteArray(Charsets.UTF_8)
|
|
90
|
+
|
|
91
|
+
assertEquals(G2NotificationFile.crc32c(a), G2NotificationFile.crc32c(a))
|
|
92
|
+
assertNotEquals(G2NotificationFile.crc32c(a), G2NotificationFile.crc32c(b))
|
|
93
|
+
assertEquals(0, G2NotificationFile.crc32c(ByteArray(0)))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@Test
|
|
97
|
+
fun `whitelist drops over-long ids and truncates names`() {
|
|
98
|
+
val longId = "c".repeat(G2NotificationFile.MAX_IDENTIFIER_BYTES + 1)
|
|
99
|
+
val json =
|
|
100
|
+
String(
|
|
101
|
+
G2NotificationFile.whitelistJson(
|
|
102
|
+
filterEnabled = true,
|
|
103
|
+
apps =
|
|
104
|
+
listOf(
|
|
105
|
+
AllowedApp("com.example.mail", "A Very Long Application Name"),
|
|
106
|
+
AllowedApp(longId, "Dropped")
|
|
107
|
+
)
|
|
108
|
+
),
|
|
109
|
+
Charsets.UTF_8
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
assertTrue(json.contains("\"enable\":true"))
|
|
113
|
+
assertTrue(json.contains("\"id\":\"com.example.mail\""))
|
|
114
|
+
assertFalse(json.contains(longId))
|
|
115
|
+
assertTrue(json.contains("\"name\":\"A Very Long App\""))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@Test
|
|
119
|
+
fun `whitelist caps the list and is deterministic`() {
|
|
120
|
+
val apps = (0 until G2NotificationFile.MAX_APPS + 10).map { AllowedApp("app.$it", "App $it") }
|
|
121
|
+
val first = G2NotificationFile.whitelistJson(false, apps)
|
|
122
|
+
val second = G2NotificationFile.whitelistJson(false, apps)
|
|
123
|
+
|
|
124
|
+
assertTrue(first.contentEquals(second))
|
|
125
|
+
assertEquals(
|
|
126
|
+
G2NotificationFile.MAX_APPS,
|
|
127
|
+
String(first, Charsets.UTF_8).split("\"id\":").size - 1
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@Test
|
|
132
|
+
fun `notification json carries every field under the android root`() {
|
|
133
|
+
val upload =
|
|
134
|
+
PhoneNotificationUpload(
|
|
135
|
+
notificationId = "n-1",
|
|
136
|
+
packageName = "com.example.chat",
|
|
137
|
+
appName = "Chat",
|
|
138
|
+
title = " Ada ",
|
|
139
|
+
subtitle = "Re: build",
|
|
140
|
+
body = "the lenses are fine",
|
|
141
|
+
timestampMs = 1_700_000_000_000L
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
val json = String(G2NotificationFile.notificationJson(upload, 4242, 0L), Charsets.UTF_8)
|
|
145
|
+
|
|
146
|
+
assertTrue(json.startsWith("{\"android_notification\":{"))
|
|
147
|
+
assertTrue(json.contains("\"action\":0"))
|
|
148
|
+
assertTrue(json.contains("\"app_identifier\":\"com.example.chat\""))
|
|
149
|
+
assertTrue(json.contains("\"display_name\":\"Chat\""))
|
|
150
|
+
assertTrue(json.contains("\"msg_id\":4242"))
|
|
151
|
+
assertTrue(json.contains("\"time_s\":1700000000"))
|
|
152
|
+
assertTrue(json.contains("\"title\":\"Ada\""))
|
|
153
|
+
assertTrue(json.contains("\"subtitle\":\"Re: build\""))
|
|
154
|
+
assertTrue(json.contains("\"message\":\"the lenses are fine\""))
|
|
155
|
+
assertTrue(Regex("\"date\":\"\\d{8}T\\d{6}\"").containsMatchIn(json))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@Test
|
|
159
|
+
fun `notification json caps title and message lengths`() {
|
|
160
|
+
val upload =
|
|
161
|
+
PhoneNotificationUpload(
|
|
162
|
+
notificationId = "n-2",
|
|
163
|
+
packageName = "com.example.chat",
|
|
164
|
+
appName = "Chat",
|
|
165
|
+
title = "t".repeat(400),
|
|
166
|
+
subtitle = "",
|
|
167
|
+
body = "b".repeat(2000),
|
|
168
|
+
timestampMs = 1_700_000_000_000L
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
val json = String(G2NotificationFile.notificationJson(upload, 1, 0L), Charsets.UTF_8)
|
|
172
|
+
|
|
173
|
+
assertTrue(json.contains("\"title\":\"" + "t".repeat(G2NotificationFile.MAX_TITLE_BYTES) + "\""))
|
|
174
|
+
assertTrue(json.contains("\"message\":\"" + "b".repeat(G2NotificationFile.MAX_MESSAGE_BYTES) + "\""))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
@Test
|
|
178
|
+
fun `truncation never splits a code point`() {
|
|
179
|
+
// Each emoji is 4 UTF-8 bytes, so a 6-byte budget keeps exactly one.
|
|
180
|
+
val truncated = G2NotificationFile.truncateUtf8("😀😀", 6)
|
|
181
|
+
|
|
182
|
+
assertEquals("😀", truncated)
|
|
183
|
+
assertEquals(4, truncated.toByteArray(Charsets.UTF_8).size)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@Test
|
|
187
|
+
fun `json strings escape quotes and control characters`() {
|
|
188
|
+
val upload =
|
|
189
|
+
PhoneNotificationUpload(
|
|
190
|
+
notificationId = "n-3",
|
|
191
|
+
packageName = "com.example.chat",
|
|
192
|
+
appName = "Chat",
|
|
193
|
+
title = "say \"hi\"",
|
|
194
|
+
subtitle = "",
|
|
195
|
+
body = "line\none",
|
|
196
|
+
timestampMs = 1_700_000_000_000L
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
val json = String(G2NotificationFile.notificationJson(upload, 1, 0L), Charsets.UTF_8)
|
|
200
|
+
|
|
201
|
+
assertTrue(json.contains("\\\"hi\\\""))
|
|
202
|
+
assertTrue(json.contains("line\\none"))
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
package com.glassly.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import com.glassly.bluetoothsdk.GlassesStatus
|
|
4
|
+
import com.glassly.bluetoothsdk.GlassesStatusUpdate
|
|
5
|
+
import org.junit.Assert.assertEquals
|
|
6
|
+
import org.junit.Assert.assertNull
|
|
7
|
+
import org.junit.Test
|
|
8
|
+
|
|
9
|
+
class G2RingBatteryTest {
|
|
10
|
+
private fun decode(vararg bytes: Int) = G2RingBattery.decode(bytes.map { it.toByte() }.toByteArray())
|
|
11
|
+
|
|
12
|
+
@Test
|
|
13
|
+
fun connectedBatteryIncludingEmptyFullAndCharging() {
|
|
14
|
+
for (level in listOf(0, 62, 100)) {
|
|
15
|
+
assertEquals(G2RingBattery(true, level, false), decode(0x52, 0x42, 1, 3, level))
|
|
16
|
+
assertEquals(G2RingBattery(true, level, true), decode(0x52, 0x42, 1, 7, level))
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@Test
|
|
21
|
+
fun disconnectedAndUnknownReportsClearCachedBattery() {
|
|
22
|
+
assertEquals(G2RingBattery.DISCONNECTED, decode(0x52, 0x42, 1, 0, 255))
|
|
23
|
+
assertEquals(G2RingBattery.DISCONNECTED, decode(0x52, 0x42, 1, 6, 62))
|
|
24
|
+
assertEquals(G2RingBattery(true, -1, false), decode(0x52, 0x42, 1, 1, 255))
|
|
25
|
+
assertEquals(G2RingBattery(true, -1, false), decode(0x52, 0x42, 1, 5, 62))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Test
|
|
29
|
+
fun batterySurvivesSnapshotAndPartialStatusBridge() {
|
|
30
|
+
val battery = decode(0x52, 0x42, 1, 7, 62)!!
|
|
31
|
+
val values = mapOf<String, Any>(
|
|
32
|
+
"ringConnected" to battery.connected,
|
|
33
|
+
"ringBatteryLevel" to battery.level,
|
|
34
|
+
"ringCharging" to battery.charging,
|
|
35
|
+
)
|
|
36
|
+
val snapshot = GlassesStatus.fromMap(values).toMap()
|
|
37
|
+
for ((key, value) in values) assertEquals(value, snapshot[key])
|
|
38
|
+
assertEquals(false, snapshot["controllerConnected"])
|
|
39
|
+
assertEquals(-1, snapshot["controllerBatteryLevel"])
|
|
40
|
+
assertEquals(values, GlassesStatusUpdate.fromMap(values).toMap())
|
|
41
|
+
assertEquals(emptyMap<String, Any>(), GlassesStatusUpdate.fromMap(emptyMap()).toMap())
|
|
42
|
+
assertEquals(-1, GlassesStatus.fromMap(emptyMap()).ringBatteryLevel)
|
|
43
|
+
val cleared = mapOf<String, Any>("ringConnected" to false, "ringBatteryLevel" to -1, "ringCharging" to false)
|
|
44
|
+
assertEquals(cleared, GlassesStatusUpdate.fromMap(cleared).toMap())
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@Test
|
|
48
|
+
fun malformedReportsAreIgnored() {
|
|
49
|
+
val malformed = listOf(
|
|
50
|
+
intArrayOf(), intArrayOf(0x52, 0x42, 1, 3), intArrayOf(0x52, 0x42, 1, 3, 62, 0),
|
|
51
|
+
intArrayOf(0x41, 0x42, 1, 3, 62), intArrayOf(0x52, 0x41, 1, 3, 62),
|
|
52
|
+
intArrayOf(0x52, 0x42, 2, 3, 62), intArrayOf(0x52, 0x42, 1, 3, 101), intArrayOf(0x52, 0x42, 1, 3, 255),
|
|
53
|
+
)
|
|
54
|
+
for (bytes in malformed) assertNull(decode(*bytes))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
package com.glassly.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.*
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
class G2SensorsTest {
|
|
7
|
+
@Test fun northMayOmitTheProtobufScalarButMalformedHeadingsAreRejected() {
|
|
8
|
+
assertEquals(0, G2Sensors.heading(emptyMap()))
|
|
9
|
+
assertEquals(0, G2Sensors.heading(mapOf(1 to 0)))
|
|
10
|
+
assertEquals(359, G2Sensors.heading(mapOf(1 to 359)))
|
|
11
|
+
assertNull(G2Sensors.heading(mapOf(1 to 360)))
|
|
12
|
+
assertNull(G2Sensors.heading(mapOf(1 to byteArrayOf(0))))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@Test fun commandsMatchUpstreamModesAndLittleEndianOptions() {
|
|
16
|
+
assertArrayEquals(byteArrayOf(16, 0), G2Sensors.command("light", "query", emptyMap()))
|
|
17
|
+
assertArrayEquals(byteArrayOf(16, 1, 1, 244.toByte(), 1, 1, 0, 136.toByte(), 19), G2Sensors.command("light", "start", emptyMap()))
|
|
18
|
+
assertArrayEquals(byteArrayOf(10, 2, 250.toByte(), 0, 0, 0), G2Sensors.command("compass", "start", mapOf("intervalMs" to 250.0, "minChangeDegrees" to 0.0)))
|
|
19
|
+
assertArrayEquals(byteArrayOf(16, 2), G2Sensors.command("light", "stop", emptyMap()))
|
|
20
|
+
assertArrayEquals(byteArrayOf(10, 0), G2Sensors.command("compass", "stop", emptyMap()))
|
|
21
|
+
assertNull(G2Sensors.command("light", "start", mapOf("intervalMs" to 99.0)))
|
|
22
|
+
assertNull(G2Sensors.command("compass", "start", mapOf("intervalMs" to Double.NaN)))
|
|
23
|
+
assertNull(G2Sensors.command("compass", "start", mapOf("minChangeDegrees" to 0.5)))
|
|
24
|
+
}
|
|
25
|
+
@Test fun lightReportPreservesRawValuesAndFreshness() {
|
|
26
|
+
val bytes = listOf(65,76,1,2,15,3,210,4,0,0,46,22,0,0,250,0,9,3,0,4,66,0,0,240).map { it.toByte() }.toByteArray()
|
|
27
|
+
val report = G2Sensors.light(bytes)!!
|
|
28
|
+
assertEquals("poll", report["reason"])
|
|
29
|
+
assertEquals(1234L, report["value"])
|
|
30
|
+
assertEquals(5678L, report["peak"])
|
|
31
|
+
assertEquals(4026531906L, report["sampleTimestampMs"])
|
|
32
|
+
assertEquals(true, report["readSucceeded"])
|
|
33
|
+
assertEquals(true, report["autoBrightness"])
|
|
34
|
+
assertEquals(true, report["passive"])
|
|
35
|
+
assertNull(G2Sensors.light(bytes.copyOf(23)))
|
|
36
|
+
bytes[4] = 0
|
|
37
|
+
assertEquals(false, G2Sensors.light(bytes)!!["readSucceeded"])
|
|
38
|
+
bytes[2] = 2
|
|
39
|
+
assertNull(G2Sensors.light(bytes))
|
|
40
|
+
}
|
|
41
|
+
@Test fun compassDiagnosticsAndUnknownMarkers() {
|
|
42
|
+
val bytes = listOf(67,77,1,3,1,2,143,0,66,0,0,240).map { it.toByte() }.toByteArray()
|
|
43
|
+
val report = G2Sensors.compass(bytes)!!
|
|
44
|
+
assertEquals("gmrv", report["source"])
|
|
45
|
+
assertEquals(3, report["accuracy"])
|
|
46
|
+
assertEquals(true, report["sampleMatched"])
|
|
47
|
+
assertEquals(true, report["magneticDataFresh"])
|
|
48
|
+
assertEquals(4026531906L, report["sampleTimestampMs"])
|
|
49
|
+
bytes[3] = 255.toByte(); bytes[4] = 255.toByte(); bytes[5] = 0; bytes[6] = 0
|
|
50
|
+
assertNull(G2Sensors.compass(bytes)!!["accuracy"])
|
|
51
|
+
assertEquals(false, G2Sensors.compass(bytes)!!["sampleMatched"])
|
|
52
|
+
bytes[3] = 4
|
|
53
|
+
assertNull(G2Sensors.compass(bytes))
|
|
54
|
+
assertNull(G2Sensors.compass(bytes.copyOf(11)))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
package com.glassly.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.*
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
class G2TouchInputTest {
|
|
7
|
+
private fun fields(event: Int, wire: Int, tick: Long = 123): Map<Int, Any> = mapOf(
|
|
8
|
+
1 to event, 2 to 2, 100 to byteArrayOf(0x52, 0x49, 1, 1, wire.toByte(), 0, 0, 0,
|
|
9
|
+
tick.toByte(), (tick shr 8).toByte(), (tick shr 16).toByte(), (tick shr 24).toByte()),
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
@Test fun rawRingCompletionsSurviveCloselySpacedInput() {
|
|
13
|
+
val debounce = G2TouchDebounce()
|
|
14
|
+
// The old global gate accepted the second touch_down but lost its click.
|
|
15
|
+
for ((event, wire) in listOf(1 to 4, 14 to 10, 0 to 1)) {
|
|
16
|
+
val ring = G2RingInput.parse(fields(event, wire))!!
|
|
17
|
+
assertTrue(debounce.accepts(event, ring, 1000))
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Test fun delayedProcessingUsesReceiptTimeForLegacyInput() {
|
|
22
|
+
val debounce = G2TouchDebounce()
|
|
23
|
+
// Processed together after a delay, but received a second apart.
|
|
24
|
+
assertTrue(debounce.accepts(0, null, 1000))
|
|
25
|
+
assertTrue(debounce.accepts(0, null, 2000))
|
|
26
|
+
assertFalse(debounce.accepts(0, null, 2010))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Test fun edgesAndUnknownReportsDoNotConsumeLegacyDebounce() {
|
|
30
|
+
val debounce = G2TouchDebounce()
|
|
31
|
+
for (event in listOf(9, 10, 14, 127)) assertTrue(debounce.accepts(event, null, 1000))
|
|
32
|
+
assertTrue(debounce.accepts(0, null, 1000))
|
|
33
|
+
assertFalse(debounce.accepts(0, null, 1010))
|
|
34
|
+
assertTrue(debounce.accepts(10, null, 1020))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Test fun ringInputDoesNotSuppressIndependentTempleInput() {
|
|
38
|
+
val debounce = G2TouchDebounce()
|
|
39
|
+
assertTrue(debounce.accepts(0, G2RingInput.parse(fields(0, 1))!!, 1000))
|
|
40
|
+
assertTrue(debounce.accepts(0, null, 1000))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@Test fun metadataValidatesSourceFormatAndEventMapping() {
|
|
44
|
+
val valid = fields(10, 8, 0xf1234567L)
|
|
45
|
+
val ring = G2RingInput.parse(valid)!!
|
|
46
|
+
assertEquals(8, ring.wireType)
|
|
47
|
+
assertEquals(0xf1234567L, ring.tick)
|
|
48
|
+
for (key in listOf(1, 2, 100)) assertNull(G2RingInput.parse(valid - key))
|
|
49
|
+
assertNull(G2RingInput.parse(valid + (2 to 0)))
|
|
50
|
+
for (index in listOf(0, 1, 2, 3, 4, 7)) {
|
|
51
|
+
val bytes = (valid[100] as ByteArray).copyOf().also { it[index] = 255.toByte() }
|
|
52
|
+
assertNull(G2RingInput.parse(valid + (100 to bytes)))
|
|
53
|
+
}
|
|
54
|
+
assertNull(G2RingInput.parse(valid + (100 to byteArrayOf(0x52, 0x49))))
|
|
55
|
+
assertNotNull(G2RingInput.parse(fields(127, 255)))
|
|
56
|
+
for ((wire, event) in mapOf(0 to 9, 1 to 0, 2 to 3, 4 to 1, 5 to 2, 8 to 10, 9 to 11, 10 to 14)) {
|
|
57
|
+
assertNotNull(G2RingInput.parse(fields(event, wire)))
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|