@mentra/bluetooth-sdk 3.3.0-dev.421 → 3.3.0-dev.422

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +0 -7
  2. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +3 -17
  3. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  4. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +0 -11
  5. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +0 -21
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +31 -213
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +2 -13
  8. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +6 -41
  9. package/build/BluetoothSdk.types.d.ts +0 -10
  10. package/build/BluetoothSdk.types.d.ts.map +1 -1
  11. package/build/BluetoothSdk.types.js.map +1 -1
  12. package/build/_private/BluetoothSdkModule.d.ts +0 -9
  13. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  14. package/build/_private/BluetoothSdkModule.js +0 -2
  15. package/build/_private/BluetoothSdkModule.js.map +1 -1
  16. package/build/generated/releaseMetadata.js +5 -5
  17. package/build/generated/releaseMetadata.js.map +1 -1
  18. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  19. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  20. package/package.json +2 -2
  21. package/src/BluetoothSdk.types.ts +0 -10
  22. package/src/_private/BluetoothSdkModule.ts +0 -13
  23. package/src/generated/releaseMetadata.ts +5 -5
  24. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleEvidenceLog.kt +0 -454
  25. package/android/src/test/java/com/mentra/bluetoothsdk/WifiScanEvidenceTest.kt +0 -117
  26. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleEvidenceLogTest.kt +0 -282
  27. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveEvidenceTest.kt +0 -603
  28. package/android/src/test/java/com/mentra/bluetoothsdk/utils/MessageChunkReassemblerOriginTest.kt +0 -51
  29. package/build/_private/diagnosticRender.d.ts +0 -12
  30. package/build/_private/diagnosticRender.d.ts.map +0 -1
  31. package/build/_private/diagnosticRender.js +0 -27
  32. package/build/_private/diagnosticRender.js.map +0 -1
  33. package/src/_private/diagnosticRender.ts +0 -35
