mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 03:45:21 +00:00
Harden panic recovery and service shutdown
This commit is contained in:
@@ -21,6 +21,9 @@ final class AppChromeModel: ObservableObject {
|
|||||||
|
|
||||||
private let chatViewModel: ChatViewModel
|
private let chatViewModel: ChatViewModel
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
/// 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.
|
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||||
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||||
@@ -97,7 +100,12 @@ final class AppChromeModel: ObservableObject {
|
|||||||
showScreenshotPrivacyWarning = true
|
showScreenshotPrivacyWarning = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setPanicPreparation(_ preparation: (@MainActor () -> Void)?) {
|
||||||
|
prepareForPanic = preparation
|
||||||
|
}
|
||||||
|
|
||||||
func panicClearAllData() {
|
func panicClearAllData() {
|
||||||
|
prepareForPanic?()
|
||||||
chatViewModel.panicClearAllData()
|
chatViewModel.panicClearAllData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,12 +107,15 @@ final class AppRuntime: ObservableObject {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
if chatViewModel.networkActivationAllowed {
|
||||||
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
|
}
|
||||||
bindRuntimeObservers()
|
bindRuntimeObservers()
|
||||||
NotificationDelegate.shared.runtime = self
|
NotificationDelegate.shared.runtime = self
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
|
guard chatViewModel.networkActivationAllowed else { return }
|
||||||
guard !started else {
|
guard !started else {
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
return
|
return
|
||||||
@@ -151,12 +154,14 @@ final class AppRuntime: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleDidBecomeActiveNotification() {
|
func handleDidBecomeActiveNotification() {
|
||||||
|
guard chatViewModel.networkActivationAllowed else { return }
|
||||||
chatViewModel.handleDidBecomeActive()
|
chatViewModel.handleDidBecomeActive()
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
func handleMacDidBecomeActiveNotification() {
|
func handleMacDidBecomeActiveNotification() {
|
||||||
|
guard chatViewModel.networkActivationAllowed else { return }
|
||||||
record(.scenePhaseChanged(.active))
|
record(.scenePhaseChanged(.active))
|
||||||
chatViewModel.handleDidBecomeActive()
|
chatViewModel.handleDidBecomeActive()
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
@@ -175,6 +180,7 @@ final class AppRuntime: ObservableObject {
|
|||||||
didEnterBackground = true
|
didEnterBackground = true
|
||||||
|
|
||||||
case .active:
|
case .active:
|
||||||
|
guard chatViewModel.networkActivationAllowed else { return }
|
||||||
record(.scenePhaseChanged(.active))
|
record(.scenePhaseChanged(.active))
|
||||||
chatViewModel.meshService.startServices()
|
chatViewModel.meshService.startServices()
|
||||||
TorManager.shared.setAppForeground(true)
|
TorManager.shared.setAppForeground(true)
|
||||||
@@ -222,6 +228,7 @@ final class AppRuntime: ObservableObject {
|
|||||||
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
||||||
userInfo: [AnyHashable: Any]
|
userInfo: [AnyHashable: Any]
|
||||||
) {
|
) {
|
||||||
|
guard chatViewModel.networkActivationAllowed else { return }
|
||||||
if actionIdentifier == NotificationService.waveActionID {
|
if actionIdentifier == NotificationService.waveActionID {
|
||||||
chatViewModel.sendMeshWave()
|
chatViewModel.sendMeshWave()
|
||||||
return
|
return
|
||||||
@@ -273,6 +280,8 @@ private extension AppRuntime {
|
|||||||
NotificationCenter.default.publisher(for: .TorWillRestart)
|
NotificationCenter.default.publisher(for: .TorWillRestart)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
|
guard self?.chatViewModel.networkActivationAllowed == true
|
||||||
|
else { return }
|
||||||
self?.record(.torLifecycleChanged(.willRestart))
|
self?.record(.torLifecycleChanged(.willRestart))
|
||||||
self?.chatViewModel.handleTorWillRestart()
|
self?.chatViewModel.handleTorWillRestart()
|
||||||
}
|
}
|
||||||
@@ -281,6 +290,8 @@ private extension AppRuntime {
|
|||||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
|
guard self?.chatViewModel.networkActivationAllowed == true
|
||||||
|
else { return }
|
||||||
self?.record(.torLifecycleChanged(.didBecomeReady))
|
self?.record(.torLifecycleChanged(.didBecomeReady))
|
||||||
self?.chatViewModel.handleTorDidBecomeReady()
|
self?.chatViewModel.handleTorDidBecomeReady()
|
||||||
}
|
}
|
||||||
@@ -289,6 +300,8 @@ private extension AppRuntime {
|
|||||||
NotificationCenter.default.publisher(for: .TorWillStart)
|
NotificationCenter.default.publisher(for: .TorWillStart)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
|
guard self?.chatViewModel.networkActivationAllowed == true
|
||||||
|
else { return }
|
||||||
self?.record(.torLifecycleChanged(.willStart))
|
self?.record(.torLifecycleChanged(.willStart))
|
||||||
self?.chatViewModel.handleTorWillStart()
|
self?.chatViewModel.handleTorWillStart()
|
||||||
}
|
}
|
||||||
@@ -297,6 +310,8 @@ private extension AppRuntime {
|
|||||||
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
|
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] notification in
|
.sink { [weak self] notification in
|
||||||
|
guard self?.chatViewModel.networkActivationAllowed == true
|
||||||
|
else { return }
|
||||||
self?.record(.torLifecycleChanged(.preferenceChanged))
|
self?.record(.torLifecycleChanged(.preferenceChanged))
|
||||||
self?.chatViewModel.handleTorPreferenceChanged(notification)
|
self?.chatViewModel.handleTorPreferenceChanged(notification)
|
||||||
}
|
}
|
||||||
@@ -313,6 +328,7 @@ private extension AppRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkForSharedContent() {
|
func checkForSharedContent() {
|
||||||
|
guard chatViewModel.networkActivationAllowed else { return }
|
||||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
|
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
|
||||||
let clearSharedContent = {
|
let clearSharedContent = {
|
||||||
userDefaults.removeObject(forKey: "sharedContent")
|
userDefaults.removeObject(forKey: "sharedContent")
|
||||||
@@ -359,7 +375,9 @@ private extension AppRuntime {
|
|||||||
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
||||||
lastNostrRelayConnectedState = isConnected
|
lastNostrRelayConnectedState = isConnected
|
||||||
|
|
||||||
guard started, becameConnected else { return }
|
guard chatViewModel.networkActivationAllowed,
|
||||||
|
started,
|
||||||
|
becameConnected else { return }
|
||||||
|
|
||||||
let isInitialConnection = !didHandleInitialNostrConnection
|
let isInitialConnection = !didHandleInitialNostrConnection
|
||||||
didHandleInitialNostrConnection = true
|
didHandleInitialNostrConnection = true
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ protocol VoiceCaptureSession: AnyObject {
|
|||||||
/// nothing valid was captured.
|
/// nothing valid was captured.
|
||||||
func finish() async -> URL?
|
func finish() async -> URL?
|
||||||
func cancel() async
|
func cancel() async
|
||||||
|
/// Stops capture and suppresses every later send before returning.
|
||||||
|
func panicCancelSynchronously()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
||||||
@@ -55,6 +57,10 @@ final class VoiceNoteCaptureSession: VoiceCaptureSession {
|
|||||||
func cancel() async {
|
func cancel() async {
|
||||||
await recorder.cancelRecording(owner: owner)
|
await recorder.cancelRecording(owner: owner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func panicCancelSynchronously() {
|
||||||
|
recorder.panicCancelSynchronously(owner: owner)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Testable surface of the live capture engine. Production uses
|
/// 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) {
|
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
|
||||||
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
|
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
|
||||||
sendPacket(packet.encode())
|
sendPacket(packet.encode())
|
||||||
|
|||||||
@@ -246,6 +246,21 @@ actor VoiceRecorder {
|
|||||||
currentURL = nil
|
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 audio session was interrupted (call, Siri) or reconfigured: stop
|
||||||
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
|
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
|
||||||
/// `stopRecording()` still returns the partial note.
|
/// `stopRecording()` still returns the partial note.
|
||||||
|
|||||||
@@ -2,8 +2,116 @@ import BitLogger
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
import Foundation
|
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 {
|
struct BLEIncomingFileStore {
|
||||||
|
enum PanicRecoveryError: Error {
|
||||||
|
case externalMarkerCommitFailed
|
||||||
|
case markerWriteFailed(Error)
|
||||||
|
case markerWriteAndMediaWipeFailed(
|
||||||
|
markerError: Error,
|
||||||
|
mediaError: Error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
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 = [
|
private static let mediaSubdirectories = [
|
||||||
"voicenotes/incoming",
|
"voicenotes/incoming",
|
||||||
"voicenotes/outgoing",
|
"voicenotes/outgoing",
|
||||||
@@ -25,28 +133,96 @@ struct BLEIncomingFileStore {
|
|||||||
let fileManager: FileManager
|
let fileManager: FileManager
|
||||||
private let baseDirectory: URL?
|
private let baseDirectory: URL?
|
||||||
private let dateProvider: () -> Date
|
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.fileManager = fileManager
|
||||||
self.baseDirectory = baseDirectory
|
self.baseDirectory = baseDirectory
|
||||||
self.dateProvider = dateProvider
|
self.dateProvider = dateProvider
|
||||||
|
self.panicMarkerWriter = panicMarkerWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Panic-wipe every managed incoming and outgoing media artifact before
|
/// Panic-wipe every managed incoming and outgoing media artifact before
|
||||||
/// returning. Recreating the directory tree keeps later capture/receive
|
/// returning. Recreating the directory tree keeps later capture/receive
|
||||||
/// paths usable without allowing a detached cleanup task to race them.
|
/// paths usable without allowing a detached cleanup task to race them.
|
||||||
func panicWipe() throws {
|
///
|
||||||
let filesDirectory = try rootDirectory().appendingPathComponent("files", isDirectory: true)
|
/// Marker persistence and deletion are deliberately separate error
|
||||||
if fileManager.fileExists(atPath: filesDirectory.path) {
|
/// domains: even when both durable marker channels fail, deletion is
|
||||||
try fileManager.removeItem(at: filesDirectory)
|
/// still attempted before this method reports the marker failure.
|
||||||
}
|
func panicWipe(
|
||||||
for subdirectory in Self.mediaSubdirectories {
|
hasDurablePendingMarker: Bool = false
|
||||||
try fileManager.createDirectory(
|
) throws {
|
||||||
at: filesDirectory.appendingPathComponent(subdirectory, isDirectory: true),
|
let markerError: Error?
|
||||||
withIntermediateDirectories: true,
|
do {
|
||||||
attributes: nil
|
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
|
/// 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 {
|
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||||
var candidate = (name ?? "")
|
var candidate = (name ?? "")
|
||||||
.replacingOccurrences(of: "\0", with: "")
|
.replacingOccurrences(of: "\0", with: "")
|
||||||
|
|||||||
@@ -155,6 +155,10 @@ final class BLEService: NSObject {
|
|||||||
private var centralManager: CBCentralManager?
|
private var centralManager: CBCentralManager?
|
||||||
private var peripheralManager: CBPeripheralManager?
|
private var peripheralManager: CBPeripheralManager?
|
||||||
private var characteristic: CBMutableCharacteristic?
|
private var characteristic: CBMutableCharacteristic?
|
||||||
|
private let shouldInitializeBluetoothManagers: Bool
|
||||||
|
private let panicLifecycleLock = NSLock()
|
||||||
|
private var _isPanicSuspended: Bool
|
||||||
|
private var panicLifecycleGeneration: UInt64 = 0
|
||||||
|
|
||||||
// MARK: - Identity
|
// MARK: - Identity
|
||||||
|
|
||||||
@@ -275,10 +279,13 @@ final class BLEService: NSObject {
|
|||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
idBridge: NostrIdentityBridge,
|
idBridge: NostrIdentityBridge,
|
||||||
identityManager: SecureIdentityStateManagerProtocol,
|
identityManager: SecureIdentityStateManagerProtocol,
|
||||||
initializeBluetoothManagers: Bool = true
|
initializeBluetoothManagers: Bool = true,
|
||||||
|
startSuspendedForPanicRecovery: Bool = false
|
||||||
) {
|
) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
|
self.shouldInitializeBluetoothManagers = initializeBluetoothManagers
|
||||||
|
self._isPanicSuspended = startSuspendedForPanicRecovery
|
||||||
noiseService = NoiseEncryptionService(keychain: keychain)
|
noiseService = NoiseEncryptionService(keychain: keychain)
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
super.init()
|
super.init()
|
||||||
@@ -327,37 +334,90 @@ final class BLEService: NSObject {
|
|||||||
// any access from another queue (cross-queue reads use readLinkState).
|
// any access from another queue (cross-queue reads use readLinkState).
|
||||||
linkStateStore.assumeOwnership(of: bleQueue)
|
linkStateStore.assumeOwnership(of: bleQueue)
|
||||||
|
|
||||||
if initializeBluetoothManagers {
|
if !startSuspendedForPanicRecovery {
|
||||||
// Initialize BLE on background queue to prevent main thread blocking.
|
initializeBluetoothManagersIfNeeded()
|
||||||
#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
|
// Single maintenance timer for all periodic tasks (dispatch-based for
|
||||||
// determinism). Only run it when real Bluetooth managers exist.
|
// determinism). Only run it when real Bluetooth managers exist.
|
||||||
meshBackgroundEnabled = initializeBluetoothManagers
|
meshBackgroundEnabled = initializeBluetoothManagers
|
||||||
startMaintenanceTimer()
|
if !startSuspendedForPanicRecovery {
|
||||||
|
startMaintenanceTimer()
|
||||||
|
}
|
||||||
|
|
||||||
// Publish initial empty state
|
// Publish initial empty state
|
||||||
requestPeerDataPublish()
|
requestPeerDataPublish()
|
||||||
|
|
||||||
// Initialize gossip sync manager
|
// 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() {
|
private func restartGossipManager() {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
// Stop existing
|
// Stop existing
|
||||||
gossipSyncManager?.stop()
|
gossipSyncManager?.stop()
|
||||||
|
|
||||||
@@ -416,7 +476,35 @@ final class BLEService: NSObject {
|
|||||||
#endif
|
#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) {
|
messageQueue.sync(flags: .barrier) {
|
||||||
pendingNoiseSessionQueues.removeAll()
|
pendingNoiseSessionQueues.removeAll()
|
||||||
}
|
}
|
||||||
@@ -460,16 +548,19 @@ final class BLEService: NSObject {
|
|||||||
configureNoiseServiceCallbacks(for: newNoise)
|
configureNoiseServiceCallbacks(for: newNoise)
|
||||||
refreshPeerIdentity()
|
refreshPeerIdentity()
|
||||||
}
|
}
|
||||||
restartGossipManager()
|
// Keep the transport silent until the application-level transaction
|
||||||
|
// has also removed its media and committed both recovery markers.
|
||||||
setNickname(currentNickname)
|
myNickname = currentNickname
|
||||||
|
|
||||||
messageDeduplicator.reset()
|
messageDeduplicator.reset()
|
||||||
messageQueue.async(flags: .barrier) { [weak self] in
|
messageQueue.async(flags: .barrier) { [weak self] in
|
||||||
self?.selfBroadcastTracker.removeAll()
|
self?.selfBroadcastTracker.removeAll()
|
||||||
}
|
}
|
||||||
requestPeerDataPublish()
|
requestPeerDataPublish()
|
||||||
startServices()
|
if restartServices {
|
||||||
|
restartGossipManager()
|
||||||
|
startServices()
|
||||||
|
sendAnnounce(forceSend: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure this runs on message queue to avoid main thread blocking
|
// Ensure this runs on message queue to avoid main thread blocking
|
||||||
@@ -481,6 +572,7 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
|
|
||||||
guard content.count <= maxMessageLength else {
|
guard content.count <= maxMessageLength else {
|
||||||
SecureLogger.error("Message too long: \(content.count) chars", category: .session)
|
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
|
/// `startServices()` — the latter matters after a panic reset, where
|
||||||
/// `stopServices()` cancels and nils the timer.
|
/// `stopServices()` cancels and nils the timer.
|
||||||
private func startMaintenanceTimer() {
|
private func startMaintenanceTimer() {
|
||||||
guard meshBackgroundEnabled, maintenanceTimer == nil else { return }
|
guard !isPanicSuspended,
|
||||||
|
meshBackgroundEnabled,
|
||||||
|
maintenanceTimer == nil else { return }
|
||||||
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
||||||
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
|
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
|
||||||
repeating: TransportConfig.bleMaintenanceInterval,
|
repeating: TransportConfig.bleMaintenanceInterval,
|
||||||
@@ -575,6 +669,12 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startServices() {
|
func startServices() {
|
||||||
|
guard let lifecycleGeneration =
|
||||||
|
capturePanicLifecycleGeneration() else { return }
|
||||||
|
initializeBluetoothManagersIfNeeded()
|
||||||
|
if gossipSyncManager == nil {
|
||||||
|
restartGossipManager()
|
||||||
|
}
|
||||||
// Restart the maintenance timer if a prior stopServices() cancelled it
|
// Restart the maintenance timer if a prior stopServices() cancelled it
|
||||||
// (e.g. the panic flow), otherwise periodic announces, peer reconciliation
|
// (e.g. the panic flow), otherwise periodic announces, peer reconciliation
|
||||||
// and cache cleanup would never resume until app restart.
|
// and cache cleanup would never resume until app restart.
|
||||||
@@ -591,7 +691,11 @@ final class BLEService: NSObject {
|
|||||||
// Send initial announce after services are ready
|
// Send initial announce after services are ready
|
||||||
// Use longer delay to avoid conflicts with other announces
|
// Use longer delay to avoid conflicts with other announces
|
||||||
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,9 +764,36 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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() {
|
func emergencyDisconnectAll() {
|
||||||
stopServices()
|
stopServices()
|
||||||
|
clearEmergencySessionState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearEmergencySessionState() {
|
||||||
// Clear all sessions and peers
|
// Clear all sessions and peers
|
||||||
let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) {
|
let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) {
|
||||||
let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) }
|
let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) }
|
||||||
@@ -899,6 +1030,7 @@ final class BLEService: NSObject {
|
|||||||
func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) {
|
func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) {
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
guard !self.isPanicSuspended else { return }
|
||||||
guard let payload = filePacket.encode() else {
|
guard let payload = filePacket.encode() else {
|
||||||
SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session)
|
SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session)
|
||||||
return
|
return
|
||||||
@@ -935,6 +1067,7 @@ final class BLEService: NSObject {
|
|||||||
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
guard !self.isPanicSuspended else { return }
|
||||||
guard let payload = filePacket.encode() else {
|
guard let payload = filePacket.encode() else {
|
||||||
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
||||||
return
|
return
|
||||||
@@ -1088,6 +1221,7 @@ final class BLEService: NSObject {
|
|||||||
// MARK: - Packet Broadcasting
|
// MARK: - Packet Broadcasting
|
||||||
|
|
||||||
private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) {
|
private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
// Apply route if recipient exists (centralized route application)
|
// Apply route if recipient exists (centralized route application)
|
||||||
let packetToSend: BitchatPacket
|
let packetToSend: BitchatPacket
|
||||||
if let recipientPeerID = PeerID(hexData: packet.recipientID) {
|
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) {
|
private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
guard !self.isPanicSuspended else { return }
|
||||||
let result = self.pendingNotifications.enqueue(
|
let result = self.pendingNotifications.enqueue(
|
||||||
data: data,
|
data: data,
|
||||||
targets: centrals,
|
targets: centrals,
|
||||||
@@ -1271,6 +1407,7 @@ final class BLEService: NSObject {
|
|||||||
requireDirectPeerLink: Bool = false,
|
requireDirectPeerLink: Bool = false,
|
||||||
requireNoiseAuthenticatedPeerLink: Bool = false
|
requireNoiseAuthenticatedPeerLink: Bool = false
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
guard !isPanicSuspended else { return false }
|
||||||
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
|
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
|
||||||
var excludedPeerLinks = links(to: ingressRecord?.peerID)
|
var excludedPeerLinks = links(to: ingressRecord?.peerID)
|
||||||
if requireNoiseAuthenticatedPeerLink {
|
if requireNoiseAuthenticatedPeerLink {
|
||||||
@@ -1421,6 +1558,7 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func flushDirectedSpool() {
|
private func flushDirectedSpool() {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
|
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
|
||||||
let toSend = collectionsQueue.sync(flags: .barrier) {
|
let toSend = collectionsQueue.sync(flags: .barrier) {
|
||||||
pendingDirectedRelays.drainUnexpired(
|
pendingDirectedRelays.drainUnexpired(
|
||||||
@@ -1637,6 +1775,7 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
private func sendAnnounce(forceSend: Bool = false) {
|
private func sendAnnounce(forceSend: Bool = false) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
// Throttle announces to prevent flooding
|
// Throttle announces to prevent flooding
|
||||||
if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
|
if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
|
||||||
return
|
return
|
||||||
@@ -1846,6 +1985,13 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
||||||
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
|
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
|
||||||
|
guard !isPanicSuspended else {
|
||||||
|
central.stopScan()
|
||||||
|
restoredPeripherals.forEach {
|
||||||
|
central.cancelPeripheralConnection($0)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
|
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
|
||||||
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
|
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
|
||||||
let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool
|
let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool
|
||||||
@@ -1901,6 +2047,10 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
|
|
||||||
switch central.state {
|
switch central.state {
|
||||||
case .poweredOn:
|
case .poweredOn:
|
||||||
|
guard !isPanicSuspended else {
|
||||||
|
central.stopScan()
|
||||||
|
return
|
||||||
|
}
|
||||||
// Links restored as connected have no characteristic in the new
|
// Links restored as connected have no characteristic in the new
|
||||||
// process; without rediscovery they sit connected-but-unusable
|
// process; without rediscovery they sit connected-but-unusable
|
||||||
// until the peer disconnects. Runs here (not willRestoreState)
|
// until the peer disconnects. Runs here (not willRestoreState)
|
||||||
@@ -1957,7 +2107,8 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startScanning() {
|
private func startScanning() {
|
||||||
guard let central = centralManager,
|
guard !isPanicSuspended,
|
||||||
|
let central = centralManager,
|
||||||
central.state == .poweredOn,
|
central.state == .poweredOn,
|
||||||
!central.isScanning else { return }
|
!central.isScanning else { return }
|
||||||
|
|
||||||
@@ -1978,6 +2129,7 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
let peripheralID = peripheral.identifier.uuidString
|
let peripheralID = peripheral.identifier.uuidString
|
||||||
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…")
|
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…")
|
||||||
let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true
|
let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true
|
||||||
@@ -2019,6 +2171,10 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||||
|
guard !isPanicSuspended else {
|
||||||
|
central.cancelPeripheralConnection(peripheral)
|
||||||
|
return
|
||||||
|
}
|
||||||
let peripheralID = peripheral.identifier.uuidString
|
let peripheralID = peripheral.identifier.uuidString
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -2169,7 +2325,9 @@ private extension CBPeripheralState {
|
|||||||
|
|
||||||
extension BLEService {
|
extension BLEService {
|
||||||
private func tryConnectFromQueue() {
|
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(
|
let decision = connectionScheduler.nextCandidate(
|
||||||
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
|
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
|
||||||
@@ -2195,6 +2353,7 @@ extension BLEService {
|
|||||||
using central: CBCentralManager,
|
using central: CBCentralManager,
|
||||||
logPrefix: String
|
logPrefix: String
|
||||||
) {
|
) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
let peripheral = candidate.peripheral
|
let peripheral = candidate.peripheral
|
||||||
let peripheralID = candidate.peripheralID
|
let peripheralID = candidate.peripheralID
|
||||||
linkStateStore.beginConnecting(to: peripheral, at: Date())
|
linkStateStore.beginConnecting(to: peripheral, at: Date())
|
||||||
@@ -2376,6 +2535,7 @@ extension BLEService {
|
|||||||
|
|
||||||
extension BLEService: CBPeripheralDelegate {
|
extension BLEService: CBPeripheralDelegate {
|
||||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
||||||
// Retry service discovery after a delay
|
// Retry service discovery after a delay
|
||||||
@@ -2402,6 +2562,7 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
||||||
return
|
return
|
||||||
@@ -2449,6 +2610,7 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
|
SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
|
||||||
return
|
return
|
||||||
@@ -2566,6 +2728,7 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
// Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
|
// Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
|
||||||
if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") {
|
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)
|
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]) {
|
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
|
SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
|
||||||
|
|
||||||
let shouldRediscover = BLEService.shouldRediscoverBitChatService(
|
let shouldRediscover = BLEService.shouldRediscoverBitChatService(
|
||||||
@@ -2594,6 +2758,7 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
|
SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
|
||||||
} else {
|
} else {
|
||||||
@@ -2617,6 +2782,12 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
|
|
||||||
switch peripheral.state {
|
switch peripheral.state {
|
||||||
case .poweredOn:
|
case .poweredOn:
|
||||||
|
guard !isPanicSuspended else {
|
||||||
|
peripheral.stopAdvertising()
|
||||||
|
peripheral.removeAllServices()
|
||||||
|
characteristic = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
// Remove all services first to ensure clean state
|
// Remove all services first to ensure clean state
|
||||||
peripheral.removeAllServices()
|
peripheral.removeAllServices()
|
||||||
|
|
||||||
@@ -2677,6 +2848,12 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
|
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 restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
|
||||||
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
|
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
|
||||||
|
|
||||||
@@ -2703,6 +2880,10 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
|
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
|
||||||
|
guard !isPanicSuspended else {
|
||||||
|
peripheral.stopAdvertising()
|
||||||
|
return
|
||||||
|
}
|
||||||
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)
|
||||||
return
|
return
|
||||||
@@ -2718,6 +2899,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
|
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
let centralUUID = central.identifier.uuidString
|
let centralUUID = central.identifier.uuidString
|
||||||
SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))…", category: .session)
|
SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))…", category: .session)
|
||||||
linkStateStore.addSubscribedCentral(central)
|
linkStateStore.addSubscribedCentral(central)
|
||||||
@@ -2759,7 +2941,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
|
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
|
||||||
|
|
||||||
// Ensure we're still advertising for other devices to find us
|
// 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)
|
SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
|
||||||
peripheral.startAdvertising(buildAdvertisementData())
|
peripheral.startAdvertising(buildAdvertisementData())
|
||||||
}
|
}
|
||||||
@@ -2796,6 +2978,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
|
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
drainPendingNotifications(logPrefix: "✅ Sent")
|
drainPendingNotifications(logPrefix: "✅ Sent")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2856,6 +3039,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
for request in requests {
|
for request in requests {
|
||||||
peripheral.respond(to: request, withResult: .success)
|
peripheral.respond(to: request, withResult: .success)
|
||||||
}
|
}
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
|
|
||||||
// Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets.
|
// Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets.
|
||||||
// Combine per-central request values by offset before decoding.
|
// Combine per-central request values by offset before decoding.
|
||||||
@@ -4138,6 +4322,7 @@ extension BLEService {
|
|||||||
let uuid = peripheral.identifier.uuidString
|
let uuid = peripheral.identifier.uuidString
|
||||||
bleQueue.async { [weak self] in
|
bleQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
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 }
|
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
|
// Atomically take all pending items from the queue to avoid race conditions
|
||||||
@@ -4228,7 +4413,10 @@ extension BLEService {
|
|||||||
slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
|
slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
|
||||||
) {
|
) {
|
||||||
bleQueue.async { [weak self] in
|
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
|
let budget = TransportConfig.bleMaxCentralLinks
|
||||||
- slotReserve
|
- slotReserve
|
||||||
- self.linkStateStore.connectedOrConnectingPeripheralCount
|
- self.linkStateStore.connectedOrConnectingPeripheralCount
|
||||||
@@ -5535,6 +5723,7 @@ extension BLEService {
|
|||||||
// MARK: Consolidated Maintenance
|
// MARK: Consolidated Maintenance
|
||||||
|
|
||||||
private func performMaintenance() {
|
private func performMaintenance() {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
maintenanceCounter += 1
|
maintenanceCounter += 1
|
||||||
lastMaintenanceAt = Date()
|
lastMaintenanceAt = Date()
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
|
|
||||||
private var subscriptions = Set<AnyCancellable>()
|
private var subscriptions = Set<AnyCancellable>()
|
||||||
private var heartbeatTimer: GeohashPresenceTimerProtocol?
|
private var heartbeatTimer: GeohashPresenceTimerProtocol?
|
||||||
|
private var pendingBroadcastTasks: [UUID: Task<Void, Never>] = [:]
|
||||||
|
private var heartbeatGeneration: UInt64 = 0
|
||||||
|
private var started = false
|
||||||
private let availableChannelsProvider: () -> [GeohashChannel]
|
private let availableChannelsProvider: () -> [GeohashChannel]
|
||||||
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
|
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
|
||||||
private let torReadyPublisher: AnyPublisher<Void, Never>
|
private let torReadyPublisher: AnyPublisher<Void, Never>
|
||||||
@@ -147,10 +150,25 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
|
|
||||||
/// Start the service (safe to call multiple times)
|
/// Start the service (safe to call multiple times)
|
||||||
func start() {
|
func start() {
|
||||||
|
guard !started else { return }
|
||||||
|
started = true
|
||||||
|
heartbeatGeneration &+= 1
|
||||||
SecureLogger.info("Presence: service starting...", category: .session)
|
SecureLogger.info("Presence: service starting...", category: .session)
|
||||||
scheduleNextHeartbeat()
|
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() {
|
private func setupObservers() {
|
||||||
// Monitor location channel changes
|
// Monitor location channel changes
|
||||||
locationChanges
|
locationChanges
|
||||||
@@ -169,20 +187,26 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleLocationChange() {
|
func handleLocationChange() {
|
||||||
|
guard started else { return }
|
||||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
||||||
// to announce presence in the new zone, then reset the loop.
|
// to announce presence in the new zone, then reset the loop.
|
||||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
||||||
heartbeatTimer?.invalidate()
|
heartbeatTimer?.invalidate()
|
||||||
|
|
||||||
// Small delay to allow location state to settle
|
// Small delay to allow location state to settle
|
||||||
|
let generation = heartbeatGeneration
|
||||||
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.performHeartbeat()
|
guard let self,
|
||||||
|
self.started,
|
||||||
|
self.heartbeatGeneration == generation else { return }
|
||||||
|
self.performHeartbeat()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleConnectivityChange() {
|
func handleConnectivityChange() {
|
||||||
|
guard started else { return }
|
||||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
||||||
// If we were waiting for network, do it now
|
// If we were waiting for network, do it now
|
||||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
||||||
@@ -191,18 +215,29 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func scheduleNextHeartbeat() {
|
func scheduleNextHeartbeat() {
|
||||||
|
guard started else { return }
|
||||||
heartbeatTimer?.invalidate()
|
heartbeatTimer?.invalidate()
|
||||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
||||||
|
let generation = heartbeatGeneration
|
||||||
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.performHeartbeat()
|
guard let self,
|
||||||
|
self.started,
|
||||||
|
self.heartbeatGeneration == generation else { return }
|
||||||
|
self.performHeartbeat()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func performHeartbeat() {
|
func performHeartbeat() {
|
||||||
|
guard started else { return }
|
||||||
|
let generation = heartbeatGeneration
|
||||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
// Always schedule next loop first ensures continuity even if this one fails/skips
|
||||||
defer { scheduleNextHeartbeat() }
|
defer {
|
||||||
|
if started, heartbeatGeneration == generation {
|
||||||
|
scheduleNextHeartbeat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Check preconditions
|
// 1. Check preconditions
|
||||||
guard torIsReady() else {
|
guard torIsReady() else {
|
||||||
@@ -228,14 +263,27 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Launch independent task for each channel's delay
|
// 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
|
// Random delay for decorrelation
|
||||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
await sleeper(nanoseconds)
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
|
||||||
await self.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)
|
self.broadcastPresence(for: channel.geohash)
|
||||||
}
|
}
|
||||||
|
pendingBroadcastTasks[taskID] = task
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,19 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
.store(in: &cancellables)
|
.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) {
|
func setUserTorEnabled(_ enabled: Bool) {
|
||||||
guard enabled != userTorEnabled else { return }
|
guard enabled != userTorEnabled else { return }
|
||||||
userTorEnabled = enabled
|
userTorEnabled = enabled
|
||||||
@@ -167,6 +180,7 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func reevaluate() {
|
private func reevaluate() {
|
||||||
|
guard started else { return }
|
||||||
let allowed = effectiveAllowed()
|
let allowed = effectiveAllowed()
|
||||||
let torDesired = allowed && userTorEnabled
|
let torDesired = allowed && userTorEnabled
|
||||||
let statusChanged = allowed != activationAllowed
|
let statusChanged = allowed != activationAllowed
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ protocol NetworkReachabilityMonitoring: AnyObject {
|
|||||||
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
|
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
|
||||||
/// Begin monitoring. Idempotent.
|
/// Begin monitoring. Idempotent.
|
||||||
func start()
|
func start()
|
||||||
|
/// Stop monitoring and discard pending debounce work. Idempotent.
|
||||||
|
func stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure debounce/decision logic for reachability, split out so it can be
|
/// Pure debounce/decision logic for reachability, split out so it can be
|
||||||
@@ -98,6 +100,7 @@ final class AlwaysReachableMonitor: NetworkReachabilityMonitoring {
|
|||||||
Empty(completeImmediately: false).eraseToAnyPublisher()
|
Empty(completeImmediately: false).eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
func start() {}
|
func start() {}
|
||||||
|
func stop() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
|
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
|
||||||
@@ -146,6 +149,18 @@ final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring {
|
|||||||
#endif
|
#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.
|
/// Feed an observation into the debounce and publish committed changes.
|
||||||
/// Exposed internally so higher layers/tests could drive it if needed.
|
/// Exposed internally so higher layers/tests could drive it if needed.
|
||||||
func ingest(reachable: Bool) {
|
func ingest(reachable: Bool) {
|
||||||
|
|||||||
@@ -89,6 +89,26 @@ import UIKit
|
|||||||
#endif
|
#endif
|
||||||
import UniformTypeIdentifiers
|
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.
|
/// Manages the application state and business logic for BitChat.
|
||||||
/// Acts as the primary coordinator between UI components and backend services,
|
/// Acts as the primary coordinator between UI components and backend services,
|
||||||
/// implementing the BitchatDelegate protocol to handle network events.
|
/// 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 currentColorScheme: ColorScheme = .light
|
||||||
@Published var currentTheme: AppTheme = .matrix
|
@Published var currentTheme: AppTheme = .matrix
|
||||||
@Published var isConnected = false
|
@Published var isConnected = false
|
||||||
|
@Published private(set) var panicRecoveryBlocked = false
|
||||||
|
var networkActivationAllowed: Bool { !panicRecoveryBlocked }
|
||||||
@Published var nickname: String = "" {
|
@Published var nickname: String = "" {
|
||||||
didSet {
|
didSet {
|
||||||
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
|
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
|
||||||
@@ -151,7 +173,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Update mesh service nickname if it's initialized
|
// Update mesh service nickname if it's initialized
|
||||||
if !meshService.myPeerID.isEmpty {
|
if !isPanicResetting, !meshService.myPeerID.isEmpty {
|
||||||
meshService.setNickname(nickname)
|
meshService.setNickname(nickname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,7 +317,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
var nostrRelayManager: NostrRelayManager?
|
var nostrRelayManager: NostrRelayManager?
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
let keychain: KeychainManagerProtocol
|
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.
|
/// Private group membership: keys in the keychain, metadata on disk.
|
||||||
let groupStore: GroupStore
|
let groupStore: GroupStore
|
||||||
private let nicknameKey = "bitchat.nickname"
|
private let nicknameKey = "bitchat.nickname"
|
||||||
@@ -773,7 +797,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
locationPresenceStore: LocationPresenceStore? = nil,
|
locationPresenceStore: LocationPresenceStore? = nil,
|
||||||
locationManager: LocationChannelManager = .shared
|
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
|
meshService.sfMetrics = .shared
|
||||||
self.init(
|
self.init(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
@@ -785,7 +836,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
|
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
|
||||||
locationManager: locationManager,
|
locationManager: locationManager,
|
||||||
outboxStore: MessageOutboxStore(keychain: keychain),
|
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,
|
readReceiptsDefaults: UserDefaults? = nil,
|
||||||
outboxStore: MessageOutboxStore? = nil,
|
outboxStore: MessageOutboxStore? = nil,
|
||||||
sfMetrics: StoreAndForwardMetrics? = nil,
|
sfMetrics: StoreAndForwardMetrics? = nil,
|
||||||
panicMediaWipe: (() throws -> Void)? = nil
|
panicMediaWipe: (() throws -> Void)? = nil,
|
||||||
|
panicRecoveryOperations: PanicRecoveryOperations? = nil,
|
||||||
|
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
|
||||||
) {
|
) {
|
||||||
let conversations = conversations ?? ConversationStore()
|
let conversations = conversations ?? ConversationStore()
|
||||||
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
||||||
@@ -819,13 +874,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.panicMediaWipe = panicMediaWipe ?? {
|
self.panicRecoveryOperations = panicRecoveryOperations
|
||||||
// Unit tests share the developer's real Application Support
|
?? .ephemeral(wipeMedia: panicMediaWipe ?? {})
|
||||||
// directory. Production uses the managed store; tests that need
|
self.panicNetworkLifecycle = panicNetworkLifecycle
|
||||||
// to exercise the wipe inject a temporary-directory closure.
|
|
||||||
guard !TestEnvironment.isRunningTests else { return }
|
|
||||||
try BLEIncomingFileStore().panicWipe()
|
|
||||||
}
|
|
||||||
self.groupStore = GroupStore(keychain: keychain)
|
self.groupStore = GroupStore(keychain: keychain)
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
@@ -861,7 +912,31 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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
|
// MARK: - Deinitialization
|
||||||
@@ -1165,8 +1240,28 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
// PANIC: Emergency data clearing for activist safety
|
// PANIC: Emergency data clearing for activist safety
|
||||||
@MainActor
|
@MainActor
|
||||||
func panicClearAllData() {
|
@discardableResult
|
||||||
// Messages are processed immediately - nothing to flush
|
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
|
// Invalidate detached media preparation and close live capture file
|
||||||
// handles before clearing state or removing the media directory.
|
// handles before clearing state or removing the media directory.
|
||||||
@@ -1193,7 +1288,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
// Reset nickname to anonymous
|
// Reset nickname to anonymous
|
||||||
nickname = "anon\(Int.random(in: 1000...9999))"
|
nickname = "anon\(Int.random(in: 1000...9999))"
|
||||||
saveNickname()
|
userDefaults.set(nickname, forKey: nicknameKey)
|
||||||
|
|
||||||
// Clear favorites and peer mappings
|
// Clear favorites and peer mappings
|
||||||
// Clear through SecureIdentityStateManager instead of directly
|
// Clear through SecureIdentityStateManager instead of directly
|
||||||
@@ -1265,46 +1360,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// Clear Nostr identity associations
|
// Clear Nostr identity associations
|
||||||
idBridge.clearAllAssociations()
|
idBridge.clearAllAssociations()
|
||||||
|
|
||||||
// Disconnect from all peers and clear persistent identity
|
// Replace the BLE identity while keeping the radio stopped. It may
|
||||||
// This will force creation of a new identity (new fingerprint) on next launch
|
// reopen only after the durable panic transaction commits.
|
||||||
meshService.emergencyDisconnectAll()
|
|
||||||
if let bleService = meshService as? BLEService {
|
if let bleService = meshService as? BLEService {
|
||||||
bleService.resetIdentityForPanic(currentNickname: nickname)
|
bleService.resetIdentityForPanic(
|
||||||
}
|
currentNickname: nickname,
|
||||||
|
restartServices: false
|
||||||
// No need to force UserDefaults synchronization
|
)
|
||||||
|
} else {
|
||||||
// Reinitialize Nostr with new identity
|
meshService.setNickname(nickname)
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The wipe must finish before this security action returns. A detached
|
// 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
|
// task could otherwise lose a race with a new capture or app exit and
|
||||||
// leave pre-panic media behind.
|
// leave pre-panic media behind.
|
||||||
|
let panicCompleted: Bool
|
||||||
do {
|
do {
|
||||||
try panicMediaWipe()
|
try panicRecoveryOperations.wipeMedia(recoveryIntent)
|
||||||
|
try panicRecoveryOperations.complete()
|
||||||
|
panicCompleted = true
|
||||||
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
|
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
|
||||||
} catch {
|
} 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
|
// 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.
|
// 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
|
#endif
|
||||||
|
|
||||||
// Force immediate UI update for panic mode
|
guard panicCompleted else { return false }
|
||||||
// UI updates immediately - no flushing needed
|
|
||||||
|
|
||||||
|
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
|
/// BCH-01-013: Clear iOS app switcher snapshots during panic mode
|
||||||
|
|||||||
@@ -188,8 +188,25 @@ final class VoiceRecordingViewModel: ObservableObject {
|
|||||||
|
|
||||||
Task {
|
Task {
|
||||||
let finalDuration = Date().timeIntervalSince(startDate)
|
let finalDuration = Date().timeIntervalSince(startDate)
|
||||||
if let url = await session.finish(),
|
if let url = await session.finish() {
|
||||||
isValidRecording(at: url, duration: finalDuration) {
|
// 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)
|
completion(url)
|
||||||
} else {
|
} else {
|
||||||
guard generation == holdGeneration, state == .idle else { return }
|
guard generation == holdGeneration, state == .idle else { return }
|
||||||
@@ -206,6 +223,17 @@ final class VoiceRecordingViewModel: ObservableObject {
|
|||||||
finish(completion: nil)
|
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 {
|
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
|
||||||
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||||
let fileSize = attributes[.size] as? NSNumber,
|
let fileSize = attributes[.size] as? NSNumber,
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ struct ContentView: View {
|
|||||||
voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in
|
voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in
|
||||||
conversationUIModel?.makeVoiceCaptureSession() ?? VoiceNoteCaptureSession()
|
conversationUIModel?.makeVoiceCaptureSession() ?? VoiceNoteCaptureSession()
|
||||||
}
|
}
|
||||||
|
appChromeModel.setPanicPreparation { [weak voiceRecordingVM] in
|
||||||
|
voiceRecordingVM?.panicWipe()
|
||||||
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
isNicknameFieldFocused = false
|
isNicknameFieldFocused = false
|
||||||
@@ -229,6 +232,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
autocompleteDebounceTimer?.invalidate()
|
autocompleteDebounceTimer?.invalidate()
|
||||||
|
appChromeModel.setPanicPreparation(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -572,6 +572,26 @@ struct BLEServiceCoreTests {
|
|||||||
#expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16)))
|
#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
|
@Test
|
||||||
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
||||||
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import BitFoundation
|
|||||||
/// Creates a ChatViewModel with mock dependencies for testing
|
/// Creates a ChatViewModel with mock dependencies for testing
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeTestableViewModel(
|
private func makeTestableViewModel(
|
||||||
panicMediaWipe: (() throws -> Void)? = nil
|
panicMediaWipe: (() throws -> Void)? = nil,
|
||||||
|
panicRecoveryOperations: PanicRecoveryOperations? = nil,
|
||||||
|
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
|
||||||
) -> (viewModel: ChatViewModel, transport: MockTransport) {
|
) -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
let keychainHelper = MockKeychainHelper()
|
let keychainHelper = MockKeychainHelper()
|
||||||
@@ -29,7 +31,9 @@ private func makeTestableViewModel(
|
|||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
identityManager: identityManager,
|
identityManager: identityManager,
|
||||||
transport: transport,
|
transport: transport,
|
||||||
panicMediaWipe: panicMediaWipe
|
panicMediaWipe: panicMediaWipe,
|
||||||
|
panicRecoveryOperations: panicRecoveryOperations,
|
||||||
|
panicNetworkLifecycle: panicNetworkLifecycle
|
||||||
)
|
)
|
||||||
|
|
||||||
return (viewModel, transport)
|
return (viewModel, transport)
|
||||||
@@ -1122,15 +1126,89 @@ struct ChatViewModelPanicTests {
|
|||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func panicClearAllData_finishesMediaWipeBeforeReturning() {
|
func panicClearAllData_finishesMediaWipeBeforeReturning() {
|
||||||
var wipeFinished = false
|
var wipeFinished = false
|
||||||
let (viewModel, _) = makeTestableViewModel {
|
let (viewModel, _) = makeTestableViewModel(panicMediaWipe: {
|
||||||
wipeFinished = true
|
wipeFinished = true
|
||||||
}
|
})
|
||||||
|
|
||||||
viewModel.panicClearAllData()
|
viewModel.panicClearAllData()
|
||||||
|
|
||||||
#expect(wipeFinished)
|
#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
|
@Test @MainActor
|
||||||
func panicClearAllData_delegatesToTransport() async {
|
func panicClearAllData_delegatesToTransport() async {
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|||||||
@@ -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) {
|
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||||
#expect(recorder.signedNameQueries.isEmpty)
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
#expect(recorder.trackedPackets.isEmpty)
|
#expect(recorder.trackedPackets.isEmpty)
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
burstMaxDelay: 0
|
burstMaxDelay: 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
service.start()
|
||||||
service.performHeartbeat()
|
service.performHeartbeat()
|
||||||
|
|
||||||
let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 }
|
let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 }
|
||||||
@@ -83,7 +84,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
||||||
XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
||||||
XCTAssertEqual(sleptNanoseconds.count, 3)
|
XCTAssertEqual(sleptNanoseconds.count, 3)
|
||||||
XCTAssertEqual(scheduler.intervals, [17])
|
XCTAssertEqual(scheduler.intervals, [17, 17])
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async {
|
func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async {
|
||||||
@@ -97,11 +98,12 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
loopMaxInterval: 21
|
loopMaxInterval: 21
|
||||||
)
|
)
|
||||||
|
|
||||||
|
service.start()
|
||||||
service.performHeartbeat()
|
service.performHeartbeat()
|
||||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
XCTAssertEqual(sendCount, 0)
|
XCTAssertEqual(sendCount, 0)
|
||||||
XCTAssertEqual(scheduler.intervals, [21])
|
XCTAssertEqual(scheduler.intervals, [21, 21])
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async {
|
func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async {
|
||||||
@@ -115,11 +117,45 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
loopMaxInterval: 22
|
loopMaxInterval: 22
|
||||||
)
|
)
|
||||||
|
|
||||||
|
service.start()
|
||||||
service.performHeartbeat()
|
service.performHeartbeat()
|
||||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
XCTAssertEqual(sendCount, 0)
|
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<Void, Never>?
|
||||||
|
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 {
|
func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws {
|
||||||
|
|||||||
@@ -91,6 +91,53 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1)
|
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(
|
private func makeService(
|
||||||
permission: LocationChannelManager.PermissionState,
|
permission: LocationChannelManager.PermissionState,
|
||||||
favorites: Set<Data>
|
favorites: Set<Data>
|
||||||
@@ -104,6 +151,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
let torController = MockNetworkActivationTorController()
|
let torController = MockNetworkActivationTorController()
|
||||||
let relayController = MockNetworkActivationRelayController()
|
let relayController = MockNetworkActivationRelayController()
|
||||||
let proxyController = MockNetworkActivationProxyController()
|
let proxyController = MockNetworkActivationProxyController()
|
||||||
|
let reachability = MockNetworkActivationReachability()
|
||||||
let notificationCenter = NotificationCenter()
|
let notificationCenter = NotificationCenter()
|
||||||
let service = NetworkActivationService(
|
let service = NetworkActivationService(
|
||||||
storage: storage,
|
storage: storage,
|
||||||
@@ -111,7 +159,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
||||||
permissionProvider: { permissionSubject.value },
|
permissionProvider: { permissionSubject.value },
|
||||||
mutualFavoritesProvider: { favoritesSubject.value },
|
mutualFavoritesProvider: { favoritesSubject.value },
|
||||||
reachabilityMonitor: AlwaysReachableMonitor(),
|
reachabilityMonitor: reachability,
|
||||||
torController: torController,
|
torController: torController,
|
||||||
relayController: relayController,
|
relayController: relayController,
|
||||||
proxyController: proxyController,
|
proxyController: proxyController,
|
||||||
@@ -121,6 +169,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
service: service,
|
service: service,
|
||||||
storage: storage,
|
storage: storage,
|
||||||
favoritesSubject: favoritesSubject,
|
favoritesSubject: favoritesSubject,
|
||||||
|
reachability: reachability,
|
||||||
torController: torController,
|
torController: torController,
|
||||||
relayController: relayController,
|
relayController: relayController,
|
||||||
proxyController: proxyController,
|
proxyController: proxyController,
|
||||||
@@ -148,12 +197,38 @@ private struct NetworkActivationTestContext {
|
|||||||
let service: NetworkActivationService
|
let service: NetworkActivationService
|
||||||
let storage: UserDefaults
|
let storage: UserDefaults
|
||||||
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
|
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
|
||||||
|
let reachability: MockNetworkActivationReachability
|
||||||
let torController: MockNetworkActivationTorController
|
let torController: MockNetworkActivationTorController
|
||||||
let relayController: MockNetworkActivationRelayController
|
let relayController: MockNetworkActivationRelayController
|
||||||
let proxyController: MockNetworkActivationProxyController
|
let proxyController: MockNetworkActivationProxyController
|
||||||
let notificationCenter: NotificationCenter
|
let notificationCenter: NotificationCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class MockNetworkActivationReachability:
|
||||||
|
NetworkReachabilityMonitoring {
|
||||||
|
private let subject = CurrentValueSubject<Bool, Never>(true)
|
||||||
|
private(set) var startCallCount = 0
|
||||||
|
private(set) var stopCallCount = 0
|
||||||
|
|
||||||
|
var isReachable: Bool { subject.value }
|
||||||
|
var reachabilityPublisher: AnyPublisher<Bool, Never> {
|
||||||
|
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
startCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
stopCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(_ reachable: Bool) {
|
||||||
|
subject.send(reachable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class MockNetworkActivationTorController: NetworkActivationTorControlling {
|
private final class MockNetworkActivationTorController: NetworkActivationTorControlling {
|
||||||
private(set) var autoStartAllowedValues: [Bool] = []
|
private(set) var autoStartAllowedValues: [Bool] = []
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ private final class ControllableReachabilityMonitor: NetworkReachabilityMonitori
|
|||||||
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
func start() { startCalled = true }
|
func start() { startCalled = true }
|
||||||
|
func stop() { startCalled = false }
|
||||||
func set(_ reachable: Bool) { subject.send(reachable) }
|
func set(_ reachable: Bool) { subject.send(reachable) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
|
|||||||
private let startError: Error?
|
private let startError: Error?
|
||||||
private(set) var finishStarted = false
|
private(set) var finishStarted = false
|
||||||
private(set) var cancelCount = 0
|
private(set) var cancelCount = 0
|
||||||
|
private(set) var panicCancelCount = 0
|
||||||
private var finishContinuation: CheckedContinuation<URL?, Never>?
|
private var finishContinuation: CheckedContinuation<URL?, Never>?
|
||||||
|
|
||||||
init(startError: Error? = nil) {
|
init(startError: Error? = nil) {
|
||||||
@@ -83,6 +84,10 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
|
|||||||
cancelCount += 1
|
cancelCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func panicCancelSynchronously() {
|
||||||
|
panicCancelCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
func resolveFinish(with url: URL?) {
|
func resolveFinish(with url: URL?) {
|
||||||
let continuation = finishContinuation
|
let continuation = finishContinuation
|
||||||
finishContinuation = nil
|
finishContinuation = nil
|
||||||
@@ -204,4 +209,57 @@ struct VoiceCaptureSessionTests {
|
|||||||
}
|
}
|
||||||
#expect(viewModel.state == .idle)
|
#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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,6 +360,35 @@ struct VoiceRecorderTests {
|
|||||||
#expect(FileManager.default.fileExists(atPath: secondURL.path))
|
#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(
|
private func verifyFailedStart(
|
||||||
firstPlan: TestVoiceAudioRecorderFactory.Plan,
|
firstPlan: TestVoiceAudioRecorderFactory.Plan,
|
||||||
expectedPrepareCalls: Int,
|
expectedPrepareCalls: Int,
|
||||||
|
|||||||
Reference in New Issue
Block a user