From 14c4e586fc4a05395a06e84a9127718316ff7163 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Thu, 23 Oct 2025 19:54:06 +0200 Subject: [PATCH] Improve background BLE stability and nearby alerts (#864) * Harden background BLE restoration and notifications * Guard BLE restoration paths to iOS --------- Co-authored-by: jack --- .../xcschemes/bitchat (iOS).xcscheme | 4 +- bitchat/Services/BLE/BLEService.swift | 139 ++++++++++++++++++ bitchat/Services/TransportConfig.swift | 1 + bitchat/ViewModels/ChatViewModel.swift | 114 ++++++++++---- 4 files changed, 229 insertions(+), 29 deletions(-) diff --git a/bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme b/bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme index 6c461299..8000e27d 100644 --- a/bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme +++ b/bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme @@ -98,8 +98,8 @@ diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 11717fbe..0724d4f3 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -22,6 +22,8 @@ final class BLEService: NSObject { static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet #endif static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") + private static let centralRestorationID = "chat.bitchat.ble.central" + private static let peripheralRestorationID = "chat.bitchat.ble.peripheral" // Default per-fragment chunk size when link limits are unknown private let defaultFragmentSize = TransportConfig.bleDefaultFragmentSize @@ -288,8 +290,20 @@ final class BLEService: NSObject { // Initialize BLE on background queue to prevent main thread blocking // This prevents app freezes during BLE operations + #if os(iOS) + let centralOptions: [String: Any] = [ + CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID + ] + centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions) + + let peripheralOptions: [String: Any] = [ + CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID + ] + peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions) + #else centralManager = CBCentralManager(delegate: self, queue: bleQueue) peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue) + #endif // Single maintenance timer for all periodic tasks (dispatch-based for determinism) let timer = DispatchSource.makeTimerSource(queue: bleQueue) @@ -1464,6 +1478,48 @@ extension BLEService: GossipSyncManager.Delegate { // MARK: - CBCentralManagerDelegate extension BLEService: CBCentralManagerDelegate { + #if os(iOS) + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { + let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? [] + let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? [] + let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:] + let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool + + SecureLogger.info( + "♻️ Central restore: peripherals=\(restoredPeripherals.count) services=\(restoredServices.count) allowDuplicates=\(String(describing: allowDuplicates))", + category: .session + ) + + for peripheral in restoredPeripherals { + let identifier = peripheral.identifier.uuidString + peripheral.delegate = self + let existing = peripherals[identifier] + let assembler = existing?.assembler ?? NotificationStreamAssembler() + let characteristic = existing?.characteristic + let peerID = existing?.peerID + let wasConnecting = existing?.isConnecting ?? false + let wasConnected = existing?.isConnected ?? false + + let restoredState = PeripheralState( + peripheral: peripheral, + characteristic: characteristic, + peerID: peerID, + isConnecting: wasConnecting || peripheral.state == .connecting, + isConnected: wasConnected || peripheral.state == .connected, + lastConnectionAttempt: existing?.lastConnectionAttempt, + assembler: assembler + ) + peripherals[identifier] = restoredState + } + + captureBluetoothStatus(context: "central-restore") + + if central.state == .poweredOn { + startScanning() + } + } + #endif + func centralManagerDidUpdateState(_ central: CBCentralManager) { // Notify delegate about state change on main thread Task { @MainActor in @@ -2059,6 +2115,32 @@ extension BLEService: CBPeripheralManagerDelegate { } } + #if os(iOS) + func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String : Any]) { + let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? [] + let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:] + + SecureLogger.info( + "♻️ Peripheral restore: services=\(restoredServices.count) advertisingDataKeys=\(Array(restoredAdvertisement.keys))", + category: .session + ) + + // Attempt to recover characteristic from restored services + if characteristic == nil { + if let service = restoredServices.first(where: { $0.uuid == BLEService.serviceUUID }), + let restoredCharacteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) as? CBMutableCharacteristic { + characteristic = restoredCharacteristic + } + } + + captureBluetoothStatus(context: "peripheral-restore") + + if peripheral.state == .poweredOn && !peripheral.isAdvertising { + peripheral.startAdvertising(buildAdvertisementData()) + } + } + #endif + func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { if let error = error { SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session) @@ -2283,6 +2365,59 @@ extension BLEService { } } + private func logBluetoothStatus(_ context: String) { + bleQueue.async { [weak self] in + guard let self = self else { return } + self.captureBluetoothStatus(context: context) + } + } + + private func scheduleBluetoothStatusSample(after delay: TimeInterval, context: String) { + bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self = self else { return } + self.captureBluetoothStatus(context: context) + } + } + + private func captureBluetoothStatus(context: String) { + assert(DispatchQueue.getSpecific(key: bleQueueKey) != nil, "captureBluetoothStatus must run on bleQueue") + + let centralState = centralManager?.state ?? .unknown + let isScanning = centralManager?.isScanning ?? false + let peripheralState = peripheralManager?.state ?? .unknown + let isAdvertising = peripheralManager?.isAdvertising ?? false + + let peerSummary = collectionsQueue.sync { + ( + connected: peers.values.filter { $0.isConnected }.count, + known: peers.count, + candidates: connectionCandidates.count + ) + } + + #if os(iOS) + var backgroundDescriptor = "" + var backgroundSeconds: TimeInterval = 0 + DispatchQueue.main.sync { + backgroundSeconds = UIApplication.shared.backgroundTimeRemaining + } + if backgroundSeconds == .greatestFiniteMagnitude { + backgroundDescriptor = " bgRemaining=∞" + } else { + backgroundDescriptor = String(format: " bgRemaining=%.1fs", backgroundSeconds) + } + let appPhase = isAppActive ? "foreground" : "background" + #else + let backgroundDescriptor = "" + let appPhase = "foreground" + #endif + + SecureLogger.info( + "📊 BLE status [\(context)]: phase=\(appPhase) central=\(centralState) scanning=\(isScanning) peripheral=\(peripheralState) advertising=\(isAdvertising) connected=\(peerSummary.connected) known=\(peerSummary.known) candidates=\(peerSummary.candidates)\(backgroundDescriptor)", + category: .session + ) + } + /// Safely fetch the current direct-link state for a peer using the BLE queue. private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) { let computeState = { () -> (Bool, Bool) in @@ -2505,6 +2640,8 @@ extension BLEService { centralManager?.stopScan() startScanning() } + logBluetoothStatus("became-active") + scheduleBluetoothStatusSample(after: 5.0, context: "active-5s") // No Local Name; nothing to refresh for advertising policy } @@ -2515,6 +2652,8 @@ extension BLEService { centralManager?.stopScan() startScanning() } + logBluetoothStatus("entered-background") + scheduleBluetoothStatusSample(after: 15.0, context: "background-15s") // No Local Name; nothing to refresh for advertising policy } #endif diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 80a5ce80..72690e4a 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -70,6 +70,7 @@ enum TransportConfig { static let uiAnimationMediumSeconds: TimeInterval = 0.2 static let uiAnimationSidebarSeconds: TimeInterval = 0.25 static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60 + static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0 // BLE maintenance & thresholds static let bleMaintenanceInterval: TimeInterval = 5.0 diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index a2046762..1a3c5d0b 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -248,10 +248,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @Published var currentColorScheme: ColorScheme = .light private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed @Published var isConnected = false - private var hasNotifiedNetworkAvailable = false private var recentlySeenPeers: Set = [] private var lastNetworkNotificationTime = Date.distantPast private var networkResetTimer: Timer? = nil + private var networkEmptyTimer: Timer? = nil private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects @Published var nickname: String = "" { didSet { @@ -5229,34 +5229,29 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { self.cleanupStaleUnreadPeerIDs() // Smart notification logic for "bitchatters nearby" - if !peers.isEmpty { - // Cancel any pending reset if peers are back - self.networkResetTimer?.invalidate() - self.networkResetTimer = nil - // Count mesh peers that are connected OR recently reachable via mesh relays - let meshPeers = peers.filter { peerID in - self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID) - } - - // Rising-edge only: previously zero peers, now > 0 peers - let currentPeerSet = Set(meshPeers) - let hadNone = self.recentlySeenPeers.isEmpty - if meshPeers.count > 0 && hadNone && !self.hasNotifiedNetworkAvailable { - self.hasNotifiedNetworkAvailable = true - self.lastNetworkNotificationTime = Date() - self.recentlySeenPeers = currentPeerSet - NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count) - SecureLogger.info("👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers", category: .session) - } + let meshPeers = peers.filter { peerID in + self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID) + } + let meshPeerSet = Set(meshPeers) + + if meshPeerSet.isEmpty { + self.scheduleNetworkEmptyTimer() } else { - // No peers — immediately reset to allow next rising-edge to notify - self.hasNotifiedNetworkAvailable = false - self.recentlySeenPeers.removeAll() - if self.networkResetTimer != nil { - self.networkResetTimer?.invalidate() - self.networkResetTimer = nil + self.invalidateNetworkEmptyTimer() + // Trim out peers we no longer observe before comparing for new arrivals + self.recentlySeenPeers.formIntersection(meshPeerSet) + let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers) + + if !newPeers.isEmpty { + self.lastNetworkNotificationTime = Date() + self.recentlySeenPeers.formUnion(newPeers) + NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count) + SecureLogger.info( + "👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))", + category: .session + ) + self.scheduleNetworkResetTimer() } - SecureLogger.debug("⏳ Mesh empty — reset network notification state", category: .session) } // Register ephemeral sessions for all connected peers @@ -5326,6 +5321,71 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Keep only receipts from messages we still have cleanupOldReadReceipts() } + + @MainActor + private func scheduleNetworkResetTimer() { + networkResetTimer?.invalidate() + networkResetTimer = Timer.scheduledTimer( + timeInterval: networkResetGraceSeconds, + target: self, + selector: #selector(onNetworkResetTimerFired(_:)), + userInfo: nil, + repeats: false + ) + } + + @MainActor + @objc private func onNetworkResetTimerFired(_ timer: Timer) { + let activeMeshPeers = meshService + .currentPeerSnapshots() + .filter { snapshot in + snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID) + } + if activeMeshPeers.isEmpty { + recentlySeenPeers.removeAll() + SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session) + } else { + SecureLogger.debug("⏱️ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers", category: .session) + } + networkResetTimer = nil + } + + @MainActor + private func scheduleNetworkEmptyTimer() { + guard networkEmptyTimer == nil else { return } + networkEmptyTimer = Timer.scheduledTimer( + timeInterval: TransportConfig.uiMeshEmptyConfirmationSeconds, + target: self, + selector: #selector(onNetworkEmptyTimerFired(_:)), + userInfo: nil, + repeats: false + ) + SecureLogger.debug("⏳ Mesh empty — waiting before resetting notification state", category: .session) + } + + @MainActor + private func invalidateNetworkEmptyTimer() { + if networkEmptyTimer != nil { + networkEmptyTimer?.invalidate() + networkEmptyTimer = nil + } + } + + @MainActor + @objc private func onNetworkEmptyTimerFired(_ timer: Timer) { + let activeMeshPeers = meshService + .currentPeerSnapshots() + .filter { snapshot in + snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID) + } + if activeMeshPeers.isEmpty { + recentlySeenPeers.removeAll() + SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session) + } else { + SecureLogger.debug("⏳ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again", category: .session) + } + networkEmptyTimer = nil + } private func cleanupOldReadReceipts() { // Skip cleanup during startup phase or if privateChats is empty