@mentra/bluetooth-sdk 3.4.0-dev.996 → 3.4.0-dev.998

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 (68) hide show
  1. package/MIGRATION.md +61 -0
  2. package/README.md +47 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +8 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +48 -4
  6. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +21 -0
  7. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +2 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +5 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicy.kt +64 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +788 -134
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicy.kt +227 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +19 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicy.kt +94 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachine.kt +411 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateStore.kt +124 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +50 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerOwnerLossTest.kt +146 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusAudioSetupTest.kt +51 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +2 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicyTest.kt +65 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveAudioSetupReadinessTest.kt +231 -0
  23. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicyTest.kt +368 -0
  24. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +20 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicyTest.kt +136 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachineTest.kt +346 -0
  27. package/build/BluetoothSdk.types.d.ts +101 -0
  28. package/build/BluetoothSdk.types.d.ts.map +1 -1
  29. package/build/BluetoothSdk.types.js.map +1 -1
  30. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  31. package/build/_private/BluetoothSdkModule.js +1 -0
  32. package/build/_private/BluetoothSdkModule.js.map +1 -1
  33. package/build/generated/releaseMetadata.js +5 -5
  34. package/build/generated/releaseMetadata.js.map +1 -1
  35. package/build/index.d.ts +1 -1
  36. package/build/index.d.ts.map +1 -1
  37. package/build/index.js +3 -0
  38. package/build/index.js.map +1 -1
  39. package/ios/BluetoothSdkModule.swift +8 -2
  40. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  41. package/ios/Source/Bridge.swift +11 -3
  42. package/ios/Source/DeviceManager.swift +80 -6
  43. package/ios/Source/DeviceStore.swift +27 -0
  44. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  45. package/ios/Source/MentraBluetoothSDK.swift +10 -4
  46. package/ios/Source/errors/BluetoothSdkError.swift +5 -1
  47. package/ios/Source/sgcs/GlassesAudioSetupPolicy.swift +181 -0
  48. package/ios/Source/sgcs/MentraLive.swift +1053 -166
  49. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +260 -1
  50. package/ios/Source/sgcs/MentraLivePairingStateMachine.swift +385 -0
  51. package/ios/Source/sgcs/MentraLivePairingStateStore.swift +121 -0
  52. package/ios/Source/sgcs/SGCManager.swift +7 -0
  53. package/ios/Source/sgcs/Simulated.swift +2 -0
  54. package/ios/Source/status/DeviceStatus.swift +35 -0
  55. package/ios/Source/types/DeviceModels.swift +8 -1
  56. package/ios/Source/utils/AudioSessionMonitor.swift +158 -5
  57. package/ios/Tests/BluetoothAudioRouteTests.swift +82 -0
  58. package/ios/Tests/GlassesAudioSetupPolicyTests.swift +191 -0
  59. package/ios/Tests/MentraLiveAudioSetupTests.swift +215 -0
  60. package/ios/Tests/MentraLiveConnectionOptionsTests.swift +55 -0
  61. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +327 -0
  62. package/ios/Tests/MentraLivePairingStateMachineTests.swift +402 -0
  63. package/ios/Tests/MentraLiveSessionReadinessTests.swift +328 -26
  64. package/package.json +3 -2
  65. package/src/BluetoothSdk.types.ts +97 -0
  66. package/src/_private/BluetoothSdkModule.ts +1 -0
  67. package/src/generated/releaseMetadata.ts +5 -5
  68. package/src/index.ts +10 -0
