mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
Gate Tor/relay startup on network reachability (#1389)
On a mesh-only/offline device the app used to bootstrap Tor and spin
Nostr relay reconnects forever ("connecting to Tor…"), wasting battery
even when there was provably no network path at all.
Add an NWPathMonitor-backed reachability signal (NetworkReachabilityMonitor)
and fold it into NetworkActivationService's activation gate:
- Tor bootstrap and relay connect/reconnect are now gated on the network
path being usable. When the path is fully unsatisfied (no interface at
all) we set autoStart off, shut Tor down, and disconnect relays instead
of looping. When a usable path returns we resume.
- Conservative policy: only NWPath.Status.unsatisfied counts as offline.
A flaky-but-present link stays "reachable" (Tor tolerates intermittent
connectivity); we never tear down on the first hiccup.
- Transitions are debounced (ReachabilityDebounce, ~2.5s) so path flapping
cannot thrash Tor/relay startup. The debounce is a pure value type,
unit-tested without the Network framework or real timers.
- Starts optimistic (reachable) so nothing is suppressed before the first
path evaluation arrives.
- BLE mesh never consults this gate and works fully offline.
- NWPathMonitor's background callback hops to the main actor before
touching any state.
Surfaces NetworkActivationService.isNetworkReachable for UI to distinguish
"offline" from "connecting to Tor".
Tests: pure debounce (satisfied → allowed, unsatisfied → suppressed after
interval, flap debounced, recover-after-outage) plus service wiring
(unreachable suppresses Tor+relays, recovery resumes, loss disconnects).
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
201dbac49a
commit
276cde44e7
@@ -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<AnyCancellable>()
|
||||
private var started = false
|
||||
@@ -43,6 +49,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
|
||||
private let permissionProvider: () -> LocationChannelManager.PermissionState
|
||||
private let mutualFavoritesProvider: () -> Set<Data>
|
||||
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<Set<Data>, Never>,
|
||||
permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
|
||||
mutualFavoritesProvider: @escaping () -> Set<Data>,
|
||||
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 {
|
||||
|
||||
@@ -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<Bool, Never> { 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<Bool, Never> {
|
||||
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<Bool, Never>
|
||||
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<Bool, Never> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<LocationChannelManager.PermissionState, Never>(permission)
|
||||
let favoritesSubject = CurrentValueSubject<Set<Data>, 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<Bool, Never>
|
||||
private(set) var startCalled = false
|
||||
|
||||
init(initial: Bool) {
|
||||
subject = CurrentValueSubject(initial)
|
||||
}
|
||||
|
||||
var isReachable: Bool { subject.value }
|
||||
var reachabilityPublisher: AnyPublisher<Bool, Never> {
|
||||
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) }
|
||||
}
|
||||
Reference in New Issue
Block a user