@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,454 +0,0 @@
1
- package com.mentra.bluetoothsdk.debug
2
-
3
- import android.os.Handler
4
- import android.os.Looper
5
- import android.os.Process
6
- import android.os.SystemClock
7
- import java.nio.charset.StandardCharsets
8
- import java.security.MessageDigest
9
- import java.util.UUID
10
- import java.util.concurrent.CountDownLatch
11
- import java.util.concurrent.TimeUnit
12
- import java.util.concurrent.atomic.AtomicReference
13
- import org.json.JSONArray
14
- import org.json.JSONObject
15
-
16
- /**
17
- * Bounded provenance record for Mentra Live battery and Wi-Fi scan observations (schema v1).
18
- *
19
- * The record lets a device test prove that a value shown by the app came from a specific
20
- * glasses notification on a specific, still-current BLE connection. It is not telemetry:
21
- * it stores only lifecycle boundaries, battery percentages, scan request/send/chunk/terminal
22
- * metadata and allowlisted render markers, in a fixed-size in-memory ring. Nothing is uploaded.
23
- *
24
- * Identities are derived from one random [streamId] per process plus a strictly increasing
25
- * [Record.seq]:
26
- * - a connection is the seq of its `connection_accepted` record (nonreused within the stream,
27
- * and a manager recreated in the same process still gets a new seq);
28
- * - a wire epoch is the seq of the latest `wire_reset` (or the connection record itself);
29
- * - a BLE write generation is the seq of the `ble_generation` record naming the send-queue
30
- * generation that each queued write captured when it was queued;
31
- * - a logical event id is `"<streamId>:<seq>"` of its `battery`/`scan_chunk` record.
32
- *
33
- * SSIDs are never stored or serialized. A scan chunk keeps SHA-256 digests of up to
34
- * [MAX_SCAN_SSIDS] of its SSIDs in memory so a snapshot can report only whether a
35
- * caller-supplied target digest was present. A target that is not among the retained digests
36
- * is reported as absent only when every network of the chunk was retained; otherwise unknown.
37
- *
38
- * Read access is [dump], reached through `dumpsys` on the non-exported foreground service
39
- * (DUMP permission, shell/system only). It is read-only and runs on the owner looper so its
40
- * fence is ordered after every callback that already ran there.
41
- */
42
- object BleEvidenceLog {
43
- const val SCHEMA_VERSION = 1
44
- const val CAPACITY = 512
45
- const val DUMP_COMMAND = "mentra-ble-evidence"
46
- const val OUTPUT_PREFIX = "MENTRA_BLE_EVIDENCE "
47
- const val SURFACE_BATTERY = "glasses_battery"
48
- const val SURFACE_WIFI_SCAN = "wifi_scan"
49
- private const val MAX_SCAN_SSIDS = 64
50
- private const val MAX_RENDER_EVENT_IDS = 32
51
- private const val DUMP_TIMEOUT_MS = 2_000L
52
- private val SHA256_HEX = Regex("^[0-9a-f]{64}$")
53
-
54
- /** Receive origin captured at the accepted notification, before decoding. */
55
- data class Origin(val connection: Long, val wire: Long)
56
-
57
- private class Record(
58
- val seq: Long,
59
- val elapsedRealtimeMs: Long,
60
- val kind: String,
61
- val fields: Map<String, Any?>,
62
- /** SSID digest -> exact requiresPassword boolean (null when not a boolean). Never serialized. */
63
- val ssidSecurity: Map<String, Boolean?>? = null,
64
- /** False when some network of the chunk was not retained in [ssidSecurity]. */
65
- val ssidCoverageComplete: Boolean = true,
66
- /**
67
- * False when some entry of the chunk could not be identified (unparsed or without a
68
- * string SSID); such an entry could repeat a seen target with another security value.
69
- */
70
- val ssidSecurityCertain: Boolean = true,
71
- )
72
-
73
- private val lock = Any()
74
- private val records = ArrayDeque<Record>()
75
- @Volatile var streamId: String = UUID.randomUUID().toString()
76
- private set
77
- private var lastSeq = 0L
78
- private var droppedCount = 0L
79
- private var currentConnection = 0L
80
- private var currentPeerMac: String? = null
81
- private var currentWire = 0L
82
- private var currentWriteGeneration = 0L
83
- internal var clock: () -> Long = { SystemClock.elapsedRealtime() }
84
-
85
- /** Starts a new stream as a fresh process would. Tests only. */
86
- internal fun resetForTest() {
87
- synchronized(lock) {
88
- records.clear()
89
- streamId = UUID.randomUUID().toString()
90
- lastSeq = 0L
91
- droppedCount = 0L
92
- currentConnection = 0L
93
- currentPeerMac = null
94
- currentWire = 0L
95
- currentWriteGeneration = 0L
96
- }
97
- }
98
-
99
- fun eventId(seq: Long): String = "$streamId:$seq"
100
-
101
- /** Records an accepted connection for the actual callback peer and returns its origin. */
102
- fun connectionAccepted(peerMac: String): Origin =
103
- synchronized(lock) {
104
- val seq = append("connection_accepted", mapOf("peerMac" to peerMac))
105
- currentConnection = seq
106
- currentPeerMac = peerMac
107
- currentWire = seq
108
- Origin(seq, seq)
109
- }
110
-
111
- fun connectionClosed(connection: Long, reason: String) {
112
- if (connection == 0L) return
113
- synchronized(lock) {
114
- append("connection_closed", mapOf("connection" to connection, "reason" to reason))
115
- if (currentConnection == connection) {
116
- currentConnection = 0L
117
- currentPeerMac = null
118
- currentWire = 0L
119
- }
120
- }
121
- }
122
-
123
- /** Wire negotiation reset without a BLE disconnect (e.g. glasses_ready). Returns the new wire epoch. */
124
- fun wireReset(connection: Long): Long =
125
- synchronized(lock) {
126
- val seq = append("wire_reset", mapOf("connection" to connection))
127
- if (connection == currentConnection) currentWire = seq
128
- seq
129
- }
130
-
131
- /**
132
- * The BLE send queue moved to [generation] (manager creation or disconnect reset). Writes
133
- * queued under an older generation are never transmitted. Returns the boundary seq that
134
- * queued writes of this generation reference as `writeGeneration`.
135
- */
136
- fun bleGeneration(connection: Long, generation: Long): Long =
137
- synchronized(lock) {
138
- val seq =
139
- append(
140
- "ble_generation",
141
- mapOf("connection" to (if (connection == 0L) null else connection), "generation" to generation),
142
- )
143
- currentWriteGeneration = seq
144
- seq
145
- }
146
-
147
- fun receiveRejected(reason: String, origin: Origin?) {
148
- synchronized(lock) {
149
- append("receive_rejected", mapOf("reason" to reason, "origin" to originJson(origin)))
150
- }
151
- }
152
-
153
- /**
154
- * Records a battery value decoded from one notification. [pmuCharging] is only for the
155
- * PMU `charg` bit of `sr_hrt`; percent-only sources pass null.
156
- */
157
- fun battery(origin: Origin?, percent: Int, source: String, pmuCharging: Boolean?): String =
158
- synchronized(lock) {
159
- val fields = linkedMapOf<String, Any?>(
160
- "origin" to originJson(origin),
161
- "percent" to percent,
162
- "source" to source,
163
- )
164
- if (pmuCharging != null) fields["pmuCharging"] = pmuCharging
165
- eventId(append("battery", fields))
166
- }
167
-
168
- fun scanRequest(scanId: String, fresh: Boolean) {
169
- synchronized(lock) {
170
- append("scan_request", mapOf("scanId" to scanId, "mode" to if (fresh) "fresh" else "joined"))
171
- }
172
- }
173
-
174
- /**
175
- * One native write of a scan command. Outcomes: queued, not_queued, gatt_accepted,
176
- * gatt_refused, write_ok, write_failed, stale_dropped. [connection] and [writeGeneration]
177
- * are the values captured when the write was queued, never re-read later.
178
- */
179
- fun scanSend(
180
- scanId: String,
181
- outcome: String,
182
- fragment: Int,
183
- fragments: Int,
184
- connection: Long,
185
- writeGeneration: Long?,
186
- ) {
187
- synchronized(lock) {
188
- append(
189
- "scan_send",
190
- mapOf(
191
- "scanId" to scanId,
192
- "outcome" to outcome,
193
- "fragment" to fragment,
194
- "fragments" to fragments,
195
- "connection" to (if (connection == 0L) null else connection),
196
- "writeGeneration" to writeGeneration,
197
- ),
198
- )
199
- }
200
- }
201
-
202
- /**
203
- * [networks] are the entries the decoder parsed. [entriesParsed] is false when the raw chunk
204
- * had entries (or a network list) that could not be parsed; they were not delivered either,
205
- * so a target among them is unknown rather than absent.
206
- */
207
- fun scanChunk(
208
- origin: Origin?,
209
- scanId: String?,
210
- networks: List<Map<String, Any>>,
211
- complete: Boolean,
212
- entriesParsed: Boolean,
213
- ): String {
214
- val security = LinkedHashMap<String, Boolean?>()
215
- var identitiesKnown = entriesParsed
216
- var coverageComplete = entriesParsed
217
- for (network in networks) {
218
- // An entry without a string SSID could be anything: coverage is no longer complete.
219
- val ssid = network["ssid"] as? String
220
- if (ssid == null) {
221
- identitiesKnown = false
222
- coverageComplete = false
223
- continue
224
- }
225
- val digest = sha256Hex(ssid)
226
- val requiresPassword = network["requiresPassword"] as? Boolean
227
- if (security.containsKey(digest)) {
228
- // Conflicting duplicates make the security value unknown.
229
- if (security[digest] != requiresPassword) security[digest] = null
230
- continue
231
- }
232
- if (security.size >= MAX_SCAN_SSIDS) {
233
- coverageComplete = false
234
- continue
235
- }
236
- security[digest] = requiresPassword
237
- }
238
- return synchronized(lock) {
239
- val seq =
240
- append(
241
- "scan_chunk",
242
- mapOf(
243
- "origin" to originJson(origin),
244
- "scanId" to scanId,
245
- "networks" to networks.size,
246
- "entriesParsed" to entriesParsed,
247
- "complete" to complete,
248
- ),
249
- security,
250
- coverageComplete,
251
- identitiesKnown,
252
- )
253
- eventId(seq)
254
- }
255
- }
256
-
257
- /** Result: complete, timeout_partial, timeout_empty, error, legacy_uncorrelated. */
258
- fun scanTerminal(scanId: String?, result: String, networks: Int) {
259
- synchronized(lock) {
260
- append("scan_terminal", mapOf("scanId" to scanId, "result" to result, "networks" to networks))
261
- }
262
- }
263
-
264
- fun bridgeDispatch(eventId: String, type: String, sinks: Int) {
265
- synchronized(lock) {
266
- append("bridge_dispatch", mapOf("eventId" to eventId, "type" to type, "sinks" to sinks))
267
- }
268
- }
269
-
270
- /**
271
- * Records that app UI committed state derived from [eventIds]. Only allowlisted surfaces and
272
- * bounded integer values are accepted; ids from another stream are counted, not stored.
273
- * An out-of-range value is not accepted: it is recorded as a withdrawal of the surface (no
274
- * ids, null value, the value itself not stored) and false is returned, so the surface's
275
- * earlier marker never stays current for a value it does not describe.
276
- */
277
- fun uiRender(surface: String, eventIds: List<String>, value: Int?): Boolean {
278
- val maxValue =
279
- when (surface) {
280
- SURFACE_BATTERY -> 100
281
- SURFACE_WIFI_SCAN -> 500
282
- else -> return false
283
- }
284
- if (value != null && (value < 0 || value > maxValue)) {
285
- synchronized(lock) {
286
- append(
287
- "ui_render",
288
- mapOf(
289
- "surface" to surface,
290
- "eventIds" to JSONArray(),
291
- "rejectedEventIds" to eventIds.size,
292
- "truncated" to false,
293
- "value" to null,
294
- ),
295
- )
296
- }
297
- return false
298
- }
299
- return synchronized(lock) {
300
- val prefix = "$streamId:"
301
- val accepted = eventIds.filter { id ->
302
- id.startsWith(prefix) &&
303
- id.substring(prefix.length).toLongOrNull()?.let { it in 1..lastSeq } == true
304
- }.distinct()
305
- append(
306
- "ui_render",
307
- mapOf(
308
- "surface" to surface,
309
- "eventIds" to JSONArray(accepted.take(MAX_RENDER_EVENT_IDS)),
310
- "rejectedEventIds" to (eventIds.size - accepted.size),
311
- "truncated" to (accepted.size > MAX_RENDER_EVENT_IDS),
312
- "value" to value,
313
- ),
314
- )
315
- true
316
- }
317
- }
318
-
319
- /**
320
- * Returns every retained record after [afterSeq]. `outcome` is `ok` only when the cursor
321
- * belongs to this stream and no record after it was evicted.
322
- */
323
- fun snapshot(afterSeq: Long?, targetSha256: String?): JSONObject =
324
- synchronized(lock) {
325
- val firstRetained = records.firstOrNull()?.seq ?: (lastSeq + 1)
326
- val target = targetSha256?.lowercase()
327
- val outcome =
328
- when {
329
- afterSeq == null || afterSeq < 0 -> "invalid_request"
330
- target != null && !SHA256_HEX.matches(target) -> "invalid_request"
331
- afterSeq > lastSeq -> "stale_cursor"
332
- afterSeq < firstRetained - 1 -> "gap"
333
- else -> "ok"
334
- }
335
- val out = JSONObject()
336
- out.put("schemaVersion", SCHEMA_VERSION)
337
- out.put("streamId", streamId)
338
- out.put("pid", Process.myPid())
339
- out.put("outcome", outcome)
340
- out.put("afterSeq", afterSeq ?: JSONObject.NULL)
341
- out.put("fenceSeq", lastSeq)
342
- out.put("firstRetainedSeq", firstRetained)
343
- out.put("droppedCount", droppedCount)
344
- out.put("nowElapsedRealtimeMs", clock())
345
- out.put("targetFiltered", target != null && outcome != "invalid_request")
346
- out.put(
347
- "current",
348
- JSONObject().apply {
349
- put("connection", if (currentConnection == 0L) JSONObject.NULL else currentConnection)
350
- put("peerMac", currentPeerMac ?: JSONObject.NULL)
351
- put("wire", if (currentWire == 0L) JSONObject.NULL else currentWire)
352
- put("writeGeneration", if (currentWriteGeneration == 0L) JSONObject.NULL else currentWriteGeneration)
353
- },
354
- )
355
- val list = JSONArray()
356
- if (outcome != "invalid_request" && outcome != "stale_cursor") {
357
- for (record in records) {
358
- if (record.seq > afterSeq!!) list.put(recordJson(record, target))
359
- }
360
- }
361
- out.put("records", list)
362
- out
363
- }
364
-
365
- /**
366
- * Handles `dumpsys ... mentra-ble-evidence afterSeq=<n> [targetSha256=<hex>]`. Returns null
367
- * for any other dump request. The snapshot runs on [owner] so it is ordered after work that
368
- * already ran there; if the owner does not answer in time the outcome is `unavailable`.
369
- */
370
- fun dump(args: Array<out String>?, owner: Handler, timeoutMs: Long = DUMP_TIMEOUT_MS): String? {
371
- if (args.isNullOrEmpty() || args[0] != DUMP_COMMAND) return null
372
- var afterSeq: Long? = null
373
- var target: String? = null
374
- var valid = true
375
- for (arg in args.drop(1)) {
376
- when {
377
- arg.startsWith("afterSeq=") -> afterSeq = arg.removePrefix("afterSeq=").toLongOrNull()
378
- arg.startsWith("targetSha256=") -> target = arg.removePrefix("targetSha256=")
379
- else -> valid = false
380
- }
381
- }
382
- if (!valid) afterSeq = null
383
- val result = AtomicReference<JSONObject?>()
384
- if (Looper.myLooper() == owner.looper) {
385
- result.set(snapshot(afterSeq, target))
386
- } else {
387
- val done = CountDownLatch(1)
388
- val posted =
389
- owner.post {
390
- result.set(snapshot(afterSeq, target))
391
- done.countDown()
392
- }
393
- if (posted) done.await(timeoutMs, TimeUnit.MILLISECONDS)
394
- }
395
- val snapshot =
396
- result.get()
397
- ?: JSONObject().apply {
398
- put("schemaVersion", SCHEMA_VERSION)
399
- put("streamId", streamId)
400
- put("pid", Process.myPid())
401
- put("outcome", "unavailable")
402
- }
403
- return OUTPUT_PREFIX + snapshot
404
- }
405
-
406
- fun sha256Hex(value: String): String =
407
- MessageDigest.getInstance("SHA-256")
408
- .digest(value.toByteArray(StandardCharsets.UTF_8))
409
- .joinToString("") { "%02x".format(it) }
410
-
411
- private fun append(
412
- kind: String,
413
- fields: Map<String, Any?>,
414
- ssidSecurity: Map<String, Boolean?>? = null,
415
- ssidCoverageComplete: Boolean = true,
416
- ssidSecurityCertain: Boolean = true,
417
- ): Long {
418
- val seq = ++lastSeq
419
- records.addLast(Record(seq, clock(), kind, fields, ssidSecurity, ssidCoverageComplete, ssidSecurityCertain))
420
- while (records.size > CAPACITY) {
421
- records.removeFirst()
422
- droppedCount++
423
- }
424
- return seq
425
- }
426
-
427
- private fun originJson(origin: Origin?): Any =
428
- origin?.let { JSONObject().put("connection", it.connection).put("wire", it.wire) } ?: JSONObject.NULL
429
-
430
- private fun recordJson(record: Record, target: String?): JSONObject {
431
- val json = JSONObject()
432
- json.put("seq", record.seq)
433
- json.put("elapsedRealtimeMs", record.elapsedRealtimeMs)
434
- json.put("kind", record.kind)
435
- for ((key, value) in record.fields) json.put(key, value ?: JSONObject.NULL)
436
- val security = record.ssidSecurity
437
- if (security != null && target != null) {
438
- val seen = security.containsKey(target)
439
- json.put("targetCoverage", if (record.ssidCoverageComplete) "complete" else "incomplete")
440
- when {
441
- seen -> {
442
- json.put("targetSeen", true)
443
- // Presence is certain; its security is only certain when no entry was unidentified.
444
- val requiresPassword = if (record.ssidSecurityCertain) security[target] else null
445
- json.put("targetRequiresPassword", requiresPassword ?: JSONObject.NULL)
446
- }
447
- // Absence is only known when every network of the chunk was retained.
448
- record.ssidCoverageComplete -> json.put("targetSeen", false)
449
- else -> json.put("targetSeen", JSONObject.NULL)
450
- }
451
- }
452
- return json
453
- }
454
- }
@@ -1,117 +0,0 @@
1
- package com.mentra.bluetoothsdk
2
-
3
- import androidx.test.core.app.ApplicationProvider
4
- import com.mentra.bluetoothsdk.debug.BleEvidenceLog
5
- import com.mentra.bluetoothsdk.sgcs.G1PairingTest
6
- import kotlinx.coroutines.CoroutineStart
7
- import kotlinx.coroutines.async
8
- import kotlinx.coroutines.runBlocking
9
- import kotlinx.coroutines.yield
10
- import org.json.JSONObject
11
- import org.junit.After
12
- import org.junit.Assert.assertEquals
13
- import org.junit.Assert.assertFalse
14
- import org.junit.Assert.assertTrue
15
- import org.junit.Before
16
- import org.junit.Test
17
- import org.junit.runner.RunWith
18
- import org.robolectric.RobolectricTestRunner
19
- import org.robolectric.annotation.Config
20
-
21
- /**
22
- * Runs the production MentraBluetoothSdk.requestWifiScan and its Bridge chunk handling. Chunks
23
- * enter through Bridge.updateWifiScanResults, the same call MentraLive makes after decoding.
24
- */
25
- @RunWith(RobolectricTestRunner::class)
26
- @Config(sdk = [33], shadows = [G1PairingTest.ShadowLc3Cpp::class], instrumentedPackages = ["com.mentra.lc3Lib"])
27
- class WifiScanEvidenceTest {
28
- private lateinit var sdk: MentraBluetoothSdk
29
-
30
- @Before fun setup() {
31
- BleEvidenceLog.resetForTest()
32
- sdk = MentraBluetoothSdk.create(ApplicationProvider.getApplicationContext(), object : MentraBluetoothSdkListener {})
33
- }
34
-
35
- @After fun close() = sdk.close()
36
-
37
- private fun records(kind: String): List<JSONObject> {
38
- val array = BleEvidenceLog.snapshot(0, null).getJSONArray("records")
39
- return (0 until array.length()).map { array.getJSONObject(it) }.filter { it.getString("kind") == kind }
40
- }
41
-
42
- private fun freshScanId() = records("scan_request").single { it.getString("mode") == "fresh" }.getString("scanId")
43
-
44
- private fun chunk(scanId: String?, ssid: String, complete: Boolean) =
45
- Bridge.updateWifiScanResults(
46
- listOf(mapOf("ssid" to ssid, "requiresPassword" to true, "signalStrength" to -40)),
47
- complete,
48
- scanId,
49
- BleEvidenceLog.scanChunk(null, scanId, emptyList(), complete, entriesParsed = true),
50
- )
51
-
52
- @Test
53
- fun `explicit completion of the fresh request is the only complete terminal`() = runBlocking {
54
- val scan = async(start = CoroutineStart.UNDISPATCHED) { sdk.requestWifiScan() }
55
- val scanId = freshScanId()
56
- chunk(scanId, "LabAP", complete = false)
57
- yield()
58
- assertFalse(scan.isCompleted)
59
- assertTrue("streamed chunks are not completion", records("scan_terminal").isEmpty())
60
-
61
- chunk(scanId, "Other", complete = true)
62
- assertEquals(listOf("LabAP", "Other"), scan.await().map { it.ssid })
63
- val terminal = records("scan_terminal").single()
64
- assertEquals(scanId, terminal.getString("scanId"))
65
- assertEquals("complete", terminal.getString("result"))
66
- }
67
-
68
- @Test
69
- fun `a joined caller is recorded as joining the older request, not a fresh scan`() = runBlocking {
70
- val first = async(start = CoroutineStart.UNDISPATCHED) { sdk.requestWifiScan() }
71
- val joined = async(start = CoroutineStart.UNDISPATCHED) { sdk.requestWifiScan() }
72
- val scanId = freshScanId()
73
- val requests = records("scan_request")
74
- assertEquals(listOf("fresh", "joined"), requests.map { it.getString("mode") })
75
- assertEquals(listOf(scanId, scanId), requests.map { it.getString("scanId") })
76
-
77
- chunk(scanId, "LabAP", complete = true)
78
- assertEquals(first.await(), joined.await())
79
- }
80
-
81
- @Test
82
- fun `a chunk for another scan id neither resolves nor completes the fresh request`() = runBlocking {
83
- val scan = async(start = CoroutineStart.UNDISPATCHED) { sdk.requestWifiScan() }
84
- val scanId = freshScanId()
85
- chunk("scan-older", "Stale", complete = true)
86
- yield()
87
- assertFalse(scan.isCompleted)
88
- assertTrue(records("scan_terminal").isEmpty())
89
-
90
- chunk(scanId, "LabAP", complete = true)
91
- assertEquals(listOf("LabAP"), scan.await().map { it.ssid })
92
- }
93
-
94
- @Test
95
- fun `legacy uncorrelated results are never a correlated completion`() = runBlocking {
96
- val scan = async(start = CoroutineStart.UNDISPATCHED) { sdk.requestWifiScan() }
97
- val scanId = freshScanId()
98
- chunk(null, "Legacy", complete = true)
99
- assertEquals(listOf("Legacy"), scan.await().map { it.ssid })
100
- val terminal = records("scan_terminal").single()
101
- assertEquals(scanId, terminal.getString("scanId"))
102
- assertEquals("legacy_uncorrelated", terminal.getString("result"))
103
- }
104
-
105
- @Test
106
- fun `a timeout that returns partial results is recorded as partial, not complete`() = runBlocking {
107
- val scan = async(start = CoroutineStart.UNDISPATCHED) { sdk.requestWifiScan() }
108
- val scanId = freshScanId()
109
- chunk(scanId, "LabAP", complete = false)
110
- // Customer behavior is unchanged: the promise still resolves with the partial list.
111
- assertEquals(listOf("LabAP"), scan.await().map { it.ssid })
112
- val terminal = records("scan_terminal").single()
113
- assertEquals(scanId, terminal.getString("scanId"))
114
- assertEquals("timeout_partial", terminal.getString("result"))
115
- assertEquals(1, terminal.getInt("networks"))
116
- }
117
- }