@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.
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +7 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +17 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleEvidenceLog.kt +454 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +21 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +213 -31
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +13 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +41 -6
- package/android/src/test/java/com/mentra/bluetoothsdk/WifiScanEvidenceTest.kt +117 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleEvidenceLogTest.kt +282 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveEvidenceTest.kt +603 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/MessageChunkReassemblerOriginTest.kt +51 -0
- package/build/BluetoothSdk.types.d.ts +10 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +9 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +2 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/diagnosticRender.d.ts +12 -0
- package/build/_private/diagnosticRender.d.ts.map +1 -0
- package/build/_private/diagnosticRender.js +27 -0
- package/build/_private/diagnosticRender.js.map +1 -0
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +10 -0
- package/src/_private/BluetoothSdkModule.ts +13 -0
- package/src/_private/diagnosticRender.ts +35 -0
- 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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
7
|
-
const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
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.
|
|
10
|
-
const val OTA_MANIFEST_SHA256: String = "
|
|
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
|
}
|