mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
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:
@@ -98,8 +98,8 @@
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
||||
value = ""
|
||||
key = "BITCHAT_LOG_LEVEL"
|
||||
value = "debug"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user