fix: cancel in-flight wake connects when proximity wake is disabled

This commit is contained in:
Taksh 2026-08-01 14:41:50 +03:00
parent 4eb2a35e2f
commit ed13ba2625
4 changed files with 73 additions and 12 deletions

View File

@ -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)
}
}
}

View File

@ -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()
}
}

View File

@ -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

View File

@ -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
}
}
}