diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift index d5ca09298..7bb703be2 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift @@ -52,7 +52,7 @@ extension AccessoryManager { Logger.transport.info("🔗👟[Connect] Step 1: connection to \(device.id, privacy: .public)") do { let connection: Connection - if let providedConnection = withConnection { + if let providedConnection = withConnection, await providedConnection.isConnected { connection = providedConnection } else { connection = try await transport.connect(to: device) @@ -213,6 +213,14 @@ extension AccessoryManager { do { try await connectionStepper?.run() Logger.transport.debug("🔗 [Connect] ConnectionStepper completed.") + + // Ensure we're still connected (catches disconnects that happen in the middle of connection steps) + if !(await self.activeConnection?.connection.isConnected ?? false) { + Logger.transport.error("🔗 [Connect] Disconnected during connection steps. Resetting to discovering.") + try await self.closeConnection() + updateState(.discovering) + self.lastConnectionError = AccessoryError.connectionFailed("Disconnected during connection steps") + } } catch { Logger.transport.error("🔗 [Connect] Error returned by connectionStepper: \(error)") try await self.closeConnection() @@ -373,6 +381,11 @@ actor SequentialSteps { func executeWithTimeout(stepNumber: Int, timeout: Duration, operation: @escaping @Sendable () async throws -> ReturnType) -> Task { return Task { try await withThrowingTaskGroup(of: ReturnType.self) { group -> ReturnType in + defer { + // Always cancel, even if we hit a timeout or an error. + group.cancelAll() + } + group.addTask(operation: operation) group.addTask { try await _Concurrency.Task.sleep(for: timeout) @@ -381,7 +394,6 @@ actor SequentialSteps { guard let success = try await group.next() else { throw SequentialStepError.timeout(stepNumber: stepNumber, afterWaiting: timeout) } - group.cancelAll() return success } } diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift index 266b69459..1acbe01bf 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift @@ -52,12 +52,7 @@ extension AccessoryManager { existing.rssi = newDevice.rssi self.devices[index] = existing } else { - // This is a new device, add it to our list if we are in the foreground - if !(self.isInBackground) { - self.devices.append(newDevice) - } else { - Logger.transport.debug("🔎 [Discovery] Found a new device but not in the foreground, not adding to our list: peripheral \(newDevice.name)") - } + self.devices.append(newDevice) } if self.shouldAutomaticallyConnectToPreferredPeripheral, @@ -75,6 +70,14 @@ extension AccessoryManager { case .deviceReportedRssi(let deviceId, let newRssi): updateDevice(deviceId: deviceId, key: \.rssi, value: newRssi) + + // If we see RSSI for the preferred device while idle, attempt auto-connect + if self.shouldAutomaticallyConnectToPreferredPeripheral, + UserDefaults.autoconnectOnDiscovery, + !self.isConnected, !self.isConnecting, + deviceId.uuidString == UserDefaults.preferredPeripheralId { + self.connectToPreferredDevice() + } } } catch { break diff --git a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift index f7a0f012b..6f37cd08d 100644 --- a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift +++ b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift @@ -7,6 +7,9 @@ import Foundation @preconcurrency import CoreBluetooth +#if canImport(UIKit) +import UIKit +#endif import OSLog import MeshtasticProtobufs @@ -483,24 +486,125 @@ class BLEConnectionDelegate: NSObject, CBPeripheralDelegate { self.connection = connection } + // See "Core Bluetooth Background Processing for iOS Apps": https://developer.apple.com/library/archive/documentation/NetworkingInternetWeb/Conceptual/CoreBluetooth_concepts/CoreBluetoothBackgroundProcessingForIOSApps/PerformingTasksWhileYourAppIsInTheBackground.html + // "Apps woken up for any Bluetooth-related events should process them and return as quickly as possible so that the app can be suspended again." + // If the CBPeripheralDelegate peripheral(...) call returns, the app may be suspended again. + // Therefore, if we enqueue any work with Task { ... } (so that we can use an async actor), we have to make sure it runs even though the call returns. + // To do this, we use a pattern of UIApplication.shared.beginBackgroundTask and endBackgroundTask to request that the app not be suspended yet until the newly enqueued Task gets a chance to run! + // MARK: CBPeripheralDelegate func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - Task { await connection?.didDiscoverServices(error: error) } + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + let backgroundTaskName = "BLEConnectionDelegate.didDiscoverServices" + let backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: backgroundTaskName) { + Logger.transport.error("[BGTask] expiration reached: \(backgroundTaskName)") + } + // Logger.transport.debug("[BGTask] started: \(backgroundTaskName)") + #endif + + Task { + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + defer { + DispatchQueue.main.async { + // Logger.transport.debug("[BGTask] finished: \(backgroundTaskName)") + UIApplication.shared.endBackgroundTask(backgroundTaskId) + } + } + #endif + + await connection?.didDiscoverServices(error: error) + } } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - Task { await connection?.didDiscoverCharacteristicsFor(service: service, error: error) } + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + let backgroundTaskName = "BLEConnectionDelegate.didDiscoverCharacteristicsFor" + let backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: backgroundTaskName) { + Logger.transport.error("[BGTask] expiration reached: \(backgroundTaskName)") + } + // Logger.transport.debug("[BGTask] started: \(backgroundTaskName)") + #endif + + Task { + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + defer { + DispatchQueue.main.async { + // Logger.transport.debug("[BGTask] finished: \(backgroundTaskName)") + UIApplication.shared.endBackgroundTask(backgroundTaskId) + } + } + #endif + + await connection?.didDiscoverCharacteristicsFor(service: service, error: error) + } } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - Task { await connection?.didUpdateValueFor(characteristic: characteristic, error: error) } + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + let backgroundTaskName = "BLEConnectionDelegate.didUpdateValueFor" + let backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: backgroundTaskName) { + Logger.transport.error("[BGTask] expiration reached: \(backgroundTaskName)") + } + // Logger.transport.debug("[BGTask] started: \(backgroundTaskName)") + #endif + + Task { + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + defer { + DispatchQueue.main.async { + // Logger.transport.debug("[BGTask] finished: \(backgroundTaskName)") + UIApplication.shared.endBackgroundTask(backgroundTaskId) + } + } + #endif + + await connection?.didUpdateValueFor(characteristic: characteristic, error: error) + } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { - Task { await connection?.didWriteValueFor(characteristic: characteristic, error: error) } + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + let backgroundTaskName = "BLEConnectionDelegate.didWriteValueFor" + let backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: backgroundTaskName) { + Logger.transport.error("[BGTask] expiration reached: \(backgroundTaskName)") + } + // Logger.transport.debug("[BGTask] started: \(backgroundTaskName)") + #endif + + Task { + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + defer { + DispatchQueue.main.async { + // Logger.transport.debug("[BGTask] finished: \(backgroundTaskName)") + UIApplication.shared.endBackgroundTask(backgroundTaskId) + } + } + #endif + + await connection?.didWriteValueFor(characteristic: characteristic, error: error) + } } func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { - Task { await connection?.didReadRSSI(RSSI: RSSI, error: error) } + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + let backgroundTaskName = "BLEConnectionDelegate.didReadRSSI" + let backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: backgroundTaskName) { + Logger.transport.error("[BGTask] expiration reached: \(backgroundTaskName)") + } + // Logger.transport.debug("[BGTask] started: \(backgroundTaskName)") + #endif + + Task { + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + defer { + DispatchQueue.main.async { + // Logger.transport.debug("[BGTask] finished: \(backgroundTaskName)") + UIApplication.shared.endBackgroundTask(backgroundTaskId) + } + } + #endif + + await connection?.didReadRSSI(RSSI: RSSI, error: error) + } } } diff --git a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift index aa1a32d49..00d00c9f5 100644 --- a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift +++ b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift @@ -7,6 +7,9 @@ import Foundation @preconcurrency import CoreBluetooth +#if canImport(UIKit) +import UIKit +#endif import SwiftUI import OSLog @@ -223,7 +226,26 @@ class BLETransport: Transport { if let connection = self.activeConnection { discoveredPeripherals.removeValue(forKey: peripheral.identifier) discoveredDeviceContinuation?.yield(.deviceLost(peripheral.identifier)) + + // Use beginBackgroundTask/endBackgroundTask to keep the app from being suspended before the async Task runs. (See BLEConnection for more details.) + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + let backgroundTaskName = "BLETransport.handlePeripheralDisconnect" + let backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: backgroundTaskName) { + Logger.transport.error("[BGTask] expiration reached: \(backgroundTaskName)") + } + // Logger.transport.debug("[BGTask] started: \(backgroundTaskName)") + #endif + Task { + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + defer { + DispatchQueue.main.async { + // Logger.transport.debug("[BGTask] finished: \(backgroundTaskName)") + UIApplication.shared.endBackgroundTask(backgroundTaskId) + } + } + #endif + if await connection.peripheral.identifier == peripheral.identifier { try await connection.disconnect(withError: AccessoryError.disconnected("BLE connection lost"), shouldReconnect: true) self.connectionDidDisconnect(fromPeripheral: peripheral) @@ -262,7 +284,26 @@ class BLETransport: Transport { } else if let activeConnection = self.activeConnection { // Inform the active connection that there was an error and it should disconnect Logger.transport.debug("🛜 [BLETransport] Error while connecting. Disconnecting the active connection.") + + // Use beginBackgroundTask/endBackgroundTask to keep the app from being suspended before the async Task runs. (See BLEConnection for more details.) + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + let backgroundTaskName = "BLETransport.handlePeripheralDisconnectError" + let backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: backgroundTaskName) { + Logger.transport.error("[BGTask] expiration reached: \(backgroundTaskName)") + } + // Logger.transport.debug("[BGTask] started: \(backgroundTaskName)") + #endif + Task { + #if canImport(UIKit) && !targetEnvironment(macCatalyst) + defer { + DispatchQueue.main.async { + // Logger.transport.debug("[BGTask] finished: \(backgroundTaskName)") + UIApplication.shared.endBackgroundTask(backgroundTaskId) + } + } + #endif + try? await activeConnection.disconnect(withError: error, shouldReconnect: shouldReconnect) self.connectionDidDisconnect(fromPeripheral: peripheral) } diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 8b7a74232..248e7b08e 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -880,11 +880,10 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage let meshActivity = Activity.activities.first(where: { $0.attributes.nodeNum == connectedNode }) if meshActivity != nil { - Task { - // await meshActivity?.update(updatedContent, alertConfiguration: alertConfiguration) - await meshActivity?.update(updatedContent) - Logger.services.debug("Updated live activity.") - } + // Synchronously update the Live Activity content, because scheduling in a Task { } may allow the backgrounded app to go back to idle. + // await meshActivity?.update(updatedContent, alertConfiguration: alertConfiguration) + await meshActivity?.update(updatedContent) + Logger.services.debug("Updated live activity.") } #endif #endif