mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +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>
|
</BuildableProductRunnable>
|
||||||
<EnvironmentVariables>
|
<EnvironmentVariables>
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
key = "BITCHAT_LOG_LEVEL"
|
||||||
value = ""
|
value = "debug"
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
</EnvironmentVariables>
|
</EnvironmentVariables>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ final class BLEService: NSObject {
|
|||||||
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet
|
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet
|
||||||
#endif
|
#endif
|
||||||
static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
|
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
|
// Default per-fragment chunk size when link limits are unknown
|
||||||
private let defaultFragmentSize = TransportConfig.bleDefaultFragmentSize
|
private let defaultFragmentSize = TransportConfig.bleDefaultFragmentSize
|
||||||
@@ -288,8 +290,20 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Initialize BLE on background queue to prevent main thread blocking
|
// Initialize BLE on background queue to prevent main thread blocking
|
||||||
// This prevents app freezes during BLE operations
|
// 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)
|
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
|
||||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
|
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
|
||||||
|
#endif
|
||||||
|
|
||||||
// Single maintenance timer for all periodic tasks (dispatch-based for determinism)
|
// Single maintenance timer for all periodic tasks (dispatch-based for determinism)
|
||||||
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
||||||
@@ -1464,6 +1478,48 @@ extension BLEService: GossipSyncManager.Delegate {
|
|||||||
// MARK: - CBCentralManagerDelegate
|
// MARK: - CBCentralManagerDelegate
|
||||||
|
|
||||||
extension BLEService: 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) {
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||||
// Notify delegate about state change on main thread
|
// Notify delegate about state change on main thread
|
||||||
Task { @MainActor in
|
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?) {
|
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session)
|
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.
|
/// Safely fetch the current direct-link state for a peer using the BLE queue.
|
||||||
private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) {
|
private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) {
|
||||||
let computeState = { () -> (Bool, Bool) in
|
let computeState = { () -> (Bool, Bool) in
|
||||||
@@ -2505,6 +2640,8 @@ extension BLEService {
|
|||||||
centralManager?.stopScan()
|
centralManager?.stopScan()
|
||||||
startScanning()
|
startScanning()
|
||||||
}
|
}
|
||||||
|
logBluetoothStatus("became-active")
|
||||||
|
scheduleBluetoothStatusSample(after: 5.0, context: "active-5s")
|
||||||
// No Local Name; nothing to refresh for advertising policy
|
// No Local Name; nothing to refresh for advertising policy
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2515,6 +2652,8 @@ extension BLEService {
|
|||||||
centralManager?.stopScan()
|
centralManager?.stopScan()
|
||||||
startScanning()
|
startScanning()
|
||||||
}
|
}
|
||||||
|
logBluetoothStatus("entered-background")
|
||||||
|
scheduleBluetoothStatusSample(after: 15.0, context: "background-15s")
|
||||||
// No Local Name; nothing to refresh for advertising policy
|
// No Local Name; nothing to refresh for advertising policy
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ enum TransportConfig {
|
|||||||
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
||||||
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
||||||
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
||||||
|
static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0
|
||||||
|
|
||||||
// BLE maintenance & thresholds
|
// BLE maintenance & thresholds
|
||||||
static let bleMaintenanceInterval: TimeInterval = 5.0
|
static let bleMaintenanceInterval: TimeInterval = 5.0
|
||||||
|
|||||||
@@ -248,10 +248,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
@Published var currentColorScheme: ColorScheme = .light
|
@Published var currentColorScheme: ColorScheme = .light
|
||||||
private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
|
private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
|
||||||
@Published var isConnected = false
|
@Published var isConnected = false
|
||||||
private var hasNotifiedNetworkAvailable = false
|
|
||||||
private var recentlySeenPeers: Set<PeerID> = []
|
private var recentlySeenPeers: Set<PeerID> = []
|
||||||
private var lastNetworkNotificationTime = Date.distantPast
|
private var lastNetworkNotificationTime = Date.distantPast
|
||||||
private var networkResetTimer: Timer? = nil
|
private var networkResetTimer: Timer? = nil
|
||||||
|
private var networkEmptyTimer: Timer? = nil
|
||||||
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
|
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
|
||||||
@Published var nickname: String = "" {
|
@Published var nickname: String = "" {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -5229,34 +5229,29 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
self.cleanupStaleUnreadPeerIDs()
|
self.cleanupStaleUnreadPeerIDs()
|
||||||
|
|
||||||
// Smart notification logic for "bitchatters nearby"
|
// Smart notification logic for "bitchatters nearby"
|
||||||
if !peers.isEmpty {
|
let meshPeers = peers.filter { peerID in
|
||||||
// Cancel any pending reset if peers are back
|
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
|
||||||
self.networkResetTimer?.invalidate()
|
}
|
||||||
self.networkResetTimer = nil
|
let meshPeerSet = Set(meshPeers)
|
||||||
// Count mesh peers that are connected OR recently reachable via mesh relays
|
|
||||||
let meshPeers = peers.filter { peerID in
|
if meshPeerSet.isEmpty {
|
||||||
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
|
self.scheduleNetworkEmptyTimer()
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// No peers — immediately reset to allow next rising-edge to notify
|
self.invalidateNetworkEmptyTimer()
|
||||||
self.hasNotifiedNetworkAvailable = false
|
// Trim out peers we no longer observe before comparing for new arrivals
|
||||||
self.recentlySeenPeers.removeAll()
|
self.recentlySeenPeers.formIntersection(meshPeerSet)
|
||||||
if self.networkResetTimer != nil {
|
let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers)
|
||||||
self.networkResetTimer?.invalidate()
|
|
||||||
self.networkResetTimer = nil
|
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
|
// Register ephemeral sessions for all connected peers
|
||||||
@@ -5326,6 +5321,71 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Keep only receipts from messages we still have
|
// Keep only receipts from messages we still have
|
||||||
cleanupOldReadReceipts()
|
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() {
|
private func cleanupOldReadReceipts() {
|
||||||
// Skip cleanup during startup phase or if privateChats is empty
|
// Skip cleanup during startup phase or if privateChats is empty
|
||||||
|
|||||||
Reference in New Issue
Block a user