@@ -203,6 +203,8 @@ class MentraLive : SGCManager() {
203
203
  10_000L // 10s before first scan after shutdown
204
204
 
205
205
  private const val MENTRA_MANUFACTURER_ID = 0xB822
206
+ /** `phone_hello.pairing_protocol`: this SDK speaks ownership transfer v4. */
207
+ private const val PAIRING_PROTOCOL_VERSION = 4
206
208
 
207
209
  private fun manufacturerData(result: ScanResult): ByteArray? {
208
210
  return result.scanRecord?.getManufacturerSpecificData(MENTRA_MANUFACTURER_ID)
@@ -221,9 +223,16 @@ class MentraLive : SGCManager() {
221
223
  val advertisement =
222
224
  MentraLivePairingAdvertisementParser.parse(manufacturerData(result))
223
225
  ?: return Triple(false, null, false)
226
+ result.device?.address?.let { secureTrailerScanMemory.noteSecureTrailer(it) }
224
227
  return Triple(advertisement.pairingMode, advertisement.pairingCode, true)
225
228
  }
226
229
 
230
+ /**
231
+ * Addresses advertising the secure-pairing trailer in the current scan; survives SGC
232
+ * recreation and clears at each [startScan].
233
+ */
234
+ private val secureTrailerScanMemory = MentraLiveSecureTrailerScanMemory()
235
+
227
236
  fun isMentraLiveBluetoothName(name: String?): Boolean {
228
237
  if (name.isNullOrEmpty()) {
229
238
  return false
@@ -622,11 +631,41 @@ class MentraLive : SGCManager() {
622
631
  private var isConnecting = false
623
632
  private var isKilled = false
624
633
 
625
- /** Glasses opened a pairing window — stop reconnect without forgetting identity/bonds. */
626
- private var pairingYieldActive = false
627
- private var pairingYieldEndRunnable: Runnable? = null
628
- /** After yield ends, auth/not-owner failures may clear saved glasses. */
629
- private var pairingYieldAwaitingReclaim = false
634
+ /**
635
+ * Ends the pairing-mode pause at its deadline. The pause itself lives in
636
+ * [MentraLivePairingStateStore] so it survives this instance.
637
+ */
638
+ private var pairingPauseEndRunnable: Runnable? = null
639
+ /** `phone_hello` / `pair_select` went out on this GATT session. */
640
+ @Volatile private var phoneHelloSentThisSession = false
641
+ @Volatile private var pairSelectSentThisSession = false
642
+ /** Fails an explicit secure pair if bonding never reaches a terminal state. */
643
+ private var secureBondWaitRunnable: Runnable? = null
644
+ /**
645
+ * glasses_ready arrived during an explicit secure pair but readiness is parked until the
646
+ * glasses report `owner_committed` (and the BLE bond exists). Mirrors iOS
647
+ * `awaitingSecurePairingConfirmation`.
648
+ */
649
+ @Volatile private var awaitingSecurePairingConfirmation = false
650
+ /** The glasses' `pairing_info` reported `owner_committed` on this GATT session. */
651
+ @Volatile private var securePairingConfirmed = false
652
+ /** `secure_pairing_capable` from this GATT session's `pairing_info`; null before one arrives. */
653
+ @Volatile private var sessionPairingInfoSecure: Boolean? = null
654
+ /** `pairing_info` has arrived on this GATT session. CTKD waits for it on an explicit secure pair. */
655
+ private val pairingInfoSeen: Boolean
656
+ get() = sessionPairingInfoSecure != null
657
+ /**
658
+ * glasses_ready arrived during an explicit pair whose target's secure capability is unknown;
659
+ * readiness is parked until `pairing_info` arrives or the unknown-capability wait ends.
660
+ */
661
+ @Volatile private var awaitingPairingInfoForReadiness = false
662
+ /** The unknown-capability wait ran out on this GATT session: the target counts as legacy. */
663
+ @Volatile private var unknownCapabilityWaitExpired = false
664
+ private var unknownCapabilityWaitRunnable: Runnable? = null
665
+ /** Clears `mentra_live_secure_firmware` when a ready session never sends `pairing_info`. */
666
+ private var secureFirmwareRefreshRunnable: Runnable? = null
667
+ /** elapsedRealtime of the last GATT link that was not an explicit pairing attempt. */
668
+ private var lastAutoReconnectLinkAt: Long? = null
630
669
  /** Hold GATT teardown until the unpair write can leave the phone. */
631
670
  private var unpairFlushPending = false
632
671
  private var unpairFlushRunnable: Runnable? = null
@@ -1142,6 +1181,8 @@ class MentraLive : SGCManager() {
1142
1181
  } else {
1143
1182
  Bridge.log("LIVE: 🎵 Phone audio monitor disabled (BLOCK_AUDIO_DUPLEX=false)")
1144
1183
  }
1184
+
1185
+ restorePairingState()
1145
1186
  }
1146
1187
 
1147
1188
  override fun cleanup() {
@@ -1224,8 +1265,18 @@ class MentraLive : SGCManager() {
1224
1265
  DeviceStore.apply("glasses", "connectionState", state)
1225
1266
 
1226
1267
  if (state == ConnTypes.CONNECTED) {
1268
+ // A session that reached ready means the glasses still admit this phone. An explicit
1269
+ // pair already cleared a rejection in DeviceManager.handleDeviceReady.
1270
+ MentraLivePairingStateStore.update("session_ready") {
1271
+ it.onSessionReady(explicitAttempt = false)
1272
+ }
1227
1273
  DeviceStore.apply("glasses", "connected", true)
1228
- if (glassesReadyReceived) {
1274
+ // A ready report parked for the owner commit or for pairing_info must not be
1275
+ // promoted by this path.
1276
+ if (glassesReadyReceived &&
1277
+ !awaitingSecurePairingConfirmation &&
1278
+ !awaitingPairingInfoForReadiness
1279
+ ) {
1229
1280
  DeviceStore.apply("glasses", "fullyBooted", true)
1230
1281
  }
1231
1282
  // Drop cached version fields from the previous BLE session so the next version_info
@@ -1301,10 +1352,8 @@ class MentraLive : SGCManager() {
1301
1352
  /** Starts BLE scanning for Mentra Live glasses */
1302
1353
  private fun startScan() {
1303
1354
  if (postGattLifecycle { startScan() }) return
1304
- if (pairingYieldActive) {
1305
- Bridge.log("LIVE: startScan blocked — pairing yield active")
1306
- return
1307
- }
1355
+ // A pause or rejection still scans: the callback never auto-connects then, and the
1356
+ // saved glasses' advertisement is what ends a pause early.
1308
1357
  if (bluetoothAdapter == null || isScanning) {
1309
1358
  return
1310
1359
  }
@@ -1364,6 +1413,8 @@ class MentraLive : SGCManager() {
1364
1413
  }
1365
1414
 
1366
1415
  val generation = ++scanGeneration
1416
+ // A unit rolled back to old firmware must not keep counting as secure.
1417
+ secureTrailerScanMemory.onScanStart()
1367
1418
  isScanning = true
1368
1419
  scanCallback = createScanCallback(generation)
1369
1420
  bluetoothScanner!!.startScan(filters, settings, scanCallback)
@@ -1404,6 +1455,8 @@ class MentraLive : SGCManager() {
1404
1455
  "LIVE: 🔌 ⏰ Reconnect scan timed out - scheduling next reconnect attempt"
1405
1456
  )
1406
1457
  handleReconnection()
1458
+ } else {
1459
+ startPairingWatchScan()
1407
1460
  }
1408
1461
  }
1409
1462
  }
@@ -1455,7 +1508,7 @@ class MentraLive : SGCManager() {
1455
1508
  * an established owner's connected glasses have no pending name/address and stay hidden.
1456
1509
  */
1457
1510
  private fun emitConnectedPendingDeviceForPairingScan() {
1458
- if (!manualDiscoveryActive || isReconnecting || pairingYieldActive) return
1511
+ if (!manualDiscoveryActive || isReconnecting) return
1459
1512
  val device = connectedDevice ?: bluetoothGatt?.device ?: return
1460
1513
  val name = safeBondedDeviceName(device)
1461
1514
  if (!isMentraLiveBluetoothName(name)) return
@@ -1550,17 +1603,21 @@ class MentraLive : SGCManager() {
1550
1603
  // Connect-by-name only when reconnecting OR connectById() asked for
1551
1604
  // this name. A discovery-only pairing scan must not auto-connect just
1552
1605
  // because savedDeviceName still matches (spoof risk).
1606
+ val isSavedGlasses =
1607
+ savedDeviceName.isNotEmpty() && savedDeviceName == deviceName
1553
1608
  val isNamedConnectTarget =
1554
- savedDeviceName.isNotEmpty() &&
1555
- savedDeviceName == deviceName &&
1556
- (isReconnecting || explicitConnectByName)
1557
- if (!isReconnecting &&
1558
- advertisesPairingFlag(result) &&
1559
- !isPairingDiscoverable(result)
1609
+ isSavedGlasses && (isReconnecting || explicitConnectByName)
1610
+ if (MentraLiveOwnerLossPolicy.shouldExposeAsNonPairable(
1611
+ savedGlasses = isSavedGlasses,
1612
+ reconnectScan = isReconnecting,
1613
+ secureTrailer = advertisesPairingFlag(result),
1614
+ pairingDiscoverable = isPairingDiscoverable(result),
1615
+ )
1560
1616
  ) {
1561
- // Tell RN a nearby unit is advertising but not pairable. The scan
1562
- // screen keeps it visible with pairing-mode guidance but blocks the
1563
- // connection until a pairable advertisement arrives.
1617
+ // Other secure units stay visible with pairing-mode guidance, and
1618
+ // the scan screen blocks the connection until they are pairable.
1619
+ // The saved glasses fall through so the pause watch scan can see
1620
+ // the pairing flag clear and reconnect.
1564
1621
  Bridge.log(
1565
1622
  "LIVE: Nearby $deviceName is secure firmware not in pairing mode — exposing as non-pairable"
1566
1623
  )
@@ -1597,8 +1654,28 @@ class MentraLive : SGCManager() {
1597
1654
  securePairingCapable = secureCapable,
1598
1655
  )
1599
1656
 
1657
+ if (savedDeviceName.isNotEmpty() && savedDeviceName == deviceName) {
1658
+ observeSavedGlassesAdvertisement(result)
1659
+ }
1660
+
1661
+ if (isNamedConnectTarget && automaticConnectBlocked()) {
1662
+ return
1663
+ }
1664
+
1600
1665
  // connectById / reconnect: user (or reconnect) asked for this name —
1601
1666
  // GATT-connect. Discovery-only scans never set explicitConnectByName.
1667
+ // While the saved glasses advertise pairing mode an automatic reconnect
1668
+ // waits instead of racing the new phone for the single BLE slot.
1669
+ if (isNamedConnectTarget &&
1670
+ MentraLiveOwnerLossPolicy.shouldStandDownInsteadOfReconnect(
1671
+ pairingMode = secureCapable && pairingMode,
1672
+ savedGlasses = true,
1673
+ explicitPairing = isExplicitPairingAttempt(),
1674
+ )
1675
+ ) {
1676
+ return
1677
+ }
1678
+
1602
1679
  if (isNamedConnectTarget) {
1603
1680
  Log.i(
1604
1681
  TAG,
@@ -1618,7 +1695,7 @@ class MentraLive : SGCManager() {
1618
1695
  else " (connectById)")
1619
1696
  )
1620
1697
  synchronized(connectionLock) {
1621
- if (pairingYieldActive || isConnected || isConnecting) {
1698
+ if (automaticConnectBlocked() || isConnected || isConnecting) {
1622
1699
  return
1623
1700
  }
1624
1701
  isConnecting = true
@@ -1719,6 +1796,7 @@ class MentraLive : SGCManager() {
1719
1796
  streamControlVersion = 0
1720
1797
  readinessCompletedThisBleSession = false
1721
1798
  glassesReadyReceived = false
1799
+ resetSecurePairingConfirmation()
1722
1800
  ctkdInitiatedThisGattSession = false
1723
1801
  classicAudioServiceDiscovery.cancel()
1724
1802
  audioConnected = false
@@ -1839,8 +1917,10 @@ class MentraLive : SGCManager() {
1839
1917
  if (device == null) {
1840
1918
  return
1841
1919
  }
1842
- if (isKilled || pairingYieldActive) {
1843
- Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
1920
+ if (isKilled || automaticConnectBlocked()) {
1921
+ Bridge.log(
1922
+ "LIVE: connectToDevice blocked — pairing state ${MentraLivePairingStateStore.describe()}"
1923
+ )
1844
1924
  isConnecting = false
1845
1925
  isReconnecting = false
1846
1926
  return
@@ -1849,7 +1929,7 @@ class MentraLive : SGCManager() {
1849
1929
  // Never replace a still-open GATT. Its disconnect/close must finish first.
1850
1930
  bluetoothGatt?.let { beginGattTeardown(it) }
1851
1931
  gattTeardownBarrier.runWhenIdle {
1852
- if (requestEpoch == connectionRequestEpoch && !isKilled && !pairingYieldActive) {
1932
+ if (requestEpoch == connectionRequestEpoch && !isKilled && !automaticConnectBlocked()) {
1853
1933
  connectToDeviceWhenIdle(device)
1854
1934
  }
1855
1935
  }
@@ -1964,87 +2044,253 @@ class MentraLive : SGCManager() {
1964
2044
  // }
1965
2045
  // }
1966
2046
 
1967
- private fun enterPairingYield(windowMs: Long) {
1968
- if (postGattLifecycle { enterPairingYield(windowMs) }) return
2047
+ /**
2048
+ * Automatic connection attempts wait while the glasses run a pairing window for another
2049
+ * phone or after they rejected this phone. An explicit pair (scan screen, Pair Again) is
2050
+ * never blocked.
2051
+ */
2052
+ private fun automaticConnectBlocked(): Boolean =
2053
+ !MentraLivePairingStateStore.allowsAutomaticReconnect && !isExplicitPairingAttempt()
2054
+
2055
+ /** Carry out what a pairing-state input decided. */
2056
+ private fun applyPairingTransition(transition: MentraLivePairingTransition) {
2057
+ when (transition.effect) {
2058
+ MentraLivePairingEffect.NONE -> Unit
2059
+ MentraLivePairingEffect.PAUSE -> enterPairingPause()
2060
+ MentraLivePairingEffect.RESUME -> resumeAfterPairingPause()
2061
+ MentraLivePairingEffect.REJECT -> stopAfterPairingRejected()
2062
+ }
2063
+ }
2064
+
2065
+ /**
2066
+ * The glasses opened a pairing window. Leave the link without forgetting the saved glasses
2067
+ * or any bond, make no automatic connection until the window ends, and keep a passive scan
2068
+ * so a cleared pairing flag can end the pause early.
2069
+ */
2070
+ private fun enterPairingPause() {
2071
+ if (postGattLifecycle { enterPairingPause() }) return
2072
+ if (isKilled) return
2073
+ leaveGlassesLink()
2074
+ armPairingPauseTimer()
2075
+ startPairingWatchScan()
2076
+ }
2077
+
2078
+ /** Pause over (`pairing_mode_ended`, flag cleared, or deadline): reconnect right away. */
2079
+ private fun resumeAfterPairingPause() {
2080
+ if (postGattLifecycle { resumeAfterPairingPause() }) return
2081
+ cancelPairingPauseTimer()
2082
+ if (isKilled || isConnected || isConnecting) return
2083
+ if (isScanning && !isReconnecting && !manualDiscoveryActive) {
2084
+ stopScan()
2085
+ emitStopScanEvent()
2086
+ }
2087
+ reconnectAttempts = 0
2088
+ isReconnecting = false
2089
+ handleReconnection()
2090
+ }
2091
+
2092
+ /**
2093
+ * The glasses belong to another phone. Stop every automatic attempt but keep the saved
2094
+ * glasses and bonds; only an explicit pair or a forget clears this (see
2095
+ * [MentraLivePairingStateStore]).
2096
+ */
2097
+ private fun stopAfterPairingRejected() {
2098
+ if (postGattLifecycle { stopAfterPairingRejected() }) return
2099
+ cancelPairingPauseTimer()
2100
+ if (isKilled) return
2101
+ if (isExplicitPairingAttempt()) return
2102
+ leaveGlassesLink()
2103
+ }
2104
+
2105
+ /** Drop the link and every pending automatic attempt. Bonds and saved identity stay. */
2106
+ private fun leaveGlassesLink() {
1969
2107
  connectionRequestEpoch++
1970
- pairingYieldActive = true
1971
- pairingYieldAwaitingReclaim = false
2108
+ // Invalidate delayed reconnect callbacks already on the handler.
2109
+ reconnectGeneration++
1972
2110
  isReconnecting = false
1973
2111
  isConnecting = false
1974
- pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
2112
+ cancelSecureBondWait()
1975
2113
  if (connectionTimeoutRunnable != null) {
1976
2114
  connectionTimeoutHandler.removeCallbacks(connectionTimeoutRunnable!!)
1977
2115
  connectionTimeoutRunnable = null
1978
2116
  }
1979
- // Stop scans/reconnect work; keep default_wearable / Classic bond.
1980
- if (isScanning) {
2117
+ if (isScanning && !manualDiscoveryActive) {
1981
2118
  stopScan()
1982
2119
  emitStopScanEvent()
1983
2120
  }
1984
- // Drop A2DP/HFP (keep the Classic bond) so glasses can advertise for reclaim.
1985
- // Do this before nulling connectedDevice — disconnectClassicProfiles needs it.
1986
- val yieldingDevice = connectedDevice
1987
- disconnectClassicProfiles(yieldingDevice)
1988
- // closeGattQuietly nulls bluetoothGatt before the disconnect callback, so
1989
- // onConnectionStateChange treats it as stale and never publishes DISCONNECTED.
1990
- // Mirror a real disconnect for UI + session state, then tear GATT down.
1991
- isConnected = false
1992
- connectedDevice = null
1993
- glassesReady = false
1994
- glassesSessionId = null
1995
- streamControlVersion = 0
1996
- readinessCompletedThisBleSession = false
1997
- glassesReadyReceived = false
1998
- ctkdInitiatedThisGattSession = false
1999
- classicAudioServiceDiscovery.cancel()
2121
+ // Drop A2DP/HFP (keep the Classic bond) so the glasses can serve the new phone.
2122
+ // Capture the device first: GATT teardown nulls connectedDevice.
2123
+ val leavingDevice = connectedDevice
2124
+ val hadLink = bluetoothGatt != null || isConnected
2125
+ if (leavingDevice != null) disconnectClassicProfiles(leavingDevice)
2000
2126
  audioConnected = false
2001
- notificationsEnabled = false
2002
- processSendQueueRunnable?.let { handler.removeCallbacks(it) }
2003
- stopReadinessCheckLoop()
2004
- stopHeartbeat()
2005
- stopSignalStrengthPolling()
2006
- stopMicBeat()
2007
- closeL2capFileChannel()
2008
- fileProcessingHandler.removeCallbacksAndMessages(null)
2009
- clearFilePacketBuffer()
2010
- updateConnectionState(ConnTypes.DISCONNECTED)
2011
- closeGattQuietly(true)
2012
- // Stand down for the whole window. Probing GATT during yield races the new
2013
- // phone for BLE_CONNECTION_MAX=1 and is exactly what entering_pairing_mode forbids.
2014
- val end =
2015
- Runnable {
2016
- Bridge.log("LIVE: Pairing yield ended — resume reconnect if still owned")
2017
- pairingYieldActive = false
2018
- pairingYieldAwaitingReclaim = true
2019
- pairingYieldEndRunnable = null
2020
- if (!isKilled && !isConnected && !isConnecting) {
2021
- handleReconnection()
2127
+ if (hadLink) {
2128
+ // beginGattTeardown resets the session state and publishes DISCONNECTED.
2129
+ closeGattQuietly(true)
2130
+ updateConnectionState(ConnTypes.DISCONNECTED)
2131
+ }
2132
+ }
2133
+
2134
+ private fun armPairingPauseTimer() {
2135
+ cancelPairingPauseTimer()
2136
+ val remaining = MentraLivePairingStateStore.remainingPauseMs()
2137
+ if (remaining <= 0L) return
2138
+ val end = Runnable {
2139
+ pairingPauseEndRunnable = null
2140
+ applyPairingTransition(
2141
+ MentraLivePairingStateStore.update("pause_deadline") {
2142
+ it.onTick(System.currentTimeMillis())
2022
2143
  }
2023
- }
2024
- pairingYieldEndRunnable = end
2025
- handler.postDelayed(end, windowMs)
2144
+ )
2145
+ }
2146
+ pairingPauseEndRunnable = end
2147
+ handler.postDelayed(end, remaining + 50L)
2026
2148
  }
2027
2149
 
2028
- fun isPairingYieldActive(): Boolean = pairingYieldActive
2150
+ private fun cancelPairingPauseTimer() {
2151
+ pairingPauseEndRunnable?.let { handler.removeCallbacks(it) }
2152
+ pairingPauseEndRunnable = null
2153
+ }
2029
2154
 
2030
- private fun clearSavedGlassesAfterOwnerLoss(reason: String) {
2031
- Bridge.log("LIVE: Owner loss ($reason) — clearing saved glasses (not generic GATT)")
2032
- pairingYieldAwaitingReclaim = false
2033
- pairingYieldActive = false
2034
- pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
2035
- pairingYieldEndRunnable = null
2155
+ /**
2156
+ * Observation-only scan during a pause: the scan callback never connects while paused, it
2157
+ * only reads the saved glasses' advertisement. Restarted when the default scan times out.
2158
+ */
2159
+ private fun startPairingWatchScan() {
2160
+ if (isKilled || !MentraLivePairingStateStore.isPaused) return
2036
2161
  try {
2037
- forget()
2038
- } catch (e: Exception) {
2039
- Bridge.log("LIVE: forget after owner loss failed: ${e.message}")
2162
+ if (!isScanning) startScan()
2163
+ } catch (e: SecurityException) {
2164
+ Bridge.log("LIVE: pairing watch scan blocked — ${e.message}")
2040
2165
  }
2041
- DeviceStore.apply("bluetooth", "default_wearable", "")
2042
- DeviceStore.apply("bluetooth", "device_name", "")
2043
- DeviceStore.apply("bluetooth", "device_address", "")
2044
- Bridge.saveSetting("default_wearable", "")
2045
- Bridge.saveSetting("device_name", "")
2046
- Bridge.saveSetting("device_address", "")
2047
- Bridge.sendTypedMessage("owner_replaced", mapOf("reason" to reason))
2166
+ }
2167
+
2168
+ /** The saved glasses' secure trailer feeds the pause and takeover checks. */
2169
+ private fun observeSavedGlassesAdvertisement(result: ScanResult) {
2170
+ val advertisement =
2171
+ MentraLivePairingAdvertisementParser.parse(manufacturerData(result)) ?: return
2172
+ applyPairingTransition(
2173
+ MentraLivePairingStateStore.update("advertisement") {
2174
+ it.onAdvertisement(
2175
+ advertisement.pairingMode,
2176
+ advertisement.ownerChanged,
2177
+ advertisement.pairingGeneration,
2178
+ System.currentTimeMillis(),
2179
+ )
2180
+ }
2181
+ )
2182
+ }
2183
+
2184
+ /**
2185
+ * Pairing-protocol messages for this GATT session: `phone_hello` so v4 glasses send window
2186
+ * events to this phone, and `pair_select` only when the user chose these glasses.
2187
+ */
2188
+ private fun sendPairingSessionMessages(reason: String) {
2189
+ if (isKilled || !isConnected) return
2190
+ // The scan's trailer is a hint for speaking the protocol at all; pair_select below uses
2191
+ // only the selected target's capability.
2192
+ val secure = isSecurePairingTarget() || isSavedGlassesSecureFirmware() ||
2193
+ pairingInfoSeen || secureTrailerScanMemory.contains(connectedDevice?.address)
2194
+ if (!secure) return
2195
+ if (!phoneHelloSentThisSession) {
2196
+ phoneHelloSentThisSession = true
2197
+ val hello = JSONObject()
2198
+ hello.put("type", "phone_hello")
2199
+ hello.put("pairing_protocol", PAIRING_PROTOCOL_VERSION)
2200
+ Bridge.log("PAIRFLOW: phone_hello sent ($reason)")
2201
+ sendJsonWithoutAck(hello)
2202
+ }
2203
+ if (!pairSelectSentThisSession &&
2204
+ MentraLiveOwnerLossPolicy.shouldSendPairSelect(
2205
+ isExplicitPairingAttempt(),
2206
+ isSecurePairingTarget(),
2207
+ )
2208
+ ) {
2209
+ pairSelectSentThisSession = true
2210
+ val select = JSONObject()
2211
+ select.put("type", "pair_select")
2212
+ Bridge.log("PAIRFLOW: pair_select sent ($reason)")
2213
+ sendJsonWithoutAck(select)
2214
+ }
2215
+ }
2216
+
2217
+ /** The selected scan row's secure capability; null when the row did not know it. */
2218
+ private fun selectedTargetSecureCapability(): Boolean? =
2219
+ DeviceStore.get("bluetooth", "pending_device_secure_pairing_capable") as? Boolean
2220
+
2221
+ /**
2222
+ * The explicit pairing target runs secure-pairing firmware. Only the selected scan row's
2223
+ * capability counts, never an address seen in an earlier scan; when the row did not know it,
2224
+ * this session's `pairing_info` decides.
2225
+ */
2226
+ private fun isSecurePairingTarget(): Boolean =
2227
+ MentraLivePairingReadinessPolicy.isSecurePairingTarget(
2228
+ selectedTargetSecureCapability(),
2229
+ sessionPairingInfoSecure,
2230
+ )
2231
+
2232
+ /** When glasses_ready may report this session ready (see [MentraLivePairingReadinessPolicy]). */
2233
+ private fun pairingReadiness(device: BluetoothDevice?): MentraLivePairingReadinessPolicy.Readiness =
2234
+ MentraLivePairingReadinessPolicy.readiness(
2235
+ explicitPairing = isExplicitPairingAttempt(),
2236
+ selectedCapability = selectedTargetSecureCapability(),
2237
+ sessionPairingInfoSecure = sessionPairingInfoSecure,
2238
+ unknownCapabilityWaitExpired = unknownCapabilityWaitExpired,
2239
+ bondState = device?.bondState,
2240
+ ownerCommitted = securePairingConfirmed,
2241
+ )
2242
+
2243
+ /** The user is pairing glasses from the scan screen rather than reconnecting a saved owner. */
2244
+ private fun isExplicitPairingAttempt(): Boolean {
2245
+ val pendingName = DeviceStore.get("bluetooth", "pending_device_name") as? String ?: ""
2246
+ val pendingAddress = DeviceStore.get("bluetooth", "pending_device_address") as? String ?: ""
2247
+ return manualDiscoveryActive || pendingName.isNotEmpty() || pendingAddress.isNotEmpty()
2248
+ }
2249
+
2250
+ /**
2251
+ * The saved glasses run secure-pairing firmware, learned from their last `pairing_info`.
2252
+ * Legacy firmware never rejects a non-owner on purpose, so its failures are not owner loss.
2253
+ */
2254
+ private fun isSavedGlassesSecureFirmware(): Boolean =
2255
+ DeviceStore.get("bluetooth", "mentra_live_secure_firmware") as? Boolean == true
2256
+
2257
+ /** Remember what the connected glasses reported so later rejections can be read correctly. */
2258
+ private fun rememberSecureFirmware(secure: Boolean) {
2259
+ if (isSavedGlassesSecureFirmware() == secure) return
2260
+ DeviceStore.apply("bluetooth", "mentra_live_secure_firmware", secure)
2261
+ Bridge.saveSetting("mentra_live_secure_firmware", secure)
2262
+ }
2263
+
2264
+ /**
2265
+ * One authentication rejection (HCI 0x05/0x0F or a bond the glasses dropped). The machine
2266
+ * ignores it during a pause, while the glasses advertise pairing mode, and on legacy
2267
+ * firmware; it rejects after the `OWNER_CHANGED` verification fails or the streak fills.
2268
+ * @return true when this phone is now rejected
2269
+ */
2270
+ private fun recordAuthRejection(reason: String): Boolean {
2271
+ val transition =
2272
+ MentraLivePairingStateStore.update("auth_rejection:$reason") {
2273
+ it.onAuthRejection(
2274
+ isSavedGlassesSecureFirmware(),
2275
+ isExplicitPairingAttempt(),
2276
+ System.currentTimeMillis(),
2277
+ )
2278
+ }
2279
+ Bridge.log("LIVE: Auth rejection ($reason) — ${transition.note}")
2280
+ applyPairingTransition(transition)
2281
+ return transition.effect == MentraLivePairingEffect.REJECT
2282
+ }
2283
+
2284
+ /** Restore the pairing record once per process and act on it. */
2285
+ private fun restorePairingState() {
2286
+ val transition = MentraLivePairingStateStore.ensureRestored(savedDeviceName) ?: run {
2287
+ if (MentraLivePairingStateStore.isPaused) {
2288
+ armPairingPauseTimer()
2289
+ startPairingWatchScan()
2290
+ }
2291
+ return
2292
+ }
2293
+ applyPairingTransition(transition)
2048
2294
  }
2049
2295
 
2050
2296
  /** Handle reconnection with exponential backoff */
@@ -2065,9 +2311,12 @@ class MentraLive : SGCManager() {
2065
2311
  return
2066
2312
  }
2067
2313
 
2068
- if (pairingYieldActive) {
2069
- Bridge.log("LIVE: 🔌 RECONNECT ABORTED - pairing yield active (glasses in pairing mode)")
2314
+ if (!MentraLivePairingStateStore.allowsAutomaticReconnect) {
2315
+ Bridge.log(
2316
+ "LIVE: 🔌 RECONNECT ABORTED - pairing state ${MentraLivePairingStateStore.describe()}"
2317
+ )
2070
2318
  isReconnecting = false
2319
+ if (MentraLivePairingStateStore.isPaused) startPairingWatchScan()
2071
2320
  return
2072
2321
  }
2073
2322
 
@@ -2154,20 +2403,23 @@ class MentraLive : SGCManager() {
2154
2403
  )
2155
2404
  return
2156
2405
  }
2157
- if (pairingYieldActive) {
2406
+ if (!MentraLivePairingStateStore.allowsAutomaticReconnect) {
2158
2407
  isReconnecting = false
2159
2408
  Bridge.log(
2160
- "LIVE: 🔌 RECONNECT ABORTED - pairing yield active (glasses in pairing mode)"
2409
+ "LIVE: 🔌 RECONNECT ABORTED - pairing state ${MentraLivePairingStateStore.describe()}"
2161
2410
  )
2162
2411
  return
2163
2412
  }
2164
2413
  if (!isConnected && !isConnecting && !isKilled) {
2165
2414
  // Prefer saved MAC for direct GATT connect (faster and more reliable
2166
2415
  // than scanning).
2167
- // Falls back to name-based scan if no address is saved.
2416
+ // Falls back to name-based scan if no address is saved. Right after a
2417
+ // pairing window the scan comes first so the advertisement's
2418
+ // OWNER_CHANGED bit is read before the first attempt.
2168
2419
  val lastDeviceAddress =
2169
2420
  selectedConnectionAddress()
2170
2421
  if (lastDeviceAddress != null &&
2422
+ !MentraLivePairingStateStore.awaitingTakeoverCheck &&
2171
2423
  !lastDeviceAddress.isEmpty() &&
2172
2424
  bluetoothAdapter != null
2173
2425
  ) {
@@ -2328,6 +2580,9 @@ class MentraLive : SGCManager() {
2328
2580
  "LIVE: 🔌 🔗 BLE GATT link connected - validating services/characteristics..."
2329
2581
  )
2330
2582
  markPairingTiming("gatt_connected")
2583
+ lastAutoReconnectLinkAt =
2584
+ if (isExplicitPairingAttempt()) null
2585
+ else SystemClock.elapsedRealtime()
2331
2586
  isConnecting = false
2332
2587
  isConnected = true
2333
2588
  connectedDevice = gatt.device
@@ -2365,6 +2620,8 @@ class MentraLive : SGCManager() {
2365
2620
  bondingRetryCount = 0
2366
2621
  ctkdInitiatedThisGattSession = false
2367
2622
  classicAudioServiceDiscovery.cancel()
2623
+ // A new GATT link has not heard the glasses' owner commit yet.
2624
+ resetSecurePairingConfirmation()
2368
2625
  Bridge.log(
2369
2626
  "LIVE: CTKD: BLE GATT up - deferring Classic/A2DP until notifications ready"
2370
2627
  )
@@ -2412,6 +2669,7 @@ class MentraLive : SGCManager() {
2412
2669
 
2413
2670
  // Reset audio pairing flags
2414
2671
  glassesReadyReceived = false
2672
+ resetSecurePairingConfirmation()
2415
2673
  ctkdInitiatedThisGattSession = false
2416
2674
  classicAudioServiceDiscovery.cancel()
2417
2675
  audioConnected = false
@@ -2474,6 +2732,7 @@ class MentraLive : SGCManager() {
2474
2732
  streamControlVersion = 0
2475
2733
  readinessCompletedThisBleSession = false
2476
2734
  glassesReadyReceived = false
2735
+ resetSecurePairingConfirmation()
2477
2736
  audioConnected = false
2478
2737
  ctkdInitiatedThisGattSession = false
2479
2738
  classicAudioServiceDiscovery.cancel()
@@ -2500,17 +2759,22 @@ class MentraLive : SGCManager() {
2500
2759
  // Clean up resources
2501
2760
  closeGattQuietly(false)
2502
2761
 
2503
- // Auth/not-owner after pairing yield — not a generic RF blip.
2504
- // GATT 8 / 0x08 is connection timeout; a reclaim RF timeout must
2505
- // not wipe the saved owner.
2506
- if (pairingYieldAwaitingReclaim &&
2507
- (status == 5 ||
2508
- status == 15 ||
2509
- status == 0x05 ||
2510
- status == 0x0F)
2762
+ // Secure glasses reject a non-owner at connect time with HCI
2763
+ // Authentication Failure. One rejection can also be a glasses reboot or
2764
+ // a stale bond, so the pairing state machine decides: one failed
2765
+ // OWNER_CHANGED verification, or a repeating streak. GATT 8 (timeout),
2766
+ // 19 (remote terminated) and the window's 0x14 refusal never count.
2767
+ if (MentraLiveOwnerLossPolicy.isOwnerRejectionOnGattError(
2768
+ status,
2769
+ isExplicitPairingAttempt(),
2770
+ isSavedGlassesSecureFirmware(),
2771
+ )
2511
2772
  ) {
2512
- clearSavedGlassesAfterOwnerLoss("gatt_auth_status_$status")
2513
- return
2773
+ if (recordAuthRejection("gatt_auth_status_$status")) return
2774
+ } else {
2775
+ MentraLivePairingStateStore.update("connect_failed_$status") {
2776
+ it.onConnectAttemptFailed()
2777
+ }
2514
2778
  }
2515
2779
 
2516
2780
  // Attempt reconnection if not killed
@@ -2975,6 +3239,7 @@ class MentraLive : SGCManager() {
2975
3239
  Bridge.log("LIVE: Starting send queue and readiness check loop")
2976
3240
  startSignalStrengthPolling()
2977
3241
  handler.post(processSendQueueRunnable!!)
3242
+ sendPairingSessionMessages("gatt_ready")
2978
3243
  startReadinessCheckLoop()
2979
3244
  // Classic/A2DP after BLE is armed — avoids GATT 19 when dual-mode stacks
2980
3245
  // collide during service discovery / descriptor writes.
@@ -4170,23 +4435,93 @@ class MentraLive : SGCManager() {
4170
4435
  // Process heartbeat pong response
4171
4436
  Bridge.log("LIVE: Received pong response - connection healthy")
4172
4437
  "entering_pairing_mode" -> {
4173
- val windowMs = json.optLong("window_ms", 120_000L).coerceIn(5_000L, 180_000L)
4174
- Bridge.log("LIVE: Glasses entering pairing mode — yield ${windowMs}ms (no forget)")
4175
- enterPairingYield(windowMs)
4438
+ val ownerRetained = json.optBoolean("owner_retained", false)
4439
+ val windowMs =
4440
+ json.optLong("window_ms", MentraLiveOwnerLossPolicy.DEFAULT_PAIRING_WINDOW_MS)
4441
+ val windowId = if (json.has("window_id")) json.optInt("window_id") else null
4442
+ val transition =
4443
+ MentraLivePairingStateStore.update("entering_pairing_mode") {
4444
+ it.onEnteringPairingMode(
4445
+ windowMs = windowMs,
4446
+ windowId = windowId,
4447
+ ownerRetained = ownerRetained,
4448
+ explicitAttempt = isExplicitPairingAttempt(),
4449
+ deviceId = savedDeviceName,
4450
+ nowMs = System.currentTimeMillis(),
4451
+ )
4452
+ }
4453
+ Bridge.log(
4454
+ "PAIRFLOW: entering_pairing_mode window_id=${windowId ?: ""} " +
4455
+ "window_ms=$windowMs owner_retained=$ownerRetained " +
4456
+ "effect=${transition.effect} (${transition.note})"
4457
+ )
4176
4458
  val body = HashMap<String, Any>()
4177
4459
  body["window_ms"] = windowMs
4460
+ if (windowId != null) body["window_id"] = windowId
4178
4461
  body["reason"] = json.optString("reason", "user_gesture")
4462
+ body["owner_retained"] = ownerRetained
4179
4463
  Bridge.sendTypedMessage("entering_pairing_mode", body)
4180
- }
4181
- "pairing_info" ->
4182
- Bridge.sendPairingInfo(
4183
- json.optBoolean("had_previous_bond", false),
4184
- jsonNullableString(json, "pairing_code"),
4185
- json.optBoolean("classic_bond_ready", false),
4186
- // Legacy firmware that omits this field is not secure-capable.
4187
- json.optBoolean("secure_pairing_capable", false),
4188
- json.optInt("protocol_version", 1),
4464
+ applyPairingTransition(transition)
4465
+ }
4466
+ "pairing_mode_ended" -> {
4467
+ val reason = jsonNullableString(json, "reason")
4468
+ val windowId = if (json.has("window_id")) json.optInt("window_id") else null
4469
+ val transition =
4470
+ MentraLivePairingStateStore.update("pairing_mode_ended") {
4471
+ it.onPairingModeEnded(reason, windowId)
4472
+ }
4473
+ Bridge.log(
4474
+ "PAIRFLOW: pairing_mode_ended reason=${reason ?: ""} window_id=${windowId ?: ""} " +
4475
+ "effect=${transition.effect} (${transition.note})"
4476
+ )
4477
+ val body = HashMap<String, Any>()
4478
+ body["reason"] = reason ?: ""
4479
+ if (windowId != null) body["window_id"] = windowId
4480
+ Bridge.sendTypedMessage("pairing_mode_ended", body)
4481
+ applyPairingTransition(transition)
4482
+ }
4483
+ "pairing_info" -> {
4484
+ // Legacy firmware that omits this field is not secure-capable.
4485
+ val secureCapable = json.optBoolean("secure_pairing_capable", false)
4486
+ rememberSecureFirmware(secureCapable)
4487
+ cancelSecureFirmwareRefresh()
4488
+ // Decides the capability of a pairing target the scan row did not know.
4489
+ sessionPairingInfoSecure = secureCapable
4490
+ // Ownership is the separate owner_committed bit, set after the BLE bond.
4491
+ val ownerCommitted = json.optBoolean("owner_committed", false)
4492
+ if (MentraLiveOwnerLossPolicy.pairingInfoConfirmsOwner(ownerCommitted)) {
4493
+ securePairingConfirmed = true
4494
+ if (awaitingSecurePairingConfirmation) {
4495
+ Bridge.log("LIVE: pairing_info owner_committed — finishing parked readiness")
4496
+ completeSecurePairingReadiness("pairing_info_owner_committed")
4497
+ }
4498
+ }
4499
+ if (secureCapable) sendPairingSessionMessages("pairing_info")
4500
+ if (awaitingPairingInfoForReadiness) {
4501
+ Bridge.log(
4502
+ "LIVE: pairing_info secure_pairing_capable=$secureCapable for a " +
4503
+ "pairing target of unknown capability"
4189
4504
  )
4505
+ resolveUnknownCapabilityReadiness("pairing_info")
4506
+ }
4507
+ maybeInitiateCtkdAudioAfterBleReady()
4508
+ Bridge.sendPairingInfo(
4509
+ json.optBoolean("had_previous_bond", false),
4510
+ jsonNullableString(json, "pairing_code"),
4511
+ json.optBoolean("classic_bond_ready", false),
4512
+ secureCapable,
4513
+ json.optInt("protocol_version", 1),
4514
+ json.optBoolean("ownership_transfer", false),
4515
+ ownerCommitted,
4516
+ json.optBoolean("auto_audio", false),
4517
+ )
4518
+ }
4519
+ "classic_link" -> {
4520
+ // Android attaches Classic audio from the bond, so audioSetup stays
4521
+ // not_applicable here; the report only matters to iOS.
4522
+ val link = MentraLiveClassicLink.parse(json)
4523
+ Log.d(TAG, "LIVE: classic_link a2dp=${link.a2dp} hfp=${link.hfp}")
4524
+ }
4190
4525
  "imu_response",
4191
4526
  "imu_stream_response",
4192
4527
  "imu_gesture_response",
@@ -4614,15 +4949,11 @@ class MentraLive : SGCManager() {
4614
4949
  }
4615
4950
  "glasses_ready" -> {
4616
4951
  // Glasses SOC has booted and is ready for communication
4617
- if (pairingYieldActive && !isConnected) {
4618
- Bridge.log("LIVE: Ignoring stale glasses_ready during pairing yield")
4952
+ if (!isConnected) {
4953
+ Bridge.log("LIVE: Ignoring stale glasses_ready without a link")
4619
4954
  return
4620
4955
  }
4621
4956
  Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
4622
- pairingYieldAwaitingReclaim = false
4623
- pairingYieldActive = false
4624
- pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
4625
- pairingYieldEndRunnable = null
4626
4957
  markPairingTiming(
4627
4958
  "glasses_ready",
4628
4959
  "audioConnected=$audioConnected queueSize=${sendQueue.size}"
@@ -4654,6 +4985,7 @@ class MentraLive : SGCManager() {
4654
4985
  // Set the ready flag to stop any future readiness checks
4655
4986
  glassesReady = true
4656
4987
  glassesReadyReceived = true
4988
+ armSecureFirmwareRefresh()
4657
4989
  // Match iOS MentraLive.handleGlassesReady: BLE/SOC ready is enough for
4658
4990
  // fullyBooted + CONNECTED. CTKD Classic bonding continues in the background
4659
4991
  // for SCO/A2DP and must not gate the pairing success UI (~9s on some phones).
@@ -4776,8 +5108,42 @@ class MentraLive : SGCManager() {
4776
5108
  Bridge.log("LIVE: ⚠️ glasses_ready: mic restore threw: " + t)
4777
5109
  }
4778
5110
 
4779
- // Pairing UI / DeviceManager.handleDeviceReady gate on fullyBooted.
4780
- // Do not wait for CTKD here — iOS already marks ready on glasses_ready alone.
5111
+ // Pairing UI / DeviceManager.handleDeviceReady gate on fullyBooted, and
5112
+ // handleDeviceReady promotes the pairing target to the default device.
5113
+ // Classic CTKD is not awaited, but secure glasses own a phone only once the
5114
+ // BLE bond exists; the BOND_BONDED handler finishes readiness. A target whose
5115
+ // capability the scan row did not know first waits, bounded, for pairing_info.
5116
+ when (pairingReadiness(connectedDevice)) {
5117
+ MentraLivePairingReadinessPolicy.Readiness.AWAIT_PAIRING_INFO -> {
5118
+ Bridge.log(
5119
+ "LIVE: glasses_ready for a pairing target of unknown capability — " +
5120
+ "waiting up to " +
5121
+ "${MentraLivePairingReadinessPolicy.UNKNOWN_CAPABILITY_PAIRING_INFO_WAIT_MS}ms " +
5122
+ "for pairing_info"
5123
+ )
5124
+ markPairingTiming("waiting_pairing_info", "queueSize=${sendQueue.size}")
5125
+ awaitingPairingInfoForReadiness = true
5126
+ armUnknownCapabilityPairingInfoWait()
5127
+ return
5128
+ }
5129
+ MentraLivePairingReadinessPolicy.Readiness.AWAIT_OWNER_COMMIT -> {
5130
+ Bridge.log(
5131
+ "LIVE: glasses_ready before BLE bond / owner commit — waiting " +
5132
+ "(bond=${connectedDevice?.bondState} ownerCommitted=$securePairingConfirmed)"
5133
+ )
5134
+ markPairingTiming(
5135
+ "waiting_ble_bond",
5136
+ "queueSize=${sendQueue.size} ownerCommitted=$securePairingConfirmed"
5137
+ )
5138
+ awaitingSecurePairingConfirmation = true
5139
+ armSecureBondWait()
5140
+ return
5141
+ }
5142
+ MentraLivePairingReadinessPolicy.Readiness.READY -> Unit
5143
+ }
5144
+ awaitingSecurePairingConfirmation = false
5145
+ awaitingPairingInfoForReadiness = false
5146
+ cancelUnknownCapabilityPairingInfoWait()
4781
5147
  endPairingTiming(
4782
5148
  "fully_connected",
4783
5149
  "via=glasses_ready audioConnected=$audioConnected queueSize=${sendQueue.size}"
@@ -5655,6 +6021,7 @@ class MentraLive : SGCManager() {
5655
6021
  updateConnectionState(ConnTypes.DISCONNECTED)
5656
6022
  glassesReady = false
5657
6023
  glassesReadyReceived = false
6024
+ resetSecurePairingConfirmation()
5658
6025
  glassesSessionId = null
5659
6026
  streamControlVersion = 0
5660
6027
  readinessCompletedThisBleSession = false
@@ -6580,8 +6947,19 @@ class MentraLive : SGCManager() {
6580
6947
 
6581
6948
  override fun connectById(id: String) {
6582
6949
  if (postGattLifecycle { connectById(id) }) return
6583
- if (pairingYieldActive) {
6584
- Bridge.log("LIVE: connectById blocked — pairing yield active")
6950
+ if (!isExplicitPairingAttempt()) {
6951
+ // An automatic connect names the saved glasses; a record for other glasses ends.
6952
+ MentraLivePairingStateStore.update("connect_by_id") { it.bindDevice(id) }
6953
+ }
6954
+ if (automaticConnectBlocked()) {
6955
+ Bridge.log(
6956
+ "LIVE: connectById deferred — pairing state ${MentraLivePairingStateStore.describe()}"
6957
+ )
6958
+ savedDeviceName = id
6959
+ if (MentraLivePairingStateStore.isPaused) {
6960
+ armPairingPauseTimer()
6961
+ startPairingWatchScan()
6962
+ }
6585
6963
  return
6586
6964
  }
6587
6965
  reconnectGeneration++
@@ -6705,8 +7083,10 @@ class MentraLive : SGCManager() {
6705
7083
 
6706
7084
  fun connectToSmartGlasses() {
6707
7085
  if (postGattLifecycle { connectToSmartGlasses() }) return
6708
- if (pairingYieldActive) {
6709
- Bridge.log("LIVE: connectToSmartGlasses blocked — pairing yield active")
7086
+ if (automaticConnectBlocked()) {
7087
+ Bridge.log(
7088
+ "LIVE: connectToSmartGlasses blocked — pairing state ${MentraLivePairingStateStore.describe()}"
7089
+ )
6710
7090
  return
6711
7091
  }
6712
7092
  Bridge.log("LIVE: Connecting to Mentra Live glasses")
@@ -7287,6 +7667,12 @@ class MentraLive : SGCManager() {
7287
7667
  }
7288
7668
  bondingRetryCount =
7289
7669
  0 // Reset retry counter on success
7670
+ // During an explicit secure pair the bond alone is
7671
+ // not ownership: keep the failure timeout armed
7672
+ // until pairing_info reports owner_committed.
7673
+ if (!shouldGateReadyOnOwnerCommit(device)) {
7674
+ cancelSecureBondWait()
7675
+ }
7290
7676
  // Bond != audio profile. Explicitly open A2DP
7291
7677
  // after createBond() completes (already-bonded
7292
7678
  // path does this at GATT connect).
@@ -7303,10 +7689,34 @@ class MentraLive : SGCManager() {
7303
7689
  "fullyBooted"
7304
7690
  ) as? Boolean
7305
7691
  ?: false
7306
- if (!alreadyBooted) {
7692
+ if (!alreadyBooted &&
7693
+ awaitingPairingInfoForReadiness
7694
+ ) {
7695
+ // Unknown capability: pairing_info or
7696
+ // its bounded wait decides readiness.
7697
+ Bridge.log(
7698
+ "LIVE: CTKD bonded; readiness still waiting for pairing_info"
7699
+ )
7700
+ } else if (!alreadyBooted &&
7701
+ shouldGateReadyOnOwnerCommit(device)
7702
+ ) {
7703
+ // Bonded, but the glasses have not
7704
+ // reported owner_committed. pairing_info
7705
+ // finishes readiness (or the wait fails).
7706
+ Bridge.log(
7707
+ "LIVE: CTKD bonded; glasses_ready received but owner not committed yet — waiting for pairing_info"
7708
+ )
7709
+ markPairingTiming(
7710
+ "waiting_owner_commit",
7711
+ "queueSize=${sendQueue.size}"
7712
+ )
7713
+ awaitingSecurePairingConfirmation = true
7714
+ armSecureBondWait()
7715
+ } else if (!alreadyBooted) {
7307
7716
  Bridge.log(
7308
7717
  "LIVE: Audio: CTKD bonded; catching up fullyBooted (glasses_ready already received)"
7309
7718
  )
7719
+ awaitingSecurePairingConfirmation = false
7310
7720
  endPairingTiming(
7311
7721
  "fully_connected",
7312
7722
  "via=ctkd_bond+glasses_ready queueSize=${sendQueue.size}"
@@ -7333,6 +7743,22 @@ class MentraLive : SGCManager() {
7333
7743
  Bridge.log(
7334
7744
  "LIVE: CTKD: ❌ Bonding failed or removed for device"
7335
7745
  )
7746
+ val sinceReconnect =
7747
+ lastAutoReconnectLinkAt?.let {
7748
+ SystemClock.elapsedRealtime() - it
7749
+ }
7750
+ if (MentraLiveOwnerLossPolicy.isOwnerRejectionOnBondRemoved(
7751
+ previousBondState,
7752
+ bondState,
7753
+ isKilled,
7754
+ unpairFlushPending,
7755
+ isExplicitPairingAttempt(),
7756
+ sinceReconnect,
7757
+ isSavedGlassesSecureFirmware(),
7758
+ ) && recordAuthRejection("bond_removed_by_glasses")
7759
+ ) {
7760
+ return
7761
+ }
7336
7762
  markPairingTiming(
7337
7763
  "ctkd_bond_none",
7338
7764
  "prev=$previousBondState queueSize=${sendQueue.size}"
@@ -7348,7 +7774,20 @@ class MentraLive : SGCManager() {
7348
7774
  classicAudioConnectionTracker.clear(device.address)
7349
7775
  }
7350
7776
  audioConnected = false
7351
- if (previousBondState == BluetoothDevice.BOND_BONDING) {
7777
+ if (previousBondState == BluetoothDevice.BOND_BONDING &&
7778
+ isExplicitPairingAttempt() &&
7779
+ isSecurePairingTarget()
7780
+ ) {
7781
+ // Secure glasses own a phone only through this bond.
7782
+ // Re-prompting after a decline only repeats the
7783
+ // system dialog; let the pairing screen offer Try again.
7784
+ Bridge.log(
7785
+ "LIVE: CTKD: secure pairing bond not completed — reporting pairing failure"
7786
+ )
7787
+ bondingRetryCount = 0
7788
+ cancelSecureBondWait()
7789
+ Bridge.sendPairFailureEvent("errors:pairingCouldNotStart")
7790
+ } else if (previousBondState == BluetoothDevice.BOND_BONDING) {
7352
7791
  // User cancelled or bonding failed - retry up to
7353
7792
  // MAX_BONDING_RETRIES times
7354
7793
  bondingRetryCount++
@@ -7586,6 +8025,16 @@ class MentraLive : SGCManager() {
7586
8025
  if (ctkdInitiatedThisGattSession) {
7587
8026
  return
7588
8027
  }
8028
+ if (MentraLiveOwnerLossPolicy.shouldDeferCtkdBond(
8029
+ isExplicitPairingAttempt(),
8030
+ isSecurePairingTarget(),
8031
+ device.bondState,
8032
+ pairingInfoSeen,
8033
+ )
8034
+ ) {
8035
+ Bridge.log("LIVE: CTKD: deferring bond until pairing_info allows it")
8036
+ return
8037
+ }
7589
8038
  ctkdInitiatedThisGattSession = true
7590
8039
  Bridge.log(
7591
8040
  "LIVE: CTKD: BLE ready - initiating CTKD bonding for BT Classic (bondState=" +
@@ -7667,7 +8116,7 @@ class MentraLive : SGCManager() {
7667
8116
  }
7668
8117
 
7669
8118
  private fun canConnectClassicAudio(device: BluetoothDevice): Boolean =
7670
- !isKilled && !pairingYieldActive && isConnected && notificationsEnabled &&
8119
+ !isKilled && !automaticConnectBlocked() && isConnected && notificationsEnabled &&
7671
8120
  ctkdInitiatedThisGattSession && connectedDevice?.address == device.address &&
7672
8121
  device.bondState == BluetoothDevice.BOND_BONDED
7673
8122
 
@@ -7782,6 +8231,194 @@ class MentraLive : SGCManager() {
7782
8231
  )
7783
8232
  }
7784
8233
 
8234
+ private fun armSecureBondWait() {
8235
+ if (!MentraLiveOwnerLossPolicy.shouldRunSecureBondWait(
8236
+ isExplicitPairingAttempt(),
8237
+ isSecurePairingTarget(),
8238
+ )
8239
+ ) {
8240
+ return
8241
+ }
8242
+ if (secureBondWaitRunnable != null) return
8243
+ val runnable = Runnable {
8244
+ secureBondWaitRunnable = null
8245
+ if (isKilled || !isExplicitPairingAttempt()) return@Runnable
8246
+ val alreadyBooted = DeviceStore.store.get("glasses", "fullyBooted") as? Boolean ?: false
8247
+ if (alreadyBooted) return@Runnable
8248
+ // A bond alone no longer ends the wait: the glasses must also have reported
8249
+ // owner_committed. If both arrived, readiness is being finished by the bond or
8250
+ // pairing_info handlers.
8251
+ val device = connectedDevice
8252
+ if (device != null &&
8253
+ !MentraLiveOwnerLossPolicy.shouldDeferReadyForBond(
8254
+ isSecurePairingTarget(),
8255
+ device.bondState,
8256
+ securePairingConfirmed,
8257
+ )
8258
+ ) {
8259
+ return@Runnable
8260
+ }
8261
+ awaitingSecurePairingConfirmation = false
8262
+ Bridge.log(
8263
+ "LIVE: secure pairing bond wait expired " +
8264
+ "(bond=${device?.bondState} ownerCommitted=$securePairingConfirmed)"
8265
+ )
8266
+ Bridge.sendPairFailureEvent("errors:pairingCouldNotStart")
8267
+ }
8268
+ secureBondWaitRunnable = runnable
8269
+ handler.postDelayed(runnable, MentraLiveOwnerLossPolicy.SECURE_BOND_WAIT_MS)
8270
+ }
8271
+
8272
+ private fun cancelSecureBondWait() {
8273
+ secureBondWaitRunnable?.let { handler.removeCallbacks(it) }
8274
+ secureBondWaitRunnable = null
8275
+ }
8276
+
8277
+ /** Forget the owner commit when the GATT session ends or a new one starts, like iOS. */
8278
+ private fun resetSecurePairingConfirmation() {
8279
+ securePairingConfirmed = false
8280
+ sessionPairingInfoSecure = null
8281
+ awaitingSecurePairingConfirmation = false
8282
+ awaitingPairingInfoForReadiness = false
8283
+ unknownCapabilityWaitExpired = false
8284
+ phoneHelloSentThisSession = false
8285
+ pairSelectSentThisSession = false
8286
+ // Also tear down any in-flight owner-commit wait so a leftover 40s timer can't fire
8287
+ // pairingCouldNotStart on a later session after GATT drops mid-wait. The other
8288
+ // session timers die with the session too.
8289
+ cancelSecureBondWait()
8290
+ cancelUnknownCapabilityPairingInfoWait()
8291
+ cancelSecureFirmwareRefresh()
8292
+ }
8293
+
8294
+ /**
8295
+ * Bounds the wait for `pairing_info` on an explicit pair to glasses of unknown capability.
8296
+ * Without one by the deadline the glasses count as legacy firmware and are reported ready.
8297
+ */
8298
+ private fun armUnknownCapabilityPairingInfoWait() {
8299
+ if (unknownCapabilityWaitRunnable != null) return
8300
+ val runnable = Runnable {
8301
+ unknownCapabilityWaitRunnable = null
8302
+ if (isKilled || !awaitingPairingInfoForReadiness) return@Runnable
8303
+ unknownCapabilityWaitExpired = true
8304
+ Bridge.log(
8305
+ "LIVE: no pairing_info " +
8306
+ "${MentraLivePairingReadinessPolicy.UNKNOWN_CAPABILITY_PAIRING_INFO_WAIT_MS}ms " +
8307
+ "after glasses_ready — treating the pairing target as legacy firmware"
8308
+ )
8309
+ resolveUnknownCapabilityReadiness("unknown_capability_timeout")
8310
+ }
8311
+ unknownCapabilityWaitRunnable = runnable
8312
+ handler.postDelayed(
8313
+ runnable,
8314
+ MentraLivePairingReadinessPolicy.UNKNOWN_CAPABILITY_PAIRING_INFO_WAIT_MS,
8315
+ )
8316
+ }
8317
+
8318
+ private fun cancelUnknownCapabilityPairingInfoWait() {
8319
+ unknownCapabilityWaitRunnable?.let { handler.removeCallbacks(it) }
8320
+ unknownCapabilityWaitRunnable = null
8321
+ }
8322
+
8323
+ /**
8324
+ * Ends readiness parked for `pairing_info`. A secure report switches to the owner-commit rule
8325
+ * (with the [armSecureBondWait] deadline); a legacy report, or none by the deadline, reports
8326
+ * ready now.
8327
+ */
8328
+ private fun resolveUnknownCapabilityReadiness(via: String) {
8329
+ awaitingPairingInfoForReadiness = false
8330
+ cancelUnknownCapabilityPairingInfoWait()
8331
+ if (isKilled || !glassesReadyReceived) return
8332
+ when (pairingReadiness(connectedDevice)) {
8333
+ MentraLivePairingReadinessPolicy.Readiness.AWAIT_OWNER_COMMIT -> {
8334
+ Bridge.log(
8335
+ "LIVE: pairing target reported secure firmware — waiting for BLE bond / owner commit " +
8336
+ "(bond=${connectedDevice?.bondState} ownerCommitted=$securePairingConfirmed)"
8337
+ )
8338
+ markPairingTiming("waiting_owner_commit", "queueSize=${sendQueue.size}")
8339
+ awaitingSecurePairingConfirmation = true
8340
+ armSecureBondWait()
8341
+ }
8342
+ MentraLivePairingReadinessPolicy.Readiness.AWAIT_PAIRING_INFO -> {
8343
+ // Not reachable: pairing_info arrived or the wait expired. Keep a deadline anyway.
8344
+ awaitingPairingInfoForReadiness = true
8345
+ armUnknownCapabilityPairingInfoWait()
8346
+ }
8347
+ MentraLivePairingReadinessPolicy.Readiness.READY -> completeSecurePairingReadiness(via)
8348
+ }
8349
+ }
8350
+
8351
+ /**
8352
+ * A ready session refreshes `mentra_live_secure_firmware`: `pairing_info` writes it, and a
8353
+ * session without one within [MentraLivePairingReadinessPolicy.SECURE_FIRMWARE_REFRESH_MS]
8354
+ * clears it, so glasses rolled back to old firmware stop counting as secure.
8355
+ */
8356
+ private fun armSecureFirmwareRefresh() {
8357
+ if (pairingInfoSeen || secureFirmwareRefreshRunnable != null) return
8358
+ val runnable = Runnable {
8359
+ secureFirmwareRefreshRunnable = null
8360
+ if (isKilled || !isConnected || !glassesReadyReceived) return@Runnable
8361
+ val secure =
8362
+ MentraLivePairingReadinessPolicy.secureFirmwareAfterReadySession(pairingInfoSeen)
8363
+ ?: return@Runnable
8364
+ if (isSavedGlassesSecureFirmware() != secure) {
8365
+ Bridge.log(
8366
+ "LIVE: no pairing_info " +
8367
+ "${MentraLivePairingReadinessPolicy.SECURE_FIRMWARE_REFRESH_MS}ms after " +
8368
+ "glasses_ready — glasses are not secure-pairing firmware"
8369
+ )
8370
+ }
8371
+ rememberSecureFirmware(secure)
8372
+ }
8373
+ secureFirmwareRefreshRunnable = runnable
8374
+ handler.postDelayed(runnable, MentraLivePairingReadinessPolicy.SECURE_FIRMWARE_REFRESH_MS)
8375
+ }
8376
+
8377
+ private fun cancelSecureFirmwareRefresh() {
8378
+ secureFirmwareRefreshRunnable?.let { handler.removeCallbacks(it) }
8379
+ secureFirmwareRefreshRunnable = null
8380
+ }
8381
+
8382
+ /**
8383
+ * True while an explicit secure pair must keep waiting: either the BLE bond or the glasses'
8384
+ * `owner_committed` is still missing. Non-explicit reconnects and legacy targets never gate.
8385
+ */
8386
+ private fun shouldGateReadyOnOwnerCommit(device: BluetoothDevice?): Boolean =
8387
+ isExplicitPairingAttempt() &&
8388
+ MentraLiveOwnerLossPolicy.shouldDeferReadyForBond(
8389
+ isSecurePairingTarget(),
8390
+ device?.bondState,
8391
+ securePairingConfirmed,
8392
+ )
8393
+
8394
+ /**
8395
+ * Finishes readiness that glasses_ready parked for the owner commit. Runs from
8396
+ * `pairing_info` once `owner_committed` arrives; if the BLE bond is still missing, the
8397
+ * BOND_BONDED handler finishes it instead. Also finishes a parked unknown-capability target
8398
+ * once [resolveUnknownCapabilityReadiness] found it ready.
8399
+ */
8400
+ private fun completeSecurePairingReadiness(via: String) {
8401
+ if (isKilled || !glassesReadyReceived) return
8402
+ val alreadyBooted = DeviceStore.store.get("glasses", "fullyBooted") as? Boolean ?: false
8403
+ if (alreadyBooted) {
8404
+ awaitingSecurePairingConfirmation = false
8405
+ cancelSecureBondWait()
8406
+ return
8407
+ }
8408
+ if (shouldGateReadyOnOwnerCommit(connectedDevice)) {
8409
+ Bridge.log("LIVE: owner committed but BLE bond not reported yet — waiting for bond")
8410
+ return
8411
+ }
8412
+ awaitingSecurePairingConfirmation = false
8413
+ cancelSecureBondWait()
8414
+ endPairingTiming("fully_connected", "via=$via queueSize=${sendQueue.size}")
8415
+ DeviceStore.apply("glasses", "fullyBooted", true)
8416
+ updateConnectionState(ConnTypes.CONNECTED)
8417
+ if (!audioConnected) {
8418
+ markPairingTiming("waiting_ctkd_bond", "queueSize=${sendQueue.size}")
8419
+ }
8420
+ }
8421
+
7785
8422
  /** Helper to mark audio as connected and notify */
7786
8423
  private fun markAudioConnected(device: BluetoothDevice) {
7787
8424
  if (isKilled) {
@@ -7796,6 +8433,24 @@ class MentraLive : SGCManager() {
7796
8433
  Bridge.sendAudioConnected(safeDeviceName(device))
7797
8434
  }
7798
8435
  if (glassesReadyReceived) {
8436
+ if (awaitingPairingInfoForReadiness) {
8437
+ Bridge.log("LIVE: A2DP up while readiness waits for pairing_info — not marking paired yet")
8438
+ return
8439
+ }
8440
+ if (!MentraLiveOwnerLossPolicy.shouldMarkReadyFromAudio(
8441
+ isExplicitPairingAttempt(),
8442
+ isSecurePairingTarget(),
8443
+ device.bondState,
8444
+ securePairingConfirmed,
8445
+ )
8446
+ ) {
8447
+ Bridge.log("LIVE: A2DP up before BLE bond / owner commit — not marking paired")
8448
+ awaitingSecurePairingConfirmation = true
8449
+ armSecureBondWait()
8450
+ return
8451
+ }
8452
+ awaitingSecurePairingConfirmation = false
8453
+ cancelSecureBondWait()
7799
8454
  Bridge.log(
7800
8455
  "LIVE: A2DP: Both audio and glasses_ready confirmed - marking as fully connected"
7801
8456
  )
@@ -7997,10 +8652,8 @@ class MentraLive : SGCManager() {
7997
8652
  isKilled = true
7998
8653
  classicAudioServiceDiscovery.cancel()
7999
8654
  explicitConnectByName = false
8000
- pairingYieldActive = false
8001
- pairingYieldAwaitingReclaim = false
8002
- pairingYieldEndRunnable?.let { handler.removeCallbacks(it) }
8003
- pairingYieldEndRunnable = null
8655
+ // The pairing state itself lives on in MentraLivePairingStateStore.
8656
+ cancelPairingPauseTimer()
8004
8657
  unpairFlushRunnable?.let { handler.removeCallbacks(it) }
8005
8658
  unpairFlushRunnable = null
8006
8659
  unpairFlushPending = false
@@ -8096,6 +8749,7 @@ class MentraLive : SGCManager() {
8096
8749
  isReconnecting = false
8097
8750
  glassesReady = false
8098
8751
  glassesReadyReceived = false
8752
+ resetSecurePairingConfirmation()
8099
8753
  audioConnected = false
8100
8754
  ctkdInitiatedThisGattSession = false
8101
8755
  classicAudioServiceDiscovery.cancel()