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

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 +7 -0
  2. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +17 -3
  3. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  4. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +11 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleEvidenceLog.kt +454 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +21 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +213 -31
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +13 -2
  9. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +41 -6
  10. package/android/src/test/java/com/mentra/bluetoothsdk/WifiScanEvidenceTest.kt +117 -0
  11. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleEvidenceLogTest.kt +282 -0
  12. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveEvidenceTest.kt +603 -0
  13. package/android/src/test/java/com/mentra/bluetoothsdk/utils/MessageChunkReassemblerOriginTest.kt +51 -0
  14. package/build/BluetoothSdk.types.d.ts +10 -0
  15. package/build/BluetoothSdk.types.d.ts.map +1 -1
  16. package/build/BluetoothSdk.types.js.map +1 -1
  17. package/build/_private/BluetoothSdkModule.d.ts +9 -0
  18. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  19. package/build/_private/BluetoothSdkModule.js +2 -0
  20. package/build/_private/BluetoothSdkModule.js.map +1 -1
  21. package/build/_private/diagnosticRender.d.ts +12 -0
  22. package/build/_private/diagnosticRender.d.ts.map +1 -0
  23. package/build/_private/diagnosticRender.js +27 -0
  24. package/build/_private/diagnosticRender.js.map +1 -0
  25. package/build/generated/releaseMetadata.js +5 -5
  26. package/build/generated/releaseMetadata.js.map +1 -1
  27. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  28. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  29. package/package.json +2 -2
  30. package/src/BluetoothSdk.types.ts +10 -0
  31. package/src/_private/BluetoothSdkModule.ts +13 -0
  32. package/src/_private/diagnosticRender.ts +35 -0
  33. package/src/generated/releaseMetadata.ts +5 -5
@@ -4,6 +4,7 @@ package com.mentra.bluetoothsdk
4
4
 
5
5
  import androidx.lifecycle.Lifecycle
6
6
  import androidx.lifecycle.LifecycleOwner
7
+ import com.mentra.bluetoothsdk.debug.BleEvidenceLog
7
8
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
8
9
  import com.mentra.bluetoothsdk.utils.DeviceTypes
9
10
  import com.mentra.bluetoothsdk.utils.audio.PcmStreamManager
@@ -632,6 +633,12 @@ class BluetoothSdkModule : Module() {
632
633
  deviceManager?.sendPhoneNotification(notification)
633
634
  }
634
635
 
636
+ // Diagnostic correlation of committed UI state with native provenance ids. Accepts only
637
+ // allowlisted surfaces and bounded integers; returns false for anything else.
638
+ Function("reportDiagnosticRender") { surface: String, eventIds: List<String>, value: Int? ->
639
+ BleEvidenceLog.uiRender(surface, eventIds, value)
640
+ }
641
+
635
642
  // MARK: - WiFi Commands
636
643
 
637
644
  SdkCoroutineFunction("requestWifiScan") { -> requireSdk().requestWifiScan().map { it.toMap() } }
@@ -9,6 +9,7 @@ package com.mentra.bluetoothsdk
9
9
 
10
10
  import android.util.Base64
11
11
  import android.util.Log
12
+ import com.mentra.bluetoothsdk.debug.BleEvidenceLog
12
13
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
13
14
  import java.util.HashMap
14
15
  import java.util.UUID
@@ -248,13 +249,18 @@ public class Bridge private constructor() {
248
249
  sendTypedMessage("speaking_status", body as Map<String, Any>)
249
250
  }
250
251
 
251
- /** Send battery status */
252
+ /**
253
+ * Send battery status. [eventId] is the opaque native provenance id of the decoded
254
+ * notification (see BleEvidenceLog); it is diagnostic only.
255
+ */
252
256
  @JvmStatic
