@mentra/bluetooth-sdk 3.3.0-dev.775 → 3.3.0-dev.784

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.
@@ -617,7 +617,7 @@ class BluetoothSdkModule : Module() {
617
617
  // MARK: - Incident Reporting
618
618
 
619
619
  SdkAsyncFunction("sendIncidentId") { incidentId: String, apiBaseUrl: String? ->
620
- sdk?.sendIncidentId(incidentId, apiBaseUrl)
620
+ requireSdk().sendIncidentId(incidentId, apiBaseUrl)
621
621
  }
622
622
 
623
623
  // MARK: - Native Notification Centre
@@ -1989,8 +1989,11 @@ class DeviceManager internal constructor(initializeHardware: Boolean) {
1989
1989
  sgc?.requestSavedWifiNetworks(requestId, sid) ?: false
1990
1990
 
1991
1991
  fun sendIncidentId(incidentId: String, apiBaseUrl: String? = null) {
1992
+ val driver = sgc ?: throw BluetoothSdkException(
1993
+ "glasses_not_connected", "Cannot request incident logs because glasses are not connected.",
1994
+ )
1992
1995
  Bridge.log("MAN: Sending incidentId to glasses for log upload: $incidentId")
1993
- sgc?.sendIncidentId(incidentId, apiBaseUrl)
1996
+ driver.sendIncidentId(incidentId, apiBaseUrl)
1994
1997
  }
1995
1998
 
1996
1999
  /** Push a notification into the glasses' own notification centre; rejects unsupported or disconnected devices. */
@@ -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.775"
7
- const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.775"
8
- const val SOURCE_COMMIT: String = "f74b4870eb081f496cbfe2442111ac1b7779a8f2"
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.775.json"
10
- const val OTA_MANIFEST_SHA256: String = "d6f34112823147203720d8fd99fc010ae6751ea2109542315f4b425b393f282a"
6
+ const val RELEASE_IDENTITY: String = "3.3.0-dev.784"
7
+ const val RELEASE_SET_ID: String = "mentra-3.3.0-dev.784"
8
+ const val SOURCE_COMMIT: String = "7537129fe1722c047fff1b29e0fbccb83a832ea2"
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.784.json"
10
+ const val OTA_MANIFEST_SHA256: String = "6c1f8f02cd7defbb485ee267ecc015ab0791322faa53e87c344bf345c60e5644"
11
11
  }
@@ -34,6 +34,7 @@ import android.os.SystemClock
34
34
  import com.mentra.bluetoothsdk.utils.NativeLog as Log
35
35
  import androidx.core.app.ActivityCompat
36
36
  import com.mentra.bluetoothsdk.BluetoothSdkDefaults
37
+ import com.mentra.bluetoothsdk.BluetoothSdkException
37
38
  import com.mentra.bluetoothsdk.Bridge
38
39
  import com.mentra.bluetoothsdk.DeviceManager
39
40
  import com.mentra.bluetoothsdk.PhotoRequest
@@ -3371,10 +3372,12 @@ class MentraLive : SGCManager() {
3371
3372
  sendJson(json, wakeup, true)
3372
3373
  }
3373
3374
 
3374
- private fun sendJson(json: JSONObject?, wakeup: Boolean, bridgeLogging: Boolean): Boolean {
3375
+ private fun sendJson(
3376
+ json: JSONObject?, wakeup: Boolean, bridgeLogging: Boolean,
3377
+ sessionGeneration: Long = bleSessionGeneration.get(),
3378
+ ): Boolean {
3375
3379
  if (json != null) {
3376
3380
  try {
3377
- val sessionGeneration = bleSessionGeneration.get()
3378
3381
  if (buildNumberInt < 5) {
3379
3382
  val jsonStr = json.toString()
3380
3383
  // Bridge.log("LIVE: 📤 Sending JSON with esoteric message ID: " + jsonStr);
@@ -6075,6 +6078,17 @@ class MentraLive : SGCManager() {
6075
6078
  }
6076
6079
 
6077
6080
  override fun sendIncidentId(incidentId: String, apiBaseUrl: String?) {
6081
+ if (!isConnected || bluetoothGatt == null || txCharacteristic == null) {
6082
+ throw BluetoothSdkException(
6083
+ "glasses_not_connected", "Cannot request incident logs because the glasses BLE link is not ready.",
6084
+ )
6085
+ }
6086
+ // The JS token sync may happen after glasses_ready. Use only its current native store value.
6087
+ val coreToken = DeviceStore.get("bluetooth", "core_token") as? String
6088
+ if (coreToken.isNullOrBlank()) {
6089
+ throw BluetoothSdkException("core_token_unavailable", "Cannot request incident logs without the current Core access token.")
6090
+ }
6091
+ val sessionGeneration = bleSessionGeneration.get()
6078
6092
  try {
6079
6093
  var base = if (apiBaseUrl != null) apiBaseUrl.trim() else ""
6080
6094
  if (base.isEmpty()) {
@@ -6087,13 +6101,25 @@ class MentraLive : SGCManager() {
6087
6101
  bleIncidentLogRelays[lKey] =
6088
6102
  BleIncidentLogRelay(lKey, incidentId, base, BleIncidentLogKind.LOGCAT)
6089
6103
 
6104
+ val auth = JSONObject()
6105
+ auth.put("type", "auth_token")
6106
+ auth.put("coreToken", coreToken)
6107
+ auth.put("timestamp", System.currentTimeMillis())
6090
6108
  val json = JSONObject()
6091
6109
  json.put("type", "upload_incident_logs")
6092
6110
  json.put("incidentId", incidentId)
6093
6111
  json.put("apiBaseUrl", base)
6094
- sendJson(json, true)
6112
+ // Wake ASG with its current token before the upload command enters the same FIFO.
6113
+ if (!sendJson(auth, true, true, sessionGeneration) ||
6114
+ sessionGeneration != bleSessionGeneration.get() ||
6115
+ !isConnected || bluetoothGatt == null || txCharacteristic == null ||
6116
+ !sendJson(json, true, true, sessionGeneration) || sessionGeneration != bleSessionGeneration.get()) {
6117
+ bleIncidentLogRelays.remove(bKey)
6118
+ bleIncidentLogRelays.remove(lKey)
6119
+ throw BluetoothSdkException("incident_dispatch_failed", "Could not queue the glasses incident-log request.")
6120
+ }
6095
6121
  Bridge.log(
6096
- "LIVE: Sent incidentId to glasses for log upload: " +
6122
+ "LIVE: Queued incidentId to glasses for log upload: " +
6097
6123
  incidentId +
6098
6124
  " (BLE relay keys " +
6099
6125
  bKey +
@@ -6102,7 +6128,7 @@ class MentraLive : SGCManager() {
6102
6128
  ")"
6103
6129
  )
6104
6130
  } catch (e: JSONException) {
6105
- Log.e(TAG, "Error creating upload_incident_logs command", e)
6131
+ throw BluetoothSdkException("incident_dispatch_failed", "Could not create the glasses incident-log request.", e)
6106
6132
  }
6107
6133
  }
6108
6134
 
@@ -9804,12 +9830,12 @@ class MentraLive : SGCManager() {
9804
9830
  }
9805
9831
 
9806
9832
  /**
9807
- * Wi-Fi credentials are sent unchanged but never logged; the BLE trace records the command
9808
- * with the password redacted.
9833
+ * Credentials are sent unchanged but never logged; the BLE trace redacts their fields.
9809
9834
  */
9810
9835
  private fun loggableOutgoingPayload(payload: String, commandType: String): String =
9811
9836
  try {
9812
- if (JSONObject(payload).has("password")) "<$commandType with credentials omitted>"
9837
+ val json = JSONObject(payload)
9838
+ if (json.has("password") || json.has("coreToken")) "<$commandType with credentials omitted>"
9813
9839
  else payload
9814
9840
  } catch (_: JSONException) {
9815
9841
  payload
@@ -0,0 +1,126 @@
1
+ package com.mentra.bluetoothsdk.sgcs
2
+
3
+ import android.bluetooth.BluetoothAdapter
4
+ import android.bluetooth.BluetoothGattCallback
5
+ import android.bluetooth.BluetoothGattCharacteristic
6
+ import android.os.Looper
7
+ import androidx.test.core.app.ApplicationProvider
8
+ import com.mentra.bluetoothsdk.BluetoothSdkException
9
+ import com.mentra.bluetoothsdk.Bridge
10
+ import com.mentra.bluetoothsdk.DeviceStore
11
+ import java.nio.charset.StandardCharsets
12
+ import java.time.Duration
13
+ import java.util.UUID
14
+ import java.util.concurrent.atomic.AtomicLong
15
+ import org.junit.Assert.*
16
+ import org.junit.Test
17
+ import org.junit.runner.RunWith
18
+ import org.robolectric.RobolectricTestRunner
19
+ import org.robolectric.Shadows.shadowOf
20
+ import org.robolectric.annotation.Config
21
+ import org.robolectric.annotation.LooperMode
22
+ import org.robolectric.shadows.ShadowLog
23
+
24
+ @RunWith(RobolectricTestRunner::class)
25
+ @Config(sdk = [33], shadows = [G1PairingTest.ShadowLc3Cpp::class], instrumentedPackages = ["com.mentra.lc3Lib"])
26
+ @LooperMode(LooperMode.Mode.PAUSED)
27
+ class MentraLiveIncidentDispatchTest {
28
+ private val incident = "rep_01M4HBVT88DFSN8TYNZ1WTV4PB"
29
+ private val apiBase = "https://core.dev.us-west-2.mentraglass.com"
30
+
31
+ @Test fun `missing native BLE link rejects even when cached status is connected`() = withLive { live ->
32
+ DeviceStore.set("glasses", "connected", true)
33
+ expectCode("glasses_not_connected") { live.sendIncidentId(incident, apiBase) }
34
+ assertTrue(queuedWrites(live).isEmpty())
35
+ }
36
+
37
+ @Test fun `missing current token does not fall back to old stored credentials`() = withLive { live ->
38
+ ready(live)
39
+ DeviceStore.set("bluetooth", "core_token", "")
40
+ val context = ApplicationProvider.getApplicationContext<android.content.Context>()
41
+ val prefs = context.getSharedPreferences("augmentos_auth_prefs", android.content.Context.MODE_PRIVATE)
42
+ prefs.edit().putString("core_token", "stale-synthetic-token").commit()
43
+ expectCode("core_token_unavailable") { live.sendIncidentId(incident, apiBase) }
44
+ assertTrue(queuedWrites(live).isEmpty())
45
+ prefs.edit().remove("core_token").commit()
46
+ }
47
+
48
+ @Test fun `current token is queued before upload and never logged`() = withLive { live ->
49
+ ready(live)
50
+ val token = "synthetic-${UUID.randomUUID()}"
51
+ DeviceStore.set("bluetooth", "core_token", token)
52
+ ShadowLog.clear()
53
+ live.sendIncidentId(incident, apiBase)
54
+ val writes = queuedWrites(live)
55
+ val wire = writes.joinToString("") { String(it, StandardCharsets.UTF_8) }
56
+ assertEquals(2, writes.size)
57
+ assertTrue(wire.indexOf("auth_token") < wire.indexOf("upload_incident_logs"))
58
+ assertTrue(wire.contains(token))
59
+ assertTrue(wire.contains(incident))
60
+ assertFalse("token logged", ShadowLog.getLogs().any { it.msg.contains(token) })
61
+ }
62
+
63
+ @Test fun `link losing TX characteristic rejects instead of retaining incident relay`() = withLive { live ->
64
+ ready(live)
65
+ DeviceStore.set("bluetooth", "core_token", "synthetic")
66
+ set(live, "txCharacteristic", null)
67
+ expectCode("glasses_not_connected") { live.sendIncidentId(incident, apiBase) }
68
+ assertTrue(queuedWrites(live).isEmpty())
69
+ val relays = get(live, "bleIncidentLogRelays") as Map<*, *>
70
+ assertTrue(relays.isEmpty())
71
+ }
72
+
73
+ @Test fun `session loss during token serialization cannot queue an upload into the next session`() = withLive { live ->
74
+ ready(live)
75
+ DeviceStore.set("bluetooth", "core_token", "synthetic-token")
76
+ val generation = get(live, "bleSessionGeneration") as AtomicLong
77
+ val sink = Bridge.addEventSink { type, body ->
78
+ if (type == "log" && body["message"].toString().contains("<auth_token with credentials omitted>")) {
79
+ generation.incrementAndGet()
80
+ }
81
+ }
82
+ try {
83
+ expectCode("incident_dispatch_failed") { live.sendIncidentId(incident, apiBase) }
84
+ val wire = queuedWrites(live).joinToString("") { String(it, StandardCharsets.UTF_8) }
85
+ assertFalse(wire.contains("upload_incident_logs"))
86
+ assertTrue((get(live, "bleIncidentLogRelays") as Map<*, *>).isEmpty())
87
+ } finally { Bridge.removeEventSink(sink) }
88
+ }
89
+
90
+ private fun ready(live: MentraLive) {
91
+ val context = ApplicationProvider.getApplicationContext<android.content.Context>()
92
+ val gatt = BluetoothAdapter.getDefaultAdapter().getRemoteDevice("AA:BB:CC:DD:EE:FF")
93
+ .connectGatt(context, false, object : BluetoothGattCallback() {})
94
+ set(live, "isConnected", true)
95
+ set(live, "bluetoothGatt", gatt)
96
+ set(live, "txCharacteristic", BluetoothGattCharacteristic(UUID.randomUUID(), 0, 0))
97
+ }
98
+
99
+ private fun withLive(body: (MentraLive) -> Unit) {
100
+ Bridge.initialize(ApplicationProvider.getApplicationContext())
101
+ val live = MentraLive()
102
+ val token = DeviceStore.get("bluetooth", "core_token")
103
+ val connected = DeviceStore.get("glasses", "connected")
104
+ try { body(live) } finally {
105
+ DeviceStore.set("bluetooth", "core_token", token ?: "")
106
+ DeviceStore.set("glasses", "connected", connected ?: false)
107
+ live.destroy()
108
+ shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(2))
109
+ }
110
+ }
111
+
112
+ private fun expectCode(code: String, body: () -> Unit) {
113
+ try { body(); fail("Expected $code") } catch (error: BluetoothSdkException) { assertEquals(code, error.code) }
114
+ }
115
+
116
+ private fun get(live: MentraLive, name: String): Any? =
117
+ MentraLive::class.java.getDeclaredField(name).apply { isAccessible = true }.get(live)
118
+
119
+ private fun set(live: MentraLive, name: String, value: Any?) {
120
+ MentraLive::class.java.getDeclaredField(name).apply { isAccessible = true }.set(live, value)
121
+ }
122
+
123
+ private fun queuedWrites(live: MentraLive): List<ByteArray> = (get(live, "sendQueue") as Iterable<*>).map { write ->
124
+ write!!.javaClass.getDeclaredField("data").apply { isAccessible = true }.get(write) as ByteArray
125
+ }
126
+ }
@@ -1,10 +1,10 @@
1
1
  export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
2
2
  "schemaVersion": 1,
3
3
  "familyBaseVersion": "3.3.0",
4
- "releaseIdentity": "3.3.0-dev.775",
5
- "releaseSetId": "mentra-3.3.0-dev.775",
6
- "sourceCommit": "f74b4870eb081f496cbfe2442111ac1b7779a8f2",
7
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.775.json",
8
- "otaManifestSha256": "d6f34112823147203720d8fd99fc010ae6751ea2109542315f4b425b393f282a"
4
+ "releaseIdentity": "3.3.0-dev.784",
5
+ "releaseSetId": "mentra-3.3.0-dev.784",
6
+ "sourceCommit": "7537129fe1722c047fff1b29e0fbccb83a832ea2",
7
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.784.json",
8
+ "otaManifestSha256": "6c1f8f02cd7defbb485ee267ecc015ab0791322faa53e87c344bf345c60e5644"
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.3.0\",\n \"releaseIdentity\": \"3.3.0-dev.775\",\n \"releaseSetId\": \"mentra-3.3.0-dev.775\",\n \"sourceCommit\": \"f74b4870eb081f496cbfe2442111ac1b7779a8f2\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.775.json\",\n \"otaManifestSha256\": \"d6f34112823147203720d8fd99fc010ae6751ea2109542315f4b425b393f282a\"\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.3.0\",\n \"releaseIdentity\": \"3.3.0-dev.784\",\n \"releaseSetId\": \"mentra-3.3.0-dev.784\",\n \"sourceCommit\": \"7537129fe1722c047fff1b29e0fbccb83a832ea2\",\n \"otaManifestUrl\": \"https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.784.json\",\n \"otaManifestSha256\": \"6c1f8f02cd7defbb485ee267ecc015ab0791322faa53e87c344bf345c60e5644\"\n})\n"]}
@@ -368,8 +368,8 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
368
368
  // MARK: - Incident Reporting
369
369
 
370
370
  AsyncFunction("sendIncidentId") { (incidentId: String, apiBaseUrl: String?) in
371
- await MainActor.run {
372
- self.bluetoothSdk().sendIncidentId(incidentId, apiBaseUrl: apiBaseUrl)
371
+ try await MainActor.run {
372
+ try self.bluetoothSdk().sendIncidentId(incidentId, apiBaseUrl: apiBaseUrl)
373
373
  }
374
374
  }
375
375
 
@@ -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.3.0-dev.775"
19
+ private static let swiftPackageSdkVersion = "3.3.0-dev.784"
20
20
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
21
21
 
22
22
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -1518,9 +1518,12 @@ struct ViewState {
1518
1518
  sgc?.requestSavedWifiNetworks(requestId: requestId, sid: sid) ?? false
1519
1519
  }
1520
1520
 
1521
- func sendIncidentId(_ incidentId: String, apiBaseUrl: String? = nil) {
1521
+ func sendIncidentId(_ incidentId: String, apiBaseUrl: String? = nil) throws {
1522
+ guard let driver = sgc else {
1523
+ throw BluetoothSdkError(code: "glasses_not_connected", message: "Cannot request incident logs because glasses are not connected.")
1524
+ }
1522
1525
  Bridge.log("MAN: Sending incidentId to glasses for log upload: \(incidentId)")
1523
- sgc?.sendIncidentId(incidentId, apiBaseUrl: apiBaseUrl)
1526
+ try driver.sendIncidentId(incidentId, apiBaseUrl: apiBaseUrl)
1524
1527
  }
1525
1528
 
1526
1529
  func sendWifiCredentials(_ ssid: String, _ password: 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.3.0"
6
- static let releaseIdentity = "3.3.0-dev.775"
7
- static let releaseSetId = "mentra-3.3.0-dev.775"
8
- static let sourceCommit = "f74b4870eb081f496cbfe2442111ac1b7779a8f2"
9
- static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.775.json"
10
- static let otaManifestSha256 = "d6f34112823147203720d8fd99fc010ae6751ea2109542315f4b425b393f282a"
6
+ static let releaseIdentity = "3.3.0-dev.784"
7
+ static let releaseSetId = "mentra-3.3.0-dev.784"
8
+ static let sourceCommit = "7537129fe1722c047fff1b29e0fbccb83a832ea2"
9
+ static let otaManifestUrl = "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.784.json"
10
+ static let otaManifestSha256 = "6c1f8f02cd7defbb485ee267ecc015ab0791322faa53e87c344bf345c60e5644"
11
11
  }
@@ -1667,8 +1667,8 @@ public final class MentraBluetoothSDK {
1667
1667
  DeviceManager.shared.sendReboot()
1668
1668
  }
1669
1669
 
1670
- func sendIncidentId(_ incidentId: String, apiBaseUrl: String? = nil) {
1671
- DeviceManager.shared.sendIncidentId(incidentId, apiBaseUrl: apiBaseUrl)
1670
+ func sendIncidentId(_ incidentId: String, apiBaseUrl: String? = nil) throws {
1671
+ try DeviceManager.shared.sendIncidentId(incidentId, apiBaseUrl: apiBaseUrl)
1672
1672
  }
1673
1673
 
1674
1674
  public func invalidate() {
@@ -1349,7 +1349,7 @@ class MentraLive: NSObject, SGCManager {
1349
1349
  // Queued writes are session-bound: transmitting them into the NEXT session
1350
1350
  // (e.g. a stale handshake whose reply activates v2 before the new session
1351
1351
  // negotiated) is the bug class this clear removes.
1352
- Task { await commandQueue.removeAll() }
1352
+ commandQueue.removeAll()
1353
1353
  stopSignalStrengthPolling()
1354
1354
  DeviceStore.shared.apply("glasses", "signalStrength", -1)
1355
1355
  DeviceStore.shared.apply("glasses", "signalStrengthUpdatedAt", 0)
@@ -2123,8 +2123,7 @@ class MentraLive: NSObject, SGCManager {
2123
2123
  /// Wire-session generation at enqueue time; the drain loop drops entries from a
2124
2124
  /// previous session so a stale write (worst case: an old handshake whose reply
2125
2125
  /// would activate v2 pre-negotiation) can never cross a session boundary. This is
2126
- /// the deterministic guard - the async removeAll() on disconnect is best-effort
2127
- /// cleanup that may land after a reconnect has already resumed the drain loop.
2126
+ /// the deterministic guard if a consumer already dequeued a previous-session write.
2128
2127
  let generation: Int
2129
2128
  }
2130
2129
 
@@ -2166,7 +2165,7 @@ class MentraLive: NSObject, SGCManager {
2166
2165
  Bridge.log("LIVE: Cleared pending ACK mId \(pendingMessage.id) because \(reason)")
2167
2166
  }
2168
2167
 
2169
- actor CommandQueue {
2168
+ @MainActor final class CommandQueue {
2170
2169
  private var commands: [PendingMessage] = []
2171
2170
 
2172
2171
  func enqueue(_ command: PendingMessage) -> Int {
@@ -2282,15 +2281,11 @@ class MentraLive: NSObject, SGCManager {
2282
2281
  )
2283
2282
 
2284
2283
  // Push to front of queue for immediate retry
2285
- Task {
2286
- let queueSize = await self.commandQueue.pushToFront(retryMessage)
2287
- await MainActor.run {
2288
- self.logBleWriteTrace("retry_queued", retryMessage.trace, extra: [
2289
- "retryDelayMs": 0,
2290
- "queueSizeAfterAdd": queueSize,
2291
- ])
2292
- }
2293
- }
2284
+ let queueSize = commandQueue.pushToFront(retryMessage)
2285
+ logBleWriteTrace("retry_queued", retryMessage.trace, extra: [
2286
+ "retryDelayMs": 0,
2287
+ "queueSizeAfterAdd": queueSize,
2288
+ ])
2294
2289
 
2295
2290
  Bridge.log(
2296
2291
  "🔄 Retrying message mId: \(pendingMessage.id) (attempt \(retryMessage.retries)/3)"
@@ -3714,7 +3709,19 @@ class MentraLive: NSObject, SGCManager {
3714
3709
  sendJson(json, wakeUp: true)
3715
3710
  }
3716
3711
 
3717
- func sendIncidentId(_ incidentId: String, apiBaseUrl: String?) {
3712
+ var incidentLogTransportReady: Bool {
3713
+ connectedPeripheral?.state == .connected && txCharacteristic != nil
3714
+ }
3715
+
3716
+ func sendIncidentId(_ incidentId: String, apiBaseUrl: String?) throws {
3717
+ guard incidentLogTransportReady else {
3718
+ throw BluetoothSdkError(code: "glasses_not_connected", message: "Cannot request incident logs because the glasses BLE link is not ready.")
3719
+ }
3720
+ // Token sync may happen after glasses_ready; never rely on the last pairing's token.
3721
+ let coreToken = DeviceStore.shared.get("bluetooth", "core_token") as? String ?? ""
3722
+ guard !coreToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
3723
+ throw BluetoothSdkError(code: "core_token_unavailable", message: "Cannot request incident logs without the current Core access token.")
3724
+ }
3718
3725
  var base = (apiBaseUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
3719
3726
  if base.isEmpty {
3720
3727
  base = "https://api.mentra.glass"
@@ -3731,10 +3738,20 @@ class MentraLive: NSObject, SGCManager {
3731
3738
  fileBaseKey: lKey, incidentId: incidentId, apiBaseUrl: base, kind: .logcat
3732
3739
  )
3733
3740
 
3734
- Bridge.log(
3735
- "LIVE: Sending incidentId to glasses for log upload: \(incidentId) (BLE relay \(bKey), \(lKey))"
3736
- )
3737
- sendJson(["type": "upload_incident_logs", "incidentId": incidentId, "apiBaseUrl": base], wakeUp: true)
3741
+ let auth: [String: Any] = [
3742
+ "type": "auth_token", "coreToken": coreToken,
3743
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
3744
+ ]
3745
+ // MainActor queue insertion preserves auth-before-upload order, including all fragments.
3746
+ guard sendJson(auth, wakeUp: true, requireAck: true, bridgeLogging: true),
3747
+ sendJson(["type": "upload_incident_logs", "incidentId": incidentId, "apiBaseUrl": base],
3748
+ wakeUp: true, requireAck: true, bridgeLogging: true)
3749
+ else {
3750
+ bleIncidentLogRelays.removeValue(forKey: bKey)
3751
+ bleIncidentLogRelays.removeValue(forKey: lKey)
3752
+ throw BluetoothSdkError(code: "incident_dispatch_failed", message: "Could not queue the glasses incident-log request.")
3753
+ }
3754
+ Bridge.log("LIVE: Queued incidentId to glasses for log upload: \(incidentId) (BLE relay \(bKey), \(lKey))")
3738
3755
  }
3739
3756
 
3740
3757
  private static func incidentBleFileBase(incidentId: String, prefix: Character) -> String {
@@ -4671,20 +4688,13 @@ class MentraLive: NSObject, SGCManager {
4671
4688
 
4672
4689
  func queueSend(_ data: Data, id: String, trace: BleWriteTrace?) {
4673
4690
  let significantQueueSize = SIGNIFICANT_BLE_TRACE_QUEUE_SIZE
4674
- // Capture the epoch at enqueue-request time: the Task body may run after a
4675
- // session reset, and a pre-reset payload stamped with the new generation would
4676
- // defeat the stale-write guard in the drain loop.
4691
+ // Insert synchronously on MainActor so fragments and dependent commands retain call order.
4677
4692
  let generation = wireSessionGeneration
4678
- Task {
4679
- let queueSize = await commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0, trace: trace, generation: generation))
4680
- guard trace != nil, queueSize >= significantQueueSize else {
4681
- return
4682
- }
4683
- await MainActor.run {
4684
- self.logBleChunkTrace("queued", trace, extra: [
4685
- "queueSizeAfterAdd": queueSize,
4686
- ])
4687
- }
4693
+ let queueSize = commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0, trace: trace, generation: generation))
4694
+ if trace != nil, queueSize >= significantQueueSize {
4695
+ logBleChunkTrace("queued", trace, extra: [
4696
+ "queueSizeAfterAdd": queueSize,
4697
+ ])
4688
4698
  }
4689
4699
  }
4690
4700
 
@@ -5201,9 +5211,8 @@ class MentraLive: NSObject, SGCManager {
5201
5211
  bridgeLogging: bridgeLogging
5202
5212
  )
5203
5213
  }
5204
- // Wi-Fi credentials are sent unchanged but never logged; the BLE trace
5205
- // above records this command with the password redacted.
5206
- let loggedPayload = json["password"] == nil
5214
+ // Credentials reach the wire unchanged; diagnostic logs omit their values.
5215
+ let loggedPayload = json["password"] == nil && json["coreToken"] == nil
5207
5216
  ? jsonString : "<\(commandInfo.commandType) with credentials omitted>"
5208
5217
  transportLog("LIVE: Sending data to glasses: \(loggedPayload)", bridgeLogging: bridgeLogging)
5209
5218
  let packedData =
@@ -6000,7 +6009,7 @@ class MentraLive: NSObject, SGCManager {
6000
6009
  // Stop all timers
6001
6010
  stopAllTimers()
6002
6011
  resetWireNegotiationState()
6003
- Task { await commandQueue.removeAll() } // stale writes die with the session
6012
+ commandQueue.removeAll() // stale writes die with the session
6004
6013
  closeL2capFileChannel()
6005
6014
 
6006
6015
  // Disconnect BLE
@@ -217,7 +217,7 @@ protocol SGCManager {
217
217
 
218
218
  // MARK: - Incident Reporting
219
219
 
220
- func sendIncidentId(_ incidentId: String, apiBaseUrl: String?)
220
+ func sendIncidentId(_ incidentId: String, apiBaseUrl: String?) throws
221
221
 
222
222
  // MARK: - Gallery
223
223
 
@@ -0,0 +1,111 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ /// Keep native admission, serialization and fragment packing; replace only the radio writes.
5
+ @MainActor
6
+ private final class IncidentLogTransport: MentraLive {
7
+ var ready = true
8
+ var writes: [(Data, BleWriteTrace?)] = []
9
+
10
+ override var incidentLogTransportReady: Bool {
11
+ ready
12
+ }
13
+
14
+ override func queueSend(_ data: Data, id _: String, trace: BleWriteTrace?) {
15
+ writes.append((data, trace))
16
+ }
17
+ }
18
+
19
+ @MainActor
20
+ final class IncidentLogDispatchTests: XCTestCase {
21
+ private let incident = "rep_01M4HBVT88DFSN8TYNZ1WTV4PB"
22
+ private let apiBase = "https://core.dev.us-west-2.mentraglass.com"
23
+
24
+ func testMissingNativeLinkRejectsDespiteCachedConnectedStatus() {
25
+ let live = MentraLive()
26
+ let store = DeviceStore.shared.store
27
+ let saved = store.getCategory("glasses")["connected"]
28
+ store.set("glasses", "connected", true)
29
+ defer {
30
+ if let saved { store.set("glasses", "connected", saved) }
31
+ else { store.remove("glasses", "connected") }
32
+ }
33
+ XCTAssertThrowsError(try live.sendIncidentId(incident, apiBaseUrl: apiBase)) { error in
34
+ XCTAssertEqual((error as? BluetoothSdkError)?.code, "glasses_not_connected")
35
+ }
36
+ }
37
+
38
+ func testNoDriverRejectsThroughDeviceManager() {
39
+ let manager = DeviceManager()
40
+ manager.sgc = nil
41
+ XCTAssertThrowsError(try manager.sendIncidentId(incident, apiBaseUrl: apiBase)) { error in
42
+ XCTAssertEqual((error as? BluetoothSdkError)?.code, "glasses_not_connected")
43
+ }
44
+ }
45
+
46
+ func testMissingCurrentTokenDoesNotQueueAnUpload() throws {
47
+ try withToken("") {
48
+ let live = IncidentLogTransport()
49
+ XCTAssertThrowsError(try live.sendIncidentId(incident, apiBaseUrl: apiBase)) { error in
50
+ XCTAssertEqual((error as? BluetoothSdkError)?.code, "core_token_unavailable")
51
+ }
52
+ XCTAssertTrue(live.writes.isEmpty)
53
+ }
54
+ }
55
+
56
+ func testCurrentTokenPrecedesUploadAndNeverAppearsInLogs() throws {
57
+ let token = "synthetic-\(UUID().uuidString)"
58
+ try withToken(token) {
59
+ let live = IncidentLogTransport()
60
+ let manager = DeviceManager()
61
+ manager.sgc = live
62
+ var logs: [String] = []
63
+ let sink = Bridge.addEventSink { event, body in
64
+ if event == "log", let message = body["message"] as? String { logs.append(message) }
65
+ }
66
+ defer { Bridge.removeEventSink(sink) }
67
+
68
+ try manager.sendIncidentId(incident, apiBaseUrl: apiBase)
69
+
70
+ XCTAssertEqual(live.writes.compactMap { $0.1?.commandType }, ["auth_token", "upload_incident_logs"])
71
+ XCTAssertTrue(live.writes.allSatisfy { $0.1?.wakeup == true })
72
+ let wire = live.writes.map { String(decoding: $0.0, as: UTF8.self) }.joined()
73
+ XCTAssertTrue(wire.contains(token), "The current token must reach the glasses")
74
+ XCTAssertTrue(wire.contains(incident))
75
+ XCTAssertFalse(logs.contains { $0.contains(token) }, "Tokens must stay out of native and incident logs")
76
+ }
77
+ }
78
+
79
+ func testAllTokenFragmentsPrecedeUpload() throws {
80
+ try withToken(String(repeating: "synthetic", count: 120)) {
81
+ let live = IncidentLogTransport()
82
+ try live.sendIncidentId(incident, apiBaseUrl: apiBase)
83
+ let types = live.writes.compactMap { $0.1?.commandType }
84
+ XCTAssertGreaterThan(types.filter { $0 == "auth_token" }.count, 1)
85
+ XCTAssertEqual(types.last, "upload_incident_logs")
86
+ XCTAssertTrue(types.dropLast().allSatisfy { $0 == "auth_token" })
87
+ }
88
+ }
89
+
90
+ func testCommandQueueInsertionAndSessionClearAreSynchronous() {
91
+ let queue = MentraLive.CommandQueue()
92
+ let first = MentraLive.PendingMessage(data: Data([1]), id: "auth", retries: 0)
93
+ let second = MentraLive.PendingMessage(data: Data([2]), id: "upload", retries: 0)
94
+ XCTAssertEqual(queue.enqueue(first), 1)
95
+ XCTAssertEqual(queue.enqueue(second), 2)
96
+ XCTAssertEqual(queue.dequeue()?.id, "auth")
97
+ queue.removeAll()
98
+ XCTAssertNil(queue.dequeue())
99
+ }
100
+
101
+ private func withToken<T>(_ value: String, body: () throws -> T) rethrows -> T {
102
+ let store = DeviceStore.shared.store
103
+ let saved = store.getCategory("bluetooth")["core_token"]
104
+ store.set("bluetooth", "core_token", value)
105
+ defer {
106
+ if let saved { store.set("bluetooth", "core_token", saved) }
107
+ else { store.remove("bluetooth", "core_token") }
108
+ }
109
+ return try body()
110
+ }
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.3.0-dev.775",
3
+ "version": "3.3.0-dev.784",
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.3.0-dev.775"
117
+ "@mentra/cloud-protocol": "3.3.0-dev.784"
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.3.0",
15
- "releaseIdentity": "3.3.0-dev.775",
16
- "releaseSetId": "mentra-3.3.0-dev.775",
17
- "sourceCommit": "f74b4870eb081f496cbfe2442111ac1b7779a8f2",
18
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.775.json",
19
- "otaManifestSha256": "d6f34112823147203720d8fd99fc010ae6751ea2109542315f4b425b393f282a"
15
+ "releaseIdentity": "3.3.0-dev.784",
16
+ "releaseSetId": "mentra-3.3.0-dev.784",
17
+ "sourceCommit": "7537129fe1722c047fff1b29e0fbccb83a832ea2",
18
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.3.0/mentra-live-ota-3.3.0-dev.784.json",
19
+ "otaManifestSha256": "6c1f8f02cd7defbb485ee267ecc015ab0791322faa53e87c344bf345c60e5644"
20
20
  })