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

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 (60) hide show
  1. package/README.md +46 -45
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -9
  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 +298 -181
  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 +326 -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/main/java/com/mentra/bluetoothsdk/streaming/StreamControllerProbe.kt +14 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamSessionState.kt +32 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/streaming/StreamControllerProbeTest.kt +24 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/streaming/StreamSessionStateTest.kt +43 -0
  23. package/build/BluetoothSdk.types.d.ts +68 -2
  24. package/build/BluetoothSdk.types.d.ts.map +1 -1
  25. package/build/BluetoothSdk.types.js.map +1 -1
  26. package/build/_private/BluetoothSdkModule.d.ts +3 -4
  27. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  28. package/build/_private/BluetoothSdkModule.js.map +1 -1
  29. package/build/generated/changelogCatalog.d.ts +1 -1
  30. package/build/generated/changelogCatalog.js +1 -1
  31. package/build/generated/changelogCatalog.js.map +1 -1
  32. package/build/generated/releaseMetadata.js +5 -5
  33. package/build/generated/releaseMetadata.js.map +1 -1
  34. package/build/index.d.ts +1 -1
  35. package/build/index.d.ts.map +1 -1
  36. package/build/index.js +3 -0
  37. package/build/index.js.map +1 -1
  38. package/ios/BluetoothSdkModule.swift +8 -13
  39. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  40. package/ios/Source/Bridge.swift +63 -0
  41. package/ios/Source/DeviceManager.swift +6 -3
  42. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  43. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  44. package/ios/Source/MentraBluetoothSDK.swift +372 -220
  45. package/ios/Source/WifiRequestResolution.swift +251 -0
  46. package/ios/Source/sgcs/MentraLive.swift +216 -70
  47. package/ios/Source/sgcs/SGCManager.swift +13 -1
  48. package/ios/Source/status/DeviceStatus.swift +28 -0
  49. package/ios/Source/streaming/StreamControllerProbe.swift +18 -0
  50. package/ios/Source/streaming/StreamModels.swift +12 -0
  51. package/ios/Source/streaming/StreamSessionState.swift +29 -0
  52. package/ios/Tests/StreamControllerProbeTests.swift +27 -0
  53. package/ios/Tests/StreamSessionStateTests.swift +38 -0
  54. package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
  55. package/package.json +1 -1
  56. package/src/BluetoothSdk.types.ts +82 -2
  57. package/src/_private/BluetoothSdkModule.ts +4 -4
  58. package/src/generated/changelogCatalog.ts +1 -1
  59. package/src/generated/releaseMetadata.ts +5 -5
  60. 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)
@@ -4044,6 +4086,14 @@ class MentraLive : SGCManager() {
4044
4086
  val percent = json.optInt("percent", batteryLevel)
4045
4087
  updateBatteryStatus(percent, isCharging)
4046
4088
  }
4089
+ "stream_controller_probe" -> {
4090
+ val values = mapOf("protocolVersion" to json.opt("protocolVersion"),
4091
+ "controllerId" to json.opt("controllerId"), "streamId" to json.opt("streamId"),
4092
+ "probeId" to json.opt("probeId"))
4093
+ com.mentra.bluetoothsdk.streaming.StreamControllerProbe.response(values)?.let {
4094
+ sendJson(JSONObject(it))
4095
+ }
4096
+ }
4047
4097
  "pong" ->
4048
4098
  // Process heartbeat pong response
4049
4099
  Bridge.log("LIVE: Received pong response - connection healthy")
@@ -4098,6 +4148,44 @@ class MentraLive : SGCManager() {
4098
4148
  wifiError.takeIf { it.isNotEmpty() }
4099
4149
  )
4100
4150
  }