253
- fun sendBatteryStatus(level: Int, charging: Boolean) {
257
+ @JvmOverloads
258
+ fun sendBatteryStatus(level: Int, charging: Boolean, eventId: String? = null) {
254
259
  val body = HashMap<String, Any>()
255
260
  body["level"] = level
256
261
  body["charging"] = charging
257
262
  body["timestamp"] = System.currentTimeMillis()
263
+ if (eventId != null) body["eventId"] = eventId
258
264
  sendTypedMessage("battery_status", body as Map<String, Any>)
259
265
  }
260
266
 
@@ -696,10 +702,12 @@ public class Bridge private constructor() {
696
702
 
697
703
  /** Send WiFi scan results */
698
704
  @JvmStatic
705
+ @JvmOverloads
699
706
  fun updateWifiScanResults(
700
707
  networks: List<Map<String, Any>>,
701
708
  scanComplete: Boolean,
702
- scanId: String? = null
709
+ scanId: String? = null,
710
+ eventId: String? = null
703
711
  ) {
704
712
  // Only chunks echoing the active scanId claimed at request time may mutate
705
713
  // the store; foreign chunks are still forwarded to the SDK sink, which
@@ -737,6 +745,9 @@ public class Bridge private constructor() {
737
745
  if (scanId != null) {
738
746
  body["scanId"] = scanId
739
747
  }
748
+ if (eventId != null) {
749
+ body["eventId"] = eventId
750
+ }
740
751
  sendTypedMessage("wifi_scan_result", body)
741
752
  }
742
753
 
@@ -930,6 +941,9 @@ public class Bridge private constructor() {
930
941
 
931
942
  try {
932
943
  val sinks = getEventSinks()
944
+ (mutableBody["eventId"] as? String)?.let {
945
+ BleEvidenceLog.bridgeDispatch(it, type, sinks.size)
946
+ }
933
947
  if (sinks.isEmpty()) {
934
948
  Log.w(
935
949
  TAG,
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
5
  const val FAMILY_BASE_VERSION: String = "3.3.0"
6
- const val RELEASE_IDENTITY: String = "3.3.0-dev.420"
7
- const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.420"
8
- const val SOURCE_COMMIT: String = "cad421d261e3dc99f743ba902a7e0c4a0f42b095"
9
- const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.420.json"
10
- const val OTA_MANIFEST_SHA256: String = "0bff87c1bcd2f19eb90079980fc42fe2dbd787ceb0a74bea56b8948f3b4190dc"
6
+ const val RELEASE_IDENTITY: String = "3.3.0-dev.421"
7
+ const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.421"
8
+ const val SOURCE_COMMIT: String = "0b80a3c5c01206548775f1d149dae7150bf4c7ea"
9
+ const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.421.json"
10
+ const val OTA_MANIFEST_SHA256: String = "b62d3567ba6482cca927c0a2425157473bc089c22aeb24197a89729e518a5243"
11
11
  }
@@ -8,6 +8,7 @@ import android.content.Context
8
8
  import android.os.Bundle
9
9
  import android.os.Handler
10
10
  import android.os.Looper
11
+ import com.mentra.bluetoothsdk.debug.BleEvidenceLog
11
12
  import com.mentra.bluetoothsdk.streaming.StreamSessionState
12
13
  import com.mentra.bluetoothsdk.utils.ControllerTypes
13
14
  import com.mentra.bluetoothsdk.utils.PhoneAudioMonitor
@@ -885,6 +886,8 @@ class MentraBluetoothSdk private constructor(
885
886
  if (existing != null) {
886
887
  // Join the in-flight scan instead of failing with request_in_flight;
887
888
  // the scan screen auto-starts a scan on mount and can be pushed twice.
889
+ // A joined caller did not start a scan; its results belong to the older request.
890
+ BleEvidenceLog.scanRequest(existing.scanId, fresh = false)
888
891
  try {
889
892
  return existing.pending.await(WIFI_SCAN_TIMEOUT_MS)
890
893
  } finally {
@@ -896,6 +899,7 @@ class MentraBluetoothSdk private constructor(
896
899
  // out, so a delayed chunk from an older, abandoned scan can no longer
897
900
  // mutate it.
898
901
  Bridge.claimWifiScanResults(request.scanId)
902
+ BleEvidenceLog.scanRequest(request.scanId, fresh = true)
899
903
  deviceManager.requestWifiScan(request.scanId)
900
904
  // The glasses wait up to 15s for scan-results broadcasts before sending
901
905
  // scan_complete, so give them longer than that before falling back.
@@ -910,9 +914,14 @@ class MentraBluetoothSdk private constructor(
910
914
  val fallbackResults = synchronized(oneShotLock) { request.latestResults }
911
915
  if (fallbackResults.isNotEmpty()) {
912
916
  // Resolve so callers joined on the same scan get the fallback too.
917
+ // Partial results never count as a completed scan.
918
+ BleEvidenceLog.scanTerminal(request.scanId, "timeout_partial", fallbackResults.size)
913
919
  pending.resolve(fallbackResults)
914
920
  return fallbackResults
915
921
  }
922
+ BleEvidenceLog.scanTerminal(request.scanId, "timeout_empty", 0)
923
+ } else {
924
+ BleEvidenceLog.scanTerminal(request.scanId, "error", 0)
916
925
  }
917
926
  // Fail joined callers immediately instead of letting them run out their
918
927
  // own timeout.
@@ -2299,6 +2308,7 @@ class MentraBluetoothSdk private constructor(
2299
2308
  pendingWifiScan = null
2300
2309
  }
2301
2310
  }
2311
+ BleEvidenceLog.scanTerminal(request.scanId, "legacy_uncorrelated", results.size)
2302
2312
  request.pending.resolve(results)
2303
2313
  }
2304
2314
 
@@ -2328,6 +2338,7 @@ class MentraBluetoothSdk private constructor(
2328
2338
  pendingWifiScan = null
2329
2339
  }
2330
2340
  }
2341
+ BleEvidenceLog.scanTerminal(scanId, "complete", accumulated.size)
2331
2342
  request.pending.resolve(accumulated)
2332
2343
  }
2333
2344
 
@@ -0,0 +1,454 @@
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
+ }
@@ -9,13 +9,18 @@ import android.content.pm.PackageManager
9
9
  import android.content.pm.ServiceInfo
10
10
  import android.location.LocationManager
11
11
  import android.os.Build
12
+ import android.os.Handler
12
13
  import android.os.IBinder
14
+ import android.os.Looper
13
15
  import androidx.core.app.NotificationCompat
14
16
  import androidx.core.content.ContextCompat
15
17
  import com.mentra.bluetoothsdk.Bridge
16
18
  import com.mentra.bluetoothsdk.DeviceManager
17
19
  import com.mentra.bluetoothsdk.DeviceStore
20
+ import com.mentra.bluetoothsdk.debug.BleEvidenceLog
18
21
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
22
+ import java.io.FileDescriptor
23
+ import java.io.PrintWriter
19
24
 
20
25
  class ForegroundService : Service() {
21
26
  companion object {
@@ -336,4 +341,20 @@ class ForegroundService : Service() {
336
341
  }
337
342
 
338
343
  override fun onBind(intent: Intent?): IBinder? = null
344
+
345
+ /**
346
+ * Read-only BLE provenance snapshot for device tests:
347
+ * `adb shell dumpsys activity service <package>/com.mentra.bluetoothsdk.services.ForegroundService
348
+ * mentra-ble-evidence afterSeq=<n> [targetSha256=<hex>]`. Android only lets callers holding
349
+ * DUMP (shell/system) reach this. Every other dump request keeps the default behavior.
350
+ */
351
+ override fun dump(fd: FileDescriptor?, writer: PrintWriter?, args: Array<out String>?) {
352
+ val line = BleEvidenceLog.dump(args, Handler(Looper.getMainLooper()))
353
+ if (line == null) {
354
+ super.dump(fd, writer, args)
355
+ return
356
+ }
357
+ writer?.println(line)
358
+ writer?.flush()
359
+ }
339
360
  }