Improve background BLE stability and nearby alerts (#864)

* Harden background BLE restoration and notifications

* Guard BLE restoration paths to iOS

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-10-23 19:54:06 +02:00
committed by GitHub
co-authored by jack
parent 83779240ae
commit 14c4e586fc
4 changed files with 229 additions and 29 deletions
@@ -98,8 +98,8 @@
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
value = ""
key = "BITCHAT_LOG_LEVEL"
value = "debug"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
+139
View File
@@ -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
+1
View File
@@ -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
+87 -27
View File
@@ -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<PeerID> = []
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