@mentra/bluetooth-sdk 3.2.0-dev.180 → 3.2.0-dev.187
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 +29 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +21 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/ConnectedDeviceMatcher.kt +69 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +66 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/NativeNotifications.kt +42 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +38 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/EvenFileService.kt +101 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/EvenNotificationProtocol.kt +113 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +378 -24
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NotificationIds.kt +19 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ConnectedDeviceMatcherTest.kt +161 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +87 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/EvenFileServiceTest.kt +153 -0
- package/build/BluetoothSdk.types.d.ts +57 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +6 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +28 -49
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/scanSession.d.ts +21 -0
- package/build/_private/scanSession.d.ts.map +1 -0
- package/build/_private/scanSession.js +75 -0
- package/build/_private/scanSession.js.map +1 -0
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +2 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/build/react/useBluetoothScan.d.ts +2 -1
- package/build/react/useBluetoothScan.d.ts.map +1 -1
- package/build/react/useBluetoothScan.js +13 -0
- package/build/react/useBluetoothScan.js.map +1 -1
- package/build/react/useMentraBluetooth.d.ts +2 -1
- package/build/react/useMentraBluetooth.d.ts.map +1 -1
- package/build/react/useMentraBluetooth.js +1 -0
- package/build/react/useMentraBluetooth.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +30 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +53 -2
- package/ios/Source/NativeNotifications.swift +52 -0
- package/ios/Source/connection/ScanSession.swift +11 -0
- package/ios/Source/internal/BluetoothAvailability.swift +7 -0
- package/ios/Source/internal/ConnectedDeviceMatcher.swift +45 -0
- package/ios/Source/sgcs/G2.swift +131 -21
- package/ios/Source/sgcs/SGCManager.swift +22 -0
- package/ios/Tests/ConnectedDeviceMatcherTests.swift +27 -0
- package/ios/Tests/NativeNotificationTests.swift +20 -0
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +62 -0
- package/src/_private/BluetoothSdkModule.ts +47 -57
- package/src/_private/scanSession.ts +88 -0
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +7 -0
- package/src/react/useBluetoothScan.ts +12 -1
- package/src/react/useMentraBluetooth.ts +2 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
class ConnectedDeviceMatcherTest {
|
|
7
|
+
private val savedLive =
|
|
8
|
+
Device(
|
|
9
|
+
model = DeviceModel.MENTRA_LIVE,
|
|
10
|
+
name = "Mentra Live 4X2A",
|
|
11
|
+
address = "AA:BB:CC:DD:EE:FF",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
@Test
|
|
15
|
+
fun `matches Mentra Live advertised name prefixes`() {
|
|
16
|
+
for (name in listOf("Xy_A", "XyBLE_1234", "MENTRA_LIVE_BLE_9", "MENTRA_LIVE_BT_9", "mentra_live_abc")) {
|
|
17
|
+
assertThat(
|
|
18
|
+
ConnectedDeviceMatcher.matches(
|
|
19
|
+
model = DeviceModel.MENTRA_LIVE,
|
|
20
|
+
defaultDevice = null,
|
|
21
|
+
candidateName = name,
|
|
22
|
+
candidateAddress = null,
|
|
23
|
+
),
|
|
24
|
+
).describedAs(name).isTrue()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Test
|
|
29
|
+
fun `matches Mentra Nex advertised name prefixes`() {
|
|
30
|
+
for (name in listOf("Nex1-77", "MENTRA_DISPLAY_02")) {
|
|
31
|
+
assertThat(
|
|
32
|
+
ConnectedDeviceMatcher.matches(
|
|
33
|
+
model = DeviceModel.MENTRA_NEX,
|
|
34
|
+
defaultDevice = null,
|
|
35
|
+
candidateName = name,
|
|
36
|
+
candidateAddress = null,
|
|
37
|
+
),
|
|
38
|
+
).describedAs(name).isTrue()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Test
|
|
43
|
+
fun `name prefixes are scoped to the scanned model`() {
|
|
44
|
+
// A held Mentra Live can never explain an empty Nex (or G1) scan.
|
|
45
|
+
assertThat(
|
|
46
|
+
ConnectedDeviceMatcher.matches(
|
|
47
|
+
model = DeviceModel.MENTRA_NEX,
|
|
48
|
+
defaultDevice = null,
|
|
49
|
+
candidateName = "XyBLE_1234",
|
|
50
|
+
candidateAddress = null,
|
|
51
|
+
),
|
|
52
|
+
).isFalse()
|
|
53
|
+
assertThat(
|
|
54
|
+
ConnectedDeviceMatcher.matches(
|
|
55
|
+
model = DeviceModel.G1,
|
|
56
|
+
defaultDevice = null,
|
|
57
|
+
candidateName = "MENTRA_LIVE_BT_9",
|
|
58
|
+
candidateAddress = null,
|
|
59
|
+
),
|
|
60
|
+
).isFalse()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@Test
|
|
64
|
+
fun `rejects non-Mentra peripherals`() {
|
|
65
|
+
for (name in listOf("Even G1_22_L_", "Pixel Watch", "MX Master 3S")) {
|
|
66
|
+
assertThat(
|
|
67
|
+
ConnectedDeviceMatcher.matches(
|
|
68
|
+
model = DeviceModel.MENTRA_LIVE,
|
|
69
|
+
defaultDevice = savedLive,
|
|
70
|
+
candidateName = name,
|
|
71
|
+
candidateAddress = "11:22:33:44:55:66",
|
|
72
|
+
),
|
|
73
|
+
).describedAs(name).isFalse()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@Test
|
|
78
|
+
fun `matches the saved default by exact name`() {
|
|
79
|
+
assertThat(
|
|
80
|
+
ConnectedDeviceMatcher.matches(
|
|
81
|
+
model = DeviceModel.MENTRA_LIVE,
|
|
82
|
+
defaultDevice = savedLive,
|
|
83
|
+
candidateName = "Mentra Live 4X2A",
|
|
84
|
+
candidateAddress = null,
|
|
85
|
+
),
|
|
86
|
+
).isTrue()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@Test
|
|
90
|
+
fun `matches the saved default by address ignoring case`() {
|
|
91
|
+
// Stored addresses can arrive lowercase from the JS side.
|
|
92
|
+
assertThat(
|
|
93
|
+
ConnectedDeviceMatcher.matches(
|
|
94
|
+
model = DeviceModel.MENTRA_LIVE,
|
|
95
|
+
defaultDevice = savedLive.copy(address = "aa:bb:cc:dd:ee:ff"),
|
|
96
|
+
candidateName = null,
|
|
97
|
+
candidateAddress = "AA:BB:CC:DD:EE:FF",
|
|
98
|
+
),
|
|
99
|
+
).isTrue()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@Test
|
|
103
|
+
fun `saved default only counts for its own model`() {
|
|
104
|
+
// Scanning for G1 while the saved default is a Mentra Live.
|
|
105
|
+
assertThat(
|
|
106
|
+
ConnectedDeviceMatcher.matches(
|
|
107
|
+
model = DeviceModel.G1,
|
|
108
|
+
defaultDevice = savedLive,
|
|
109
|
+
candidateName = "Mentra Live 4X2A",
|
|
110
|
+
candidateAddress = "AA:BB:CC:DD:EE:FF",
|
|
111
|
+
),
|
|
112
|
+
).isFalse()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@Test
|
|
116
|
+
fun `models without a branded name rely on the saved default`() {
|
|
117
|
+
val savedG1 = Device(model = DeviceModel.G1, name = "Even G1_22_L_", address = null)
|
|
118
|
+
assertThat(
|
|
119
|
+
ConnectedDeviceMatcher.matches(
|
|
120
|
+
model = DeviceModel.G1,
|
|
121
|
+
defaultDevice = savedG1,
|
|
122
|
+
candidateName = "Even G1_22_L_",
|
|
123
|
+
candidateAddress = null,
|
|
124
|
+
),
|
|
125
|
+
).isTrue()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@Test
|
|
129
|
+
fun `simulated model never matches`() {
|
|
130
|
+
assertThat(
|
|
131
|
+
ConnectedDeviceMatcher.matches(
|
|
132
|
+
model = DeviceModel.SIMULATED,
|
|
133
|
+
defaultDevice = savedLive,
|
|
134
|
+
candidateName = "Mentra Live 4X2A",
|
|
135
|
+
candidateAddress = "AA:BB:CC:DD:EE:FF",
|
|
136
|
+
),
|
|
137
|
+
).isFalse()
|
|
138
|
+
}
|
|
139
|
+
@Test
|
|
140
|
+
fun `blank saved identities never identify an unrelated connection`() {
|
|
141
|
+
val saved = Device(DeviceModel.G1, name = "", address = "")
|
|
142
|
+
assertThat(ConnectedDeviceMatcher.matches(DeviceModel.G1, saved, "", "")).isFalse()
|
|
143
|
+
assertThat(ConnectedDeviceMatcher.matches(DeviceModel.G1, saved, null, null)).isFalse()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@Test
|
|
147
|
+
fun `different known addresses override a matching saved name`() {
|
|
148
|
+
val saved = Device(DeviceModel.G1, name = "Even G1", address = "AA:BB:CC:DD:EE:FF")
|
|
149
|
+
assertThat(
|
|
150
|
+
ConnectedDeviceMatcher.matches(DeviceModel.G1, saved, "Even G1", "11:22:33:44:55:66"),
|
|
151
|
+
).isFalse()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
@Test
|
|
155
|
+
fun `a branded candidate can still match its model without being the saved device`() {
|
|
156
|
+
assertThat(
|
|
157
|
+
ConnectedDeviceMatcher.matches(DeviceModel.MENTRA_LIVE, savedLive, "XyBLE_5678", "11:22:33:44:55:66"),
|
|
158
|
+
).isTrue()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
class ScanCompletionTest {
|
|
7
|
+
private val hint = ScanDiagnostic("device_connected_on_phone", "A matching device is connected to this phone.")
|
|
8
|
+
|
|
9
|
+
@Test
|
|
10
|
+
fun `empty completed scan delivers diagnostic then completion without error`() {
|
|
11
|
+
val events = mutableListOf<String>()
|
|
12
|
+
val callback = object : MentraBluetoothScanCallback() {
|
|
13
|
+
override fun onDiagnostic(diagnostic: ScanDiagnostic) {
|
|
14
|
+
assertThat(diagnostic).isEqualTo(hint)
|
|
15
|
+
events.add("diagnostic")
|
|
16
|
+
}
|
|
17
|
+
override fun onComplete(devices: List<Device>) {
|
|
18
|
+
assertThat(devices).isEmpty()
|
|
19
|
+
events.add("complete")
|
|
20
|
+
}
|
|
21
|
+
override fun onError(error: BluetoothError) {
|
|
22
|
+
events.add("error")
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
callback.completeScan(ScanStopReason.COMPLETED, emptyList()) { hint }
|
|
26
|
+
assertThat(events).containsExactly("diagnostic", "complete")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Test
|
|
30
|
+
fun `legacy callbacks complete without querying diagnostics`() {
|
|
31
|
+
var completed = false
|
|
32
|
+
// Implement only the pre-existing interface; it gains no abstract method.
|
|
33
|
+
val callback = object : ScanCallback {
|
|
34
|
+
override fun onComplete(devices: List<Device>) { completed = true }
|
|
35
|
+
}
|
|
36
|
+
callback.completeScan(ScanStopReason.COMPLETED, emptyList()) {
|
|
37
|
+
error("Legacy callbacks must not query diagnostics")
|
|
38
|
+
}
|
|
39
|
+
assertThat(completed).isTrue()
|
|
40
|
+
assertThat(ScanCallback::class.java.declaredMethods.map { it.name })
|
|
41
|
+
.containsExactlyInAnyOrder("onResults", "onComplete", "onError")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@Test
|
|
45
|
+
fun `cancelled scans and nonempty scans never query diagnostics`() {
|
|
46
|
+
val device = Device(DeviceModel.MENTRA_LIVE, "XyBLE_1234")
|
|
47
|
+
val completed = mutableListOf<List<Device>>()
|
|
48
|
+
val callback = object : MentraBluetoothScanCallback() {
|
|
49
|
+
override fun onComplete(devices: List<Device>) { completed.add(devices) }
|
|
50
|
+
override fun onDiagnostic(diagnostic: ScanDiagnostic) { error("Unexpected diagnostic") }
|
|
51
|
+
}
|
|
52
|
+
for ((reason, devices) in listOf(
|
|
53
|
+
ScanStopReason.CANCELLED to emptyList(),
|
|
54
|
+
ScanStopReason.COMPLETED to listOf(device),
|
|
55
|
+
)) {
|
|
56
|
+
callback.completeScan(reason, devices) { error("Unexpected diagnostic query") }
|
|
57
|
+
}
|
|
58
|
+
assertThat(completed).containsExactly(emptyList(), listOf(device))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Test
|
|
62
|
+
fun `missing diagnostic leaves an ordinary empty completion`() {
|
|
63
|
+
val events = mutableListOf<String>()
|
|
64
|
+
val callback = object : MentraBluetoothScanCallback() {
|
|
65
|
+
override fun onComplete(devices: List<Device>) { events.add("complete") }
|
|
66
|
+
override fun onDiagnostic(diagnostic: ScanDiagnostic) { events.add("diagnostic") }
|
|
67
|
+
override fun onError(error: BluetoothError) { events.add("error") }
|
|
68
|
+
}
|
|
69
|
+
callback.completeScan(ScanStopReason.COMPLETED, emptyList()) { null }
|
|
70
|
+
assertThat(events).containsExactly("complete")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@Test
|
|
74
|
+
fun `completion still runs if the diagnostic handler throws`() {
|
|
75
|
+
var completed = false
|
|
76
|
+
val failure = IllegalStateException("consumer diagnostic handler failed")
|
|
77
|
+
val callback = object : MentraBluetoothScanCallback() {
|
|
78
|
+
override fun onComplete(devices: List<Device>) { completed = true }
|
|
79
|
+
override fun onDiagnostic(diagnostic: ScanDiagnostic) { throw failure }
|
|
80
|
+
}
|
|
81
|
+
val thrown = runCatching {
|
|
82
|
+
callback.completeScan(ScanStopReason.COMPLETED, emptyList()) { hint }
|
|
83
|
+
}.exceptionOrNull()
|
|
84
|
+
assertThat(thrown).isSameAs(failure)
|
|
85
|
+
assertThat(completed).isTrue()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import com.mentra.bluetoothsdk.NativeNotificationConfig
|
|
4
|
+
import java.io.IOException
|
|
5
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
6
|
+
import kotlinx.coroutines.async
|
|
7
|
+
import kotlinx.coroutines.cancelAndJoin
|
|
8
|
+
import kotlinx.coroutines.launch
|
|
9
|
+
import kotlinx.coroutines.runBlocking
|
|
10
|
+
import org.junit.Assert.*
|
|
11
|
+
import org.junit.Test
|
|
12
|
+
|
|
13
|
+
class EvenFileServiceTest {
|
|
14
|
+
@Test fun `upload arms fast acknowledgements and only checks result after data`() = runBlocking {
|
|
15
|
+
lateinit var files: EvenFileService
|
|
16
|
+
val frames = mutableListOf<Pair<Byte, ByteArray>>()
|
|
17
|
+
files = EvenFileService { service, data ->
|
|
18
|
+
frames += service to data
|
|
19
|
+
when {
|
|
20
|
+
service == 0xC5.toByte() -> files.acceptAck(byteArrayOf(1, 0))
|
|
21
|
+
data[0] == 0.toByte() -> files.acceptAck(byteArrayOf(0, 0))
|
|
22
|
+
data[0] == 2.toByte() -> files.acceptAck(byteArrayOf(2, 0))
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
assertEquals(0, files.transfer(1, FileService.PATH_NOTIFY, byteArrayOf(7, 8)))
|
|
26
|
+
assertEquals(listOf(0xC4.toByte(), 0xC4.toByte(), 0xC5.toByte(), 0xC4.toByte()), frames.map { it.first })
|
|
27
|
+
assertEquals(93, frames[0].second.size)
|
|
28
|
+
assertArrayEquals(byteArrayOf(7, 8), frames[2].second)
|
|
29
|
+
assertFalse(files.needsReconnect)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@Test fun `start refusal stops before sending content`() = runBlocking {
|
|
33
|
+
lateinit var files: EvenFileService
|
|
34
|
+
var writes = 0
|
|
35
|
+
files = EvenFileService { _, _ -> writes++; files.acceptAck(byteArrayOf(0, 1)) }
|
|
36
|
+
assertEquals(1, files.transfer(1, "n", byteArrayOf(1)))
|
|
37
|
+
assertEquals(1, writes)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Test fun `CRC rejection is observable and never reported delivered`() = runBlocking {
|
|
41
|
+
lateinit var files: EvenFileService
|
|
42
|
+
files = EvenFileService { service, data ->
|
|
43
|
+
val cid = if (service == 0xC5.toByte()) 1 else data[0].toInt()
|
|
44
|
+
if (service == 0xC5.toByte() || cid != 1) files.acceptAck(byteArrayOf(cid.toByte(), if (cid == 2) 2 else 0))
|
|
45
|
+
}
|
|
46
|
+
assertEquals(2, files.transfer(1, "n", byteArrayOf(9)))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@Test fun `wrong phase and echoed payload cannot satisfy a pending ACK`() = runBlocking {
|
|
50
|
+
val sent = CompletableDeferred<Unit>()
|
|
51
|
+
val files = EvenFileService { _, _ -> sent.complete(Unit) }
|
|
52
|
+
val transfer = async { files.transfer(1, "n", byteArrayOf(1)) }
|
|
53
|
+
sent.await()
|
|
54
|
+
files.acceptAck(byteArrayOf(1, 0))
|
|
55
|
+
files.acceptAck(byteArrayOf(0, 0, 0))
|
|
56
|
+
assertFalse(transfer.isCompleted)
|
|
57
|
+
transfer.cancelAndJoin()
|
|
58
|
+
assertTrue(files.needsReconnect)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Test fun `disable cancels wait and blocks ambiguous reuse until reconnect`() = runBlocking {
|
|
62
|
+
val sent = CompletableDeferred<Unit>()
|
|
63
|
+
val files = EvenFileService { _, _ -> sent.complete(Unit) }
|
|
64
|
+
val transfer = launch { files.transfer(1, "n", byteArrayOf(1)) }
|
|
65
|
+
sent.await(); transfer.cancelAndJoin()
|
|
66
|
+
files.acceptAck(byteArrayOf(0, 0)) // late old response
|
|
67
|
+
try { files.transfer(1, "n", byteArrayOf(2)); fail("ambiguous channel was reused") } catch (_: IllegalStateException) {}
|
|
68
|
+
files.resetConnection()
|
|
69
|
+
assertFalse(files.needsReconnect)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@Test fun `connection reset cancels old wait without tainting new connection`() = runBlocking {
|
|
73
|
+
val sent = CompletableDeferred<Unit>()
|
|
74
|
+
val files = EvenFileService { _, _ -> sent.complete(Unit) }
|
|
75
|
+
val transfer = launch { files.transfer(1, "n", byteArrayOf(1)) }
|
|
76
|
+
sent.await(); files.resetConnection(); transfer.join()
|
|
77
|
+
assertTrue(transfer.isCancelled)
|
|
78
|
+
assertFalse(files.needsReconnect)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@Test fun `ack timeout prevents the next upload from consuming a late response`() = runBlocking {
|
|
82
|
+
val files = EvenFileService(timeoutMs = 1) { _, _ -> }
|
|
83
|
+
try { files.transfer(1, "n", byteArrayOf(1)); fail("expected timeout") } catch (_: IOException) {}
|
|
84
|
+
assertTrue(files.needsReconnect)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@Test fun `start struct encodes unsigned little endian sizes and zero padded filename`() {
|
|
88
|
+
val data = FileService.sendStart(1, 0x01020304, 0x11223344, "n")
|
|
89
|
+
assertEquals(93, data.size)
|
|
90
|
+
assertArrayEquals(byteArrayOf(0, 1, 0, 0, 0, 4, 3, 2, 1, 0x44, 0x33, 0x22, 0x11), data.copyOfRange(0, 13))
|
|
91
|
+
assertEquals('n'.code.toByte(), data[13])
|
|
92
|
+
assertTrue(data.drop(14).all { it == 0.toByte() })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@Test fun `corrupt or truncated BLE acknowledgements cannot complete a transfer`() {
|
|
96
|
+
val valid = byteArrayOf(0xAA.toByte(), 0x12, 1, 4, 1, 1, 0xC4.toByte(), 0, 0, 0, 0x0F, 0x1D)
|
|
97
|
+
assertArrayEquals(byteArrayOf(0, 0), EvenFileService.decodeAckFrame(valid))
|
|
98
|
+
assertNull(EvenFileService.decodeAckFrame(valid.copyOf(11)))
|
|
99
|
+
valid[10] = 0
|
|
100
|
+
assertNull(EvenFileService.decodeAckFrame(valid))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@Test fun `Even CRC uses the non reflected zero seed variant`() {
|
|
104
|
+
assertEquals(0, EvenFileService.crc32(byteArrayOf()))
|
|
105
|
+
assertEquals(0xC052A8C8.toInt(), EvenFileService.crc32("123456789".toByteArray()))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@Test fun `updates reuse ids without colliding with numeric phone ids`() {
|
|
109
|
+
val ids = NotificationIds()
|
|
110
|
+
val first = ids.forPhoneId("app-key")
|
|
111
|
+
assertEquals(first, ids.forPhoneId("app-key"))
|
|
112
|
+
assertNotEquals(first, ids.forPhoneId(first.toString()))
|
|
113
|
+
assertNotEquals(ids.forPhoneId(""), ids.forPhoneId(""))
|
|
114
|
+
repeat(1000) { ids.forPhoneId("other-$it") }
|
|
115
|
+
assertEquals(first, ids.forPhoneId("app-key"))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@Test fun `capacity never evicts known ids or reuses anonymous ids`() {
|
|
119
|
+
val ids = NotificationIds()
|
|
120
|
+
val known = ids.forPhoneId("retained-card")
|
|
121
|
+
val allocated = mutableSetOf(known)
|
|
122
|
+
repeat(7999) { assertTrue(allocated.add(ids.forPhoneId(""))) }
|
|
123
|
+
assertEquals(8000, allocated.size)
|
|
124
|
+
assertEquals(2000, allocated.minOrNull())
|
|
125
|
+
assertEquals(9999, allocated.maxOrNull())
|
|
126
|
+
assertEquals("notification_id_capacity_exhausted",
|
|
127
|
+
assertThrows(IllegalStateException::class.java) { ids.forPhoneId("new-card") }.message)
|
|
128
|
+
assertThrows(IllegalStateException::class.java) { ids.forPhoneId("") }
|
|
129
|
+
assertEquals(known, ids.forPhoneId("retained-card"))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@Test fun `all named ids remain stable when capacity is exhausted`() {
|
|
133
|
+
val ids = NotificationIds()
|
|
134
|
+
val allocated = (0 until 8000).associate { it.toString() to ids.forPhoneId(it.toString()) }
|
|
135
|
+
assertEquals(8000, allocated.values.toSet().size)
|
|
136
|
+
assertThrows(IllegalStateException::class.java) { ids.forPhoneId("new") }
|
|
137
|
+
allocated.forEach { (key, value) -> assertEquals(value, ids.forPhoneId(key)) }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@Test fun `disabled controls stop presentation without changing the whitelist`() {
|
|
141
|
+
val commands = NotificationProto.controls(NativeNotificationConfig(enabled = false), 11, 12)
|
|
142
|
+
assertEquals(listOf(11), commands.map { it.first })
|
|
143
|
+
assertArrayEquals(byteArrayOf(8, 1, 16, 11, 26, 8, 8, 0, 16, 1, 24, 5, 40, 0), commands.single().second)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@Test fun `enabled controls include the phone-side filtering override`() {
|
|
147
|
+
val commands = NotificationProto.controls(NativeNotificationConfig(enabled = true), 11, 12)
|
|
148
|
+
assertEquals(listOf(11, 12), commands.map { it.first })
|
|
149
|
+
assertArrayEquals(byteArrayOf(8, 1, 16, 11, 26, 8, 8, 1, 16, 1, 24, 5, 40, 0), commands.first().second)
|
|
150
|
+
assertArrayEquals(byteArrayOf(8, 3, 16, 12, 50, 2, 8, 1), commands.last().second)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
}
|
|
@@ -776,6 +776,8 @@ export type BluetoothSdkModuleEvents = {
|
|
|
776
776
|
speaking_status: (event: SpeakingStatusEvent) => void;
|
|
777
777
|
battery_status: (event: BatteryStatusEvent) => void;
|
|
778
778
|
local_transcription: (event: LocalTranscriptionEvent) => void;
|
|
779
|
+
native_notification_status: (event: NativeNotificationStatus) => void;
|
|
780
|
+
native_notification_delivery: (event: NativeNotificationDelivery) => void;
|
|
779
781
|
phone_notification: (event: PhoneNotificationEvent) => void;
|
|
780
782
|
phone_notification_dismissed: (event: PhoneNotificationDismissedEvent) => void;
|
|
781
783
|
wifi_status_change: (event: WifiStatusChangeEvent) => void;
|
|
@@ -851,9 +853,55 @@ export interface PhoneNotificationDismissedEvent {
|
|
|
851
853
|
packageName: string;
|
|
852
854
|
timestamp: number;
|
|
853
855
|
}
|
|
856
|
+
/**
|
|
857
|
+
* Outbound payload for `sendPhoneNotification` — a notification pushed INTO the glasses' own
|
|
858
|
+
* notification centre. The inverse of {@link PhoneNotificationEvent}, which reports
|
|
859
|
+
* notifications the glasses relayed TO the phone (iOS/ANCS). Drivers map these keys onto
|
|
860
|
+
* whatever their firmware expects.
|
|
861
|
+
*/
|
|
862
|
+
export interface NativeNotificationConfig {
|
|
863
|
+
enabled: boolean;
|
|
864
|
+
autoDisplay: boolean;
|
|
865
|
+
durationSeconds: number;
|
|
866
|
+
doNotDisturb: boolean;
|
|
867
|
+
/** Android package names. iOS rejects nonempty lists; its ANCS filter is firmware-owned. */
|
|
868
|
+
blockedApps: string[];
|
|
869
|
+
}
|
|
870
|
+
export interface NativeNotificationStatus {
|
|
871
|
+
supported: boolean;
|
|
872
|
+
source: "phone" | "ancs" | "unsupported";
|
|
873
|
+
authorization: "system" | "authorized" | "not_authorized" | "unknown";
|
|
874
|
+
/** `submitted` is not a firmware-confirmed acknowledgement. */
|
|
875
|
+
state: "unavailable" | "disabled" | "configuring" | "submitted" | "needs_reconnect" | "failed";
|
|
876
|
+
config: NativeNotificationConfig;
|
|
877
|
+
error: string;
|
|
878
|
+
}
|
|
879
|
+
export interface NativeNotificationDelivery {
|
|
880
|
+
notificationId: string;
|
|
881
|
+
status: "delivered" | "failed" | "cancelled" | "dropped";
|
|
882
|
+
reason: string;
|
|
883
|
+
}
|
|
884
|
+
export interface NativePhoneNotification {
|
|
885
|
+
/** Stable id from the phone's notification listener; parsed to an int where firmware needs one. */
|
|
886
|
+
notificationId: string;
|
|
887
|
+
/** Reverse-DNS package id of the originating app. */
|
|
888
|
+
packageName: string;
|
|
889
|
+
/** Human app name (e.g. "Messages"). */
|
|
890
|
+
appName: string;
|
|
891
|
+
title: string;
|
|
892
|
+
/** Android `android.subText`. Empty when the listener didn't capture one. */
|
|
893
|
+
subtitle: string;
|
|
894
|
+
body: string;
|
|
895
|
+
/** Unix ms post time. */
|
|
896
|
+
timestampMs: number;
|
|
897
|
+
/** Posted or updated. Removal is not supported by the verified protocol. */
|
|
898
|
+
action: 0;
|
|
899
|
+
}
|
|
854
900
|
export type PublicGlassesStatus = Omit<GlassesStatus, "otaUpdateAvailable" | "otaProgress" | "otaInProgress" | "otaVersionUrl">;
|
|
855
901
|
export type PublicBluetoothStatus = Pick<BluetoothStatus, "searching" | "searchingController" | "systemMicUnavailable" | "micRanking" | "currentMic" | "searchResults" | "wifiScanResults" | "lastLog" | "otherBtConnected" | "galleryModeEnabled">;
|
|
856
902
|
export type BluetoothSdkEventMap = {
|
|
903
|
+
native_notification_status: NativeNotificationStatus;
|
|
904
|
+
native_notification_delivery: NativeNotificationDelivery;
|
|
857
905
|
log: LogEvent;
|
|
858
906
|
device_discovered: Device;
|
|
859
907
|
default_device_changed: {
|
|
@@ -912,6 +960,8 @@ export type BluetoothSdkSubscription = {
|
|
|
912
960
|
};
|
|
913
961
|
export type BluetoothSdkEvent = BluetoothSdkEventMap[BluetoothSdkEventName];
|
|
914
962
|
export interface BluetoothSdkPublicModule {
|
|
963
|
+
configureNativeNotifications(config: NativeNotificationConfig): Promise<void>;
|
|
964
|
+
getNativeNotificationStatus(): Promise<NativeNotificationStatus>;
|
|
915
965
|
addListener<EventName extends BluetoothSdkEventName>(eventName: EventName, listener: BluetoothSdkEventListener<EventName>): BluetoothSdkSubscription;
|
|
916
966
|
/** Read an immutable snapshot of the current glasses state. */
|
|
917
967
|
getGlassesStatus(): Promise<PublicGlassesStatus>;
|
|
@@ -1178,6 +1228,11 @@ export interface ConnectOptions {
|
|
|
1178
1228
|
cancelExistingConnectionAttempt?: boolean;
|
|
1179
1229
|
}
|
|
1180
1230
|
export type ScanResultsCallback = (devices: Device[]) => void;
|
|
1231
|
+
/** Advisory for an empty scan. System connection state does not identify an owning app. */
|
|
1232
|
+
export interface ScanDiagnostic {
|
|
1233
|
+
code: string;
|
|
1234
|
+
message: string;
|
|
1235
|
+
}
|
|
1181
1236
|
export interface ScanOptions {
|
|
1182
1237
|
model: DeviceModel;
|
|
1183
1238
|
/** Defaults to 15000. */
|
|
@@ -1186,6 +1241,8 @@ export interface ScanOptions {
|
|
|
1186
1241
|
timeout?: number;
|
|
1187
1242
|
/** Called every time the discovered device list changes during the scan. */
|
|
1188
1243
|
onResults?: ScanResultsCallback;
|
|
1244
|
+
/** Optional non-fatal hint before an empty scan resolves, on Android and iOS. */
|
|
1245
|
+
onDiagnostic?: (diagnostic: ScanDiagnostic) => void;
|
|
1189
1246
|
}
|
|
1190
1247
|
export type ScanModelOptions = Omit<ScanOptions, "model">;
|
|
1191
1248
|
export interface WifiSearchResult {
|