@mentra/bluetooth-sdk 3.2.0-dev.200 → 3.2.0-dev.203

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 (50) hide show
  1. package/README.md +46 -45
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -1
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +63 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +5 -4
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +285 -29
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +312 -45
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +14 -0
  14. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
  18. package/build/BluetoothSdk.types.d.ts +62 -1
  19. package/build/BluetoothSdk.types.d.ts.map +1 -1
  20. package/build/BluetoothSdk.types.js.map +1 -1
  21. package/build/_private/BluetoothSdkModule.d.ts +3 -2
  22. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  23. package/build/_private/BluetoothSdkModule.js.map +1 -1
  24. package/build/generated/changelogCatalog.d.ts +1 -1
  25. package/build/generated/changelogCatalog.js +1 -1
  26. package/build/generated/changelogCatalog.js.map +1 -1
  27. package/build/generated/releaseMetadata.js +5 -5
  28. package/build/generated/releaseMetadata.js.map +1 -1
  29. package/build/index.d.ts +1 -1
  30. package/build/index.d.ts.map +1 -1
  31. package/build/index.js +3 -0
  32. package/build/index.js.map +1 -1
  33. package/ios/BluetoothSdkModule.swift +8 -2
  34. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  35. package/ios/Source/Bridge.swift +63 -0
  36. package/ios/Source/DeviceManager.swift +6 -3
  37. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  38. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  39. package/ios/Source/MentraBluetoothSDK.swift +365 -93
  40. package/ios/Source/WifiRequestResolution.swift +251 -0
  41. package/ios/Source/sgcs/MentraLive.swift +170 -35
  42. package/ios/Source/sgcs/SGCManager.swift +13 -1
  43. package/ios/Source/status/DeviceStatus.swift +28 -0
  44. package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
  45. package/package.json +1 -1
  46. package/src/BluetoothSdk.types.ts +76 -1
  47. package/src/_private/BluetoothSdkModule.ts +4 -1
  48. package/src/generated/changelogCatalog.ts +1 -1
  49. package/src/generated/releaseMetadata.ts +5 -5
  50. package/src/index.ts +11 -0
@@ -39,6 +39,8 @@ import com.mentra.bluetoothsdk.PhotoSize
39
39
  import com.mentra.bluetoothsdk.PhotoMode
40
40
  import com.mentra.bluetoothsdk.DeviceStore
41
41
  import com.mentra.bluetoothsdk.ObservableStore
42
+ import com.mentra.bluetoothsdk.incomingGlassesMessageAckId
43
+ import com.mentra.bluetoothsdk.wifiResponseEnvelopeIsValid
42
44
  import com.mentra.bluetoothsdk.debug.BleTraceLogger
43
45
  import com.mentra.bluetoothsdk.utils.BlePhotoUploadService
44
46
  import com.mentra.bluetoothsdk.utils.ConnTypes
