@mentra/bluetooth-sdk 3.4.0-dev.993 → 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.
- 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
|
@@ -728,28 +728,30 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
728
728
|
guard let self else { return }
|
|
729
729
|
switch state {
|
|
730
730
|
case .poweredOn:
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
731
|
+
self.pairFlow("central", "state=powered_on")
|
|
732
|
+
if self.pairingStore.isPaused {
|
|
733
|
+
self.startPairingWatchScan(reason: "powered_on")
|
|
734
|
+
} else if !self.automaticConnectBlocked(),
|
|
735
|
+
let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
|
|
736
|
+
!savedDeviceName.isEmpty
|
|
735
737
|
{
|
|
736
|
-
self.
|
|
738
|
+
self.attachKnownOrScan(savedDeviceName)
|
|
737
739
|
}
|
|
738
740
|
|
|
739
741
|
case .poweredOff:
|
|
740
|
-
|
|
742
|
+
self.pairFlow("central", "state=powered_off")
|
|
741
743
|
self.updateConnectionState(ConnTypes.DISCONNECTED)
|
|
742
744
|
|
|
743
745
|
case .unauthorized:
|
|
744
|
-
|
|
746
|
+
self.pairFlow("central", "state=unauthorized")
|
|
745
747
|
self.updateConnectionState(ConnTypes.DISCONNECTED)
|
|
746
748
|
|
|
747
749
|
case .unsupported:
|
|
748
|
-
|
|
750
|
+
self.pairFlow("central", "state=unsupported")
|
|
749
751
|
self.updateConnectionState(ConnTypes.DISCONNECTED)
|
|
750
752
|
|
|
751
753
|
default:
|
|
752
|
-
|
|
754
|
+
self.pairFlow("central", "state=\(state.rawValue)")
|
|
753
755
|
}
|
|
754
756
|
}
|
|
755
757
|
}
|
|
@@ -759,7 +761,14 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
759
761
|
advertisementData: [String: Any] = [:],
|
|
760
762
|
rssi: NSNumber? = nil
|
|
761
763
|
) {
|
|
762
|
-
|
|
764
|
+
// The first iOS advertisement often has the name only in the packet, not on
|
|
765
|
+
// CBPeripheral. Dropping that packet hides glasses that are already in pairing mode.
|
|
766
|
+
guard let name = discoveredAdvertisementName(peripheral, advertisementData: advertisementData) else {
|
|
767
|
+
if manualDiscoveryActive, loggedUnnamedDiscoveries.insert(peripheral.identifier).inserted {
|
|
768
|
+
pairFlow("discover", "unnamed id=\(peripheral.identifier.uuidString)")
|
|
769
|
+
}
|
|
770
|
+
return
|
|
771
|
+
}
|
|
763
772
|
|
|
764
773
|
// Check for compatible device names
|
|
765
774
|
if name == "Xy_A" || name.hasPrefix("XyBLE_") || name.hasPrefix("MENTRA_LIVE_BLE")
|
|
@@ -767,60 +776,112 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
767
776
|
{
|
|
768
777
|
let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME)
|
|
769
778
|
let isReconnectTarget = savedDeviceName != nil && savedDeviceName == name
|
|
779
|
+
let discoveredAtMs = Self.monotonicNowMs()
|
|
780
|
+
if advertisesPairingFlag(advertisementData) {
|
|
781
|
+
Self.secureTrailerMemory.recordSecureTrailer(peripheral.identifier, nowMs: discoveredAtMs)
|
|
782
|
+
}
|
|
770
783
|
if !isReconnectTarget && advertisesPairingFlag(advertisementData)
|
|
771
784
|
&& !isPairingDiscoverable(advertisementData)
|
|
772
785
|
{
|
|
773
786
|
// Keep nearby secure units visible with pairing-mode guidance, while RN blocks
|
|
774
787
|
// the connection until a pairable advertisement arrives.
|
|
775
|
-
|
|
776
|
-
"
|
|
788
|
+
pairFlow(
|
|
789
|
+
"discover",
|
|
790
|
+
"name=\(name) id=\(peripheral.identifier.uuidString) saved=\(savedDeviceName ?? "-") pairingMode=false secure=true connect=false"
|
|
777
791
|
)
|
|
778
792
|
discoveredPeripherals[name] = peripheral
|
|
779
|
-
|
|
793
|
+
rememberPeripheral(peripheral, name: name)
|
|
794
|
+
if cacheAdvPairing(
|
|
780
795
|
name,
|
|
781
796
|
pairingMode: false,
|
|
782
797
|
pairingCode: nil,
|
|
783
798
|
securePairingCapable: true
|
|
784
|
-
)
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
799
|
+
) {
|
|
800
|
+
emitDiscoveredDeviceFromCache(
|
|
801
|
+
name,
|
|
802
|
+
identifier: peripheral.identifier.uuidString,
|
|
803
|
+
rssi: rssi?.intValue
|
|
804
|
+
)
|
|
805
|
+
}
|
|
790
806
|
return
|
|
791
807
|
}
|
|
792
808
|
|
|
793
809
|
let glassType = name == "Xy_A" ? "Standard" : "K900"
|
|
794
810
|
let trailer = parseSecurePairingTrailer(advertisementData)
|
|
811
|
+
// A packet without the trailer from a peripheral that just showed it is a partial
|
|
812
|
+
// advertisement, not legacy firmware. Do not publish it: the scan list would show a
|
|
813
|
+
// secure unit that is not in pairing mode as pairable, with no caption.
|
|
814
|
+
let partialAdvertisement = Self.secureTrailerMemory.isPartialAdvertisement(
|
|
815
|
+
peripheral.identifier,
|
|
816
|
+
secureCapable: trailer.secureCapable,
|
|
817
|
+
nowMs: discoveredAtMs
|
|
818
|
+
)
|
|
795
819
|
Bridge.log(
|
|
796
820
|
trailer.secureCapable
|
|
797
821
|
? "Found compatible \(glassType) glasses device: \(name) (pairingMode=\(trailer.pairingMode))"
|
|
798
|
-
:
|
|
822
|
+
: partialAdvertisement
|
|
823
|
+
? "Found compatible \(glassType) glasses device: \(name) (partial advertisement, secure trailer seen recently)"
|
|
824
|
+
: "Found compatible \(glassType) glasses device: \(name) (legacy firmware, pairable)"
|
|
799
825
|
)
|
|
800
826
|
|
|
801
827
|
// Store the peripheral
|
|
802
828
|
discoveredPeripherals[name] = peripheral
|
|
829
|
+
rememberPeripheral(peripheral, name: name)
|
|
803
830
|
|
|
804
|
-
cacheAdvPairing(
|
|
831
|
+
if !partialAdvertisement, cacheAdvPairing(
|
|
805
832
|
name,
|
|
806
833
|
// Field firmware has no pairing flag. Treat it as pairable so the
|
|
807
834
|
// new Mentra App can still find old glasses.
|
|
808
835
|
pairingMode: trailer.secureCapable ? trailer.pairingMode : true,
|
|
809
836
|
pairingCode: trailer.pairingCode,
|
|
810
837
|
securePairingCapable: trailer.secureCapable
|
|
838
|
+
) {
|
|
839
|
+
emitDiscoveredDeviceFromCache(
|
|
840
|
+
name,
|
|
841
|
+
identifier: peripheral.identifier.uuidString,
|
|
842
|
+
rssi: rssi?.intValue
|
|
843
|
+
)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
let isSavedGlasses = savedDeviceName == name
|
|
847
|
+
if isSavedGlasses, trailer.secureCapable {
|
|
848
|
+
observeSavedGlassesAdvertisement(trailer)
|
|
849
|
+
}
|
|
850
|
+
if isSavedGlasses, automaticConnectBlocked() {
|
|
851
|
+
// Paused for a pairing window or rejected: observe only. The watch scan keeps
|
|
852
|
+
// reading the advertisement so a cleared pairing flag can end the pause.
|
|
853
|
+
if !pairingWatchScanActive {
|
|
854
|
+
pairFlow(
|
|
855
|
+
"discover",
|
|
856
|
+
"name=\(name) id=\(peripheral.identifier.uuidString) action=wait state=\(pairingStore.describe())"
|
|
857
|
+
)
|
|
858
|
+
}
|
|
859
|
+
return
|
|
860
|
+
}
|
|
861
|
+
let waitForWindow = MentraLiveConnectionAttemptPolicy.shouldStandDownInsteadOfReconnect(
|
|
862
|
+
pairingMode: trailer.secureCapable && trailer.pairingMode,
|
|
863
|
+
savedGlasses: isSavedGlasses,
|
|
864
|
+
explicitPairing: isExplicitPairingAttempt()
|
|
811
865
|
)
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
866
|
+
if waitForWindow {
|
|
867
|
+
// The saved glasses are in a pairing window this phone never heard about.
|
|
868
|
+
// Connecting would race the new phone for the single BLE slot; keep watching
|
|
869
|
+
// and connect once the flag clears.
|
|
870
|
+
if !pairingWatchScanActive {
|
|
871
|
+
pairFlow(
|
|
872
|
+
"discover",
|
|
873
|
+
"name=\(name) id=\(peripheral.identifier.uuidString) action=wait_pairing_mode"
|
|
874
|
+
)
|
|
875
|
+
}
|
|
876
|
+
startPairingWatchScan(reason: "saved_glasses_pairing_mode")
|
|
877
|
+
return
|
|
878
|
+
}
|
|
879
|
+
let willConnect = isSavedGlasses
|
|
880
|
+
pairFlow(
|
|
881
|
+
"discover",
|
|
882
|
+
"name=\(name) id=\(peripheral.identifier.uuidString) saved=\(savedDeviceName ?? "-") pairingMode=\(trailer.pairingMode) secure=\(trailer.secureCapable) connect=\(willConnect)"
|
|
816
883
|
)
|
|
817
|
-
|
|
818
|
-
// Check if this is the device we want to connect to
|
|
819
|
-
if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
|
|
820
|
-
savedDeviceName == name
|
|
821
|
-
{
|
|
822
|
-
Bridge.log("Found our remembered device by name, connecting: \(name)")
|
|
823
|
-
// stopScan()
|
|
884
|
+
if willConnect {
|
|
824
885
|
centralManager?.stopScan()
|
|
825
886
|
isScanning = false
|
|
826
887
|
connectToDevice(peripheral)
|
|
@@ -842,7 +903,7 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
842
903
|
guard let self else { return }
|
|
843
904
|
let matchesActiveAttempt = self.connectingPeripheral === peripheral
|
|
844
905
|
guard MentraLiveConnectionAttemptPolicy.shouldAcceptDidConnect(
|
|
845
|
-
pairingYieldActive: self.
|
|
906
|
+
pairingYieldActive: self.automaticConnectBlocked(),
|
|
846
907
|
matchesActiveAttempt: matchesActiveAttempt
|
|
847
908
|
) else {
|
|
848
909
|
Bridge.log(
|
|
@@ -859,7 +920,11 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
859
920
|
self.centralManager?.cancelPeripheralConnection(peripheral)
|
|
860
921
|
return
|
|
861
922
|
}
|
|
862
|
-
|
|
923
|
+
self.pairFlow(
|
|
924
|
+
"gatt_connect",
|
|
925
|
+
"id=\(peripheral.identifier.uuidString) name=\(peripheral.name ?? "-")"
|
|
926
|
+
)
|
|
927
|
+
Self.clearStaleIosBondPrompt()
|
|
863
928
|
|
|
864
929
|
self.stopConnectionTimeout()
|
|
865
930
|
self.clearPendingReconnectTracking()
|
|
@@ -913,11 +978,19 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
913
978
|
let isCurrent =
|
|
914
979
|
self.connectedPeripheral === peripheral || self.connectingPeripheral === peripheral
|
|
915
980
|
if !isCurrent {
|
|
916
|
-
|
|
981
|
+
self.pairFlow("gatt_disconnect", "ignored id=\(peripheral.identifier.uuidString)")
|
|
917
982
|
return
|
|
918
983
|
}
|
|
919
984
|
self.connectingPeripheral = nil
|
|
920
985
|
self.clearPendingReconnectTracking()
|
|
986
|
+
let nsError = error as NSError?
|
|
987
|
+
self.pairFlow(
|
|
988
|
+
"gatt_disconnect",
|
|
989
|
+
"id=\(peripheral.identifier.uuidString) name=\(peripheral.name ?? "-") domain=\(nsError?.domain ?? "-") code=\(nsError?.code ?? 0)"
|
|
990
|
+
)
|
|
991
|
+
Bridge.log(
|
|
992
|
+
"PAIRFLOW: ble_disconnect id=\(peripheral.identifier.uuidString) domain=\(nsError?.domain ?? "none") code=\(nsError?.code ?? 0) killed=\(self.isKilled) explicitPairing=\(self.isExplicitPairingAttempt())"
|
|
993
|
+
)
|
|
921
994
|
Bridge.log("LIVE: Disconnected from GATT server")
|
|
922
995
|
|
|
923
996
|
self.isConnecting = false
|
|
@@ -941,9 +1014,18 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
941
1014
|
// Clean up characteristics
|
|
942
1015
|
self.txCharacteristic = nil
|
|
943
1016
|
self.rxCharacteristic = nil
|
|
1017
|
+
let waitingForOwnerCommit = self.awaitingSecurePairingConfirmation
|
|
1018
|
+
self.resetSecurePairingSession()
|
|
944
1019
|
|
|
945
|
-
if self.
|
|
946
|
-
|
|
1020
|
+
if self.stopAfterConnectFailure(error) {
|
|
1021
|
+
return
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// The system pairing sheet was dismissed, or the bond never finished.
|
|
1025
|
+
// Reconnecting would leave the loading screen up with no failure.
|
|
1026
|
+
if waitingForOwnerCommit && self.isExplicitPairingAttempt() {
|
|
1027
|
+
Bridge.log("LIVE: secure pairing link dropped before owner commit")
|
|
1028
|
+
Bridge.sendPairFailureEvent("errors:pairingCouldNotStart")
|
|
947
1029
|
return
|
|
948
1030
|
}
|
|
949
1031
|
|
|
@@ -962,11 +1044,15 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
962
1044
|
let isCurrent =
|
|
963
1045
|
self.connectedPeripheral === peripheral || self.connectingPeripheral === peripheral
|
|
964
1046
|
if !isCurrent {
|
|
965
|
-
|
|
1047
|
+
self.pairFlow("gatt_connect_fail", "ignored id=\(peripheral.identifier.uuidString)")
|
|
966
1048
|
return
|
|
967
1049
|
}
|
|
968
1050
|
self.connectingPeripheral = nil
|
|
969
|
-
|
|
1051
|
+
let nsError = error as NSError?
|
|
1052
|
+
self.pairFlow(
|
|
1053
|
+
"gatt_connect_fail",
|
|
1054
|
+
"id=\(peripheral.identifier.uuidString) name=\(peripheral.name ?? "-") domain=\(nsError?.domain ?? "-") code=\(nsError?.code ?? 0) \(errorDescription)"
|
|
1055
|
+
)
|
|
970
1056
|
|
|
971
1057
|
self.stopConnectionTimeout()
|
|
972
1058
|
self.clearPendingReconnectTracking()
|
|
@@ -975,8 +1061,7 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
975
1061
|
self.connectedPeripheral = nil
|
|
976
1062
|
self.updateConnectionState(ConnTypes.DISCONNECTED)
|
|
977
1063
|
|
|
978
|
-
if self.
|
|
979
|
-
self.clearSavedGlassesAfterOwnerLoss(reason: "ios_auth_fail")
|
|
1064
|
+
if self.stopAfterConnectFailure(error) {
|
|
980
1065
|
return
|
|
981
1066
|
}
|
|
982
1067
|
|
|
@@ -1078,7 +1163,10 @@ extension MentraLive: CBPeripheralDelegate {
|
|
|
1078
1163
|
|
|
1079
1164
|
// Check if we have both characteristics
|
|
1080
1165
|
if self.txCharacteristic != nil, let rx = self.rxCharacteristic {
|
|
1081
|
-
|
|
1166
|
+
self.pairFlow(
|
|
1167
|
+
"gatt_ready",
|
|
1168
|
+
"id=\(peripheral.identifier.uuidString) name=\(peripheral.name ?? "-") mtu=\(max(23, min(peripheral.maximumWriteValueLength(for: .withResponse) + 3, peripheral.maximumWriteValueLength(for: .withoutResponse) + 3)))"
|
|
1169
|
+
)
|
|
1082
1170
|
Bridge.log("LIVE: 🔄 Waiting for glasses SOC to become ready...")
|
|
1083
1171
|
|
|
1084
1172
|
// Don't set connected=true here - wait for SOC to be ready (fullyBooted=true)
|
|
@@ -1222,16 +1310,25 @@ extension MentraLive: CBPeripheralDelegate {
|
|
|
1222
1310
|
let uuid = characteristic.uuid
|
|
1223
1311
|
let isNotifying = characteristic.isNotifying
|
|
1224
1312
|
let errorDescription = error?.localizedDescription
|
|
1313
|
+
let notifyError = error
|
|
1225
1314
|
DispatchQueue.main.async { [weak self] in
|
|
1226
1315
|
guard let self else { return }
|
|
1227
1316
|
if let errorDescription {
|
|
1228
1317
|
Bridge.log("LIVE: Error updating notification state: \(errorDescription)")
|
|
1318
|
+
// Insufficient authentication/encryption on the RX subscribe is the glasses
|
|
1319
|
+
// refusing this phone's keys.
|
|
1320
|
+
if uuid == self.RX_CHAR_UUID, Self.isPairingAuthFailure(notifyError),
|
|
1321
|
+
self.recordAuthRejection(reason: "ios_notify_auth_\((notifyError as NSError?)?.code ?? 0)")
|
|
1322
|
+
{
|
|
1323
|
+
return
|
|
1324
|
+
}
|
|
1229
1325
|
} else {
|
|
1230
1326
|
Bridge.log("Notification state updated for \(uuid): \(isNotifying ? "ON" : "OFF")")
|
|
1231
1327
|
|
|
1232
1328
|
if uuid == self.RX_CHAR_UUID, isNotifying {
|
|
1233
1329
|
Bridge.log("LIVE: 🔔 Ready to receive data via notifications")
|
|
1234
1330
|
self.enableAncsRelayIfAuthorized()
|
|
1331
|
+
self.sendPairingSessionMessages(reason: "gatt_ready")
|
|
1235
1332
|
}
|
|
1236
1333
|
}
|
|
1237
1334
|
}
|
|
@@ -1264,12 +1361,25 @@ enum MentraLiveConnectionState {
|
|
|
1264
1361
|
}
|
|
1265
1362
|
|
|
1266
1363
|
enum MentraLiveConnectionOptions {
|
|
1267
|
-
|
|
1364
|
+
/// `enableTransportBridging` asks iOS to bring up the Classic profiles (A2DP/HFP)
|
|
1365
|
+
/// after the BLE link is up (CBConnectPeripheralOptionEnableTransportBridgingKey,
|
|
1366
|
+
/// iOS 13+). The product default is on; this helper still omits the key unless
|
|
1367
|
+
/// the caller passes true.
|
|
1368
|
+
static func coreBluetoothOptions(
|
|
1369
|
+
requiresAncs: Bool,
|
|
1370
|
+
enableTransportBridging: Bool = false
|
|
1371
|
+
) -> [String: Any]? {
|
|
1268
1372
|
#if os(macOS)
|
|
1269
1373
|
return nil
|
|
1270
1374
|
#else
|
|
1271
|
-
|
|
1272
|
-
|
|
1375
|
+
var options = [String: Any]()
|
|
1376
|
+
if requiresAncs {
|
|
1377
|
+
options[CBConnectPeripheralOptionRequiresANCS] = true
|
|
1378
|
+
}
|
|
1379
|
+
if enableTransportBridging {
|
|
1380
|
+
options[CBConnectPeripheralOptionEnableTransportBridgingKey] = true
|
|
1381
|
+
}
|
|
1382
|
+
return options.isEmpty ? nil : options
|
|
1273
1383
|
#endif
|
|
1274
1384
|
}
|
|
1275
1385
|
}
|
|
@@ -1328,16 +1438,23 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1328
1438
|
let pairingMode: Bool
|
|
1329
1439
|
let pairingCode: String?
|
|
1330
1440
|
let secureCapable: Bool
|
|
1441
|
+
let pairingGeneration: Int?
|
|
1442
|
+
let ownerChanged: Bool
|
|
1331
1443
|
}
|
|
1332
1444
|
|
|
1333
1445
|
private func parseSecurePairingTrailer(_ advertisementData: [String: Any]) -> SecurePairingTrailer {
|
|
1334
1446
|
guard let advertisement = pairingAdvertisement(advertisementData) else {
|
|
1335
|
-
return SecurePairingTrailer(
|
|
1447
|
+
return SecurePairingTrailer(
|
|
1448
|
+
pairingMode: false, pairingCode: nil, secureCapable: false, pairingGeneration: nil,
|
|
1449
|
+
ownerChanged: false
|
|
1450
|
+
)
|
|
1336
1451
|
}
|
|
1337
1452
|
return SecurePairingTrailer(
|
|
1338
1453
|
pairingMode: advertisement.pairingMode,
|
|
1339
1454
|
pairingCode: advertisement.pairingCode,
|
|
1340
|
-
secureCapable: true
|
|
1455
|
+
secureCapable: true,
|
|
1456
|
+
pairingGeneration: advertisement.pairingGeneration,
|
|
1457
|
+
ownerChanged: advertisement.ownerChanged
|
|
1341
1458
|
)
|
|
1342
1459
|
}
|
|
1343
1460
|
|
|
@@ -1376,6 +1493,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1376
1493
|
DeviceStore.shared.apply("glasses", "hotspotOtaVersion", 0)
|
|
1377
1494
|
}
|
|
1378
1495
|
connectionState = state
|
|
1496
|
+
pairFlow(
|
|
1497
|
+
"conn_state",
|
|
1498
|
+
"state=\(state) gatt=\(txCharacteristic != nil) connecting=\(isConnecting) killed=\(isKilled) scanning=\(isScanning) id=\(connectedPeripheral?.identifier.uuidString ?? connectingPeripheral?.identifier.uuidString ?? "-") name=\(connectedPeripheral?.name ?? connectingPeripheral?.name ?? "-")"
|
|
1499
|
+
)
|
|
1379
1500
|
DeviceStore.shared.apply("glasses", "connectionState", state)
|
|
1380
1501
|
// Drop OTA caches when fully disconnected — avoids leaking session/step state from
|
|
1381
1502
|
// a previous pairing into the next one (would otherwise surface as wrong overall_percent
|
|
@@ -1392,6 +1513,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1392
1513
|
DeviceStore.shared.apply("glasses", "signalStrength", -1)
|
|
1393
1514
|
DeviceStore.shared.apply("glasses", "signalStrengthUpdatedAt", 0)
|
|
1394
1515
|
resetOtaCache()
|
|
1516
|
+
endAudioSetupSession()
|
|
1395
1517
|
}
|
|
1396
1518
|
}
|
|
1397
1519
|
|
|
@@ -1426,9 +1548,12 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1426
1548
|
|
|
1427
1549
|
func forget() {
|
|
1428
1550
|
Bridge.log("LIVE: Forgetting Mentra Live glasses")
|
|
1551
|
+
Self.secureTrailerMemory.clear()
|
|
1429
1552
|
|
|
1430
1553
|
// Clear saved device name so the pairing filter is not bypassed on rescan
|
|
1431
1554
|
UserDefaults.standard.removeObject(forKey: PREFS_DEVICE_NAME)
|
|
1555
|
+
UserDefaults.standard.removeObject(forKey: PREFS_PERIPHERAL_IDS)
|
|
1556
|
+
pairFlow("forget", "cleared_saved_peripheral")
|
|
1432
1557
|
|
|
1433
1558
|
// Stop scanning first
|
|
1434
1559
|
if isScanning {
|
|
@@ -1451,6 +1576,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1451
1576
|
|
|
1452
1577
|
var type = "Mentra Live"
|
|
1453
1578
|
var hasMic = true
|
|
1579
|
+
var publishesAudioSetup: Bool {
|
|
1580
|
+
true
|
|
1581
|
+
}
|
|
1454
1582
|
|
|
1455
1583
|
func setMicEnabled(_ enabled: Bool) {
|
|
1456
1584
|
Bridge.log("LIVE: 🎤 Microphone state change requested: \(enabled)")
|
|
@@ -1558,6 +1686,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1558
1686
|
|
|
1559
1687
|
/// Device Settings Keys
|
|
1560
1688
|
private let PREFS_DEVICE_NAME = "MentraLiveLastConnectedDeviceName"
|
|
1689
|
+
private let PREFS_PERIPHERAL_IDS = "MentraLivePeripheralIds"
|
|
1561
1690
|
|
|
1562
1691
|
// MARK: - Properties
|
|
1563
1692
|
|
|
@@ -1583,10 +1712,49 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1583
1712
|
private var manualDiscoveryActive = false
|
|
1584
1713
|
private var isConnecting = false
|
|
1585
1714
|
private var isKilled = false
|
|
1586
|
-
///
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1715
|
+
/// Pairing protocol this phone speaks (`phone_hello.pairing_protocol`). The glasses send
|
|
1716
|
+
/// window notices only to phones that announced protocol 4 or later.
|
|
1717
|
+
static let pairingProtocolVersion = 4
|
|
1718
|
+
/// Pause, takeover and rejection state for the saved glasses. Outlives this manager.
|
|
1719
|
+
private let pairingStore = MentraLivePairingStateStore.shared
|
|
1720
|
+
/// Ends the pairing-mode pause at its deadline.
|
|
1721
|
+
private var pairingPauseWorkItem: DispatchWorkItem?
|
|
1722
|
+
/// The running scan is the duplicate-reporting watch scan, not a connect scan.
|
|
1723
|
+
private var pairingWatchScanActive = false
|
|
1724
|
+
private var phoneHelloSentThisSession = false
|
|
1725
|
+
private var pairSelectSentThisSession = false
|
|
1726
|
+
/// `pairing_info` arrived on this BLE session, so the glasses run secure firmware.
|
|
1727
|
+
private var pairingInfoSeenThisSession = false
|
|
1728
|
+
/// `pairing_info.auto_audio` on this BLE session. Old firmware never sends it.
|
|
1729
|
+
private var pairingInfoAutoAudio = false
|
|
1730
|
+
/// Peripherals that advertised the secure-pairing trailer in the last few seconds.
|
|
1731
|
+
/// CoreBluetooth sometimes reports an advertisement without the manufacturer data that
|
|
1732
|
+
/// carries the trailer, so a packet without it right after one with it is partial, not
|
|
1733
|
+
/// legacy firmware. Nothing is persisted: glasses rolled back to legacy firmware must list
|
|
1734
|
+
/// as legacy again.
|
|
1735
|
+
private static var secureTrailerMemory = MentraLiveSecureTrailerMemory()
|
|
1736
|
+
/// Retired persisted name set that hid rolled-back glasses for good. Removed on startup.
|
|
1737
|
+
private static let retiredSecurePairingNamesKey = "mentra_live_secure_pairing_names"
|
|
1738
|
+
/// glasses_ready arrived for a secure pairing target before pairing_info confirmed the bond.
|
|
1739
|
+
private var awaitingSecurePairingConfirmation = false
|
|
1740
|
+
/// pairing_info from secure firmware arrived on this BLE session.
|
|
1741
|
+
private var securePairingConfirmed = false
|
|
1742
|
+
private var securePairingWaitWorkItem: DispatchWorkItem?
|
|
1743
|
+
/// glasses_ready arrived for an explicit pair whose capability is unknown; waiting for
|
|
1744
|
+
/// pairing_info to tell secure from legacy firmware.
|
|
1745
|
+
private var awaitingPairingInfoForUnknownCapability = false
|
|
1746
|
+
private var unknownCapabilityWaitWorkItem: DispatchWorkItem?
|
|
1747
|
+
/// Marks the saved glasses as legacy firmware when a ready session gets no pairing_info.
|
|
1748
|
+
private var secureFirmwareRefreshWorkItem: DispatchWorkItem?
|
|
1749
|
+
/// Internal so tests can shorten the waits.
|
|
1750
|
+
var unknownCapabilityPairingInfoWaitMs = MentraLiveConnectionAttemptPolicy.unknownCapabilityPairingInfoWaitMs
|
|
1751
|
+
var secureFirmwareRefreshMs = MentraLiveConnectionAttemptPolicy.secureFirmwareRefreshMs
|
|
1752
|
+
/// `GlassesStatus.audioSetup` for this driver (see `GlassesAudioSetupPolicy`).
|
|
1753
|
+
private var audioSetup = GlassesAudioSetupTracker()
|
|
1754
|
+
private var audioSetupDeadlineWorkItem: DispatchWorkItem?
|
|
1755
|
+
private var audioSetupStoreListenerId: String?
|
|
1756
|
+
/// Monotonic milliseconds. Internal so tests can drive the audio-setup clock.
|
|
1757
|
+
var audioSetupNowMs: () -> Int = { MentraLive.monotonicNowMs() }
|
|
1590
1758
|
/// Hold GATT teardown until the unpair write can leave the phone.
|
|
1591
1759
|
private var unpairFlushPending = false
|
|
1592
1760
|
private var unpairFlushWorkItem: DispatchWorkItem?
|
|
@@ -1706,6 +1874,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1706
1874
|
private var pendingReconnectSettleWorkItem: DispatchWorkItem?
|
|
1707
1875
|
private var reconnectionWorkItem: DispatchWorkItem?
|
|
1708
1876
|
private var requiresAncs = true
|
|
1877
|
+
/// Experimental iOS transport bridging (Classic profiles after BLE connect). Off by default.
|
|
1878
|
+
/// Static so it can be set from `ConnectOptions` without threading a parameter
|
|
1879
|
+
/// through `DeviceManager`; it applies to every connect issued after it is set.
|
|
1880
|
+
static var enableTransportBridging = true
|
|
1709
1881
|
|
|
1710
1882
|
// MARK: - Initialization
|
|
1711
1883
|
|
|
@@ -1725,6 +1897,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1725
1897
|
} else {
|
|
1726
1898
|
Bridge.log("LIVE: 🎵 Phone audio monitor disabled (BLOCK_AUDIO_DUPLEX=false)")
|
|
1727
1899
|
}
|
|
1900
|
+
|
|
1901
|
+
UserDefaults.standard.removeObject(forKey: Self.retiredSecurePairingNamesKey)
|
|
1902
|
+
restorePairingState()
|
|
1903
|
+
observeAudioSetupInputs()
|
|
1728
1904
|
}
|
|
1729
1905
|
|
|
1730
1906
|
deinit {
|
|
@@ -1745,12 +1921,15 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1745
1921
|
// MARK: - React Native Interface
|
|
1746
1922
|
|
|
1747
1923
|
private var discoveredPeripherals = [String: CBPeripheral]() // name -> peripheral
|
|
1924
|
+
/// Advertisements that arrived with no name. Logged once so a pairing scan that
|
|
1925
|
+
/// sees the glasses but cannot list them is visible in the phone log.
|
|
1926
|
+
private var loggedUnnamedDiscoveries = Set<UUID>()
|
|
1748
1927
|
/// Last advertisement pairing trailer for a discovered name. Re-emits (already-
|
|
1749
1928
|
/// connected / scan replay) must not drop these flags: omitting them makes a
|
|
1750
1929
|
/// secure idle unit look like pairable legacy firmware.
|
|
1751
1930
|
private var discoveredAdvPairing = [String: CachedAdvPairing]()
|
|
1752
1931
|
|
|
1753
|
-
private struct CachedAdvPairing {
|
|
1932
|
+
private struct CachedAdvPairing: Equatable {
|
|
1754
1933
|
let pairingMode: Bool
|
|
1755
1934
|
let pairingCode: String?
|
|
1756
1935
|
let securePairingCapable: Bool
|
|
@@ -1779,20 +1958,67 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1779
1958
|
}
|
|
1780
1959
|
}
|
|
1781
1960
|
|
|
1782
|
-
|
|
1783
|
-
|
|
1961
|
+
/// Automatic connects wait: the saved glasses are in a pairing window or no longer accept
|
|
1962
|
+
/// this phone. An explicit pair is never blocked.
|
|
1963
|
+
private func automaticConnectBlocked() -> Bool {
|
|
1964
|
+
!pairingStore.allowsAutomaticReconnect && !isExplicitPairingAttempt()
|
|
1784
1965
|
}
|
|
1785
1966
|
|
|
1786
|
-
private func
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1967
|
+
private func applyPairingTransition(_ transition: MentraLivePairingTransition) {
|
|
1968
|
+
switch transition.effect {
|
|
1969
|
+
case .none:
|
|
1970
|
+
break
|
|
1971
|
+
case .pause:
|
|
1972
|
+
enterPairingPause()
|
|
1973
|
+
case .resume:
|
|
1974
|
+
resumeAfterPairingPause()
|
|
1975
|
+
case .reject:
|
|
1976
|
+
stopAfterPairingRejected()
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/// The glasses opened a pairing window. Leave the link, keep the saved glasses and the bonds,
|
|
1981
|
+
/// and make no automatic connection until the window ends. Probing GATT during the window
|
|
1982
|
+
/// races the new phone for the single BLE slot. The watch scan only reads the advertisement.
|
|
1983
|
+
private func enterPairingPause() {
|
|
1984
|
+
leaveGlassesLink(reason: "pairing_mode")
|
|
1985
|
+
armPairingPauseTimer()
|
|
1986
|
+
startPairingWatchScan(reason: "pairing_pause")
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
/// The window is over. Reconnect through a scan so the post-window advertisement
|
|
1990
|
+
/// (`OWNER_CHANGED`) is read before any GATT connect.
|
|
1991
|
+
private func resumeAfterPairingPause() {
|
|
1992
|
+
cancelPairingPauseTimer()
|
|
1993
|
+
if isScanning, !manualDiscoveryActive {
|
|
1994
|
+
centralManager?.stopScan()
|
|
1995
|
+
isScanning = false
|
|
1996
|
+
}
|
|
1997
|
+
guard !isKilled, connectedPeripheral == nil else { return }
|
|
1998
|
+
Bridge.log("LIVE: Pairing pause ended — resuming reconnect to saved glasses")
|
|
1999
|
+
reconnectAttempts = 0
|
|
2000
|
+
handleReconnection()
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
/// The glasses no longer accept this phone. Stop reconnecting, keep the saved glasses so the
|
|
2004
|
+
/// app can offer Pair again or forget. The manager stays alive; every automatic path checks
|
|
2005
|
+
/// the pairing state.
|
|
2006
|
+
private func stopAfterPairingRejected() {
|
|
2007
|
+
cancelPairingPauseTimer()
|
|
2008
|
+
if isExplicitPairingAttempt() { return }
|
|
2009
|
+
leaveGlassesLink(reason: "pairing_rejected")
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
/// Drop the BLE link and every session flag without destroying the manager.
|
|
2013
|
+
private func leaveGlassesLink(reason: String) {
|
|
2014
|
+
cancelPendingReconnect(reason: reason)
|
|
2015
|
+
cancelSecurePairingWait()
|
|
2016
|
+
if isScanning, !manualDiscoveryActive {
|
|
2017
|
+
centralManager?.stopScan()
|
|
2018
|
+
isScanning = false
|
|
2019
|
+
}
|
|
2020
|
+
// Publish the disconnect immediately. didDisconnectPeripheral may lag or race with
|
|
2021
|
+
// cancelPeripheralConnection, and is ignored once connectedPeripheral is nil.
|
|
1796
2022
|
isConnecting = false
|
|
1797
2023
|
clearPhysicalReadiness()
|
|
1798
2024
|
connected = false
|
|
@@ -1800,6 +2026,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1800
2026
|
streamControlVersion = 0
|
|
1801
2027
|
readinessCompletedThisBleSession = false
|
|
1802
2028
|
rgbLedAuthorityClaimed = false
|
|
2029
|
+
lastSentMicTuningBody = nil
|
|
2030
|
+
wearTuningQueriedThisLink = false
|
|
2031
|
+
resetSecurePairingSession()
|
|
1803
2032
|
stopAllTimers()
|
|
1804
2033
|
closeL2capFileChannel()
|
|
1805
2034
|
txCharacteristic = nil
|
|
@@ -1810,66 +2039,282 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1810
2039
|
}
|
|
1811
2040
|
connectedPeripheral = nil
|
|
1812
2041
|
connectingPeripheral = nil
|
|
1813
|
-
|
|
1814
|
-
// phone for the single BLE slot and is exactly what entering_pairing_mode forbids.
|
|
2042
|
+
}
|
|
1815
2043
|
|
|
2044
|
+
private func armPairingPauseTimer() {
|
|
2045
|
+
cancelPairingPauseTimer()
|
|
2046
|
+
guard pairingStore.isPaused else { return }
|
|
2047
|
+
let remaining = pairingStore.remainingPauseMs()
|
|
1816
2048
|
let work = DispatchWorkItem { [weak self] in
|
|
1817
2049
|
guard let self else { return }
|
|
1818
|
-
|
|
1819
|
-
self.
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
if
|
|
1823
|
-
self.
|
|
2050
|
+
self.pairingPauseWorkItem = nil
|
|
2051
|
+
let transition = self.pairingStore.update("pause_deadline") {
|
|
2052
|
+
$0.onTick(nowMs: MentraLivePairingStateStore.nowMs())
|
|
2053
|
+
}
|
|
2054
|
+
if transition.effect == .none, self.pairingStore.isPaused {
|
|
2055
|
+
self.armPairingPauseTimer()
|
|
2056
|
+
return
|
|
1824
2057
|
}
|
|
2058
|
+
self.applyPairingTransition(transition)
|
|
2059
|
+
}
|
|
2060
|
+
pairingPauseWorkItem = work
|
|
2061
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(max(remaining, 0)), execute: work)
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
private func cancelPairingPauseTimer() {
|
|
2065
|
+
pairingPauseWorkItem?.cancel()
|
|
2066
|
+
pairingPauseWorkItem = nil
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
/// Duplicate-reporting scan that only observes the saved glasses' advertisement, so a
|
|
2070
|
+
/// cleared pairing flag is seen while the phone waits.
|
|
2071
|
+
private func startPairingWatchScan(reason: String) {
|
|
2072
|
+
if centralManager == nil {
|
|
2073
|
+
centralManager = CBCentralManager(
|
|
2074
|
+
delegate: self, queue: bluetoothQueue,
|
|
2075
|
+
options: ["CBCentralManagerOptionShowPowerAlertKey": 0]
|
|
2076
|
+
)
|
|
2077
|
+
return
|
|
2078
|
+
}
|
|
2079
|
+
guard !isKilled, let central = centralManager, central.state == .poweredOn else { return }
|
|
2080
|
+
if isScanning, pairingWatchScanActive { return }
|
|
2081
|
+
if isScanning {
|
|
2082
|
+
central.stopScan()
|
|
2083
|
+
}
|
|
2084
|
+
isScanning = true
|
|
2085
|
+
pairingWatchScanActive = true
|
|
2086
|
+
central.scanForPeripherals(
|
|
2087
|
+
withServices: nil,
|
|
2088
|
+
options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
|
|
2089
|
+
)
|
|
2090
|
+
pairFlow("scan", "start trigger=pairing_watch reason=\(reason) state=\(pairingStore.describe())")
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
private func observeSavedGlassesAdvertisement(_ trailer: SecurePairingTrailer) {
|
|
2094
|
+
let wasArmed = pairingStore.verificationArmed
|
|
2095
|
+
let transition = pairingStore.update("advert") {
|
|
2096
|
+
$0.onAdvertisement(
|
|
2097
|
+
pairingMode: trailer.pairingMode,
|
|
2098
|
+
ownerChanged: trailer.ownerChanged,
|
|
2099
|
+
generation: trailer.pairingGeneration,
|
|
2100
|
+
nowMs: MentraLivePairingStateStore.nowMs()
|
|
2101
|
+
)
|
|
2102
|
+
}
|
|
2103
|
+
if !wasArmed, pairingStore.verificationArmed {
|
|
2104
|
+
pairFlow("takeover_check", "owner_changed window=\(trailer.pairingGeneration ?? -1) action=verify_once")
|
|
2105
|
+
}
|
|
2106
|
+
applyPairingTransition(transition)
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
/// `phone_hello` on every secure session (the glasses send window notices only to protocol
|
|
2110
|
+
/// 4 phones) and `pair_select` once on an explicit attempt, as soon as GATT is ready. Both
|
|
2111
|
+
/// callers run on a live link: the RX subscribe and a received `pairing_info`.
|
|
2112
|
+
private func sendPairingSessionMessages(reason: String) {
|
|
2113
|
+
let secureTarget = isSecurePairingTarget()
|
|
2114
|
+
// A trailer this scan just saw is a hint to speak the protocol at all; pair_select below
|
|
2115
|
+
// follows only the selected target.
|
|
2116
|
+
let recentSecureTrailer = connectedPeripheral.map {
|
|
2117
|
+
Self.secureTrailerMemory.hasRecentSecureTrailer($0.identifier, nowMs: Self.monotonicNowMs())
|
|
2118
|
+
} ?? false
|
|
2119
|
+
guard secureTarget || isSavedGlassesSecureFirmware() || pairingInfoSeenThisSession || recentSecureTrailer
|
|
2120
|
+
else { return }
|
|
2121
|
+
if !phoneHelloSentThisSession {
|
|
2122
|
+
phoneHelloSentThisSession = true
|
|
2123
|
+
pairFlow("phone_hello", "protocol=\(Self.pairingProtocolVersion) trigger=\(reason)")
|
|
2124
|
+
sendJson(["type": "phone_hello", "pairing_protocol": Self.pairingProtocolVersion], wakeUp: false, requireAck: false)
|
|
2125
|
+
}
|
|
2126
|
+
if !pairSelectSentThisSession,
|
|
2127
|
+
MentraLiveConnectionAttemptPolicy.shouldSendPairSelect(
|
|
2128
|
+
explicitPairing: isExplicitPairingAttempt(),
|
|
2129
|
+
securePairingTarget: secureTarget
|
|
2130
|
+
)
|
|
2131
|
+
{
|
|
2132
|
+
pairSelectSentThisSession = true
|
|
2133
|
+
pairFlow("pair_select", "trigger=\(reason)")
|
|
2134
|
+
sendJson(["type": "pair_select"], wakeUp: false, requireAck: false)
|
|
1825
2135
|
}
|
|
1826
|
-
pairingYieldEndWorkItem = work
|
|
1827
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(windowMs), execute: work)
|
|
1828
2136
|
}
|
|
1829
2137
|
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
2138
|
+
private func resetSecurePairingSession() {
|
|
2139
|
+
securePairingConfirmed = false
|
|
2140
|
+
awaitingSecurePairingConfirmation = false
|
|
2141
|
+
cancelSecurePairingWait()
|
|
2142
|
+
cancelUnknownCapabilityWait()
|
|
2143
|
+
cancelSecureFirmwareRefresh()
|
|
2144
|
+
phoneHelloSentThisSession = false
|
|
2145
|
+
pairSelectSentThisSession = false
|
|
2146
|
+
pairingInfoSeenThisSession = false
|
|
2147
|
+
pairingInfoAutoAudio = false
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
/// Owner-loss destroy marks the manager dead. A later connect must build a new one.
|
|
2151
|
+
/// A live manager already holds the peripheral found during the pairing scan.
|
|
2152
|
+
var isManagerKilled: Bool {
|
|
2153
|
+
isKilled
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
/// Pairing/auth failures: CBError 14 or insufficient authentication/encryption.
|
|
2157
|
+
/// Connection timeouts and the glasses' 0x14 refusal are not.
|
|
2158
|
+
nonisolated static func isPairingAuthFailure(_ error: Error?) -> Bool {
|
|
1833
2159
|
guard let error = error as NSError? else { return false }
|
|
1834
2160
|
if error.domain == CBATTErrorDomain {
|
|
1835
2161
|
return error.code == CBATTError.insufficientAuthentication.rawValue
|
|
1836
2162
|
|| error.code == CBATTError.insufficientEncryption.rawValue
|
|
1837
2163
|
}
|
|
1838
2164
|
if error.domain == CBErrorDomain {
|
|
1839
|
-
return error
|
|
2165
|
+
return Self.isPeerRemovedPairingInformation(error)
|
|
1840
2166
|
}
|
|
1841
2167
|
return false
|
|
1842
2168
|
}
|
|
1843
2169
|
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
2170
|
+
nonisolated static func isPeerRemovedPairingInformation(_ error: Error?) -> Bool {
|
|
2171
|
+
guard let error = error as NSError?, error.domain == CBErrorDomain else { return false }
|
|
2172
|
+
return error.code == CBError.peerRemovedPairingInformation.rawValue
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
static let staleIosBondPromptedAtKey = "mentra_live_stale_ios_bond_prompted_at"
|
|
2176
|
+
|
|
2177
|
+
/// A new pairing attempt should be allowed to show the prompt again if it still fails.
|
|
2178
|
+
static func clearStaleIosBondPrompt() {
|
|
2179
|
+
UserDefaults.standard.removeObject(forKey: staleIosBondPromptedAtKey)
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
/// What a connect or link failure means for the next attempt.
|
|
2183
|
+
private enum ConnectFailureOutcome {
|
|
2184
|
+
/// Not a pairing failure: retry as usual.
|
|
2185
|
+
case retry
|
|
2186
|
+
/// An auth failure that did not settle anything yet: retry through the scan backoff,
|
|
2187
|
+
/// because a pending direct connect would repeat the failure without delay.
|
|
2188
|
+
case backoff
|
|
2189
|
+
/// Stop: the user must act, or the glasses rejected this phone.
|
|
2190
|
+
case stop
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
private func classifyConnectFailure(_ error: Error?) -> ConnectFailureOutcome {
|
|
2194
|
+
// iOS keeps keys the glasses no longer have. On an explicit attempt only the user can
|
|
2195
|
+
// fix that, in Settings > Bluetooth.
|
|
2196
|
+
if Self.isPeerRemovedPairingInformation(error), isExplicitPairingAttempt() {
|
|
2197
|
+
promptToForgetStaleIosBond()
|
|
2198
|
+
return .stop
|
|
2199
|
+
}
|
|
2200
|
+
if Self.isPairingAuthFailure(error) {
|
|
2201
|
+
let code = (error as NSError?)?.code ?? 0
|
|
2202
|
+
return recordAuthRejection(reason: "ios_auth_\(code)") ? .stop : .backoff
|
|
2203
|
+
}
|
|
2204
|
+
pairingStore.update("connect_failed") { $0.onConnectAttemptFailed() }
|
|
2205
|
+
return .retry
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
/// Internal so native tests can feed CoreBluetooth errors.
|
|
2209
|
+
/// - Returns: true when the caller must not reconnect.
|
|
2210
|
+
func stopAfterConnectFailure(_ error: Error?) -> Bool {
|
|
2211
|
+
switch classifyConnectFailure(error) {
|
|
2212
|
+
case .retry:
|
|
2213
|
+
return false
|
|
2214
|
+
case .backoff:
|
|
2215
|
+
if !isKilled { handleReconnection() }
|
|
2216
|
+
return true
|
|
2217
|
+
case .stop:
|
|
2218
|
+
return true
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/// Error 14 on an explicit attempt: show the forget-in-Settings screen once per attempt.
|
|
2223
|
+
private func promptToForgetStaleIosBond() {
|
|
2224
|
+
cancelPendingReconnect(reason: "stale_ios_bond")
|
|
2225
|
+
let now = Date().timeIntervalSince1970
|
|
2226
|
+
let promptedAt = UserDefaults.standard.double(forKey: Self.staleIosBondPromptedAtKey)
|
|
2227
|
+
guard MentraLiveConnectionAttemptPolicy.shouldPromptToForgetStaleIosBond(
|
|
2228
|
+
peerRemovedPairingInformation: true,
|
|
2229
|
+
promptedAt: promptedAt,
|
|
2230
|
+
now: now
|
|
2231
|
+
) else {
|
|
2232
|
+
pairFlow("stale_ios_bond", "prompt=suppressed")
|
|
2233
|
+
return
|
|
2234
|
+
}
|
|
2235
|
+
UserDefaults.standard.set(now, forKey: Self.staleIosBondPromptedAtKey)
|
|
2236
|
+
pairFlow("stale_ios_bond", "prompt=forget_bluetooth code=\(MentraLiveConnectionAttemptPolicy.iosForgetPairingCode)")
|
|
2237
|
+
Bridge.sendPairFailureEvent(
|
|
2238
|
+
"errors:pairingCouldNotStart",
|
|
2239
|
+
code: MentraLiveConnectionAttemptPolicy.iosForgetPairingCode
|
|
2240
|
+
)
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/// The saved glasses run secure-pairing firmware, learned from their last `pairing_info`.
|
|
2244
|
+
/// Legacy firmware never rejects a non-owner on purpose, so its failures are not owner loss.
|
|
2245
|
+
private func isSavedGlassesSecureFirmware() -> Bool {
|
|
2246
|
+
DeviceStore.shared.get("bluetooth", "mentra_live_secure_firmware") as? Bool == true
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
/// Remember what the connected glasses reported so later rejections can be read correctly.
|
|
2250
|
+
private func rememberSecureFirmware(_ secure: Bool) {
|
|
2251
|
+
guard isSavedGlassesSecureFirmware() != secure else { return }
|
|
1851
2252
|
Task { @MainActor in
|
|
1852
|
-
DeviceStore.shared.apply("bluetooth", "
|
|
1853
|
-
|
|
1854
|
-
|
|
2253
|
+
DeviceStore.shared.apply("bluetooth", "mentra_live_secure_firmware", secure)
|
|
2254
|
+
}
|
|
2255
|
+
Bridge.saveSetting("mentra_live_secure_firmware", secure)
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
/// One authentication-type failure. The state machine decides whether it settles a takeover
|
|
2259
|
+
/// (the one verification after `OWNER_CHANGED`) or completes the 3-strike streak.
|
|
2260
|
+
/// - Returns: true when this phone is now `pairing_rejected`.
|
|
2261
|
+
@discardableResult
|
|
2262
|
+
private func recordAuthRejection(reason: String) -> Bool {
|
|
2263
|
+
let explicitAttempt = isExplicitPairingAttempt()
|
|
2264
|
+
let secureFirmware = isSavedGlassesSecureFirmware()
|
|
2265
|
+
let transition = pairingStore.update(reason) {
|
|
2266
|
+
$0.onAuthRejection(
|
|
2267
|
+
secureFirmware: secureFirmware,
|
|
2268
|
+
explicitAttempt: explicitAttempt,
|
|
2269
|
+
nowMs: MentraLivePairingStateStore.nowMs()
|
|
2270
|
+
)
|
|
2271
|
+
}
|
|
2272
|
+
Bridge.log("LIVE: Auth rejection (\(reason)) — \(transition.note)")
|
|
2273
|
+
applyPairingTransition(transition)
|
|
2274
|
+
return transition.effect == .reject
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
/// A restarted app keeps the pause until its deadline and keeps a rejection.
|
|
2278
|
+
private func restorePairingState() {
|
|
2279
|
+
let savedName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME) ?? ""
|
|
2280
|
+
guard let transition = pairingStore.ensureRestored(savedDeviceId: savedName) else {
|
|
2281
|
+
if pairingStore.isPaused {
|
|
2282
|
+
armPairingPauseTimer()
|
|
2283
|
+
startPairingWatchScan(reason: "manager_init")
|
|
2284
|
+
}
|
|
2285
|
+
return
|
|
2286
|
+
}
|
|
2287
|
+
if transition.effect == .pause {
|
|
2288
|
+
armPairingPauseTimer()
|
|
2289
|
+
startPairingWatchScan(reason: "restored_pause")
|
|
1855
2290
|
}
|
|
1856
|
-
Bridge.saveSetting("default_wearable", "")
|
|
1857
|
-
Bridge.saveSetting("device_name", "")
|
|
1858
|
-
Bridge.saveSetting("device_address", "")
|
|
1859
|
-
Bridge.sendTypedMessage("owner_replaced", body: ["reason": reason])
|
|
1860
2291
|
}
|
|
1861
2292
|
|
|
1862
2293
|
func connectById(_ deviceName: String) {
|
|
1863
|
-
|
|
1864
|
-
|
|
2294
|
+
// A destroyed manager stays inert; DeviceManager builds a new one for the next connect.
|
|
2295
|
+
if isKilled {
|
|
2296
|
+
pairFlow("connect_by_id", "blocked reason=manager_killed name=\(deviceName)")
|
|
2297
|
+
return
|
|
2298
|
+
}
|
|
2299
|
+
if !isExplicitPairingAttempt() {
|
|
2300
|
+
pairingStore.update("connect_by_id") { $0.bindDevice(deviceName) }
|
|
2301
|
+
}
|
|
2302
|
+
if automaticConnectBlocked() {
|
|
2303
|
+
// Paused for a pairing window or rejected. Keep the saved glasses; a pause watches
|
|
2304
|
+
// the advertisement and resumes on its own, a rejection waits for Pair again.
|
|
2305
|
+
UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
|
|
2306
|
+
pairFlow("connect_by_id", "deferred state=\(pairingStore.describe()) name=\(deviceName)")
|
|
2307
|
+
if pairingStore.isPaused {
|
|
2308
|
+
armPairingPauseTimer()
|
|
2309
|
+
startPairingWatchScan(reason: "connect_by_id")
|
|
2310
|
+
}
|
|
1865
2311
|
return
|
|
1866
2312
|
}
|
|
1867
|
-
|
|
2313
|
+
pairFlow("connect_by_id", "name=\(deviceName)")
|
|
1868
2314
|
cancelPendingReconnect(reason: "connect_by_id")
|
|
1869
2315
|
// Save the device name for future reconnection
|
|
1870
2316
|
UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
|
|
1871
2317
|
|
|
1872
|
-
// Start scanning to find this specific device
|
|
1873
2318
|
if centralManager == nil {
|
|
1874
2319
|
centralManager = CBCentralManager(
|
|
1875
2320
|
delegate: self, queue: bluetoothQueue,
|
|
@@ -1877,37 +2322,111 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1877
2322
|
)
|
|
1878
2323
|
}
|
|
1879
2324
|
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
2325
|
+
attachKnownOrScan(deviceName)
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
/// Connect the peripheral this phone already found. After the BLE bond the
|
|
2329
|
+
/// glasses stop general advertising, so a new name scan never sees them.
|
|
2330
|
+
private func attachKnownOrScan(_ deviceName: String) {
|
|
2331
|
+
if isConnecting {
|
|
2332
|
+
pairFlow("attach", "decision=already_connecting name=\(deviceName)")
|
|
2333
|
+
return
|
|
2334
|
+
}
|
|
2335
|
+
if txCharacteristic != nil, connectedPeripheral?.name == deviceName {
|
|
2336
|
+
pairFlow(
|
|
2337
|
+
"attach",
|
|
2338
|
+
"decision=live_gatt id=\(connectedPeripheral?.identifier.uuidString ?? "-") name=\(deviceName)"
|
|
2339
|
+
)
|
|
2340
|
+
return
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
let poweredOn = centralManager?.state == .poweredOn
|
|
2344
|
+
let cached = discoveredPeripherals[deviceName]
|
|
2345
|
+
let savedId = savedPeripheralId(for: deviceName)
|
|
2346
|
+
let systemMatch = poweredOn ? systemConnectedPeripheral(deviceName: deviceName, savedId: savedId) : nil
|
|
2347
|
+
let route = MentraLiveKnownConnectRoute.decide(
|
|
2348
|
+
bluetoothPoweredOn: poweredOn,
|
|
2349
|
+
hasCachedPeripheral: cached != nil,
|
|
2350
|
+
hasSavedIdentifier: savedId != nil,
|
|
2351
|
+
systemConnectedMatch: systemMatch != nil
|
|
2352
|
+
)
|
|
2353
|
+
pairFlow(
|
|
2354
|
+
"attach",
|
|
2355
|
+
"decision=\(route.rawValue) name=\(deviceName) cached=\(cached != nil) savedId=\(savedId?.uuidString ?? "-") system=\(systemMatch?.identifier.uuidString ?? "-") central=\(centralManager?.state.rawValue ?? -1)"
|
|
2356
|
+
)
|
|
2357
|
+
|
|
2358
|
+
switch route {
|
|
2359
|
+
case .waitForPower:
|
|
2360
|
+
return
|
|
2361
|
+
case .cachedPeripheral:
|
|
2362
|
+
if let cached {
|
|
2363
|
+
connectToDevice(cached)
|
|
2364
|
+
}
|
|
2365
|
+
case .savedIdentifier:
|
|
2366
|
+
if let savedId,
|
|
2367
|
+
let peripheral = centralManager?.retrievePeripherals(withIdentifiers: [savedId]).first
|
|
1889
2368
|
{
|
|
1890
|
-
|
|
1891
|
-
discoveredPeripherals[
|
|
1892
|
-
|
|
2369
|
+
rememberPeripheral(peripheral, name: deviceName)
|
|
2370
|
+
discoveredPeripherals[deviceName] = peripheral
|
|
2371
|
+
connectToDevice(peripheral)
|
|
2372
|
+
} else {
|
|
2373
|
+
pairFlow("attach", "decision=saved_identifier_missing id=\(savedId?.uuidString ?? "-") fallback=scan")
|
|
2374
|
+
startScan(reason: "saved_identifier_missing")
|
|
2375
|
+
}
|
|
2376
|
+
case .alreadyConnected:
|
|
2377
|
+
if let systemMatch {
|
|
2378
|
+
let matchedName = systemMatch.name ?? deviceName
|
|
2379
|
+
discoveredPeripherals[matchedName] = systemMatch
|
|
2380
|
+
rememberPeripheral(systemMatch, name: deviceName)
|
|
2381
|
+
connectToDevice(systemMatch)
|
|
2382
|
+
}
|
|
2383
|
+
case .scan:
|
|
2384
|
+
if isScanning {
|
|
2385
|
+
pairFlow("attach", "decision=already_scanning name=\(deviceName)")
|
|
2386
|
+
return
|
|
2387
|
+
}
|
|
2388
|
+
startScan(reason: "no_known_peripheral")
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
1893
2391
|
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
2392
|
+
private func savedPeripheralId(for name: String) -> UUID? {
|
|
2393
|
+
let map = UserDefaults.standard.dictionary(forKey: PREFS_PERIPHERAL_IDS) as? [String: String]
|
|
2394
|
+
guard let raw = map?[name] else {
|
|
2395
|
+
return nil
|
|
2396
|
+
}
|
|
2397
|
+
return UUID(uuidString: raw)
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
private func rememberPeripheral(_ peripheral: CBPeripheral, name: String) {
|
|
2401
|
+
var map = UserDefaults.standard.dictionary(forKey: PREFS_PERIPHERAL_IDS) as? [String: String] ?? [:]
|
|
2402
|
+
map[name] = peripheral.identifier.uuidString
|
|
2403
|
+
UserDefaults.standard.set(map, forKey: PREFS_PERIPHERAL_IDS)
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
private func systemConnectedPeripheral(deviceName: String, savedId: UUID?) -> CBPeripheral? {
|
|
2407
|
+
let connected = centralManager?.retrieveConnectedPeripherals(withServices: [SERVICE_UUID]) ?? []
|
|
2408
|
+
pairFlow("system_connected", "count=\(connected.count)")
|
|
2409
|
+
for peripheral in connected {
|
|
2410
|
+
let name = peripheral.name ?? "-"
|
|
2411
|
+
let matches = MentraLivePendingPairingTarget.matches(
|
|
2412
|
+
connectedName: peripheral.name ?? "",
|
|
2413
|
+
connectedIdentifier: peripheral.identifier.uuidString,
|
|
2414
|
+
pendingName: deviceName,
|
|
2415
|
+
pendingIdentifier: savedId?.uuidString ?? ""
|
|
2416
|
+
)
|
|
2417
|
+
pairFlow(
|
|
2418
|
+
"system_connected",
|
|
2419
|
+
"name=\(name) id=\(peripheral.identifier.uuidString) match=\(matches)"
|
|
2420
|
+
)
|
|
2421
|
+
if matches {
|
|
2422
|
+
return peripheral
|
|
1906
2423
|
}
|
|
1907
2424
|
}
|
|
2425
|
+
return nil
|
|
2426
|
+
}
|
|
1908
2427
|
|
|
1909
|
-
|
|
1910
|
-
|
|
2428
|
+
private func pairFlow(_ event: String, _ detail: String) {
|
|
2429
|
+
Bridge.log("PAIRFLOW: \(event) \(detail)")
|
|
1911
2430
|
}
|
|
1912
2431
|
|
|
1913
2432
|
func getConnectedBluetoothName() -> String? {
|
|
@@ -1919,7 +2438,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1919
2438
|
Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
|
|
1920
2439
|
return
|
|
1921
2440
|
}
|
|
1922
|
-
|
|
2441
|
+
pairFlow(
|
|
2442
|
+
"disconnect",
|
|
2443
|
+
"name=\(connectedPeripheral?.name ?? "-") id=\(connectedPeripheral?.identifier.uuidString ?? "-") scanning=\(isScanning) connecting=\(isConnecting)"
|
|
2444
|
+
)
|
|
1923
2445
|
|
|
1924
2446
|
// if rgbLedAuthorityClaimed {
|
|
1925
2447
|
// sendRgbLedControlAuthority(false)
|
|
@@ -2351,25 +2873,26 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2351
2873
|
|
|
2352
2874
|
// MARK: - BLE Scanning
|
|
2353
2875
|
|
|
2354
|
-
private func startScan() {
|
|
2355
|
-
if pairingYieldActive {
|
|
2356
|
-
Bridge.log("LIVE: startScan blocked — pairing yield active")
|
|
2357
|
-
return
|
|
2358
|
-
}
|
|
2359
|
-
|
|
2876
|
+
private func startScan(reason: String = "scan") {
|
|
2360
2877
|
guard centralManager!.state == .poweredOn else {
|
|
2361
|
-
|
|
2878
|
+
pairFlow("scan", "blocked reason=bluetooth_off trigger=\(reason) central=\(centralManager?.state.rawValue ?? -1)")
|
|
2362
2879
|
return
|
|
2363
2880
|
}
|
|
2364
2881
|
|
|
2365
|
-
|
|
2882
|
+
pairFlow("scan", "start trigger=\(reason) target=\(UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME) ?? "-")")
|
|
2366
2883
|
discoveredAdvPairing.removeAll()
|
|
2884
|
+
loggedUnnamedDiscoveries.removeAll()
|
|
2367
2885
|
isScanning = true
|
|
2886
|
+
pairingWatchScanActive = false
|
|
2368
2887
|
|
|
2369
2888
|
startReadinessCheckLoop()
|
|
2370
2889
|
|
|
2890
|
+
// The choose-glasses scan has to hear a later advertisement when the glasses
|
|
2891
|
+
// enter pairing mode. iOS reports each peripheral once unless duplicates are
|
|
2892
|
+
// allowed, so the row would stay "Not in pairing mode". Reconnect scans stay
|
|
2893
|
+
// duplicate-free so a saved glasses advertisement cannot start another connect.
|
|
2371
2894
|
let scanOptions: [String: Any] = [
|
|
2372
|
-
CBCentralManagerScanOptionAllowDuplicatesKey:
|
|
2895
|
+
CBCentralManagerScanOptionAllowDuplicatesKey: manualDiscoveryActive,
|
|
2373
2896
|
]
|
|
2374
2897
|
|
|
2375
2898
|
// let knownPeripherals = centralManager?.retrieveConnectedPeripherals(withServices: [SERVICE_UUID])
|
|
@@ -2403,18 +2926,24 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2403
2926
|
centralManager?.stopScan()
|
|
2404
2927
|
isScanning = false
|
|
2405
2928
|
DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searching", false)
|
|
2406
|
-
|
|
2929
|
+
pairFlow("scan", "stop")
|
|
2407
2930
|
}
|
|
2408
2931
|
|
|
2409
2932
|
// MARK: - Connection Management
|
|
2410
2933
|
|
|
2411
2934
|
private func connectToDevice(_ peripheral: CBPeripheral) {
|
|
2412
|
-
if
|
|
2413
|
-
|
|
2935
|
+
if automaticConnectBlocked() {
|
|
2936
|
+
pairFlow(
|
|
2937
|
+
"connect_device",
|
|
2938
|
+
"blocked state=\(pairingStore.describe()) id=\(peripheral.identifier.uuidString)"
|
|
2939
|
+
)
|
|
2414
2940
|
isConnecting = false
|
|
2415
2941
|
return
|
|
2416
2942
|
}
|
|
2417
|
-
|
|
2943
|
+
pairFlow(
|
|
2944
|
+
"connect_device",
|
|
2945
|
+
"id=\(peripheral.identifier.uuidString) name=\(peripheral.name ?? "-") state=\(peripheral.state.rawValue)"
|
|
2946
|
+
)
|
|
2418
2947
|
|
|
2419
2948
|
// A timed attempt supersedes any pending reconnect: withdraw one aimed at other
|
|
2420
2949
|
// glasses, and drop tracking for this one so its settle timer cannot touch this attempt.
|
|
@@ -2445,7 +2974,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2445
2974
|
guard let centralManager,
|
|
2446
2975
|
MentraLiveConnectionAttemptPolicy.shouldReconnectDirectly(
|
|
2447
2976
|
isKilled: isKilled,
|
|
2448
|
-
|
|
2977
|
+
// A window just ended: scan first so its advertisement is read.
|
|
2978
|
+
pairingYieldActive: automaticConnectBlocked() || pairingStore.awaitingTakeoverCheck,
|
|
2449
2979
|
bluetoothPoweredOn: centralManager.state == .poweredOn,
|
|
2450
2980
|
peripheralName: peripheral.name,
|
|
2451
2981
|
savedDeviceName: UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME)
|
|
@@ -2476,9 +3006,13 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2476
3006
|
// The default requirement lets the system complete that authorization flow
|
|
2477
3007
|
// before the glasses subscribe; apps that do not relay notifications can opt out.
|
|
2478
3008
|
Bridge.log("LIVE: ANCS connection requirement \(requiresAncs ? "enabled" : "disabled")")
|
|
3009
|
+
Bridge.log("LIVE: Transport bridging \(Self.enableTransportBridging ? "enabled" : "disabled")")
|
|
2479
3010
|
centralManager?.connect(
|
|
2480
3011
|
peripheral,
|
|
2481
|
-
options: MentraLiveConnectionOptions.coreBluetoothOptions(
|
|
3012
|
+
options: MentraLiveConnectionOptions.coreBluetoothOptions(
|
|
3013
|
+
requiresAncs: requiresAncs,
|
|
3014
|
+
enableTransportBridging: Self.enableTransportBridging
|
|
3015
|
+
)
|
|
2482
3016
|
)
|
|
2483
3017
|
#endif
|
|
2484
3018
|
}
|
|
@@ -2566,8 +3100,11 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2566
3100
|
return
|
|
2567
3101
|
}
|
|
2568
3102
|
|
|
2569
|
-
if
|
|
2570
|
-
Bridge.log("LIVE: Reconnection aborted - pairing
|
|
3103
|
+
if automaticConnectBlocked() {
|
|
3104
|
+
Bridge.log("LIVE: Reconnection aborted - pairing state \(pairingStore.describe())")
|
|
3105
|
+
if pairingStore.isPaused {
|
|
3106
|
+
startPairingWatchScan(reason: "reconnect_while_paused")
|
|
3107
|
+
}
|
|
2571
3108
|
return
|
|
2572
3109
|
}
|
|
2573
3110
|
|
|
@@ -2604,8 +3141,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2604
3141
|
guard let self = self else { return }
|
|
2605
3142
|
|
|
2606
3143
|
// Use peripheral presence, not connectionState: we stay CONNECTING during backoff until scan/connect.
|
|
2607
|
-
if self.
|
|
2608
|
-
Bridge.log("LIVE: Reconnection aborted - pairing
|
|
3144
|
+
if self.automaticConnectBlocked() {
|
|
3145
|
+
Bridge.log("LIVE: Reconnection aborted - pairing state \(self.pairingStore.describe())")
|
|
2609
3146
|
return
|
|
2610
3147
|
}
|
|
2611
3148
|
if self.connectedPeripheral == nil, !self.isKilled {
|
|
@@ -2840,10 +3377,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2840
3377
|
|
|
2841
3378
|
switch type {
|
|
2842
3379
|
case "glasses_ready":
|
|
2843
|
-
// A queued glasses_ready from the connection
|
|
2844
|
-
// must not
|
|
2845
|
-
if
|
|
2846
|
-
Bridge.log("LIVE: Ignoring stale glasses_ready during pairing
|
|
3380
|
+
// A queued glasses_ready from the connection just left for a pairing window
|
|
3381
|
+
// must not count as a session. Only accept readiness on a live GATT.
|
|
3382
|
+
if pairingStore.isPaused && connectedPeripheral == nil {
|
|
3383
|
+
Bridge.log("LIVE: Ignoring stale glasses_ready during pairing pause")
|
|
2847
3384
|
return
|
|
2848
3385
|
}
|
|
2849
3386
|
// glasses_ready is a REMOTE wire-session reset: the glasses ran
|
|
@@ -2851,10 +3388,6 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2851
3388
|
// framing regardless of what this side negotiated earlier. Start a fresh
|
|
2852
3389
|
// wire epoch (clears v2-active state that would otherwise gate the
|
|
2853
3390
|
// handshake off), then negotiate from the caps this message advertises.
|
|
2854
|
-
pairingYieldAwaitingReclaim = false
|
|
2855
|
-
pairingYieldActive = false
|
|
2856
|
-
pairingYieldEndWorkItem?.cancel()
|
|
2857
|
-
pairingYieldEndWorkItem = nil
|
|
2858
3391
|
resetWireNegotiationState()
|
|
2859
3392
|
parsePeerWireCaps(json)
|
|
2860
3393
|
maybeSendWireHandshake()
|
|
@@ -3050,25 +3583,113 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3050
3583
|
Bridge.log("LIVE: Received pong response - connection healthy")
|
|
3051
3584
|
|
|
3052
3585
|
case "entering_pairing_mode":
|
|
3053
|
-
let windowMs =
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
let
|
|
3057
|
-
|
|
3058
|
-
"
|
|
3586
|
+
let windowMs = json["window_ms"] as? Int
|
|
3587
|
+
let windowId = json["window_id"] as? Int
|
|
3588
|
+
let ownerRetained = json["owner_retained"] as? Bool ?? false
|
|
3589
|
+
let enterReason = json["reason"] as? String ?? "user_gesture"
|
|
3590
|
+
Bridge.log(
|
|
3591
|
+
"PAIRFLOW: entering_pairing_mode reason=\(enterReason) window_ms=\(windowMs ?? -1) window_id=\(windowId ?? -1) owner_retained=\(ownerRetained)"
|
|
3592
|
+
)
|
|
3593
|
+
var enterBody: [String: Any] = [
|
|
3594
|
+
"window_ms": windowMs ?? MentraLiveConnectionAttemptPolicy.defaultPairingWindowMs,
|
|
3595
|
+
"reason": enterReason,
|
|
3596
|
+
"owner_retained": ownerRetained,
|
|
3059
3597
|
]
|
|
3060
|
-
|
|
3598
|
+
if let windowId { enterBody["window_id"] = windowId }
|
|
3599
|
+
Bridge.sendTypedMessage("entering_pairing_mode", body: enterBody)
|
|
3600
|
+
let explicitAttempt = isExplicitPairingAttempt()
|
|
3601
|
+
let deviceId = connectedPeripheral?.name ?? UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME) ?? ""
|
|
3602
|
+
let enterTransition = pairingStore.update("entering_pairing_mode") {
|
|
3603
|
+
$0.onEnteringPairingMode(
|
|
3604
|
+
windowMs: windowMs,
|
|
3605
|
+
windowId: windowId,
|
|
3606
|
+
ownerRetained: ownerRetained,
|
|
3607
|
+
explicitAttempt: explicitAttempt,
|
|
3608
|
+
deviceId: deviceId,
|
|
3609
|
+
nowMs: MentraLivePairingStateStore.nowMs()
|
|
3610
|
+
)
|
|
3611
|
+
}
|
|
3612
|
+
applyPairingTransition(enterTransition)
|
|
3613
|
+
|
|
3614
|
+
case "pairing_mode_ended":
|
|
3615
|
+
let endedReason = json["reason"] as? String
|
|
3616
|
+
let endedWindowId = json["window_id"] as? Int
|
|
3617
|
+
Bridge.log("PAIRFLOW: pairing_mode_ended reason=\(endedReason ?? "-") window_id=\(endedWindowId ?? -1)")
|
|
3618
|
+
var endedBody: [String: Any] = ["reason": endedReason ?? ""]
|
|
3619
|
+
if let endedWindowId { endedBody["window_id"] = endedWindowId }
|
|
3620
|
+
Bridge.sendTypedMessage("pairing_mode_ended", body: endedBody)
|
|
3621
|
+
let endedTransition = pairingStore.update("pairing_mode_ended") {
|
|
3622
|
+
$0.onPairingModeEnded(reason: endedReason, windowId: endedWindowId)
|
|
3623
|
+
}
|
|
3624
|
+
applyPairingTransition(endedTransition)
|
|
3061
3625
|
|
|
3062
3626
|
case "pairing_info":
|
|
3627
|
+
Bridge.log(
|
|
3628
|
+
"PAIRFLOW: pairing_info secure=\(json["secure_pairing_capable"] as? Bool ?? false) classic_ready=\(json["classic_bond_ready"] as? Bool ?? false) had_previous=\(json["had_previous_bond"] as? Bool ?? false) transfer=\(json["ownership_transfer"] as? Bool ?? false) owner_committed=\(json["owner_committed"] as? Bool ?? false) auto_audio=\(json["auto_audio"] as? Bool ?? false) code=<redacted>"
|
|
3629
|
+
)
|
|
3630
|
+
let ownerCommitted = json["owner_committed"] as? Bool == true
|
|
3631
|
+
// Legacy firmware that omits this field is not secure-capable.
|
|
3632
|
+
let securePairingCapable = json["secure_pairing_capable"] as? Bool ?? false
|
|
3633
|
+
// Read before any readiness below: the audio wait of a pairing depends on it.
|
|
3634
|
+
pairingInfoAutoAudio = json["auto_audio"] as? Bool ?? false
|
|
3635
|
+
rememberSecureFirmware(securePairingCapable)
|
|
3636
|
+
if securePairingCapable {
|
|
3637
|
+
pairingInfoSeenThisSession = true
|
|
3638
|
+
cancelSecureFirmwareRefresh()
|
|
3639
|
+
sendPairingSessionMessages(reason: "pairing_info")
|
|
3640
|
+
}
|
|
3641
|
+
// Readiness parked on an unknown capability: this pairing_info decides the rule.
|
|
3642
|
+
var legacyReadiness = false
|
|
3643
|
+
if awaitingPairingInfoForUnknownCapability {
|
|
3644
|
+
cancelUnknownCapabilityWait()
|
|
3645
|
+
if securePairingCapable {
|
|
3646
|
+
pairFlow("readiness", "capability=unknown pairing_info=secure action=await_owner_commit")
|
|
3647
|
+
awaitingSecurePairingConfirmation = true
|
|
3648
|
+
if !ownerCommitted { armSecurePairingWait() }
|
|
3649
|
+
} else {
|
|
3650
|
+
pairFlow("readiness", "capability=unknown pairing_info=legacy action=ready")
|
|
3651
|
+
legacyReadiness = true
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
// Only owner_committed proves the bond. secure_pairing_capable is a capability flag
|
|
3655
|
+
// readable on the unencrypted window session.
|
|
3656
|
+
if MentraLiveConnectionAttemptPolicy.pairingInfoConfirmsOwner(ownerCommitted: ownerCommitted) {
|
|
3657
|
+
securePairingConfirmed = true
|
|
3658
|
+
cancelSecurePairingWait()
|
|
3659
|
+
if awaitingSecurePairingConfirmation {
|
|
3660
|
+
Bridge.log("LIVE: pairing_info owner_committed — reporting ready")
|
|
3661
|
+
markFullyBooted()
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
if legacyReadiness { markFullyBooted() }
|
|
3063
3665
|
Bridge.sendPairingInfo(
|
|
3064
3666
|
hadPreviousBond: json["had_previous_bond"] as? Bool ?? false,
|
|
3065
3667
|
pairingCode: json["pairing_code"] as? String,
|
|
3066
3668
|
classicBondReady: json["classic_bond_ready"] as? Bool ?? false,
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3669
|
+
securePairingCapable: securePairingCapable,
|
|
3670
|
+
protocolVersion: json["protocol_version"] as? Int ?? 1,
|
|
3671
|
+
ownershipTransfer: json["ownership_transfer"] as? Bool ?? false,
|
|
3672
|
+
ownerCommitted: ownerCommitted,
|
|
3673
|
+
autoAudio: json["auto_audio"] as? Bool
|
|
3070
3674
|
)
|
|
3071
3675
|
|
|
3676
|
+
case "classic_link":
|
|
3677
|
+
// The glasses' own view of the phone's A2DP and HFP links to them. Preferred over
|
|
3678
|
+
// the phone's audio route, which another Bluetooth audio device can hold.
|
|
3679
|
+
let a2dp = json["a2dp"] as? Bool ?? false
|
|
3680
|
+
let hfp = json["hfp"] as? Bool ?? false
|
|
3681
|
+
pairFlow("classic_link", "a2dp=\(a2dp) hfp=\(hfp)")
|
|
3682
|
+
audioSetup.classicLink(a2dp: a2dp, hfp: hfp)
|
|
3683
|
+
refreshAudioSetup(trigger: "classic_link")
|
|
3684
|
+
#if !os(macOS)
|
|
3685
|
+
if !AudioSessionMonitor.glassesReportClassicLink {
|
|
3686
|
+
// From now on the phone signals come from passive route observation, never
|
|
3687
|
+
// from the category probe. Re-read them that way.
|
|
3688
|
+
AudioSessionMonitor.glassesReportClassicLink = true
|
|
3689
|
+
DeviceManager.shared.checkCurrentAudioDevice()
|
|
3690
|
+
}
|
|
3691
|
+
#endif
|
|
3692
|
+
|
|
3072
3693
|
case "imu_response", "imu_stream_response", "imu_gesture_response",
|
|
3073
3694
|
"imu_gesture_subscribed", "imu_ack", "imu_error":
|
|
3074
3695
|
// Handle IMU-related responses
|
|
@@ -4050,6 +4671,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
4050
4671
|
// MARK: - Message Handlers
|
|
4051
4672
|
|
|
4052
4673
|
private func handleGlassesReady() {
|
|
4674
|
+
pairFlow(
|
|
4675
|
+
"glasses_ready",
|
|
4676
|
+
"id=\(connectedPeripheral?.identifier.uuidString ?? "-") name=\(connectedPeripheral?.name ?? "-") gatt=\(txCharacteristic != nil)"
|
|
4677
|
+
)
|
|
4053
4678
|
Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
|
|
4054
4679
|
|
|
4055
4680
|
stopReadinessCheckLoop()
|
|
@@ -4107,11 +4732,142 @@ class MentraLive: NSObject, SGCManager {
|
|
|
4107
4732
|
}
|
|
4108
4733
|
}
|
|
4109
4734
|
|
|
4735
|
+
// handleDeviceReady promotes the pairing target to the default device. Secure glasses
|
|
4736
|
+
// own a phone only after a BLE bond and announce it with pairing_info; GATT works
|
|
4737
|
+
// unencrypted inside the open window, so readiness alone is not a pairing.
|
|
4738
|
+
let readiness = MentraLiveConnectionAttemptPolicy.readinessAtGlassesReady(
|
|
4739
|
+
explicitPairing: isExplicitPairingAttempt(),
|
|
4740
|
+
selectedCapability: selectedTargetSecureCapability(),
|
|
4741
|
+
securePairingInfoThisSession: pairingInfoSeenThisSession,
|
|
4742
|
+
securePairingConfirmed: securePairingConfirmed
|
|
4743
|
+
)
|
|
4744
|
+
switch readiness {
|
|
4745
|
+
case .ready:
|
|
4746
|
+
markFullyBooted()
|
|
4747
|
+
case .awaitOwnerCommit:
|
|
4748
|
+
Bridge.log("LIVE: glasses_ready before owner commit — waiting for pairing_info")
|
|
4749
|
+
awaitingSecurePairingConfirmation = true
|
|
4750
|
+
armSecurePairingWait()
|
|
4751
|
+
case .awaitPairingInfo:
|
|
4752
|
+
pairFlow("readiness", "capability=unknown action=await_pairing_info waitMs=\(unknownCapabilityPairingInfoWaitMs)")
|
|
4753
|
+
armUnknownCapabilityWait()
|
|
4754
|
+
}
|
|
4755
|
+
}
|
|
4756
|
+
|
|
4757
|
+
private func markFullyBooted() {
|
|
4758
|
+
// A session that reached ready means the glasses still admit this phone. Read the
|
|
4759
|
+
// attempt kind first: setting fullyBooted runs handleDeviceReady, which clears pending.
|
|
4760
|
+
let explicitAttempt = isExplicitPairingAttempt()
|
|
4761
|
+
pairingStore.update("session_ready") { $0.onSessionReady(explicitAttempt: explicitAttempt) }
|
|
4762
|
+
awaitingSecurePairingConfirmation = false
|
|
4763
|
+
cancelSecurePairingWait()
|
|
4764
|
+
cancelUnknownCapabilityWait()
|
|
4765
|
+
armSecureFirmwareRefresh()
|
|
4766
|
+
if !fullyBooted {
|
|
4767
|
+
audioSetup.startPhase(
|
|
4768
|
+
explicitAttempt ? .pairing(autoAudio: pairingInfoAutoAudio) : .reconnect,
|
|
4769
|
+
nowMs: audioSetupNowMs()
|
|
4770
|
+
)
|
|
4771
|
+
}
|
|
4772
|
+
// Publish audioSetup before readiness so the ready update never carries a stale idle.
|
|
4773
|
+
refreshAudioSetup(trigger: "session_ready", sessionReady: true)
|
|
4110
4774
|
fullyBooted = true
|
|
4111
4775
|
connected = true
|
|
4112
4776
|
updateConnectionState(ConnTypes.CONNECTED)
|
|
4113
4777
|
}
|
|
4114
4778
|
|
|
4779
|
+
/// The selected scan row's capability (`pending_device_secure_pairing_capable`); nil when
|
|
4780
|
+
/// unknown, for example a target re-listed from saved state.
|
|
4781
|
+
private func selectedTargetSecureCapability() -> Bool? {
|
|
4782
|
+
DeviceStore.shared.get("bluetooth", "pending_device_secure_pairing_capable") as? Bool
|
|
4783
|
+
}
|
|
4784
|
+
|
|
4785
|
+
/// Unknown capability: a secure pairing_info within the wait switches to the secure rule,
|
|
4786
|
+
/// none means legacy firmware and the glasses are reported ready.
|
|
4787
|
+
private func armUnknownCapabilityWait() {
|
|
4788
|
+
cancelUnknownCapabilityWait()
|
|
4789
|
+
awaitingPairingInfoForUnknownCapability = true
|
|
4790
|
+
let work = DispatchWorkItem { [weak self] in
|
|
4791
|
+
guard let self, self.awaitingPairingInfoForUnknownCapability else { return }
|
|
4792
|
+
self.unknownCapabilityWaitWorkItem = nil
|
|
4793
|
+
self.awaitingPairingInfoForUnknownCapability = false
|
|
4794
|
+
self.pairFlow("readiness", "capability=unknown pairing_info=none action=ready_legacy")
|
|
4795
|
+
self.markFullyBooted()
|
|
4796
|
+
}
|
|
4797
|
+
unknownCapabilityWaitWorkItem = work
|
|
4798
|
+
DispatchQueue.main.asyncAfter(
|
|
4799
|
+
deadline: .now() + .milliseconds(max(unknownCapabilityPairingInfoWaitMs, 0)),
|
|
4800
|
+
execute: work
|
|
4801
|
+
)
|
|
4802
|
+
}
|
|
4803
|
+
|
|
4804
|
+
private func cancelUnknownCapabilityWait() {
|
|
4805
|
+
awaitingPairingInfoForUnknownCapability = false
|
|
4806
|
+
unknownCapabilityWaitWorkItem?.cancel()
|
|
4807
|
+
unknownCapabilityWaitWorkItem = nil
|
|
4808
|
+
}
|
|
4809
|
+
|
|
4810
|
+
/// `mentra_live_secure_firmware` follows the current glasses: a ready session that gets no
|
|
4811
|
+
/// pairing_info runs legacy firmware (for example after a rollback).
|
|
4812
|
+
private func armSecureFirmwareRefresh() {
|
|
4813
|
+
guard secureFirmwareRefreshWorkItem == nil, !pairingInfoSeenThisSession else { return }
|
|
4814
|
+
let work = DispatchWorkItem { [weak self] in
|
|
4815
|
+
guard let self else { return }
|
|
4816
|
+
self.secureFirmwareRefreshWorkItem = nil
|
|
4817
|
+
guard !self.pairingInfoSeenThisSession else { return }
|
|
4818
|
+
self.pairFlow("secure_firmware", "pairing_info=none afterMs=\(self.secureFirmwareRefreshMs) secure=false")
|
|
4819
|
+
self.rememberSecureFirmware(false)
|
|
4820
|
+
}
|
|
4821
|
+
secureFirmwareRefreshWorkItem = work
|
|
4822
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(max(secureFirmwareRefreshMs, 0)), execute: work)
|
|
4823
|
+
}
|
|
4824
|
+
|
|
4825
|
+
private func cancelSecureFirmwareRefresh() {
|
|
4826
|
+
secureFirmwareRefreshWorkItem?.cancel()
|
|
4827
|
+
secureFirmwareRefreshWorkItem = nil
|
|
4828
|
+
}
|
|
4829
|
+
|
|
4830
|
+
/// Bond confirmation has to arrive on its own. A dismissed pairing sheet, or a bond that
|
|
4831
|
+
/// never finishes, must fail the attempt instead of leaving the loading screen up.
|
|
4832
|
+
private func armSecurePairingWait() {
|
|
4833
|
+
cancelSecurePairingWait()
|
|
4834
|
+
// Every parked secure readiness gets this deadline, so the pair can never hang.
|
|
4835
|
+
guard MentraLiveConnectionAttemptPolicy.shouldRunSecurePairingWait(
|
|
4836
|
+
explicitPairing: isExplicitPairingAttempt(),
|
|
4837
|
+
securePairingTarget: isSecurePairingTarget()
|
|
4838
|
+
) else { return }
|
|
4839
|
+
let work = DispatchWorkItem { [weak self] in
|
|
4840
|
+
guard let self else { return }
|
|
4841
|
+
guard self.awaitingSecurePairingConfirmation, self.isExplicitPairingAttempt(),
|
|
4842
|
+
!self.securePairingConfirmed
|
|
4843
|
+
else { return }
|
|
4844
|
+
self.awaitingSecurePairingConfirmation = false
|
|
4845
|
+
Bridge.log("LIVE: secure pairing bond wait expired")
|
|
4846
|
+
Bridge.sendPairFailureEvent("errors:pairingCouldNotStart")
|
|
4847
|
+
}
|
|
4848
|
+
securePairingWaitWorkItem = work
|
|
4849
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 40, execute: work)
|
|
4850
|
+
}
|
|
4851
|
+
|
|
4852
|
+
private func cancelSecurePairingWait() {
|
|
4853
|
+
securePairingWaitWorkItem?.cancel()
|
|
4854
|
+
securePairingWaitWorkItem = nil
|
|
4855
|
+
}
|
|
4856
|
+
|
|
4857
|
+
private func isSecurePairingTarget() -> Bool {
|
|
4858
|
+
MentraLiveConnectionAttemptPolicy.isSecurePairingTarget(
|
|
4859
|
+
selectedCapability: selectedTargetSecureCapability(),
|
|
4860
|
+
securePairingInfoThisSession: pairingInfoSeenThisSession
|
|
4861
|
+
)
|
|
4862
|
+
}
|
|
4863
|
+
|
|
4864
|
+
/// The user is pairing from the scan screen rather than reconnecting a saved owner.
|
|
4865
|
+
private func isExplicitPairingAttempt() -> Bool {
|
|
4866
|
+
let pendingName = DeviceStore.shared.get("bluetooth", "pending_device_name") as? String ?? ""
|
|
4867
|
+
let pendingAddress = DeviceStore.shared.get("bluetooth", "pending_device_address") as? String ?? ""
|
|
4868
|
+
return manualDiscoveryActive || !pendingName.isEmpty || !pendingAddress.isEmpty
|
|
4869
|
+
}
|
|
4870
|
+
|
|
4115
4871
|
private func handleWifiScanResult(_ json: [String: Any]) {
|
|
4116
4872
|
var networks: [[String: Any]] = []
|
|
4117
4873
|
|
|
@@ -5883,7 +6639,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5883
6639
|
private var readinessCheckDispatchTimer: DispatchSourceTimer?
|
|
5884
6640
|
|
|
5885
6641
|
private func startReadinessCheckLoop() {
|
|
5886
|
-
|
|
6642
|
+
pairFlow(
|
|
6643
|
+
"readiness",
|
|
6644
|
+
"start gatt=\(txCharacteristic != nil) id=\(connectedPeripheral?.identifier.uuidString ?? "-") name=\(connectedPeripheral?.name ?? "-")"
|
|
6645
|
+
)
|
|
5887
6646
|
stopReadinessCheckLoop()
|
|
5888
6647
|
|
|
5889
6648
|
readinessCheckCounter = 0
|
|
@@ -5903,8 +6662,15 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5903
6662
|
guard let self else { return }
|
|
5904
6663
|
|
|
5905
6664
|
self.readinessCheckCounter += 1
|
|
6665
|
+
let gattReady = self.txCharacteristic != nil
|
|
6666
|
+
if self.readinessCheckCounter == 1 || self.readinessCheckCounter % 4 == 0 {
|
|
6667
|
+
self.pairFlow(
|
|
6668
|
+
"readiness",
|
|
6669
|
+
"n=\(self.readinessCheckCounter) gatt=\(gattReady) id=\(self.connectedPeripheral?.identifier.uuidString ?? "-")"
|
|
6670
|
+
)
|
|
6671
|
+
}
|
|
5906
6672
|
Bridge.log(
|
|
5907
|
-
"LIVE: 🔄 Readiness check #\(self.readinessCheckCounter): waiting for glasses SOC to boot"
|
|
6673
|
+
"LIVE: 🔄 Readiness check #\(self.readinessCheckCounter): waiting for glasses SOC to boot gatt=\(gattReady)"
|
|
5908
6674
|
)
|
|
5909
6675
|
// requestReadyK900()
|
|
5910
6676
|
|
|
@@ -5935,8 +6701,15 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5935
6701
|
}
|
|
5936
6702
|
|
|
5937
6703
|
private func stopReadinessCheckLoop() {
|
|
6704
|
+
let wasRunning = readinessCheckDispatchTimer != nil
|
|
5938
6705
|
readinessCheckDispatchTimer?.cancel()
|
|
5939
6706
|
readinessCheckDispatchTimer = nil
|
|
6707
|
+
if wasRunning {
|
|
6708
|
+
pairFlow(
|
|
6709
|
+
"readiness",
|
|
6710
|
+
"stop n=\(readinessCheckCounter) gatt=\(txCharacteristic != nil) id=\(connectedPeripheral?.identifier.uuidString ?? "-")"
|
|
6711
|
+
)
|
|
6712
|
+
}
|
|
5940
6713
|
Bridge.log("LIVE: 🔄 Stopped glasses SOC readiness check loop")
|
|
5941
6714
|
}
|
|
5942
6715
|
|
|
@@ -5948,6 +6721,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5948
6721
|
guard let self else { return }
|
|
5949
6722
|
|
|
5950
6723
|
if self.isConnecting, self.connectionState != ConnTypes.CONNECTED {
|
|
6724
|
+
self.pairFlow(
|
|
6725
|
+
"connect_timeout",
|
|
6726
|
+
"id=\(self.connectedPeripheral?.identifier.uuidString ?? "-") name=\(self.connectedPeripheral?.name ?? "-") state=\(self.connectionState)"
|
|
6727
|
+
)
|
|
5951
6728
|
Bridge.log("LIVE: Connection timeout - closing GATT connection")
|
|
5952
6729
|
self.isConnecting = false
|
|
5953
6730
|
|
|
@@ -5982,7 +6759,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5982
6759
|
/// promotes it to the default device. Re-emit only that explicit pending target; an
|
|
5983
6760
|
/// established owner's connected glasses have no pending identity and remain hidden.
|
|
5984
6761
|
private func emitConnectedPendingDeviceForPairingScan() {
|
|
5985
|
-
guard manualDiscoveryActive,
|
|
6762
|
+
guard manualDiscoveryActive, let peripheral = connectedPeripheral,
|
|
5986
6763
|
let name = peripheral.name,
|
|
5987
6764
|
MentraLivePendingPairingTarget.shouldRecover(
|
|
5988
6765
|
isConnected: peripheral.state == .connected,
|
|
@@ -6008,17 +6785,35 @@ class MentraLive: NSObject, SGCManager {
|
|
|
6008
6785
|
)
|
|
6009
6786
|
}
|
|
6010
6787
|
|
|
6788
|
+
private func discoveredAdvertisementName(
|
|
6789
|
+
_ peripheral: CBPeripheral,
|
|
6790
|
+
advertisementData: [String: Any]
|
|
6791
|
+
) -> String? {
|
|
6792
|
+
if let name = peripheral.name, !name.isEmpty { return name }
|
|
6793
|
+
if let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String, !name.isEmpty {
|
|
6794
|
+
return name
|
|
6795
|
+
}
|
|
6796
|
+
return nil
|
|
6797
|
+
}
|
|
6798
|
+
|
|
6799
|
+
/// Returns true when this advertisement changed the row the scan screen is showing.
|
|
6800
|
+
@discardableResult
|
|
6011
6801
|
private func cacheAdvPairing(
|
|
6012
6802
|
_ name: String,
|
|
6013
6803
|
pairingMode: Bool,
|
|
6014
6804
|
pairingCode: String?,
|
|
6015
6805
|
securePairingCapable: Bool
|
|
6016
|
-
) {
|
|
6017
|
-
|
|
6806
|
+
) -> Bool {
|
|
6807
|
+
let next = CachedAdvPairing(
|
|
6018
6808
|
pairingMode: pairingMode,
|
|
6019
6809
|
pairingCode: pairingCode,
|
|
6020
6810
|
securePairingCapable: securePairingCapable
|
|
6021
6811
|
)
|
|
6812
|
+
if discoveredAdvPairing[name] == next {
|
|
6813
|
+
return false
|
|
6814
|
+
}
|
|
6815
|
+
discoveredAdvPairing[name] = next
|
|
6816
|
+
return true
|
|
6022
6817
|
}
|
|
6023
6818
|
|
|
6024
6819
|
private func emitDiscoveredDeviceFromCache(
|
|
@@ -6139,9 +6934,14 @@ class MentraLive: NSObject, SGCManager {
|
|
|
6139
6934
|
// MARK: - Cleanup
|
|
6140
6935
|
|
|
6141
6936
|
private func destroy() {
|
|
6142
|
-
|
|
6937
|
+
pairFlow(
|
|
6938
|
+
"destroy",
|
|
6939
|
+
"name=\(connectedPeripheral?.name ?? "-") id=\(connectedPeripheral?.identifier.uuidString ?? "-") scanning=\(isScanning)"
|
|
6940
|
+
)
|
|
6143
6941
|
|
|
6144
6942
|
isKilled = true
|
|
6943
|
+
// The pairing state outlives the manager; only its timer belongs to this instance.
|
|
6944
|
+
cancelPairingPauseTimer()
|
|
6145
6945
|
unpairFlushWorkItem?.cancel()
|
|
6146
6946
|
unpairFlushWorkItem = nil
|
|
6147
6947
|
unpairFlushPending = false
|
|
@@ -6185,6 +6985,92 @@ class MentraLive: NSObject, SGCManager {
|
|
|
6185
6985
|
centralManager = nil
|
|
6186
6986
|
|
|
6187
6987
|
updateConnectionState(ConnTypes.DISCONNECTED)
|
|
6988
|
+
resetSecurePairingSession()
|
|
6989
|
+
stopObservingAudioSetupInputs()
|
|
6990
|
+
}
|
|
6991
|
+
}
|
|
6992
|
+
|
|
6993
|
+
// MARK: - Audio setup
|
|
6994
|
+
|
|
6995
|
+
extension MentraLive {
|
|
6996
|
+
nonisolated static func monotonicNowMs() -> Int {
|
|
6997
|
+
Int(ProcessInfo.processInfo.systemUptime * 1000)
|
|
6998
|
+
}
|
|
6999
|
+
|
|
7000
|
+
private static let audioSetupInputKeys: Set<String> = [
|
|
7001
|
+
"fullyBooted", "bluetoothClassicConnected", "bluetoothClassicProfilesConnected", "bluetoothAudioRouted",
|
|
7002
|
+
]
|
|
7003
|
+
|
|
7004
|
+
/// Readiness (from any writer, including `sr_hrt ready=0`) and the phone's three audio
|
|
7005
|
+
/// signals re-evaluate the state in the same update that changed them.
|
|
7006
|
+
fileprivate func observeAudioSetupInputs() {
|
|
7007
|
+
guard audioSetupStoreListenerId == nil else { return }
|
|
7008
|
+
audioSetup.startPhase(.reconnect, nowMs: audioSetupNowMs())
|
|
7009
|
+
audioSetupStoreListenerId = DeviceStore.shared.store.addListener { [weak self] category, changes in
|
|
7010
|
+
MainActor.assumeIsolated {
|
|
7011
|
+
guard category == "glasses",
|
|
7012
|
+
changes.keys.contains(where: { MentraLive.audioSetupInputKeys.contains($0) })
|
|
7013
|
+
else { return }
|
|
7014
|
+
self?.refreshAudioSetup(trigger: "status")
|
|
7015
|
+
}
|
|
7016
|
+
}
|
|
7017
|
+
refreshAudioSetup(trigger: "init")
|
|
7018
|
+
}
|
|
7019
|
+
|
|
7020
|
+
fileprivate func stopObservingAudioSetupInputs() {
|
|
7021
|
+
if let id = audioSetupStoreListenerId {
|
|
7022
|
+
DeviceStore.shared.store.removeListener(id)
|
|
7023
|
+
audioSetupStoreListenerId = nil
|
|
7024
|
+
}
|
|
7025
|
+
audioSetupDeadlineWorkItem?.cancel()
|
|
7026
|
+
audioSetupDeadlineWorkItem = nil
|
|
7027
|
+
}
|
|
7028
|
+
|
|
7029
|
+
/// The phone's view: the Classic bond, a connected HFP input or a matching Bluetooth output.
|
|
7030
|
+
private func phoneAudioUp() -> Bool {
|
|
7031
|
+
["bluetoothClassicConnected", "bluetoothClassicProfilesConnected", "bluetoothAudioRouted"].contains {
|
|
7032
|
+
DeviceStore.shared.get("glasses", $0) as? Bool == true
|
|
7033
|
+
}
|
|
7034
|
+
}
|
|
7035
|
+
|
|
7036
|
+
/// The BLE session ended. Its `classic_link` report no longer applies.
|
|
7037
|
+
fileprivate func endAudioSetupSession() {
|
|
7038
|
+
audioSetup.endSession()
|
|
7039
|
+
#if !os(macOS)
|
|
7040
|
+
AudioSessionMonitor.glassesReportClassicLink = false
|
|
7041
|
+
#endif
|
|
7042
|
+
refreshAudioSetup(trigger: "disconnect")
|
|
7043
|
+
}
|
|
7044
|
+
|
|
7045
|
+
/// Feeds `GlassesAudioSetupPolicy`, arms one timer for its next deadline and publishes on change.
|
|
7046
|
+
/// Only the active driver publishes; a manager being created or torn down stays quiet.
|
|
7047
|
+
fileprivate func refreshAudioSetup(trigger: String, sessionReady: Bool? = nil) {
|
|
7048
|
+
guard (DeviceManager.shared.sgc as AnyObject?) === self else {
|
|
7049
|
+
audioSetupDeadlineWorkItem?.cancel()
|
|
7050
|
+
audioSetupDeadlineWorkItem = nil
|
|
7051
|
+
return
|
|
7052
|
+
}
|
|
7053
|
+
let nowMs = audioSetupNowMs()
|
|
7054
|
+
let decision = audioSetup.evaluate(
|
|
7055
|
+
sessionReady: sessionReady ?? fullyBooted,
|
|
7056
|
+
phoneAudioUp: phoneAudioUp(),
|
|
7057
|
+
nowMs: nowMs
|
|
7058
|
+
)
|
|
7059
|
+
audioSetupDeadlineWorkItem?.cancel()
|
|
7060
|
+
audioSetupDeadlineWorkItem = nil
|
|
7061
|
+
if let deadlineMs = decision.nextDeadlineMs {
|
|
7062
|
+
let work = DispatchWorkItem { [weak self] in
|
|
7063
|
+
guard let self else { return }
|
|
7064
|
+
self.audioSetupDeadlineWorkItem = nil
|
|
7065
|
+
self.refreshAudioSetup(trigger: "deadline")
|
|
7066
|
+
}
|
|
7067
|
+
audioSetupDeadlineWorkItem = work
|
|
7068
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(max(deadlineMs - nowMs, 0)), execute: work)
|
|
7069
|
+
}
|
|
7070
|
+
guard DeviceStore.shared.publishAudioSetup(decision.state) else { return }
|
|
7071
|
+
Bridge.log(
|
|
7072
|
+
"PAIRFLOW: audio_setup state=\(decision.state.name) phase=\(audioSetup.phase.logName) afterMs=\(nowMs - audioSetup.phaseStartedAtMs) trigger=\(trigger) glassesReport=\(audioSetup.glassesReportsClassicLink ? "a2dp:\(audioSetup.glassesA2dp),hfp:\(audioSetup.glassesHfp)" : "none") phoneAudio=\(phoneAudioUp())"
|
|
7073
|
+
)
|
|
6188
7074
|
}
|
|
6189
7075
|
}
|
|
6190
7076
|
|
|
@@ -6288,6 +7174,7 @@ extension MentraLive {
|
|
|
6288
7174
|
|
|
6289
7175
|
/// Sends phone_ready to (re)start the glasses-driven readiness flow.
|
|
6290
7176
|
private func sendPhoneReady(reason: String) {
|
|
7177
|
+
pairFlow("phone_ready", "reason=\(reason) gatt=\(txCharacteristic != nil) id=\(connectedPeripheral?.identifier.uuidString ?? "-")")
|
|
6291
7178
|
Bridge.log("LIVE: 📱 Sending phone_ready to glasses (\(reason)) - waiting for glasses_ready response")
|
|
6292
7179
|
let readyMsg: [String: Any] = [
|
|
6293
7180
|
"type": "phone_ready",
|