@mentra/bluetooth-sdk 3.4.0-dev.996 → 3.4.0-dev.999
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.
- package/MIGRATION.md +61 -0
- package/README.md +47 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +6 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +8 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +48 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +21 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/connection/ConnectionModels.kt +5 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicy.kt +64 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +788 -134
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicy.kt +227 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +19 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicy.kt +94 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachine.kt +411 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateStore.kt +124 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +50 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerOwnerLossTest.kt +146 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusAudioSetupTest.kt +51 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +2 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/GlassesAudioSetupPolicyTest.kt +65 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveAudioSetupReadinessTest.kt +231 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveOwnerLossPolicyTest.kt +368 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingReadinessPolicyTest.kt +136 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingStateMachineTest.kt +346 -0
- package/build/BluetoothSdk.types.d.ts +101 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +1 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +8 -2
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +11 -3
- package/ios/Source/DeviceManager.swift +80 -6
- package/ios/Source/DeviceStore.swift +27 -0
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +10 -4
- package/ios/Source/errors/BluetoothSdkError.swift +5 -1
- package/ios/Source/sgcs/GlassesAudioSetupPolicy.swift +181 -0
- package/ios/Source/sgcs/MentraLive.swift +1053 -166
- package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +260 -1
- package/ios/Source/sgcs/MentraLivePairingStateMachine.swift +385 -0
- package/ios/Source/sgcs/MentraLivePairingStateStore.swift +121 -0
- package/ios/Source/sgcs/SGCManager.swift +7 -0
- package/ios/Source/sgcs/Simulated.swift +2 -0
- package/ios/Source/status/DeviceStatus.swift +35 -0
- package/ios/Source/types/DeviceModels.swift +8 -1
- package/ios/Source/utils/AudioSessionMonitor.swift +158 -5
- package/ios/Tests/BluetoothAudioRouteTests.swift +82 -0
- package/ios/Tests/GlassesAudioSetupPolicyTests.swift +191 -0
- package/ios/Tests/MentraLiveAudioSetupTests.swift +215 -0
- package/ios/Tests/MentraLiveConnectionOptionsTests.swift +55 -0
- package/ios/Tests/MentraLivePairingAdvertisementTests.swift +327 -0
- package/ios/Tests/MentraLivePairingStateMachineTests.swift +402 -0
- package/ios/Tests/MentraLiveSessionReadinessTests.swift +328 -26
- package/package.json +3 -2
- package/src/BluetoothSdk.types.ts +97 -0
- package/src/_private/BluetoothSdkModule.ts +1 -0
- package/src/generated/releaseMetadata.ts +5 -5
- 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
|
-
/**
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
private var
|
|
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
|
-
|
|
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
|
-
|
|
1305
|
-
|
|
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
|
|
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
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
advertisesPairingFlag(result)
|
|
1559
|
-
|
|
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
|
-
//
|
|
1562
|
-
//
|
|
1563
|
-
//
|
|
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 (
|
|
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 ||
|
|
1843
|
-
Bridge.log(
|
|
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 && !
|
|
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
|
-
|
|
1968
|
-
|
|
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
|
-
|
|
1971
|
-
|
|
2108
|
+
// Invalidate delayed reconnect callbacks already on the handler.
|
|
2109
|
+
reconnectGeneration++
|
|
1972
2110
|
isReconnecting = false
|
|
1973
2111
|
isConnecting = false
|
|
1974
|
-
|
|
2112
|
+
cancelSecureBondWait()
|
|
1975
2113
|
if (connectionTimeoutRunnable != null) {
|
|
1976
2114
|
connectionTimeoutHandler.removeCallbacks(connectionTimeoutRunnable!!)
|
|
1977
2115
|
connectionTimeoutRunnable = null
|
|
1978
2116
|
}
|
|
1979
|
-
|
|
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
|
|
1985
|
-
//
|
|
1986
|
-
val
|
|
1987
|
-
|
|
1988
|
-
|
|
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
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
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
|
-
|
|
2025
|
-
|
|
2144
|
+
)
|
|
2145
|
+
}
|
|
2146
|
+
pairingPauseEndRunnable = end
|
|
2147
|
+
handler.postDelayed(end, remaining + 50L)
|
|
2026
2148
|
}
|
|
2027
2149
|
|
|
2028
|
-
fun
|
|
2150
|
+
private fun cancelPairingPauseTimer() {
|
|
2151
|
+
pairingPauseEndRunnable?.let { handler.removeCallbacks(it) }
|
|
2152
|
+
pairingPauseEndRunnable = null
|
|
2153
|
+
}
|
|
2029
2154
|
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
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
|
-
|
|
2038
|
-
} catch (e:
|
|
2039
|
-
Bridge.log("LIVE:
|
|
2162
|
+
if (!isScanning) startScan()
|
|
2163
|
+
} catch (e: SecurityException) {
|
|
2164
|
+
Bridge.log("LIVE: pairing watch scan blocked — ${e.message}")
|
|
2040
2165
|
}
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
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 (
|
|
2069
|
-
Bridge.log(
|
|
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 (
|
|
2406
|
+
if (!MentraLivePairingStateStore.allowsAutomaticReconnect) {
|
|
2158
2407
|
isReconnecting = false
|
|
2159
2408
|
Bridge.log(
|
|
2160
|
-
"LIVE: 🔌 RECONNECT ABORTED - pairing
|
|
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
|
-
//
|
|
2504
|
-
//
|
|
2505
|
-
//
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
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
|
-
|
|
2513
|
-
|
|
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
|
|
4174
|
-
|
|
4175
|
-
|
|
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
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
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 (
|
|
4618
|
-
Bridge.log("LIVE: Ignoring stale glasses_ready
|
|
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
|
-
//
|
|
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 (
|
|
6584
|
-
|
|
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 (
|
|
6709
|
-
Bridge.log(
|
|
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 && !
|
|
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
|
-
|
|
8001
|
-
|
|
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()
|