@@ -600,9 +602,14 @@ class MentraLive : SGCManager() {
600
602
 
601
603
  // CTKD (Cross-Transport Key Derivation) support for BES devices
602
604
  private var isBondingReceiverRegistered = false
603
- private var isBtClassicConnected = false
604
605
  private var bondingReceiver: BroadcastReceiver? = null
605
606
  private var bondingRetryCount = 0
607
+ private var classicAudioReceiver: BroadcastReceiver? = null
608
+ private var isClassicAudioReceiverRegistered = false
609
+ private val classicAudioConnectionTracker =
610
+ ClassicAudioConnectionTracker { connected ->
611
+ DeviceStore.apply("glasses", "bluetoothClassicConnected", connected)
612
+ }
606
613
 
607
614
  // Pairing timing diagnostics (filter logcat: PAIRING_TIMING)
608
615
  private var pairingTimingActive = false
@@ -1004,6 +1011,8 @@ class MentraLive : SGCManager() {
1004
1011
 
1005
1012
  // Initialize CTKD bonding receiver
1006
1013
  initializeBondingReceiver()
1014
+ initializeClassicAudioReceiver()
1015
+ registerClassicAudioReceiver()
1007
1016
 
1008
1017
  // Initialize the send queue processor
1009
1018
  processSendQueueRunnable = Runnable {
@@ -2272,6 +2281,8 @@ class MentraLive : SGCManager() {
2272
2281
  isConnecting = false
2273
2282
  isConnected = true
2274
2283
  connectedDevice = gatt.device
2284
+ classicAudioConnectionTracker.setTarget(gatt.device.address)
2285
+ refreshClassicAudioConnectionState(gatt.device)
2275
2286
  emitConnectedPendingDeviceForPairingScan()
2276
2287
 
2277
2288
  DeviceStore.apply("glasses", "bluetoothName", connectedDevice!!.name)
@@ -3305,8 +3316,12 @@ class MentraLive : SGCManager() {
3305
3316
  return messageId
3306
3317
  }
3307
3318
 
3308
- /** Send a JSON object to the glasses with message ID and ACK tracking */
3319
+ /** Send a JSON object to the glasses with message ID and ACK tracking. */
3309
3320
  private fun sendJson(json: JSONObject?, wakeup: Boolean) {
3321
+ sendJson(json, wakeup, true)
3322
+ }
3323
+
3324
+ private fun sendJson(json: JSONObject?, wakeup: Boolean, bridgeLogging: Boolean): Boolean {
3310
3325
  if (json != null) {
3311
3326
  try {
3312
3327
  val sessionGeneration = bleSessionGeneration.get()
@@ -3325,7 +3340,7 @@ class MentraLive : SGCManager() {
3325
3340
  json,
3326
3341
  jsonStr.length
3327
3342
  )
3328
- sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
3343
+ return sendDataToGlasses(jsonStr, wakeup, sessionGeneration, bridgeLogging)
3329
3344
  } else {
3330
3345
  // Add esoteric message ID to the JSON
3331
3346
  val messageId = generateEsotericMessageId()
@@ -3350,12 +3365,13 @@ class MentraLive : SGCManager() {
3350
3365
  // Calculate dynamic timeout for chunked message
3351
3366
  val estimatedChunks = Math.ceil(jsonStr.length / 300.0).toInt()
3352
3367
  ackTimeout = ACK_TIMEOUT_MS + (estimatedChunks * 50L) + 2000L
3353
- Bridge.log(
3368
+ transportLog(
3354
3369
  "LIVE: Message will be chunked into ~" +
3355
3370
  estimatedChunks +
3356
3371
  " chunks, using dynamic timeout: " +
3357
3372
  ackTimeout +
3358
- "ms"
3373
+ "ms",
3374
+ bridgeLogging,
3359
3375
  )
3360
3376
  }
3361
3377
  } catch (e: JSONException) {
@@ -3367,7 +3383,13 @@ class MentraLive : SGCManager() {
3367
3383
  }
3368
3384
 
3369
3385
  // Track the message for ACK with appropriate timeout
3370
- trackMessageForAck(messageId, jsonStr, ackTimeout, sessionGeneration)
3386
+ trackMessageForAck(
3387
+ messageId,
3388
+ jsonStr,
3389
+ ackTimeout,
3390
+ sessionGeneration,
3391
+ bridgeLogging,
3392
+ )
3371
3393
 
3372
3394
  // Send the data
3373
3395
  if ("take_photo" == json.optString("type", "")) {
@@ -3386,7 +3408,9 @@ class MentraLive : SGCManager() {
3386
3408
  json,
3387
3409
  jsonStr.length
3388
3410
  )
3389
- sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
3411
+ val accepted = sendDataToGlasses(jsonStr, wakeup, sessionGeneration, bridgeLogging)
3412
+ if (!accepted) pendingMessages.remove(messageId)
3413
+ return accepted
3390
3414
  }
3391
3415
  } catch (e: JSONException) {
3392
3416
  Log.e(TAG, "Error adding message ID to JSON", e)
@@ -3394,6 +3418,7 @@ class MentraLive : SGCManager() {
3394
3418
  } else {
3395
3419
  Bridge.log("LIVE: Cannot send JSON to ASG, JSON is null")
3396
3420
  }
3421
+ return false
3397
3422
  }
3398
3423
 
3399
3424
  private fun sendJson(json: JSONObject?) {
@@ -3411,20 +3436,25 @@ class MentraLive : SGCManager() {
3411
3436
  messageId: Long,
3412
3437
  messageData: String,
3413
3438
  timeoutMs: Long,
3414
- sessionGeneration: Long
3439
+ sessionGeneration: Long,
3440
+ bridgeLogging: Boolean = true,
3415
3441
  ) {
3416
3442
  if (!isConnected || sessionGeneration != bleSessionGeneration.get()) {
3417
- Bridge.log("LIVE: Not connected, skipping ACK tracking for message " + messageId)
3443
+ transportLog(
3444
+ "LIVE: Not connected, skipping ACK tracking for message " + messageId,
3445
+ bridgeLogging,
3446
+ )
3418
3447
  return
3419
3448
  }
3420
3449
 
3421
3450
  // Skip ACK tracking for glasses with build number < 5 (older firmware)
3422
3451
  if (buildNumberInt < 5) {
3423
- Bridge.log(
3452
+ transportLog(
3424
3453
  "LIVE: Glasses build number (" +
3425
3454
  buildNumberInt +
3426
3455
  ") < 5, skipping ACK tracking for message " +
3427
- messageId
3456
+ messageId,
3457
+ bridgeLogging,
3428
3458
  )
3429
3459
  return
3430
3460
  }
@@ -3446,11 +3476,20 @@ class MentraLive : SGCManager() {
3446
3476
  // Schedule ACK timeout with custom timeout
3447
3477
  handler.postDelayed({ checkMessageAck(messageId, sessionGeneration) }, timeoutMs)
3448
3478
 
3449
- Bridge.log(
3450
- "LIVE: 📋 Tracking message " + messageId + " for ACK (timeout: " + timeoutMs + "ms)"
3479
+ transportLog(
3480
+ "LIVE: 📋 Tracking message " + messageId + " for ACK (timeout: " + timeoutMs + "ms)",
3481
+ bridgeLogging,
3451
3482
  )
3452
3483
  }
3453
3484
 
3485
+ private fun transportLog(message: String, bridgeLogging: Boolean) {
3486
+ if (bridgeLogging) {
3487
+ Bridge.log(message)
3488
+ } else {
3489
+ Log.d(TAG, message)
3490
+ }
3491
+ }
3492
+
3454
3493
  /**
3455
3494
  * Ends the current BLE send session before clearing its ACK state. Scheduled callbacks capture
3456
3495
  * the old generation, and queued retries retain it, so a retry racing this clear cannot become
@@ -3944,6 +3983,9 @@ class MentraLive : SGCManager() {
3944
3983
  }
3945
3984
  }
3946
3985
 
3986
+ val incomingMessageId = if (json.has("mId")) json.optLong("mId") else null
3987
+ incomingGlassesMessageAckId(type, incomingMessageId)?.let(::sendAckToGlasses)
3988
+
3947
3989
  // Check if this is a K900 command format (has "C" field instead of "type")
3948
3990
  if (json.has("C")) {
3949
3991
  processK900JsonMessage(json)
@@ -4098,6 +4140,44 @@ class MentraLive : SGCManager() {
4098
4140
  wifiError.takeIf { it.isNotEmpty() }
4099
4141
  )
4100
4142
  }
4143
+ "wifi_forget_result" -> {
4144
+ if (!wifiResponseEnvelopeIsValid(json.keys().asSequence().associateWith { json.get(it) }, allowLegacy = true)) return
4145
+ Bridge.sendWifiForgetResult(
4146
+ requestId = json.optString("requestId", ""),
4147
+ sid = json.optString("sid", ""),
4148
+ ssid = json.optString("ssid", ""),
4149
+ protocolVersion = json.optInt("protocol_version", 0),
4150
+ outcome = json.optString("outcome", ""),
4151
+ legacyDispatched =
4152
+ if (json.has("dispatched")) json.optBoolean("dispatched") else null,
4153
+ connected =
4154
+ if (json.has("connected")) json.optBoolean("connected") else null,
4155
+ currentSsid = json.optString("current_ssid", ""),
4156
+ localIp = json.optString("local_ip", ""),
4157
+ error = json.optString("error", "").ifEmpty { null },
4158
+ )
4159
+ }
4160
+ "saved_wifi_networks" -> {
4161
+ if (!wifiResponseEnvelopeIsValid(json.keys().asSequence().associateWith { json.get(it) }, allowLegacy = false)) return
4162
+ val networks = mutableListOf<String>()
4163
+ val networkArray = json.optJSONArray("networks") ?: return
4164
+ if (networkArray != null) {
4165
+ for (i in 0 until networkArray.length()) {
4166
+ val network = networkArray.opt(i) as? String ?: return
4167
+ network.takeIf { it.trim().isNotEmpty() }?.let {
4168
+ networks.add(it)
4169
+ }
4170
+ }
4171
+ }
4172
+ Bridge.sendSavedWifiNetworks(
4173
+ requestId = json.optString("requestId", ""),
4174
+ sid = json.optString("sid", ""),
4175
+ protocolVersion = json.optInt("protocol_version", 0),
4176
+ outcome = json.optString("outcome", ""),
4177
+ networks = networks,
4178
+ error = json.optString("error", "").ifEmpty { null },
4179
+ )
4180
+ }
4101
4181
  "hotspot_status_update" -> {
4102
4182
  // Process hotspot status information (same pattern as "wifi_status")
4103
4183
  val hotspotEnabled = json.optBoolean("hotspot_enabled", false)
@@ -4479,7 +4559,11 @@ class MentraLive : SGCManager() {
4479
4559
  // Record the session id BEFORE marking ready: an unsolicited glasses_ready
4480
4560
  // already runs this full remote-reset flow, so recording (not re-triggering)
4481
4561
  // is correct here; version_info detection covers the restart case.
4482
- json.optString("sid", "").takeIf { it.isNotEmpty() }?.let { glassesSessionId = it }
4562
+ glassesSessionId = json.optString("sid", "").takeIf { it.isNotEmpty() }
4563
+ Bridge.sendTypedMessage(
4564
+ "wifi_protocol_session_ready",
4565
+ mapOf("sid" to (glassesSessionId ?: "")),
4566
+ )
4483
4567
  readinessCompletedThisBleSession = true
4484
4568
 
4485
4569
  // Set the ready flag to stop any future readiness checks
@@ -4673,6 +4757,7 @@ class MentraLive : SGCManager() {
4673
4757
  }
4674
4758
 
4675
4759
  val versionInfoLegacy = HashMap<String, Any>()
4760
+ versionInfoLegacy["version_info_type"] = "version_info"
4676
4761
  versionInfoLegacy["appVersion"] = appVersionLegacy
4677
4762
  versionInfoLegacy["buildNumber"] = buildNumberLegacy
4678
4763
  versionInfoLegacy["deviceModel"] = deviceModelLegacy
@@ -4856,6 +4941,7 @@ class MentraLive : SGCManager() {
4856
4941
 
4857
4942
  // Extract all fields from JSON (except "type")
4858
4943
  val fields = HashMap<String, Any>()
4944
+ fields["version_info_type"] = type
4859
4945
  val keys = json.keys()
4860
4946
  while (keys.hasNext()) {
4861
4947
  val key = keys.next()
@@ -6978,7 +7064,16 @@ class MentraLive : SGCManager() {
6978
7064
  "ctkd_bond_none",
6979
7065
  "prev=$previousBondState queueSize=${sendQueue.size}"
6980
7066
  )
6981
- isBtClassicConnected = false
7067
+ if (previousBondState == BluetoothDevice.BOND_BONDED) {
7068
+ // A real unpair ends this Classic session. Invalidate
7069
+ // the target so delayed profile broadcasts cannot
7070
+ // make pairing look connected again.
7071
+ classicAudioConnectionTracker.invalidate(device.address)
7072
+ } else {
7073
+ // A failed/cancelled bond may be retried below, so keep
7074
+ // the target while clearing any partial profile state.
7075
+ classicAudioConnectionTracker.clear(device.address)
7076
+ }
6982
7077
  audioConnected = false
6983
7078
  if (previousBondState == BluetoothDevice.BOND_BONDING) {
6984
7079
  // User cancelled or bonding failed - retry up to
@@ -7078,6 +7173,124 @@ class MentraLive : SGCManager() {
7078
7173
  }
7079
7174
  }
7080
7175
 
7176
+ /** Observe the two Android Classic audio profiles that Mentra Live exposes. */
7177
+ private fun initializeClassicAudioReceiver() {
7178
+ classicAudioReceiver =
7179
+ object : BroadcastReceiver() {
7180
+ override fun onReceive(context: Context?, intent: Intent?) {
7181
+ if (intent?.action != BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED &&
7182
+ intent?.action != BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) return
7183
+ val device = intent.bluetoothDeviceExtra() ?: return
7184
+ refreshClassicAudioConnectionState(device)
7185
+ }
7186
+ }
7187
+ }
7188
+
7189
+ private fun registerClassicAudioReceiver() {
7190
+ val ctx = context ?: return
7191
+ val receiver = classicAudioReceiver ?: return
7192
+ if (isClassicAudioReceiverRegistered) return
7193
+
7194
+ try {
7195
+ val filter =
7196
+ IntentFilter().apply {
7197
+ addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)
7198
+ addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
7199
+ }
7200
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
7201
+ ctx.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
7202
+ } else {
7203
+ ctx.registerReceiver(receiver, filter)
7204
+ }
7205
+ isClassicAudioReceiverRegistered = true
7206
+ Bridge.log("LIVE: Classic: A2DP/HFP status receiver registered")
7207
+ } catch (e: Exception) {
7208
+ Bridge.log("LIVE: Classic: failed to register status receiver: " + e.message)
7209
+ }
7210
+ }
7211
+
7212
+ private fun unregisterClassicAudioReceiver() {
7213
+ if (!isClassicAudioReceiverRegistered || classicAudioReceiver == null) return
7214
+
7215
+ try {
7216
+ context?.unregisterReceiver(classicAudioReceiver)
7217
+ } catch (e: Exception) {
7218
+ Bridge.log("LIVE: Classic: failed to unregister status receiver: " + e.message)
7219
+ } finally {
7220
+ isClassicAudioReceiverRegistered = false
7221
+ }
7222
+ }
7223
+
7224
+ /**
7225
+ * Broadcast payloads are hints, never current truth (even when the MAC matches). Query both
7226
+ * profiles and publish one combined snapshot. A newer refresh or teardown invalidates all
7227
+ * outstanding results, including a previous connection to the same physical glasses.
7228
+ */
7229
+ private fun refreshClassicAudioConnectionState(device: BluetoothDevice) {
7230
+ if (Looper.myLooper() != handler.looper) {
7231
+ handler.post { refreshClassicAudioConnectionState(device) }
7232
+ return
7233
+ }
7234
+ if (isKilled) return
7235
+ val adapter = bluetoothAdapter ?: return
7236
+ val ctx = context ?: return
7237
+ val ticket = classicAudioConnectionTracker.beginSnapshot(device.address) ?: return
7238
+ val states = mutableMapOf<ClassicAudioProfile, Boolean>()
7239
+ for ((profileId, audioProfile) in listOf(
7240
+ BluetoothProfile.A2DP to ClassicAudioProfile.A2DP,
7241
+ BluetoothProfile.HEADSET to ClassicAudioProfile.HEADSET,
7242
+ )) {
7243
+ try {
7244
+ val requested = adapter.getProfileProxy(ctx, object : BluetoothProfile.ServiceListener {
7245
+ override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
7246
+ // Close before dispatch: destroy() may discard queued handler work.
7247
+ val connectedState = try {
7248
+ if (profile != profileId) null
7249
+ else proxy.getConnectionState(device) == BluetoothProfile.STATE_CONNECTED
7250
+ } catch (e: Exception) {
7251
+ Bridge.log("LIVE: Classic: snapshot unavailable: " + e.message)
7252
+ null
7253
+ } finally {
7254
+ try { adapter.closeProfileProxy(profileId, proxy) }
7255
+ catch (_: Exception) {}
7256
+ }
7257
+ if (connectedState == null) return
7258
+ handler.post {
7259
+ if (isKilled || profile != profileId) return@post
7260
+ states[audioProfile] = connectedState
7261
+ val connected = readyClassicAudioSnapshot(states) ?: return@post
7262
+ classicAudioConnectionTracker.applySnapshot(
7263
+ ticket, device.address, connected) {
7264
+ if (ClassicAudioProfile.A2DP in connected) {
7265
+ markAudioConnected(device)
7266
+ } else if (states[ClassicAudioProfile.A2DP] == false && audioConnected) {
7267
+ audioConnected = false
7268
+ Bridge.sendAudioDisconnected()
7269
+ }
7270
+ }
7271
+ }
7272
+ }
7273
+ override fun onServiceDisconnected(profile: Int) {}
7274
+ }, profileId)
7275
+ if (!requested) {
7276
+ // Unknown is not disconnected. A successful other-profile read can still
7277
+ // publish connected without waiting for this unavailable service.
7278
+ Bridge.log("LIVE: Classic: profile service unavailable: $profileId")
7279
+ }
7280
+ } catch (e: Exception) {
7281
+ Bridge.log("LIVE: Classic: profile query unavailable: " + e.message)
7282
+ }
7283
+ }
7284
+ }
7285
+
7286
+ @Suppress("DEPRECATION")
7287
+ private fun Intent.bluetoothDeviceExtra(): BluetoothDevice? =
7288
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
7289
+ getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
7290
+ } else {
7291
+ getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
7292
+ }
7293
+
7081
7294
  /**
7082
7295
  * Start CTKD Classic bonding / A2DP only after BLE notifications are ready. Calling this from
7083
7296
  * onConnectionStateChange(CONNECTED) races dual-mode stacks and frequently yields GATT 19
@@ -7154,7 +7367,7 @@ class MentraLive : SGCManager() {
7154
7367
  val method = device.javaClass.getMethod("removeBond")
7155
7368
  val result = method.invoke(device) as Boolean
7156
7369
  Bridge.log("LIVE: CTKD: Bond removal initiated, result: " + result)
7157
- isBtClassicConnected = false
7370
+ classicAudioConnectionTracker.invalidate(device.address)
7158
7371
  return result
7159
7372
  } catch (e: Exception) {
7160
7373
  Bridge.log("LIVE: CTKD: Error removing bond: " + e.message)
@@ -7164,7 +7377,7 @@ class MentraLive : SGCManager() {
7164
7377
 
7165
7378
  /** Check if BT Classic is connected via CTKD */
7166
7379
  fun isBtClassicConnected(): Boolean {
7167
- return isBtClassicConnected
7380
+ return classicAudioConnectionTracker.connected
7168
7381
  }
7169
7382
 
7170
7383
  /** A2DP profile service listener for connecting to already-bonded devices */
@@ -7223,11 +7436,11 @@ class MentraLive : SGCManager() {
7223
7436
  try {
7224
7437
  val state = a2dpProfile!!.getConnectionState(device)
7225
7438
  Bridge.log("LIVE: A2DP: Current connection state: " + state)
7439
+ refreshClassicAudioConnectionState(device)
7226
7440
 
7227
7441
  if (state == BluetoothProfile.STATE_CONNECTED) {
7228
7442
  Bridge.log("LIVE: A2DP: Already connected to " + device.name)
7229
7443
  a2dpConnectAttempts = 0
7230
- markAudioConnected(device.name)
7231
7444
  } else if (state == BluetoothProfile.STATE_DISCONNECTED) {
7232
7445
  Bridge.log("LIVE: A2DP: Connecting to " + device.name)
7233
7446
  // Use reflection to call connect() as it's a hidden API
@@ -7280,16 +7493,18 @@ class MentraLive : SGCManager() {
7280
7493
  }
7281
7494
 
7282
7495
  /** Helper to mark audio as connected and notify */
7283
- private fun markAudioConnected(deviceName: String?) {
7496
+ private fun markAudioConnected(device: BluetoothDevice) {
7284
7497
  if (isKilled) {
7285
7498
  Bridge.log(
7286
7499
  "LIVE: A2DP: Ignoring markAudioConnected — SGC destroyed (would confuse DeviceManager)"
7287
7500
  )
7288
7501
  return
7289
7502
  }
7290
- isBtClassicConnected = true
7503
+ val wasAudioConnected = audioConnected
7291
7504
  audioConnected = true
7292
- Bridge.sendAudioConnected(deviceName!!)
7505
+ if (!wasAudioConnected) {
7506
+ Bridge.sendAudioConnected(safeDeviceName(device))
7507
+ }
7293
7508
  if (glassesReadyReceived) {
7294
7509
  Bridge.log(
7295
7510
  "LIVE: A2DP: Both audio and glasses_ready confirmed - marking as fully connected"
@@ -7372,10 +7587,10 @@ class MentraLive : SGCManager() {
7372
7587
  device.address +
7373
7588
  ")"
7374
7589
  )
7375
- val isActiveDevice =
7376
- connectedDevice?.address?.equals(device.address, ignoreCase = true) == true
7377
- if (isActiveDevice) {
7378
- isBtClassicConnected = false
7590
+ // The BLE device reference may already be cleared after a GATT drop. The tracker owns the
7591
+ // authoritative target address, so let it decide whether this teardown belongs to the
7592
+ // active Classic session instead of consulting connectedDevice.
7593
+ if (classicAudioConnectionTracker.invalidate(device.address)) {
7379
7594
  audioConnected = false
7380
7595
  }
7381
7596
 
@@ -7507,12 +7722,15 @@ class MentraLive : SGCManager() {
7507
7722
 
7508
7723
  // CTKD Implementation: Unregister bonding receiver
7509
7724
  unregisterBondingReceiver()
7725
+ unregisterClassicAudioReceiver()
7510
7726
 
7511
7727
  // Tear down Classic (A2DP/HFP) before GATT close. Closing only the proxy left the
7512
7728
  // phone's system Bluetooth UI showing Mentra Live still connected.
7513
7729
  val classicDevice = connectedDevice
7514
7730
  disconnectClassicProfiles(classicDevice)
7515
7731
  closeA2dpProxy()
7732
+ classicAudioConnectionTracker.reset()
7733
+ DeviceStore.apply("glasses", "bluetoothClassicConnected", false)
7516
7734
 
7517
7735
  // Stop readiness check loop
7518
7736
  stopReadinessCheckLoop()
@@ -8867,9 +9085,10 @@ class MentraLive : SGCManager() {
8867
9085
  wireBytes: Int,
8868
9086
  packetCount: Int,
8869
9087
  protocolVersion: Int,
8870
- direction: String
9088
+ direction: String,
9089
+ bridgeLogging: Boolean = true,
8871
9090
  ) {
8872
- Bridge.log(
9091
+ transportLog(
8873
9092
  "BLE_TRACE direction=" +
8874
9093
  direction +
8875
9094
  " proto=v" +
@@ -8879,7 +9098,8 @@ class MentraLive : SGCManager() {
8879
9098
  " wire=" +
8880
9099
  wireBytes +
8881
9100
  " packets=" +
8882
- packetCount
9101
+ packetCount,
9102
+ bridgeLogging,
8883
9103
  )
8884
9104
  }
8885
9105
 
@@ -8897,15 +9117,19 @@ class MentraLive : SGCManager() {
8897
9117
  private fun sendDataToGlasses(
8898
9118
  data: String?,
8899
9119
  wakeup: Boolean,
8900
- sessionGeneration: Long
8901
- ) {
9120
+ sessionGeneration: Long,
9121
+ bridgeLogging: Boolean = true,
9122
+ ): Boolean {
8902
9123
  if (data == null || data.isEmpty()) {
8903
9124
  Log.e(TAG, "Cannot send empty data to glasses")
8904
- return
9125
+ return false
8905
9126
  }
8906
9127
  if (sessionGeneration != bleSessionGeneration.get()) {
8907
- Bridge.log("LIVE: Dropping send request from stale BLE session $sessionGeneration")
8908
- return
9128
+ transportLog(
9129
+ "LIVE: Dropping send request from stale BLE session $sessionGeneration",
9130
+ bridgeLogging,
9131
+ )
9132
+ return false
8909
9133
  }
8910
9134
 
8911
9135
  val wireData =
@@ -8933,14 +9157,14 @@ class MentraLive : SGCManager() {
8933
9157
  }
8934
9158
 
8935
9159
  if (useBinaryWireProtocol && buildNumberInt >= 5) {
8936
- sendDataToGlassesBinary(
9160
+ return sendDataToGlassesBinary(
8937
9161
  wireData,
8938
9162
  wakeup,
8939
9163
  commandTraceInfo,
8940
9164
  isPhotoRequest,
8941
- sessionGeneration
9165
+ sessionGeneration,
9166
+ bridgeLogging,
8942
9167
  )
8943
- return
8944
9168
  }
8945
9169
 
8946
9170
  // First check if the message needs chunking
@@ -8954,7 +9178,7 @@ class MentraLive : SGCManager() {
8954
9178
 
8955
9179
  // Check if chunking is needed
8956
9180
  if (MessageChunker.needsChunking(testWrappedJson)) {
8957
- Bridge.log("LIVE: Message exceeds threshold, chunking required")
9181
+ transportLog("LIVE: Message exceeds threshold, chunking required", bridgeLogging)
8958
9182
  if (isPhotoRequest) {
8959
9183
  Bridge.log(
8960
9184
  "LIVE: PHOTO PIPELINE BLE handoff — chunking enabled for request payload"
@@ -8972,7 +9196,8 @@ class MentraLive : SGCManager() {
8972
9196
 
8973
9197
  // Create chunks
8974
9198
  val chunks = MessageChunker.createChunks(wireData, messageId, wakeup)
8975
- Bridge.log("LIVE: Sending " + chunks.size + " chunks")
9199
+ if (chunks.isEmpty()) return false
9200
+ transportLog("LIVE: Sending " + chunks.size + " chunks", bridgeLogging)
8976
9201
  if (isPhotoRequest) {
8977
9202
  Bridge.log(
8978
9203
  "LIVE: PHOTO PIPELINE BLE handoff — created " +
@@ -9030,13 +9255,13 @@ class MentraLive : SGCManager() {
9030
9255
  }
9031
9256
  }
9032
9257
 
9033
- Bridge.log("LIVE: All chunks queued for transmission")
9258
+ transportLog("LIVE: All chunks queued for transmission", bridgeLogging)
9034
9259
  if (isPhotoRequest) {
9035
9260
  Bridge.log("LIVE: PHOTO PIPELINE BLE handoff — all photo chunks queued")
9036
9261
  }
9037
9262
  } else {
9038
9263
  // Normal single message transmission
9039
- Bridge.log("LIVE: Sending data to glasses: " + wireData)
9264
+ transportLog("LIVE: Sending data to glasses: " + wireData, bridgeLogging)
9040
9265
 
9041
9266
  // Pack the data using the centralized utility with the negotiated endianness
9042
9267
  val packedData =
@@ -9070,7 +9295,9 @@ class MentraLive : SGCManager() {
9070
9295
  }
9071
9296
  } catch (e: Exception) {
9072
9297
  Log.e(TAG, "Error creating data JSON", e)
9298
+ return false
9073
9299
  }
9300
+ return sessionGeneration == bleSessionGeneration.get()
9074
9301
  }
9075
9302
 
9076
9303
  private fun sendDataToGlassesBinary(
@@ -9078,8 +9305,9 @@ class MentraLive : SGCManager() {
9078
9305
  wakeup: Boolean,
9079
9306
  commandTraceInfo: OutgoingBleCommandTraceInfo,
9080
9307
  isPhotoRequest: Boolean,
9081
- sessionGeneration: Long
9082
- ) {
9308
+ sessionGeneration: Long,
9309
+ bridgeLogging: Boolean,
9310
+ ): Boolean {
9083
9311
  val payloadBytes = data.toByteArray(StandardCharsets.UTF_8)
9084
9312
  var messageId = 0
9085
9313
  var ackRequested = false
@@ -9098,6 +9326,7 @@ class MentraLive : SGCManager() {
9098
9326
  wakeup,
9099
9327
  ackRequested
9100
9328
  )
9329
+ if (fragments.isEmpty()) return false
9101
9330
  var totalWireBytes = 0
9102
9331
  for (i in fragments.indices) {
9103
9332
  val fragment = fragments[i]
@@ -9146,7 +9375,8 @@ class MentraLive : SGCManager() {
9146
9375
  totalWireBytes,
9147
9376
  fragments.size,
9148
9377
  BleWireProtocol.PROTOCOL_V2,
9149
- "phone_to_glasses"
9378
+ "phone_to_glasses",
9379
+ bridgeLogging,
9150
9380
  )
9151
9381
 
9152
9382
  if (isPhotoRequest) {
@@ -9157,6 +9387,7 @@ class MentraLive : SGCManager() {
9157
9387
  totalWireBytes
9158
9388
  )
9159
9389
  }
9390
+ return sessionGeneration == bleSessionGeneration.get()
9160
9391
  }
9161
9392
 
9162
9393
  private fun parseOutgoingBleCommandTraceInfo(payload: String): OutgoingBleCommandTraceInfo {
@@ -9438,16 +9669,45 @@ class MentraLive : SGCManager() {
9438
9669
  * K900 SystemUI can properly clear the cached credentials
9439
9670
  */
9440
9671
  override fun forgetWifiNetwork(ssid: String) {
9441
- Bridge.log("LIVE: 📶 Sending WiFi forget command for SSID: " + ssid)
9672
+ forgetWifiNetwork(ssid, null, null)
9673
+ }
9674
+
9675
+ override fun forgetWifiNetwork(ssid: String, requestId: String?, sid: String?): Boolean {
9676
+ if (!isConnected || bluetoothGatt == null || txCharacteristic == null || ssid.isBlank()) return false
9677
+ if ((requestId == null) != (sid == null)) return false
9678
+ if (requestId != null && (requestId.isBlank() || sid.isNullOrBlank())) return false
9679
+ Log.d(TAG, "LIVE: 📶 Sending WiFi forget command for SSID: " + ssid)
9442
9680
 
9443
9681
  try {
9444
9682
  val wifiCommand = JSONObject()
9445
9683
  wifiCommand.put("type", "forget_wifi")
9446
9684
  wifiCommand.put("ssid", ssid)
9447
- sendJson(wifiCommand, true)
9685
+ if (!requestId.isNullOrEmpty()) {
9686
+ wifiCommand.put("requestId", requestId)
9687
+ }
9688
+ if (!sid.isNullOrEmpty()) {
9689
+ wifiCommand.put("sid", sid)
9690
+ wifiCommand.put("protocolVersion", 1)
9691
+ }
9692
+ // This dispatch runs under the WiFi lifecycle lock. Keep its transport logs
9693
+ // native-only so a public log listener cannot re-enter reset before it is queued.
9694
+ return sendJson(wifiCommand, true, bridgeLogging = false)
9448
9695
  } catch (e: JSONException) {
9449
9696
  Log.e(TAG, "Error creating WiFi forget JSON", e)
9450
9697
  }
9698
+ return false
9699
+ }
9700
+
9701
+ override fun requestSavedWifiNetworks(requestId: String, sid: String): Boolean {
9702
+ if (!isConnected || bluetoothGatt == null || txCharacteristic == null || requestId.isBlank() || sid.isBlank()) return false
9703
+ val command = JSONObject()
9704
+ command.put("type", "request_saved_wifi_networks")
9705
+ command.put("requestId", requestId)
9706
+ command.put("protocolVersion", 1)
9707
+ if (sid.isNotEmpty()) {
9708
+ command.put("sid", sid)
9709
+ }
9710
+ return sendJson(command, true, bridgeLogging = false)
9451
9711
  }
9452
9712
 
9453
9713
  override fun sendHotspotState(enabled: Boolean) {
@@ -9554,6 +9814,13 @@ class MentraLive : SGCManager() {
9554
9814
  }
9555
9815
  }
9556
9816
 
9817
+ private fun sendAckToGlasses(messageId: Long) {
9818
+ val ack = JSONObject()
9819
+ ack.put("type", "msg_ack")
9820
+ ack.put("mId", messageId)
9821
+ sendJsonWithoutAck(ack, false)
9822
+ }
9823
+
9557
9824
  private fun sendJsonWithoutAck(json: JSONObject?) {
9558
9825
  sendJsonWithoutAck(json, false)
9559
9826
  }