@mentra/bluetooth-sdk 3.2.0-dev.195 → 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 (63) hide show
  1. package/README.md +64 -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 +77 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +12 -6
  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 +318 -36
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +626 -120
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +76 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +16 -3
  18. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoBridgeTest.kt +33 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +169 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
  23. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt +57 -0
  24. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +52 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +94 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycleTest.kt +112 -0
  28. package/build/BluetoothSdk.types.d.ts +62 -1
  29. package/build/BluetoothSdk.types.d.ts.map +1 -1
  30. package/build/BluetoothSdk.types.js.map +1 -1
  31. package/build/_private/BluetoothSdkModule.d.ts +3 -2
  32. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  33. package/build/_private/BluetoothSdkModule.js.map +1 -1
  34. package/build/generated/changelogCatalog.d.ts +1 -1
  35. package/build/generated/changelogCatalog.js +1 -1
  36. package/build/generated/changelogCatalog.js.map +1 -1
  37. package/build/generated/releaseMetadata.js +5 -5
  38. package/build/generated/releaseMetadata.js.map +1 -1
  39. package/build/index.d.ts +1 -1
  40. package/build/index.d.ts.map +1 -1
  41. package/build/index.js +3 -0
  42. package/build/index.js.map +1 -1
  43. package/ios/BluetoothSdkModule.swift +8 -2
  44. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  45. package/ios/Source/Bridge.swift +78 -1
  46. package/ios/Source/DeviceManager.swift +13 -6
  47. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  48. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  49. package/ios/Source/MentraBluetoothSDK.swift +397 -99
  50. package/ios/Source/WifiRequestResolution.swift +251 -0
  51. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
  52. package/ios/Source/sgcs/MentraLive.swift +179 -37
  53. package/ios/Source/sgcs/SGCManager.swift +13 -1
  54. package/ios/Source/status/DeviceStatus.swift +30 -3
  55. package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
  56. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
  57. package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
  58. package/package.json +1 -1
  59. package/src/BluetoothSdk.types.ts +76 -1
  60. package/src/_private/BluetoothSdkModule.ts +4 -1
  61. package/src/generated/changelogCatalog.ts +1 -1
  62. package/src/generated/releaseMetadata.ts +5 -5
  63. 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
