From f0c8b4e2eaefbcc178d4b376255dd40299049dfe Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 25 Jul 2026 17:43:12 +0200 Subject: [PATCH] Harden panic recovery and service shutdown --- bitchat/App/AppChromeModel.swift | 8 + bitchat/App/AppRuntime.swift | 22 +- .../Features/voice/VoiceCaptureSession.swift | 13 + bitchat/Features/voice/VoiceRecorder.swift | 15 ++ .../Services/BLE/BLEIncomingFileStore.swift | 219 ++++++++++++++- bitchat/Services/BLE/BLEService.swift | 251 +++++++++++++++--- bitchat/Services/GeohashPresenceService.swift | 64 ++++- .../Services/NetworkActivationService.swift | 14 + .../Services/NetworkReachabilityMonitor.swift | 15 ++ bitchat/ViewModels/ChatViewModel.swift | 201 ++++++++++---- .../ViewModels/VoiceRecordingViewModel.swift | 32 ++- bitchat/Views/ContentView.swift | 4 + bitchatTests/BLEServiceCoreTests.swift | 20 ++ bitchatTests/ChatViewModelTests.swift | 86 +++++- .../BLEFileTransferHandlerTests.swift | 86 ++++++ .../GeohashPresenceServiceTests.swift | 42 ++- .../NetworkActivationServiceTests.swift | 77 +++++- .../NetworkReachabilityGateTests.swift | 1 + bitchatTests/VoiceCaptureSessionTests.swift | 58 ++++ bitchatTests/VoiceRecorderTests.swift | 29 ++ 20 files changed, 1147 insertions(+), 110 deletions(-) diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 9db9e51e..7f4ad329 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -21,6 +21,9 @@ final class AppChromeModel: ObservableObject { private let chatViewModel: ChatViewModel private var cancellables = Set() + /// The composer owns capture state above ChatViewModel. ContentView + /// installs this hook so both panic entry points synchronously stop it. + private var prepareForPanic: (@MainActor () -> Void)? /// Bulletin-board coordinator, created on first use of the board sheet. private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService) @@ -97,7 +100,12 @@ final class AppChromeModel: ObservableObject { showScreenshotPrivacyWarning = true } + func setPanicPreparation(_ preparation: (@MainActor () -> Void)?) { + prepareForPanic = preparation + } + func panicClearAllData() { + prepareForPanic?() chatViewModel.panicClearAllData() } diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index 4c1e20a0..309db931 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -107,12 +107,15 @@ final class AppRuntime: ObservableObject { ) ) - GeoRelayDirectory.shared.prefetchIfNeeded() + if chatViewModel.networkActivationAllowed { + GeoRelayDirectory.shared.prefetchIfNeeded() + } bindRuntimeObservers() NotificationDelegate.shared.runtime = self } func start() { + guard chatViewModel.networkActivationAllowed else { return } guard !started else { checkForSharedContent() return @@ -151,12 +154,14 @@ final class AppRuntime: ObservableObject { } func handleDidBecomeActiveNotification() { + guard chatViewModel.networkActivationAllowed else { return } chatViewModel.handleDidBecomeActive() checkForSharedContent() } #if os(macOS) func handleMacDidBecomeActiveNotification() { + guard chatViewModel.networkActivationAllowed else { return } record(.scenePhaseChanged(.active)) chatViewModel.handleDidBecomeActive() checkForSharedContent() @@ -175,6 +180,7 @@ final class AppRuntime: ObservableObject { didEnterBackground = true case .active: + guard chatViewModel.networkActivationAllowed else { return } record(.scenePhaseChanged(.active)) chatViewModel.meshService.startServices() TorManager.shared.setAppForeground(true) @@ -222,6 +228,7 @@ final class AppRuntime: ObservableObject { actionIdentifier: String = UNNotificationDefaultActionIdentifier, userInfo: [AnyHashable: Any] ) { + guard chatViewModel.networkActivationAllowed else { return } if actionIdentifier == NotificationService.waveActionID { chatViewModel.sendMeshWave() return @@ -273,6 +280,8 @@ private extension AppRuntime { NotificationCenter.default.publisher(for: .TorWillRestart) .receive(on: DispatchQueue.main) .sink { [weak self] _ in + guard self?.chatViewModel.networkActivationAllowed == true + else { return } self?.record(.torLifecycleChanged(.willRestart)) self?.chatViewModel.handleTorWillRestart() } @@ -281,6 +290,8 @@ private extension AppRuntime { NotificationCenter.default.publisher(for: .TorDidBecomeReady) .receive(on: DispatchQueue.main) .sink { [weak self] _ in + guard self?.chatViewModel.networkActivationAllowed == true + else { return } self?.record(.torLifecycleChanged(.didBecomeReady)) self?.chatViewModel.handleTorDidBecomeReady() } @@ -289,6 +300,8 @@ private extension AppRuntime { NotificationCenter.default.publisher(for: .TorWillStart) .receive(on: DispatchQueue.main) .sink { [weak self] _ in + guard self?.chatViewModel.networkActivationAllowed == true + else { return } self?.record(.torLifecycleChanged(.willStart)) self?.chatViewModel.handleTorWillStart() } @@ -297,6 +310,8 @@ private extension AppRuntime { NotificationCenter.default.publisher(for: .TorUserPreferenceChanged) .receive(on: DispatchQueue.main) .sink { [weak self] notification in + guard self?.chatViewModel.networkActivationAllowed == true + else { return } self?.record(.torLifecycleChanged(.preferenceChanged)) self?.chatViewModel.handleTorPreferenceChanged(notification) } @@ -313,6 +328,7 @@ private extension AppRuntime { } func checkForSharedContent() { + guard chatViewModel.networkActivationAllowed else { return } guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return } let clearSharedContent = { userDefaults.removeObject(forKey: "sharedContent") @@ -359,7 +375,9 @@ private extension AppRuntime { let becameConnected = isConnected && !lastNostrRelayConnectedState lastNostrRelayConnectedState = isConnected - guard started, becameConnected else { return } + guard chatViewModel.networkActivationAllowed, + started, + becameConnected else { return } let isInitialConnection = !didHandleInitialNostrConnection didHandleInitialNostrConnection = true diff --git a/bitchat/Features/voice/VoiceCaptureSession.swift b/bitchat/Features/voice/VoiceCaptureSession.swift index d2f4f8c3..b49c3beb 100644 --- a/bitchat/Features/voice/VoiceCaptureSession.swift +++ b/bitchat/Features/voice/VoiceCaptureSession.swift @@ -26,6 +26,8 @@ protocol VoiceCaptureSession: AnyObject { /// nothing valid was captured. func finish() async -> URL? func cancel() async + /// Stops capture and suppresses every later send before returning. + func panicCancelSynchronously() } /// The classic record-then-send backend, wrapping the shared `VoiceRecorder`. @@ -55,6 +57,10 @@ final class VoiceNoteCaptureSession: VoiceCaptureSession { func cancel() async { await recorder.cancelRecording(owner: owner) } + + func panicCancelSynchronously() { + recorder.panicCancelSynchronously(owner: owner) + } } /// Testable surface of the live capture engine. Production uses @@ -216,6 +222,13 @@ final class PTTLiveVoiceSession: VoiceCaptureSession { } } + func panicCancelSynchronously() { + // Do not emit a canceled packet: it would itself be pre-panic + // conversation data racing the emergency transport reset. + completed = true + capture.cancel() + } + private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) { guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return } sendPacket(packet.encode()) diff --git a/bitchat/Features/voice/VoiceRecorder.swift b/bitchat/Features/voice/VoiceRecorder.swift index eb1b6af2..80412856 100644 --- a/bitchat/Features/voice/VoiceRecorder.swift +++ b/bitchat/Features/voice/VoiceRecorder.swift @@ -246,6 +246,21 @@ actor VoiceRecorder { currentURL = nil } + /// Panic is a synchronous security boundary: the caller must know the + /// microphone, audio-session lease, and partial file are gone before it + /// rotates identities or deletes the media tree. VoiceRecorder is an + /// independent actor and this cleanup path never hops to MainActor, so a + /// short semaphore join is safe even when invoked by the UI actor. + nonisolated + func panicCancelSynchronously(owner: RecordingOwner) { + let finished = DispatchSemaphore(value: 0) + Task { + await cancelRecording(owner: owner) + finished.signal() + } + finished.wait() + } + /// The audio session was interrupted (call, Siri) or reconfigured: stop /// the recorder but keep `recorder`/`currentURL` so the caller's pending /// `stopRecording()` still returns the partial note. diff --git a/bitchat/Services/BLE/BLEIncomingFileStore.swift b/bitchat/Services/BLE/BLEIncomingFileStore.swift index 62f49631..f7c7703c 100644 --- a/bitchat/Services/BLE/BLEIncomingFileStore.swift +++ b/bitchat/Services/BLE/BLEIncomingFileStore.swift @@ -2,8 +2,116 @@ import BitLogger import BitFoundation import Foundation +struct PanicRecoveryIntent { + let fileMarkerEstablished: Bool + let externalMarkerEstablished: Bool + + var hasDurableMarker: Bool { + fileMarkerEstablished || externalMarkerEstablished + } +} + +/// Small, dependency-injectable transaction surface used by ChatViewModel. +/// Production persists the same intent in two independent locations before +/// any application state is erased. Tests can inject an ephemeral operation +/// set without touching the developer's Application Support directory. +struct PanicRecoveryOperations { + let isPending: () throws -> Bool + let begin: () -> PanicRecoveryIntent + let wipeMedia: (PanicRecoveryIntent) throws -> Void + let complete: () throws -> Void + + static func ephemeral( + wipeMedia: @escaping () throws -> Void = {} + ) -> PanicRecoveryOperations { + PanicRecoveryOperations( + isPending: { false }, + begin: { + PanicRecoveryIntent( + fileMarkerEstablished: false, + externalMarkerEstablished: false + ) + }, + wipeMedia: { _ in try wipeMedia() }, + complete: {} + ) + } + + static func live( + fileStore: BLEIncomingFileStore = BLEIncomingFileStore(), + defaults: UserDefaults = .standard + ) -> PanicRecoveryOperations { + let defaultsKey = "bitchat.panicResetPending" + return PanicRecoveryOperations( + isPending: { + if defaults.bool(forKey: defaultsKey) { + return true + } + return try fileStore.isPanicRecoveryPending() + }, + begin: { + defaults.set(true, forKey: defaultsKey) + let externalMarkerEstablished = + defaults.synchronize() + && defaults.bool(forKey: defaultsKey) + + let fileMarkerEstablished: Bool + do { + try fileStore.markPanicRecoveryPending() + fileMarkerEstablished = true + } catch { + fileMarkerEstablished = false + SecureLogger.error( + "Failed to persist file panic-recovery marker: \(error)", + category: .security + ) + } + + return PanicRecoveryIntent( + fileMarkerEstablished: fileMarkerEstablished, + externalMarkerEstablished: externalMarkerEstablished + ) + }, + wipeMedia: { intent in + try fileStore.panicWipe( + hasDurablePendingMarker: intent.hasDurableMarker + ) + }, + complete: { + // Keep the independent defaults latch until the file marker + // has definitely cleared. Any failure therefore remains + // visible to the next launch. + try fileStore.completePanicRecovery() + defaults.removeObject(forKey: defaultsKey) + guard defaults.synchronize(), + !defaults.bool(forKey: defaultsKey) else { + throw BLEIncomingFileStore.PanicRecoveryError + .externalMarkerCommitFailed + } + } + ) + } +} + struct BLEIncomingFileStore { + enum PanicRecoveryError: Error { + case externalMarkerCommitFailed + case markerWriteFailed(Error) + case markerWriteAndMediaWipeFailed( + markerError: Error, + mediaError: Error + ) + } + private static let quotaBytes: Int64 = 100 * 1024 * 1024 + /// Kept outside `files/` so deleting the media tree cannot erase the + /// fail-closed startup decision before the full panic has committed. + private static let panicRecoveryPendingMarkerFileName = + ".panic-recovery-pending" + /// Compatibility with a short-lived development build that used the + /// media-specific name for the same full-transaction latch. + private static let legacyPanicRecoveryPendingMarkerFileName = + ".panic-media-wipe-pending" private static let mediaSubdirectories = [ "voicenotes/incoming", "voicenotes/outgoing", @@ -25,28 +133,96 @@ struct BLEIncomingFileStore { let fileManager: FileManager private let baseDirectory: URL? private let dateProvider: () -> Date + private let panicMarkerWriter: (Data, URL) throws -> Void - init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) { + init( + fileManager: FileManager = .default, + baseDirectory: URL? = nil, + dateProvider: @escaping () -> Date = Date.init, + panicMarkerWriter: @escaping (Data, URL) throws -> Void = { + try $0.write(to: $1, options: .atomic) + } + ) { self.fileManager = fileManager self.baseDirectory = baseDirectory self.dateProvider = dateProvider + self.panicMarkerWriter = panicMarkerWriter } /// Panic-wipe every managed incoming and outgoing media artifact before /// returning. Recreating the directory tree keeps later capture/receive /// paths usable without allowing a detached cleanup task to race them. - func panicWipe() throws { - let filesDirectory = try rootDirectory().appendingPathComponent("files", isDirectory: true) - if fileManager.fileExists(atPath: filesDirectory.path) { - try fileManager.removeItem(at: filesDirectory) - } - for subdirectory in Self.mediaSubdirectories { - try fileManager.createDirectory( - at: filesDirectory.appendingPathComponent(subdirectory, isDirectory: true), - withIntermediateDirectories: true, - attributes: nil + /// + /// Marker persistence and deletion are deliberately separate error + /// domains: even when both durable marker channels fail, deletion is + /// still attempted before this method reports the marker failure. + func panicWipe( + hasDurablePendingMarker: Bool = false + ) throws { + let markerError: Error? + do { + try markPanicRecoveryPending() + markerError = nil + } catch { + markerError = error + SecureLogger.error( + "Could not persist file panic-recovery marker; attempting media deletion anyway: \(error)", + category: .security ) } + + do { + let filesDirectory = try rootDirectory() + .appendingPathComponent("files", isDirectory: true) + if fileManager.fileExists(atPath: filesDirectory.path) { + try fileManager.removeItem(at: filesDirectory) + } + for subdirectory in Self.mediaSubdirectories { + try fileManager.createDirectory( + at: filesDirectory.appendingPathComponent( + subdirectory, + isDirectory: true + ), + withIntermediateDirectories: true, + attributes: nil + ) + } + } catch { + if let markerError { + throw PanicRecoveryError.markerWriteAndMediaWipeFailed( + markerError: markerError, + mediaError: error + ) + } + throw error + } + + if let markerError, !hasDurablePendingMarker { + throw PanicRecoveryError.markerWriteFailed(markerError) + } + } + + func markPanicRecoveryPending() throws { + let markerURL = try panicRecoveryPendingMarkerURL() + try fileManager.createDirectory( + at: markerURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil + ) + try panicMarkerWriter(Data([1]), markerURL) + } + + func isPanicRecoveryPending() throws -> Bool { + try panicRecoveryMarkerURLs().contains { + fileManager.fileExists(atPath: $0.path) + } + } + + func completePanicRecovery() throws { + for markerURL in try panicRecoveryMarkerURLs() + where fileManager.fileExists(atPath: markerURL.path) { + try fileManager.removeItem(at: markerURL) + } } /// Resolves (and creates) an incoming-media directory for callers that @@ -152,6 +328,27 @@ struct BLEIncomingFileStore { ) } + private func panicRecoveryPendingMarkerURL() throws -> URL { + try rootDirectory().appendingPathComponent( + Self.panicRecoveryPendingMarkerFileName, + isDirectory: false + ) + } + + private func panicRecoveryMarkerURLs() throws -> [URL] { + let root = try rootDirectory() + return [ + root.appendingPathComponent( + Self.panicRecoveryPendingMarkerFileName, + isDirectory: false + ), + root.appendingPathComponent( + Self.legacyPanicRecoveryPendingMarkerFileName, + isDirectory: false + ) + ] + } + private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String { var candidate = (name ?? "") .replacingOccurrences(of: "\0", with: "") diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 1f919a4a..d8d7a6c6 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -155,6 +155,10 @@ final class BLEService: NSObject { private var centralManager: CBCentralManager? private var peripheralManager: CBPeripheralManager? private var characteristic: CBMutableCharacteristic? + private let shouldInitializeBluetoothManagers: Bool + private let panicLifecycleLock = NSLock() + private var _isPanicSuspended: Bool + private var panicLifecycleGeneration: UInt64 = 0 // MARK: - Identity @@ -275,10 +279,13 @@ final class BLEService: NSObject { keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, - initializeBluetoothManagers: Bool = true + initializeBluetoothManagers: Bool = true, + startSuspendedForPanicRecovery: Bool = false ) { self.keychain = keychain self.idBridge = idBridge + self.shouldInitializeBluetoothManagers = initializeBluetoothManagers + self._isPanicSuspended = startSuspendedForPanicRecovery noiseService = NoiseEncryptionService(keychain: keychain) self.identityManager = identityManager super.init() @@ -327,37 +334,90 @@ final class BLEService: NSObject { // any access from another queue (cross-queue reads use readLinkState). linkStateStore.assumeOwnership(of: bleQueue) - if initializeBluetoothManagers { - // Initialize BLE on background queue to prevent main thread blocking. - #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 + if !startSuspendedForPanicRecovery { + initializeBluetoothManagersIfNeeded() } // Single maintenance timer for all periodic tasks (dispatch-based for // determinism). Only run it when real Bluetooth managers exist. meshBackgroundEnabled = initializeBluetoothManagers - startMaintenanceTimer() + if !startSuspendedForPanicRecovery { + startMaintenanceTimer() + } // Publish initial empty state requestPeerDataPublish() // Initialize gossip sync manager - restartGossipManager() + if !startSuspendedForPanicRecovery { + restartGossipManager() + } + } + + private var isPanicSuspended: Bool { + panicLifecycleLock.lock() + defer { panicLifecycleLock.unlock() } + return _isPanicSuspended + } + + private func setPanicSuspended(_ suspended: Bool) { + panicLifecycleLock.lock() + if suspended { + panicLifecycleGeneration &+= 1 + } + _isPanicSuspended = suspended + panicLifecycleLock.unlock() + } + + private func capturePanicLifecycleGeneration() -> UInt64? { + panicLifecycleLock.lock() + defer { panicLifecycleLock.unlock() } + return _isPanicSuspended ? nil : panicLifecycleGeneration + } + + private func isCurrentPanicLifecycleGeneration(_ generation: UInt64) -> Bool { + panicLifecycleLock.lock() + defer { panicLifecycleLock.unlock() } + return !_isPanicSuspended && panicLifecycleGeneration == generation + } + + private func initializeBluetoothManagersIfNeeded() { + guard shouldInitializeBluetoothManagers, + centralManager == nil, + peripheralManager == nil, + !isPanicSuspended else { return } + + // Initialize BLE on its dedicated delegate queue. On iOS, retain the + // restoration identifiers even when construction was deferred by a + // pending panic-recovery latch. + #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 } private func restartGossipManager() { + guard !isPanicSuspended else { return } // Stop existing gossipSyncManager?.stop() @@ -416,7 +476,35 @@ final class BLEService: NSObject { #endif } - func resetIdentityForPanic(currentNickname: String) { + /// Close radio admission before application state starts disappearing. + /// CoreBluetooth callbacks consult the same gate and cannot restart scan + /// or advertising while the full panic transaction is incomplete. + func suspendForPanicReset() { + setPanicSuspended(true) + gossipSyncManager?.stop() + gossipSyncManager = nil + // Wait out sends that passed admission before the gate closed. Work + // queued behind this barrier observes `isPanicSuspended` and exits, + // so the radio stop below is a true synchronous boundary. + messageQueue.sync(flags: .barrier) {} + stopServicesImmediatelyForPanic() + clearEmergencySessionState() + } + + /// Reopen the radio only after media deletion and recovery-marker commit. + func completePanicReset(restartServices: Bool) { + setPanicSuspended(false) + guard restartServices else { return } + startServices() + sendAnnounce(forceSend: true) + } + + func resetIdentityForPanic( + currentNickname: String, + restartServices: Bool = true + ) { + gossipSyncManager?.stop() + gossipSyncManager = nil messageQueue.sync(flags: .barrier) { pendingNoiseSessionQueues.removeAll() } @@ -460,16 +548,19 @@ final class BLEService: NSObject { configureNoiseServiceCallbacks(for: newNoise) refreshPeerIdentity() } - restartGossipManager() - - setNickname(currentNickname) - + // Keep the transport silent until the application-level transaction + // has also removed its media and committed both recovery markers. + myNickname = currentNickname messageDeduplicator.reset() messageQueue.async(flags: .barrier) { [weak self] in self?.selfBroadcastTracker.removeAll() } requestPeerDataPublish() - startServices() + if restartServices { + restartGossipManager() + startServices() + sendAnnounce(forceSend: true) + } } // Ensure this runs on message queue to avoid main thread blocking @@ -481,6 +572,7 @@ final class BLEService: NSObject { } return } + guard !isPanicSuspended else { return } guard content.count <= maxMessageLength else { SecureLogger.error("Message too long: \(content.count) chars", category: .session) @@ -562,7 +654,9 @@ final class BLEService: NSObject { /// `startServices()` — the latter matters after a panic reset, where /// `stopServices()` cancels and nils the timer. private func startMaintenanceTimer() { - guard meshBackgroundEnabled, maintenanceTimer == nil else { return } + guard !isPanicSuspended, + meshBackgroundEnabled, + maintenanceTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: bleQueue) timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval, repeating: TransportConfig.bleMaintenanceInterval, @@ -575,6 +669,12 @@ final class BLEService: NSObject { } func startServices() { + guard let lifecycleGeneration = + capturePanicLifecycleGeneration() else { return } + initializeBluetoothManagersIfNeeded() + if gossipSyncManager == nil { + restartGossipManager() + } // Restart the maintenance timer if a prior stopServices() cancelled it // (e.g. the panic flow), otherwise periodic announces, peer reconciliation // and cache cleanup would never resume until app restart. @@ -591,7 +691,11 @@ final class BLEService: NSObject { // Send initial announce after services are ready // Use longer delay to avoid conflicts with other announces messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in - self?.sendAnnounce(forceSend: true) + guard let self, + self.isCurrentPanicLifecycleGeneration( + lifecycleGeneration + ) else { return } + self.sendAnnounce(forceSend: true) } } @@ -659,10 +763,37 @@ final class BLEService: NSObject { centralManager?.cancelPeripheralConnection(state.peripheral) } } + + /// Panic cannot spend its security boundary sending a signed LEAVE or + /// pumping the main run loop. Close the radio and timers immediately; + /// the identity/session cleanup follows synchronously. + private func stopServicesImmediatelyForPanic() { + collectionsQueue.sync(flags: .barrier) { + pendingNotifications.removeAll() + } + + maintenanceTimer?.cancel() + maintenanceTimer = nil + scanDutyTimer?.cancel() + scanDutyTimer = nil + + centralManager?.stopScan() + peripheralManager?.stopAdvertising() + + let peripheralsToDisconnect = bleQueue.sync { + linkStateStore.peripheralStates + } + for state in peripheralsToDisconnect { + centralManager?.cancelPeripheralConnection(state.peripheral) + } + } func emergencyDisconnectAll() { stopServices() + clearEmergencySessionState() + } + private func clearEmergencySessionState() { // Clear all sessions and peers let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) { let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) } @@ -899,6 +1030,7 @@ final class BLEService: NSObject { func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) { messageQueue.async { [weak self] in guard let self = self else { return } + guard !self.isPanicSuspended else { return } guard let payload = filePacket.encode() else { SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session) return @@ -935,6 +1067,7 @@ final class BLEService: NSObject { func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) { messageQueue.async { [weak self] in guard let self = self else { return } + guard !self.isPanicSuspended else { return } guard let payload = filePacket.encode() else { SecureLogger.error("❌ Failed to encode file packet for private send", category: .session) return @@ -1088,6 +1221,7 @@ final class BLEService: NSObject { // MARK: - Packet Broadcasting private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) { + guard !isPanicSuspended else { return } // Apply route if recipient exists (centralized route application) let packetToSend: BitchatPacket if let recipientPeerID = PeerID(hexData: packet.recipientID) { @@ -1169,8 +1303,10 @@ final class BLEService: NSObject { } private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) { + guard !isPanicSuspended else { return } collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } + guard !self.isPanicSuspended else { return } let result = self.pendingNotifications.enqueue( data: data, targets: centrals, @@ -1271,6 +1407,7 @@ final class BLEService: NSObject { requireDirectPeerLink: Bool = false, requireNoiseAuthenticatedPeerLink: Bool = false ) -> Bool { + guard !isPanicSuspended else { return false } let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) } var excludedPeerLinks = links(to: ingressRecord?.peerID) if requireNoiseAuthenticatedPeerLink { @@ -1421,6 +1558,7 @@ final class BLEService: NSObject { } private func flushDirectedSpool() { + guard !isPanicSuspended else { return } // Move items out and attempt broadcast; if still no links, they'll be re-spooled let toSend = collectionsQueue.sync(flags: .barrier) { pendingDirectedRelays.drainUnexpired( @@ -1637,6 +1775,7 @@ final class BLEService: NSObject { } } private func sendAnnounce(forceSend: Bool = false) { + guard !isPanicSuspended else { return } // Throttle announces to prevent flooding if !announceThrottle.shouldSend(force: forceSend, now: Date()) { return @@ -1846,6 +1985,13 @@ extension BLEService: CBCentralManagerDelegate { #if os(iOS) func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? [] + guard !isPanicSuspended else { + central.stopScan() + restoredPeripherals.forEach { + central.cancelPeripheralConnection($0) + } + return + } let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? [] let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:] let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool @@ -1901,6 +2047,10 @@ extension BLEService: CBCentralManagerDelegate { switch central.state { case .poweredOn: + guard !isPanicSuspended else { + central.stopScan() + return + } // Links restored as connected have no characteristic in the new // process; without rediscovery they sit connected-but-unusable // until the peer disconnects. Runs here (not willRestoreState) @@ -1957,7 +2107,8 @@ extension BLEService: CBCentralManagerDelegate { } private func startScanning() { - guard let central = centralManager, + guard !isPanicSuspended, + let central = centralManager, central.state == .poweredOn, !central.isScanning else { return } @@ -1978,6 +2129,7 @@ extension BLEService: CBCentralManagerDelegate { } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { + guard !isPanicSuspended else { return } let peripheralID = peripheral.identifier.uuidString let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…") let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true @@ -2019,6 +2171,10 @@ extension BLEService: CBCentralManagerDelegate { } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + guard !isPanicSuspended else { + central.cancelPeripheralConnection(peripheral) + return + } let peripheralID = peripheral.identifier.uuidString #if os(iOS) @@ -2169,7 +2325,9 @@ private extension CBPeripheralState { extension BLEService { private func tryConnectFromQueue() { - guard let central = centralManager, central.state == .poweredOn else { return } + guard !isPanicSuspended, + let central = centralManager, + central.state == .poweredOn else { return } let decision = connectionScheduler.nextCandidate( connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount, @@ -2195,6 +2353,7 @@ extension BLEService { using central: CBCentralManager, logPrefix: String ) { + guard !isPanicSuspended else { return } let peripheral = candidate.peripheral let peripheralID = candidate.peripheralID linkStateStore.beginConnecting(to: peripheral, at: Date()) @@ -2376,6 +2535,7 @@ extension BLEService { extension BLEService: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard !isPanicSuspended else { return } if let error = error { SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session) // Retry service discovery after a delay @@ -2402,6 +2562,7 @@ extension BLEService: CBPeripheralDelegate { } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + guard !isPanicSuspended else { return } if let error = error { SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session) return @@ -2449,6 +2610,7 @@ extension BLEService: CBPeripheralDelegate { } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard !isPanicSuspended else { return } if let error = error { SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session) return @@ -2566,6 +2728,7 @@ extension BLEService: CBPeripheralDelegate { } func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + guard !isPanicSuspended else { return } // Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") { SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session) @@ -2574,6 +2737,7 @@ extension BLEService: CBPeripheralDelegate { } func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { + guard !isPanicSuspended else { return } SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session) let shouldRediscover = BLEService.shouldRediscoverBitChatService( @@ -2594,6 +2758,7 @@ extension BLEService: CBPeripheralDelegate { } func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + guard !isPanicSuspended else { return } if let error = error { SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session) } else { @@ -2617,6 +2782,12 @@ extension BLEService: CBPeripheralManagerDelegate { switch peripheral.state { case .poweredOn: + guard !isPanicSuspended else { + peripheral.stopAdvertising() + peripheral.removeAllServices() + characteristic = nil + return + } // Remove all services first to ensure clean state peripheral.removeAllServices() @@ -2677,6 +2848,12 @@ extension BLEService: CBPeripheralManagerDelegate { #if os(iOS) func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) { + guard !isPanicSuspended else { + peripheral.stopAdvertising() + peripheral.removeAllServices() + characteristic = nil + return + } let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? [] let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:] @@ -2703,6 +2880,10 @@ extension BLEService: CBPeripheralManagerDelegate { #endif func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { + guard !isPanicSuspended else { + peripheral.stopAdvertising() + return + } if let error = error { SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session) return @@ -2718,6 +2899,7 @@ extension BLEService: CBPeripheralManagerDelegate { } func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { + guard !isPanicSuspended else { return } let centralUUID = central.identifier.uuidString SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))…", category: .session) linkStateStore.addSubscribedCentral(central) @@ -2759,7 +2941,7 @@ extension BLEService: CBPeripheralManagerDelegate { let removedPeerID = linkStateStore.removeSubscribedCentral(central) // Ensure we're still advertising for other devices to find us - if peripheral.isAdvertising == false { + if !isPanicSuspended, peripheral.isAdvertising == false { SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session) peripheral.startAdvertising(buildAdvertisementData()) } @@ -2796,6 +2978,7 @@ extension BLEService: CBPeripheralManagerDelegate { } func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { + guard !isPanicSuspended else { return } drainPendingNotifications(logPrefix: "✅ Sent") } @@ -2856,6 +3039,7 @@ extension BLEService: CBPeripheralManagerDelegate { for request in requests { peripheral.respond(to: request, withResult: .success) } + guard !isPanicSuspended else { return } // Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets. // Combine per-central request values by offset before decoding. @@ -4138,6 +4322,7 @@ extension BLEService { let uuid = peripheral.identifier.uuidString bleQueue.async { [weak self] in guard let self = self else { return } + guard !self.isPanicSuspended else { return } guard let state = self.linkStateStore.state(forPeripheralID: uuid), let ch = state.characteristic else { return } // Atomically take all pending items from the queue to avoid race conditions @@ -4228,7 +4413,10 @@ extension BLEService { slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve ) { bleQueue.async { [weak self] in - guard let self, let central = self.centralManager, central.state == .poweredOn else { return } + guard let self, + !self.isPanicSuspended, + let central = self.centralManager, + central.state == .poweredOn else { return } let budget = TransportConfig.bleMaxCentralLinks - slotReserve - self.linkStateStore.connectedOrConnectingPeripheralCount @@ -5535,6 +5723,7 @@ extension BLEService { // MARK: Consolidated Maintenance private func performMaintenance() { + guard !isPanicSuspended else { return } maintenanceCounter += 1 lastMaintenanceAt = Date() diff --git a/bitchat/Services/GeohashPresenceService.swift b/bitchat/Services/GeohashPresenceService.swift index e311c5c4..15e8ade2 100644 --- a/bitchat/Services/GeohashPresenceService.swift +++ b/bitchat/Services/GeohashPresenceService.swift @@ -45,6 +45,9 @@ final class GeohashPresenceService: ObservableObject { private var subscriptions = Set() private var heartbeatTimer: GeohashPresenceTimerProtocol? + private var pendingBroadcastTasks: [UUID: Task] = [:] + private var heartbeatGeneration: UInt64 = 0 + private var started = false private let availableChannelsProvider: () -> [GeohashChannel] private let locationChanges: AnyPublisher<[GeohashChannel], Never> private let torReadyPublisher: AnyPublisher @@ -147,10 +150,25 @@ final class GeohashPresenceService: ObservableObject { /// Start the service (safe to call multiple times) func start() { + guard !started else { return } + started = true + heartbeatGeneration &+= 1 SecureLogger.info("Presence: service starting...", category: .session) scheduleNextHeartbeat() } + /// Stops the timer and every decorrelation task synchronously at the panic + /// boundary. Generation checks also protect against custom sleepers that + /// ignore task cancellation and return later. + func stopForPanic() { + started = false + heartbeatGeneration &+= 1 + heartbeatTimer?.invalidate() + heartbeatTimer = nil + pendingBroadcastTasks.values.forEach { $0.cancel() } + pendingBroadcastTasks.removeAll(keepingCapacity: false) + } + private func setupObservers() { // Monitor location channel changes locationChanges @@ -169,20 +187,26 @@ final class GeohashPresenceService: ObservableObject { } func handleLocationChange() { + guard started else { return } // When location changes, we trigger an immediate (but slightly delayed) heartbeat // to announce presence in the new zone, then reset the loop. SecureLogger.debug("Presence: location changed, scheduling update", category: .session) heartbeatTimer?.invalidate() // Small delay to allow location state to settle + let generation = heartbeatGeneration heartbeatTimer = scheduleTimer(5.0) { [weak self] in Task { @MainActor [weak self] in - self?.performHeartbeat() + guard let self, + self.started, + self.heartbeatGeneration == generation else { return } + self.performHeartbeat() } } } func handleConnectivityChange() { + guard started else { return } SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session) // If we were waiting for network, do it now if heartbeatTimer == nil || !heartbeatTimer!.isValid { @@ -191,18 +215,29 @@ final class GeohashPresenceService: ObservableObject { } func scheduleNextHeartbeat() { + guard started else { return } heartbeatTimer?.invalidate() let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval) + let generation = heartbeatGeneration heartbeatTimer = scheduleTimer(interval) { [weak self] in Task { @MainActor [weak self] in - self?.performHeartbeat() + guard let self, + self.started, + self.heartbeatGeneration == generation else { return } + self.performHeartbeat() } } } func performHeartbeat() { + guard started else { return } + let generation = heartbeatGeneration // Always schedule next loop first ensures continuity even if this one fails/skips - defer { scheduleNextHeartbeat() } + defer { + if started, heartbeatGeneration == generation { + scheduleNextHeartbeat() + } + } // 1. Check preconditions guard torIsReady() else { @@ -228,14 +263,27 @@ final class GeohashPresenceService: ObservableObject { } // Launch independent task for each channel's delay - Task { @MainActor in + let taskID = UUID() + let sleeper = self.sleeper + let delay = TimeInterval.random( + in: burstMinDelay...burstMaxDelay + ) + let nanoseconds = UInt64(delay * 1_000_000_000) + let task = Task { @MainActor [weak self] in // Random delay for decorrelation - let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay) - let nanoseconds = UInt64(delay * 1_000_000_000) - await self.sleeper(nanoseconds) - + await sleeper(nanoseconds) + + guard let self else { return } + guard !Task.isCancelled, + self.started, + self.heartbeatGeneration == generation else { + self.pendingBroadcastTasks.removeValue(forKey: taskID) + return + } + self.pendingBroadcastTasks.removeValue(forKey: taskID) self.broadcastPresence(for: channel.geohash) } + pendingBroadcastTasks[taskID] = task } } diff --git a/bitchat/Services/NetworkActivationService.swift b/bitchat/Services/NetworkActivationService.swift index b8f5a7ba..e4633d59 100644 --- a/bitchat/Services/NetworkActivationService.swift +++ b/bitchat/Services/NetworkActivationService.swift @@ -154,6 +154,19 @@ final class NetworkActivationService: ObservableObject { .store(in: &cancellables) } + /// Stops all internet-facing work at the synchronous panic boundary. + /// `start()` may be called again only after the full wipe commits. + func stopForPanic() { + cancellables.removeAll() + started = false + reachabilityMonitor.stop() + activationAllowed = false + torAutoStartDesired = false + relayController.disconnect() + torController.setAutoStartAllowed(false) + applyTorState(torDesired: false) + } + func setUserTorEnabled(_ enabled: Bool) { guard enabled != userTorEnabled else { return } userTorEnabled = enabled @@ -167,6 +180,7 @@ final class NetworkActivationService: ObservableObject { } private func reevaluate() { + guard started else { return } let allowed = effectiveAllowed() let torDesired = allowed && userTorEnabled let statusChanged = allowed != activationAllowed diff --git a/bitchat/Services/NetworkReachabilityMonitor.swift b/bitchat/Services/NetworkReachabilityMonitor.swift index 53bb0677..6bb7bde3 100644 --- a/bitchat/Services/NetworkReachabilityMonitor.swift +++ b/bitchat/Services/NetworkReachabilityMonitor.swift @@ -27,6 +27,8 @@ protocol NetworkReachabilityMonitoring: AnyObject { var reachabilityPublisher: AnyPublisher { get } /// Begin monitoring. Idempotent. func start() + /// Stop monitoring and discard pending debounce work. Idempotent. + func stop() } /// Pure debounce/decision logic for reachability, split out so it can be @@ -98,6 +100,7 @@ final class AlwaysReachableMonitor: NetworkReachabilityMonitoring { Empty(completeImmediately: false).eraseToAnyPublisher() } func start() {} + func stop() {} } /// `NWPathMonitor`-backed reachability. All state lives on the main actor; the @@ -146,6 +149,18 @@ final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring { #endif } + func stop() { + guard started else { return } + started = false + flushWorkItem?.cancel() + flushWorkItem = nil + #if canImport(Network) + monitor?.pathUpdateHandler = nil + monitor?.cancel() + monitor = nil + #endif + } + /// Feed an observation into the debounce and publish committed changes. /// Exposed internally so higher layers/tests could drive it if needed. func ingest(reachable: Bool) { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index c6797aa5..f9619e42 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -89,6 +89,26 @@ import UIKit #endif import UniformTypeIdentifiers +struct PanicNetworkLifecycle { + let stop: @MainActor () -> Void + let restart: @MainActor () -> Void + + static let noop = PanicNetworkLifecycle(stop: {}, restart: {}) + + static var live: PanicNetworkLifecycle { + PanicNetworkLifecycle( + stop: { + GeohashPresenceService.shared.stopForPanic() + NetworkActivationService.shared.stopForPanic() + }, + restart: { + NetworkActivationService.shared.start() + GeohashPresenceService.shared.start() + } + ) + } +} + /// Manages the application state and business logic for BitChat. /// Acts as the primary coordinator between UI components and backend services, /// implementing the BitchatDelegate protocol to handle network events. @@ -142,6 +162,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @Published var currentColorScheme: ColorScheme = .light @Published var currentTheme: AppTheme = .matrix @Published var isConnected = false + @Published private(set) var panicRecoveryBlocked = false + var networkActivationAllowed: Bool { !panicRecoveryBlocked } @Published var nickname: String = "" { didSet { // Trim whitespace whenever nickname is set; whitespace-only becomes "" @@ -151,7 +173,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele return } // Update mesh service nickname if it's initialized - if !meshService.myPeerID.isEmpty { + if !isPanicResetting, !meshService.myPeerID.isEmpty { meshService.setNickname(nickname) } } @@ -295,7 +317,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele var nostrRelayManager: NostrRelayManager? private let userDefaults = UserDefaults.standard let keychain: KeychainManagerProtocol - private let panicMediaWipe: () throws -> Void + private let panicRecoveryOperations: PanicRecoveryOperations + private let panicNetworkLifecycle: PanicNetworkLifecycle + private var isPanicResetting = false /// Private group membership: keys in the keychain, metadata on disk. let groupStore: GroupStore private let nicknameKey = "bitchat.nickname" @@ -773,7 +797,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele locationPresenceStore: LocationPresenceStore? = nil, locationManager: LocationChannelManager = .shared ) { - let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager) + let livePanicRecoveryOperations = PanicRecoveryOperations.live() + let startSuspendedForRecovery: Bool + do { + startSuspendedForRecovery = + try livePanicRecoveryOperations.isPending() + } catch { + startSuspendedForRecovery = true + } + // Preserve the preflight decision used to defer CoreBluetooth. A + // transiently successful second read must not skip recovery and leave + // the service permanently suspended without running the wipe. + let panicRecoveryOperations = PanicRecoveryOperations( + isPending: { + if startSuspendedForRecovery { + return true + } + return try livePanicRecoveryOperations.isPending() + }, + begin: livePanicRecoveryOperations.begin, + wipeMedia: livePanicRecoveryOperations.wipeMedia, + complete: livePanicRecoveryOperations.complete + ) + let meshService = BLEService( + keychain: keychain, + idBridge: idBridge, + identityManager: identityManager, + startSuspendedForPanicRecovery: startSuspendedForRecovery + ) meshService.sfMetrics = .shared self.init( keychain: keychain, @@ -785,7 +836,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(), locationManager: locationManager, outboxStore: MessageOutboxStore(keychain: keychain), - sfMetrics: .shared + sfMetrics: .shared, + panicRecoveryOperations: panicRecoveryOperations, + panicNetworkLifecycle: .live ) } @@ -804,7 +857,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele readReceiptsDefaults: UserDefaults? = nil, outboxStore: MessageOutboxStore? = nil, sfMetrics: StoreAndForwardMetrics? = nil, - panicMediaWipe: (() throws -> Void)? = nil + panicMediaWipe: (() throws -> Void)? = nil, + panicRecoveryOperations: PanicRecoveryOperations? = nil, + panicNetworkLifecycle: PanicNetworkLifecycle = .noop ) { let conversations = conversations ?? ConversationStore() let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() @@ -819,13 +874,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ) self.keychain = keychain - self.panicMediaWipe = panicMediaWipe ?? { - // Unit tests share the developer's real Application Support - // directory. Production uses the managed store; tests that need - // to exercise the wipe inject a temporary-directory closure. - guard !TestEnvironment.isRunningTests else { return } - try BLEIncomingFileStore().panicWipe() - } + self.panicRecoveryOperations = panicRecoveryOperations + ?? .ephemeral(wipeMedia: panicMediaWipe ?? {}) + self.panicNetworkLifecycle = panicNetworkLifecycle self.groupStore = GroupStore(keychain: keychain) self.idBridge = idBridge self.identityManager = identityManager @@ -861,7 +912,31 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } .store(in: &cancellables) - ChatViewModelBootstrapper(viewModel: self).configure() + let recoveryRequired: Bool + do { + recoveryRequired = try self.panicRecoveryOperations.isPending() + } catch { + // Failure to read the latch cannot fail open. Re-run the complete + // transaction; a persistent storage failure leaves services + // blocked below. + recoveryRequired = true + SecureLogger.error( + "Could not read panic-recovery state; retrying the full wipe before startup: \(error)", + category: .security + ) + } + + if recoveryRequired { + SecureLogger.warning( + "Pending panic recovery detected; wiping before runtime services start", + category: .security + ) + _ = panicClearAllData(restartServices: false) + } + + if networkActivationAllowed { + ChatViewModelBootstrapper(viewModel: self).configure() + } } // MARK: - Deinitialization @@ -1165,8 +1240,28 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // PANIC: Emergency data clearing for activist safety @MainActor - func panicClearAllData() { - // Messages are processed immediately - nothing to flush + @discardableResult + func panicClearAllData(restartServices: Bool = true) -> Bool { + panicRecoveryBlocked = true + isPanicResetting = true + defer { isPanicResetting = false } + + // Stop internet and location-presence work before clearing identity or + // state. These services cancel their subscriptions and delayed tasks, + // so old callbacks cannot reconnect during the transaction. + panicNetworkLifecycle.stop() + + // Establish both independent durable intents before erasing anything. + // `wipeMedia` will still attempt deletion if neither write succeeds. + let recoveryIntent = panicRecoveryOperations.begin() + + // Quiesce the mesh before clearing stores. Identity replacement below + // deliberately stays stopped until media deletion and marker commit. + if let bleService = meshService as? BLEService { + bleService.suspendForPanicReset() + } else { + meshService.emergencyDisconnectAll() + } // Invalidate detached media preparation and close live capture file // handles before clearing state or removing the media directory. @@ -1193,7 +1288,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Reset nickname to anonymous nickname = "anon\(Int.random(in: 1000...9999))" - saveNickname() + userDefaults.set(nickname, forKey: nicknameKey) // Clear favorites and peer mappings // Clear through SecureIdentityStateManager instead of directly @@ -1265,46 +1360,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Clear Nostr identity associations idBridge.clearAllAssociations() - // Disconnect from all peers and clear persistent identity - // This will force creation of a new identity (new fingerprint) on next launch - meshService.emergencyDisconnectAll() + // Replace the BLE identity while keeping the radio stopped. It may + // reopen only after the durable panic transaction commits. if let bleService = meshService as? BLEService { - bleService.resetIdentityForPanic(currentNickname: nickname) - } - - // No need to force UserDefaults synchronization - - // Reinitialize Nostr with new identity - // This will generate new Nostr keys derived from new Noise keys. - // Skipped under tests: connecting the shared relay singleton starts - // real network/reconnect work that never completes and would keep the - // test process alive (the singleton, unlike a discardable instance, is - // never deallocated to cancel it). - if !TestEnvironment.isRunningTests { - Task { @MainActor in - // Small delay to ensure cleanup completes - try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds - - // Reinitialize Nostr relay manager with new identity. Reuse the - // shared singleton — every other component (NostrTransport, geohash - // subscriptions, AppRuntime observers) is bound to `.shared`, so - // creating a fresh instance here would split relay state and leave - // sends running against a disconnected manager. - nostrRelayManager = NostrRelayManager.shared - setupNostrMessageHandling() - nostrRelayManager?.connect() - } + bleService.resetIdentityForPanic( + currentNickname: nickname, + restartServices: false + ) + } else { + meshService.setNickname(nickname) } // The wipe must finish before this security action returns. A detached // task could otherwise lose a race with a new capture or app exit and // leave pre-panic media behind. + let panicCompleted: Bool do { - try panicMediaWipe() + try panicRecoveryOperations.wipeMedia(recoveryIntent) + try panicRecoveryOperations.complete() + panicCompleted = true SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session) } catch { - SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session) + panicCompleted = false + SecureLogger.error( + "Panic transaction did not commit; services remain stopped: \(error)", + category: .security + ) } + panicRecoveryBlocked = !panicCompleted // BCH-01-013: Clear iOS app switcher snapshots. Keep tests away from // the host user's real cache tree just as the default media wipe does. @@ -1314,9 +1397,31 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } #endif - // Force immediate UI update for panic mode - // UI updates immediately - no flushing needed + guard panicCompleted else { return false } + if let bleService = meshService as? BLEService { + // Startup recovery reopens admission but leaves actual service + // start to the bootstrapper immediately after this method. + bleService.completePanicReset( + restartServices: restartServices + ) + } + + if restartServices { + // All persistent state and media are gone. Bring each service back + // only now, under the new identity. + if !(meshService is BLEService) { + meshService.startServices() + } + + if !TestEnvironment.isRunningTests { + nostrRelayManager = NostrRelayManager.shared + setupNostrMessageHandling() + } + panicNetworkLifecycle.restart() + } + + return true } /// BCH-01-013: Clear iOS app switcher snapshots during panic mode diff --git a/bitchat/ViewModels/VoiceRecordingViewModel.swift b/bitchat/ViewModels/VoiceRecordingViewModel.swift index 4e3d4c6e..727fa39a 100644 --- a/bitchat/ViewModels/VoiceRecordingViewModel.swift +++ b/bitchat/ViewModels/VoiceRecordingViewModel.swift @@ -188,8 +188,25 @@ final class VoiceRecordingViewModel: ObservableObject { Task { let finalDuration = Date().timeIntervalSince(startDate) - if let url = await session.finish(), - isValidRecording(at: url, duration: finalDuration) { + if let url = await session.finish() { + // Panic and a newer hold both invalidate this completion. + // Never route an old recording using a post-panic target. + guard generation == holdGeneration else { + try? FileManager.default.removeItem(at: url) + return + } + guard isValidRecording( + at: url, + duration: finalDuration + ) else { + guard state == .idle else { return } + state = .error( + message: finalDuration < VoiceRecorder.minRecordingDuration + ? "Recording is too short." + : "Recording failed to save." + ) + return + } completion(url) } else { guard generation == holdGeneration, state == .idle else { return } @@ -206,6 +223,17 @@ final class VoiceRecordingViewModel: ObservableObject { finish(completion: nil) } + /// Invalidates in-flight permission/start/finalize callbacks and tears + /// down an active microphone before the panic transaction continues. + func panicWipe() { + holdGeneration &+= 1 + let session = activeSession + activeSession = nil + state = .idle + isLiveStreaming = false + session?.panicCancelSynchronously() + } + private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool { if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), let fileSize = attributes[.size] as? NSNumber, diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index fc1a6766..a8134ed9 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -79,6 +79,9 @@ struct ContentView: View { voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in conversationUIModel?.makeVoiceCaptureSession() ?? VoiceNoteCaptureSession() } + appChromeModel.setPanicPreparation { [weak voiceRecordingVM] in + voiceRecordingVM?.panicWipe() + } #if os(macOS) DispatchQueue.main.async { isNicknameFieldFocused = false @@ -229,6 +232,7 @@ struct ContentView: View { } .onDisappear { autocompleteDebounceTimer?.invalidate() + appChromeModel.setPanicPreparation(nil) } } diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index a1d62cae..62bcfcea 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -572,6 +572,26 @@ struct BLEServiceCoreTests { #expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16))) } + @Test + func panicSuspension_dropsLateOutboundWorkUntilCommit() async { + let ble = makeService() + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record + let packet = makePublicPacket( + content: "late callback", + sender: ble.myPeerID, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000) + ) + + ble.suspendForPanicReset() + ble.sendPacket(packet) + #expect(outbound.count(ofType: .message) == 0) + + ble.completePanicReset(restartServices: false) + ble.sendPacket(packet) + #expect(outbound.count(ofType: .message) == 1) + } + @Test func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws { let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB") diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index cd097f65..74720b9c 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -16,7 +16,9 @@ import BitFoundation /// Creates a ChatViewModel with mock dependencies for testing @MainActor private func makeTestableViewModel( - panicMediaWipe: (() throws -> Void)? = nil + panicMediaWipe: (() throws -> Void)? = nil, + panicRecoveryOperations: PanicRecoveryOperations? = nil, + panicNetworkLifecycle: PanicNetworkLifecycle = .noop ) -> (viewModel: ChatViewModel, transport: MockTransport) { let keychain = MockKeychain() let keychainHelper = MockKeychainHelper() @@ -29,7 +31,9 @@ private func makeTestableViewModel( idBridge: idBridge, identityManager: identityManager, transport: transport, - panicMediaWipe: panicMediaWipe + panicMediaWipe: panicMediaWipe, + panicRecoveryOperations: panicRecoveryOperations, + panicNetworkLifecycle: panicNetworkLifecycle ) return (viewModel, transport) @@ -1122,15 +1126,89 @@ struct ChatViewModelPanicTests { @Test @MainActor func panicClearAllData_finishesMediaWipeBeforeReturning() { var wipeFinished = false - let (viewModel, _) = makeTestableViewModel { + let (viewModel, _) = makeTestableViewModel(panicMediaWipe: { wipeFinished = true - } + }) viewModel.panicClearAllData() #expect(wipeFinished) } + @Test @MainActor + func panicClearAllData_stopsNetworkBeforeWipeAndRestartsAfterCommit() { + var events: [String] = [] + let lifecycle = PanicNetworkLifecycle( + stop: { events.append("stop") }, + restart: { events.append("restart") } + ) + let (viewModel, _) = makeTestableViewModel( + panicMediaWipe: { events.append("wipe") }, + panicNetworkLifecycle: lifecycle + ) + + let completed = viewModel.panicClearAllData() + + #expect(completed) + #expect(events == ["stop", "wipe", "restart"]) + #expect(viewModel.networkActivationAllowed) + } + + @Test @MainActor + func pendingPanicRecoveryCompletesBeforeTransportBootstrap() { + var events: [String] = [] + let operations = PanicRecoveryOperations( + isPending: { + events.append("read") + return true + }, + begin: { + events.append("begin") + return PanicRecoveryIntent( + fileMarkerEstablished: true, + externalMarkerEstablished: false + ) + }, + wipeMedia: { _ in events.append("wipe") }, + complete: { events.append("complete") } + ) + + let (viewModel, transport) = makeTestableViewModel( + panicRecoveryOperations: operations + ) + + #expect(events == ["read", "begin", "wipe", "complete"]) + #expect(transport.emergencyDisconnectCallCount == 1) + #expect(transport.startServicesCallCount == 1) + #expect(viewModel.networkActivationAllowed) + } + + @Test @MainActor + func failedStartupRecoveryLeavesTransportAndNetworkBlocked() { + enum WipeFailure: Error { case failed } + var completedMarker = false + let operations = PanicRecoveryOperations( + isPending: { true }, + begin: { + PanicRecoveryIntent( + fileMarkerEstablished: true, + externalMarkerEstablished: false + ) + }, + wipeMedia: { _ in throw WipeFailure.failed }, + complete: { completedMarker = true } + ) + + let (viewModel, transport) = makeTestableViewModel( + panicRecoveryOperations: operations + ) + + #expect(!completedMarker) + #expect(transport.emergencyDisconnectCallCount == 1) + #expect(transport.startServicesCallCount == 0) + #expect(!viewModel.networkActivationAllowed) + } + @Test @MainActor func panicClearAllData_delegatesToTransport() async { let (viewModel, transport) = makeTestableViewModel() diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift index 5da56814..5a898c58 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -410,6 +410,92 @@ struct BLEFileTransferHandlerTests { } } + @Test + func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws { + enum MarkerFailure: Error { case unavailable } + + let base = FileManager.default.temporaryDirectory + .appendingPathComponent( + "panic-marker-failure-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: base) } + let secret = base + .appendingPathComponent("files/images/outgoing", isDirectory: true) + .appendingPathComponent("secret.jpg") + try FileManager.default.createDirectory( + at: secret.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("secret".utf8).write(to: secret) + let store = BLEIncomingFileStore( + baseDirectory: base, + panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable } + ) + + do { + try store.panicWipe(hasDurablePendingMarker: false) + Issue.record("Expected the missing durable marker to fail closed") + } catch { + // The marker error is reported only after the deletion attempt. + } + + #expect(!FileManager.default.fileExists(atPath: secret.path)) + #expect( + FileManager.default.fileExists( + atPath: secret.deletingLastPathComponent().path + ) + ) + } + + @Test + func externalMarkerAllowsDeletionToCommitWhenFileMarkerFails() throws { + enum MarkerFailure: Error { case unavailable } + + let base = FileManager.default.temporaryDirectory + .appendingPathComponent( + "panic-external-marker-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: base) } + let secret = base + .appendingPathComponent("files/voicenotes/incoming", isDirectory: true) + .appendingPathComponent("secret.m4a") + try FileManager.default.createDirectory( + at: secret.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("secret".utf8).write(to: secret) + let store = BLEIncomingFileStore( + baseDirectory: base, + panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable } + ) + + try store.panicWipe(hasDurablePendingMarker: true) + + #expect(!FileManager.default.fileExists(atPath: secret.path)) + } + + @Test + func panicRecoveryMarkerPersistsUntilExplicitCommit() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent( + "panic-recovery-marker-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: base) } + let store = BLEIncomingFileStore(baseDirectory: base) + + try store.markPanicRecoveryPending() + #expect(try store.isPanicRecoveryPending()) + try store.panicWipe(hasDurablePendingMarker: true) + #expect(try store.isPanicRecoveryPending()) + + try store.completePanicRecovery() + + #expect(try !store.isPanicRecoveryPending()) + } + private func expectNoSideEffects(_ recorder: Recorder) { #expect(recorder.signedNameQueries.isEmpty) #expect(recorder.trackedPackets.isEmpty) diff --git a/bitchatTests/Services/GeohashPresenceServiceTests.swift b/bitchatTests/Services/GeohashPresenceServiceTests.swift index 65c33f0c..f463c4a1 100644 --- a/bitchatTests/Services/GeohashPresenceServiceTests.swift +++ b/bitchatTests/Services/GeohashPresenceServiceTests.swift @@ -76,6 +76,7 @@ final class GeohashPresenceServiceTests: XCTestCase { burstMaxDelay: 0 ) + service.start() service.performHeartbeat() let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 } @@ -83,7 +84,7 @@ final class GeohashPresenceServiceTests: XCTestCase { XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"])) XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"])) XCTAssertEqual(sleptNanoseconds.count, 3) - XCTAssertEqual(scheduler.intervals, [17]) + XCTAssertEqual(scheduler.intervals, [17, 17]) } func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async { @@ -97,11 +98,12 @@ final class GeohashPresenceServiceTests: XCTestCase { loopMaxInterval: 21 ) + service.start() service.performHeartbeat() try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertEqual(sendCount, 0) - XCTAssertEqual(scheduler.intervals, [21]) + XCTAssertEqual(scheduler.intervals, [21, 21]) } func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async { @@ -115,11 +117,45 @@ final class GeohashPresenceServiceTests: XCTestCase { loopMaxInterval: 22 ) + service.start() service.performHeartbeat() try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertEqual(sendCount, 0) - XCTAssertEqual(scheduler.intervals, [22]) + XCTAssertEqual(scheduler.intervals, [22, 22]) + } + + func test_stopForPanic_cancelsTimerAndSuppressesDelayedBroadcast() async throws { + let identity = try NostrIdentity.generate() + let scheduler = MockGeohashPresenceScheduler() + var sleeperContinuation: CheckedContinuation? + var sendCount = 0 + let service = makeService( + scheduler: scheduler, + deriveIdentity: { _ in identity }, + relaySender: { _, _ in sendCount += 1 }, + sleeper: { _ in + await withCheckedContinuation { continuation in + sleeperContinuation = continuation + } + }, + burstMinDelay: 1, + burstMaxDelay: 1 + ) + + service.start() + service.performHeartbeat() + let delayStarted = await waitUntil { + sleeperContinuation != nil + } + XCTAssertTrue(delayStarted) + + service.stopForPanic() + sleeperContinuation?.resume() + try? await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertEqual(sendCount, 0) + XCTAssertEqual(scheduler.timers.first?.invalidateCallCount, 1) } func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws { diff --git a/bitchatTests/Services/NetworkActivationServiceTests.swift b/bitchatTests/Services/NetworkActivationServiceTests.swift index 72d9f6be..b4f65f0e 100644 --- a/bitchatTests/Services/NetworkActivationServiceTests.swift +++ b/bitchatTests/Services/NetworkActivationServiceTests.swift @@ -91,6 +91,53 @@ final class NetworkActivationServiceTests: XCTestCase { XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1) } + func test_stopForPanic_synchronouslyStopsAndIgnoresPublisherUpdates() async { + let context = makeService(permission: .authorized, favorites: []) + + context.service.start() + context.service.stopForPanic() + let connectCountAfterStop = context.relayController.connectCallCount + let startCountAfterStop = context.torController.startIfNeededCallCount + + context.favoritesSubject.send([Data([0x01])]) + context.reachability.set(false) + context.reachability.set(true) + try? await Task.sleep(nanoseconds: 30_000_000) + + XCTAssertFalse(context.service.activationAllowed) + XCTAssertEqual(context.reachability.stopCallCount, 1) + XCTAssertEqual(context.torController.autoStartAllowedValues.last, false) + XCTAssertEqual(context.proxyController.proxyModes.last, false) + XCTAssertGreaterThanOrEqual( + context.torController.shutdownCompletelyCallCount, + 1 + ) + XCTAssertGreaterThanOrEqual( + context.relayController.disconnectCallCount, + 1 + ) + XCTAssertEqual( + context.relayController.connectCallCount, + connectCountAfterStop + ) + XCTAssertEqual( + context.torController.startIfNeededCallCount, + startCountAfterStop + ) + } + + func test_start_afterPanicStop_reestablishesSubscriptions() { + let context = makeService(permission: .authorized, favorites: []) + + context.service.start() + context.service.stopForPanic() + context.service.start() + + XCTAssertTrue(context.service.activationAllowed) + XCTAssertEqual(context.reachability.startCallCount, 2) + XCTAssertEqual(context.relayController.connectCallCount, 2) + } + private func makeService( permission: LocationChannelManager.PermissionState, favorites: Set @@ -104,6 +151,7 @@ final class NetworkActivationServiceTests: XCTestCase { let torController = MockNetworkActivationTorController() let relayController = MockNetworkActivationRelayController() let proxyController = MockNetworkActivationProxyController() + let reachability = MockNetworkActivationReachability() let notificationCenter = NotificationCenter() let service = NetworkActivationService( storage: storage, @@ -111,7 +159,7 @@ final class NetworkActivationServiceTests: XCTestCase { mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), permissionProvider: { permissionSubject.value }, mutualFavoritesProvider: { favoritesSubject.value }, - reachabilityMonitor: AlwaysReachableMonitor(), + reachabilityMonitor: reachability, torController: torController, relayController: relayController, proxyController: proxyController, @@ -121,6 +169,7 @@ final class NetworkActivationServiceTests: XCTestCase { service: service, storage: storage, favoritesSubject: favoritesSubject, + reachability: reachability, torController: torController, relayController: relayController, proxyController: proxyController, @@ -148,12 +197,38 @@ private struct NetworkActivationTestContext { let service: NetworkActivationService let storage: UserDefaults let favoritesSubject: CurrentValueSubject, Never> + let reachability: MockNetworkActivationReachability let torController: MockNetworkActivationTorController let relayController: MockNetworkActivationRelayController let proxyController: MockNetworkActivationProxyController let notificationCenter: NotificationCenter } +@MainActor +private final class MockNetworkActivationReachability: + NetworkReachabilityMonitoring { + private let subject = CurrentValueSubject(true) + private(set) var startCallCount = 0 + private(set) var stopCallCount = 0 + + var isReachable: Bool { subject.value } + var reachabilityPublisher: AnyPublisher { + subject.removeDuplicates().dropFirst().eraseToAnyPublisher() + } + + func start() { + startCallCount += 1 + } + + func stop() { + stopCallCount += 1 + } + + func set(_ reachable: Bool) { + subject.send(reachable) + } +} + @MainActor private final class MockNetworkActivationTorController: NetworkActivationTorControlling { private(set) var autoStartAllowedValues: [Bool] = [] diff --git a/bitchatTests/Services/NetworkReachabilityGateTests.swift b/bitchatTests/Services/NetworkReachabilityGateTests.swift index fee973aa..e3c916af 100644 --- a/bitchatTests/Services/NetworkReachabilityGateTests.swift +++ b/bitchatTests/Services/NetworkReachabilityGateTests.swift @@ -213,6 +213,7 @@ private final class ControllableReachabilityMonitor: NetworkReachabilityMonitori subject.removeDuplicates().dropFirst().eraseToAnyPublisher() } func start() { startCalled = true } + func stop() { startCalled = false } func set(_ reachable: Bool) { subject.send(reachable) } } diff --git a/bitchatTests/VoiceCaptureSessionTests.swift b/bitchatTests/VoiceCaptureSessionTests.swift index 40853f2c..344697a6 100644 --- a/bitchatTests/VoiceCaptureSessionTests.swift +++ b/bitchatTests/VoiceCaptureSessionTests.swift @@ -61,6 +61,7 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession { private let startError: Error? private(set) var finishStarted = false private(set) var cancelCount = 0 + private(set) var panicCancelCount = 0 private var finishContinuation: CheckedContinuation? init(startError: Error? = nil) { @@ -83,6 +84,10 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession { cancelCount += 1 } + func panicCancelSynchronously() { + panicCancelCount += 1 + } + func resolveFinish(with url: URL?) { let continuation = finishContinuation finishContinuation = nil @@ -204,4 +209,57 @@ struct VoiceCaptureSessionTests { } #expect(viewModel.state == .idle) } + + @Test func panicSynchronouslyCancelsActiveCaptureAndResetsUI() async { + let session = GatedVoiceCaptureSession() + let viewModel = VoiceRecordingViewModel() + viewModel.sessionProvider = { session } + + viewModel.start(shouldShow: true) + await waitUntil { self.isRecording(viewModel.state) } + + viewModel.panicWipe() + + #expect(session.panicCancelCount == 1) + #expect(viewModel.state == .idle) + #expect(!viewModel.isLiveStreaming) + } + + @Test func panicInvalidatesARecordingAlreadyFinalizing() async throws { + let session = GatedVoiceCaptureSession() + let viewModel = VoiceRecordingViewModel() + viewModel.sessionProvider = { session } + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("voice-panic-\(UUID().uuidString).m4a") + try Data([0x01]).write(to: url) + var delivered = false + + viewModel.start(shouldShow: true) + await waitUntil { self.isRecording(viewModel.state) } + viewModel.finish { _ in delivered = true } + await waitUntil { session.finishStarted } + + viewModel.panicWipe() + session.resolveFinish(with: url) + await waitUntil { + !FileManager.default.fileExists(atPath: url.path) + } + + #expect(!delivered) + #expect(viewModel.state == .idle) + } + + @Test func liveSessionPanicStopsCaptureWithoutSendingControl() { + let capture = StubPTTCapture(stopResult: (nil, 0)) + var sentPackets: [Data] = [] + let session = PTTLiveVoiceSession( + sendPacket: { sentPackets.append($0) }, + capture: capture + ) + + session.panicCancelSynchronously() + + #expect(capture.cancelCount == 1) + #expect(sentPackets.isEmpty) + } } diff --git a/bitchatTests/VoiceRecorderTests.swift b/bitchatTests/VoiceRecorderTests.swift index dfaa62d5..116e73fe 100644 --- a/bitchatTests/VoiceRecorderTests.swift +++ b/bitchatTests/VoiceRecorderTests.swift @@ -360,6 +360,35 @@ struct VoiceRecorderTests { #expect(FileManager.default.fileExists(atPath: secondURL.path)) } + @Test func classicSessionPanicStopsRecorderAndDeletesFileBeforeReturning() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let rawSession = VoiceRecorderTestSession() + let coordinator = AudioSessionCoordinator(session: rawSession) + let factory = TestVoiceAudioRecorderFactory(plans: [.success]) + let voiceRecorder = VoiceRecorder( + sessionCoordinator: coordinator, + recorderFactory: factory, + permissionGranted: { true }, + paddingInterval: 0, + outputDirectory: directory + ) + let capture = VoiceNoteCaptureSession(recorder: voiceRecorder) + + try await capture.start() + let url = try #require(factory.urls.first) + let recorder = try #require(factory.recorders.first) + + capture.panicCancelSynchronously() + + #expect(recorder.stopCallCount == 1) + #expect(!recorder.isRecording) + #expect(!FileManager.default.fileExists(atPath: url.path)) + await coordinator.drain() + #expect(rawSession.activationCalls == [true, false]) + } + private func verifyFailedStart( firstPlan: TestVoiceAudioRecorderFactory.Plan, expectedPrepareCalls: Int,