diff --git a/bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme b/bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme
index 6c461299..8000e27d 100644
--- a/bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme
+++ b/bitchat.xcodeproj/xcshareddata/xcschemes/bitchat (iOS).xcscheme
@@ -98,8 +98,8 @@
diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift
index 11717fbe..0724d4f3 100644
--- a/bitchat/Services/BLE/BLEService.swift
+++ b/bitchat/Services/BLE/BLEService.swift
@@ -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
diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift
index 80a5ce80..72690e4a 100644
--- a/bitchat/Services/TransportConfig.swift
+++ b/bitchat/Services/TransportConfig.swift
@@ -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
diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift
index a2046762..1a3c5d0b 100644
--- a/bitchat/ViewModels/ChatViewModel.swift
+++ b/bitchat/ViewModels/ChatViewModel.swift
@@ -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 = []
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