@@ -141,6 +143,9 @@ class MentraLive : SGCManager() {
141
143
  private const val A2DP_CONNECT_MAX_ATTEMPTS = 5
142
144
  private const val A2DP_CONNECT_RETRY_MS = 800L
143
145
  private const val UNPAIR_FLUSH_MS = 400L
146
+ private const val GATT_DISCONNECT_TIMEOUT_MS = 2_000L
147
+ private const val MTU_CALLBACK_TIMEOUT_MS = 3_000L
148
+ private val gattTeardownBarrier = MentraLiveGattTeardownBarrier()
144
149
 
145
150
  // L2CAP CoC fast path: new BES2700 firmware registers an LE L2CAP CoC server on this
146
151
  // PSM. When the phone opens the channel, the glasses send file packets over it instead
@@ -564,6 +569,12 @@ class MentraLive : SGCManager() {
564
569
  private var bluetoothAdapter: BluetoothAdapter? = null
565
570
  private var bluetoothScanner: BluetoothLeScanner? = null
566
571
  @Volatile private var bluetoothGatt: BluetoothGatt? = null
572
+ private val gattLifecycleHandler = Handler(Looper.getMainLooper())
573
+ // Owned exclusively by the main looper, including callback validation and mutation.
574
+ private var gattEpoch = 0L
575
+ private var connectionRequestEpoch = 0L
576
+ @Volatile private var gattTeardownToken: Long? = null
577
+ @Volatile private var gattTeardownTimeoutRunnable: Runnable? = null
567
578
  private var connectedDevice: BluetoothDevice? = null
568
579
  private var txCharacteristic: BluetoothGattCharacteristic? = null
569
580
  private var rxCharacteristic: BluetoothGattCharacteristic? = null
@@ -591,9 +602,14 @@ class MentraLive : SGCManager() {
591
602
 
592
603
  // CTKD (Cross-Transport Key Derivation) support for BES devices
593
604
  private var isBondingReceiverRegistered = false
594
- private var isBtClassicConnected = false
595
605
  private var bondingReceiver: BroadcastReceiver? = null
596
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
+ }
597
613
 
598
614
  // Pairing timing diagnostics (filter logcat: PAIRING_TIMING)
599
615
  private var pairingTimingActive = false
@@ -643,6 +659,9 @@ class MentraLive : SGCManager() {
643
659
  private var isDescriptorWriteInProgress = false
644
660
  private var notificationsEnabled =
645
661
  false // Track if enableNotifications was already called this connection
662
+ private val mtuSetupGate = MentraLiveMtuSetupGate()
663
+ private var mtuSetupToken: Long? = null
664
+ private var mtuWatchdogRunnable: Runnable? = null
646
665
  private var connectionTimeoutRunnable: Runnable? = null
647
666
  private var connectionTimeoutHandler = Handler(Looper.getMainLooper())
648
667
  private var processSendQueueRunnable: Runnable? = null
@@ -992,6 +1011,8 @@ class MentraLive : SGCManager() {
992
1011
 
993
1012
  // Initialize CTKD bonding receiver
994
1013
  initializeBondingReceiver()
1014
+ initializeClassicAudioReceiver()
1015
+ registerClassicAudioReceiver()
995
1016
 
996
1017
  // Initialize the send queue processor
997
1018
  processSendQueueRunnable = Runnable {
@@ -1233,6 +1254,7 @@ class MentraLive : SGCManager() {
1233
1254
 
1234
1255
  /** Starts BLE scanning for Mentra Live glasses */
1235
1256
  private fun startScan() {
1257
+ if (postGattLifecycle { startScan() }) return
1236
1258
  if (pairingYieldActive) {
1237
1259
  Bridge.log("LIVE: startScan blocked — pairing yield active")
1238
1260
  return
@@ -1297,6 +1319,7 @@ class MentraLive : SGCManager() {
1297
1319
 
1298
1320
  val generation = ++scanGeneration
1299
1321
  isScanning = true
1322
+ scanCallback = createScanCallback(generation)
1300
1323
  bluetoothScanner!!.startScan(filters, settings, scanCallback)
1301
1324
 
1302
1325
  // Set a timeout to stop scanning
@@ -1349,6 +1372,7 @@ class MentraLive : SGCManager() {
1349
1372
 
1350
1373
  /** Stops BLE scanning */
1351
1374
  override fun stopScan() {
1375
+ if (postGattLifecycle { stopScan() }) return
1352
1376
  manualDiscoveryActive = false
1353
1377
  if (bluetoothAdapter == null || bluetoothScanner == null || !isScanning) {
1354
1378
  return
@@ -1424,9 +1448,13 @@ class MentraLive : SGCManager() {
1424
1448
  var seenDevices: MutableSet<String> = HashSet()
1425
1449
 
1426
1450
  /** BLE Scan callback */
1427
- private val scanCallback: ScanCallback =
1451
+ private var scanCallback: ScanCallback? = null
1452
+
1453
+ private fun createScanCallback(generation: Int): ScanCallback =
1428
1454
  object : ScanCallback() {
1429
1455
  override fun onScanResult(callbackType: Int, result: ScanResult) {
1456
+ if (postGattLifecycle { onScanResult(callbackType, result) }) return
1457
+ if (!isScanning || generation != scanGeneration) return
1430
1458
  // Check if the object has been destroyed to prevent NPE
1431
1459
  if (context == null || isKilled) {
1432
1460
  Bridge.log("LIVE: Ignoring scan result - object destroyed or killed")
@@ -1559,6 +1587,8 @@ class MentraLive : SGCManager() {
1559
1587
  }
1560
1588
 
1561
1589
  override fun onScanFailed(errorCode: Int) {
1590
+ if (postGattLifecycle { onScanFailed(errorCode) }) return
1591
+ if (generation != scanGeneration || !isScanning) return
1562
1592
  Log.e(TAG, "BLE scan failed with error: " + errorCode)
1563
1593
  isScanning = false
1564
1594
  if (isReconnecting && !isKilled) {
@@ -1588,30 +1618,120 @@ class MentraLive : SGCManager() {
1588
1618
  }
1589
1619
  }
1590
1620
 
1591
- /**
1592
- * Safely tear down the GATT reference. Avoids NPE / races with gatt callbacks disconnecting on
1593
- * a binder thread while a queued teardown runnable fires. Pass disconnect=true to call
1594
- * disconnect() before close().
1595
- */
1596
- @Synchronized
1621
+ /** Return true when work was handed to the single process-wide lifecycle queue. */
1622
+ private fun postGattLifecycle(work: () -> Unit): Boolean {
1623
+ if (Looper.myLooper() == gattLifecycleHandler.looper) return false
1624
+ gattLifecycleHandler.post { work() }
1625
+ return true
1626
+ }
1627
+
1628
+ /** Safely close GATT, waiting for Android to finish a requested disconnect when possible. */
1597
1629
  private fun closeGattQuietly(disconnect: Boolean) {
1630
+ check(Looper.myLooper() == gattLifecycleHandler.looper)
1598
1631
  val gatt = bluetoothGatt
1599
- bluetoothGatt = null
1600
1632
  if (gatt == null) {
1601
1633
  return
1602
1634
  }
1635
+ cancelMtuWatchdog()
1636
+ if (disconnect) {
1637
+ beginGattTeardown(gatt)
1638
+ return
1639
+ }
1640
+ bluetoothGatt = null
1641
+ releaseGattSessionMedia()
1603
1642
  try {
1604
- if (disconnect) {
1605
- gatt.disconnect()
1606
- }
1643
+ gatt.close()
1644
+ } catch (e: Exception) {
1645
+ Log.w(TAG, "🔌 closeGattQuietly: close threw " + e)
1646
+ }
1647
+ }
1648
+
1649
+ /** Release side channels on every teardown, including missing disconnect callbacks. */
1650
+ private fun releaseGattSessionMedia() {
1651
+ closeL2capFileChannel()
1652
+ fileProcessingHandler.removeCallbacksAndMessages(null)
1653
+ clearFilePacketBuffer()
1654
+ closeLc3Logging()
1655
+ lc3AudioPlayer?.stopPlay()
1656
+ }
1657
+
1658
+ private fun beginGattTeardown(gatt: BluetoothGatt) {
1659
+ check(Looper.myLooper() == gattLifecycleHandler.looper)
1660
+ if (gatt !== bluetoothGatt || gattTeardownToken != null) {
1661
+ return
1662
+ }
1663
+ val token = gattTeardownBarrier.beginTeardown()
1664
+ gattTeardownToken = token
1665
+ // Teardown owns session invalidation, including the timeout/replacement paths that do
1666
+ // not receive the normal remote-disconnect cleanup callback.
1667
+ cancelMtuWatchdog()
1668
+ isConnected = false
1669
+ isConnecting = false
1670
+ connectedDevice = null
1671
+ glassesReady = false
1672
+ glassesSessionId = null
1673
+ readinessCompletedThisBleSession = false
1674
+ glassesReadyReceived = false
1675
+ ctkdInitiatedThisGattSession = false
1676
+ audioConnected = false
1677
+ notificationsEnabled = false
1678
+ currentMtu = 23
1679
+ pendingDescriptorWrites.clear()
1680
+ isDescriptorWriteInProgress = false
1681
+ txCharacteristic = null
1682
+ rxCharacteristic = null
1683
+ lc3ReadCharacteristic = null
1684
+ lc3WriteCharacteristic = null
1685
+ handler.removeCallbacks(processSendQueueRunnable!!)
1686
+ stopReadinessCheckLoop()
1687
+ stopHeartbeat()
1688
+ stopSignalStrengthPolling()
1689
+ stopMicBeat()
1690
+ releaseGattSessionMedia()
1691
+ updateConnectionState(ConnTypes.DISCONNECTED)
1692
+ val timeout =
1693
+ Runnable {
1694
+ Bridge.log(
1695
+ "LIVE: 🔌 GATT disconnect callback timed out after ${GATT_DISCONNECT_TIMEOUT_MS}ms; closing transport"
1696
+ )
1697
+ completeGattTeardown(gatt, token, "timeout")
1698
+ }
1699
+ gattTeardownTimeoutRunnable = timeout
1700
+ gattLifecycleHandler.postDelayed(timeout, GATT_DISCONNECT_TIMEOUT_MS)
1701
+ try {
1702
+ Bridge.log("LIVE: 🔌 Waiting for GATT disconnected callback before close")
1703
+ gatt.disconnect()
1607
1704
  } catch (e: Exception) {
1608
- Log.w(TAG, "🔌 closeGattQuietly: disconnect threw " + e)
1705
+ Log.w(TAG, "🔌 GATT disconnect threw " + e)
1706
+ completeGattTeardown(gatt, token, "disconnect_exception")
1707
+ }
1708
+ }
1709
+
1710
+ private fun completeGattTeardown(gatt: BluetoothGatt, token: Long, reason: String) {
1711
+ check(Looper.myLooper() == gattLifecycleHandler.looper)
1712
+ if (gattTeardownToken != token) {
1713
+ return
1714
+ }
1715
+ gattTeardownTimeoutRunnable?.let { gattLifecycleHandler.removeCallbacks(it) }
1716
+ gattTeardownTimeoutRunnable = null
1717
+ gattTeardownToken = null
1718
+ if (gatt === bluetoothGatt) {
1719
+ bluetoothGatt = null
1609
1720
  }
1610
1721
  try {
1611
1722
  gatt.close()
1612
1723
  } catch (e: Exception) {
1613
- Log.w(TAG, "🔌 closeGattQuietly: close threw " + e)
1724
+ Log.w(TAG, "🔌 GATT close after $reason threw " + e)
1614
1725
  }
1726
+ Bridge.log("LIVE: 🔌 GATT teardown complete ($reason)")
1727
+ gattTeardownBarrier.completeTeardown(token)
1728
+ }
1729
+
1730
+ private fun cancelMtuWatchdog() {
1731
+ mtuWatchdogRunnable?.let { gattLifecycleHandler.removeCallbacks(it) }
1732
+ mtuWatchdogRunnable = null
1733
+ mtuSetupToken = null
1734
+ mtuSetupGate.cancel()
1615
1735
  }
1616
1736
 
1617
1737
  /**
@@ -1634,6 +1754,11 @@ class MentraLive : SGCManager() {
1634
1754
  return false
1635
1755
  }
1636
1756
 
1757
+ /** Ignore all non-disconnect work once this GATT is stale or tearing down. */
1758
+ private fun isActiveGattCallback(gatt: BluetoothGatt): Boolean {
1759
+ return isCurrentGattCallback(gatt) && gattTeardownToken == null
1760
+ }
1761
+
1637
1762
  private fun beginPairingTiming(reason: String) {
1638
1763
  pairingTimingStartMs = SystemClock.elapsedRealtime()
1639
1764
  pairingTimingLastMs = pairingTimingStartMs
@@ -1662,15 +1787,32 @@ class MentraLive : SGCManager() {
1662
1787
 
1663
1788
  /** Connect to a specific BLE device */
1664
1789
  private fun connectToDevice(device: BluetoothDevice?) {
1790
+ if (postGattLifecycle { connectToDevice(device) }) return
1665
1791
  if (device == null) {
1666
1792
  return
1667
1793
  }
1668
- if (pairingYieldActive) {
1794
+ if (isKilled || pairingYieldActive) {
1669
1795
  Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
1670
1796
  isConnecting = false
1671
1797
  isReconnecting = false
1672
1798
  return
1673
1799
  }
1800
+ val requestEpoch = ++connectionRequestEpoch
1801
+ // Never replace a still-open GATT. Its disconnect/close must finish first.
1802
+ bluetoothGatt?.let { beginGattTeardown(it) }
1803
+ gattTeardownBarrier.runWhenIdle {
1804
+ if (requestEpoch == connectionRequestEpoch && !isKilled && !pairingYieldActive) {
1805
+ connectToDeviceWhenIdle(device)
1806
+ }
1807
+ }
1808
+ }
1809
+
1810
+ private fun connectToDeviceWhenIdle(device: BluetoothDevice) {
1811
+ check(Looper.myLooper() == gattLifecycleHandler.looper)
1812
+ check(bluetoothGatt == null)
1813
+ val epoch = ++gattEpoch
1814
+ currentMtu = 23
1815
+ val gattCallback = createGattCallback(epoch)
1674
1816
 
1675
1817
  beginPairingTiming(
1676
1818
  "connectToDevice addr=${device.address} name=${safeDeviceName(device)} attempt=$reconnectAttempts"
@@ -1683,7 +1825,7 @@ class MentraLive : SGCManager() {
1683
1825
 
1684
1826
  // Set connection timeout
1685
1827
  connectionTimeoutRunnable = Runnable {
1686
- if (isConnecting && !isConnected) {
1828
+ if (epoch == gattEpoch && isConnecting && !isConnected) {
1687
1829
  Log.w(
1688
1830
  TAG,
1689
1831
  "🔌 ⏰ CONNECTION TIMEOUT after " +
@@ -1775,6 +1917,8 @@ class MentraLive : SGCManager() {
1775
1917
  // }
1776
1918
 
1777
1919
  private fun enterPairingYield(windowMs: Long) {
1920
+ if (postGattLifecycle { enterPairingYield(windowMs) }) return
1921
+ connectionRequestEpoch++
1778
1922
  pairingYieldActive = true
1779
1923
  pairingYieldAwaitingReclaim = false
1780
1924
  isReconnecting = false
@@ -1855,6 +1999,7 @@ class MentraLive : SGCManager() {
1855
1999
 
1856
2000
  /** Handle reconnection with exponential backoff */
1857
2001
  private fun handleReconnection() {
2002
+ if (postGattLifecycle { handleReconnection() }) return
1858
2003
  // Don't attempt reconnection if we've been killed/forgotten
1859
2004
  if (isKilled) {
1860
2005
  Bridge.log("LIVE: 🔌 RECONNECT ABORTED - device has been killed/forgotten")
@@ -2093,9 +2238,13 @@ class MentraLive : SGCManager() {
2093
2238
  }
2094
2239
 
2095
2240
  /** GATT callback for BLE operations */
2096
- private val gattCallback: BluetoothGattCallback =
2097
- object : BluetoothGattCallback() {
2098
- override fun onConnectionStateChange(
2241
+ private fun createGattCallback(epoch: Long): BluetoothGattCallback =
2242
+ object : SerializedGattCallback({ work ->
2243
+ // Always enqueue, even for an inline platform callback during connectGatt().
2244
+ // This lets connectGatt return and install the identity before validation.
2245
+ gattLifecycleHandler.post { work() }
2246
+ }, { gatt -> epoch == gattEpoch && isCurrentGattCallback(gatt) }) {
2247
+ override fun handleConnectionStateChange(
2099
2248
  gatt: BluetoothGatt,
2100
2249
  status: Int,
2101
2250
  newState: Int
@@ -2103,6 +2252,19 @@ class MentraLive : SGCManager() {
2103
2252
  if (!isCurrentGattCallback(gatt)) {
2104
2253
  return
2105
2254
  }
2255
+ val teardownToken = gattTeardownToken
2256
+ if (teardownToken != null) {
2257
+ if (
2258
+ newState == BluetoothProfile.STATE_DISCONNECTED ||
2259
+ status != BluetoothGatt.GATT_SUCCESS
2260
+ ) {
2261
+ completeGattTeardown(gatt, teardownToken, "disconnected_callback")
2262
+ }
2263
+ // Once teardown starts, a queued successful CONNECTED callback belongs to
2264
+ // the closing transport. It must not resurrect application connection
2265
+ // state while the disconnect callback is still pending.
2266
+ return
2267
+ }
2106
2268
 
2107
2269
  // Cancel the connection timeout
2108
2270
  if (connectionTimeoutRunnable != null) {
@@ -2119,6 +2281,8 @@ class MentraLive : SGCManager() {
2119
2281
  isConnecting = false
2120
2282
  isConnected = true
2121
2283
  connectedDevice = gatt.device
2284
+ classicAudioConnectionTracker.setTarget(gatt.device.address)
2285
+ refreshClassicAudioConnectionState(gatt.device)
2122
2286
  emitConnectedPendingDeviceForPairingScan()
2123
2287
 
2124
2288
  DeviceStore.apply("glasses", "bluetoothName", connectedDevice!!.name)
@@ -2209,11 +2373,6 @@ class MentraLive : SGCManager() {
2209
2373
  // Stop micbeat mechanism
2210
2374
  stopMicBeat()
2211
2375
 
2212
- // Close the L2CAP file channel (if the fast path was open)
2213
- closeL2capFileChannel()
2214
- fileProcessingHandler.removeCallbacksAndMessages(null)
2215
- clearFilePacketBuffer()
2216
-
2217
2376
  // Clean up GATT resources
2218
2377
  closeGattQuietly(false)
2219
2378
 
@@ -2223,13 +2382,6 @@ class MentraLive : SGCManager() {
2223
2382
  handleReconnection()
2224
2383
  }
2225
2384
 
2226
- // Close LC3 audio logging
2227
- closeLc3Logging()
2228
-
2229
- // stop LC3 player
2230
- if (lc3AudioPlayer != null) {
2231
- lc3AudioPlayer!!.stopPlay()
2232
- }
2233
2385
  }
2234
2386
  } else {
2235
2387
  // Connection error
@@ -2302,7 +2454,7 @@ class MentraLive : SGCManager() {
2302
2454
  }
2303
2455
  }
2304
2456
 
2305
- override fun onPhyUpdate(
2457
+ override fun handlePhyUpdate(
2306
2458
  gatt: BluetoothGatt,
2307
2459
  txPhy: Int,
2308
2460
  rxPhy: Int,
@@ -2318,7 +2470,7 @@ class MentraLive : SGCManager() {
2318
2470
  )
2319
2471
  }
2320
2472
 
2321
- override fun onPhyRead(
2473
+ override fun handlePhyRead(
2322
2474
  gatt: BluetoothGatt,
2323
2475
  txPhy: Int,
2324
2476
  rxPhy: Int,
@@ -2334,7 +2486,10 @@ class MentraLive : SGCManager() {
2334
2486
  )
2335
2487
  }
2336
2488
 
2337
- override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
2489
+ override fun handleServicesDiscovered(gatt: BluetoothGatt, status: Int) {
2490
+ if (!isActiveGattCallback(gatt)) {
2491
+ return
2492
+ }
2338
2493
  if (status == BluetoothGatt.GATT_SUCCESS) {
2339
2494
  Bridge.log("LIVE: GATT services discovered")
2340
2495
 
@@ -2397,7 +2552,17 @@ class MentraLive : SGCManager() {
2397
2552
  // This ensures no concurrent GATT operations on older Android BLE
2398
2553
  // stacks.
2399
2554
  if (checkPermission()) {
2400
- val mtuRequested = gatt.requestMtu(512)
2555
+ val mtuToken = mtuSetupGate.begin()
2556
+ mtuSetupToken = mtuToken
2557
+ val mtuRequested =
2558
+ try {
2559
+ gatt.requestMtu(512)
2560
+ } catch (e: SecurityException) {
2561
+ Bridge.log(
2562
+ "LIVE: ⚠️ MTU request denied; continuing with notification setup"
2563
+ )
2564
+ false
2565
+ }
2401
2566
  Bridge.log(
2402
2567
  "LIVE: 🔄 Requested MTU size 512, success: " +
2403
2568
  mtuRequested
@@ -2405,12 +2570,18 @@ class MentraLive : SGCManager() {
2405
2570
  if (!mtuRequested) {
2406
2571
  // MTU request failed to even start, enable notifications
2407
2572
  // directly
2408
- enableNotifications()
2573
+ completeMtuSetup(
2574
+ gatt,
2575
+ mtuToken,
2576
+ "request_not_started"
2577
+ )
2578
+ } else {
2579
+ startMtuWatchdog(gatt, mtuToken)
2409
2580
  }
2410
2581
  // Otherwise, enableNotifications() will be called from
2411
2582
  // onMtuChanged
2412
2583
  } else {
2413
- enableNotifications()
2584
+ enableNotificationsAfterMtu(gatt, "permission_fallback")
2414
2585
  }
2415
2586
 
2416
2587
  // NOTE: Send queue and readiness loop are started AFTER descriptor
@@ -2437,11 +2608,14 @@ class MentraLive : SGCManager() {
2437
2608
  }
2438
2609
  }
2439
2610
 
2440
- override fun onCharacteristicRead(
2611
+ override fun handleCharacteristicRead(
2441
2612
  gatt: BluetoothGatt,
2442
2613
  characteristic: BluetoothGattCharacteristic,
2443
2614
  status: Int
2444
2615
  ) {
2616
+ if (!isActiveGattCallback(gatt)) {
2617
+ return
2618
+ }
2445
2619
  if (status == BluetoothGatt.GATT_SUCCESS) {
2446
2620
  Bridge.log("LIVE: Characteristic read successful")
2447
2621
  // Process the read data if needed
@@ -2450,7 +2624,10 @@ class MentraLive : SGCManager() {
2450
2624
  }
2451
2625
  }
2452
2626
 
2453
- override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
2627
+ override fun handleReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
2628
+ if (!isActiveGattCallback(gatt)) {
2629
+ return
2630
+ }
2454
2631
  rssiReadInProgress = false
2455
2632
  if (status == BluetoothGatt.GATT_SUCCESS) {
2456
2633
  if (isConnected && bluetoothGatt != null && gatt === bluetoothGatt) {
@@ -2461,11 +2638,14 @@ class MentraLive : SGCManager() {
2461
2638
  }
2462
2639
  }
2463
2640
 
2464
- override fun onCharacteristicWrite(
2641
+ override fun handleCharacteristicWrite(
2465
2642
  gatt: BluetoothGatt,
2466
2643
  characteristic: BluetoothGattCharacteristic,
2467
2644
  status: Int
2468
2645
  ) {
2646
+ if (!isActiveGattCallback(gatt)) {
2647
+ return
2648
+ }
2469
2649
  val trace = inFlightBleWriteTrace
2470
2650
  val callbackAtMs = System.currentTimeMillis()
2471
2651
  val callbackDelayMs =
@@ -2527,11 +2707,12 @@ class MentraLive : SGCManager() {
2527
2707
  }
2528
2708
  }
2529
2709
 
2530
- override fun onCharacteristicChanged(
2710
+ override fun handleCharacteristicChanged(
2531
2711
  gatt: BluetoothGatt,
2532
- characteristic: BluetoothGattCharacteristic
2712
+ characteristic: BluetoothGattCharacteristic,
2713
+ data: ByteArray
2533
2714
  ) {
2534
- if (!isCurrentGattCallback(gatt)) {
2715
+ if (!isActiveGattCallback(gatt)) {
2535
2716
  return
2536
2717
  }
2537
2718
 
@@ -2539,8 +2720,7 @@ class MentraLive : SGCManager() {
2539
2720
  val threadId = Thread.currentThread().id
2540
2721
  val uuid = characteristic.uuid
2541
2722
 
2542
- val data = characteristic.value
2543
- if (data == null || data.isEmpty()) {
2723
+ if (data.isEmpty()) {
2544
2724
  return
2545
2725
  }
2546
2726
 
@@ -2584,11 +2764,14 @@ class MentraLive : SGCManager() {
2584
2764
  processReceivedData(data, data.size)
2585
2765
  }
2586
2766
 
2587
- override fun onDescriptorWrite(
2767
+ override fun handleDescriptorWrite(
2588
2768
  gatt: BluetoothGatt,
2589
2769
  descriptor: BluetoothGattDescriptor,
2590
2770
  status: Int
2591
2771
  ) {
2772
+ if (!isActiveGattCallback(gatt)) {
2773
+ return
2774
+ }
2592
2775
  val threadId = Thread.currentThread().id
2593
2776
 
2594
2777
  if (status == BluetoothGatt.GATT_SUCCESS) {
@@ -2616,43 +2799,91 @@ class MentraLive : SGCManager() {
2616
2799
  writeNextDescriptor()
2617
2800
  }
2618
2801
 
2619
- override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
2620
- if (status == BluetoothGatt.GATT_SUCCESS) {
2621
- Bridge.log(
2622
- "LIVE: 🔵 MTU negotiation successful - changed to " + mtu + " bytes"
2623
- )
2624
- val effectivePayload = mtu - 3
2625
- Bridge.log(
2626
- "LIVE: Effective payload size: " + effectivePayload + " bytes"
2627
- )
2802
+ override fun handleMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
2803
+ handler.post {
2804
+ if (!isActiveGattCallback(gatt)) {
2805
+ return@post
2806
+ }
2807
+ if (status == BluetoothGatt.GATT_SUCCESS) {
2808
+ Bridge.log(
2809
+ "LIVE: 🔵 MTU negotiation successful - changed to " +
2810
+ mtu +
2811
+ " bytes"
2812
+ )
2813
+ val effectivePayload = mtu - 3
2814
+ Bridge.log(
2815
+ "LIVE: Effective payload size: " +
2816
+ effectivePayload +
2817
+ " bytes"
2818
+ )
2628
2819
 
2629
- // Store the new MTU value
2630
- currentMtu = mtu
2820
+ currentMtu = mtu
2631
2821
 
2632
- // If the negotiated MTU is sufficient for LC3 audio packets (typically
2633
- // 40-60 bytes)
2634
- if (mtu >= 64) {
2635
- Bridge.log("LIVE: ✅ MTU size is sufficient for LC3 audio data packets")
2822
+ if (mtu >= 64) {
2823
+ Bridge.log(
2824
+ "LIVE: MTU size is sufficient for LC3 audio data packets"
2825
+ )
2826
+ } else {
2827
+ Log.w(
2828
+ TAG,
2829
+ "⚠️ MTU size may be too small for LC3 audio data packets"
2830
+ )
2831
+ Bridge.log(
2832
+ "LIVE: 📊 Effective MTU payload: " +
2833
+ effectivePayload +
2834
+ " bytes"
2835
+ )
2836
+ }
2636
2837
  } else {
2637
- Log.w(TAG, "⚠️ MTU size may be too small for LC3 audio data packets")
2638
- Bridge.log(
2639
- "LIVE: 📊 Effective MTU payload: " + effectivePayload + " bytes"
2838
+ Log.e(TAG, " MTU change failed with status: " + status)
2839
+ Log.w(
2840
+ TAG,
2841
+ " Will continue with default MTU (23 bytes, 20 byte payload)"
2640
2842
  )
2641
2843
  }
2642
- } else {
2643
- Log.e(TAG, "❌ MTU change failed with status: " + status)
2644
- Log.w(TAG, " Will continue with default MTU (23 bytes, 20 byte payload)")
2645
- }
2646
2844
 
2647
- // Now that MTU operation is complete, enable notifications
2648
- // (descriptor writes are GATT operations and can't overlap with MTU request)
2649
- if (!notificationsEnabled) {
2650
- notificationsEnabled = true
2651
- enableNotifications()
2845
+ mtuSetupToken?.let { completeMtuSetup(gatt, it, "callback") }
2652
2846
  }
2653
2847
  }
2654
2848
  }
2655
2849
 
2850
+ private fun startMtuWatchdog(gatt: BluetoothGatt, token: Long) {
2851
+ mtuWatchdogRunnable?.let { gattLifecycleHandler.removeCallbacks(it) }
2852
+ val watchdog =
2853
+ Runnable {
2854
+ handler.post {
2855
+ if (!isActiveGattCallback(gatt)) {
2856
+ return@post
2857
+ }
2858
+ Bridge.log(
2859
+ "LIVE: ⚠️ MTU callback timed out after ${MTU_CALLBACK_TIMEOUT_MS}ms; continuing with notification setup"
2860
+ )
2861
+ completeMtuSetup(gatt, token, "watchdog")
2862
+ }
2863
+ }
2864
+ mtuWatchdogRunnable = watchdog
2865
+ gattLifecycleHandler.postDelayed(watchdog, MTU_CALLBACK_TIMEOUT_MS)
2866
+ }
2867
+
2868
+ private fun completeMtuSetup(gatt: BluetoothGatt, token: Long, reason: String) {
2869
+ if (!mtuSetupGate.complete(token)) {
2870
+ return
2871
+ }
2872
+ mtuWatchdogRunnable?.let { gattLifecycleHandler.removeCallbacks(it) }
2873
+ mtuWatchdogRunnable = null
2874
+ mtuSetupToken = null
2875
+ enableNotificationsAfterMtu(gatt, reason)
2876
+ }
2877
+
2878
+ private fun enableNotificationsAfterMtu(gatt: BluetoothGatt, reason: String) {
2879
+ if (!isActiveGattCallback(gatt) || notificationsEnabled) {
2880
+ return
2881
+ }
2882
+ notificationsEnabled = true
2883
+ Bridge.log("LIVE: 🔔 Continuing GATT setup after MTU ($reason)")
2884
+ enableNotifications()
2885
+ }
2886
+
2656
2887
  /**
2657
2888
  * Write the next queued descriptor, or mark the queue as idle. Must be called after each
2658
2889
  * onDescriptorWrite callback to serialize BLE GATT operations. On older Android devices,
@@ -2660,6 +2891,12 @@ class MentraLive : SGCManager() {
2660
2891
  * subsequent writes to silently fail.
2661
2892
  */
2662
2893
  private fun writeNextDescriptor() {
2894
+ val gatt = bluetoothGatt ?: return
2895
+ if (!isActiveGattCallback(gatt)) return
2896
+ val epoch = gattEpoch
2897
+ val continueSetup = Runnable {
2898
+ if (epoch == gattEpoch && isActiveGattCallback(gatt)) writeNextDescriptor()
2899
+ }
2663
2900
  val next = pendingDescriptorWrites.poll()
2664
2901
  if (next == null) {
2665
2902
  isDescriptorWriteInProgress = false
@@ -2703,12 +2940,12 @@ class MentraLive : SGCManager() {
2703
2940
  uuid +
2704
2941
  ", continuing queue"
2705
2942
  )
2706
- handler.postDelayed(this::writeNextDescriptor, 50L)
2943
+ handler.postDelayed(continueSetup, 50L)
2707
2944
  }
2708
2945
  } catch (e: Exception) {
2709
2946
  val threadId = Thread.currentThread().id
2710
2947
  Log.e(TAG, "Thread-" + threadId + ": ⚠️ Error writing descriptor: " + e.message)
2711
- handler.postDelayed(this::writeNextDescriptor, 50L)
2948
+ handler.postDelayed(continueSetup, 50L)
2712
2949
  }
2713
2950
  }
2714
2951
 
@@ -3079,8 +3316,12 @@ class MentraLive : SGCManager() {
3079
3316
  return messageId
3080
3317
  }
3081
3318
 
3082
- /** 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. */
3083
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 {
3084
3325
  if (json != null) {
3085
3326
  try {
3086
3327
  val sessionGeneration = bleSessionGeneration.get()
@@ -3099,7 +3340,7 @@ class MentraLive : SGCManager() {
3099
3340
  json,
3100
3341
  jsonStr.length
3101
3342
  )
3102
- sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
3343
+ return sendDataToGlasses(jsonStr, wakeup, sessionGeneration, bridgeLogging)
3103
3344
  } else {
3104
3345
  // Add esoteric message ID to the JSON
3105
3346
  val messageId = generateEsotericMessageId()
@@ -3124,12 +3365,13 @@ class MentraLive : SGCManager() {
3124
3365
  // Calculate dynamic timeout for chunked message
3125
3366
  val estimatedChunks = Math.ceil(jsonStr.length / 300.0).toInt()
3126
3367
  ackTimeout = ACK_TIMEOUT_MS + (estimatedChunks * 50L) + 2000L
3127
- Bridge.log(
3368
+ transportLog(
3128
3369
  "LIVE: Message will be chunked into ~" +
3129
3370
  estimatedChunks +
3130
3371
  " chunks, using dynamic timeout: " +
3131
3372
  ackTimeout +
3132
- "ms"
3373
+ "ms",
3374
+ bridgeLogging,
3133
3375
  )
3134
3376
  }
3135
3377
  } catch (e: JSONException) {
@@ -3141,7 +3383,13 @@ class MentraLive : SGCManager() {
3141
3383
  }
3142
3384
 
3143
3385
  // Track the message for ACK with appropriate timeout
3144
- trackMessageForAck(messageId, jsonStr, ackTimeout, sessionGeneration)
3386
+ trackMessageForAck(
3387
+ messageId,
3388
+ jsonStr,
3389
+ ackTimeout,
3390
+ sessionGeneration,
3391
+ bridgeLogging,
3392
+ )
3145
3393
 
3146
3394
  // Send the data
3147
3395
  if ("take_photo" == json.optString("type", "")) {
@@ -3160,7 +3408,9 @@ class MentraLive : SGCManager() {
3160
3408
  json,
3161
3409
  jsonStr.length
3162
3410
  )
3163
- sendDataToGlasses(jsonStr, wakeup, sessionGeneration)
3411
+ val accepted = sendDataToGlasses(jsonStr, wakeup, sessionGeneration, bridgeLogging)
3412
+ if (!accepted) pendingMessages.remove(messageId)
3413
+ return accepted
3164
3414
  }
3165
3415
  } catch (e: JSONException) {
3166
3416
  Log.e(TAG, "Error adding message ID to JSON", e)
@@ -3168,6 +3418,7 @@ class MentraLive : SGCManager() {
3168
3418
  } else {
3169
3419
  Bridge.log("LIVE: Cannot send JSON to ASG, JSON is null")
3170
3420
  }
3421
+ return false
3171
3422
  }
3172
3423
 
3173
3424
  private fun sendJson(json: JSONObject?) {
@@ -3185,20 +3436,25 @@ class MentraLive : SGCManager() {
3185
3436
  messageId: Long,
3186
3437
  messageData: String,
3187
3438
  timeoutMs: Long,
3188
- sessionGeneration: Long
3439
+ sessionGeneration: Long,
3440
+ bridgeLogging: Boolean = true,
3189
3441
  ) {
3190
3442
  if (!isConnected || sessionGeneration != bleSessionGeneration.get()) {
3191
- 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
+ )
3192
3447
  return
3193
3448
  }
3194
3449
 
3195
3450
  // Skip ACK tracking for glasses with build number < 5 (older firmware)
3196
3451
  if (buildNumberInt < 5) {
3197
- Bridge.log(
3452
+ transportLog(
3198
3453
  "LIVE: Glasses build number (" +
3199
3454
  buildNumberInt +
3200
3455
  ") < 5, skipping ACK tracking for message " +
3201
- messageId
3456
+ messageId,
3457
+ bridgeLogging,
3202
3458
  )
3203
3459
  return
3204
3460
  }
@@ -3220,11 +3476,20 @@ class MentraLive : SGCManager() {
3220
3476
  // Schedule ACK timeout with custom timeout
3221
3477
  handler.postDelayed({ checkMessageAck(messageId, sessionGeneration) }, timeoutMs)
3222
3478
 
3223
- Bridge.log(
3224
- "LIVE: 📋 Tracking message " + messageId + " for ACK (timeout: " + timeoutMs + "ms)"
3479
+ transportLog(
3480
+ "LIVE: 📋 Tracking message " + messageId + " for ACK (timeout: " + timeoutMs + "ms)",
3481
+ bridgeLogging,
3225
3482
  )
3226
3483
  }
3227
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
+
3228
3493
  /**
3229
3494
  * Ends the current BLE send session before clearing its ACK state. Scheduled callbacks capture
3230
3495
  * the old generation, and queued retries retain it, so a retry racing this clear cannot become
@@ -3718,6 +3983,9 @@ class MentraLive : SGCManager() {
3718
3983
  }
3719
3984
  }
3720
3985
 
3986
+ val incomingMessageId = if (json.has("mId")) json.optLong("mId") else null
3987
+ incomingGlassesMessageAckId(type, incomingMessageId)?.let(::sendAckToGlasses)
3988
+
3721
3989
  // Check if this is a K900 command format (has "C" field instead of "type")
3722
3990
  if (json.has("C")) {
3723
3991
  processK900JsonMessage(json)
@@ -3872,6 +4140,44 @@ class MentraLive : SGCManager() {
3872
4140
  wifiError.takeIf { it.isNotEmpty() }
3873
4141
  )
3874
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
+ }
3875
4181
  "hotspot_status_update" -> {
3876
4182
  // Process hotspot status information (same pattern as "wifi_status")
3877
4183
  val hotspotEnabled = json.optBoolean("hotspot_enabled", false)
@@ -4253,7 +4559,11 @@ class MentraLive : SGCManager() {
4253
4559
  // Record the session id BEFORE marking ready: an unsolicited glasses_ready
4254
4560
  // already runs this full remote-reset flow, so recording (not re-triggering)
4255
4561
  // is correct here; version_info detection covers the restart case.
4256
- 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
+ )
4257
4567
  readinessCompletedThisBleSession = true
4258
4568
 
4259
4569
  // Set the ready flag to stop any future readiness checks
@@ -4447,6 +4757,7 @@ class MentraLive : SGCManager() {
4447
4757
  }
4448
4758
 
4449
4759
  val versionInfoLegacy = HashMap<String, Any>()
4760
+ versionInfoLegacy["version_info_type"] = "version_info"
4450
4761
  versionInfoLegacy["appVersion"] = appVersionLegacy
4451
4762
  versionInfoLegacy["buildNumber"] = buildNumberLegacy
4452
4763
  versionInfoLegacy["deviceModel"] = deviceModelLegacy
@@ -4630,6 +4941,7 @@ class MentraLive : SGCManager() {
4630
4941
 
4631
4942
  // Extract all fields from JSON (except "type")
4632
4943
  val fields = HashMap<String, Any>()
4944
+ fields["version_info_type"] = type
4633
4945
  val keys = json.keys()
4634
4946
  while (keys.hasNext()) {
4635
4947
  val key = keys.next()
@@ -4743,7 +5055,7 @@ class MentraLive : SGCManager() {
4743
5055
  handleGlassesSessionId(json)
4744
5056
 
4745
5057
  Bridge.log("LIVE: Processed version_info fields and sent to RN")
4746
- Bridge.sendVersionInfo(fields)
5058
+ Bridge.sendVersionInfo(fields, type)
4747
5059
  } else {
4748
5060
  Log.d(TAG, "📦 Unknown message type: " + type)
4749
5061
  }
@@ -5664,9 +5976,14 @@ class MentraLive : SGCManager() {
5664
5976
  * build number, firmware version, etc.
5665
5977
  */
5666
5978
  override fun requestVersionInfo() {
5979
+ requestVersionInfo(null)
5980
+ }
5981
+
5982
+ fun requestVersionInfo(requestId: String?) {
5667
5983
  try {
5668
5984
  val json = JSONObject()
5669
5985
  json.put("type", "request_version")
5986
+ requestId?.let { json.put("request_id", it) }
5670
5987
  sendJson(json, false)
5671
5988
  Bridge.log("LIVE: 📱 Requesting version info from glasses")
5672
5989
  } catch (e: JSONException) {
@@ -5938,6 +6255,8 @@ class MentraLive : SGCManager() {
5938
6255
  // SmartGlassesCommunicator interface implementation
5939
6256
 
5940
6257
  override fun findCompatibleDevices() {
6258
+ if (postGattLifecycle { findCompatibleDevices() }) return
6259
+ connectionRequestEpoch++
5941
6260
  Bridge.log("LIVE: Finding compatible Mentra Live glasses")
5942
6261
 
5943
6262
  // A fresh user scan owns the radio. Invalidate delayed reconnect callbacks
@@ -6002,6 +6321,7 @@ class MentraLive : SGCManager() {
6002
6321
  }
6003
6322
 
6004
6323
  override fun connectById(id: String) {
6324
+ if (postGattLifecycle { connectById(id) }) return
6005
6325
  if (pairingYieldActive) {
6006
6326
  Bridge.log("LIVE: connectById blocked — pairing yield active")
6007
6327
  return
@@ -6031,6 +6351,7 @@ class MentraLive : SGCManager() {
6031
6351
  }
6032
6352
 
6033
6353
  override fun forget() {
6354
+ if (postGattLifecycle { forget() }) return
6034
6355
  Bridge.log("LIVE: Forgetting Mentra Live glasses")
6035
6356
 
6036
6357
  // Clear saved device name so a leftover name can't bypass the pairing-mode
@@ -6075,6 +6396,7 @@ class MentraLive : SGCManager() {
6075
6396
  }
6076
6397
 
6077
6398
  override fun disconnect() {
6399
+ if (postGattLifecycle { disconnect() }) return
6078
6400
  if (unpairFlushPending) {
6079
6401
  Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
6080
6402
  return
@@ -6116,6 +6438,7 @@ class MentraLive : SGCManager() {
6116
6438
  }
6117
6439
 
6118
6440
  fun connectToSmartGlasses() {
6441
+ if (postGattLifecycle { connectToSmartGlasses() }) return
6119
6442
  if (pairingYieldActive) {
6120
6443
  Bridge.log("LIVE: connectToSmartGlasses blocked — pairing yield active")
6121
6444
  return
@@ -6741,7 +7064,16 @@ class MentraLive : SGCManager() {
6741
7064
  "ctkd_bond_none",
6742
7065
  "prev=$previousBondState queueSize=${sendQueue.size}"
6743
7066
  )
6744
- 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
+ }
6745
7077
  audioConnected = false
6746
7078
  if (previousBondState == BluetoothDevice.BOND_BONDING) {
6747
7079
  // User cancelled or bonding failed - retry up to
@@ -6841,6 +7173,124 @@ class MentraLive : SGCManager() {
6841
7173
  }
6842
7174
  }
6843
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
+
6844
7294
  /**
6845
7295
  * Start CTKD Classic bonding / A2DP only after BLE notifications are ready. Calling this from
6846
7296
  * onConnectionStateChange(CONNECTED) races dual-mode stacks and frequently yields GATT 19
@@ -6917,7 +7367,7 @@ class MentraLive : SGCManager() {
6917
7367
  val method = device.javaClass.getMethod("removeBond")
6918
7368
  val result = method.invoke(device) as Boolean
6919
7369
  Bridge.log("LIVE: CTKD: Bond removal initiated, result: " + result)
6920
- isBtClassicConnected = false
7370
+ classicAudioConnectionTracker.invalidate(device.address)
6921
7371
  return result
6922
7372
  } catch (e: Exception) {
6923
7373
  Bridge.log("LIVE: CTKD: Error removing bond: " + e.message)
@@ -6927,7 +7377,7 @@ class MentraLive : SGCManager() {
6927
7377
 
6928
7378
  /** Check if BT Classic is connected via CTKD */
6929
7379
  fun isBtClassicConnected(): Boolean {
6930
- return isBtClassicConnected
7380
+ return classicAudioConnectionTracker.connected
6931
7381
  }
6932
7382
 
6933
7383
  /** A2DP profile service listener for connecting to already-bonded devices */
@@ -6986,11 +7436,11 @@ class MentraLive : SGCManager() {
6986
7436
  try {
6987
7437
  val state = a2dpProfile!!.getConnectionState(device)
6988
7438
  Bridge.log("LIVE: A2DP: Current connection state: " + state)
7439
+ refreshClassicAudioConnectionState(device)
6989
7440
 
6990
7441
  if (state == BluetoothProfile.STATE_CONNECTED) {
6991
7442
  Bridge.log("LIVE: A2DP: Already connected to " + device.name)
6992
7443
  a2dpConnectAttempts = 0
6993
- markAudioConnected(device.name)
6994
7444
  } else if (state == BluetoothProfile.STATE_DISCONNECTED) {
6995
7445
  Bridge.log("LIVE: A2DP: Connecting to " + device.name)
6996
7446
  // Use reflection to call connect() as it's a hidden API
@@ -7043,16 +7493,18 @@ class MentraLive : SGCManager() {
7043
7493
  }
7044
7494
 
7045
7495
  /** Helper to mark audio as connected and notify */
7046
- private fun markAudioConnected(deviceName: String?) {
7496
+ private fun markAudioConnected(device: BluetoothDevice) {
7047
7497
  if (isKilled) {
7048
7498
  Bridge.log(
7049
7499
  "LIVE: A2DP: Ignoring markAudioConnected — SGC destroyed (would confuse DeviceManager)"
7050
7500
  )
7051
7501
  return
7052
7502
  }
7053
- isBtClassicConnected = true
7503
+ val wasAudioConnected = audioConnected
7054
7504
  audioConnected = true
7055
- Bridge.sendAudioConnected(deviceName!!)
7505
+ if (!wasAudioConnected) {
7506
+ Bridge.sendAudioConnected(safeDeviceName(device))
7507
+ }
7056
7508
  if (glassesReadyReceived) {
7057
7509
  Bridge.log(
7058
7510
  "LIVE: A2DP: Both audio and glasses_ready confirmed - marking as fully connected"
@@ -7135,10 +7587,10 @@ class MentraLive : SGCManager() {
7135
7587
  device.address +
7136
7588
  ")"
7137
7589
  )
7138
- val isActiveDevice =
7139
- connectedDevice?.address?.equals(device.address, ignoreCase = true) == true
7140
- if (isActiveDevice) {
7141
- 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)) {
7142
7594
  audioConnected = false
7143
7595
  }
7144
7596
 
@@ -7247,6 +7699,8 @@ class MentraLive : SGCManager() {
7247
7699
  }
7248
7700
 
7249
7701
  fun destroy() {
7702
+ if (postGattLifecycle { destroy() }) return
7703
+ connectionRequestEpoch++
7250
7704
  Bridge.log("LIVE: Destroying MentraLiveSGC")
7251
7705
 
7252
7706
  // Mark as killed to prevent reconnection attempts
@@ -7268,12 +7722,15 @@ class MentraLive : SGCManager() {
7268
7722
 
7269
7723
  // CTKD Implementation: Unregister bonding receiver
7270
7724
  unregisterBondingReceiver()
7725
+ unregisterClassicAudioReceiver()
7271
7726
 
7272
7727
  // Tear down Classic (A2DP/HFP) before GATT close. Closing only the proxy left the
7273
7728
  // phone's system Bluetooth UI showing Mentra Live still connected.
7274
7729
  val classicDevice = connectedDevice
7275
7730
  disconnectClassicProfiles(classicDevice)
7276
7731
  closeA2dpProxy()
7732
+ classicAudioConnectionTracker.reset()
7733
+ DeviceStore.apply("glasses", "bluetoothClassicConnected", false)
7277
7734
 
7278
7735
  // Stop readiness check loop
7279
7736
  stopReadinessCheckLoop()
@@ -8628,9 +9085,10 @@ class MentraLive : SGCManager() {
8628
9085
  wireBytes: Int,
8629
9086
  packetCount: Int,
8630
9087
  protocolVersion: Int,
8631
- direction: String
9088
+ direction: String,
9089
+ bridgeLogging: Boolean = true,
8632
9090
  ) {
8633
- Bridge.log(
9091
+ transportLog(
8634
9092
  "BLE_TRACE direction=" +
8635
9093
  direction +
8636
9094
  " proto=v" +
@@ -8640,7 +9098,8 @@ class MentraLive : SGCManager() {
8640
9098
  " wire=" +
8641
9099
  wireBytes +
8642
9100
  " packets=" +
8643
- packetCount
9101
+ packetCount,
9102
+ bridgeLogging,
8644
9103
  )
8645
9104
  }
8646
9105
 
@@ -8658,15 +9117,19 @@ class MentraLive : SGCManager() {
8658
9117
  private fun sendDataToGlasses(
8659
9118
  data: String?,
8660
9119
  wakeup: Boolean,
8661
- sessionGeneration: Long
8662
- ) {
9120
+ sessionGeneration: Long,
9121
+ bridgeLogging: Boolean = true,
9122
+ ): Boolean {
8663
9123
  if (data == null || data.isEmpty()) {
8664
9124
  Log.e(TAG, "Cannot send empty data to glasses")
8665
- return
9125
+ return false
8666
9126
  }
8667
9127
  if (sessionGeneration != bleSessionGeneration.get()) {
8668
- Bridge.log("LIVE: Dropping send request from stale BLE session $sessionGeneration")
8669
- return
9128
+ transportLog(
9129
+ "LIVE: Dropping send request from stale BLE session $sessionGeneration",
9130
+ bridgeLogging,
9131
+ )
9132
+ return false
8670
9133
  }
8671
9134
 
8672
9135
  val wireData =
@@ -8694,14 +9157,14 @@ class MentraLive : SGCManager() {
8694
9157
  }
8695
9158
 
8696
9159
  if (useBinaryWireProtocol && buildNumberInt >= 5) {
8697
- sendDataToGlassesBinary(
9160
+ return sendDataToGlassesBinary(
8698
9161
  wireData,
8699
9162
  wakeup,
8700
9163
  commandTraceInfo,
8701
9164
  isPhotoRequest,
8702
- sessionGeneration
9165
+ sessionGeneration,
9166
+ bridgeLogging,
8703
9167
  )
8704
- return
8705
9168
  }
8706
9169
 
8707
9170
  // First check if the message needs chunking
@@ -8715,7 +9178,7 @@ class MentraLive : SGCManager() {
8715
9178
 
8716
9179
  // Check if chunking is needed
8717
9180
  if (MessageChunker.needsChunking(testWrappedJson)) {
8718
- Bridge.log("LIVE: Message exceeds threshold, chunking required")
9181
+ transportLog("LIVE: Message exceeds threshold, chunking required", bridgeLogging)
8719
9182
  if (isPhotoRequest) {
8720
9183
  Bridge.log(
8721
9184
  "LIVE: PHOTO PIPELINE BLE handoff — chunking enabled for request payload"
@@ -8733,7 +9196,8 @@ class MentraLive : SGCManager() {
8733
9196
 
8734
9197
  // Create chunks
8735
9198
  val chunks = MessageChunker.createChunks(wireData, messageId, wakeup)
8736
- Bridge.log("LIVE: Sending " + chunks.size + " chunks")
9199
+ if (chunks.isEmpty()) return false
9200
+ transportLog("LIVE: Sending " + chunks.size + " chunks", bridgeLogging)
8737
9201
  if (isPhotoRequest) {
8738
9202
  Bridge.log(
8739
9203
  "LIVE: PHOTO PIPELINE BLE handoff — created " +
@@ -8791,13 +9255,13 @@ class MentraLive : SGCManager() {
8791
9255
  }
8792
9256
  }
8793
9257
 
8794
- Bridge.log("LIVE: All chunks queued for transmission")
9258
+ transportLog("LIVE: All chunks queued for transmission", bridgeLogging)
8795
9259
  if (isPhotoRequest) {
8796
9260
  Bridge.log("LIVE: PHOTO PIPELINE BLE handoff — all photo chunks queued")
8797
9261
  }
8798
9262
  } else {
8799
9263
  // Normal single message transmission
8800
- Bridge.log("LIVE: Sending data to glasses: " + wireData)
9264
+ transportLog("LIVE: Sending data to glasses: " + wireData, bridgeLogging)
8801
9265
 
8802
9266
  // Pack the data using the centralized utility with the negotiated endianness
8803
9267
  val packedData =
@@ -8831,7 +9295,9 @@ class MentraLive : SGCManager() {
8831
9295
  }
8832
9296
  } catch (e: Exception) {
8833
9297
  Log.e(TAG, "Error creating data JSON", e)
9298
+ return false
8834
9299
  }
9300
+ return sessionGeneration == bleSessionGeneration.get()
8835
9301
  }
8836
9302
 
8837
9303
  private fun sendDataToGlassesBinary(
@@ -8839,8 +9305,9 @@ class MentraLive : SGCManager() {
8839
9305
  wakeup: Boolean,
8840
9306
  commandTraceInfo: OutgoingBleCommandTraceInfo,
8841
9307
  isPhotoRequest: Boolean,
8842
- sessionGeneration: Long
8843
- ) {
9308
+ sessionGeneration: Long,
9309
+ bridgeLogging: Boolean,
9310
+ ): Boolean {
8844
9311
  val payloadBytes = data.toByteArray(StandardCharsets.UTF_8)
8845
9312
  var messageId = 0
8846
9313
  var ackRequested = false
@@ -8859,6 +9326,7 @@ class MentraLive : SGCManager() {
8859
9326
  wakeup,
8860
9327
  ackRequested
8861
9328
  )
9329
+ if (fragments.isEmpty()) return false
8862
9330
  var totalWireBytes = 0
8863
9331
  for (i in fragments.indices) {
8864
9332
  val fragment = fragments[i]
@@ -8907,7 +9375,8 @@ class MentraLive : SGCManager() {
8907
9375
  totalWireBytes,
8908
9376
  fragments.size,
8909
9377
  BleWireProtocol.PROTOCOL_V2,
8910
- "phone_to_glasses"
9378
+ "phone_to_glasses",
9379
+ bridgeLogging,
8911
9380
  )
8912
9381
 
8913
9382
  if (isPhotoRequest) {
@@ -8918,6 +9387,7 @@ class MentraLive : SGCManager() {
8918
9387
  totalWireBytes
8919
9388
  )
8920
9389
  }
9390
+ return sessionGeneration == bleSessionGeneration.get()
8921
9391
  }
8922
9392
 
8923
9393
  private fun parseOutgoingBleCommandTraceInfo(payload: String): OutgoingBleCommandTraceInfo {
@@ -9199,16 +9669,45 @@ class MentraLive : SGCManager() {
9199
9669
  * K900 SystemUI can properly clear the cached credentials
9200
9670
  */
9201
9671
  override fun forgetWifiNetwork(ssid: String) {
9202
- 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)
9203
9680
 
9204
9681
  try {
9205
9682
  val wifiCommand = JSONObject()
9206
9683
  wifiCommand.put("type", "forget_wifi")
9207
9684
  wifiCommand.put("ssid", ssid)
9208
- 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)
9209
9695
  } catch (e: JSONException) {
9210
9696
  Log.e(TAG, "Error creating WiFi forget JSON", e)
9211
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)
9212
9711
  }
9213
9712
 
9214
9713
  override fun sendHotspotState(enabled: Boolean) {
@@ -9315,6 +9814,13 @@ class MentraLive : SGCManager() {
9315
9814
  }
9316
9815
  }
9317
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
+
9318
9824
  private fun sendJsonWithoutAck(json: JSONObject?) {
9319
9825
  sendJsonWithoutAck(json, false)
9320
9826
  }