diff --git a/bitchat/Services/NetworkActivationService.swift b/bitchat/Services/NetworkActivationService.swift index 1f68af8d..b8f5a7ba 100644 --- a/bitchat/Services/NetworkActivationService.swift +++ b/bitchat/Services/NetworkActivationService.swift @@ -25,14 +25,20 @@ extension NostrRelayManager: NetworkActivationRelayControlling {} extension TorURLSession: NetworkActivationProxyControlling {} /// Coordinates when the app is allowed to start Tor and connect to Nostr relays. -/// Policy: permit start when either location permissions are authorized OR -/// there exists at least one mutual favorite. Otherwise, do not start. +/// Policy: permit start when (location permissions are authorized OR there +/// exists at least one mutual favorite) AND the device has a usable network +/// path. When there is provably no network at all we do not bootstrap Tor or +/// spin relay reconnects — that only wastes battery on a mesh-only/offline +/// device. BLE mesh is entirely independent of this gate. @MainActor final class NetworkActivationService: ObservableObject { static let shared = NetworkActivationService() @Published private(set) var activationAllowed: Bool = false @Published private(set) var userTorEnabled: Bool = true + /// Coarse, debounced network reachability. `false` only when the OS reports + /// no usable interface at all. Surfaced for UI ("offline" vs "connecting"). + @Published private(set) var isNetworkReachable: Bool = true private var cancellables = Set() private var started = false @@ -43,6 +49,7 @@ final class NetworkActivationService: ObservableObject { private let mutualFavoritesPublisher: AnyPublisher, Never> private let permissionProvider: () -> LocationChannelManager.PermissionState private let mutualFavoritesProvider: () -> Set + private let reachabilityMonitor: NetworkReachabilityMonitoring private let torController: NetworkActivationTorControlling // Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared // (via its live dependencies), so capturing NostrRelayManager.shared here would @@ -58,6 +65,7 @@ final class NetworkActivationService: ObservableObject { mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher() permissionProvider = { LocationChannelManager.shared.permissionState } mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites } + reachabilityMonitor = NWPathReachabilityMonitor() torController = TorManager.shared relayControllerProvider = { NostrRelayManager.shared } proxyController = TorURLSession.shared @@ -70,6 +78,7 @@ final class NetworkActivationService: ObservableObject { mutualFavoritesPublisher: AnyPublisher, Never>, permissionProvider: @escaping () -> LocationChannelManager.PermissionState, mutualFavoritesProvider: @escaping () -> Set, + reachabilityMonitor: NetworkReachabilityMonitoring, torController: NetworkActivationTorControlling, relayController: NetworkActivationRelayControlling, proxyController: NetworkActivationProxyControlling, @@ -80,6 +89,7 @@ final class NetworkActivationService: ObservableObject { self.mutualFavoritesPublisher = mutualFavoritesPublisher self.permissionProvider = permissionProvider self.mutualFavoritesProvider = mutualFavoritesProvider + self.reachabilityMonitor = reachabilityMonitor self.torController = torController self.relayControllerProvider = { relayController } self.proxyController = proxyController @@ -96,8 +106,12 @@ final class NetworkActivationService: ObservableObject { userTorEnabled = true } + // Begin (idempotent) reachability monitoring and seed initial state. + reachabilityMonitor.start() + isNetworkReachable = reachabilityMonitor.isReachable + // Initial compute - let allowed = basePolicyAllowed() + let allowed = effectiveAllowed() activationAllowed = allowed torAutoStartDesired = allowed && userTorEnabled torController.setAutoStartAllowed(torAutoStartDesired) @@ -123,6 +137,21 @@ final class NetworkActivationService: ObservableObject { self?.reevaluate() } .store(in: &cancellables) + + // React to network reachability changes (debounced, unsatisfied-only). + reachabilityMonitor.reachabilityPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] reachable in + guard let self else { return } + guard reachable != self.isNetworkReachable else { return } + self.isNetworkReachable = reachable + SecureLogger.info( + "NetworkActivationService: isNetworkReachable -> \(reachable)", + category: .session + ) + self.reevaluate() + } + .store(in: &cancellables) } func setUserTorEnabled(_ enabled: Bool) { @@ -138,7 +167,7 @@ final class NetworkActivationService: ObservableObject { } private func reevaluate() { - let allowed = basePolicyAllowed() + let allowed = effectiveAllowed() let torDesired = allowed && userTorEnabled let statusChanged = allowed != activationAllowed let torChanged = torDesired != torAutoStartDesired @@ -163,12 +192,20 @@ final class NetworkActivationService: ObservableObject { } } + /// Base policy: who is allowed to use the network at all (permission or a + /// mutual favorite), ignoring current link state. private func basePolicyAllowed() -> Bool { let permOK = permissionProvider() == .authorized let hasMutual = !mutualFavoritesProvider().isEmpty return permOK || hasMutual } + /// Effective gate: base policy AND a usable network path. When there is + /// provably no network, Tor bootstrap and relay reconnects are suppressed. + private func effectiveAllowed() -> Bool { + basePolicyAllowed() && reachabilityMonitor.isReachable + } + private func applyTorState(torDesired: Bool) { proxyController.setProxyMode(useTor: torDesired) if torDesired { diff --git a/bitchat/Services/NetworkReachabilityMonitor.swift b/bitchat/Services/NetworkReachabilityMonitor.swift new file mode 100644 index 00000000..bc2b77cf --- /dev/null +++ b/bitchat/Services/NetworkReachabilityMonitor.swift @@ -0,0 +1,167 @@ +import Foundation +import Combine +import BitLogger +#if canImport(Network) +import Network +#endif + +/// Coarse, conservative network-reachability signal used to gate Tor bootstrap +/// and Nostr relay connections. +/// +/// Policy (deliberately conservative): +/// - Reports `false` only when the OS says there is *no* usable interface at +/// all (`NWPath.Status.unsatisfied`). A flaky-but-present link stays +/// `true` because Tor tolerates intermittent connectivity, and tearing down +/// on the first hiccup would cost more battery/latency than it saves. +/// - Transitions are debounced (see `ReachabilityDebounce`) so path flapping +/// does not thrash Tor/relay startup. +/// - Starts optimistic (`true`) so nothing is ever suppressed before the first +/// path evaluation arrives. +/// +/// BLE mesh must never consult this monitor — the mesh works fully offline. +@MainActor +protocol NetworkReachabilityMonitoring: AnyObject { + /// Current debounced coarse reachability. + var isReachable: Bool { get } + /// Emits the debounced reachability whenever it changes (main-actor). + var reachabilityPublisher: AnyPublisher { get } + /// Begin monitoring. Idempotent. + func start() +} + +/// Pure debounce/decision logic for reachability, split out so it can be +/// unit-tested without the Network framework or real timers. +/// +/// A candidate state only becomes the committed state once it has been stable +/// (uninterrupted) for `interval`. Any observation matching the committed state +/// cancels a pending opposite change, which is what makes flapping a no-op. +struct ReachabilityDebounce { + let interval: TimeInterval + private(set) var committed: Bool + private var pending: (value: Bool, since: Date)? + + init(interval: TimeInterval, initial: Bool) { + self.interval = interval + self.committed = initial + } + + /// Whether a change is currently waiting out the debounce window. + var hasPendingChange: Bool { pending != nil } + + /// Feed a raw observation. Returns the new committed value if it changed, + /// otherwise `nil`. + mutating func observe(reachable: Bool, at now: Date) -> Bool? { + if reachable == committed { + // Already in this state — cancel any pending opposite change. + pending = nil + return nil + } + // Differs from committed: (re)arm the pending change, preserving the + // timestamp if we're already waiting on this same target value. + if pending?.value != reachable { + pending = (reachable, now) + } + return commitIfAged(at: now) + } + + /// Called from a timer to commit a pending change once it has aged past + /// `interval`. Returns the new committed value if it changed, else `nil`. + mutating func flush(at now: Date) -> Bool? { + commitIfAged(at: now) + } + + private mutating func commitIfAged(at now: Date) -> Bool? { + guard let pending else { return nil } + guard now.timeIntervalSince(pending.since) >= interval else { return nil } + committed = pending.value + self.pending = nil + return committed + } +} + +/// Always-reachable stub. Used as the default in tests and as the fallback on +/// platforms without the Network framework, so reachability never suppresses +/// startup by itself. +@MainActor +final class AlwaysReachableMonitor: NetworkReachabilityMonitoring { + var isReachable: Bool { true } + var reachabilityPublisher: AnyPublisher { + Empty(completeImmediately: false).eraseToAnyPublisher() + } + func start() {} +} + +/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the +/// background path callback hops here before touching the debounce. +@MainActor +final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring { + private let subject: CurrentValueSubject + private var debounce: ReachabilityDebounce + private var flushWorkItem: DispatchWorkItem? + private var started = false + private let now: () -> Date + + #if canImport(Network) + private var monitor: NWPathMonitor? + private let monitorQueue = DispatchQueue(label: "chat.bitchat.reachability") + #endif + + init(debounceInterval: TimeInterval = 2.5, now: @escaping () -> Date = Date.init) { + self.now = now + self.debounce = ReachabilityDebounce(interval: debounceInterval, initial: true) + self.subject = CurrentValueSubject(true) + } + + var isReachable: Bool { subject.value } + + var reachabilityPublisher: AnyPublisher { + subject.removeDuplicates().dropFirst().eraseToAnyPublisher() + } + + func start() { + guard !started else { return } + started = true + #if canImport(Network) + let monitor = NWPathMonitor() + self.monitor = monitor + monitor.pathUpdateHandler = { [weak self] path in + // Conservative: only "no interface at all" counts as unreachable. + let reachable = path.status != .unsatisfied + Task { @MainActor in + self?.ingest(reachable: reachable) + } + } + monitor.start(queue: monitorQueue) + #else + // No Network framework: never suppress startup. + #endif + } + + /// Feed an observation into the debounce and publish committed changes. + /// Exposed internally so higher layers/tests could drive it if needed. + func ingest(reachable: Bool) { + flushWorkItem?.cancel() + flushWorkItem = nil + if let committed = debounce.observe(reachable: reachable, at: now()) { + publish(committed) + } else if debounce.hasPendingChange { + scheduleFlush() + } + } + + private func scheduleFlush() { + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + if let committed = self.debounce.flush(at: self.now()) { + self.publish(committed) + } + } + flushWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + debounce.interval, execute: work) + } + + private func publish(_ reachable: Bool) { + SecureLogger.info("NWPathReachabilityMonitor: network reachable -> \(reachable)", category: .session) + subject.send(reachable) + } +} diff --git a/bitchatTests/Services/NetworkActivationServiceTests.swift b/bitchatTests/Services/NetworkActivationServiceTests.swift index 5523ccdc..01d8fdca 100644 --- a/bitchatTests/Services/NetworkActivationServiceTests.swift +++ b/bitchatTests/Services/NetworkActivationServiceTests.swift @@ -111,6 +111,7 @@ final class NetworkActivationServiceTests: XCTestCase { mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), permissionProvider: { permissionSubject.value }, mutualFavoritesProvider: { favoritesSubject.value }, + reachabilityMonitor: AlwaysReachableMonitor(), torController: torController, relayController: relayController, proxyController: proxyController, diff --git a/bitchatTests/Services/NetworkReachabilityGateTests.swift b/bitchatTests/Services/NetworkReachabilityGateTests.swift new file mode 100644 index 00000000..420a54db --- /dev/null +++ b/bitchatTests/Services/NetworkReachabilityGateTests.swift @@ -0,0 +1,207 @@ +import Combine +import XCTest +@testable import bitchat + +/// Covers the reachability-gate decision logic (pure debounce) and the +/// `NetworkActivationService` wiring that suppresses Tor/relay startup when +/// there is provably no network. +@MainActor +final class NetworkReachabilityGateTests: XCTestCase { + + // MARK: - Pure debounce logic + + func test_debounce_satisfiedStaysReachable() { + var d = ReachabilityDebounce(interval: 2.5, initial: true) + let t0 = Date() + // An interface remains present: no change, no pending. + XCTAssertNil(d.observe(reachable: true, at: t0)) + XCTAssertTrue(d.committed) + XCTAssertFalse(d.hasPendingChange) + } + + func test_debounce_unsatisfiedSuppressesAfterInterval() { + var d = ReachabilityDebounce(interval: 2.5, initial: true) + let t0 = Date() + // Path drops: not committed immediately (within debounce window). + XCTAssertNil(d.observe(reachable: false, at: t0)) + XCTAssertTrue(d.committed) + XCTAssertTrue(d.hasPendingChange) + // Still within window. + XCTAssertNil(d.flush(at: t0.addingTimeInterval(1.0))) + XCTAssertTrue(d.committed) + // Past the window: commit unreachable. + XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), false) + XCTAssertFalse(d.committed) + XCTAssertFalse(d.hasPendingChange) + } + + func test_debounce_flapIsIgnored() { + var d = ReachabilityDebounce(interval: 2.5, initial: true) + let t0 = Date() + // Drop then recover well within the window — must never commit a change. + XCTAssertNil(d.observe(reachable: false, at: t0)) + XCTAssertTrue(d.hasPendingChange) + XCTAssertNil(d.observe(reachable: true, at: t0.addingTimeInterval(0.5))) + XCTAssertFalse(d.hasPendingChange, "recovery should cancel the pending drop") + // A late flush after the original deadline is a no-op (nothing pending). + XCTAssertNil(d.flush(at: t0.addingTimeInterval(3.0))) + XCTAssertTrue(d.committed) + } + + func test_debounce_recoverAfterOutageCommitsAfterInterval() { + var d = ReachabilityDebounce(interval: 2.5, initial: false) + let t0 = Date() + XCTAssertNil(d.observe(reachable: true, at: t0)) + XCTAssertTrue(d.hasPendingChange) + XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), true) + XCTAssertTrue(d.committed) + } + + // MARK: - Service gating + + func test_start_whenUnreachable_suppressesTorAndRelays() { + let ctx = makeService(permission: .authorized, reachable: false) + ctx.service.start() + + XCTAssertFalse(ctx.service.activationAllowed) + XCTAssertFalse(ctx.service.isNetworkReachable) + XCTAssertTrue(ctx.reachability.startCalled) + XCTAssertEqual(ctx.torController.startIfNeededCallCount, 0) + XCTAssertEqual(ctx.torController.autoStartAllowedValues, [false]) + XCTAssertEqual(ctx.relayController.connectCallCount, 0) + XCTAssertEqual(ctx.relayController.disconnectCallCount, 1) + } + + func test_start_whenReachable_allowsTorAndRelays() { + let ctx = makeService(permission: .authorized, reachable: true) + ctx.service.start() + + XCTAssertTrue(ctx.service.activationAllowed) + XCTAssertEqual(ctx.torController.startIfNeededCallCount, 1) + XCTAssertEqual(ctx.relayController.connectCallCount, 1) + } + + func test_reachabilityRecovery_resumesTorAndRelays() async { + let ctx = makeService(permission: .authorized, reachable: false) + ctx.service.start() + XCTAssertFalse(ctx.service.activationAllowed) + + ctx.reachability.set(true) + let resumed = await waitUntil { ctx.service.activationAllowed } + XCTAssertTrue(resumed) + XCTAssertTrue(ctx.service.isNetworkReachable) + XCTAssertGreaterThanOrEqual(ctx.torController.startIfNeededCallCount, 1) + XCTAssertGreaterThanOrEqual(ctx.relayController.connectCallCount, 1) + } + + func test_reachabilityLoss_disconnectsRelaysAndStopsTor() async { + let ctx = makeService(permission: .authorized, reachable: true) + ctx.service.start() + XCTAssertTrue(ctx.service.activationAllowed) + let disconnectsBefore = ctx.relayController.disconnectCallCount + + ctx.reachability.set(false) + let suppressed = await waitUntil { !ctx.service.activationAllowed } + XCTAssertTrue(suppressed) + XCTAssertFalse(ctx.service.isNetworkReachable) + XCTAssertGreaterThan(ctx.relayController.disconnectCallCount, disconnectsBefore) + XCTAssertTrue(ctx.torController.autoStartAllowedValues.contains(false)) + XCTAssertGreaterThanOrEqual(ctx.torController.shutdownCompletelyCallCount, 1) + } + + // MARK: - Harness + + private func makeService( + permission: LocationChannelManager.PermissionState, + reachable: Bool + ) -> Context { + let suiteName = "NetworkReachabilityGateTests-\(UUID().uuidString)" + let storage = UserDefaults(suiteName: suiteName)! + storage.removePersistentDomain(forName: suiteName) + + let permissionSubject = CurrentValueSubject(permission) + let favoritesSubject = CurrentValueSubject, Never>([]) + let reachability = ControllableReachabilityMonitor(initial: reachable) + let torController = GateMockTorController() + let relayController = GateMockRelayController() + let proxyController = GateMockProxyController() + let service = NetworkActivationService( + storage: storage, + locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(), + mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), + permissionProvider: { permissionSubject.value }, + mutualFavoritesProvider: { favoritesSubject.value }, + reachabilityMonitor: reachability, + torController: torController, + relayController: relayController, + proxyController: proxyController, + notificationCenter: NotificationCenter() + ) + return Context( + service: service, + reachability: reachability, + torController: torController, + relayController: relayController + ) + } + + private func waitUntil( + timeout: TimeInterval = 1.0, + condition: @escaping @MainActor () -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return true } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return condition() + } +} + +@MainActor +private struct Context { + let service: NetworkActivationService + let reachability: ControllableReachabilityMonitor + let torController: GateMockTorController + let relayController: GateMockRelayController +} + +@MainActor +private final class ControllableReachabilityMonitor: NetworkReachabilityMonitoring { + private let subject: CurrentValueSubject + private(set) var startCalled = false + + init(initial: Bool) { + subject = CurrentValueSubject(initial) + } + + var isReachable: Bool { subject.value } + var reachabilityPublisher: AnyPublisher { + subject.removeDuplicates().dropFirst().eraseToAnyPublisher() + } + func start() { startCalled = true } + func set(_ reachable: Bool) { subject.send(reachable) } +} + +@MainActor +private final class GateMockTorController: NetworkActivationTorControlling { + private(set) var autoStartAllowedValues: [Bool] = [] + private(set) var startIfNeededCallCount = 0 + private(set) var shutdownCompletelyCallCount = 0 + func setAutoStartAllowed(_ allowed: Bool) { autoStartAllowedValues.append(allowed) } + func startIfNeeded() { startIfNeededCallCount += 1 } + func shutdownCompletely() { shutdownCompletelyCallCount += 1 } +} + +@MainActor +private final class GateMockRelayController: NetworkActivationRelayControlling { + private(set) var connectCallCount = 0 + private(set) var disconnectCallCount = 0 + func connect() { connectCallCount += 1 } + func disconnect() { disconnectCallCount += 1 } +} + +private final class GateMockProxyController: NetworkActivationProxyControlling { + private(set) var proxyModes: [Bool] = [] + func setProxyMode(useTor: Bool) { proxyModes.append(useTor) } +}