4151
+ "wifi_forget_result" -> {
4152
+ if (!wifiResponseEnvelopeIsValid(json.keys().asSequence().associateWith { json.get(it) }, allowLegacy = true)) return
4153
+ Bridge.sendWifiForgetResult(
4154
+ requestId = json.optString("requestId", ""),
4155
+ sid = json.optString("sid", ""),
4156
+ ssid = json.optString("ssid", ""),
4157
+ protocolVersion = json.optInt("protocol_version", 0),
4158
+ outcome = json.optString("outcome", ""),
4159
+ legacyDispatched =
4160
+ if (json.has("dispatched")) json.optBoolean("dispatched") else null,
4161
+ connected =
4162
+ if (json.has("connected")) json.optBoolean("connected") else null,
4163
+ currentSsid = json.optString("current_ssid", ""),
4164
+ localIp = json.optString("local_ip", ""),
4165
+ error = json.optString("error", "").ifEmpty { null },
4166
+ )
4167
+ }
4168
+ "saved_wifi_networks" -> {
4169
+ if (!wifiResponseEnvelopeIsValid(json.keys().asSequence().associateWith { json.get(it) }, allowLegacy = false)) return
4170
+ val networks = mutableListOf<String>()
4171
+ val networkArray = json.optJSONArray("networks") ?: return
4172
+ if (networkArray != null) {
4173
+ for (i in 0 until networkArray.length()) {
4174
+ val network = networkArray.opt(i) as? String ?: return
4175
+ network.takeIf { it.trim().isNotEmpty() }?.let {
4176
+ networks.add(it)
4177
+ }
4178
+ }
4179
+ }
4180
+ Bridge.sendSavedWifiNetworks(
4181
+ requestId = json.optString("requestId", ""),
4182
+ sid = json.optString("sid", ""),
4183
+ protocolVersion = json.optInt("protocol_version", 0),
4184
+ outcome = json.optString("outcome", ""),
4185
+ networks = networks,
4186
+ error = json.optString("error", "").ifEmpty { null },
4187
+ )
4188
+ }
4101
4189
  "hotspot_status_update" -> {
4102
4190
  // Process hotspot status information (same pattern as "wifi_status")
4103
4191
  val hotspotEnabled = json.optBoolean("hotspot_enabled", false)
@@ -4479,8 +4567,16 @@ class MentraLive : SGCManager() {
4479
4567
  // Record the session id BEFORE marking ready: an unsolicited glasses_ready
4480
4568
  // already runs this full remote-reset flow, so recording (not re-triggering)
4481
4569
  // is correct here; version_info detection covers the restart case.
4482
- json.optString("sid", "").takeIf { it.isNotEmpty() }?.let { glassesSessionId = it }
4570
+ glassesSessionId = json.optString("sid", "").takeIf { it.isNotEmpty() }
4571
+ Bridge.sendTypedMessage(
4572
+ "wifi_protocol_session_ready",
4573
+ mapOf("sid" to (glassesSessionId ?: "")),
4574
+ )
4483
4575
  readinessCompletedThisBleSession = true
4576
+ Bridge.sendTypedMessage("stream_control_ready", mapOf(
4577
+ "sid" to json.optString("sid", ""),
4578
+ "streamControlVersion" to json.optInt("streamControlVersion", 0),
4579
+ ))
4484
4580
 
4485
4581
  // Set the ready flag to stop any future readiness checks
4486
4582
  glassesReady = true
@@ -4673,6 +4769,7 @@ class MentraLive : SGCManager() {
4673
4769
  }
4674
4770
 
4675
4771
  val versionInfoLegacy = HashMap<String, Any>()
4772
+ versionInfoLegacy["version_info_type"] = "version_info"
4676
4773
  versionInfoLegacy["appVersion"] = appVersionLegacy
4677
4774
  versionInfoLegacy["buildNumber"] = buildNumberLegacy
4678
4775
  versionInfoLegacy["deviceModel"] = deviceModelLegacy
@@ -4856,6 +4953,7 @@ class MentraLive : SGCManager() {
4856
4953
 
4857
4954
  // Extract all fields from JSON (except "type")
4858
4955
  val fields = HashMap<String, Any>()
4956
+ fields["version_info_type"] = type
4859
4957
  val keys = json.keys()
4860
4958
  while (keys.hasNext()) {
4861
4959
  val key = keys.next()
@@ -6716,6 +6814,8 @@ class MentraLive : SGCManager() {
6716
6814
  val json = JSONObject(message as Map<*, *>)
6717
6815
  // Remove timestamp as iOS does
6718
6816
  json.remove("timestamp")
6817
+ json.put("controllerProbeVersion", 1)
6818
+ json.put("controllerId", com.mentra.bluetoothsdk.streaming.StreamControllerProbe.controllerId)
6719
6819
  sendJson(json, true)
6720
6820
  } catch (e: Exception) {
6721
6821
  Log.e(TAG, "Error creating RTMP stream start JSON", e)
@@ -6978,7 +7078,16 @@ class MentraLive : SGCManager() {
6978
7078
  "ctkd_bond_none",
6979
7079
  "prev=$previousBondState queueSize=${sendQueue.size}"
6980
7080
  )
6981
- isBtClassicConnected = false
7081
+ if (previousBondState == BluetoothDevice.BOND_BONDED) {
7082
+ // A real unpair ends this Classic session. Invalidate
7083
+ // the target so delayed profile broadcasts cannot
7084
+ // make pairing look connected again.
7085
+ classicAudioConnectionTracker.invalidate(device.address)
7086
+ } else {
7087
+ // A failed/cancelled bond may be retried below, so keep
7088
+ // the target while clearing any partial profile state.
7089
+ classicAudioConnectionTracker.clear(device.address)
7090
+ }
6982
7091
  audioConnected = false
6983
7092
  if (previousBondState == BluetoothDevice.BOND_BONDING) {
6984
7093
  // User cancelled or bonding failed - retry up to
@@ -7078,6 +7187,124 @@ class MentraLive : SGCManager() {
7078
7187
  }
7079
7188
  }
7080
7189
 
7190
+ /** Observe the two Android Classic audio profiles that Mentra Live exposes. */
7191
+ private fun initializeClassicAudioReceiver() {
7192
+ classicAudioReceiver =
7193
+ object : BroadcastReceiver() {
7194
+ override fun onReceive(context: Context?, intent: Intent?) {
7195
+ if (intent?.action != BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED &&
7196
+ intent?.action != BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) return
7197
+ val device = intent.bluetoothDeviceExtra() ?: return
7198
+ refreshClassicAudioConnectionState(device)
7199
+ }
7200
+ }
7201
+ }
7202
+
7203
+ private fun registerClassicAudioReceiver() {
7204
+ val ctx = context ?: return
7205
+ val receiver = classicAudioReceiver ?: return
7206
+ if (isClassicAudioReceiverRegistered) return
7207
+
7208
+ try {
7209
+ val filter =
7210
+ IntentFilter().apply {
7211
+ addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)
7212
+ addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
7213
+ }
7214
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
7215
+ ctx.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
7216
+ } else {
7217
+ ctx.registerReceiver(receiver, filter)
7218
+ }
7219
+ isClassicAudioReceiverRegistered = true
7220
+ Bridge.log("LIVE: Classic: A2DP/HFP status receiver registered")
7221
+ } catch (e: Exception) {
7222
+ Bridge.log("LIVE: Classic: failed to register status receiver: " + e.message)
7223
+ }
7224
+ }
7225
+
7226
+ private fun unregisterClassicAudioReceiver() {
7227
+ if (!isClassicAudioReceiverRegistered || classicAudioReceiver == null) return
7228
+
7229
+ try {
7230
+ context?.unregisterReceiver(classicAudioReceiver)
7231
+ } catch (e: Exception) {
7232
+ Bridge.log("LIVE: Classic: failed to unregister status receiver: " + e.message)
7233
+ } finally {
7234
+ isClassicAudioReceiverRegistered = false
7235
+ }
7236
+ }
7237
+
7238
+ /**
7239
+ * Broadcast payloads are hints, never current truth (even when the MAC matches). Query both
7240
+ * profiles and publish one combined snapshot. A newer refresh or teardown invalidates all
7241
+ * outstanding results, including a previous connection to the same physical glasses.
7242
+ */
7243
+ private fun refreshClassicAudioConnectionState(device: BluetoothDevice) {
7244
+ if (Looper.myLooper() != handler.looper) {
7245
+ handler.post { refreshClassicAudioConnectionState(device) }
7246
+ return
7247
+ }
7248
+ if (isKilled) return
7249
+ val adapter = bluetoothAdapter ?: return
7250
+ val ctx = context ?: return
7251
+ val ticket = classicAudioConnectionTracker.beginSnapshot(device.address) ?: return
7252
+ val states = mutableMapOf<ClassicAudioProfile, Boolean>()
7253
+ for ((profileId, audioProfile) in listOf(
7254
+ BluetoothProfile.A2DP to ClassicAudioProfile.A2DP,
7255
+ BluetoothProfile.HEADSET to ClassicAudioProfile.HEADSET,
7256
+ )) {
7257
+ try {
7258
+ val requested = adapter.getProfileProxy(ctx, object : BluetoothProfile.ServiceListener {
7259
+ override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
7260
+ // Close before dispatch: destroy() may discard queued handler work.
7261
+ val connectedState = try {
7262
+ if (profile != profileId) null
7263
+ else proxy.getConnectionState(device) == BluetoothProfile.STATE_CONNECTED
7264
+ } catch (e: Exception) {
7265
+ Bridge.log("LIVE: Classic: snapshot unavailable: " + e.message)
7266
+ null
7267
+ } finally {
7268
+ try { adapter.closeProfileProxy(profileId, proxy) }
7269
+ catch (_: Exception) {}
7270
+ }
7271
+ if (connectedState == null) return
7272
+ handler.post {
7273
+ if (isKilled || profile != profileId) return@post
7274
+ states[audioProfile] = connectedState
7275
+ val connected = readyClassicAudioSnapshot(states) ?: return@post
7276
+ classicAudioConnectionTracker.applySnapshot(
7277
+ ticket, device.address, connected) {
7278
+ if (ClassicAudioProfile.A2DP in connected) {
7279
+ markAudioConnected(device)
7280
+ } else if (states[ClassicAudioProfile.A2DP] == false && audioConnected) {
7281
+ audioConnected = false
7282
+ Bridge.sendAudioDisconnected()
7283
+ }
7284
+ }
7285
+ }
7286
+ }
7287
+ override fun onServiceDisconnected(profile: Int) {}
7288
+ }, profileId)
7289
+ if (!requested) {
7290
+ // Unknown is not disconnected. A successful other-profile read can still
7291
+ // publish connected without waiting for this unavailable service.
7292
+ Bridge.log("LIVE: Classic: profile service unavailable: $profileId")
7293
+ }
7294
+ } catch (e: Exception) {
7295
+ Bridge.log("LIVE: Classic: profile query unavailable: " + e.message)
7296
+ }
7297
+ }
7298
+ }
7299
+
7300
+ @Suppress("DEPRECATION")
7301
+ private fun Intent.bluetoothDeviceExtra(): BluetoothDevice? =
7302
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
7303
+ getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
7304
+ } else {
7305
+ getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
7306
+ }
7307
+
7081
7308
  /**
7082
7309
  * Start CTKD Classic bonding / A2DP only after BLE notifications are ready. Calling this from
7083
7310
  * onConnectionStateChange(CONNECTED) races dual-mode stacks and frequently yields GATT 19
@@ -7154,7 +7381,7 @@ class MentraLive : SGCManager() {
7154
7381
  val method = device.javaClass.getMethod("removeBond")
7155
7382
  val result = method.invoke(device) as Boolean
7156
7383
  Bridge.log("LIVE: CTKD: Bond removal initiated, result: " + result)
7157
- isBtClassicConnected = false
7384
+ classicAudioConnectionTracker.invalidate(device.address)
7158
7385
  return result
7159
7386
  } catch (e: Exception) {
7160
7387
  Bridge.log("LIVE: CTKD: Error removing bond: " + e.message)
@@ -7164,7 +7391,7 @@ class MentraLive : SGCManager() {
7164
7391
 
7165
7392
  /** Check if BT Classic is connected via CTKD */
7166
7393
  fun isBtClassicConnected(): Boolean {
7167
- return isBtClassicConnected
7394
+ return classicAudioConnectionTracker.connected
7168
7395
  }
7169
7396
 
7170
7397
  /** A2DP profile service listener for connecting to already-bonded devices */
@@ -7223,11 +7450,11 @@ class MentraLive : SGCManager() {
7223
7450
  try {
7224
7451
  val state = a2dpProfile!!.getConnectionState(device)
7225
7452
  Bridge.log("LIVE: A2DP: Current connection state: " + state)
7453
+ refreshClassicAudioConnectionState(device)
7226
7454
 
7227
7455
  if (state == BluetoothProfile.STATE_CONNECTED) {
7228
7456
  Bridge.log("LIVE: A2DP: Already connected to " + device.name)
7229
7457
  a2dpConnectAttempts = 0
7230
- markAudioConnected(device.name)
7231
7458
  } else if (state == BluetoothProfile.STATE_DISCONNECTED) {
7232
7459
  Bridge.log("LIVE: A2DP: Connecting to " + device.name)
7233
7460
  // Use reflection to call connect() as it's a hidden API
@@ -7280,16 +7507,18 @@ class MentraLive : SGCManager() {
7280
7507
  }
7281
7508
 
7282
7509
  /** Helper to mark audio as connected and notify */
7283
- private fun markAudioConnected(deviceName: String?) {
7510
+ private fun markAudioConnected(device: BluetoothDevice) {
7284
7511
  if (isKilled) {
7285
7512
  Bridge.log(
7286
7513
  "LIVE: A2DP: Ignoring markAudioConnected — SGC destroyed (would confuse DeviceManager)"
7287
7514
  )
7288
7515
  return
7289
7516
  }
7290
- isBtClassicConnected = true
7517
+ val wasAudioConnected = audioConnected
7291
7518
  audioConnected = true
7292
- Bridge.sendAudioConnected(deviceName!!)
7519
+ if (!wasAudioConnected) {
7520
+ Bridge.sendAudioConnected(safeDeviceName(device))
7521
+ }
7293
7522
  if (glassesReadyReceived) {
7294
7523
  Bridge.log(
7295
7524
  "LIVE: A2DP: Both audio and glasses_ready confirmed - marking as fully connected"
@@ -7372,10 +7601,10 @@ class MentraLive : SGCManager() {
7372
7601
  device.address +
7373
7602
  ")"
7374
7603
  )
7375
- val isActiveDevice =
7376
- connectedDevice?.address?.equals(device.address, ignoreCase = true) == true
7377
- if (isActiveDevice) {
7378
- isBtClassicConnected = false
7604
+ // The BLE device reference may already be cleared after a GATT drop. The tracker owns the
7605
+ // authoritative target address, so let it decide whether this teardown belongs to the
7606
+ // active Classic session instead of consulting connectedDevice.
7607
+ if (classicAudioConnectionTracker.invalidate(device.address)) {
7379
7608
  audioConnected = false
7380
7609
  }
7381
7610
 
@@ -7507,12 +7736,15 @@ class MentraLive : SGCManager() {
7507
7736
 
7508
7737
  // CTKD Implementation: Unregister bonding receiver
7509
7738
  unregisterBondingReceiver()
7739
+ unregisterClassicAudioReceiver()
7510
7740
 
7511
7741
  // Tear down Classic (A2DP/HFP) before GATT close. Closing only the proxy left the
7512
7742
  // phone's system Bluetooth UI showing Mentra Live still connected.
7513
7743
  val classicDevice = connectedDevice
7514
7744
  disconnectClassicProfiles(classicDevice)
7515
7745
  closeA2dpProxy()
7746
+ classicAudioConnectionTracker.reset()
7747
+ DeviceStore.apply("glasses", "bluetoothClassicConnected", false)
7516
7748
 
7517
7749
  // Stop readiness check loop
7518
7750
  stopReadinessCheckLoop()
@@ -8867,9 +9099,10 @@ class MentraLive : SGCManager() {
8867
9099
  wireBytes: Int,
8868
9100
  packetCount: Int,
8869
9101
  protocolVersion: Int,
8870
- direction: String
9102
+ direction: String,
9103
+ bridgeLogging: Boolean = true,
8871
9104
  ) {
8872
- Bridge.log(
9105
+ transportLog(
8873
9106
  "BLE_TRACE direction=" +
8874
9107
  direction +
8875
9108
  " proto=v" +
@@ -8879,7 +9112,8 @@ class MentraLive : SGCManager() {
8879
9112
  " wire=" +
8880
9113
  wireBytes +
8881
9114
  " packets=" +
8882
- packetCount
9115
+ packetCount,
9116
+ bridgeLogging,
8883
9117
  )
8884
9118
  }
8885
9119
 
@@ -8897,15 +9131,19 @@ class MentraLive : SGCManager() {
8897
9131
  private fun sendDataToGlasses(
8898
9132
  data: String?,
8899
9133
  wakeup: Boolean,
8900
- sessionGeneration: Long
8901
- ) {
9134
+ sessionGeneration: Long,
9135
+ bridgeLogging: Boolean = true,
9136
+ ): Boolean {
8902
9137
  if (data == null || data.isEmpty()) {
8903
9138
  Log.e(TAG, "Cannot send empty data to glasses")
8904
- return
9139
+ return false
8905
9140
  }
8906
9141
  if (sessionGeneration != bleSessionGeneration.get()) {
8907
- Bridge.log("LIVE: Dropping send request from stale BLE session $sessionGeneration")
8908
- return
9142
+ transportLog(
9143
+ "LIVE: Dropping send request from stale BLE session $sessionGeneration",
9144
+ bridgeLogging,
9145
+ )
9146
+ return false
8909
9147
  }
8910
9148
 
8911
9149
  val wireData =
@@ -8933,14 +9171,14 @@ class MentraLive : SGCManager() {
8933
9171
  }
8934
9172
 
8935
9173
  if (useBinaryWireProtocol && buildNumberInt >= 5) {
8936
- sendDataToGlassesBinary(
9174
+ return sendDataToGlassesBinary(
8937
9175
  wireData,
8938
9176
  wakeup,
8939
9177
  commandTraceInfo,
8940
9178
  isPhotoRequest,
8941
- sessionGeneration
9179
+ sessionGeneration,
9180
+ bridgeLogging,
8942
9181
  )
8943
- return
8944
9182
  }
8945
9183
 
8946
9184
  // First check if the message needs chunking
@@ -8954,7 +9192,7 @@ class MentraLive : SGCManager() {
8954
9192
 
8955
9193
  // Check if chunking is needed
8956
9194
  if (MessageChunker.needsChunking(testWrappedJson)) {
8957
- Bridge.log("LIVE: Message exceeds threshold, chunking required")
9195
+ transportLog("LIVE: Message exceeds threshold, chunking required", bridgeLogging)
8958
9196
  if (isPhotoRequest) {
8959
9197
  Bridge.log(
8960
9198
  "LIVE: PHOTO PIPELINE BLE handoff — chunking enabled for request payload"
@@ -8972,7 +9210,8 @@ class MentraLive : SGCManager() {
8972
9210
 
8973
9211
  // Create chunks
8974
9212
  val chunks = MessageChunker.createChunks(wireData, messageId, wakeup)
8975
- Bridge.log("LIVE: Sending " + chunks.size + " chunks")
9213
+ if (chunks.isEmpty()) return false
9214
+ transportLog("LIVE: Sending " + chunks.size + " chunks", bridgeLogging)
8976
9215
  if (isPhotoRequest) {
8977
9216
  Bridge.log(
8978
9217
  "LIVE: PHOTO PIPELINE BLE handoff — created " +
@@ -9030,13 +9269,13 @@ class MentraLive : SGCManager() {
9030
9269
  }
9031
9270
  }
9032
9271
 
9033
- Bridge.log("LIVE: All chunks queued for transmission")
9272
+ transportLog("LIVE: All chunks queued for transmission", bridgeLogging)
9034
9273
  if (isPhotoRequest) {
9035
9274
  Bridge.log("LIVE: PHOTO PIPELINE BLE handoff — all photo chunks queued")
9036
9275
  }
9037
9276
  } else {
9038
9277
  // Normal single message transmission
9039
- Bridge.log("LIVE: Sending data to glasses: " + wireData)
9278
+ transportLog("LIVE: Sending data to glasses: " + wireData, bridgeLogging)
9040
9279
 
9041
9280
  // Pack the data using the centralized utility with the negotiated endianness
9042
9281
  val packedData =
@@ -9070,7 +9309,9 @@ class MentraLive : SGCManager() {
9070
9309
  }
9071
9310
  } catch (e: Exception) {
9072
9311
  Log.e(TAG, "Error creating data JSON", e)
9312
+ return false
9073
9313
  }
9314
+ return sessionGeneration == bleSessionGeneration.get()
9074
9315
  }
9075
9316
 
9076
9317
  private fun sendDataToGlassesBinary(
@@ -9078,8 +9319,9 @@ class MentraLive : SGCManager() {
9078
9319
  wakeup: Boolean,
9079
9320
  commandTraceInfo: OutgoingBleCommandTraceInfo,
9080
9321
  isPhotoRequest: Boolean,
9081
- sessionGeneration: Long
9082
- ) {
9322
+ sessionGeneration: Long,
9323
+ bridgeLogging: Boolean,
9324
+ ): Boolean {
9083
9325
  val payloadBytes = data.toByteArray(StandardCharsets.UTF_8)
9084
9326
  var messageId = 0
9085
9327
  var ackRequested = false
@@ -9098,6 +9340,7 @@ class MentraLive : SGCManager() {
9098
9340
  wakeup,
9099
9341
  ackRequested
9100
9342
  )
9343
+ if (fragments.isEmpty()) return false
9101
9344
  var totalWireBytes = 0
9102
9345
  for (i in fragments.indices) {
9103
9346
  val fragment = fragments[i]
@@ -9146,7 +9389,8 @@ class MentraLive : SGCManager() {
9146
9389
  totalWireBytes,
9147
9390
  fragments.size,
9148
9391
  BleWireProtocol.PROTOCOL_V2,
9149
- "phone_to_glasses"
9392
+ "phone_to_glasses",
9393
+ bridgeLogging,
9150
9394
  )
9151
9395
 
9152
9396
  if (isPhotoRequest) {
@@ -9157,6 +9401,7 @@ class MentraLive : SGCManager() {
9157
9401
  totalWireBytes
9158
9402
  )
9159
9403
  }
9404
+ return sessionGeneration == bleSessionGeneration.get()
9160
9405
  }
9161
9406
 
9162
9407
  private fun parseOutgoingBleCommandTraceInfo(payload: String): OutgoingBleCommandTraceInfo {
@@ -9438,16 +9683,45 @@ class MentraLive : SGCManager() {
9438
9683
  * K900 SystemUI can properly clear the cached credentials
9439
9684
  */
9440
9685
  override fun forgetWifiNetwork(ssid: String) {
9441
- Bridge.log("LIVE: 📶 Sending WiFi forget command for SSID: " + ssid)
9686
+ forgetWifiNetwork(ssid, null, null)
9687
+ }
9688
+
9689
+ override fun forgetWifiNetwork(ssid: String, requestId: String?, sid: String?): Boolean {
9690
+ if (!isConnected || bluetoothGatt == null || txCharacteristic == null || ssid.isBlank()) return false
9691
+ if ((requestId == null) != (sid == null)) return false
9692
+ if (requestId != null && (requestId.isBlank() || sid.isNullOrBlank())) return false
9693
+ Log.d(TAG, "LIVE: 📶 Sending WiFi forget command for SSID: " + ssid)
9442
9694
 
9443
9695
  try {
9444
9696
  val wifiCommand = JSONObject()
9445
9697
  wifiCommand.put("type", "forget_wifi")
9446
9698
  wifiCommand.put("ssid", ssid)
9447
- sendJson(wifiCommand, true)
9699
+ if (!requestId.isNullOrEmpty()) {
9700
+ wifiCommand.put("requestId", requestId)
9701
+ }
9702
+ if (!sid.isNullOrEmpty()) {
9703
+ wifiCommand.put("sid", sid)
9704
+ wifiCommand.put("protocolVersion", 1)
9705
+ }
9706
+ // This dispatch runs under the WiFi lifecycle lock. Keep its transport logs
9707
+ // native-only so a public log listener cannot re-enter reset before it is queued.
9708
+ return sendJson(wifiCommand, true, bridgeLogging = false)
9448
9709
  } catch (e: JSONException) {
9449
9710
  Log.e(TAG, "Error creating WiFi forget JSON", e)
9450
9711
  }
9712
+ return false
9713
+ }
9714
+
9715
+ override fun requestSavedWifiNetworks(requestId: String, sid: String): Boolean {
9716
+ if (!isConnected || bluetoothGatt == null || txCharacteristic == null || requestId.isBlank() || sid.isBlank()) return false
9717
+ val command = JSONObject()
9718
+ command.put("type", "request_saved_wifi_networks")
9719
+ command.put("requestId", requestId)
9720
+ command.put("protocolVersion", 1)
9721
+ if (sid.isNotEmpty()) {
9722
+ command.put("sid", sid)
9723
+ }
9724
+ return sendJson(command, true, bridgeLogging = false)
9451
9725
  }
9452
9726
 
9453
9727
  override fun sendHotspotState(enabled: Boolean) {
@@ -9554,6 +9828,13 @@ class MentraLive : SGCManager() {
9554
9828
  }
9555
9829
  }
9556
9830
 
9831
+ private fun sendAckToGlasses(messageId: Long) {
9832
+ val ack = JSONObject()
9833
+ ack.put("type", "msg_ack")
9834
+ ack.put("mId", messageId)
9835
+ sendJsonWithoutAck(ack, false)
9836
+ }
9837
+
9557
9838
  private fun sendJsonWithoutAck(json: JSONObject?) {
9558
9839
  sendJsonWithoutAck(json, false)
9559
9840
  }