Fix iOS background Bluetooth connectivity issues (#378)

- Implement Core Bluetooth state restoration with restoration identifiers
- Add background task management to protect critical BLE operations
- Fix aggressive battery optimization that killed background connectivity
- Disable scan duty cycling in background (was causing 89-97% downtime)
- Add missing didEnterBackground/willEnterForeground notifications
- Fix advertising cutoff in low battery conditions (now only <10%)
- Add background-processing entitlement to Info.plist
- Optimize scan parameters for background vs foreground modes

These changes address the issue where devices lose mesh connectivity when
the app is backgrounded, ensuring continuous Bluetooth operation within
iOS background execution limits.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-08-01 12:18:14 +02:00
committed by GitHub
co-authored by jack
parent b004bfa4aa
commit aa1ecf40fc
3 changed files with 302 additions and 12 deletions
+1
View File
@@ -40,6 +40,7 @@
<string>bluetooth-central</string> <string>bluetooth-central</string>
<string>bluetooth-peripheral</string> <string>bluetooth-peripheral</string>
<string>remote-notification</string> <string>remote-notification</string>
<string>background-processing</string>
</array> </array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
+295 -8
View File
@@ -601,6 +601,17 @@ class BluetoothMeshService: NSObject {
// App state tracking // App state tracking
private var isAppInForeground = true private var isAppInForeground = true
// Background task management
#if os(iOS)
private var backgroundTask: UIBackgroundTaskIdentifier = .invalid
private var scanBackgroundTask: UIBackgroundTaskIdentifier = .invalid
private var advertiseBackgroundTask: UIBackgroundTaskIdentifier = .invalid
#endif
// Core Bluetooth state restoration identifiers
private static let centralManagerRestorationID = "com.bitchat.central"
private static let peripheralManagerRestorationID = "com.bitchat.peripheral"
// Battery optimizer integration // Battery optimizer integration
private let batteryOptimizer = BatteryOptimizer.shared private let batteryOptimizer = BatteryOptimizer.shared
private var batteryOptimizerCancellables = Set<AnyCancellable>() private var batteryOptimizerCancellables = Set<AnyCancellable>()
@@ -1498,8 +1509,17 @@ class BluetoothMeshService: NSObject {
// Set up queue-specific key for deadlock prevention // Set up queue-specific key for deadlock prevention
collectionsQueue.setSpecific(key: collectionsQueueKey, value: ()) collectionsQueue.setSpecific(key: collectionsQueueKey, value: ())
centralManager = CBCentralManager(delegate: self, queue: nil) // Initialize Bluetooth managers with state restoration
peripheralManager = CBPeripheralManager(delegate: self, queue: nil) centralManager = CBCentralManager(
delegate: self,
queue: nil,
options: [CBCentralManagerOptionRestoreIdentifierKey: Self.centralManagerRestorationID]
)
peripheralManager = CBPeripheralManager(
delegate: self,
queue: nil,
options: [CBPeripheralManagerOptionRestoreIdentifierKey: Self.peripheralManagerRestorationID]
)
// Setup app state notifications // Setup app state notifications
setupAppStateNotifications() setupAppStateNotifications()
@@ -1691,6 +1711,52 @@ class BluetoothMeshService: NSObject {
cleanup() cleanup()
} }
// MARK: - Background Task Management
#if os(iOS)
private func beginBackgroundTask() -> UIBackgroundTaskIdentifier {
return UIApplication.shared.beginBackgroundTask { [weak self] in
self?.endBackgroundTask(self?.backgroundTask ?? .invalid)
}
}
private func endBackgroundTask(_ taskID: UIBackgroundTaskIdentifier) {
if taskID != .invalid {
UIApplication.shared.endBackgroundTask(taskID)
}
}
private func beginScanBackgroundTask() {
guard scanBackgroundTask == .invalid else { return }
scanBackgroundTask = UIApplication.shared.beginBackgroundTask(withName: "BluetoothScan") { [weak self] in
self?.endScanBackgroundTask()
}
}
private func endScanBackgroundTask() {
if scanBackgroundTask != .invalid {
UIApplication.shared.endBackgroundTask(scanBackgroundTask)
scanBackgroundTask = .invalid
}
}
private func beginAdvertiseBackgroundTask() {
guard advertiseBackgroundTask == .invalid else { return }
advertiseBackgroundTask = UIApplication.shared.beginBackgroundTask(withName: "BluetoothAdvertise") { [weak self] in
self?.endAdvertiseBackgroundTask()
}
}
private func endAdvertiseBackgroundTask() {
if advertiseBackgroundTask != .invalid {
UIApplication.shared.endBackgroundTask(advertiseBackgroundTask)
advertiseBackgroundTask = .invalid
}
}
#endif
// MARK: - App State Management // MARK: - App State Management
private func setupAppStateNotifications() { private func setupAppStateNotifications() {
@@ -1707,6 +1773,18 @@ class BluetoothMeshService: NSObject {
name: UIApplication.willResignActiveNotification, name: UIApplication.willResignActiveNotification,
object: nil object: nil
) )
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillEnterForeground),
name: UIApplication.willEnterForegroundNotification,
object: nil
)
#elseif os(macOS) #elseif os(macOS)
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
@@ -1733,6 +1811,40 @@ class BluetoothMeshService: NSObject {
// App will resign active // App will resign active
} }
@objc private func appDidEnterBackground() {
isAppInForeground = false
SecureLogger.log("📱 App entered background - switching to background scanning mode", level: .info)
#if os(iOS)
// Begin background tasks for critical operations
beginScanBackgroundTask()
beginAdvertiseBackgroundTask()
#endif
// Switch to background scanning mode (continuous with battery-efficient options)
switchToBackgroundScanning()
// Update advertising for background mode
updateAdvertisingForBackgroundMode()
}
@objc private func appWillEnterForeground() {
isAppInForeground = true
SecureLogger.log("📱 App entering foreground - switching to foreground scanning mode", level: .info)
#if os(iOS)
// End background tasks as app is coming to foreground
endScanBackgroundTask()
endAdvertiseBackgroundTask()
#endif
// Switch to foreground scanning mode (with duty cycling)
switchToForegroundScanning()
// Update advertising for foreground mode
updateAdvertisingForForegroundMode()
}
private func cleanup() { private func cleanup() {
// Send leave announcement before disconnecting // Send leave announcement before disconnecting
sendLeaveAnnouncement() sendLeaveAnnouncement()
@@ -1973,14 +2085,41 @@ class BluetoothMeshService: NSObject {
peripheralManager?.startAdvertising(advertisementData) peripheralManager?.startAdvertising(advertisementData)
} }
private func updateAdvertisingForBackgroundMode() {
// Check if we should skip advertising in critically low battery
if batteryOptimizer.currentPowerMode == .ultraLowPower && batteryOptimizer.batteryLevel < 0.1 {
// Only skip advertising if battery is critically low (<10%)
if isAdvertising {
peripheralManager?.stopAdvertising()
isAdvertising = false
SecureLogger.log("⚡ Stopped advertising due to critically low battery", level: .info)
}
return
}
// Continue advertising in background mode with battery considerations
if !isAdvertising && peripheralManager?.state == .poweredOn {
startAdvertising()
SecureLogger.log("📡 Continued advertising in background mode", level: .info)
}
}
private func updateAdvertisingForForegroundMode() {
// Always start advertising when in foreground if possible
if !isAdvertising && peripheralManager?.state == .poweredOn {
startAdvertising()
SecureLogger.log("📡 Started advertising in foreground mode", level: .info)
}
}
func startScanning() { func startScanning() {
guard centralManager?.state == .poweredOn else { guard centralManager?.state == .poweredOn else {
return return
} }
// Enable duplicate detection // Optimize scan options based on foreground/background state
let scanOptions: [String: Any] = [ let scanOptions: [String: Any] = [
CBCentralManagerScanOptionAllowDuplicatesKey: true CBCentralManagerScanOptionAllowDuplicatesKey: isAppInForeground ? true : false
] ]
centralManager?.scanForPeripherals( centralManager?.scanForPeripherals(
@@ -1991,8 +2130,10 @@ class BluetoothMeshService: NSObject {
// Update scan parameters based on battery before starting // Update scan parameters based on battery before starting
updateScanParametersForBattery() updateScanParametersForBattery()
// Implement scan duty cycling for battery efficiency // Implement scan duty cycling for battery efficiency (only in foreground)
scheduleScanDutyCycle() if isAppInForeground {
scheduleScanDutyCycle()
}
} }
func triggerRescan() { func triggerRescan() {
@@ -2061,8 +2202,18 @@ class BluetoothMeshService: NSObject {
func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) { func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) {
// Defensive check for empty content // Defensive check for empty content
guard !content.isEmpty else { return } guard !content.isEmpty else { return }
#if os(iOS)
let backgroundTask = beginBackgroundTask()
#endif
messageQueue.async { [weak self] in messageQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else {
#if os(iOS)
self?.endBackgroundTask(backgroundTask)
#endif
return
}
let nickname = self.delegate as? ChatViewModel let nickname = self.delegate as? ChatViewModel
let senderNick = nickname?.nickname ?? self.myPeerID let senderNick = nickname?.nickname ?? self.myPeerID
@@ -2112,7 +2263,14 @@ class BluetoothMeshService: NSObject {
let initialDelay = self.smartCollisionAvoidanceDelay(baseDelay: self.randomDelay()) let initialDelay = self.smartCollisionAvoidanceDelay(baseDelay: self.randomDelay())
DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) { [weak self] in
self?.broadcastPacket(packet) self?.broadcastPacket(packet)
#if os(iOS)
self?.endBackgroundTask(backgroundTask)
#endif
} }
} else {
#if os(iOS)
self.endBackgroundTask(backgroundTask)
#endif
} }
} }
} }
@@ -2134,8 +2292,17 @@ class BluetoothMeshService: NSObject {
let msgID = messageID ?? UUID().uuidString let msgID = messageID ?? UUID().uuidString
#if os(iOS)
let backgroundTask = beginBackgroundTask()
#endif
messageQueue.async { [weak self] in messageQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else {
#if os(iOS)
self?.endBackgroundTask(backgroundTask)
#endif
return
}
// Check if this is an old peer ID that has rotated // Check if this is an old peer ID that has rotated
var targetPeerID = recipientPeerID var targetPeerID = recipientPeerID
@@ -2150,6 +2317,10 @@ class BluetoothMeshService: NSObject {
// Always use Noise encryption // Always use Noise encryption
self.sendPrivateMessageViaNoise(content, to: targetPeerID, recipientNickname: recipientNickname, messageID: msgID) self.sendPrivateMessageViaNoise(content, to: targetPeerID, recipientNickname: recipientNickname, messageID: msgID)
#if os(iOS)
self.endBackgroundTask(backgroundTask)
#endif
} }
} }
@@ -4796,6 +4967,50 @@ extension BluetoothMeshService: CBCentralManagerDelegate {
} }
} }
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) {
// Restore central manager state after app backgrounding
SecureLogger.log("🔄 Restoring CBCentralManager state", level: .info)
// Restore scanned services
if let services = dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID] {
SecureLogger.log("📡 Restoring scanned services: \(services)", level: .info)
}
// Restore scan options
if let scanOptions = dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any] {
SecureLogger.log("🔍 Restoring scan options: \(scanOptions)", level: .info)
}
// Restore peripherals
if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] {
SecureLogger.log("📱 Restoring \(peripherals.count) peripherals", level: .info)
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
// Restore discovered peripherals list
self.discoveredPeripherals = peripherals
// Restore connections for connected peripherals
for peripheral in peripherals {
if peripheral.state == .connected {
// Find the peerID for this peripheral
let peerID = peripheral.identifier.uuidString
// Update our tracking
self.updatePeripheralConnection(peerID, peripheral: peripheral)
// Set delegate and discover services
peripheral.delegate = self
peripheral.discoverServices([BluetoothMeshService.serviceUUID])
SecureLogger.log("🔗 Restored connection to peer: \(peerID)", level: .info)
}
}
}
}
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
let peripheralID = peripheral.identifier.uuidString let peripheralID = peripheral.identifier.uuidString
@@ -5311,6 +5526,33 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
} }
} }
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String : Any]) {
// Restore peripheral manager state after app backgrounding
SecureLogger.log("🔄 Restoring CBPeripheralManager state", level: .info)
// Restore services
if let services = dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService] {
SecureLogger.log("🛠 Restoring \(services.count) services", level: .info)
// Services are automatically restored by Core Bluetooth
// We just need to ensure our internal state is consistent
DispatchQueue.main.async { [weak self] in
self?.isAdvertising = false
// Will be set to true when advertising starts
}
}
// Restore advertisement data
if let advertisementData = dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any] {
SecureLogger.log("📣 Restoring advertisement data: \(advertisementData)", level: .info)
self.advertisementData = advertisementData
DispatchQueue.main.async { [weak self] in
self?.isAdvertising = true
}
}
}
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
// Service added // Service added
} }
@@ -5503,6 +5745,51 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
// Keeping empty implementation for compatibility // Keeping empty implementation for compatibility
} }
private func switchToBackgroundScanning() {
guard centralManager?.state == .poweredOn else { return }
// Stop existing scanning and duty cycling
centralManager?.stopScan()
scanDutyCycleTimer?.invalidate()
scanDutyCycleTimer = nil
// Use continuous scanning with battery-efficient options in background
let backgroundScanOptions: [String: Any] = [
CBCentralManagerScanOptionAllowDuplicatesKey: false // Battery efficient
]
centralManager?.scanForPeripherals(
withServices: [BluetoothMeshService.serviceUUID],
options: backgroundScanOptions
)
SecureLogger.log("🔄 Switched to continuous background scanning", level: .info)
}
private func switchToForegroundScanning() {
guard centralManager?.state == .poweredOn else { return }
// Stop existing scanning
centralManager?.stopScan()
scanDutyCycleTimer?.invalidate()
scanDutyCycleTimer = nil
// Use foreground scanning with duty cycling and allow duplicates for faster discovery
let foregroundScanOptions: [String: Any] = [
CBCentralManagerScanOptionAllowDuplicatesKey: true // Faster discovery
]
centralManager?.scanForPeripherals(
withServices: [BluetoothMeshService.serviceUUID],
options: foregroundScanOptions
)
// Re-enable duty cycling for foreground operation
scheduleScanDutyCycle()
SecureLogger.log("🔄 Switched to foreground scanning with duty cycling", level: .info)
}
private func updateScanParametersForBattery() { private func updateScanParametersForBattery() {
// This method is now handled by BatteryOptimizer through handlePowerModeChange // This method is now handled by BatteryOptimizer through handlePowerModeChange
// Keeping empty implementation for compatibility // Keeping empty implementation for compatibility
+6 -4
View File
@@ -184,13 +184,15 @@ class BatteryOptimizer {
// When charging, use performance mode unless battery is critical // When charging, use performance mode unless battery is critical
currentPowerMode = batteryLevel < 0.1 ? .balanced : .performance currentPowerMode = batteryLevel < 0.1 ? .balanced : .performance
} else if isInBackground { } else if isInBackground {
// In background, always use power saving // In background, be less aggressive with power management
if batteryLevel < 0.2 { // Don't force ultra-low power mode until battery is below 10%
// Use balanced mode in background above 30% battery
if batteryLevel < 0.1 {
currentPowerMode = .ultraLowPower currentPowerMode = .ultraLowPower
} else if batteryLevel < 0.5 { } else if batteryLevel < 0.3 {
currentPowerMode = .powerSaver currentPowerMode = .powerSaver
} else { } else {
currentPowerMode = .balanced currentPowerMode = .balanced // Use balanced mode above 30% in background
} }
} else { } else {
// Foreground, not charging // Foreground, not charging