@mentra/bluetooth-sdk 3.4.0-dev.966 → 3.4.0-dev.971

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.
@@ -8,6 +8,7 @@ import android.os.Build
8
8
  import android.os.Handler
9
9
  import android.os.Looper
10
10
  import android.os.Process
11
+ import android.os.SystemClock
11
12
  import com.mentra.bluetoothsdk.BuildConfig
12
13
  import com.mentra.bluetoothsdk.DeviceManager
13
14
  import org.json.JSONObject
@@ -18,6 +19,7 @@ import java.util.UUID
18
19
  /** Fixed, DUMP-protected debug commands. Expected address is comparison-only, never a connection target.
19
20
  * No packet, device-command, caller path, reset, bond or radio inputs are accepted. */
20
21
  internal object NimoResourceDiagnostics {
22
+ const val CLOCK_PROTOCOL = "nimo-monotonic-receipt-v1"
21
23
  const val ACTION = "com.mentra.bluetoothsdk.NIMO_RESOURCE_LIFECYCLE"
22
24
  private val main = Handler(Looper.getMainLooper())
23
25
  private val processInstanceId = UUID.randomUUID().toString()
@@ -28,7 +30,8 @@ internal object NimoResourceDiagnostics {
28
30
  private val requests = linkedSetOf<String>()
29
31
  private data class Capture(val nonce: String, val name: String, val address: String,
30
32
  var instance: Nimo? = null, var cancelled: Boolean = false, var connectStarted: Boolean = false,
31
- var transportStarted: Boolean = false)
33
+ var transportStarted: Boolean = false, val armedElapsedMs: Long,
34
+ var admittedElapsedMs: Long? = null)
32
35
  // One diagnostic attempt per app process. Cancellation is a process-lifetime fence.
33
36
  @Volatile private var capture: Capture? = null
34
37
  internal fun preArmCaptureRequired(buildType: String = BuildConfig.BUILD_TYPE): Boolean = buildType == "nimoDiagnostic"
@@ -87,16 +90,21 @@ internal object NimoResourceDiagnostics {
87
90
  owned.instance != null && owned.instance !== instance || owned.connectStarted) return false
88
91
  if (owned.instance == null) {
89
92
  if (!instance.resourceCaptureEligible()) return false
93
+ val candidateAdmittedElapsedMs = SystemClock.elapsedRealtime()
90
94
  owned.instance = instance
91
95
  // Immutable admission survives a setup timeout before any GATT session exists.
92
- val app = application ?: return false
96
+ val app = application ?: run { owned.cancelled = true; return false }
93
97
  val root = File(app.filesDir, "nimo-lifecycle")
94
98
  val target = File(root, "${owned.nonce}.admission.json")
95
99
  if (target.exists()) { owned.cancelled = true; return false }
96
100
  val pending = File(root, "${owned.nonce}.admission.tmp")
97
101
  try {
98
- pending.outputStream().use { it.write(captureSnapshot(owned).toString().toByteArray(Charsets.UTF_8)); it.flush() }
102
+ pending.outputStream().use { it.write(captureSnapshot(owned, candidateAdmittedElapsedMs).toString().toByteArray(Charsets.UTF_8)); it.flush() }
99
103
  check(pending.renameTo(target))
104
+ // Durable admission is authoritative; no live clock may claim it
105
+ // before the original immutable receipt is safely committed.
106
+ instance.resourceCaptureAdmission(owned.nonce, owned.armedElapsedMs, candidateAdmittedElapsedMs)
107
+ owned.admittedElapsedMs = candidateAdmittedElapsedMs
100
108
  } catch (_: Exception) {
101
109
  owned.cancelled = true
102
110
  return false
@@ -124,7 +132,10 @@ internal object NimoResourceDiagnostics {
124
132
  }
125
133
 
126
134
  @Synchronized
127
- private fun captureSnapshot(owned: Capture): JSONObject = JSONObject()
135
+ private fun captureSnapshot(owned: Capture, admittedElapsedMs: Long? = owned.admittedElapsedMs): JSONObject = JSONObject()
136
+ .put("clockProtocol", CLOCK_PROTOCOL)
137
+ .put("clock", JSONObject().put("kind", "android-elapsed-realtime-v1").put("armedElapsedMs", owned.armedElapsedMs)
138
+ .put("admittedElapsedMs", admittedElapsedMs ?: JSONObject.NULL).put("observedElapsedMs", SystemClock.elapsedRealtime()))
128
139
  .put("version", "nimo-preconnect-capture-v1").put("nonce", owned.nonce)
129
140
  .put("captureRequiredBeforeArm", preArmCaptureRequired())
130
141
  .put("expectedPair", JSONObject().put("name", owned.name).put("bluetoothAddress", owned.address))
@@ -166,6 +177,7 @@ internal object NimoResourceDiagnostics {
166
177
  @Synchronized
167
178
  internal fun execute(intent: Intent) {
168
179
  check(Looper.myLooper() == main.looper)
180
+ val requestReceivedElapsedMs = SystemClock.elapsedRealtime()
169
181
  val nonce = intent.getStringExtra("nonce") ?: return
170
182
  if (!uuid(nonce) || nonce in requests) return
171
183
  val app = application ?: return
@@ -181,7 +193,9 @@ internal object NimoResourceDiagnostics {
181
193
  .put("instanceId", instanceId ?: JSONObject.NULL).put("sessionGeneration", if (generation >= 0) generation else JSONObject.NULL)
182
194
  fun save(session: JSONObject?, error: String? = null): Boolean {
183
195
  val current = DeviceManager.getInstance().sgc
184
- val receipt = JSONObject().put("schemaVersion", 1).put("request", request).put("observedAtEpochMs", System.currentTimeMillis())
196
+ val receipt = JSONObject().put("clockProtocol", CLOCK_PROTOCOL)
197
+ .put("clock", JSONObject().put("kind", "android-elapsed-realtime-v1").put("requestReceivedElapsedMs", requestReceivedElapsedMs).put("observedElapsedMs", SystemClock.elapsedRealtime()))
198
+ .put("schemaVersion", 1).put("request", request).put("observedAtEpochMs", System.currentTimeMillis())
185
199
  .put("appPid", Process.myPid()).put("processInstanceId", processInstanceId)
186
200
  .put("currentManager", JSONObject().put("type", current?.type ?: JSONObject.NULL)
187
201
  .put("instanceId", (current as? Nimo)?.resourceInstanceId ?: JSONObject.NULL))
@@ -207,7 +221,7 @@ internal object NimoResourceDiagnostics {
207
221
  if (capture != null || captureNonce != nonce || manager != null) {
208
222
  save(null, "capture-arm-refused-existing-owner"); return
209
223
  }
210
- capture = Capture(captureNonce, expectedName, address)
224
+ capture = Capture(captureNonce, expectedName, address, armedElapsedMs = requestReceivedElapsedMs)
211
225
  }
212
226
  val owned = capture
213
227
  if (owned == null || owned.nonce != captureNonce || owned.name != expectedName || owned.address != address) {
@@ -224,7 +238,7 @@ internal object NimoResourceDiagnostics {
224
238
  val original = owned.instance
225
239
  if (original == null) { captureReceipt(); return }
226
240
  if (DeviceManager.getInstance().sgc !== original) { captureReceipt(original.resourceSnapshot(), "capture-cancel-refused-replacement"); return }
227
- original.releaseCapturedResource({ DeviceManager.getInstance().sgc === original }) { result -> captureReceipt(result) }
241
+ original.releaseCapturedResource({ DeviceManager.getInstance().sgc === original }, { result -> captureReceipt(result) }, nonce, requestReceivedElapsedMs)
228
242
  return
229
243
  }
230
244
  if (mode !in listOf("status", "release") || !Regex("^[0-9A-F]{2}(:[0-9A-F]{2}){5}$").matches(address) ||
@@ -250,10 +264,10 @@ internal object NimoResourceDiagnostics {
250
264
  if (instanceId == null || generation < 1 || instance == null || instance !== current) {
251
265
  save(snapshot, "release-refused-replaced-or-unowned-target"); return
252
266
  }
253
- instance.releaseResource(generation, { DeviceManager.getInstance().sgc === instance }) { result ->
267
+ instance.releaseResource(generation, { DeviceManager.getInstance().sgc === instance }, { result ->
254
268
  track(instance)
255
269
  if (result.has("error")) save(instance.resourceSnapshot(), result.getString("error")) else save(result)
256
- }
270
+ }, nonce, requestReceivedElapsedMs)
257
271
  }
258
272
 
259
273
  private fun uuid(value: String): Boolean = try { UUID.fromString(value).toString() == value } catch (_: IllegalArgumentException) { false }
@@ -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.4.0"
6
- const val RELEASE_IDENTITY: String = "3.4.0-dev.966"
7
- const val RELEASE_SET_ID: String = "mentra-3.4.0-dev.966"
8
- const val SOURCE_COMMIT: String = "ec5c55f026bb7f8be86781b4f7b54b4b91034e30"
9
- const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.966.json"
10
- const val OTA_MANIFEST_SHA256: String = "2b6b90f176f747dc2c424edafa754ef5dcedd2217a67c91b33f13de36551153f"
6
+ const val RELEASE_IDENTITY: String = "3.4.0-dev.971"
7
+ const val RELEASE_SET_ID: String = "mentra-3.4.0-dev.971"
8
+ const val SOURCE_COMMIT: String = "c72b7ecf63112a98579639fc4fdb126aedbf425d"
9
+ const val OTA_MANIFEST_URL: String = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.971.json"
10
+ const val OTA_MANIFEST_SHA256: String = "5be88fa1406efdd5024b94ae18c9124f33ed1cd8e1661f5bcfbabc522a120037"
11
11
  }
@@ -969,6 +969,40 @@ class Nimo : SGCManager() {
969
969
  private var resourceClosingGatt: BluetoothGatt? = null
970
970
  private var resourceReleaseResult = "not-requested"
971
971
 
972
+ private var resourceCaptureNonce: String? = null
973
+ private var resourceArmedElapsedMs: Long? = null
974
+ private var resourceAdmittedElapsedMs: Long? = null
975
+ private var resourceSessionStartedElapsedMs: Long? = null
976
+ private var resourceReleaseNonce: String? = null
977
+ private var resourceReleaseStartedElapsedMs: Long? = null
978
+ private var firmwareElapsedMs: Long? = null
979
+ private var twsElapsedMs: Long? = null
980
+ private var handshakeTwsElapsedMs: Long? = null
981
+ private var timeWriteStartedElapsedMs: Long? = null
982
+ private var timeAckElapsedMs: Long? = null
983
+ private var micStopStartedElapsedMs: Long? = null
984
+ private var micStopMarkerElapsedMs: Long? = null
985
+ private var micStopWrittenElapsedMs: Long? = null
986
+ private var displayExitElapsedMs: Long? = null
987
+
988
+ // Keep paired clock sampling in this class even when the triggering callback
989
+ // is an escaping Kotlin lambda. This preserves event timing and permits APK
990
+ // bytecode inspection without treating an unrelated elapsed clock call as proof.
991
+ private fun recordResourceFirmwareClock() { resourceFirmwareAt = System.currentTimeMillis(); firmwareElapsedMs = android.os.SystemClock.elapsedRealtime() }
992
+ private fun recordResourceTwsClock() { resourceTwsAt = System.currentTimeMillis(); twsElapsedMs = android.os.SystemClock.elapsedRealtime() }
993
+ private fun recordResourceTimeWriteClock() { resourceTimeWriteStartedAt = System.currentTimeMillis(); timeWriteStartedElapsedMs = android.os.SystemClock.elapsedRealtime() }
994
+ private fun recordResourceTimeAckClock() { resourceTimeAckAt = System.currentTimeMillis(); timeAckElapsedMs = android.os.SystemClock.elapsedRealtime() }
995
+ private fun recordResourceMicStopStartedClock() { resourceMicStopStartedAt = System.currentTimeMillis(); micStopStartedElapsedMs = android.os.SystemClock.elapsedRealtime() }
996
+ private fun recordResourceMicStopMarkerClock() { resourceMicStopMarkerAt = System.currentTimeMillis(); micStopMarkerElapsedMs = android.os.SystemClock.elapsedRealtime() }
997
+ private fun recordResourceMicStopWrittenClock() { resourceMicStopWrittenAt = System.currentTimeMillis(); micStopWrittenElapsedMs = android.os.SystemClock.elapsedRealtime() }
998
+ private fun recordResourceExitClock() { resourceExitAt = System.currentTimeMillis(); displayExitElapsedMs = android.os.SystemClock.elapsedRealtime() }
999
+
1000
+ internal fun resourceCaptureAdmission(nonce: String, armed: Long, admitted: Long) {
1001
+ check(Looper.myLooper() == mainHandler.looper)
1002
+ check(resourceCaptureNonce == null && admitted >= armed)
1003
+ resourceCaptureNonce = nonce; resourceArmedElapsedMs = armed; resourceAdmittedElapsedMs = admitted
1004
+ }
1005
+
972
1006
  internal fun resourceCaptureEligible(): Boolean {
973
1007
  val snapshot = resourceSnapshot()
974
1008
  return resourceSessionGeneration == 0L && discoveryReceiver == null &&
@@ -982,6 +1016,20 @@ class Nimo : SGCManager() {
982
1016
  check(Looper.myLooper() == mainHandler.looper)
983
1017
  val pending = writes.pendingCount() + canvasEncoder.pendingCount() + audioClient.lifecyclePending() + resourcePostedWrites.get() + pendingAcks.size + canvas.pendingCount()
984
1018
  return JSONObject().put("observedAtEpochMs", System.currentTimeMillis()).put("sessionObservationCurrent", true).put("instanceId", resourceInstanceId).put("sessionGeneration", resourceSessionGeneration)
1019
+ .put("clock", JSONObject().put("kind", "android-elapsed-realtime-v1").put("observedElapsedMs", android.os.SystemClock.elapsedRealtime())
1020
+ .put("captureNonce", resourceCaptureNonce ?: JSONObject.NULL).put("armedElapsedMs", resourceArmedElapsedMs ?: JSONObject.NULL)
1021
+ .put("admittedElapsedMs", resourceAdmittedElapsedMs ?: JSONObject.NULL).put("sessionStartedElapsedMs", resourceSessionStartedElapsedMs ?: JSONObject.NULL)
1022
+ .put("releaseNonce", resourceReleaseNonce ?: JSONObject.NULL).put("releaseStartedElapsedMs", resourceReleaseStartedElapsedMs ?: JSONObject.NULL)
1023
+ .put("firmwareElapsedMs", firmwareElapsedMs ?: JSONObject.NULL)
1024
+ .put("twsElapsedMs", twsElapsedMs ?: JSONObject.NULL)
1025
+ .put("handshakeTwsElapsedMs", handshakeTwsElapsedMs ?: JSONObject.NULL)
1026
+ .put("timeWriteStartedElapsedMs", timeWriteStartedElapsedMs ?: JSONObject.NULL)
1027
+ .put("timeAckElapsedMs", timeAckElapsedMs ?: JSONObject.NULL)
1028
+ .put("micStopStartedElapsedMs", micStopStartedElapsedMs ?: JSONObject.NULL)
1029
+ .put("micStopMarkerElapsedMs", micStopMarkerElapsedMs ?: JSONObject.NULL)
1030
+ .put("micStopWrittenElapsedMs", micStopWrittenElapsedMs ?: JSONObject.NULL)
1031
+ .put("displayExitElapsedMs", displayExitElapsedMs ?: JSONObject.NULL)
1032
+ )
985
1033
  .put("pair", JSONObject().put("name", resourcePairName ?: lastDeviceName ?: JSONObject.NULL)
986
1034
  .put("bluetoothAddress", resourcePairAddress ?: lastDeviceAddress ?: JSONObject.NULL))
987
1035
  .put("handshake", handshakeState.name).put("managedSessionPresent", gatt != null)
@@ -1003,21 +1051,32 @@ class Nimo : SGCManager() {
1003
1051
 
1004
1052
  /** Exact original instance only. Never delegates to the manager's current device. */
1005
1053
  internal fun releaseResource(expectedGeneration: Long, isCurrent: () -> Boolean, completed: (JSONObject) -> Unit) =
1006
- releaseOriginalResource(expectedGeneration, false, isCurrent, completed)
1054
+ releaseOriginalResource(expectedGeneration, false, isCurrent, completed, null, null)
1055
+
1056
+ internal fun releaseResource(expectedGeneration: Long, isCurrent: () -> Boolean, completed: (JSONObject) -> Unit, requestNonce: String, requestReceivedElapsedMs: Long) =
1057
+ releaseOriginalResource(expectedGeneration, false, isCurrent, completed, requestNonce, requestReceivedElapsedMs)
1007
1058
 
1008
1059
  /** Only the private admitted capture may cancel an original pre-GATT generation-zero attempt. */
1009
1060
  internal fun releaseCapturedResource(isCurrent: () -> Boolean, completed: (JSONObject) -> Unit) =
1010
- releaseOriginalResource(resourceSessionGeneration, true, isCurrent, completed)
1061
+ releaseOriginalResource(resourceSessionGeneration, true, isCurrent, completed, null, null)
1062
+
1063
+ internal fun releaseCapturedResource(isCurrent: () -> Boolean, completed: (JSONObject) -> Unit, requestNonce: String, requestReceivedElapsedMs: Long) =
1064
+ releaseOriginalResource(resourceSessionGeneration, true, isCurrent, completed, requestNonce, requestReceivedElapsedMs)
1011
1065
 
1012
- private fun releaseOriginalResource(expectedGeneration: Long, captured: Boolean, isCurrent: () -> Boolean, completed: (JSONObject) -> Unit) {
1066
+ private fun releaseOriginalResource(expectedGeneration: Long, captured: Boolean, isCurrent: () -> Boolean, completed: (JSONObject) -> Unit, requestNonce: String?, requestReceivedElapsedMs: Long?) {
1013
1067
  check(Looper.myLooper() == mainHandler.looper)
1014
1068
  if (!isCurrent() || expectedGeneration != resourceSessionGeneration || (!captured && expectedGeneration < 1) || resourceReleasePending || resourceReceiptPending) {
1015
1069
  completed(JSONObject().put("error", "target-mismatch-or-release-pending")); return
1016
1070
  }
1017
1071
  if (resourceReleased) { completed(resourceSnapshot()); return }
1072
+ if (requestReceivedElapsedMs != null && android.os.SystemClock.elapsedRealtime() < requestReceivedElapsedMs) {
1073
+ completed(JSONObject().put("error", "invalid-request-clock")); return
1074
+ }
1075
+ resourceReleaseNonce = requestNonce
1076
+ resourceReleaseStartedElapsedMs = android.os.SystemClock.elapsedRealtime()
1018
1077
  resourceReleasePending = true
1019
1078
  resourceReleaseResult = "draining"
1020
- resourceExitStatus = null; resourceExitAt = null; resourceMicStopStartedAt = null; resourceMicStopMarkerAt = null; resourceMicStopWrittenAt = null
1079
+ resourceExitStatus = null; resourceExitAt = null; displayExitElapsedMs = null; resourceMicStopStartedAt = null; micStopStartedElapsedMs = null; resourceMicStopMarkerAt = null; micStopMarkerElapsedMs = null; resourceMicStopWrittenAt = null; micStopWrittenElapsedMs = null
1021
1080
  resourceExitRequired = canvas.active() || canvas.pendingCount() > 0
1022
1081
  resourceInitialLaunchPendingAtRelease = !canvas.active() && canvas.launchPending()
1023
1082
  isDisconnecting = true
@@ -1035,7 +1094,7 @@ class Nimo : SGCManager() {
1035
1094
  val mic = micChar
1036
1095
  resourceMicStopRequired = original != null && mic != null
1037
1096
  if (original != null && mic != null) {
1038
- writes.enqueue(original, mic, listOf(byteArrayOf(0x52, 0, 0, 0)), finalStarted = { resourceMicStopStartedAt = System.currentTimeMillis() }, completed = { resourceMicStopWrittenAt = System.currentTimeMillis() })
1097
+ writes.enqueue(original, mic, listOf(byteArrayOf(0x52, 0, 0, 0)), finalStarted = { recordResourceMicStopStartedClock() }, completed = { recordResourceMicStopWrittenClock() })
1039
1098
  }
1040
1099
  canvas.exit()
1041
1100
  val deadline = android.os.SystemClock.elapsedRealtime() + 15_000
@@ -1099,7 +1158,7 @@ class Nimo : SGCManager() {
1099
1158
  { Bridge.log("NIMO: canvas rejected status=$it; not a render ACK") },
1100
1159
  { diagnostics?.cancelHeldCapture("Canvas scope takeover preempted held capture", resumeCanvas = false) },
1101
1160
  { key, status ->
1102
- if (key == 3) { resourceExitStatus = status; resourceExitAt = System.currentTimeMillis() }
1161
+ if (key == 3) { resourceExitStatus = status; recordResourceExitClock() }
1103
1162
  if (resourceReleasePending && resourceInitialLaunchPendingAtRelease && key == 1) {
1104
1163
  // An explicit rejected initial Launch never acquired canvas ownership.
1105
1164
  // Status 2 is transport-ambiguous and must retain the unknown fence.
@@ -1869,15 +1928,17 @@ class Nimo : SGCManager() {
1869
1928
  // for system-paired dual-mode accessories).
1870
1929
  Bridge.log("NIMO: connecting GATT over BR/EDR to ${device.address}")
1871
1930
  resourceSessionGeneration++
1872
- resourceExitStatus = null; resourceExitAt = null; resourceMicStopWrittenAt = null
1873
- resourceMicStopStartedAt = null; resourceMicStopMarkerAt = null; resourceTransportFailure = false; resourceClosingGatt = null
1931
+ resourceSessionStartedElapsedMs = android.os.SystemClock.elapsedRealtime()
1932
+ resourceReleaseNonce = null; resourceReleaseStartedElapsedMs = null
1933
+ resourceExitStatus = null; resourceExitAt = null; displayExitElapsedMs = null; resourceMicStopWrittenAt = null; micStopWrittenElapsedMs = null
1934
+ resourceMicStopStartedAt = null; micStopStartedElapsedMs = null; resourceMicStopMarkerAt = null; micStopMarkerElapsedMs = null; resourceTransportFailure = false; resourceClosingGatt = null
1874
1935
  resourceClosedWithPending = false; resourceReleaseResult = "not-requested"
1875
1936
  resourceMicStopRequired = false; resourceExitRequired = false; resourceInitialLaunchPendingAtRelease = false
1876
1937
  resourceReleased = false
1877
1938
  resourcePairAddress = peer.address
1878
1939
  resourcePairName = try { device.name ?: "" } catch (_: SecurityException) { "" }
1879
- resourceFirmwareAt = null; resourceFirmwareVersion = null
1880
- resourceTwsAt = null; resourceHandshakeTwsAt = null; resourceTimeAckAt = null; resourceTimeCommandId = null; resourceTimeWriteStartedAt = null
1940
+ resourceFirmwareAt = null; firmwareElapsedMs = null; resourceFirmwareVersion = null
1941
+ resourceTwsAt = null; twsElapsedMs = null; resourceHandshakeTwsAt = null; handshakeTwsElapsedMs = null; resourceTimeAckAt = null; timeAckElapsedMs = null; resourceTimeCommandId = null; resourceTimeWriteStartedAt = null; timeWriteStartedElapsedMs = null
1881
1942
  resourceCloseCalled = false; resourceCloseObserved = false; resourceDisconnectCallbackStatus = null; resourceCloseError = null
1882
1943
  NimoResourceDiagnostics.track(this)
1883
1944
  DeviceStore.apply("glasses", "connectionState", ConnTypes.CONNECTING)
@@ -2121,8 +2182,9 @@ class Nimo : SGCManager() {
2121
2182
  handleRxPacket(data)
2122
2183
  }
2123
2184
  NimoBLE.CHAR_MIC -> {
2124
- if (resourceReleasePending && resourceMicStopStartedAt != null && data.contentEquals(byteArrayOf(0x52, 0, 0, 0)))
2125
- resourceMicStopMarkerAt = System.currentTimeMillis()
2185
+ if (resourceReleasePending && resourceMicStopStartedAt != null && data.contentEquals(byteArrayOf(0x52, 0, 0, 0))) {
2186
+ recordResourceMicStopMarkerClock()
2187
+ }
2126
2188
  audioClient.enqueue(data)
2127
2189
  }
2128
2190
  }
@@ -2180,7 +2242,7 @@ class Nimo : SGCManager() {
2180
2242
  resourcePostedWrites.decrementAndGet()
2181
2243
  if (gatt !== expectedGatt || resourceReleasePending || resourceReleased) return@post
2182
2244
  writes.enqueue(expectedGatt, char, listOf(bytes), finalStarted = {
2183
- if (timeCommand != null && timeCommand == resourceTimeCommandId) resourceTimeWriteStartedAt = System.currentTimeMillis()
2245
+ if (timeCommand != null && timeCommand == resourceTimeCommandId) { recordResourceTimeWriteClock() }
2184
2246
  })
2185
2247
  }
2186
2248
  }
@@ -2294,7 +2356,7 @@ class Nimo : SGCManager() {
2294
2356
  private fun proceedToTimeSync() {
2295
2357
  if (handshakeState != HandshakeState.AWAITING_TWS) return
2296
2358
  cancelTwsTimeout()
2297
- resourceHandshakeTwsAt = resourceTwsAt
2359
+ resourceHandshakeTwsAt = resourceTwsAt; handshakeTwsElapsedMs = twsElapsedMs
2298
2360
  handshakeState = HandshakeState.AWAITING_TIME_ACK
2299
2361
  Bridge.log("NIMO: TWS OK — sending setTime (awaiting ACK)")
2300
2362
  setTimeAttempts = 0
@@ -2306,7 +2368,7 @@ class Nimo : SGCManager() {
2306
2368
  val expectedGatt = gatt
2307
2369
  setTimeAttempts++
2308
2370
  resourceTimeCommandId = UUID.randomUUID().toString()
2309
- resourceTimeWriteStartedAt = null
2371
+ resourceTimeWriteStartedAt = null; timeWriteStartedElapsedMs = null
2310
2372
  sendAwaitingAck(
2311
2373
  NimoProtocol.CMD_SET_PARAMETER,
2312
2374
  NimoProtocol.SET_TIME,
@@ -2319,7 +2381,7 @@ class Nimo : SGCManager() {
2319
2381
  return@sendAwaitingAck
2320
2382
  }
2321
2383
  if (ok) {
2322
- resourceTimeAckAt = System.currentTimeMillis()
2384
+ recordResourceTimeAckClock()
2323
2385
  finishHandshake()
2324
2386
  } else if (setTimeAttempts < SET_TIME_MAX_ATTEMPTS &&
2325
2387
  handshakeState == HandshakeState.AWAITING_TIME_ACK &&
@@ -2544,7 +2606,7 @@ class Nimo : SGCManager() {
2544
2606
  }
2545
2607
 
2546
2608
  private fun onTwsState(connected: Boolean) {
2547
- resourceTwsAt = System.currentTimeMillis()
2609
+ recordResourceTwsClock()
2548
2610
  twsConnected = connected
2549
2611
  if (connected && handshakeState == HandshakeState.AWAITING_TWS) {
2550
2612
  proceedToTimeSync()
@@ -2617,7 +2679,7 @@ class Nimo : SGCManager() {
2617
2679
  val micro = (v ushr 12) and 0x1FF
2618
2680
  val build = v and 0xFFF
2619
2681
  firmwareVersionPacked = "$major.$minor.$micro.$build"
2620
- resourceFirmwareVersion = firmwareVersionPacked; resourceFirmwareAt = System.currentTimeMillis()
2682
+ resourceFirmwareVersion = firmwareVersionPacked; recordResourceFirmwareClock()
2621
2683
  emitVersionInfo()
2622
2684
  }
2623
2685
  }
@@ -2626,7 +2688,7 @@ class Nimo : SGCManager() {
2626
2688
  while (end > 0 && data[end - 1].toInt() == 0) end--
2627
2689
  firmwareVersionDetail = String(data, 0, end, Charsets.UTF_8).trim()
2628
2690
  if (firmwareVersionDetail.isNotEmpty() && firmwareVersionDetail.length <= 240) {
2629
- resourceFirmwareVersion = firmwareVersionDetail; resourceFirmwareAt = System.currentTimeMillis()
2691
+ resourceFirmwareVersion = firmwareVersionDetail; recordResourceFirmwareClock()
2630
2692
  }
2631
2693
  emitVersionInfo()
2632
2694
  }
@@ -120,6 +120,63 @@ class NimoLifecycleTest {
120
120
  .set(null, previousManager)
121
121
  }
122
122
 
123
+ @Test fun monotonicSnapshotRetainsOriginalAdmissionWhenWallMarkersJumpBothDirections() {
124
+ val armed = android.os.SystemClock.elapsedRealtime()
125
+ val nonce = java.util.UUID.randomUUID().toString()
126
+ org.robolectric.shadows.ShadowSystemClock.advanceBy(25, TimeUnit.MILLISECONDS)
127
+ val admitted = android.os.SystemClock.elapsedRealtime()
128
+ nimo.resourceCaptureAdmission(nonce, armed, admitted)
129
+ setField("resourceSessionStartedElapsedMs", admitted)
130
+ setField("resourceSessionGeneration", 1L)
131
+ setField("twsElapsedMs", admitted)
132
+ setField("handshakeTwsElapsedMs", admitted)
133
+ setField("timeWriteStartedElapsedMs", admitted)
134
+ org.robolectric.shadows.ShadowSystemClock.advanceBy(10, TimeUnit.MILLISECONDS)
135
+ val ack = android.os.SystemClock.elapsedRealtime()
136
+ setField("timeAckElapsedMs", ack)
137
+ // Model independent phone epoch jumps: neither direction can rewrite causal time.
138
+ for (epoch in listOf(1L, 4_000_000_000_000L)) {
139
+ setField("resourceTwsAt", epoch)
140
+ setField("resourceHandshakeTwsAt", epoch)
141
+ setField("resourceTimeWriteStartedAt", epoch)
142
+ setField("resourceTimeAckAt", epoch)
143
+ val clock = nimo.resourceSnapshot().getJSONObject("clock")
144
+ assertEquals("android-elapsed-realtime-v1", clock.getString("kind"))
145
+ assertEquals(nonce, clock.getString("captureNonce"))
146
+ assertEquals(armed, clock.getLong("armedElapsedMs"))
147
+ assertEquals(admitted, clock.getLong("admittedElapsedMs"))
148
+ assertEquals(ack, clock.getLong("timeAckElapsedMs"))
149
+ assertTrue(clock.getLong("observedElapsedMs") >= ack)
150
+ }
151
+ }
152
+
153
+ @Test fun originalReleaseClockAndNonceSurviveLaterStatusAndRepeatedRelease() {
154
+ manager.sgc = nimo
155
+ NimoResourceDiagnostics.start(context)
156
+ setField("resourceSessionGeneration", 1L)
157
+ setField("resourcePairAddress", "00:11:22:33:44:55")
158
+ val nonce = java.util.UUID.randomUUID().toString()
159
+ val received = android.os.SystemClock.elapsedRealtime()
160
+ var first: JSONObject? = null
161
+ nimo.releaseResource(1L, { true }, { first = it }, nonce, received)
162
+ Shadows.shadowOf(Looper.getMainLooper()).idleFor(300, TimeUnit.MILLISECONDS)
163
+ val original = requireNotNull(first).getJSONObject("clock")
164
+ assertEquals(nonce, original.getString("releaseNonce"))
165
+ assertTrue(original.getLong("releaseStartedElapsedMs") >= received)
166
+ org.robolectric.shadows.ShadowSystemClock.advanceBy(10, TimeUnit.SECONDS)
167
+ val status = resourceCommand("status")
168
+ assertEquals("nimo-monotonic-receipt-v1", status.getString("clockProtocol"))
169
+ val statusClock = status.getJSONObject("clock")
170
+ assertTrue(statusClock.getLong("observedElapsedMs") >= statusClock.getLong("requestReceivedElapsedMs"))
171
+ var repeated: JSONObject? = null
172
+ nimo.releaseResource(1L, { true }, { repeated = it }, java.util.UUID.randomUUID().toString(), android.os.SystemClock.elapsedRealtime())
173
+ for (snapshot in listOf(status.getJSONObject("session"), requireNotNull(repeated))) {
174
+ val clock = snapshot.getJSONObject("clock")
175
+ assertEquals(nonce, clock.getString("releaseNonce"))
176
+ assertEquals(original.getLong("releaseStartedElapsedMs"), clock.getLong("releaseStartedElapsedMs"))
177
+ }
178
+ }
179
+
123
180
  @Test fun offMainDisconnectCompletesOnMainAndInterruptsActiveCapture() {
124
181
  runOffMain { nimo.disconnect() }
125
182
  assertDisconnectedOnMain()
@@ -303,12 +360,16 @@ class NimoLifecycleTest {
303
360
  assertEquals(0, snapshot.getJSONObject("timeAck").getInt("statusCode"))
304
361
  assertFalse(snapshot.getJSONObject("timeAck").isNull("writeStartedAtEpochMs"))
305
362
  assertEquals("READY", snapshot.getString("handshake"))
306
- val causalTws = snapshot.getJSONObject("tws").getLong("handshakeAtEpochMs")
363
+ val causalTws = snapshot.getJSONObject("clock").getLong("handshakeTwsElapsedMs")
307
364
  Shadows.shadowOf(Looper.getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS)
308
365
  Nimo::class.java.getDeclaredMethod("onTwsState", Boolean::class.javaPrimitiveType).apply { isAccessible = true }.invoke(nimo, true)
309
366
  val heartbeat = nimo.resourceSnapshot()
310
- assertEquals(causalTws, heartbeat.getJSONObject("tws").getLong("handshakeAtEpochMs"))
311
- assertTrue(heartbeat.getJSONObject("tws").getLong("atEpochMs") > heartbeat.getJSONObject("timeAck").getLong("atEpochMs"))
367
+ // Raw phone epochs are diagnostics; event order belongs to phone elapsed time.
368
+ assertFalse(heartbeat.getJSONObject("tws").isNull("atEpochMs"))
369
+ assertFalse(heartbeat.getJSONObject("timeAck").isNull("atEpochMs"))
370
+ val clock = heartbeat.getJSONObject("clock")
371
+ assertEquals(causalTws, clock.getLong("handshakeTwsElapsedMs"))
372
+ assertTrue(clock.getLong("twsElapsedMs") > clock.getLong("timeAckElapsedMs"))
312
373
  assertEquals("READY", heartbeat.getString("handshake"))
313
374
  }
314
375
 
@@ -393,9 +454,34 @@ class NimoLifecycleTest {
393
454
  File(context.filesDir, "nimo-lifecycle/$nonce.admission.tmp").mkdir()
394
455
  assertFalse(NimoResourceDiagnostics.admit(nimo, "Nimo-test", true))
395
456
  assertFalse(NimoResourceDiagnostics.allowManagerConnect("NIMO", "Nimo-test"))
457
+ assertFalse(NimoResourceDiagnostics.beginTransport(nimo, "00:11:22:33:44:55", true))
458
+ assertFalse(NimoResourceDiagnostics.admit(nimo, "Nimo-test", true))
459
+ assertFalse(File(context.filesDir, "nimo-lifecycle/$nonce.admission.json").exists())
460
+ val clock = nimo.resourceSnapshot().getJSONObject("clock")
461
+ assertTrue(clock.isNull("captureNonce")); assertTrue(clock.isNull("admittedElapsedMs"))
462
+ val capture = captureCommand("capture-status", nonce).getJSONObject("session").getJSONObject("capture")
463
+ assertTrue(capture.getBoolean("cancelled"))
464
+ assertTrue(capture.getJSONObject("clock").isNull("admittedElapsedMs"))
396
465
  assertEquals(0L, nimo.resourceSnapshot().getLong("sessionGeneration"))
397
466
  }
398
467
 
468
+ @Test fun preexistingAdmissionIsNeverOverwrittenOrStampedOntoLiveInstance() {
469
+ manager.sgc = null
470
+ NimoResourceDiagnostics.start(context)
471
+ val nonce = java.util.UUID.randomUUID().toString()
472
+ captureCommand("arm-capture", nonce, nonce)
473
+ manager.sgc = nimo
474
+ val original = File(context.filesDir, "nimo-lifecycle/$nonce.admission.json")
475
+ original.writeText("preexisting-immutable-evidence")
476
+ assertFalse(NimoResourceDiagnostics.admit(nimo, "Nimo-test", true))
477
+ assertEquals("preexisting-immutable-evidence", original.readText())
478
+ assertFalse(NimoResourceDiagnostics.allowManagerConnect("NIMO", "Nimo-test"))
479
+ assertFalse(NimoResourceDiagnostics.beginTransport(nimo, "00:11:22:33:44:55", true))
480
+ val clock = nimo.resourceSnapshot().getJSONObject("clock")
481
+ assertTrue(clock.isNull("captureNonce")); assertTrue(clock.isNull("admittedElapsedMs"))
482
+ assertTrue(captureCommand("capture-status", nonce).getJSONObject("session").getJSONObject("capture").getJSONObject("clock").isNull("admittedElapsedMs"))
483
+ }
484
+
399
485
  @Test fun capturedGenerationZeroCancellationDrainsOriginalAndFencesQueuedAdmission() {
400
486
  manager.sgc = null
401
487
  NimoResourceDiagnostics.start(context)
@@ -1,10 +1,10 @@
1
1
  export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
2
2
  "schemaVersion": 1,
3
3
  "familyBaseVersion": "3.4.0",
4
- "releaseIdentity": "3.4.0-dev.966",
5
- "releaseSetId": "mentra-3.4.0-dev.966",
6
- "sourceCommit": "ec5c55f026bb7f8be86781b4f7b54b4b91034e30",
7
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.966.json",
8
- "otaManifestSha256": "2b6b90f176f747dc2c424edafa754ef5dcedd2217a67c91b33f13de36551153f"
4
+ "releaseIdentity": "3.4.0-dev.971",
5
+ "releaseSetId": "mentra-3.4.0-dev.971",
6
+ "sourceCommit": "c72b7ecf63112a98579639fc4fdb126aedbf425d",
7
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.971.json",
8
+ "otaManifestSha256": "5be88fa1406efdd5024b94ae18c9124f33ed1cd8e1661f5bcfbabc522a120037"
9
9
  });
10
10
  //# sourceMappingURL=releaseMetadata.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.4.0\",\n \"releaseIdentity\": \"3.4.0-dev.966\",\n \"releaseSetId\": \"mentra-3.4.0-dev.966\",\n \"sourceCommit\": \"ec5c55f026bb7f8be86781b4f7b54b4b91034e30\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.966.json\",\n \"otaManifestSha256\": \"2b6b90f176f747dc2c424edafa754ef5dcedd2217a67c91b33f13de36551153f\"\n})\n"]}
1
+ {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,iIAAiI;IACnJ,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.4.0\",\n \"releaseIdentity\": \"3.4.0-dev.971\",\n \"releaseSetId\": \"mentra-3.4.0-dev.971\",\n \"sourceCommit\": \"c72b7ecf63112a98579639fc4fdb126aedbf425d\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.971.json\",\n \"otaManifestSha256\": \"5be88fa1406efdd5024b94ae18c9124f33ed1cd8e1661f5bcfbabc522a120037\"\n})\n"]}
@@ -16,7 +16,7 @@ enum BluetoothSdkDefaults {
16
16
  /// silently disable it on every pair the app touches.
17
17
  static let autoPowerOffEnabled = false
18
18
  private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
19
- private static let swiftPackageSdkVersion = "3.4.0-dev.966"
19
+ private static let swiftPackageSdkVersion = "3.4.0-dev.971"
20
20
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
21
21
 
22
22
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -3,9 +3,9 @@ import Foundation
3
3
  /// Generated by release CI. Do not edit in a release checkout.
4
4
  enum GeneratedReleaseMetadata {
5
5
  static let familyBaseVersion = "3.4.0"
6
- static let releaseIdentity = "3.4.0-dev.966"
7
- static let releaseSetId = "mentra-3.4.0-dev.966"
8
- static let sourceCommit = "ec5c55f026bb7f8be86781b4f7b54b4b91034e30"
9
- static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.966.json"
10
- static let otaManifestSha256 = "2b6b90f176f747dc2c424edafa754ef5dcedd2217a67c91b33f13de36551153f"
6
+ static let releaseIdentity = "3.4.0-dev.971"
7
+ static let releaseSetId = "mentra-3.4.0-dev.971"
8
+ static let sourceCommit = "c72b7ecf63112a98579639fc4fdb126aedbf425d"
9
+ static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.971.json"
10
+ static let otaManifestSha256 = "5be88fa1406efdd5024b94ae18c9124f33ed1cd8e1661f5bcfbabc522a120037"
11
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.4.0-dev.966",
3
+ "version": "3.4.0-dev.971",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -114,7 +114,7 @@
114
114
  "registry": "https://registry.npmjs.org/"
115
115
  },
116
116
  "dependencies": {
117
- "@mentra/cloud-protocol": "3.4.0-dev.966"
117
+ "@mentra/cloud-protocol": "3.4.0-dev.971"
118
118
  },
119
119
  "devDependencies": {
120
120
  "@types/node": "^25.9.3",
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
12
12
  export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "3.4.0",
15
- "releaseIdentity": "3.4.0-dev.966",
16
- "releaseSetId": "mentra-3.4.0-dev.966",
17
- "sourceCommit": "ec5c55f026bb7f8be86781b4f7b54b4b91034e30",
18
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.966.json",
19
- "otaManifestSha256": "2b6b90f176f747dc2c424edafa754ef5dcedd2217a67c91b33f13de36551153f"
15
+ "releaseIdentity": "3.4.0-dev.971",
16
+ "releaseSetId": "mentra-3.4.0-dev.971",
17
+ "sourceCommit": "c72b7ecf63112a98579639fc4fdb126aedbf425d",
18
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.971.json",
19
+ "otaManifestSha256": "5be88fa1406efdd5024b94ae18c9124f33ed1cd8e1661f5bcfbabc522a120037"
20
20
  })