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

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 (31) hide show
  1. package/README.md +19 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +14 -1
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +7 -2
  4. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +33 -7
  6. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +314 -75
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +76 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +2 -3
  12. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoBridgeTest.kt +33 -0
  13. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +169 -0
  14. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt +57 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +52 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +94 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycleTest.kt +112 -0
  18. package/build/generated/releaseMetadata.js +5 -5
  19. package/build/generated/releaseMetadata.js.map +1 -1
  20. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  21. package/ios/Source/Bridge.swift +15 -1
  22. package/ios/Source/DeviceManager.swift +7 -3
  23. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  24. package/ios/Source/MentraBluetoothSDK.swift +32 -6
  25. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
  26. package/ios/Source/sgcs/MentraLive.swift +9 -2
  27. package/ios/Source/status/DeviceStatus.swift +2 -3
  28. package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
  29. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
  30. package/package.json +1 -1
  31. package/src/generated/releaseMetadata.ts +5 -5
@@ -141,6 +141,9 @@ class MentraLive : SGCManager() {
141
141
  private const val A2DP_CONNECT_MAX_ATTEMPTS = 5
142
142
  private const val A2DP_CONNECT_RETRY_MS = 800L
143
143
  private const val UNPAIR_FLUSH_MS = 400L
144
+ private const val GATT_DISCONNECT_TIMEOUT_MS = 2_000L
145
+ private const val MTU_CALLBACK_TIMEOUT_MS = 3_000L
146
+ private val gattTeardownBarrier = MentraLiveGattTeardownBarrier()
144
147
 
145
148
  // L2CAP CoC fast path: new BES2700 firmware registers an LE L2CAP CoC server on this
146
149
  // PSM. When the phone opens the channel, the glasses send file packets over it instead
@@ -564,6 +567,12 @@ class MentraLive : SGCManager() {
564
567
  private var bluetoothAdapter: BluetoothAdapter? = null
565
568
  private var bluetoothScanner: BluetoothLeScanner? = null
566
569
  @Volatile private var bluetoothGatt: BluetoothGatt? = null
570
+ private val gattLifecycleHandler = Handler(Looper.getMainLooper())
571
+ // Owned exclusively by the main looper, including callback validation and mutation.
572
+ private var gattEpoch = 0L
573
+ private var connectionRequestEpoch = 0L
574
+ @Volatile private var gattTeardownToken: Long? = null
575
+ @Volatile private var gattTeardownTimeoutRunnable: Runnable? = null
567
576
  private var connectedDevice: BluetoothDevice? = null
568
577
  private var txCharacteristic: BluetoothGattCharacteristic? = null
569
578
  private var rxCharacteristic: BluetoothGattCharacteristic? = null
@@ -643,6 +652,9 @@ class MentraLive : SGCManager() {
643
652
  private var isDescriptorWriteInProgress = false
644
653
  private var notificationsEnabled =
645
654
  false // Track if enableNotifications was already called this connection
655
+ private val mtuSetupGate = MentraLiveMtuSetupGate()
656
+ private var mtuSetupToken: Long? = null
657
+ private var mtuWatchdogRunnable: Runnable? = null
646
658
  private var connectionTimeoutRunnable: Runnable? = null
647
659
  private var connectionTimeoutHandler = Handler(Looper.getMainLooper())
648
660
  private var processSendQueueRunnable: Runnable? = null
@@ -1233,6 +1245,7 @@ class MentraLive : SGCManager() {
1233
1245
 
1234
1246
  /** Starts BLE scanning for Mentra Live glasses */
1235
1247
  private fun startScan() {
1248
+ if (postGattLifecycle { startScan() }) return
1236
1249
  if (pairingYieldActive) {
1237
1250
  Bridge.log("LIVE: startScan blocked — pairing yield active")
1238
1251
  return
@@ -1297,6 +1310,7 @@ class MentraLive : SGCManager() {
1297
1310
 
1298
1311
  val generation = ++scanGeneration
1299
1312
  isScanning = true
1313
+ scanCallback = createScanCallback(generation)
1300
1314
  bluetoothScanner!!.startScan(filters, settings, scanCallback)
1301
1315
 
1302
1316
  // Set a timeout to stop scanning
@@ -1349,6 +1363,7 @@ class MentraLive : SGCManager() {
1349
1363
 
1350
1364
  /** Stops BLE scanning */
1351
1365
  override fun stopScan() {
1366
+ if (postGattLifecycle { stopScan() }) return
1352
1367
  manualDiscoveryActive = false
1353
1368
  if (bluetoothAdapter == null || bluetoothScanner == null || !isScanning) {
1354
1369
  return
@@ -1424,9 +1439,13 @@ class MentraLive : SGCManager() {
1424
1439
  var seenDevices: MutableSet<String> = HashSet()
1425
1440
 
1426
1441
  /** BLE Scan callback */
1427
- private val scanCallback: ScanCallback =
1442
+ private var scanCallback: ScanCallback? = null
1443
+
1444
+ private fun createScanCallback(generation: Int): ScanCallback =
1428
1445
  object : ScanCallback() {
1429
1446
  override fun onScanResult(callbackType: Int, result: ScanResult) {
1447
+ if (postGattLifecycle { onScanResult(callbackType, result) }) return
1448
+ if (!isScanning || generation != scanGeneration) return
1430
1449
  // Check if the object has been destroyed to prevent NPE
1431
1450
  if (context == null || isKilled) {
1432
1451
  Bridge.log("LIVE: Ignoring scan result - object destroyed or killed")
@@ -1559,6 +1578,8 @@ class MentraLive : SGCManager() {
1559
1578
  }
1560
1579
 
1561
1580
  override fun onScanFailed(errorCode: Int) {
1581
+ if (postGattLifecycle { onScanFailed(errorCode) }) return
1582
+ if (generation != scanGeneration || !isScanning) return
1562
1583
  Log.e(TAG, "BLE scan failed with error: " + errorCode)
1563
1584
  isScanning = false
1564
1585
  if (isReconnecting && !isKilled) {
@@ -1588,30 +1609,120 @@ class MentraLive : SGCManager() {
1588
1609
  }
1589
1610
  }
1590
1611
 
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
1612
+ /** Return true when work was handed to the single process-wide lifecycle queue. */
1613
+ private fun postGattLifecycle(work: () -> Unit): Boolean {
1614
+ if (Looper.myLooper() == gattLifecycleHandler.looper) return false
1615
+ gattLifecycleHandler.post { work() }
1616
+ return true
1617
+ }
1618
+
1619
+ /** Safely close GATT, waiting for Android to finish a requested disconnect when possible. */
1597
1620
  private fun closeGattQuietly(disconnect: Boolean) {
1621
+ check(Looper.myLooper() == gattLifecycleHandler.looper)
1598
1622
  val gatt = bluetoothGatt
1599
- bluetoothGatt = null
1600
1623
  if (gatt == null) {
1601
1624
  return
1602
1625
  }
1626
+ cancelMtuWatchdog()
1627
+ if (disconnect) {
1628
+ beginGattTeardown(gatt)
1629
+ return
1630
+ }
1631
+ bluetoothGatt = null
1632
+ releaseGattSessionMedia()
1603
1633
  try {
1604
- if (disconnect) {
1605
- gatt.disconnect()
1606
- }
1634
+ gatt.close()
1607
1635
  } catch (e: Exception) {
1608
- Log.w(TAG, "🔌 closeGattQuietly: disconnect threw " + e)
1636
+ Log.w(TAG, "🔌 closeGattQuietly: close threw " + e)
1637
+ }
1638
+ }
1639
+
1640
+ /** Release side channels on every teardown, including missing disconnect callbacks. */
1641
+ private fun releaseGattSessionMedia() {
1642
+ closeL2capFileChannel()
1643
+ fileProcessingHandler.removeCallbacksAndMessages(null)
1644
+ clearFilePacketBuffer()
1645
+ closeLc3Logging()
1646
+ lc3AudioPlayer?.stopPlay()
1647
+ }
1648
+
1649
+ private fun beginGattTeardown(gatt: BluetoothGatt) {
1650
+ check(Looper.myLooper() == gattLifecycleHandler.looper)
1651
+ if (gatt !== bluetoothGatt || gattTeardownToken != null) {
1652
+ return
1653
+ }
1654
+ val token = gattTeardownBarrier.beginTeardown()
1655
+ gattTeardownToken = token
1656
+ // Teardown owns session invalidation, including the timeout/replacement paths that do
1657
+ // not receive the normal remote-disconnect cleanup callback.
1658
+ cancelMtuWatchdog()
1659
+ isConnected = false
1660
+ isConnecting = false
1661
+ connectedDevice = null
1662
+ glassesReady = false
1663
+ glassesSessionId = null
1664
+ readinessCompletedThisBleSession = false
1665
+ glassesReadyReceived = false
1666
+ ctkdInitiatedThisGattSession = false
1667
+ audioConnected = false
1668
+ notificationsEnabled = false
1669
+ currentMtu = 23
1670
+ pendingDescriptorWrites.clear()
1671
+ isDescriptorWriteInProgress = false
1672
+ txCharacteristic = null
1673
+ rxCharacteristic = null
1674
+ lc3ReadCharacteristic = null
1675
+ lc3WriteCharacteristic = null
1676
+ handler.removeCallbacks(processSendQueueRunnable!!)
1677
+ stopReadinessCheckLoop()
1678
+ stopHeartbeat()
1679
+ stopSignalStrengthPolling()
1680
+ stopMicBeat()
1681
+ releaseGattSessionMedia()
1682
+ updateConnectionState(ConnTypes.DISCONNECTED)
1683
+ val timeout =
1684
+ Runnable {
1685
+ Bridge.log(
1686
+ "LIVE: 🔌 GATT disconnect callback timed out after ${GATT_DISCONNECT_TIMEOUT_MS}ms; closing transport"
1687
+ )
1688
+ completeGattTeardown(gatt, token, "timeout")
1689
+ }
1690
+ gattTeardownTimeoutRunnable = timeout
1691
+ gattLifecycleHandler.postDelayed(timeout, GATT_DISCONNECT_TIMEOUT_MS)
1692
+ try {
1693
+ Bridge.log("LIVE: 🔌 Waiting for GATT disconnected callback before close")
1694
+ gatt.disconnect()
1695
+ } catch (e: Exception) {
1696
+ Log.w(TAG, "🔌 GATT disconnect threw " + e)
1697
+ completeGattTeardown(gatt, token, "disconnect_exception")
1698
+ }
1699
+ }
1700
+
1701
+ private fun completeGattTeardown(gatt: BluetoothGatt, token: Long, reason: String) {
1702
+ check(Looper.myLooper() == gattLifecycleHandler.looper)
1703
+ if (gattTeardownToken != token) {
1704
+ return
1705
+ }
1706
+ gattTeardownTimeoutRunnable?.let { gattLifecycleHandler.removeCallbacks(it) }
1707
+ gattTeardownTimeoutRunnable = null
1708
+ gattTeardownToken = null
1709
+ if (gatt === bluetoothGatt) {
1710
+ bluetoothGatt = null
1609
1711
  }
1610
1712
  try {
1611
1713
  gatt.close()
1612
1714
  } catch (e: Exception) {
1613
- Log.w(TAG, "🔌 closeGattQuietly: close threw " + e)
1715
+ Log.w(TAG, "🔌 GATT close after $reason threw " + e)
1614
1716
  }
1717
+ Bridge.log("LIVE: 🔌 GATT teardown complete ($reason)")
1718
+ gattTeardownBarrier.completeTeardown(token)
1719
+ }
1720
+
1721
+ private fun cancelMtuWatchdog() {
1722
+ mtuWatchdogRunnable?.let { gattLifecycleHandler.removeCallbacks(it) }
1723
+ mtuWatchdogRunnable = null
1724
+ mtuSetupToken = null
1725
+ mtuSetupGate.cancel()
1615
1726
  }
1616
1727
 
1617
1728
  /**
@@ -1634,6 +1745,11 @@ class MentraLive : SGCManager() {
1634
1745
  return false
1635
1746
  }
1636
1747
 
1748
+ /** Ignore all non-disconnect work once this GATT is stale or tearing down. */
1749
+ private fun isActiveGattCallback(gatt: BluetoothGatt): Boolean {
1750
+ return isCurrentGattCallback(gatt) && gattTeardownToken == null
1751
+ }
1752
+
1637
1753
  private fun beginPairingTiming(reason: String) {
1638
1754
  pairingTimingStartMs = SystemClock.elapsedRealtime()
1639
1755
  pairingTimingLastMs = pairingTimingStartMs
@@ -1662,15 +1778,32 @@ class MentraLive : SGCManager() {
1662
1778
 
1663
1779
  /** Connect to a specific BLE device */
1664
1780
  private fun connectToDevice(device: BluetoothDevice?) {
1781
+ if (postGattLifecycle { connectToDevice(device) }) return
1665
1782
  if (device == null) {
1666
1783
  return
1667
1784
  }
1668
- if (pairingYieldActive) {
1785
+ if (isKilled || pairingYieldActive) {
1669
1786
  Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
1670
1787
  isConnecting = false
1671
1788
  isReconnecting = false
1672
1789
  return
1673
1790
  }
1791
+ val requestEpoch = ++connectionRequestEpoch
1792
+ // Never replace a still-open GATT. Its disconnect/close must finish first.
1793
+ bluetoothGatt?.let { beginGattTeardown(it) }
1794
+ gattTeardownBarrier.runWhenIdle {
1795
+ if (requestEpoch == connectionRequestEpoch && !isKilled && !pairingYieldActive) {
1796
+ connectToDeviceWhenIdle(device)
1797
+ }
1798
+ }
1799
+ }
1800
+
1801
+ private fun connectToDeviceWhenIdle(device: BluetoothDevice) {
1802
+ check(Looper.myLooper() == gattLifecycleHandler.looper)
1803
+ check(bluetoothGatt == null)
1804
+ val epoch = ++gattEpoch
1805
+ currentMtu = 23
1806
+ val gattCallback = createGattCallback(epoch)
1674
1807
 
1675
1808
  beginPairingTiming(
1676
1809
  "connectToDevice addr=${device.address} name=${safeDeviceName(device)} attempt=$reconnectAttempts"
@@ -1683,7 +1816,7 @@ class MentraLive : SGCManager() {
1683
1816
 
1684
1817
  // Set connection timeout
1685
1818
  connectionTimeoutRunnable = Runnable {
1686
- if (isConnecting && !isConnected) {
1819
+ if (epoch == gattEpoch && isConnecting && !isConnected) {
1687
1820
  Log.w(
1688
1821
  TAG,
1689
1822
  "🔌 ⏰ CONNECTION TIMEOUT after " +
@@ -1775,6 +1908,8 @@ class MentraLive : SGCManager() {
1775
1908
  // }
1776
1909
 
1777
1910
  private fun enterPairingYield(windowMs: Long) {
1911
+ if (postGattLifecycle { enterPairingYield(windowMs) }) return
1912
+ connectionRequestEpoch++
1778
1913
  pairingYieldActive = true
1779
1914
  pairingYieldAwaitingReclaim = false
1780
1915
  isReconnecting = false
@@ -1855,6 +1990,7 @@ class MentraLive : SGCManager() {
1855
1990
 
1856
1991
  /** Handle reconnection with exponential backoff */
1857
1992
  private fun handleReconnection() {
1993
+ if (postGattLifecycle { handleReconnection() }) return
1858
1994
  // Don't attempt reconnection if we've been killed/forgotten
1859
1995
  if (isKilled) {
1860
1996
  Bridge.log("LIVE: 🔌 RECONNECT ABORTED - device has been killed/forgotten")
@@ -2093,9 +2229,13 @@ class MentraLive : SGCManager() {
2093
2229
  }
2094
2230
 
2095
2231
  /** GATT callback for BLE operations */
2096
- private val gattCallback: BluetoothGattCallback =
2097
- object : BluetoothGattCallback() {
2098
- override fun onConnectionStateChange(
2232
+ private fun createGattCallback(epoch: Long): BluetoothGattCallback =
2233
+ object : SerializedGattCallback({ work ->
2234
+ // Always enqueue, even for an inline platform callback during connectGatt().
2235
+ // This lets connectGatt return and install the identity before validation.
2236
+ gattLifecycleHandler.post { work() }
2237
+ }, { gatt -> epoch == gattEpoch && isCurrentGattCallback(gatt) }) {
2238
+ override fun handleConnectionStateChange(
2099
2239
  gatt: BluetoothGatt,
2100
2240
  status: Int,
2101
2241
  newState: Int
@@ -2103,6 +2243,19 @@ class MentraLive : SGCManager() {
2103
2243
  if (!isCurrentGattCallback(gatt)) {
2104
2244
  return
2105
2245
  }
2246
+ val teardownToken = gattTeardownToken
2247
+ if (teardownToken != null) {
2248
+ if (
2249
+ newState == BluetoothProfile.STATE_DISCONNECTED ||
2250
+ status != BluetoothGatt.GATT_SUCCESS
2251
+ ) {
2252
+ completeGattTeardown(gatt, teardownToken, "disconnected_callback")
2253
+ }
2254
+ // Once teardown starts, a queued successful CONNECTED callback belongs to
2255
+ // the closing transport. It must not resurrect application connection
2256
+ // state while the disconnect callback is still pending.
2257
+ return
2258
+ }
2106
2259
 
2107
2260
  // Cancel the connection timeout
2108
2261
  if (connectionTimeoutRunnable != null) {
@@ -2209,11 +2362,6 @@ class MentraLive : SGCManager() {
2209
2362
  // Stop micbeat mechanism
2210
2363
  stopMicBeat()
2211
2364
 
2212
- // Close the L2CAP file channel (if the fast path was open)
2213
- closeL2capFileChannel()
2214
- fileProcessingHandler.removeCallbacksAndMessages(null)
2215
- clearFilePacketBuffer()
2216
-
2217
2365
  // Clean up GATT resources
2218
2366
  closeGattQuietly(false)
2219
2367
 
@@ -2223,13 +2371,6 @@ class MentraLive : SGCManager() {
2223
2371
  handleReconnection()
2224
2372
  }
2225
2373
 
2226
- // Close LC3 audio logging
2227
- closeLc3Logging()
2228
-
2229
- // stop LC3 player
2230
- if (lc3AudioPlayer != null) {
2231
- lc3AudioPlayer!!.stopPlay()
2232
- }
2233
2374
  }
2234
2375
  } else {
2235
2376
  // Connection error
@@ -2302,7 +2443,7 @@ class MentraLive : SGCManager() {
2302
2443
  }
2303
2444
  }
2304
2445
 
2305
- override fun onPhyUpdate(
2446
+ override fun handlePhyUpdate(
2306
2447
  gatt: BluetoothGatt,
2307
2448
  txPhy: Int,
2308
2449
  rxPhy: Int,
@@ -2318,7 +2459,7 @@ class MentraLive : SGCManager() {
2318
2459
  )
2319
2460
  }
2320
2461
 
2321
- override fun onPhyRead(
2462
+ override fun handlePhyRead(
2322
2463
  gatt: BluetoothGatt,
2323
2464
  txPhy: Int,
2324
2465
  rxPhy: Int,
@@ -2334,7 +2475,10 @@ class MentraLive : SGCManager() {
2334
2475
  )
2335
2476
  }
2336
2477
 
2337
- override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
2478
+ override fun handleServicesDiscovered(gatt: BluetoothGatt, status: Int) {
2479
+ if (!isActiveGattCallback(gatt)) {
2480
+ return
2481
+ }
2338
2482
  if (status == BluetoothGatt.GATT_SUCCESS) {
2339
2483
  Bridge.log("LIVE: GATT services discovered")
2340
2484
 
@@ -2397,7 +2541,17 @@ class MentraLive : SGCManager() {
2397
2541
  // This ensures no concurrent GATT operations on older Android BLE
2398
2542
  // stacks.
2399
2543
  if (checkPermission()) {
2400
- val mtuRequested = gatt.requestMtu(512)
2544
+ val mtuToken = mtuSetupGate.begin()
2545
+ mtuSetupToken = mtuToken
2546
+ val mtuRequested =
2547
+ try {
2548
+ gatt.requestMtu(512)
2549
+ } catch (e: SecurityException) {
2550
+ Bridge.log(
2551
+ "LIVE: ⚠️ MTU request denied; continuing with notification setup"
2552
+ )
2553
+ false
2554
+ }
2401
2555
  Bridge.log(
2402
2556
  "LIVE: 🔄 Requested MTU size 512, success: " +
2403
2557
  mtuRequested
@@ -2405,12 +2559,18 @@ class MentraLive : SGCManager() {
2405
2559
  if (!mtuRequested) {
2406
2560
  // MTU request failed to even start, enable notifications
2407
2561
  // directly
2408
- enableNotifications()
2562
+ completeMtuSetup(
2563
+ gatt,
2564
+ mtuToken,
2565
+ "request_not_started"
2566
+ )
2567
+ } else {
2568
+ startMtuWatchdog(gatt, mtuToken)
2409
2569
  }
2410
2570
  // Otherwise, enableNotifications() will be called from
2411
2571
  // onMtuChanged
2412
2572
  } else {
2413
- enableNotifications()
2573
+ enableNotificationsAfterMtu(gatt, "permission_fallback")
2414
2574
  }
2415
2575
 
2416
2576
  // NOTE: Send queue and readiness loop are started AFTER descriptor
@@ -2437,11 +2597,14 @@ class MentraLive : SGCManager() {
2437
2597
  }
2438
2598
  }
2439
2599
 
2440
- override fun onCharacteristicRead(
2600
+ override fun handleCharacteristicRead(
2441
2601
  gatt: BluetoothGatt,
2442
2602
  characteristic: BluetoothGattCharacteristic,
2443
2603
  status: Int
2444
2604
  ) {
2605
+ if (!isActiveGattCallback(gatt)) {
2606
+ return
2607
+ }
2445
2608
  if (status == BluetoothGatt.GATT_SUCCESS) {
2446
2609
  Bridge.log("LIVE: Characteristic read successful")
2447
2610
  // Process the read data if needed
@@ -2450,7 +2613,10 @@ class MentraLive : SGCManager() {
2450
2613
  }
2451
2614
  }
2452
2615
 
2453
- override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
2616
+ override fun handleReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
2617
+ if (!isActiveGattCallback(gatt)) {
2618
+ return
2619
+ }
2454
2620
  rssiReadInProgress = false
2455
2621
  if (status == BluetoothGatt.GATT_SUCCESS) {
2456
2622
  if (isConnected && bluetoothGatt != null && gatt === bluetoothGatt) {
@@ -2461,11 +2627,14 @@ class MentraLive : SGCManager() {
2461
2627
  }
2462
2628
  }
2463
2629
 
2464
- override fun onCharacteristicWrite(
2630
+ override fun handleCharacteristicWrite(
2465
2631
  gatt: BluetoothGatt,
2466
2632
  characteristic: BluetoothGattCharacteristic,
2467
2633
  status: Int
2468
2634
  ) {
2635
+ if (!isActiveGattCallback(gatt)) {
2636
+ return
2637
+ }
2469
2638
  val trace = inFlightBleWriteTrace
2470
2639
  val callbackAtMs = System.currentTimeMillis()
2471
2640
  val callbackDelayMs =
@@ -2527,11 +2696,12 @@ class MentraLive : SGCManager() {
2527
2696
  }
2528
2697
  }
2529
2698
 
2530
- override fun onCharacteristicChanged(
2699
+ override fun handleCharacteristicChanged(
2531
2700
  gatt: BluetoothGatt,
2532
- characteristic: BluetoothGattCharacteristic
2701
+ characteristic: BluetoothGattCharacteristic,
2702
+ data: ByteArray
2533
2703
  ) {
2534
- if (!isCurrentGattCallback(gatt)) {
2704
+ if (!isActiveGattCallback(gatt)) {
2535
2705
  return
2536
2706
  }
2537
2707
 
@@ -2539,8 +2709,7 @@ class MentraLive : SGCManager() {
2539
2709
  val threadId = Thread.currentThread().id
2540
2710
  val uuid = characteristic.uuid
2541
2711
 
2542
- val data = characteristic.value
2543
- if (data == null || data.isEmpty()) {
2712
+ if (data.isEmpty()) {
2544
2713
  return
2545
2714
  }
2546
2715
 
@@ -2584,11 +2753,14 @@ class MentraLive : SGCManager() {
2584
2753
  processReceivedData(data, data.size)
2585
2754
  }
2586
2755
 
2587
- override fun onDescriptorWrite(
2756
+ override fun handleDescriptorWrite(
2588
2757
  gatt: BluetoothGatt,
2589
2758
  descriptor: BluetoothGattDescriptor,
2590
2759
  status: Int
2591
2760
  ) {
2761
+ if (!isActiveGattCallback(gatt)) {
2762
+ return
2763
+ }
2592
2764
  val threadId = Thread.currentThread().id
2593
2765
 
2594
2766
  if (status == BluetoothGatt.GATT_SUCCESS) {
@@ -2616,43 +2788,91 @@ class MentraLive : SGCManager() {
2616
2788
  writeNextDescriptor()
2617
2789
  }
2618
2790
 
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
- )
2791
+ override fun handleMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
2792
+ handler.post {
2793
+ if (!isActiveGattCallback(gatt)) {
2794
+ return@post
2795
+ }
2796
+ if (status == BluetoothGatt.GATT_SUCCESS) {
2797
+ Bridge.log(
2798
+ "LIVE: 🔵 MTU negotiation successful - changed to " +
2799
+ mtu +
2800
+ " bytes"
2801
+ )
2802
+ val effectivePayload = mtu - 3
2803
+ Bridge.log(
2804
+ "LIVE: Effective payload size: " +
2805
+ effectivePayload +
2806
+ " bytes"
2807
+ )
2628
2808
 
2629
- // Store the new MTU value
2630
- currentMtu = mtu
2809
+ currentMtu = mtu
2631
2810
 
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")
2811
+ if (mtu >= 64) {
2812
+ Bridge.log(
2813
+ "LIVE: MTU size is sufficient for LC3 audio data packets"
2814
+ )
2815
+ } else {
2816
+ Log.w(
2817
+ TAG,
2818
+ "⚠️ MTU size may be too small for LC3 audio data packets"
2819
+ )
2820
+ Bridge.log(
2821
+ "LIVE: 📊 Effective MTU payload: " +
2822
+ effectivePayload +
2823
+ " bytes"
2824
+ )
2825
+ }
2636
2826
  } 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"
2827
+ Log.e(TAG, " MTU change failed with status: " + status)
2828
+ Log.w(
2829
+ TAG,
2830
+ " Will continue with default MTU (23 bytes, 20 byte payload)"
2640
2831
  )
2641
2832
  }
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
2833
 
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()
2834
+ mtuSetupToken?.let { completeMtuSetup(gatt, it, "callback") }
2652
2835
  }
2653
2836
  }
2654
2837
  }
2655
2838
 
2839
+ private fun startMtuWatchdog(gatt: BluetoothGatt, token: Long) {
2840
+ mtuWatchdogRunnable?.let { gattLifecycleHandler.removeCallbacks(it) }
2841
+ val watchdog =
2842
+ Runnable {
2843
+ handler.post {
2844
+ if (!isActiveGattCallback(gatt)) {
2845
+ return@post
2846
+ }
2847
+ Bridge.log(
2848
+ "LIVE: ⚠️ MTU callback timed out after ${MTU_CALLBACK_TIMEOUT_MS}ms; continuing with notification setup"
2849
+ )
2850
+ completeMtuSetup(gatt, token, "watchdog")
2851
+ }
2852
+ }
2853
+ mtuWatchdogRunnable = watchdog
2854
+ gattLifecycleHandler.postDelayed(watchdog, MTU_CALLBACK_TIMEOUT_MS)
2855
+ }
2856
+
2857
+ private fun completeMtuSetup(gatt: BluetoothGatt, token: Long, reason: String) {
2858
+ if (!mtuSetupGate.complete(token)) {
2859
+ return
2860
+ }
2861
+ mtuWatchdogRunnable?.let { gattLifecycleHandler.removeCallbacks(it) }
2862
+ mtuWatchdogRunnable = null
2863
+ mtuSetupToken = null
2864
+ enableNotificationsAfterMtu(gatt, reason)
2865
+ }
2866
+
2867
+ private fun enableNotificationsAfterMtu(gatt: BluetoothGatt, reason: String) {
2868
+ if (!isActiveGattCallback(gatt) || notificationsEnabled) {
2869
+ return
2870
+ }
2871
+ notificationsEnabled = true
2872
+ Bridge.log("LIVE: 🔔 Continuing GATT setup after MTU ($reason)")
2873
+ enableNotifications()
2874
+ }
2875
+
2656
2876
  /**
2657
2877
  * Write the next queued descriptor, or mark the queue as idle. Must be called after each
2658
2878
  * onDescriptorWrite callback to serialize BLE GATT operations. On older Android devices,
@@ -2660,6 +2880,12 @@ class MentraLive : SGCManager() {
2660
2880
  * subsequent writes to silently fail.
2661
2881
  */
2662
2882
  private fun writeNextDescriptor() {
2883
+ val gatt = bluetoothGatt ?: return
2884
+ if (!isActiveGattCallback(gatt)) return
2885
+ val epoch = gattEpoch
2886
+ val continueSetup = Runnable {
2887
+ if (epoch == gattEpoch && isActiveGattCallback(gatt)) writeNextDescriptor()
2888
+ }
2663
2889
  val next = pendingDescriptorWrites.poll()
2664
2890
  if (next == null) {
2665
2891
  isDescriptorWriteInProgress = false
@@ -2703,12 +2929,12 @@ class MentraLive : SGCManager() {
2703
2929
  uuid +
2704
2930
  ", continuing queue"
2705
2931
  )
2706
- handler.postDelayed(this::writeNextDescriptor, 50L)
2932
+ handler.postDelayed(continueSetup, 50L)
2707
2933
  }
2708
2934
  } catch (e: Exception) {
2709
2935
  val threadId = Thread.currentThread().id
2710
2936
  Log.e(TAG, "Thread-" + threadId + ": ⚠️ Error writing descriptor: " + e.message)
2711
- handler.postDelayed(this::writeNextDescriptor, 50L)
2937
+ handler.postDelayed(continueSetup, 50L)
2712
2938
  }
2713
2939
  }
2714
2940
 
@@ -4743,7 +4969,7 @@ class MentraLive : SGCManager() {
4743
4969
  handleGlassesSessionId(json)
4744
4970
 
4745
4971
  Bridge.log("LIVE: Processed version_info fields and sent to RN")
4746
- Bridge.sendVersionInfo(fields)
4972
+ Bridge.sendVersionInfo(fields, type)
4747
4973
  } else {
4748
4974
  Log.d(TAG, "📦 Unknown message type: " + type)
4749
4975
  }
@@ -5664,9 +5890,14 @@ class MentraLive : SGCManager() {
5664
5890
  * build number, firmware version, etc.
5665
5891
  */
5666
5892
  override fun requestVersionInfo() {
5893
+ requestVersionInfo(null)
5894
+ }
5895
+
5896
+ fun requestVersionInfo(requestId: String?) {
5667
5897
  try {
5668
5898
  val json = JSONObject()
5669
5899
  json.put("type", "request_version")
5900
+ requestId?.let { json.put("request_id", it) }
5670
5901
  sendJson(json, false)
5671
5902
  Bridge.log("LIVE: 📱 Requesting version info from glasses")
5672
5903
  } catch (e: JSONException) {
@@ -5938,6 +6169,8 @@ class MentraLive : SGCManager() {
5938
6169
  // SmartGlassesCommunicator interface implementation
5939
6170
 
5940
6171
  override fun findCompatibleDevices() {
6172
+ if (postGattLifecycle { findCompatibleDevices() }) return
6173
+ connectionRequestEpoch++
5941
6174
  Bridge.log("LIVE: Finding compatible Mentra Live glasses")
5942
6175
 
5943
6176
  // A fresh user scan owns the radio. Invalidate delayed reconnect callbacks
@@ -6002,6 +6235,7 @@ class MentraLive : SGCManager() {
6002
6235
  }
6003
6236
 
6004
6237
  override fun connectById(id: String) {
6238
+ if (postGattLifecycle { connectById(id) }) return
6005
6239
  if (pairingYieldActive) {
6006
6240
  Bridge.log("LIVE: connectById blocked — pairing yield active")
6007
6241
  return
@@ -6031,6 +6265,7 @@ class MentraLive : SGCManager() {
6031
6265
  }
6032
6266
 
6033
6267
  override fun forget() {
6268
+ if (postGattLifecycle { forget() }) return
6034
6269
  Bridge.log("LIVE: Forgetting Mentra Live glasses")
6035
6270
 
6036
6271
  // Clear saved device name so a leftover name can't bypass the pairing-mode
@@ -6075,6 +6310,7 @@ class MentraLive : SGCManager() {
6075
6310
  }
6076
6311
 
6077
6312
  override fun disconnect() {
6313
+ if (postGattLifecycle { disconnect() }) return
6078
6314
  if (unpairFlushPending) {
6079
6315
  Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
6080
6316
  return
@@ -6116,6 +6352,7 @@ class MentraLive : SGCManager() {
6116
6352
  }
6117
6353
 
6118
6354
  fun connectToSmartGlasses() {
6355
+ if (postGattLifecycle { connectToSmartGlasses() }) return
6119
6356
  if (pairingYieldActive) {
6120
6357
  Bridge.log("LIVE: connectToSmartGlasses blocked — pairing yield active")
6121
6358
  return
@@ -7247,6 +7484,8 @@ class MentraLive : SGCManager() {
7247
7484
  }
7248
7485
 
7249
7486
  fun destroy() {
7487
+ if (postGattLifecycle { destroy() }) return
7488
+ connectionRequestEpoch++
7250
7489
  Bridge.log("LIVE: Destroying MentraLiveSGC")
7251
7490
 
7252
7491
  // Mark as killed to prevent reconnection attempts