@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
@@ -43,6 +43,7 @@ import com.mentra.bluetoothsdk.DeviceStore
43
43
  import com.mentra.bluetoothsdk.ObservableStore
44
44
  import com.mentra.bluetoothsdk.incomingGlassesMessageAckId
45
45
  import com.mentra.bluetoothsdk.wifiResponseEnvelopeIsValid
46
+ import com.mentra.bluetoothsdk.debug.BleEvidenceLog
46
47
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
47
48
  import com.mentra.bluetoothsdk.utils.BlePhotoUploadService
48
49
  import com.mentra.bluetoothsdk.utils.ConnTypes
@@ -597,6 +598,12 @@ class MentraLive : SGCManager() {
597
598
  private val gattLifecycleHandler = Handler(Looper.getMainLooper())
598
599
  // Owned exclusively by the main looper, including callback validation and mutation.
599
600
  private var gattEpoch = 0L
601
+ // Provenance of the current accepted connection and wire epoch. Copied into each accepted
602
+ // notification before decoding; never re-read to label an already received message.
603
+ private var evidenceOrigin: BleEvidenceLog.Origin? = null
604
+ // Evidence boundary of the current bleSessionGeneration (seq of its ble_generation record).
605
+ @Volatile private var evidenceWriteGeneration = 0L
606
+ @Volatile private var evidenceWriteGenerationValue = -1L
600
607
  private var connectionRequestEpoch = 0L
601
608
  @Volatile private var gattTeardownToken: Long? = null
602
609
  @Volatile private var gattTeardownTimeoutRunnable: Runnable? = null
@@ -650,7 +657,9 @@ class MentraLive : SGCManager() {
650
657
  val commandType: String,
651
658
  val requestId: String?,
652
659
  val appId: String?,
653
- val messageId: Long?
660
+ val messageId: Long?,
661
+ // Wi-Fi scan request id; every native write of that command is recorded against it.
662
+ val scanId: String? = null
654
663
  )
655
664
 
656
665
  private data class BleWriteTrace(
@@ -666,7 +675,11 @@ class MentraLive : SGCManager() {
666
675
  val packedBytes: Int,
667
676
  val wakeup: Boolean,
668
677
  val chunked: Boolean,
669
- val queuedAtMs: Long
678
+ val queuedAtMs: Long,
679
+ val scanId: String? = null,
680
+ // Provenance captured when the write is queued; later outcomes never re-read state.
681
+ val evidenceConnection: Long = 0L,
682
+ val evidenceWriteGeneration: Long? = null
670
683
  )
671
684
 
672
685
  private data class QueuedBleWrite(
@@ -1024,6 +1037,7 @@ class MentraLive : SGCManager() {
1024
1037
  this.type = DeviceTypes.LIVE
1025
1038
  this.hasMic = true
1026
1039
  this.context = Bridge.getContext()
1040
+ recordWriteGeneration(bleSessionGeneration.get())
1027
1041
 
1028
1042
  // Initialize bluetooth adapter
1029
1043
  val bluetoothManager =
@@ -1179,6 +1193,9 @@ class MentraLive : SGCManager() {
1179
1193
  if (state == ConnTypes.DISCONNECTED) {
1180
1194
  DeviceStore.apply("glasses", "hotspotOtaVersion", 0)
1181
1195
  }
1196
+ if (state == ConnTypes.DISCONNECTED) {
1197
+ closeEvidenceConnection("disconnected")
1198
+ }
1182
1199
  val isEqual = state == connectionState
1183
1200
  if (isEqual) {
1184
1201
  if (state == ConnTypes.DISCONNECTED) {
@@ -1692,6 +1709,7 @@ class MentraLive : SGCManager() {
1692
1709
  }
1693
1710
  val token = gattTeardownBarrier.beginTeardown()
1694
1711
  gattTeardownToken = token
1712
+ closeEvidenceConnection("teardown")
1695
1713
  // Teardown owns session invalidation, including the timeout/replacement paths that do
1696
1714
  // not receive the normal remote-disconnect cleanup callback.
1697
1715
  cancelMtuWatchdog()
@@ -1765,6 +1783,34 @@ class MentraLive : SGCManager() {
1765
1783
  mtuSetupGate.cancel()
1766
1784
  }
1767
1785
 
1786
+ private fun closeEvidenceConnection(reason: String) {
1787
+ evidenceOrigin?.let { BleEvidenceLog.connectionClosed(it.connection, reason) }
1788
+ evidenceOrigin = null
1789
+ }
1790
+
1791
+ /** RX/TX carry every JSON/K900 message; audio and file characteristics are never evidence. */
1792
+ private fun isCommandCharacteristic(characteristic: BluetoothGattCharacteristic): Boolean =
1793
+ characteristic.uuid == RX_CHAR_UUID || characteristic.uuid == TX_CHAR_UUID
1794
+
1795
+ private fun recordScanSend(trace: BleWriteTrace?, outcome: String) {
1796
+ val scanId = trace?.scanId ?: return
1797
+ BleEvidenceLog.scanSend(
1798
+ scanId,
1799
+ outcome,
1800
+ trace.chunkIndex ?: 0,
1801
+ trace.totalChunks ?: 1,
1802
+ trace.evidenceConnection,
1803
+ trace.evidenceWriteGeneration
1804
+ )
1805
+ }
1806
+
1807
+ /** Records the send-queue generation boundary that queued writes will reference. */
1808
+ private fun recordWriteGeneration(generation: Long) {
1809
+ evidenceWriteGeneration =
1810
+ BleEvidenceLog.bleGeneration(evidenceOrigin?.connection ?: 0L, generation)
1811
+ evidenceWriteGenerationValue = generation
1812
+ }
1813
+
1768
1814
  /**
1769
1815
  * Reject callbacks delivered by a GATT object from an earlier connection attempt. Without
1770
1816
  * this identity check, a late disconnect can tear down the active connection and clear its
@@ -2276,6 +2322,15 @@ class MentraLive : SGCManager() {
2276
2322
  // This lets connectGatt return and install the identity before validation.
2277
2323
  gattLifecycleHandler.post { work() }
2278
2324
  }, { gatt -> epoch == gattEpoch && isCurrentGattCallback(gatt) }) {
2325
+ override fun onStaleNotification(
2326
+ gatt: BluetoothGatt,
2327
+ characteristic: BluetoothGattCharacteristic
2328
+ ) {
2329
+ if (isCommandCharacteristic(characteristic)) {
2330
+ BleEvidenceLog.receiveRejected("stale_gatt", null)
2331
+ }
2332
+ }
2333
+
2279
2334
  override fun handleConnectionStateChange(
2280
2335
  gatt: BluetoothGatt,
2281
2336
  status: Int,
@@ -2313,6 +2368,8 @@ class MentraLive : SGCManager() {
2313
2368
  isConnecting = false
2314
2369
  isConnected = true
2315
2370
  connectedDevice = gatt.device
2371
+ closeEvidenceConnection("replaced")
2372
+ evidenceOrigin = BleEvidenceLog.connectionAccepted(gatt.device.address)
2316
2373
  classicAudioConnectionTracker.setTarget(gatt.device.address)
2317
2374
  refreshClassicAudioConnectionState(gatt.device)
2318
2375
  emitConnectedPendingDeviceForPairingScan()
@@ -2384,6 +2441,7 @@ class MentraLive : SGCManager() {
2384
2441
  )
2385
2442
  isConnected = false
2386
2443
  isConnecting = false
2444
+ closeEvidenceConnection("remote_disconnect")
2387
2445
 
2388
2446
  connectedDevice = null
2389
2447
  glassesReady = false // Reset ready state on disconnect
@@ -2700,6 +2758,10 @@ class MentraLive : SGCManager() {
2700
2758
  else null
2701
2759
  inFlightBleWriteTrace = null
2702
2760
  inFlightBleWriteStartedAtMs = 0L
2761
+ recordScanSend(
2762
+ trace,
2763
+ if (status == BluetoothGatt.GATT_SUCCESS) "write_ok" else "write_failed"
2764
+ )
2703
2765
 
2704
2766
  if (status == BluetoothGatt.GATT_SUCCESS) {
2705
2767
  // Bridge.log("LIVE: Characteristic write successful");
@@ -2759,8 +2821,13 @@ class MentraLive : SGCManager() {
2759
2821
  data: ByteArray
2760
2822
  ) {
2761
2823
  if (!isActiveGattCallback(gatt)) {
2824
+ if (isCommandCharacteristic(characteristic)) {
2825
+ BleEvidenceLog.receiveRejected("teardown", null)
2826
+ }
2762
2827
  return
2763
2828
  }
2829
+ // Immutable origin of this notification, captured before any decoding.
2830
+ val origin = evidenceOrigin
2764
2831
 
2765
2832
  // Get thread ID for tracking thread issues
2766
2833
  val threadId = Thread.currentThread().id
@@ -2807,7 +2874,7 @@ class MentraLive : SGCManager() {
2807
2874
  }
2808
2875
 
2809
2876
  // Process command/JSON data on RX/TX characteristics
2810
- processReceivedData(data, data.size)
2877
+ processReceivedData(data, data.size, origin)
2811
2878
  }
2812
2879
 
2813
2880
  override fun handleDescriptorWrite(
@@ -3223,6 +3290,7 @@ class MentraLive : SGCManager() {
3223
3290
  var queuedWrite = sendQueue.poll()
3224
3291
  val currentGeneration = bleSessionGeneration.get()
3225
3292
  while (queuedWrite != null && queuedWrite.sessionGeneration != currentGeneration) {
3293
+ recordScanSend(queuedWrite.trace, "stale_dropped")
3226
3294
  Bridge.log(
3227
3295
  "LIVE: Dropping stale queued write from BLE session " +
3228
3296
  queuedWrite.sessionGeneration +
@@ -3258,10 +3326,15 @@ class MentraLive : SGCManager() {
3258
3326
 
3259
3327
  /** Send data through BLE */
3260
3328
  private fun sendDataInternal(write: QueuedBleWrite?) {
3261
- if (!isConnected || write == null) {
3329
+ if (write == null) {
3330
+ return
3331
+ }
3332
+ if (!isConnected) {
3333
+ recordScanSend(write.trace, "gatt_refused")
3262
3334
  return
3263
3335
  }
3264
3336
  if (write.sessionGeneration != bleSessionGeneration.get()) {
3337
+ recordScanSend(write.trace, "stale_dropped")
3265
3338
  Bridge.log("LIVE: Dropping stale BLE write before GATT transmission")
3266
3339
  return
3267
3340
  }
@@ -3269,6 +3342,7 @@ class MentraLive : SGCManager() {
3269
3342
  val gatt = bluetoothGatt
3270
3343
  val characteristic = txCharacteristic
3271
3344
  if (gatt == null || characteristic == null) {
3345
+ recordScanSend(write.trace, "gatt_refused")
3272
3346
  return
3273
3347
  }
3274
3348
 
@@ -3278,6 +3352,7 @@ class MentraLive : SGCManager() {
3278
3352
  inFlightBleWriteTrace = write.trace
3279
3353
  inFlightBleWriteStartedAtMs = writeStartedAtMs
3280
3354
  val writeAccepted = gatt.writeCharacteristic(characteristic)
3355
+ recordScanSend(write.trace, if (writeAccepted) "gatt_accepted" else "gatt_refused")
3281
3356
  logBleWriteTrace(
3282
3357
  "write_call",
3283
3358
  write.trace,
@@ -3294,6 +3369,7 @@ class MentraLive : SGCManager() {
3294
3369
  inFlightBleWriteStartedAtMs = 0L
3295
3370
  }
3296
3371
  } catch (e: Exception) {
3372
+ recordScanSend(write.trace, "gatt_refused")
3297
3373
  Log.e(TAG, "Error sending data via BLE", e)
3298
3374
  logBleWriteTrace(
3299
3375
  "write_exception",
@@ -3320,9 +3396,16 @@ class MentraLive : SGCManager() {
3320
3396
  queuedAtMs =
3321
3397
  if (trace.queuedAtMs > 0L)
3322
3398
  trace.queuedAtMs
3323
- else System.currentTimeMillis()
3399
+ else System.currentTimeMillis(),
3400
+ evidenceConnection = evidenceOrigin?.connection ?: 0L,
3401
+ // A write carrying an older generation is already stale: no boundary.
3402
+ evidenceWriteGeneration =
3403
+ if (sessionGeneration == evidenceWriteGenerationValue)
3404
+ evidenceWriteGeneration
3405
+ else null
3324
3406
  )
3325
3407
  sendQueue.add(QueuedBleWrite(data, queuedTrace, sessionGeneration))
3408
+ recordScanSend(queuedTrace, "queued")
3326
3409
  logBleChunkTrace(
3327
3410
  "queued",
3328
3411
  queuedTrace,
@@ -3543,6 +3626,7 @@ class MentraLive : SGCManager() {
3543
3626
  */
3544
3627
  private fun resetPendingAckState() {
3545
3628
  val nextGeneration = bleSessionGeneration.incrementAndGet()
3629
+ recordWriteGeneration(nextGeneration)
3546
3630
  val pendingCount = pendingMessages.size
3547
3631
  pendingMessages.clear()
3548
3632
  if (pendingCount > 0) {
@@ -3871,7 +3955,7 @@ class MentraLive : SGCManager() {
3871
3955
  }
3872
3956
 
3873
3957
  /** Process data received from the glasses */
3874
- private fun processReceivedData(data: ByteArray?, size: Int) {
3958
+ private fun processReceivedData(data: ByteArray?, size: Int, origin: BleEvidenceLog.Origin?) {
3875
3959
  // Bridge.log("LIVE: Processing received data: " + bytesToHex(data));
3876
3960
 
3877
3961
  // Check if we have enough data
@@ -3938,7 +4022,7 @@ class MentraLive : SGCManager() {
3938
4022
  }
3939
4023
 
3940
4024
  if (cmdType == K900ProtocolUtils.CMD_TYPE_BINARY_MSG) {
3941
- processBinaryWireFrame(data)
4025
+ processBinaryWireFrame(data, origin)
3942
4026
  return
3943
4027
  }
3944
4028
 
@@ -3956,7 +4040,7 @@ class MentraLive : SGCManager() {
3956
4040
  Log.w(TAG, "Thread-$threadId: Rejected unsupported compact wire form")
3957
4041
  return
3958
4042
  }
3959
- processJsonMessage(expanded)
4043
+ processJsonMessage(expanded, origin)
3960
4044
  } else {
3961
4045
  Log.w(TAG, "Thread-" + threadId + ": Failed to parse K900 protocol data")
3962
4046
  }
@@ -3981,7 +4065,7 @@ class MentraLive : SGCManager() {
3981
4065
  val jsonStr = String(data, 0, size, StandardCharsets.UTF_8)
3982
4066
  if (jsonStr.startsWith("{") && jsonStr.endsWith("}")) {
3983
4067
  val json = JSONObject(jsonStr)
3984
- processJsonMessage(json)
4068
+ processJsonMessage(json, origin)
3985
4069
  } else {
3986
4070
  Log.w(TAG, "Received data that starts with '{' but is not valid JSON")
3987
4071
  }
@@ -4004,7 +4088,7 @@ class MentraLive : SGCManager() {
4004
4088
  }
4005
4089
 
4006
4090
  /** Process a JSON message */
4007
- private fun processJsonMessage(json: JSONObject) {
4091
+ private fun processJsonMessage(json: JSONObject, origin: BleEvidenceLog.Origin?) {
4008
4092
  // Demoted from INFO (Bridge.log) to DEBUG: per-type handlers below already log
4009
4093
  // the messages that matter, and full payloads can leak PII (wifi SSID, bt_mac,
4010
4094
  // OTA URLs) into the persisted file logger when they arrive every ~50ms during OTA.
@@ -4016,7 +4100,7 @@ class MentraLive : SGCManager() {
4016
4100
  GlassesLinkDiagnostics.recordInbound()
4017
4101
 
4018
4102
  if (MessageChunker.isChunkedMessage(json)) {
4019
- processChunkedJsonMessage(json)
4103
+ processChunkedJsonMessage(json, origin)
4020
4104
  return
4021
4105
  }
4022
4106
 
@@ -4035,7 +4119,7 @@ class MentraLive : SGCManager() {
4035
4119
 
4036
4120
  // Check if this is a K900 command format (has "C" field instead of "type")
4037
4121
  if (json.has("C")) {
4038
- processK900JsonMessage(json)
4122
+ processK900JsonMessage(json, origin)
4039
4123
  return
4040
4124
  }
4041
4125
 
@@ -4131,7 +4215,15 @@ class MentraLive : SGCManager() {
4131
4215
  // that reads "charging" for most of a discharging pack's range. Charging
4132
4216
  // state comes exclusively from the PMU charg bit in the sr_hrt heartbeat.
4133
4217
  val percent = json.optInt("percent", batteryLevel)
4134
- updateBatteryStatus(percent, isCharging)
4218
+ // A missing or invalid field keeps the previous fallback display value, but only a
4219
+ // measured percentage carries provenance.
4220
+ updateBatteryStatus(
4221
+ percent,
4222
+ isCharging,
4223
+ measuredPercent(json, "percent")?.let {
4224
+ BatteryProvenance("battery_status", origin, null)
4225
+ }
4226
+ )
4135
4227
  }
4136
4228
  "stream_controller_probe" -> {
4137
4229
  val values = mapOf("protocolVersion" to json.opt("protocolVersion"),
@@ -4306,6 +4398,8 @@ class MentraLive : SGCManager() {
4306
4398
  "wifi_scan_result" -> {
4307
4399
  // Process WiFi scan results
4308
4400
  val networks: MutableList<Map<String, Any>> = ArrayList()
4401
+ // Evidence only: false when the network list is missing or not fully parsed.
4402
+ var networksParsed = false
4309
4403
 
4310
4404
  if (json.has("networks_neo")) {
4311
4405
  try {
@@ -4323,6 +4417,7 @@ class MentraLive : SGCManager() {
4323
4417
  }
4324
4418
  networks.add(networkMap)
4325
4419
  }
4420
+ networksParsed = true
4326
4421
 
4327
4422
  Bridge.log(
4328
4423
  "Received enhanced WiFi scan results: " +
@@ -4337,7 +4432,9 @@ class MentraLive : SGCManager() {
4337
4432
  val scanComplete =
4338
4433
  json.optBoolean("scan_complete", json.optBoolean("scanComplete", false))
4339
4434
  val scanId = json.optString("scanId", "").ifEmpty { null }
4340
- Bridge.updateWifiScanResults(networks, scanComplete, scanId)
4435
+ val eventId =
4436
+ BleEvidenceLog.scanChunk(origin, scanId, networks, scanComplete, networksParsed)
4437
+ Bridge.updateWifiScanResults(networks, scanComplete, scanId, eventId)
4341
4438
  }
4342
4439
  "token_status" -> {
4343
4440
  // Process coreToken acknowledgment
@@ -5211,7 +5308,7 @@ class MentraLive : SGCManager() {
5211
5308
  return value
5212
5309
  }
5213
5310
 
5214
- private fun processChunkedJsonMessage(json: JSONObject) {
5311
+ private fun processChunkedJsonMessage(json: JSONObject, origin: BleEvidenceLog.Origin?) {
5215
5312
  try {
5216
5313
  val chunkInfo = MessageChunker.getChunkInfo(json)
5217
5314
  if (chunkInfo == null) {
@@ -5229,19 +5326,24 @@ class MentraLive : SGCManager() {
5229
5326
  return
5230
5327
  }
5231
5328
 
5329
+ val mixedBefore = incomingChunkReassembler.mixedOriginDrops
5232
5330
  val reassembled =
5233
5331
  incomingChunkReassembler.addChunk(
5234
5332
  chunkInfo.chunkId,
5235
5333
  chunkInfo.chunkIndex,
5236
5334
  chunkInfo.totalChunks,
5237
- chunkInfo.data
5335
+ chunkInfo.data,
5336
+ origin
5238
5337
  )
5338
+ if (incomingChunkReassembler.mixedOriginDrops != mixedBefore) {
5339
+ BleEvidenceLog.receiveRejected("mixed_origin", origin)
5340
+ }
5239
5341
  if (reassembled == null) {
5240
5342
  return
5241
5343
  }
5242
5344
 
5243
5345
  val reassembledJson = JSONObject(reassembled)
5244
- processJsonMessage(reassembledJson)
5346
+ processJsonMessage(reassembledJson, origin)
5245
5347
  } catch (e: Exception) {
5246
5348
  Log.e(TAG, "Error processing chunked JSON message", e)
5247
5349
  }
@@ -5449,7 +5551,7 @@ class MentraLive : SGCManager() {
5449
5551
  }
5450
5552
  }
5451
5553
 
5452
- private fun processK900JsonMessage(json: JSONObject) {
5554
+ private fun processK900JsonMessage(json: JSONObject, origin: BleEvidenceLog.Origin?) {
5453
5555
  val command = json.optString("C", "")
5454
5556
  // Bridge.log("LIVE: Processing K900 command: " + command);
5455
5557
 
@@ -5493,7 +5595,17 @@ class MentraLive : SGCManager() {
5493
5595
  }
5494
5596
  val charg = bodyObj.optInt("charg", -1)
5495
5597
  if (batteryPercentage != -1 && charg != -1)
5496
- updateBatteryStatus(batteryPercentage, charg == 1)
5598
+ updateBatteryStatus(
5599
+ batteryPercentage,
5600
+ charg == 1,
5601
+ measuredPercent(bodyObj, "pt")?.let {
5602
+ BatteryProvenance(
5603
+ "k900_sr_hrt",
5604
+ origin,
5605
+ pmuChargingBit(bodyObj)
5606
+ )
5607
+ }
5608
+ )
5497
5609
  }
5498
5610
  } catch (e: Exception) {
5499
5611
  Log.e(TAG, "Error parsing sr_hrt response", e)
@@ -5524,7 +5636,13 @@ class MentraLive : SGCManager() {
5524
5636
  // charging from voltage (>4.0V) reads "not charging" for most of a
5525
5637
  // genuinely-charging pack's range. Charging state comes exclusively
5526
5638
  // from the PMU charg bit in the sr_hrt heartbeat.
5527
- updateBatteryStatus(batteryPercentage, isCharging)
5639
+ updateBatteryStatus(
5640
+ batteryPercentage,
5641
+ isCharging,
5642
+ measuredPercent(bodyObj, "pt")?.let {
5643
+ BatteryProvenance("k900_sr_batv", origin, null)
5644
+ }
5645
+ )
5528
5646
  }
5529
5647
  } catch (e: Exception) {
5530
5648
  Log.e(TAG, "Error parsing sr_batv response", e)
@@ -5761,7 +5879,7 @@ class MentraLive : SGCManager() {
5761
5879
  Log.d(TAG, "🔓 Unwrapping and processing through standard message handler")
5762
5880
 
5763
5881
  // Process through the standard message handler
5764
- processJsonMessage(innerJson)
5882
+ processJsonMessage(innerJson, origin)
5765
5883
  return // Exit after processing
5766
5884
  }
5767
5885
  } catch (e: JSONException) {
@@ -5875,7 +5993,44 @@ class MentraLive : SGCManager() {
5875
5993
  * Update battery status and notify listeners Matches iOS MentraLive.swift updateBatteryStatus
5876
5994
  * pattern
5877
5995
  */
5878
- private fun updateBatteryStatus(level: Int, isCharging: Boolean) {
5996
+ /**
5997
+ * [source] names the decoded message. [pmuCharging] is set only when the PMU charg bit of
5998
+ * this very message supplied [isCharging]; percent-only sources re-pass the inherited value.
5999
+ */
6000
+ /**
6001
+ * Provenance of a battery update decoded from one notification. [pmuCharging] is set only
6002
+ * when the PMU charg bit of that message supplied the charging state.
6003
+ */
6004
+ private data class BatteryProvenance(
6005
+ val source: String,
6006
+ val origin: BleEvidenceLog.Origin?,
6007
+ val pmuCharging: Boolean?
6008
+ )
6009
+
6010
+ /**
6011
+ * The sr_hrt PMU charging bit, only when `charg` is a number exactly equal to 0 or 1. The
6012
+ * original value is compared without truncation, so 0.5 or 1.5 is not a bit.
6013
+ */
6014
+ private fun pmuChargingBit(body: JSONObject): Boolean? =
6015
+ when ((body.opt("charg") as? Number)?.toDouble()) {
6016
+ 1.0 -> true
6017
+ 0.0 -> false
6018
+ else -> null
6019
+ }
6020
+
6021
+ /** The field's value when it is an actual integer percentage, else null (missing/invalid). */
6022
+ private fun measuredPercent(obj: JSONObject, key: String): Int? {
6023
+ val value = obj.opt(key) as? Number ?: return null
6024
+ val asDouble = value.toDouble()
6025
+ if (asDouble != Math.floor(asDouble) || asDouble < 0 || asDouble > 100) return null
6026
+ return asDouble.toInt()
6027
+ }
6028
+
6029
+ /**
6030
+ * [provenance] is null for fallback or cached values: they are still displayed as before but
6031
+ * carry no event id, so they can never count as a fresh measurement.
6032
+ */
6033
+ private fun updateBatteryStatus(level: Int, isCharging: Boolean, provenance: BatteryProvenance?) {
5879
6034
  // Keep the field in sync: percent-only messages (battery_status/sr_batv) re-pass
5880
6035
  // it as the last-known charging state, so a stale field would clobber the value
5881
6036
  // the sr_hrt PMU charg bit established.
@@ -5886,7 +6041,11 @@ class MentraLive : SGCManager() {
5886
6041
  DeviceStore.apply("glasses", "charging", isCharging)
5887
6042
 
5888
6043
  if (level >= 0) {
5889
- Bridge.sendBatteryStatus(level, isCharging)
6044
+ val eventId =
6045
+ provenance?.let {
6046
+ BleEvidenceLog.battery(it.origin, level, it.source, it.pmuCharging)
6047
+ }
6048
+ Bridge.sendBatteryStatus(level, isCharging, eventId)
5890
6049
  }
5891
6050
  }
5892
6051
 
@@ -6063,7 +6222,17 @@ class MentraLive : SGCManager() {
6063
6222
  if (!scanId.isNullOrEmpty()) {
6064
6223
  json.put("scanId", scanId)
6065
6224
  }
6066
- sendJson(json, true)
6225
+ val queued = sendJson(json, true, true)
6226
+ if (!queued && !scanId.isNullOrEmpty()) {
6227
+ BleEvidenceLog.scanSend(
6228
+ scanId,
6229
+ "not_queued",
6230
+ 0,
6231
+ 0,
6232
+ evidenceOrigin?.connection ?: 0L,
6233
+ evidenceWriteGeneration
6234
+ )
6235
+ }
6067
6236
  Bridge.log("LIVE: Sending WiFi scan request to glasses")
6068
6237
  } catch (e: JSONException) {
6069
6238
  Log.e(TAG, "Error creating WiFi scan request", e)
@@ -9009,6 +9178,9 @@ class MentraLive : SGCManager() {
9009
9178
  * the per-session handshake attempt cap) instead of trusting stale v2-active state.
9010
9179
  */
9011
9180
  private fun resetWireNegotiationState() {
9181
+ evidenceOrigin?.let {
9182
+ evidenceOrigin = it.copy(wire = BleEvidenceLog.wireReset(it.connection))
9183
+ }
9012
9184
  incomingChunkReassembler.clear()
9013
9185
  peerWireProtocolVersion = 0
9014
9186
  useBinaryWireProtocol = false
@@ -9244,7 +9416,7 @@ class MentraLive : SGCManager() {
9244
9416
  activateBinaryWireV2Session("LIVE: Peer confirmed BLE wire protocol v2")
9245
9417
  }
9246
9418
 
9247
- private fun processBinaryWireFrame(data: ByteArray) {
9419
+ private fun processBinaryWireFrame(data: ByteArray, origin: BleEvidenceLog.Origin?) {
9248
9420
  val info = K900ProtocolUtils.extractBinaryFragmentInfo(data) ?: run {
9249
9421
  Log.w(TAG, "Failed to parse binary wire frame")
9250
9422
  return
@@ -9261,14 +9433,19 @@ class MentraLive : SGCManager() {
9261
9433
  )
9262
9434
  }
9263
9435
 
9436
+ val mixedBefore = incomingChunkReassembler.mixedOriginDrops
9264
9437
  val reassembled =
9265
9438
  incomingChunkReassembler.addBinaryFragment(
9266
9439
  info.msgId,
9267
9440
  info.fragIdx,
9268
9441
  info.fragCount,
9269
- info.payload
9442
+ info.payload,
9443
+ origin
9270
9444
  )
9271
- ?: return
9445
+ if (incomingChunkReassembler.mixedOriginDrops != mixedBefore) {
9446
+ BleEvidenceLog.receiveRejected("mixed_origin", origin)
9447
+ }
9448
+ if (reassembled == null) return
9272
9449
 
9273
9450
  try {
9274
9451
  val jsonStr = String(reassembled, StandardCharsets.UTF_8)
@@ -9284,7 +9461,7 @@ class MentraLive : SGCManager() {
9284
9461
  BleWireProtocol.PROTOCOL_V2,
9285
9462
  "glasses_to_phone"
9286
9463
  )
9287
- processJsonMessage(json)
9464
+ processJsonMessage(json, origin)
9288
9465
  } catch (e: JSONException) {
9289
9466
  Log.e(TAG, "Failed to parse reassembled binary wire JSON", e)
9290
9467
  }
@@ -9629,11 +9806,15 @@ class MentraLive : SGCManager() {
9629
9806
  private fun parseOutgoingBleCommandTraceInfo(payload: String): OutgoingBleCommandTraceInfo {
9630
9807
  return try {
9631
9808
  val obj = JSONObject(payload)
9809
+ val commandType = obj.optString("type", "unknown")
9632
9810
  OutgoingBleCommandTraceInfo(
9633
- commandType = obj.optString("type", "unknown"),
9811
+ commandType = commandType,
9634
9812
  requestId = optNonBlankString(obj, "requestId"),
9635
9813
  appId = optNonBlankString(obj, "appId"),
9636
- messageId = if (obj.has("mId")) obj.optLong("mId") else null
9814
+ messageId = if (obj.has("mId")) obj.optLong("mId") else null,
9815
+ scanId =
9816
+ if (commandType == "request_wifi_scan") optNonBlankString(obj, "scanId")
9817
+ else null
9637
9818
  )
9638
9819
  } catch (_: Exception) {
9639
9820
  OutgoingBleCommandTraceInfo(
@@ -9668,7 +9849,8 @@ class MentraLive : SGCManager() {
9668
9849
  packedBytes = packedBytes,
9669
9850
  wakeup = wakeup,
9670
9851
  chunked = chunked,
9671
- queuedAtMs = System.currentTimeMillis()
9852
+ queuedAtMs = System.currentTimeMillis(),
9853
+ scanId = commandInfo.scanId
9672
9854
  )
9673
9855
  }
9674
9856
 
@@ -18,6 +18,17 @@ internal abstract class SerializedGattCallback(
18
18
  enqueue { if (isCurrent(gatt)) work() }
19
19
  }
20
20
 
21
+ private fun dispatchNotification(
22
+ gatt: BluetoothGatt,
23
+ characteristic: BluetoothGattCharacteristic,
24
+ work: () -> Unit,
25
+ ) {
26
+ enqueue { if (isCurrent(gatt)) work() else onStaleNotification(gatt, characteristic) }
27
+ }
28
+
29
+ /** A notification from a GATT that is no longer current was dropped on the owner queue. */
30
+ protected open fun onStaleNotification(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {}
31
+
21
32
  final override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) =
22
33
  dispatch(gatt) { handleConnectionStateChange(gatt, status, newState) }
23
34
 
@@ -46,14 +57,14 @@ internal abstract class SerializedGattCallback(
46
57
  gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic,
47
58
  ) {
48
59
  val value = characteristic.value?.copyOf() ?: return
49
- dispatch(gatt) { handleCharacteristicChanged(gatt, characteristic, value) }
60
+ dispatchNotification(gatt, characteristic) { handleCharacteristicChanged(gatt, characteristic, value) }
50
61
  }
51
62
 
52
63
  final override fun onCharacteristicChanged(
53
64
  gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray,
54
65
  ) {
55
66
  val snapshot = value.copyOf()
56
- dispatch(gatt) { handleCharacteristicChanged(gatt, characteristic, snapshot) }
67
+ dispatchNotification(gatt, characteristic) { handleCharacteristicChanged(gatt, characteristic, snapshot) }
57
68
  }
58
69
 
59
70
  final override fun onDescriptorWrite(