From ed13ba2625416cd0ee42743aa9d5e8a16d378169 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 1 Aug 2026 14:41:50 +0300 Subject: [PATCH] fix: cancel in-flight wake connects when proximity wake is disabled --- .../BLE/BLEProximityWakeSettings.swift | 11 +++++ bitchat/Services/BLE/BLERadioController.swift | 42 +++++++++++++------ bitchat/Services/BLE/BLEService.swift | 14 +++++++ .../BLEProximityWakeSettingsTests.swift | 18 ++++++++ 4 files changed, 73 insertions(+), 12 deletions(-) diff --git a/bitchat/Services/BLE/BLEProximityWakeSettings.swift b/bitchat/Services/BLE/BLEProximityWakeSettings.swift index 248a678e..bdc0d522 100644 --- a/bitchat/Services/BLE/BLEProximityWakeSettings.swift +++ b/bitchat/Services/BLE/BLEProximityWakeSettings.swift @@ -18,6 +18,10 @@ import Foundation enum BLEProximityWakeSettings { private static let enabledKey = "ble.proximityWakeEnabled" + /// Posted when the standard-store preference changes so BLE can cancel + /// any in-flight pending connects that would still wake the app. + static let didChangeNotification = Notification.Name("bitchat.bleProximityWakeSettingsDidChange") + /// When false, bitchat will not arm pending background connects. static var enabled: Bool { get { enabled(in: .standard) } @@ -32,11 +36,18 @@ enum BLEProximityWakeSettings { static func setEnabled(_ enabled: Bool, in defaults: UserDefaults) { defaults.set(enabled, forKey: enabledKey) + // Only the live preference store drives BLE wake behaviour. + if defaults === UserDefaults.standard { + NotificationCenter.default.post(name: didChangeNotification, object: nil) + } } /// Panic-wipe hook. Removing the key restores the on-by-default /// mesh-reachability preference of a fresh install. static func reset(in defaults: UserDefaults = .standard) { defaults.removeObject(forKey: enabledKey) + if defaults === UserDefaults.standard { + NotificationCenter.default.post(name: didChangeNotification, object: nil) + } } } diff --git a/bitchat/Services/BLE/BLERadioController.swift b/bitchat/Services/BLE/BLERadioController.swift index 419cce26..f316fb4c 100644 --- a/bitchat/Services/BLE/BLERadioController.swift +++ b/bitchat/Services/BLE/BLERadioController.swift @@ -252,12 +252,17 @@ final class BLERadioController { return } - if self.delegate?.radioIsAppActive() == false { - // Backgrounded: leave the connect pending. iOS never expires - // it — the controller completes it whenever the peer comes - // back into range, waking the app (state restoration - // relaunches us if we were terminated). Foreground return - // cancels stale pendings via cancelStalePendingConnects(). + if self.delegate?.radioIsAppActive() == false, + BLEProximityWakeSettings.enabled { + // Backgrounded with wake-on-proximity on: leave the connect + // pending. iOS never expires it — the controller completes it + // whenever the peer comes back into range, waking the app + // (state restoration relaunches us if we were terminated). + // Foreground return cancels stale pendings via + // cancelStalePendingConnects(). If the user opted out, fall + // through and cancel so an in-flight foreground connect that + // timed out after backgrounding cannot still trigger the + // iOS 26 accessory-open prompt (#1512 Codex). SecureLogger.info("🌙 Connect timeout deferred while backgrounded, left pending for wake-on-proximity: \(candidate.name)", category: .session) return } @@ -316,10 +321,8 @@ final class BLERadioController { ) { // Opt-out for people who don't want iOS 26's "accessory would like // to open bitchat" wake prompt (#1427 / #1396). Default stays on. - // Turning the setting off only prevents future arming — already - // pending connects stay until the next foreground cancel/re-arm - // cycle. That is fine in practice: the toggle lives in-app, so it - // cannot change while we are backgrounded and actively waiting. + // Disabling also cancels in-flight / pending connects via + // `cancelPendingWakeConnects()` (settings notification). guard BLEProximityWakeSettings.enabled else { return } queue.async { [weak self] in guard let self, @@ -368,20 +371,35 @@ final class BLERadioController { /// scheduler take over. Anything still nearby is rediscovered within /// seconds by the allow-duplicates foreground scan. func cancelStalePendingConnects() { + cancelPendingConnects(olderThan: TransportConfig.bleConnectTimeoutSeconds, reason: "stale pending connect(s) on foreground") + } + + /// Drop every still-connecting peripheral immediately. Used when the + /// user turns off wake-on-proximity so an already-armed or in-flight + /// connect cannot still wake the app after opt-out (#1512). + func cancelPendingWakeConnects() { + cancelPendingConnects(olderThan: 0, reason: "pending connect(s) after wake-on-proximity opt-out") + } + + private func cancelPendingConnects(olderThan minAge: TimeInterval, reason: String) { queue.async { [weak self] in guard let self, let central = self.central else { return } let now = Date() var cancelled = 0 for state in self.linkStateStore.peripheralStates where state.isConnecting && !state.isConnected { let age = state.lastConnectionAttempt.map { now.timeIntervalSince($0) } ?? .infinity - guard age > TransportConfig.bleConnectTimeoutSeconds else { continue } + // minAge == 0 means cancel every pending connect (opt-out); + // otherwise match the prior stale-foreground threshold (`>`). + if minAge > 0 { + guard age > minAge else { continue } + } let peripheralID = state.peripheral.identifier.uuidString central.cancelPeripheralConnection(state.peripheral) self.delegate?.radioTearDownPeripheralLink(peripheralID) cancelled += 1 } if cancelled > 0 { - SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session) + SecureLogger.info("🌅 Cancelled \(cancelled) \(reason)", category: .session) self.tryConnectFromQueue() } } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 0c4ecfb1..f3be64ed 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -568,6 +568,12 @@ final class BLEService: NSObject { name: UIApplication.didEnterBackgroundNotification, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(proximityWakeSettingDidChange), + name: BLEProximityWakeSettings.didChangeNotification, + object: nil + ) #endif // Tag BLE queue for re-entrancy detection @@ -5230,6 +5236,14 @@ extension BLEService { scheduleBluetoothStatusSample(after: 15.0, context: "background-15s") // No Local Name; nothing to refresh for advertising policy } + + @objc private func proximityWakeSettingDidChange() { + // Opt-out must drop already-armed / in-flight wake connects so the + // accessory-open prompt cannot fire after the user turned the setting + // off in App Info (#1512 Codex). + guard !BLEProximityWakeSettings.enabled else { return } + radio.cancelPendingWakeConnects() + } #endif // MARK: Private Message Handling diff --git a/bitchatTests/Services/BLEProximityWakeSettingsTests.swift b/bitchatTests/Services/BLEProximityWakeSettingsTests.swift index 4c2b23df..85447eae 100644 --- a/bitchatTests/Services/BLEProximityWakeSettingsTests.swift +++ b/bitchatTests/Services/BLEProximityWakeSettingsTests.swift @@ -34,4 +34,22 @@ struct BLEProximityWakeSettingsTests { BLEProximityWakeSettings.reset(in: defaults) #expect(BLEProximityWakeSettings.enabled(in: defaults)) } + + @Test + func standardStoreChangePostsNotification() async { + let previous = BLEProximityWakeSettings.enabled + defer { BLEProximityWakeSettings.enabled = previous } + + await confirmation("didChangeNotification fires for standard store") { confirm in + let observer = NotificationCenter.default.addObserver( + forName: BLEProximityWakeSettings.didChangeNotification, + object: nil, + queue: nil + ) { _ in + confirm() + } + defer { NotificationCenter.default.removeObserver(observer) } + BLEProximityWakeSettings.enabled = !previous + } + } }