@@ -1,603 +0,0 @@
1
- package com.mentra.bluetoothsdk.sgcs
2
-
3
- import android.bluetooth.BluetoothAdapter
4
- import android.bluetooth.BluetoothGatt
5
- import android.bluetooth.BluetoothGattCharacteristic
6
- import android.bluetooth.BluetoothGattService
7
- import android.bluetooth.BluetoothProfile
8
- import android.os.Looper
9
- import androidx.test.core.app.ApplicationProvider
10
- import com.mentra.bluetoothsdk.Bridge
11
- import com.mentra.bluetoothsdk.DeviceStore
12
- import com.mentra.bluetoothsdk.debug.BleEvidenceLog
13
- import com.mentra.bluetoothsdk.utils.K900ProtocolUtils
14
- import com.mentra.bluetoothsdk.utils.MessageChunker
15
- import java.nio.charset.StandardCharsets
16
- import java.time.Duration
17
- import java.util.UUID
18
- import org.json.JSONArray
19
- import org.json.JSONObject
20
- import org.junit.After
21
- import org.junit.Assert.assertEquals
22
- import org.junit.Assert.assertFalse
23
- import org.junit.Assert.assertNotEquals
24
- import org.junit.Assert.assertNotNull
25
- import org.junit.Assert.assertTrue
26
- import org.junit.Before
27
- import org.junit.Test
28
- import org.junit.runner.RunWith
29
- import org.robolectric.RobolectricTestRunner
30
- import org.robolectric.Shadows.shadowOf
31
- import org.robolectric.annotation.Config
32
- import org.robolectric.annotation.Implementation
33
- import org.robolectric.annotation.Implements
34
- import org.robolectric.annotation.LooperMode
35
- import org.robolectric.shadow.api.Shadow
36
- import org.robolectric.shadows.ShadowBluetoothGatt
37
-
38
- /**
39
- * Drives the production MentraLive GATT callback, decoder, send queue and Bridge with a fake
40
- * GATT transport (Robolectric shadows) and checks the provenance the evidence log records.
41
- */
42
- @RunWith(RobolectricTestRunner::class)
43
- @Config(
44
- sdk = [33],
45
- shadows = [G1PairingTest.ShadowLc3Cpp::class, MentraLiveEvidenceTest.FakeGattWrites::class],
46
- instrumentedPackages = ["com.mentra.lc3Lib"],
47
- )
48
- @LooperMode(LooperMode.Mode.PAUSED)
49
- class MentraLiveEvidenceTest {
50
- /** Fake GATT write acceptance at the Android API boundary. */
51
- @Implements(BluetoothGatt::class)
52
- class FakeGattWrites : ShadowBluetoothGatt() {
53
- companion object {
54
- @JvmStatic var accept = false
55
- @JvmStatic val written = mutableListOf<String>()
56
- }
57
-
58
- @Suppress("DEPRECATION")
59
- @Implementation
60
- protected fun writeCharacteristic(characteristic: BluetoothGattCharacteristic): Boolean {
61
- if (accept) written.add(String(characteristic.value, StandardCharsets.ISO_8859_1))
62
- return accept
63
- }
64
- }
65
-
66
- private val serviceUuid = UUID.fromString("00004860-0000-1000-8000-00805f9b34fb")
67
- private val rxUuid = UUID.fromString("000070FF-0000-1000-8000-00805f9b34fb")
68
- private val txUuid = UUID.fromString("000071FF-0000-1000-8000-00805f9b34fb")
69
- private val peerA = "AA:BB:CC:DD:EE:01"
70
- private val peerB = "AA:BB:CC:DD:EE:02"
71
- private val events = mutableListOf<Pair<String, Map<String, Any>>>()
72
- private lateinit var sinkId: String
73
- private val managers = mutableListOf<MentraLive>()
74
-
75
- @Before fun setup() {
76
- Bridge.initialize(ApplicationProvider.getApplicationContext())
77
- shadowOf(BluetoothAdapter.getDefaultAdapter()).setState(BluetoothAdapter.STATE_ON)
78
- BleEvidenceLog.resetForTest()
79
- FakeGattWrites.accept = false
80
- FakeGattWrites.written.clear()
81
- sinkId = Bridge.addEventSink { type, body -> events.add(type to HashMap(body)) }
82
- }
83
-
84
- @After fun teardown() {
85
- Bridge.removeEventSink(sinkId)
86
- managers.forEach { it.destroy() }
87
- // Robolectric does not deliver the disconnect callback; finish the production timeout.
88
- shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(3))
89
- }
90
-
91
- // ---- helpers -------------------------------------------------------------------------
92
-
93
- private fun idle() = shadowOf(Looper.getMainLooper()).idle()
94
-
95
- private fun idleFor(ms: Long) = shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(ms))
96
-
97
- private fun manager() = MentraLive().also { managers.add(it) }
98
-
99
- /** The send rate limiter reads wall-clock time, which Robolectric does not advance. */
100
- private fun drainSends() {
101
- Thread.sleep(250)
102
- idleFor(1_000)
103
- }
104
-
105
- private fun callback(gatt: BluetoothGatt) = Shadow.extract<ShadowBluetoothGatt>(gatt).gattCallback
106
-
107
- /** Connects [live] to [address] through connectById and the production GATT callback. */
108
- private fun connect(live: MentraLive, address: String): BluetoothGatt {
109
- val name = "MENTRA_LIVE_" + address.takeLast(2)
110
- val device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address)
111
- shadowOf(device).setName(name)
112
- DeviceStore.set("bluetooth", "pending_device_name", name)
113
- DeviceStore.set("bluetooth", "device_name", name)
114
- DeviceStore.set("bluetooth", "pending_device_address", "")
115
- DeviceStore.set("bluetooth", "device_address", address)
116
- val before = shadowOf(device).bluetoothGatts.size
117
- live.connectById(name)
118
- idle()
119
- // A replaced connection waits for its teardown (timeout without a Robolectric callback).
120
- if (shadowOf(device).bluetoothGatts.size == before) idleFor(3_000)
121
- val gatts = shadowOf(device).bluetoothGatts
122
- assertEquals(before + 1, gatts.size)
123
- val gatt = gatts.last()
124
- val service = BluetoothGattService(serviceUuid, BluetoothGattService.SERVICE_TYPE_PRIMARY)
125
- service.addCharacteristic(BluetoothGattCharacteristic(rxUuid, BluetoothGattCharacteristic.PROPERTY_NOTIFY, 0))
126
- service.addCharacteristic(BluetoothGattCharacteristic(txUuid, BluetoothGattCharacteristic.PROPERTY_WRITE, 0))
127
- Shadow.extract<ShadowBluetoothGatt>(gatt).addDiscoverableService(service)
128
- callback(gatt).onConnectionStateChange(gatt, BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_CONNECTED)
129
- idle()
130
- callback(gatt).onServicesDiscovered(gatt, BluetoothGatt.GATT_SUCCESS)
131
- idle()
132
- return gatt
133
- }
134
-
135
- private fun notifyRaw(gatt: BluetoothGatt, bytes: ByteArray) {
136
- callback(gatt).onCharacteristicChanged(gatt, BluetoothGattCharacteristic(rxUuid, 0, 0), bytes)
137
- }
138
-
139
- private fun notifyJson(gatt: BluetoothGatt, json: String) {
140
- notifyRaw(gatt, json.toByteArray(StandardCharsets.UTF_8))
141
- idle()
142
- }
143
-
144
- private fun notifyK900(gatt: BluetoothGatt, json: String) {
145
- notifyRaw(gatt, K900ProtocolUtils.packDataCommand(json.toByteArray(StandardCharsets.UTF_8), K900ProtocolUtils.CMD_TYPE_STRING))
146
- idle()
147
- }
148
-
149
- private fun records(target: String? = null): List<JSONObject> {
150
- val array = BleEvidenceLog.snapshot(0, target).getJSONArray("records")
151
- return (0 until array.length()).map { array.getJSONObject(it) }
152
- }
153
-
154
- private fun kind(kind: String, target: String? = null) = records(target).filter { it.getString("kind") == kind }
155
-
156
- private fun batteryEvents() = events.filter { it.first == "battery_status" }.map { it.second }
157
-
158
- private fun origin(record: JSONObject): Pair<Long, Long>? =
159
- if (record.isNull("origin")) null
160
- else record.getJSONObject("origin").let { it.getLong("connection") to it.getLong("wire") }
161
-
162
- // ---- receive provenance ----------------------------------------------------------------
163
-
164
- @Test
165
- fun `battery notification keeps its accepted origin through decoder and bridge`() {
166
- val live = manager()
167
- val gatt = connect(live, peerA)
168
- notifyJson(gatt, """{"type":"battery_status","percent":57}""")
169
-
170
- val event = batteryEvents().single()
171
- assertEquals(57, event["level"])
172
- val eventId = event["eventId"] as String
173
- val connection = kind("connection_accepted").single()
174
- assertEquals(peerA, connection.getString("peerMac"))
175
- val battery = kind("battery").single()
176
- assertEquals(BleEvidenceLog.eventId(battery.getLong("seq")), eventId)
177
- assertEquals(connection.getLong("seq") to connection.getLong("seq"), origin(battery))
178
- assertEquals("battery_status", battery.getString("source"))
179
- assertFalse("percent-only source never claims PMU charging", battery.has("pmuCharging"))
180
- val dispatch = kind("bridge_dispatch").single()
181
- assertEquals(eventId, dispatch.getString("eventId"))
182
- assertTrue(dispatch.getInt("sinks") > 0)
183
- assertTrue(dispatch.getLong("seq") > battery.getLong("seq"))
184
- }
185
-
186
- @Test
187
- fun `only the sr_hrt PMU bit is charging provenance`() {
188
- val gatt = connect(manager(), peerA)
189
- notifyK900(gatt, """{"C":"sr_hrt","B":{"pt":64,"ready":1,"charg":1}}""")
190
- notifyK900(gatt, """{"C":"sr_batv","B":{"vt":4010,"pt":63}}""")
191
- notifyJson(gatt, """{"type":"battery_status","percent":62}""")
192
-
193
- val batteries = kind("battery")
194
- assertEquals(listOf(64, 63, 62), batteries.map { it.getInt("percent") })
195
- assertEquals(listOf("k900_sr_hrt", "k900_sr_batv", "battery_status"), batteries.map { it.getString("source") })
196
- assertTrue(batteries[0].getBoolean("pmuCharging"))
197
- // The app still shows the inherited charging state, but it is not PMU evidence.
198
- assertEquals(listOf(true, true, true), batteryEvents().map { it["charging"] })
199
- assertFalse(batteries[1].has("pmuCharging"))
200
- assertFalse(batteries[2].has("pmuCharging"))
201
- }
202
-
203
- @Test
204
- fun `an unchanged percentage is two events with the same value, not a change`() {
205
- val gatt = connect(manager(), peerA)
206
- notifyJson(gatt, """{"type":"battery_status","percent":57}""")
207
- notifyJson(gatt, """{"type":"battery_status","percent":57}""")
208
-
209
- val batteries = kind("battery")
210
- assertEquals(listOf(57, 57), batteries.map { it.getInt("percent") })
211
- assertNotEquals(batteryEvents()[0]["eventId"], batteryEvents()[1]["eventId"])
212
- }
213
-
214
- @Test
215
- fun `queued notification bytes are copied before the owner decodes them`() {
216
- val gatt = connect(manager(), peerA)
217
- val bytes = """{"type":"battery_status","percent":41}""".toByteArray(StandardCharsets.UTF_8)
218
- notifyRaw(gatt, bytes)
219
- bytes[bytes.indexOf('4'.code.toByte())] = '9'.code.toByte()
220
- idle()
221
- assertEquals(listOf(41), kind("battery").map { it.getInt("percent") })
222
- }
223
-
224
- // ---- connection identity -----------------------------------------------------------------
225
-
226
- @Test
227
- fun `wrong peer and same-peer reconnect get distinct connection identities`() {
228
- // Switching glasses disconnects (destroys) the manager and creates another.
229
- for ((peer, percent) in listOf(peerA to 50, peerB to 51, peerA to 52)) {
230
- managers.lastOrNull()?.disconnect()
231
- idleFor(3_000)
232
- notifyJson(connect(manager(), peer), """{"type":"battery_status","percent":$percent}""")
233
- }
234
-
235
- val connections = kind("connection_accepted")
236
- assertEquals(listOf(peerA, peerB, peerA), connections.map { it.getString("peerMac") })
237
- assertEquals(3, connections.map { it.getLong("seq") }.distinct().size)
238
- // Each value is bound to the connection that received it, never the later current peer.
239
- assertEquals(connections.map { it.getLong("seq") }, kind("battery").map { origin(it)!!.first })
240
- assertEquals(listOf("teardown", "teardown"), kind("connection_closed").map { it.getString("reason") })
241
- val current = BleEvidenceLog.snapshot(0, null).getJSONObject("current")
242
- assertEquals(peerA, current.getString("peerMac"))
243
- assertEquals(connections.last().getLong("seq"), current.getLong("connection"))
244
- }
245
-
246
- @Test
247
- fun `remote disconnect closes the identity and later callbacks from that GATT are rejected`() {
248
- val gatt = connect(manager(), peerA)
249
- callback(gatt).onConnectionStateChange(gatt, BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_DISCONNECTED)
250
- idle()
251
- notifyJson(gatt, """{"type":"battery_status","percent":33}""")
252
-
253
- assertTrue(kind("connection_closed").any { it.getString("reason") == "remote_disconnect" })
254
- assertTrue(kind("battery").isEmpty())
255
- assertEquals("stale_gatt", kind("receive_rejected").last().getString("reason"))
256
- assertTrue(BleEvidenceLog.snapshot(0, null).getJSONObject("current").isNull("connection"))
257
-
258
- // High-rate audio from the dead link is not evidence and must not flood the bounded log.
259
- val before = kind("receive_rejected").size
260
- val lc3 = BluetoothGattCharacteristic(UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E"), 0, 0)
261
- repeat(20) { callback(gatt).onCharacteristicChanged(gatt, lc3, byteArrayOf(0xA0.toByte(), 1, 2)) }
262
- idle()
263
- assertEquals(before, kind("receive_rejected").size)
264
- }
265
-
266
- @Test
267
- fun `manager recreation in one process never reuses a connection identity`() {
268
- val first = manager()
269
- val oldGatt = connect(first, peerA)
270
- notifyJson(oldGatt, """{"type":"battery_status","percent":70}""")
271
- first.destroy()
272
- idleFor(3_000)
273
- val second = manager()
274
- notifyJson(connect(second, peerA), """{"type":"battery_status","percent":71}""")
275
- // A late callback for the destroyed manager's GATT cannot produce a value.
276
- notifyJson(oldGatt, """{"type":"battery_status","percent":99}""")
277
-
278
- val connections = kind("connection_accepted").map { it.getLong("seq") }
279
- assertEquals(2, connections.distinct().size)
280
- assertEquals(listOf(70, 71), kind("battery").map { it.getInt("percent") })
281
- assertEquals(connections, kind("battery").map { origin(it)!!.first })
282
- assertEquals("stale_gatt", kind("receive_rejected").last().getString("reason"))
283
- }
284
-
285
- @Test
286
- fun `a notification queued behind teardown is rejected explicitly`() {
287
- val live = manager()
288
- val gatt = connect(live, peerA)
289
- live.disconnect()
290
- idle()
291
- notifyJson(gatt, """{"type":"battery_status","percent":12}""")
292
-
293
- assertTrue(kind("battery").isEmpty())
294
- assertTrue(batteryEvents().isEmpty())
295
- assertEquals("teardown", kind("receive_rejected").last().getString("reason"))
296
- }
297
-
298
- @Test
299
- fun `wire reset without disconnect starts a new wire epoch and drops straddling chunks`() {
300
- val gatt = connect(manager(), peerA)
301
- val padded = """{"type":"battery_status","percent":44,"pad":"${"x".repeat(400)}"}"""
302
- val chunks = MessageChunker.createChunks(padded, 0L)
303
- assertTrue(chunks.size > 1)
304
- notifyJson(gatt, chunks[0].toString())
305
- notifyJson(gatt, """{"type":"glasses_ready"}""")
306
- chunks.drop(1).forEach { notifyJson(gatt, it.toString()) }
307
- assertTrue("a message straddling the reset must not be decoded", kind("battery").isEmpty())
308
-
309
- notifyJson(gatt, """{"type":"battery_status","percent":45}""")
310
- val connection = kind("connection_accepted").single().getLong("seq")
311
- val wireReset = kind("wire_reset").single()
312
- assertEquals(connection, wireReset.getLong("connection"))
313
- assertEquals(connection to wireReset.getLong("seq"), origin(kind("battery").single()))
314
- }
315
-
316
- // ---- Wi-Fi scan send and chunks -------------------------------------------------------
317
-
318
- @Test
319
- fun `scan command counts as sent only after GATT accepts and acknowledges the write`() {
320
- FakeGattWrites.accept = true
321
- val gatt = connect(manager(), peerA)
322
- live(gatt).requestWifiScan("scan-accepted")
323
- drainSends()
324
- val tx = BluetoothGattCharacteristic(txUuid, BluetoothGattCharacteristic.PROPERTY_WRITE, 0)
325
- callback(gatt).onCharacteristicWrite(gatt, tx, BluetoothGatt.GATT_SUCCESS)
326
- idle()
327
-
328
- val sends = kind("scan_send").filter { it.getString("scanId") == "scan-accepted" }
329
- assertEquals(listOf("queued", "gatt_accepted", "write_ok"), sends.map { it.getString("outcome") })
330
- assertEquals(kind("connection_accepted").single().getLong("seq"), sends.last().getLong("connection"))
331
- assertTrue(FakeGattWrites.written.any { it.contains("scan-accepted") })
332
- }
333
-
334
- @Test
335
- fun `refused or failed scan writes never become an accepted send`() {
336
- val gatt = connect(manager(), peerA)
337
- live(gatt).requestWifiScan("scan-refused")
338
- drainSends()
339
- assertEquals(
340
- listOf("queued", "gatt_refused"),
341
- kind("scan_send").filter { it.getString("scanId") == "scan-refused" }.map { it.getString("outcome") },
342
- )
343
-
344
- FakeGattWrites.accept = true
345
- live(gatt).requestWifiScan("scan-failed")
346
- drainSends()
347
- callback(gatt).onCharacteristicWrite(
348
- gatt,
349
- BluetoothGattCharacteristic(txUuid, BluetoothGattCharacteristic.PROPERTY_WRITE, 0),
350
- 133,
351
- )
352
- idle()
353
- assertEquals(
354
- listOf("queued", "gatt_accepted", "write_failed"),
355
- kind("scan_send").filter { it.getString("scanId") == "scan-failed" }.map { it.getString("outcome") },
356
- )
357
- }
358
-
359
- @Test
360
- fun `scan requested while disconnected is refused, not sent`() {
361
- FakeGattWrites.accept = true
362
- val live = manager()
363
- val gatt = connect(live, peerA)
364
- callback(gatt).onConnectionStateChange(gatt, BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_DISCONNECTED)
365
- idle()
366
- live.requestWifiScan("scan-offline")
367
- drainSends()
368
-
369
- // Queued is intent only; it never reaches GATT without a connection.
370
- val outcomes = kind("scan_send").filter { it.getString("scanId") == "scan-offline" }.map { it.getString("outcome") }
371
- assertEquals(listOf("queued"), outcomes)
372
- assertFalse(FakeGattWrites.written.any { it.contains("scan-offline") })
373
- }
374
-
375
- @Test
376
- fun `scan chunks carry origin and expose only the protected target`() {
377
- val gatt = connect(manager(), peerA)
378
- val chunk = JSONObject()
379
- .put("type", "wifi_scan_result")
380
- .put("scanId", "scan-1")
381
- .put("scan_complete", true)
382
- .put(
383
- "networks_neo",
384
- JSONArray()
385
- .put(JSONObject().put("ssid", "LabAP").put("requiresPassword", true))
386
- .put(JSONObject().put("ssid", "Neighbor").put("requiresPassword", false)),
387
- )
388
- notifyJson(gatt, chunk.toString())
389
-
390
- val event = events.single { it.first == "wifi_scan_result" }.second
391
- val eventId = event["eventId"] as String
392
- val target = BleEvidenceLog.sha256Hex("LabAP")
393
- val record = kind("scan_chunk", target).single()
394
- assertEquals(BleEvidenceLog.eventId(record.getLong("seq")), eventId)
395
- assertEquals("scan-1", record.getString("scanId"))
396
- assertTrue(record.getBoolean("complete"))
397
- assertEquals(kind("connection_accepted").single().getLong("seq"), origin(record)!!.first)
398
- assertTrue(record.getBoolean("targetSeen"))
399
- assertTrue(record.getBoolean("targetRequiresPassword"))
400
- val serialized = BleEvidenceLog.snapshot(0, target).toString()
401
- assertFalse(serialized.contains("LabAP"))
402
- assertFalse(serialized.contains("Neighbor"))
403
- }
404
-
405
- @Test
406
- fun `a decoded scan with more networks than retained digests never certifies target absence`() {
407
- val gatt = connect(manager(), peerA)
408
- val networks = JSONArray()
409
- (1..70).forEach { networks.put(JSONObject().put("ssid", "Net$it").put("requiresPassword", it == 70)) }
410
- notifyJson(
411
- gatt,
412
- JSONObject().put("type", "wifi_scan_result").put("scanId", "scan-many").put("scan_complete", true)
413
- .put("networks_neo", networks).toString(),
414
- )
415
-
416
- // The normal consumer still receives every network unchanged.
417
- @Suppress("UNCHECKED_CAST")
418
- val delivered = events.single { it.first == "wifi_scan_result" }.second["networks"] as List<Map<String, Any>>
419
- assertEquals(70, delivered.size)
420
- assertEquals(true, delivered.last()["requiresPassword"])
421
-
422
- val beyond = kind("scan_chunk", BleEvidenceLog.sha256Hex("Net70")).single()
423
- assertTrue("target beyond retained digests must not be reported absent", beyond.isNull("targetSeen"))
424
- assertEquals("incomplete", beyond.getString("targetCoverage"))
425
- val covered = kind("scan_chunk", BleEvidenceLog.sha256Hex("Net1")).single()
426
- assertTrue(covered.getBoolean("targetSeen"))
427
- assertFalse(covered.getBoolean("targetRequiresPassword"))
428
- }
429
-
430
- @Test
431
- fun `a scan write queued before a send-queue reset keeps its generation and is never sent`() {
432
- FakeGattWrites.accept = true
433
- val live = manager()
434
- val gatt = connect(live, peerA)
435
- val tx = BluetoothGattCharacteristic(txUuid, BluetoothGattCharacteristic.PROPERTY_WRITE, 0)
436
- live.requestWifiScan("scan-first")
437
- drainSends()
438
- callback(gatt).onCharacteristicWrite(gatt, tx, BluetoothGatt.GATT_SUCCESS)
439
- // Without real time passing, the rate limiter keeps the next write queued.
440
- live.requestWifiScan("scan-pending")
441
- idle()
442
- callback(gatt).onConnectionStateChange(gatt, BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_DISCONNECTED)
443
- idle()
444
- drainSends()
445
-
446
- val generations = kind("ble_generation")
447
- assertEquals(listOf(0L, 1L), generations.map { it.getLong("generation") })
448
- val before = generations[0].getLong("seq")
449
- val after = generations[1].getLong("seq")
450
- assertTrue(kind("connection_accepted").single().getLong("seq") > before)
451
- val first = kind("scan_send").filter { it.getString("scanId") == "scan-first" }
452
- assertEquals(listOf("queued", "gatt_accepted", "write_ok"), first.map { it.getString("outcome") })
453
- assertTrue(first.all { it.getLong("writeGeneration") == before })
454
- val pending = kind("scan_send").filter { it.getString("scanId") == "scan-pending" }
455
- assertEquals(listOf("queued"), pending.map { it.getString("outcome") })
456
- assertEquals(before, pending.single().getLong("writeGeneration"))
457
- assertTrue(after > pending.single().getLong("seq"))
458
- assertEquals(after, BleEvidenceLog.snapshot(0, null).getJSONObject("current").getLong("writeGeneration"))
459
- assertFalse(FakeGattWrites.written.any { it.contains("scan-pending") })
460
- }
461
-
462
- private fun scanJson(networksNeo: String?) =
463
- "{\"type\":\"wifi_scan_result\",\"scanId\":\"scan-raw\",\"scan_complete\":true" +
464
- (networksNeo?.let { ",\"networks_neo\":$it" } ?: "") + "}"
465
-
466
- private fun scanDelivered(): List<String> {
467
- @Suppress("UNCHECKED_CAST")
468
- val networks = events.last { it.first == "wifi_scan_result" }.second["networks"] as List<Map<String, Any>>
469
- return networks.map { it["ssid"] as String }
470
- }
471
-
472
- @Test
473
- fun `an unparsed scan entry leaves later targets unknown, not absent`() {
474
- val gatt = connect(manager(), peerA)
475
- val other = "{\"ssid\":\"Other\",\"requiresPassword\":false}"
476
- val lab = "{\"ssid\":\"LabAP\",\"requiresPassword\":true}"
477
- for (bad in listOf("null", "42", "\"text\"", "[1]")) {
478
- events.clear()
479
- BleEvidenceLog.resetForTest()
480
- notifyJson(gatt, scanJson("[$other,$bad,$lab]"))
481
- // The normal payload keeps its existing behavior: entries before the bad one.
482
- assertEquals(listOf("Other"), scanDelivered())
483
- val lab70 = kind("scan_chunk", BleEvidenceLog.sha256Hex("LabAP")).single()
484
- assertTrue("[$bad] must not certify LabAP absent", lab70.isNull("targetSeen"))
485
- assertEquals("incomplete", lab70.getString("targetCoverage"))
486
- // A parsed target is present, but unparsed entries could repeat it with other security.
487
- val seen = kind("scan_chunk", BleEvidenceLog.sha256Hex("Other")).single()
488
- assertTrue(seen.getBoolean("targetSeen"))
489
- assertTrue("[$bad] security must stay uncertified", seen.isNull("targetRequiresPassword"))
490
- }
491
- }
492
-
493
- @Test
494
- fun `a malformed or missing network array is never a certified empty scan`() {
495
- val gatt = connect(manager(), peerA)
496
- for (form in listOf("{\"ssid\":\"LabAP\"}", "\"LabAP\"", "null", null)) {
497
- events.clear()
498
- BleEvidenceLog.resetForTest()
499
- notifyJson(gatt, scanJson(form))
500
- assertEquals(emptyList<String>(), scanDelivered())
501
- val chunk = kind("scan_chunk", BleEvidenceLog.sha256Hex("LabAP")).single()
502
- assertTrue("networks_neo=$form must not certify absence", chunk.isNull("targetSeen"))
503
- assertEquals("incomplete", chunk.getString("targetCoverage"))
504
- }
505
- // Control: a well-formed empty list is a complete, certified empty chunk.
506
- BleEvidenceLog.resetForTest()
507
- notifyJson(gatt, scanJson("[]"))
508
- val empty = kind("scan_chunk", BleEvidenceLog.sha256Hex("LabAP")).single()
509
- assertEquals("complete", empty.getString("targetCoverage"))
510
- assertFalse(empty.getBoolean("targetSeen"))
511
- }
512
-
513
- @Test
514
- fun `fallback battery values are displayed without fresh provenance`() {
515
- val gatt = connect(manager(), peerA)
516
- notifyJson(gatt, """{"type":"battery_status","percent":57}""")
517
- for (bad in listOf("""{"type":"battery_status"}""", """{"type":"battery_status","percent":"abc"}""",
518
- """{"type":"battery_status","percent":null}""", """{"type":"battery_status","percent":57.5}""",
519
- """{"type":"battery_status","percent":150}""")) {
520
- notifyJson(gatt, bad)
521
- }
522
- val events = batteryEvents()
523
- // Display behavior is unchanged: missing/invalid fields fall back as before.
524
- assertEquals(listOf(57, 57, 57, 57, 57, 150), events.map { it["level"] })
525
- assertTrue(events.first().containsKey("eventId"))
526
- assertTrue("fallback values must not carry an event id", events.drop(1).none { it.containsKey("eventId") })
527
- assertEquals(listOf(57), kind("battery").map { it.getInt("percent") })
528
- }
529
-
530
- @Test
531
- fun `K900 battery fields are validated before provenance`() {
532
- val gatt = connect(manager(), peerA)
533
- notifyK900(gatt, """{"C":"sr_hrt","B":{"pt":64,"ready":1,"charg":1}}""")
534
- notifyK900(gatt, """{"C":"sr_batv","B":{"vt":4010,"pt":63}}""")
535
- notifyK900(gatt, """{"C":"sr_batv","B":{"vt":4010}}""")
536
- notifyK900(gatt, """{"C":"sr_batv","B":{"vt":4010,"pt":"x"}}""")
537
- notifyK900(gatt, """{"C":"sr_hrt","B":{"pt":61,"ready":1,"charg":2}}""")
538
-
539
- val events = batteryEvents()
540
- // sr_batv without a valid pt keeps its existing 0 display value, with no id.
541
- assertEquals(listOf(64, 63, 0, 0, 61), events.map { it["level"] })
542
- assertEquals(listOf(true, true, false, false, true), events.map { it.containsKey("eventId") })
543
- val batteries = kind("battery")
544
- assertEquals(listOf(64, 63, 61), batteries.map { it.getInt("percent") })
545
- assertTrue(batteries[0].getBoolean("pmuCharging"))
546
- assertFalse(batteries[1].has("pmuCharging"))
547
- // charg=2 is not a PMU bit: the percentage is measured, the charging claim is not.
548
- assertEquals("k900_sr_hrt", batteries[2].getString("source"))
549
- assertFalse(batteries[2].has("pmuCharging"))
550
- }
551
-
552
- @Test
553
- fun `only an exact 0 or 1 PMU charg bit is charging provenance`() {
554
- val gatt = connect(manager(), peerA)
555
- val chargValues = listOf("1", "0", "0.5", "1.5", "1.0", "0.0", "\"1\"")
556
- for (charg in chargValues) {
557
- notifyK900(gatt, """{"C":"sr_hrt","B":{"pt":60,"ready":1,"charg":$charg}}""")
558
- }
559
- // A non-numeric charg keeps the existing behavior of skipping the update entirely.
560
- notifyK900(gatt, """{"C":"sr_hrt","B":{"pt":60,"ready":1,"charg":true}}""")
561
-
562
- val batteries = kind("battery")
563
- // Each percentage is measured; only a value exactly equal to 0 or 1 is PMU evidence.
564
- assertEquals(List(chargValues.size) { 60 }, batteries.map { it.getInt("percent") })
565
- assertEquals(
566
- listOf<Boolean?>(true, false, null, null, true, false, null),
567
- batteries.map { if (it.has("pmuCharging")) it.getBoolean("pmuCharging") else null },
568
- )
569
- // Display/charging semantics are unchanged: optInt truncation still drives isCharging.
570
- assertEquals(
571
- listOf(true, false, false, true, true, false, true),
572
- batteryEvents().map { it["charging"] },
573
- )
574
- }
575
-
576
- @Test
577
- fun `sr_hrt without a measured pt issues no fresh battery id`() {
578
- val gatt = connect(manager(), peerA)
579
- notifyK900(gatt, """{"C":"sr_hrt","B":{"pt":64,"ready":1,"charg":1}}""")
580
- for (pt in listOf(null, "\"x\"", "\"57\"", "57.5", "150", "-3")) {
581
- val field = pt?.let { ",\"pt\":$it" } ?: ""
582
- notifyK900(gatt, """{"C":"sr_hrt","B":{"ready":1,"charg":1$field}}""")
583
- }
584
- // Missing/non-numeric pt keeps the previous no-update behavior; numeric fallbacks are
585
- // displayed as before, but none of them carries a fresh id or record.
586
- val events = batteryEvents()
587
- assertEquals(listOf(64, 57, 57, 150), events.map { it["level"] })
588
- assertEquals(listOf(true, false, false, false), events.map { it.containsKey("eventId") })
589
- assertEquals(listOf(64), kind("battery").map { it.getInt("percent") })
590
- }
591
-
592
- @Test
593
- fun `events without native provenance carry no id`() {
594
- // e.g. another glasses model or a synthesized status: nothing to adopt.
595
- Bridge.sendBatteryStatus(80, false)
596
- val event = batteryEvents().single()
597
- assertFalse(event.containsKey("eventId"))
598
- assertTrue(kind("bridge_dispatch").isEmpty())
599
- assertNotNull(event["level"])
600
- }
601
-
602
- private fun live(gatt: BluetoothGatt): MentraLive = managers.last()
603
- }
@@ -1,51 +0,0 @@
1
- package com.mentra.bluetoothsdk.utils
2
-
3
- import com.mentra.bluetoothsdk.debug.BleEvidenceLog
4
- import org.junit.Assert.assertEquals
5
- import org.junit.Assert.assertNull
6
- import org.junit.Test
7
- import org.junit.runner.RunWith
8
- import org.robolectric.RobolectricTestRunner
9
- import org.robolectric.annotation.Config
10
-
11
- @RunWith(RobolectricTestRunner::class)
12
- @Config(sdk = [33])
13
- class MessageChunkReassemblerOriginTest {
14
- private val first = BleEvidenceLog.Origin(connection = 3, wire = 3)
15
- private val afterWireReset = BleEvidenceLog.Origin(connection = 3, wire = 9)
16
-
17
- @Test
18
- fun `chunks from one origin reassemble`() {
19
- val reassembler = MessageChunkReassembler()
20
- assertNull(reassembler.addChunk("c1", 0, 2, "{\"a\":", first))
21
- assertEquals("{\"a\":1}", reassembler.addChunk("c1", 1, 2, "1}", first))
22
- assertEquals(0L, reassembler.mixedOriginDrops)
23
- }
24
-
25
- @Test
26
- fun `a chunk from another origin discards the session instead of mixing`() {
27
- val reassembler = MessageChunkReassembler()
28
- assertNull(reassembler.addChunk("c1", 0, 2, "{\"a\":", first))
29
- // Completing index 1 from a later wire epoch must not produce a message.
30
- assertNull(reassembler.addChunk("c1", 1, 2, "1}", afterWireReset))
31
- assertEquals(1L, reassembler.mixedOriginDrops)
32
- // The discarded session's first chunk is gone; only fragments from the new origin combine.
33
- assertEquals("{\"b\":1}", reassembler.addChunk("c1", 0, 2, "{\"b\":", afterWireReset))
34
- }
35
-
36
- @Test
37
- fun `binary fragments from another origin are discarded`() {
38
- val reassembler = MessageChunkReassembler()
39
- assertNull(reassembler.addBinaryFragment(7, 0, 2, byteArrayOf(1), first))
40
- assertNull(reassembler.addBinaryFragment(7, 1, 2, byteArrayOf(2), afterWireReset))
41
- assertEquals(1L, reassembler.mixedOriginDrops)
42
- }
43
-
44
- @Test
45
- fun `callers without an origin keep the previous behavior`() {
46
- val reassembler = MessageChunkReassembler()
47
- assertNull(reassembler.addChunk("c1", 0, 2, "{\"a\":"))
48
- assertEquals("{\"a\":1}", reassembler.addChunk("c1", 1, 2, "1}"))
49
- assertEquals(0L, reassembler.mixedOriginDrops)
50
- }
51
- }
@@ -1,12 +0,0 @@
1
- export type DiagnosticRenderSurface = "glasses_battery" | "wifi_scan";
2
- export type ReportDiagnosticRender = (surface: DiagnosticRenderSurface, eventIds: string[], value?: number | null) => boolean;
3
- /**
4
- * Wraps the Android-only native render marker. An empty id list is forwarded because it is
5
- * meaningful: it invalidates the surface's earlier marker. A displayed value the evidence
6
- * cannot represent (not an integer, or outside the surface's range) is sent as a withdrawal
7
- * (no ids, null value) rather than dropped or passed through, so an earlier marker never stays
8
- * attributed to it. Returns false (and does nothing) on other platforms, on native builds
9
- * without the function, or when the native call throws.
10
- */
11
- export declare function createReportDiagnosticRender(native: Record<string, unknown>, platformOs: string): ReportDiagnosticRender;
12
- //# sourceMappingURL=diagnosticRender.d.ts.map