mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 15:45:20 +00:00
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>
199 lines
7.8 KiB
Swift
199 lines
7.8 KiB
Swift
import Combine
|
|
import XCTest
|
|
@testable import bitchat
|
|
|
|
@MainActor
|
|
final class NetworkActivationServiceTests: XCTestCase {
|
|
private let torPreferenceKey = "networkActivationService.userTorEnabled"
|
|
|
|
func test_start_leavesNetworkDisabledWithoutPermissionOrFavorites() {
|
|
let context = makeService(permission: .denied, favorites: [])
|
|
|
|
context.service.start()
|
|
|
|
XCTAssertFalse(context.service.activationAllowed)
|
|
XCTAssertEqual(context.torController.autoStartAllowedValues, [false])
|
|
XCTAssertEqual(context.proxyController.proxyModes, [false])
|
|
XCTAssertEqual(context.torController.startIfNeededCallCount, 0)
|
|
XCTAssertEqual(context.torController.shutdownCompletelyCallCount, 1)
|
|
XCTAssertEqual(context.relayController.connectCallCount, 0)
|
|
XCTAssertEqual(context.relayController.disconnectCallCount, 1)
|
|
}
|
|
|
|
func test_start_enablesTorAndRelaysWhenAuthorized() {
|
|
let context = makeService(permission: .authorized, favorites: [])
|
|
|
|
context.service.start()
|
|
|
|
XCTAssertTrue(context.service.activationAllowed)
|
|
XCTAssertEqual(context.torController.autoStartAllowedValues, [true])
|
|
XCTAssertEqual(context.proxyController.proxyModes, [true])
|
|
XCTAssertEqual(context.torController.startIfNeededCallCount, 1)
|
|
XCTAssertEqual(context.relayController.connectCallCount, 1)
|
|
XCTAssertEqual(context.relayController.disconnectCallCount, 0)
|
|
}
|
|
|
|
func test_start_respectsStoredTorPreferenceForDirectMode() {
|
|
let context = makeService(permission: .authorized, favorites: [])
|
|
context.storage.set(false, forKey: torPreferenceKey)
|
|
|
|
context.service.start()
|
|
|
|
XCTAssertTrue(context.service.activationAllowed)
|
|
XCTAssertFalse(context.service.userTorEnabled)
|
|
XCTAssertEqual(context.torController.autoStartAllowedValues, [false])
|
|
XCTAssertEqual(context.proxyController.proxyModes, [false])
|
|
XCTAssertEqual(context.torController.startIfNeededCallCount, 0)
|
|
XCTAssertEqual(context.torController.shutdownCompletelyCallCount, 1)
|
|
XCTAssertEqual(context.relayController.connectCallCount, 1)
|
|
}
|
|
|
|
func test_setUserTorEnabled_postsNotificationAndReconnectsOnTransportSwitch() {
|
|
let context = makeService(permission: .authorized, favorites: [])
|
|
let notified = expectation(description: "Tor preference notification")
|
|
let token = context.notificationCenter.addObserver(
|
|
forName: .TorUserPreferenceChanged,
|
|
object: nil,
|
|
queue: nil
|
|
) { note in
|
|
XCTAssertEqual(note.userInfo?["enabled"] as? Bool, false)
|
|
notified.fulfill()
|
|
}
|
|
|
|
context.service.start()
|
|
context.service.setUserTorEnabled(false)
|
|
|
|
wait(for: [notified], timeout: 1.0)
|
|
context.notificationCenter.removeObserver(token)
|
|
|
|
XCTAssertFalse(context.service.userTorEnabled)
|
|
XCTAssertEqual(context.storage.object(forKey: torPreferenceKey) as? Bool, false)
|
|
XCTAssertEqual(Array(context.proxyController.proxyModes.suffix(2)), [true, false])
|
|
XCTAssertEqual(Array(context.torController.autoStartAllowedValues.suffix(2)), [true, false])
|
|
XCTAssertEqual(context.relayController.disconnectCallCount, 1)
|
|
XCTAssertEqual(context.relayController.connectCallCount, 2)
|
|
}
|
|
|
|
func test_mutualFavoritesPublisher_reactivatesNetwork() async {
|
|
let context = makeService(permission: .denied, favorites: [])
|
|
|
|
context.service.start()
|
|
XCTAssertFalse(context.service.activationAllowed)
|
|
|
|
context.favoritesSubject.send([Data([0x01])])
|
|
let becameActive = await waitUntil { context.service.activationAllowed }
|
|
XCTAssertTrue(becameActive)
|
|
|
|
XCTAssertTrue(context.service.activationAllowed)
|
|
XCTAssertTrue(context.torController.autoStartAllowedValues.contains(true))
|
|
XCTAssertTrue(context.proxyController.proxyModes.contains(true))
|
|
XCTAssertGreaterThanOrEqual(context.torController.startIfNeededCallCount, 1)
|
|
XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1)
|
|
}
|
|
|
|
private func makeService(
|
|
permission: LocationChannelManager.PermissionState,
|
|
favorites: Set<Data>
|
|
) -> NetworkActivationTestContext {
|
|
let suiteName = "NetworkActivationServiceTests-\(UUID().uuidString)"
|
|
let storage = UserDefaults(suiteName: suiteName)!
|
|
storage.removePersistentDomain(forName: suiteName)
|
|
|
|
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
|
|
let favoritesSubject = CurrentValueSubject<Set<Data>, Never>(favorites)
|
|
let torController = MockNetworkActivationTorController()
|
|
let relayController = MockNetworkActivationRelayController()
|
|
let proxyController = MockNetworkActivationProxyController()
|
|
let notificationCenter = NotificationCenter()
|
|
let service = NetworkActivationService(
|
|
storage: storage,
|
|
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
|
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
|
permissionProvider: { permissionSubject.value },
|
|
mutualFavoritesProvider: { favoritesSubject.value },
|
|
reachabilityMonitor: AlwaysReachableMonitor(),
|
|
torController: torController,
|
|
relayController: relayController,
|
|
proxyController: proxyController,
|
|
notificationCenter: notificationCenter
|
|
)
|
|
return NetworkActivationTestContext(
|
|
service: service,
|
|
storage: storage,
|
|
permissionSubject: permissionSubject,
|
|
favoritesSubject: favoritesSubject,
|
|
torController: torController,
|
|
relayController: relayController,
|
|
proxyController: proxyController,
|
|
notificationCenter: notificationCenter
|
|
)
|
|
}
|
|
|
|
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 NetworkActivationTestContext {
|
|
let service: NetworkActivationService
|
|
let storage: UserDefaults
|
|
let permissionSubject: CurrentValueSubject<LocationChannelManager.PermissionState, Never>
|
|
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
|
|
let torController: MockNetworkActivationTorController
|
|
let relayController: MockNetworkActivationRelayController
|
|
let proxyController: MockNetworkActivationProxyController
|
|
let notificationCenter: NotificationCenter
|
|
}
|
|
|
|
@MainActor
|
|
private final class MockNetworkActivationTorController: 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 MockNetworkActivationRelayController: NetworkActivationRelayControlling {
|
|
private(set) var connectCallCount = 0
|
|
private(set) var disconnectCallCount = 0
|
|
|
|
func connect() {
|
|
connectCallCount += 1
|
|
}
|
|
|
|
func disconnect() {
|
|
disconnectCallCount += 1
|
|
}
|
|
}
|
|
|
|
private final class MockNetworkActivationProxyController: NetworkActivationProxyControlling {
|
|
private(set) var proxyModes: [Bool] = []
|
|
|
|
func setProxyMode(useTor: Bool) {
|
|
proxyModes.append(useTor)
|
|
}
|
|
}
|