mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +00:00
Expand coverage for relay, identity, and location flows (#1055)
* Expand coverage for relay, identity, and location flows * Fix macOS SwiftPM CI failures --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -4,6 +4,100 @@ import Network
|
|||||||
import Combine
|
import Combine
|
||||||
import Tor
|
import Tor
|
||||||
|
|
||||||
|
protocol NostrRelayConnectionProtocol: AnyObject {
|
||||||
|
func resume()
|
||||||
|
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
|
||||||
|
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void)
|
||||||
|
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
|
||||||
|
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol NostrRelaySessionProtocol {
|
||||||
|
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class URLSessionWebSocketTaskAdapter: NostrRelayConnectionProtocol {
|
||||||
|
private let base: URLSessionWebSocketTask
|
||||||
|
|
||||||
|
init(base: URLSessionWebSocketTask) {
|
||||||
|
self.base = base
|
||||||
|
}
|
||||||
|
|
||||||
|
func resume() {
|
||||||
|
base.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||||
|
base.cancel(with: closeCode, reason: reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
||||||
|
base.send(message, completionHandler: completionHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||||
|
base.receive(completionHandler: completionHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
|
||||||
|
base.sendPing(pongReceiveHandler: pongReceiveHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct URLSessionAdapter: NostrRelaySessionProtocol {
|
||||||
|
let base: URLSession
|
||||||
|
|
||||||
|
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
|
||||||
|
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NostrRelayManagerDependencies {
|
||||||
|
var activationAllowed: () -> Bool
|
||||||
|
var userTorEnabled: () -> Bool
|
||||||
|
var hasMutualFavorites: () -> Bool
|
||||||
|
var hasLocationPermission: () -> Bool
|
||||||
|
var mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
|
||||||
|
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
||||||
|
var torEnforced: () -> Bool
|
||||||
|
var torIsReady: () -> Bool
|
||||||
|
var torIsForeground: () -> Bool
|
||||||
|
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
|
||||||
|
var makeSession: () -> NostrRelaySessionProtocol
|
||||||
|
var scheduleAfter: (TimeInterval, @escaping () -> Void) -> Void
|
||||||
|
var now: () -> Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension NostrRelayManagerDependencies {
|
||||||
|
@MainActor
|
||||||
|
static func live() -> Self {
|
||||||
|
Self(
|
||||||
|
activationAllowed: { NetworkActivationService.shared.activationAllowed },
|
||||||
|
userTorEnabled: { NetworkActivationService.shared.userTorEnabled },
|
||||||
|
hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },
|
||||||
|
hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },
|
||||||
|
mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),
|
||||||
|
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
|
||||||
|
torEnforced: { TorManager.shared.torEnforced },
|
||||||
|
torIsReady: { TorManager.shared.isReady },
|
||||||
|
torIsForeground: { TorManager.shared.isForeground() },
|
||||||
|
awaitTorReady: { completion in
|
||||||
|
Task.detached {
|
||||||
|
let ready = await TorManager.shared.awaitReady()
|
||||||
|
await MainActor.run {
|
||||||
|
completion(ready)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
makeSession: { URLSessionAdapter(base: TorURLSession.shared.session) },
|
||||||
|
scheduleAfter: { delay, action in
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||||
|
},
|
||||||
|
now: Date.init
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages WebSocket connections to Nostr relays
|
/// Manages WebSocket connections to Nostr relays
|
||||||
@MainActor
|
@MainActor
|
||||||
final class NostrRelayManager: ObservableObject {
|
final class NostrRelayManager: ObservableObject {
|
||||||
@@ -41,10 +135,11 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
@Published private(set) var relays: [Relay] = []
|
@Published private(set) var relays: [Relay] = []
|
||||||
@Published private(set) var isConnected = false
|
@Published private(set) var isConnected = false
|
||||||
|
|
||||||
|
private let dependencies: NostrRelayManagerDependencies
|
||||||
private var allowDefaultRelays: Bool = false
|
private var allowDefaultRelays: Bool = false
|
||||||
private var hasMutualFavorites: Bool = false
|
private var hasMutualFavorites: Bool = false
|
||||||
private var hasLocationPermission: Bool = false
|
private var hasLocationPermission: Bool = false
|
||||||
private var connections: [String: URLSessionWebSocketTask] = [:]
|
private var connections: [String: NostrRelayConnectionProtocol] = [:]
|
||||||
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
||||||
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
||||||
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
|
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
|
||||||
@@ -69,8 +164,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var messageQueue: [PendingSend] = []
|
private var messageQueue: [PendingSend] = []
|
||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
|
||||||
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
|
||||||
|
|
||||||
// Exponential backoff configuration
|
// Exponential backoff configuration
|
||||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||||
@@ -82,12 +176,13 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var connectionGeneration: Int = 0
|
private var connectionGeneration: Int = 0
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
self.dependencies = .live()
|
||||||
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
|
hasMutualFavorites = dependencies.hasMutualFavorites()
|
||||||
|
hasLocationPermission = dependencies.hasLocationPermission()
|
||||||
applyDefaultRelayPolicy(force: true)
|
applyDefaultRelayPolicy(force: true)
|
||||||
// Deterministic JSON shape for outbound requests
|
// Deterministic JSON shape for outbound requests
|
||||||
self.encoder.outputFormatting = .sortedKeys
|
self.encoder.outputFormatting = .sortedKeys
|
||||||
FavoritesPersistenceService.shared.$mutualFavorites
|
dependencies.mutualFavoritesPublisher
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] favorites in
|
.sink { [weak self] favorites in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -95,7 +190,34 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
self.applyDefaultRelayPolicy()
|
self.applyDefaultRelayPolicy()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
LocationChannelManager.shared.$permissionState
|
dependencies.locationPermissionPublisher
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] state in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let authorized = (state == .authorized)
|
||||||
|
if authorized == self.hasLocationPermission { return }
|
||||||
|
self.hasLocationPermission = authorized
|
||||||
|
self.applyDefaultRelayPolicy()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal init(dependencies: NostrRelayManagerDependencies) {
|
||||||
|
self.dependencies = dependencies
|
||||||
|
hasMutualFavorites = dependencies.hasMutualFavorites()
|
||||||
|
hasLocationPermission = dependencies.hasLocationPermission()
|
||||||
|
applyDefaultRelayPolicy(force: true)
|
||||||
|
// Deterministic JSON shape for outbound requests
|
||||||
|
self.encoder.outputFormatting = .sortedKeys
|
||||||
|
dependencies.mutualFavoritesPublisher
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] favorites in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.hasMutualFavorites = !favorites.isEmpty
|
||||||
|
self.applyDefaultRelayPolicy()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
dependencies.locationPermissionPublisher
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] state in
|
.sink { [weak self] state in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -110,20 +232,18 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
/// Connect to all configured relays
|
/// Connect to all configured relays
|
||||||
func connect() {
|
func connect() {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard networkService.activationAllowed else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
if shouldUseTor {
|
if shouldUseTor {
|
||||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||||
Task.detached {
|
dependencies.awaitTorReady { [weak self] ready in
|
||||||
let ready = await TorManager.shared.awaitReady()
|
guard let self = self else { return }
|
||||||
await MainActor.run {
|
if !ready {
|
||||||
if !ready {
|
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
return
|
||||||
return
|
}
|
||||||
}
|
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
for relay in self.relays {
|
||||||
for relay in self.relays {
|
self.connectToRelay(relay.url)
|
||||||
self.connectToRelay(relay.url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -150,15 +270,14 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||||
func ensureConnections(to relayUrls: [String]) {
|
func ensureConnections(to relayUrls: [String]) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard networkService.activationAllowed else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
let targets = allowedRelayList(from: relayUrls)
|
let targets = allowedRelayList(from: relayUrls)
|
||||||
guard !targets.isEmpty else { return }
|
guard !targets.isEmpty else { return }
|
||||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||||
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
||||||
Task.detached { [weak self] in
|
dependencies.awaitTorReady { [weak self] ready in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let ready = await TorManager.shared.awaitReady()
|
if ready { self.ensureConnections(to: relayUrls) }
|
||||||
await MainActor.run { if ready { self.ensureConnections(to: relayUrls) } }
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -175,13 +294,12 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
/// Send an event to specified relays (or all if none specified)
|
/// Send an event to specified relays (or all if none specified)
|
||||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard networkService.activationAllowed else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||||
// Defer sends until Tor is ready to avoid premature queueing
|
// Defer sends until Tor is ready to avoid premature queueing
|
||||||
Task.detached { [weak self] in
|
dependencies.awaitTorReady { [weak self] ready in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let ready = await TorManager.shared.awaitReady()
|
if ready { self.sendEvent(event, to: relayUrls) }
|
||||||
await MainActor.run { if ready { self.sendEvent(event, to: relayUrls) } }
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -253,24 +371,21 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
onEOSE: (() -> Void)? = nil
|
onEOSE: (() -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard networkService.activationAllowed else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
||||||
let now = Date()
|
let now = dependencies.now()
|
||||||
if messageHandlers[id] != nil {
|
if messageHandlers[id] != nil {
|
||||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
|
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
subscribeCoalesce[id] = now
|
subscribeCoalesce[id] = now
|
||||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||||
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
||||||
Task.detached { [weak self] in
|
dependencies.awaitTorReady { [weak self] ready in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let ready = await TorManager.shared.awaitReady()
|
if ready {
|
||||||
await MainActor.run {
|
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE)
|
||||||
if ready {
|
|
||||||
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -346,7 +461,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
relays.append(Relay(url: url))
|
relays.append(Relay(url: url))
|
||||||
existing.insert(url)
|
existing.insert(url)
|
||||||
}
|
}
|
||||||
if networkService.activationAllowed {
|
if dependencies.activationAllowed() {
|
||||||
ensureConnections(to: Self.defaultRelays)
|
ensureConnections(to: Self.defaultRelays)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -400,10 +515,9 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Send unsubscribe to all relays
|
// Send unsubscribe to all relays
|
||||||
for (relayUrl, connection) in connections {
|
for (relayUrl, connection) in connections {
|
||||||
if subscriptions[relayUrl]?.contains(id) == true {
|
if subscriptions[relayUrl]?.contains(id) == true {
|
||||||
|
subscriptions[relayUrl]?.remove(id)
|
||||||
connection.send(.string(messageString)) { _ in
|
connection.send(.string(messageString)) { _ in
|
||||||
Task { @MainActor in
|
// Local state is cleared before sending so callers can re-subscribe immediately.
|
||||||
self.subscriptions[relayUrl]?.remove(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -413,14 +527,14 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
private func connectToRelay(_ urlString: String) {
|
private func connectToRelay(_ urlString: String) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard networkService.activationAllowed else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
guard let url = URL(string: urlString) else {
|
guard let url = URL(string: urlString) else {
|
||||||
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
|
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
|
||||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,19 +549,16 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Attempting to connect to Nostr relay via the proxied session
|
// Attempting to connect to Nostr relay via the proxied session
|
||||||
|
|
||||||
// If Tor is enforced but not ready, delay connection until it is.
|
// If Tor is enforced but not ready, delay connection until it is.
|
||||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||||
Task.detached { [weak self] in
|
dependencies.awaitTorReady { [weak self] ready in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let ready = await TorManager.shared.awaitReady()
|
if ready { self.connectToRelay(urlString) }
|
||||||
await MainActor.run {
|
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
|
||||||
if ready { self.connectToRelay(urlString) }
|
|
||||||
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let session = TorURLSession.shared.session
|
let session = dependencies.makeSession()
|
||||||
let task = session.webSocketTask(with: url)
|
let task = session.webSocketTask(with: url)
|
||||||
|
|
||||||
connections[urlString] = task
|
connections[urlString] = task
|
||||||
@@ -495,7 +606,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
pendingSubscriptions[relayUrl] = nil
|
pendingSubscriptions[relayUrl] = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) {
|
private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) {
|
||||||
task.receive { [weak self] result in
|
task.receive { [weak self] result in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
@@ -505,7 +616,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
Task.detached(priority: .utility) {
|
Task.detached(priority: .utility) {
|
||||||
guard let parsed = ParsedInbound(message) else { return }
|
guard let parsed = ParsedInbound(message) else { return }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
|
self.handleParsedMessage(parsed, from: relayUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,7 +680,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendToRelay(event: NostrEvent, connection: URLSessionWebSocketTask, relayUrl: String) {
|
private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
|
||||||
let req = NostrRequest.event(event)
|
let req = NostrRequest.event(event)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -601,11 +712,11 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
relays[index].isConnected = isConnected
|
relays[index].isConnected = isConnected
|
||||||
relays[index].lastError = error
|
relays[index].lastError = error
|
||||||
if isConnected {
|
if isConnected {
|
||||||
relays[index].lastConnectedAt = Date()
|
relays[index].lastConnectedAt = dependencies.now()
|
||||||
relays[index].reconnectAttempts = 0 // Reset on successful connection
|
relays[index].reconnectAttempts = 0 // Reset on successful connection
|
||||||
relays[index].nextReconnectTime = nil
|
relays[index].nextReconnectTime = nil
|
||||||
} else {
|
} else {
|
||||||
relays[index].lastDisconnectedAt = Date()
|
relays[index].lastDisconnectedAt = dependencies.now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
@@ -621,7 +732,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||||
// If networking is disallowed, do not schedule reconnection
|
// If networking is disallowed, do not schedule reconnection
|
||||||
if !networkService.activationAllowed {
|
if !dependencies.activationAllowed() {
|
||||||
connections.removeValue(forKey: relayUrl)
|
connections.removeValue(forKey: relayUrl)
|
||||||
subscriptions.removeValue(forKey: relayUrl)
|
subscriptions.removeValue(forKey: relayUrl)
|
||||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||||
@@ -666,13 +777,13 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
maxBackoffInterval
|
maxBackoffInterval
|
||||||
)
|
)
|
||||||
|
|
||||||
let nextReconnectTime = Date().addingTimeInterval(backoffInterval)
|
let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)
|
||||||
relays[index].nextReconnectTime = nextReconnectTime
|
relays[index].nextReconnectTime = nextReconnectTime
|
||||||
|
|
||||||
|
|
||||||
// Schedule reconnection with exponential backoff
|
// Schedule reconnection with exponential backoff
|
||||||
let gen = connectionGeneration
|
let gen = connectionGeneration
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in
|
dependencies.scheduleAfter(backoffInterval) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
// Ignore stale scheduled reconnects from a previous generation
|
// Ignore stale scheduled reconnects from a previous generation
|
||||||
guard gen == self.connectionGeneration else { return }
|
guard gen == self.connectionGeneration else { return }
|
||||||
|
|||||||
@@ -251,7 +251,8 @@ final class BLEService: NSObject {
|
|||||||
init(
|
init(
|
||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
idBridge: NostrIdentityBridge,
|
idBridge: NostrIdentityBridge,
|
||||||
identityManager: SecureIdentityStateManagerProtocol
|
identityManager: SecureIdentityStateManagerProtocol,
|
||||||
|
initializeBluetoothManagers: Bool = true
|
||||||
) {
|
) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
@@ -294,22 +295,23 @@ final class BLEService: NSObject {
|
|||||||
// Tag BLE queue for re-entrancy detection
|
// Tag BLE queue for re-entrancy detection
|
||||||
bleQueue.setSpecific(key: bleQueueKey, value: ())
|
bleQueue.setSpecific(key: bleQueueKey, value: ())
|
||||||
|
|
||||||
// Initialize BLE on background queue to prevent main thread blocking
|
if initializeBluetoothManagers {
|
||||||
// This prevents app freezes during BLE operations
|
// Initialize BLE on background queue to prevent main thread blocking.
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
let centralOptions: [String: Any] = [
|
let centralOptions: [String: Any] = [
|
||||||
CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID
|
CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID
|
||||||
]
|
]
|
||||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions)
|
centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions)
|
||||||
|
|
||||||
let peripheralOptions: [String: Any] = [
|
let peripheralOptions: [String: Any] = [
|
||||||
CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID
|
CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID
|
||||||
]
|
]
|
||||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions)
|
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions)
|
||||||
#else
|
#else
|
||||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
|
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
|
||||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
|
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
|
||||||
#endif
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// Single maintenance timer for all periodic tasks (dispatch-based for determinism)
|
// Single maintenance timer for all periodic tasks (dispatch-based for determinism)
|
||||||
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
||||||
|
|||||||
@@ -13,6 +13,25 @@ import Combine
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Tor
|
import Tor
|
||||||
|
|
||||||
|
protocol GeohashPresenceTimerProtocol: AnyObject {
|
||||||
|
var isValid: Bool { get }
|
||||||
|
func invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class GeohashPresenceTimerAdapter: GeohashPresenceTimerProtocol {
|
||||||
|
private let base: Timer
|
||||||
|
|
||||||
|
init(base: Timer) {
|
||||||
|
self.base = base
|
||||||
|
}
|
||||||
|
|
||||||
|
var isValid: Bool { base.isValid }
|
||||||
|
|
||||||
|
func invalidate() {
|
||||||
|
base.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Service that coordinates the broadcasting of presence heartbeats.
|
/// Service that coordinates the broadcasting of presence heartbeats.
|
||||||
///
|
///
|
||||||
/// Behavior:
|
/// Behavior:
|
||||||
@@ -25,18 +44,27 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
static let shared = GeohashPresenceService()
|
static let shared = GeohashPresenceService()
|
||||||
|
|
||||||
private var subscriptions = Set<AnyCancellable>()
|
private var subscriptions = Set<AnyCancellable>()
|
||||||
private var heartbeatTimer: Timer?
|
private var heartbeatTimer: GeohashPresenceTimerProtocol?
|
||||||
private let idBridge = NostrIdentityBridge()
|
private let availableChannelsProvider: () -> [GeohashChannel]
|
||||||
|
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
|
||||||
|
private let torReadyPublisher: AnyPublisher<Void, Never>
|
||||||
|
private let torIsReady: () -> Bool
|
||||||
|
private let torIsForeground: () -> Bool
|
||||||
|
private let deriveIdentity: (String) throws -> NostrIdentity
|
||||||
|
private let relayLookup: (String, Int) -> [String]
|
||||||
|
private let relaySender: (NostrEvent, [String]) -> Void
|
||||||
|
private let sleeper: (UInt64) async -> Void
|
||||||
|
private let scheduleTimer: (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol
|
||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
|
|
||||||
// Loop interval range in seconds
|
// Loop interval range in seconds
|
||||||
private let loopMinInterval: TimeInterval = 40.0
|
private let loopMinInterval: TimeInterval
|
||||||
private let loopMaxInterval: TimeInterval = 80.0
|
private let loopMaxInterval: TimeInterval
|
||||||
|
|
||||||
// Per-broadcast decorrelation delay range in seconds
|
// Per-broadcast decorrelation delay range in seconds
|
||||||
private let burstMinDelay: TimeInterval = 2.0
|
private let burstMinDelay: TimeInterval
|
||||||
private let burstMaxDelay: TimeInterval = 5.0
|
private let burstMaxDelay: TimeInterval
|
||||||
|
|
||||||
// Privacy: Only broadcast to these levels
|
// Privacy: Only broadcast to these levels
|
||||||
private let allowedPrecisions: Set<Int> = [
|
private let allowedPrecisions: Set<Int> = [
|
||||||
@@ -46,6 +74,74 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
]
|
]
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
|
let idBridge = NostrIdentityBridge()
|
||||||
|
self.availableChannelsProvider = { LocationStateManager.shared.availableChannels }
|
||||||
|
self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher()
|
||||||
|
self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||||
|
.map { _ in () }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
self.torIsReady = { TorManager.shared.isReady }
|
||||||
|
self.torIsForeground = { TorManager.shared.isForeground() }
|
||||||
|
self.deriveIdentity = { try idBridge.deriveIdentity(forGeohash: $0) }
|
||||||
|
self.relayLookup = { geohash, count in
|
||||||
|
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
||||||
|
}
|
||||||
|
self.relaySender = { event, relays in
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||||
|
}
|
||||||
|
self.sleeper = { nanoseconds in
|
||||||
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||||
|
}
|
||||||
|
self.scheduleTimer = { interval, action in
|
||||||
|
GeohashPresenceTimerAdapter(
|
||||||
|
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.loopMinInterval = 40.0
|
||||||
|
self.loopMaxInterval = 80.0
|
||||||
|
self.burstMinDelay = 2.0
|
||||||
|
self.burstMaxDelay = 5.0
|
||||||
|
setupObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal init(
|
||||||
|
availableChannelsProvider: @escaping () -> [GeohashChannel],
|
||||||
|
locationChanges: AnyPublisher<[GeohashChannel], Never>,
|
||||||
|
torReadyPublisher: AnyPublisher<Void, Never>,
|
||||||
|
torIsReady: @escaping () -> Bool,
|
||||||
|
torIsForeground: @escaping () -> Bool,
|
||||||
|
deriveIdentity: @escaping (String) throws -> NostrIdentity,
|
||||||
|
relayLookup: @escaping (String, Int) -> [String],
|
||||||
|
relaySender: @escaping (NostrEvent, [String]) -> Void,
|
||||||
|
sleeper: @escaping (UInt64) async -> Void = { nanoseconds in try? await Task.sleep(nanoseconds: nanoseconds) },
|
||||||
|
scheduleTimer: @escaping (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol = { interval, action in
|
||||||
|
GeohashPresenceTimerAdapter(
|
||||||
|
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
loopMinInterval: TimeInterval = 40.0,
|
||||||
|
loopMaxInterval: TimeInterval = 80.0,
|
||||||
|
burstMinDelay: TimeInterval = 2.0,
|
||||||
|
burstMaxDelay: TimeInterval = 5.0
|
||||||
|
) {
|
||||||
|
self.availableChannelsProvider = availableChannelsProvider
|
||||||
|
self.locationChanges = locationChanges
|
||||||
|
self.torReadyPublisher = torReadyPublisher
|
||||||
|
self.torIsReady = torIsReady
|
||||||
|
self.torIsForeground = torIsForeground
|
||||||
|
self.deriveIdentity = deriveIdentity
|
||||||
|
self.relayLookup = relayLookup
|
||||||
|
self.relaySender = relaySender
|
||||||
|
self.sleeper = sleeper
|
||||||
|
self.scheduleTimer = scheduleTimer
|
||||||
|
self.loopMinInterval = loopMinInterval
|
||||||
|
self.loopMaxInterval = loopMaxInterval
|
||||||
|
self.burstMinDelay = burstMinDelay
|
||||||
|
self.burstMaxDelay = burstMaxDelay
|
||||||
setupObservers()
|
setupObservers()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +153,7 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
|
|
||||||
private func setupObservers() {
|
private func setupObservers() {
|
||||||
// Monitor location channel changes
|
// Monitor location channel changes
|
||||||
LocationStateManager.shared.$availableChannels
|
locationChanges
|
||||||
.dropFirst()
|
.dropFirst()
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
self?.handleLocationChange()
|
self?.handleLocationChange()
|
||||||
@@ -65,28 +161,28 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
.store(in: &subscriptions)
|
.store(in: &subscriptions)
|
||||||
|
|
||||||
// Monitor Tor readiness to kick off heartbeat if it was stalled
|
// Monitor Tor readiness to kick off heartbeat if it was stalled
|
||||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
torReadyPublisher
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
self?.handleConnectivityChange()
|
self?.handleConnectivityChange()
|
||||||
}
|
}
|
||||||
.store(in: &subscriptions)
|
.store(in: &subscriptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleLocationChange() {
|
func handleLocationChange() {
|
||||||
// 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
|
||||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { [weak self] _ in
|
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.performHeartbeat()
|
self?.performHeartbeat()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleConnectivityChange() {
|
func handleConnectivityChange() {
|
||||||
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 {
|
||||||
@@ -94,33 +190,33 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleNextHeartbeat() {
|
func scheduleNextHeartbeat() {
|
||||||
heartbeatTimer?.invalidate()
|
heartbeatTimer?.invalidate()
|
||||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
||||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
|
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.performHeartbeat()
|
self?.performHeartbeat()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performHeartbeat() {
|
func performHeartbeat() {
|
||||||
// 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 { scheduleNextHeartbeat() }
|
||||||
|
|
||||||
// 1. Check preconditions
|
// 1. Check preconditions
|
||||||
guard TorManager.shared.isReady else {
|
guard torIsReady() else {
|
||||||
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
|
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// App must be active (or at least we shouldn't broadcast if in background, usually)
|
// App must be active (or at least we shouldn't broadcast if in background, usually)
|
||||||
if !TorManager.shared.isForeground() {
|
if !torIsForeground() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get channels
|
// 2. Get channels
|
||||||
let channels = LocationStateManager.shared.availableChannels
|
let channels = availableChannelsProvider()
|
||||||
guard !channels.isEmpty else { return }
|
guard !channels.isEmpty else { return }
|
||||||
|
|
||||||
// 3. Filter and broadcast
|
// 3. Filter and broadcast
|
||||||
@@ -136,16 +232,16 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
// Random delay for decorrelation
|
// Random delay for decorrelation
|
||||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
await self.sleeper(nanoseconds)
|
||||||
|
|
||||||
self.broadcastPresence(for: channel.geohash)
|
self.broadcastPresence(for: channel.geohash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func broadcastPresence(for geohash: String) {
|
func broadcastPresence(for geohash: String) {
|
||||||
do {
|
do {
|
||||||
guard let identity = try? idBridge.deriveIdentity(forGeohash: geohash) else {
|
guard let identity = try? deriveIdentity(geohash) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,13 +251,10 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Send via RelayManager
|
// Send via RelayManager
|
||||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||||
toGeohash: geohash,
|
|
||||||
count: TransportConfig.nostrGeoRelayCount
|
|
||||||
)
|
|
||||||
|
|
||||||
if !targetRelays.isEmpty {
|
if !targetRelays.isEmpty {
|
||||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
relaySender(event, targetRelays)
|
||||||
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
|
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -5,6 +5,82 @@ import Combine
|
|||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS)
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
|
||||||
|
protocol LocationStateManaging: AnyObject {
|
||||||
|
var delegate: CLLocationManagerDelegate? { get set }
|
||||||
|
var desiredAccuracy: CLLocationAccuracy { get set }
|
||||||
|
var distanceFilter: CLLocationDistance { get set }
|
||||||
|
var authorizationStatus: CLAuthorizationStatus { get }
|
||||||
|
func requestWhenInUseAuthorization()
|
||||||
|
func requestLocation()
|
||||||
|
func startUpdatingLocation()
|
||||||
|
func stopUpdatingLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol LocationStateGeocoding: AnyObject {
|
||||||
|
func cancelGeocode()
|
||||||
|
func reverseGeocodeLocation(
|
||||||
|
_ location: CLLocation,
|
||||||
|
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class CLLocationManagerAdapter: NSObject, LocationStateManaging {
|
||||||
|
private let base = CLLocationManager()
|
||||||
|
|
||||||
|
var delegate: CLLocationManagerDelegate? {
|
||||||
|
get { base.delegate }
|
||||||
|
set { base.delegate = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var desiredAccuracy: CLLocationAccuracy {
|
||||||
|
get { base.desiredAccuracy }
|
||||||
|
set { base.desiredAccuracy = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var distanceFilter: CLLocationDistance {
|
||||||
|
get { base.distanceFilter }
|
||||||
|
set { base.distanceFilter = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorizationStatus: CLAuthorizationStatus {
|
||||||
|
if #available(iOS 14.0, macOS 11.0, *) {
|
||||||
|
return base.authorizationStatus
|
||||||
|
}
|
||||||
|
return CLLocationManager.authorizationStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestWhenInUseAuthorization() {
|
||||||
|
base.requestWhenInUseAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestLocation() {
|
||||||
|
base.requestLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startUpdatingLocation() {
|
||||||
|
base.startUpdatingLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopUpdatingLocation() {
|
||||||
|
base.stopUpdatingLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class CLGeocoderAdapter: LocationStateGeocoding {
|
||||||
|
private let base = CLGeocoder()
|
||||||
|
|
||||||
|
func cancelGeocode() {
|
||||||
|
base.cancelGeocode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverseGeocodeLocation(
|
||||||
|
_ location: CLLocation,
|
||||||
|
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
|
||||||
|
) {
|
||||||
|
base.reverseGeocodeLocation(location, completionHandler: completionHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Unified manager for location-based channel state including:
|
/// Unified manager for location-based channel state including:
|
||||||
/// - CoreLocation permissions and one-shot location retrieval
|
/// - CoreLocation permissions and one-shot location retrieval
|
||||||
/// - Geohash channel computation from coordinates
|
/// - Geohash channel computation from coordinates
|
||||||
@@ -26,8 +102,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
|
|
||||||
// MARK: - Private Properties (CoreLocation)
|
// MARK: - Private Properties (CoreLocation)
|
||||||
|
|
||||||
private let cl = CLLocationManager()
|
private let cl: LocationStateManaging
|
||||||
private let geocoder = CLGeocoder()
|
private let geocoder: LocationStateGeocoding
|
||||||
private var lastLocation: CLLocation?
|
private var lastLocation: CLLocation?
|
||||||
private var refreshTimer: Timer?
|
private var refreshTimer: Timer?
|
||||||
private var isGeocoding: Bool = false
|
private var isGeocoding: Bool = false
|
||||||
@@ -73,6 +149,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
|
|
||||||
private override init() {
|
private override init() {
|
||||||
self.storage = .standard
|
self.storage = .standard
|
||||||
|
self.cl = CLLocationManagerAdapter()
|
||||||
|
self.geocoder = CLGeocoderAdapter()
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
// Skip CoreLocation setup in test environments
|
// Skip CoreLocation setup in test environments
|
||||||
@@ -92,10 +170,30 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
/// Internal initializer for testing with custom storage
|
/// Internal initializer for testing with custom storage
|
||||||
init(storage: UserDefaults) {
|
init(storage: UserDefaults) {
|
||||||
self.storage = storage
|
self.storage = storage
|
||||||
|
self.cl = CLLocationManagerAdapter()
|
||||||
|
self.geocoder = CLGeocoderAdapter()
|
||||||
super.init()
|
super.init()
|
||||||
loadPersistedState()
|
loadPersistedState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal init(
|
||||||
|
storage: UserDefaults,
|
||||||
|
locationManager: LocationStateManaging,
|
||||||
|
geocoder: LocationStateGeocoding,
|
||||||
|
shouldInitializeCoreLocation: Bool
|
||||||
|
) {
|
||||||
|
self.storage = storage
|
||||||
|
self.cl = locationManager
|
||||||
|
self.geocoder = geocoder
|
||||||
|
super.init()
|
||||||
|
loadPersistedState()
|
||||||
|
guard shouldInitializeCoreLocation else { return }
|
||||||
|
cl.delegate = self
|
||||||
|
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
|
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||||
|
initializePermissionState()
|
||||||
|
}
|
||||||
|
|
||||||
private func loadPersistedState() {
|
private func loadPersistedState() {
|
||||||
// Load selected channel
|
// Load selected channel
|
||||||
if let data = storage.data(forKey: selectedChannelKey),
|
if let data = storage.data(forKey: selectedChannelKey),
|
||||||
@@ -132,12 +230,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func initializePermissionState() {
|
private func initializePermissionState() {
|
||||||
let status: CLAuthorizationStatus
|
let status = cl.authorizationStatus
|
||||||
if #available(iOS 14.0, macOS 11.0, *) {
|
|
||||||
status = cl.authorizationStatus
|
|
||||||
} else {
|
|
||||||
status = CLLocationManager.authorizationStatus()
|
|
||||||
}
|
|
||||||
updatePermissionState(from: status)
|
updatePermissionState(from: status)
|
||||||
|
|
||||||
// Fall back to persisted teleport state if no location authorization
|
// Fall back to persisted teleport state if no location authorization
|
||||||
@@ -156,12 +249,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
// MARK: - Public API (Permissions & Location)
|
// MARK: - Public API (Permissions & Location)
|
||||||
|
|
||||||
func enableLocationChannels() {
|
func enableLocationChannels() {
|
||||||
let status: CLAuthorizationStatus
|
let status = cl.authorizationStatus
|
||||||
if #available(iOS 14.0, macOS 11.0, *) {
|
|
||||||
status = cl.authorizationStatus
|
|
||||||
} else {
|
|
||||||
status = CLLocationManager.authorizationStatus()
|
|
||||||
}
|
|
||||||
switch status {
|
switch status {
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
cl.requestWhenInUseAuthorization()
|
cl.requestWhenInUseAuthorization()
|
||||||
|
|||||||
@@ -3,6 +3,27 @@ import BitLogger
|
|||||||
import Combine
|
import Combine
|
||||||
import Tor
|
import Tor
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol NetworkActivationTorControlling: AnyObject {
|
||||||
|
func setAutoStartAllowed(_ allowed: Bool)
|
||||||
|
func startIfNeeded()
|
||||||
|
func shutdownCompletely()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol NetworkActivationRelayControlling: AnyObject {
|
||||||
|
func connect()
|
||||||
|
func disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol NetworkActivationProxyControlling: AnyObject {
|
||||||
|
func setProxyMode(useTor: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TorManager: NetworkActivationTorControlling {}
|
||||||
|
extension NostrRelayManager: NetworkActivationRelayControlling {}
|
||||||
|
extension TorURLSession: NetworkActivationProxyControlling {}
|
||||||
|
|
||||||
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
||||||
/// Policy: permit start when either location permissions are authorized OR
|
/// Policy: permit start when either location permissions are authorized OR
|
||||||
/// there exists at least one mutual favorite. Otherwise, do not start.
|
/// there exists at least one mutual favorite. Otherwise, do not start.
|
||||||
@@ -17,14 +38,55 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
private var started = false
|
private var started = false
|
||||||
private let torPreferenceKey = "networkActivationService.userTorEnabled"
|
private let torPreferenceKey = "networkActivationService.userTorEnabled"
|
||||||
private var torAutoStartDesired: Bool = false
|
private var torAutoStartDesired: Bool = false
|
||||||
|
private let storage: UserDefaults
|
||||||
|
private let locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
||||||
|
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
|
||||||
|
private let permissionProvider: () -> LocationChannelManager.PermissionState
|
||||||
|
private let mutualFavoritesProvider: () -> Set<Data>
|
||||||
|
private let torController: NetworkActivationTorControlling
|
||||||
|
private let relayController: NetworkActivationRelayControlling
|
||||||
|
private let proxyController: NetworkActivationProxyControlling
|
||||||
|
private let notificationCenter: NotificationCenter
|
||||||
|
|
||||||
private init() {}
|
private init() {
|
||||||
|
storage = .standard
|
||||||
|
locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher()
|
||||||
|
mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher()
|
||||||
|
permissionProvider = { LocationChannelManager.shared.permissionState }
|
||||||
|
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
|
||||||
|
torController = TorManager.shared
|
||||||
|
relayController = NostrRelayManager.shared
|
||||||
|
proxyController = TorURLSession.shared
|
||||||
|
notificationCenter = .default
|
||||||
|
}
|
||||||
|
|
||||||
|
internal init(
|
||||||
|
storage: UserDefaults,
|
||||||
|
locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>,
|
||||||
|
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
|
||||||
|
permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
|
||||||
|
mutualFavoritesProvider: @escaping () -> Set<Data>,
|
||||||
|
torController: NetworkActivationTorControlling,
|
||||||
|
relayController: NetworkActivationRelayControlling,
|
||||||
|
proxyController: NetworkActivationProxyControlling,
|
||||||
|
notificationCenter: NotificationCenter = .default
|
||||||
|
) {
|
||||||
|
self.storage = storage
|
||||||
|
self.locationPermissionPublisher = locationPermissionPublisher
|
||||||
|
self.mutualFavoritesPublisher = mutualFavoritesPublisher
|
||||||
|
self.permissionProvider = permissionProvider
|
||||||
|
self.mutualFavoritesProvider = mutualFavoritesProvider
|
||||||
|
self.torController = torController
|
||||||
|
self.relayController = relayController
|
||||||
|
self.proxyController = proxyController
|
||||||
|
self.notificationCenter = notificationCenter
|
||||||
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
guard !started else { return }
|
guard !started else { return }
|
||||||
started = true
|
started = true
|
||||||
|
|
||||||
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
|
if let stored = storage.object(forKey: torPreferenceKey) as? Bool {
|
||||||
userTorEnabled = stored
|
userTorEnabled = stored
|
||||||
} else {
|
} else {
|
||||||
userTorEnabled = true
|
userTorEnabled = true
|
||||||
@@ -34,16 +96,16 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
let allowed = basePolicyAllowed()
|
let allowed = basePolicyAllowed()
|
||||||
activationAllowed = allowed
|
activationAllowed = allowed
|
||||||
torAutoStartDesired = allowed && userTorEnabled
|
torAutoStartDesired = allowed && userTorEnabled
|
||||||
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
|
torController.setAutoStartAllowed(torAutoStartDesired)
|
||||||
applyTorState(torDesired: torAutoStartDesired)
|
applyTorState(torDesired: torAutoStartDesired)
|
||||||
if allowed {
|
if allowed {
|
||||||
NostrRelayManager.shared.connect()
|
relayController.connect()
|
||||||
} else {
|
} else {
|
||||||
NostrRelayManager.shared.disconnect()
|
relayController.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// React to location permission changes
|
// React to location permission changes
|
||||||
LocationChannelManager.shared.$permissionState
|
locationPermissionPublisher
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
self?.reevaluate()
|
self?.reevaluate()
|
||||||
@@ -51,7 +113,7 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// React to mutual favorites changes
|
// React to mutual favorites changes
|
||||||
FavoritesPersistenceService.shared.$mutualFavorites
|
mutualFavoritesPublisher
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
self?.reevaluate()
|
self?.reevaluate()
|
||||||
@@ -62,8 +124,8 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
func setUserTorEnabled(_ enabled: Bool) {
|
func setUserTorEnabled(_ enabled: Bool) {
|
||||||
guard enabled != userTorEnabled else { return }
|
guard enabled != userTorEnabled else { return }
|
||||||
userTorEnabled = enabled
|
userTorEnabled = enabled
|
||||||
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
|
storage.set(enabled, forKey: torPreferenceKey)
|
||||||
NotificationCenter.default.post(
|
notificationCenter.post(
|
||||||
name: .TorUserPreferenceChanged,
|
name: .TorUserPreferenceChanged,
|
||||||
object: nil,
|
object: nil,
|
||||||
userInfo: ["enabled": enabled]
|
userInfo: ["enabled": enabled]
|
||||||
@@ -82,33 +144,33 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
}
|
}
|
||||||
if statusChanged || torChanged {
|
if statusChanged || torChanged {
|
||||||
torAutoStartDesired = torDesired
|
torAutoStartDesired = torDesired
|
||||||
TorManager.shared.setAutoStartAllowed(torDesired)
|
torController.setAutoStartAllowed(torDesired)
|
||||||
applyTorState(torDesired: torDesired)
|
applyTorState(torDesired: torDesired)
|
||||||
}
|
}
|
||||||
|
|
||||||
if allowed {
|
if allowed {
|
||||||
if torChanged {
|
if torChanged {
|
||||||
// Reset relay sockets when switching transport path (Tor ↔︎ direct)
|
// Reset relay sockets when switching transport path (Tor ↔︎ direct)
|
||||||
NostrRelayManager.shared.disconnect()
|
relayController.disconnect()
|
||||||
}
|
}
|
||||||
NostrRelayManager.shared.connect()
|
relayController.connect()
|
||||||
} else if statusChanged {
|
} else if statusChanged {
|
||||||
NostrRelayManager.shared.disconnect()
|
relayController.disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func basePolicyAllowed() -> Bool {
|
private func basePolicyAllowed() -> Bool {
|
||||||
let permOK = LocationChannelManager.shared.permissionState == .authorized
|
let permOK = permissionProvider() == .authorized
|
||||||
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
let hasMutual = !mutualFavoritesProvider().isEmpty
|
||||||
return permOK || hasMutual
|
return permOK || hasMutual
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyTorState(torDesired: Bool) {
|
private func applyTorState(torDesired: Bool) {
|
||||||
TorURLSession.shared.setProxyMode(useTor: torDesired)
|
proxyController.setProxyMode(useTor: torDesired)
|
||||||
if torDesired {
|
if torDesired {
|
||||||
TorManager.shared.startIfNeeded()
|
torController.startIfNeeded()
|
||||||
} else {
|
} else {
|
||||||
TorManager.shared.shutdownCompletely()
|
torController.shutdownCompletely()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,24 +14,103 @@ import UIKit
|
|||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
protocol NotificationAuthorizing {
|
||||||
|
func requestAuthorization(
|
||||||
|
options: UNAuthorizationOptions,
|
||||||
|
completionHandler: @escaping (Bool, Error?) -> Void
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol NotificationRequestDelivering {
|
||||||
|
func add(_ request: UNNotificationRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing {
|
||||||
|
private let center: UNUserNotificationCenter
|
||||||
|
|
||||||
|
init(center: UNUserNotificationCenter) {
|
||||||
|
self.center = center
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAuthorization(
|
||||||
|
options: UNAuthorizationOptions,
|
||||||
|
completionHandler: @escaping (Bool, Error?) -> Void
|
||||||
|
) {
|
||||||
|
center.requestAuthorization(options: options, completionHandler: completionHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class NotificationCenterRequestDelivererAdapter: NotificationRequestDelivering {
|
||||||
|
private let center: UNUserNotificationCenter
|
||||||
|
|
||||||
|
init(center: UNUserNotificationCenter) {
|
||||||
|
self.center = center
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(_ request: UNNotificationRequest) {
|
||||||
|
Task {
|
||||||
|
try? await center.add(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NoopNotificationAuthorizer: NotificationAuthorizing {
|
||||||
|
func requestAuthorization(
|
||||||
|
options: UNAuthorizationOptions,
|
||||||
|
completionHandler: @escaping (Bool, Error?) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler(false, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering {
|
||||||
|
func add(_ request: UNNotificationRequest) {}
|
||||||
|
}
|
||||||
|
|
||||||
final class NotificationService {
|
final class NotificationService {
|
||||||
static let shared = NotificationService()
|
static let shared = NotificationService()
|
||||||
|
|
||||||
|
private let isRunningTestsProvider: () -> Bool
|
||||||
|
private let authorizer: NotificationAuthorizing
|
||||||
|
private let requestDeliverer: NotificationRequestDelivering
|
||||||
|
|
||||||
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
|
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
|
||||||
private var isRunningTests: Bool {
|
private var isRunningTests: Bool {
|
||||||
let env = ProcessInfo.processInfo.environment
|
isRunningTestsProvider()
|
||||||
return NSClassFromString("XCTestCase") != nil ||
|
|
||||||
env["XCTestConfigurationFilePath"] != nil ||
|
|
||||||
env["XCTestBundlePath"] != nil ||
|
|
||||||
env["GITHUB_ACTIONS"] != nil ||
|
|
||||||
env["CI"] != nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private init() {}
|
private init() {
|
||||||
|
self.isRunningTestsProvider = {
|
||||||
|
let env = ProcessInfo.processInfo.environment
|
||||||
|
return NSClassFromString("XCTestCase") != nil ||
|
||||||
|
env["XCTestConfigurationFilePath"] != nil ||
|
||||||
|
env["XCTestBundlePath"] != nil ||
|
||||||
|
env["GITHUB_ACTIONS"] != nil ||
|
||||||
|
env["CI"] != nil
|
||||||
|
}
|
||||||
|
if isRunningTestsProvider() {
|
||||||
|
self.authorizer = NoopNotificationAuthorizer()
|
||||||
|
self.requestDeliverer = NoopNotificationRequestDeliverer()
|
||||||
|
} else {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
self.authorizer = NotificationCenterAuthorizerAdapter(center: center)
|
||||||
|
self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal init(
|
||||||
|
isRunningTestsProvider: @escaping () -> Bool,
|
||||||
|
authorizer: NotificationAuthorizing,
|
||||||
|
requestDeliverer: NotificationRequestDelivering
|
||||||
|
) {
|
||||||
|
self.isRunningTestsProvider = isRunningTestsProvider
|
||||||
|
self.authorizer = authorizer
|
||||||
|
self.requestDeliverer = requestDeliverer
|
||||||
|
}
|
||||||
|
|
||||||
func requestAuthorization() {
|
func requestAuthorization() {
|
||||||
guard !isRunningTests else { return }
|
guard !isRunningTests else { return }
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||||
if granted {
|
if granted {
|
||||||
// Permission granted
|
// Permission granted
|
||||||
} else {
|
} else {
|
||||||
@@ -64,7 +143,7 @@ final class NotificationService {
|
|||||||
trigger: nil // Deliver immediately
|
trigger: nil // Deliver immediately
|
||||||
)
|
)
|
||||||
|
|
||||||
UNUserNotificationCenter.current().add(request)
|
requestDeliverer.add(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMentionNotification(from sender: String, message: String) {
|
func sendMentionNotification(from sender: String, message: String) {
|
||||||
|
|||||||
@@ -89,7 +89,12 @@ private func makeService() -> BLEService {
|
|||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
let identityManager = MockIdentityManager(keychain)
|
let identityManager = MockIdentityManager(keychain)
|
||||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||||
return BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
return BLEService(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: identityManager,
|
||||||
|
initializeBluetoothManagers: false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {
|
private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {
|
||||||
|
|||||||
@@ -125,26 +125,20 @@ struct ChatViewModelRefactoringTests {
|
|||||||
let (viewModel, _, _) = makePinnedViewModel()
|
let (viewModel, _, _) = makePinnedViewModel()
|
||||||
let senderID = PeerID(str: "sender_2")
|
let senderID = PeerID(str: "sender_2")
|
||||||
|
|
||||||
// Setup
|
// Action
|
||||||
let message = BitchatMessage(
|
viewModel.didReceivePublicMessage(
|
||||||
id: "msg_2",
|
from: senderID,
|
||||||
sender: "charlie",
|
nickname: "charlie",
|
||||||
content: "Public Hi",
|
content: "Public Hi",
|
||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false,
|
messageID: "msg_2"
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: senderID,
|
|
||||||
mentions: nil
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Action
|
|
||||||
viewModel.didReceiveMessage(message)
|
|
||||||
|
|
||||||
// Wait for async processing with proper timeout
|
// Wait for async processing with proper timeout
|
||||||
let found = await TestHelpers.waitUntil(
|
let found = await TestHelpers.waitUntil(
|
||||||
{ viewModel.messages.contains(where: { $0.content == "Public Hi" }) },
|
{
|
||||||
|
viewModel.timelineStore.messages(for: .mesh).contains(where: { $0.content == "Public Hi" })
|
||||||
|
},
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.defaultTimeout
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -196,8 +196,7 @@ struct ChatViewModelReceivingTests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let found = await TestHelpers.waitUntil({
|
let found = await TestHelpers.waitUntil({
|
||||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
viewModel.timelineStore.messages(for: .mesh).contains { $0.content == "Public hello from Bob" }
|
||||||
return viewModel.messages.contains { $0.content == "Public hello from Bob" }
|
|
||||||
}, timeout: TestConstants.defaultTimeout)
|
}, timeout: TestConstants.defaultTimeout)
|
||||||
|
|
||||||
#expect(found)
|
#expect(found)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite(.serialized)
|
||||||
struct CommandProcessorTests {
|
struct CommandProcessorTests {
|
||||||
private var identityManager = MockIdentityManager(MockKeychain())
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func slapNotFoundGrammar() {
|
@Test func slapNotFoundGrammar() {
|
||||||
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
||||||
let result = processor.process("/slap @system")
|
let result = processor.process("/slap @system")
|
||||||
switch result {
|
switch result {
|
||||||
@@ -18,6 +20,7 @@ struct CommandProcessorTests {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func hugNotFoundGrammar() {
|
@Test func hugNotFoundGrammar() {
|
||||||
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
||||||
let result = processor.process("/hug @system")
|
let result = processor.process("/hug @system")
|
||||||
switch result {
|
switch result {
|
||||||
@@ -30,6 +33,7 @@ struct CommandProcessorTests {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func slapUsageMessage() {
|
@Test func slapUsageMessage() {
|
||||||
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
||||||
let result = processor.process("/slap")
|
let result = processor.process("/slap")
|
||||||
switch result {
|
switch result {
|
||||||
@@ -39,4 +43,389 @@ struct CommandProcessorTests {
|
|||||||
Issue.record("Expected error result for usage message")
|
Issue.record("Expected error result for usage message")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func msgStartsPrivateChatAndSendsMessage() async {
|
||||||
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
|
let context = MockCommandContextProvider()
|
||||||
|
let peerID = PeerID(str: "abcd1234abcd1234")
|
||||||
|
context.nicknameToPeerID["alice"] = peerID
|
||||||
|
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
|
||||||
|
|
||||||
|
let result = await withSelectedChannel(.mesh) {
|
||||||
|
processor.process("/msg @alice hello there")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let message):
|
||||||
|
#expect(message == "started private chat with alice")
|
||||||
|
default:
|
||||||
|
Issue.record("Expected success result")
|
||||||
|
}
|
||||||
|
#expect(context.startedPrivateChats == [peerID])
|
||||||
|
#expect(context.sentPrivateMessages.count == 1)
|
||||||
|
#expect(context.sentPrivateMessages.first?.content == "hello there")
|
||||||
|
#expect(context.sentPrivateMessages.first?.peerID == peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func whoInMeshListsSortedPeerNicknames() async {
|
||||||
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.peerNicknames = [
|
||||||
|
PeerID(str: "b"): "bob",
|
||||||
|
PeerID(str: "a"): "alice"
|
||||||
|
]
|
||||||
|
let processor = CommandProcessor(contextProvider: MockCommandContextProvider(), meshService: transport, identityManager: identityManager)
|
||||||
|
|
||||||
|
let result = await withSelectedChannel(.mesh) {
|
||||||
|
processor.process("/who")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let message):
|
||||||
|
#expect(message == "online: alice, bob")
|
||||||
|
default:
|
||||||
|
Issue.record("Expected success result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func whoInGeohashListsVisibleParticipantsExcludingSelf() async throws {
|
||||||
|
let bridge = NostrIdentityBridge(keychain: MockKeychain())
|
||||||
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
|
let context = MockCommandContextProvider(idBridge: bridge)
|
||||||
|
let geohash = "u4pruy"
|
||||||
|
let selfPubkey = try bridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased()
|
||||||
|
context.visibleGeoParticipants = [
|
||||||
|
CommandGeoParticipant(id: selfPubkey, displayName: "me"),
|
||||||
|
CommandGeoParticipant(id: String(repeating: "b", count: 64), displayName: "bob")
|
||||||
|
]
|
||||||
|
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
|
||||||
|
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
||||||
|
|
||||||
|
let result = try await withSelectedChannel(channel) {
|
||||||
|
processor.process("/who")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let message):
|
||||||
|
#expect(message == "online: bob")
|
||||||
|
default:
|
||||||
|
Issue.record("Expected success result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func clearInPrivateChatRemovesOnlySelectedConversation() async {
|
||||||
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
|
let context = MockCommandContextProvider()
|
||||||
|
let activePeer = PeerID(str: "active")
|
||||||
|
let otherPeer = PeerID(str: "other")
|
||||||
|
context.selectedPrivateChatPeer = activePeer
|
||||||
|
context.privateChats = [
|
||||||
|
activePeer: [makeMessage(sender: "alice", content: "secret")],
|
||||||
|
otherPeer: [makeMessage(sender: "bob", content: "keep")]
|
||||||
|
]
|
||||||
|
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
|
||||||
|
|
||||||
|
let result = await withSelectedChannel(.mesh) {
|
||||||
|
processor.process("/clear")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .handled:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
Issue.record("Expected handled result")
|
||||||
|
}
|
||||||
|
#expect(context.privateChats[activePeer] == [])
|
||||||
|
#expect(context.privateChats[otherPeer]?.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func clearInPublicChatClearsTimeline() async {
|
||||||
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
|
let context = MockCommandContextProvider()
|
||||||
|
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
|
||||||
|
|
||||||
|
let result = await withSelectedChannel(.mesh) {
|
||||||
|
processor.process("/clear")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .handled:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
Issue.record("Expected handled result")
|
||||||
|
}
|
||||||
|
#expect(context.clearCurrentPublicTimelineCallCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func hugInPrivateChatSendsPersonalizedMessageAndLocalEcho() async {
|
||||||
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
|
let context = MockCommandContextProvider(nickname: "me")
|
||||||
|
let transport = MockTransport()
|
||||||
|
let peerID = PeerID(str: "abcd1234abcd1234")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
context.nicknameToPeerID["bob"] = peerID
|
||||||
|
transport.peerNicknames[peerID] = "Bob"
|
||||||
|
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
|
||||||
|
|
||||||
|
let result = await withSelectedChannel(.mesh) {
|
||||||
|
processor.process("/hug @bob")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .handled:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
Issue.record("Expected handled result")
|
||||||
|
}
|
||||||
|
#expect(transport.sentPrivateMessages.count == 1)
|
||||||
|
#expect(transport.sentPrivateMessages.first?.content == "* 🫂 me hugs you *")
|
||||||
|
#expect(context.localPrivateSystemMessages.first?.content == "🫂 you hugged bob")
|
||||||
|
#expect(context.localPrivateSystemMessages.first?.peerID == peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func slapInPublicChatSendsPublicRawAndEcho() async {
|
||||||
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
|
let context = MockCommandContextProvider(nickname: "me")
|
||||||
|
let peerID = PeerID(str: "abcd1234abcd1234")
|
||||||
|
context.nicknameToPeerID["bob"] = peerID
|
||||||
|
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
|
||||||
|
|
||||||
|
let result = await withSelectedChannel(.mesh) {
|
||||||
|
processor.process("/slap @bob")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .handled:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
Issue.record("Expected handled result")
|
||||||
|
}
|
||||||
|
#expect(context.sentPublicRawMessages == ["* 🐟 me slaps bob around a bit with a large trout *"])
|
||||||
|
#expect(context.publicSystemMessages == ["🐟 me slaps bob around a bit with a large trout"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func blockWithoutArgsListsMeshAndGeohashBlocks() async {
|
||||||
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
|
let context = MockCommandContextProvider()
|
||||||
|
let transport = MockTransport()
|
||||||
|
let peerID = PeerID(str: "abcd1234abcd1234")
|
||||||
|
transport.peerNicknames[peerID] = "bob"
|
||||||
|
transport.peerFingerprints[peerID] = "fp-bob"
|
||||||
|
context.blockedUsers = ["fp-bob"]
|
||||||
|
context.visibleGeoParticipants = [
|
||||||
|
CommandGeoParticipant(id: String(repeating: "c", count: 64), displayName: "carol")
|
||||||
|
]
|
||||||
|
identityManager.setNostrBlocked(String(repeating: "c", count: 64), isBlocked: true)
|
||||||
|
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
|
||||||
|
|
||||||
|
let result = await withSelectedChannel(.mesh) {
|
||||||
|
processor.process("/block")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let message):
|
||||||
|
#expect(message == "blocked peers: bob | geohash blocks: carol")
|
||||||
|
default:
|
||||||
|
Issue.record("Expected success result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func blockAndUnblockMeshPeerUpdateIdentityState() async {
|
||||||
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
|
let context = MockCommandContextProvider()
|
||||||
|
let transport = MockTransport()
|
||||||
|
let peerID = PeerID(str: "abcd1234abcd1234")
|
||||||
|
transport.peerFingerprints[peerID] = "fp-bob"
|
||||||
|
context.nicknameToPeerID["bob"] = peerID
|
||||||
|
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
|
||||||
|
|
||||||
|
let blockResult = await withSelectedChannel(.mesh) {
|
||||||
|
processor.process("/block @bob")
|
||||||
|
}
|
||||||
|
switch blockResult {
|
||||||
|
case .success(let message):
|
||||||
|
#expect(message == "blocked bob. you will no longer receive messages from them")
|
||||||
|
default:
|
||||||
|
Issue.record("Expected success result")
|
||||||
|
}
|
||||||
|
#expect(identityManager.isBlocked(fingerprint: "fp-bob"))
|
||||||
|
|
||||||
|
let unblockResult = await withSelectedChannel(.mesh) {
|
||||||
|
processor.process("/unblock bob")
|
||||||
|
}
|
||||||
|
switch unblockResult {
|
||||||
|
case .success(let message):
|
||||||
|
#expect(message == "unblocked bob")
|
||||||
|
default:
|
||||||
|
Issue.record("Expected success result")
|
||||||
|
}
|
||||||
|
#expect(!identityManager.isBlocked(fingerprint: "fp-bob"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func blockAndUnblockGeohashPeerUseNostrBlockList() async {
|
||||||
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
|
let context = MockCommandContextProvider()
|
||||||
|
context.displayNameToNostrPubkey["carol"] = String(repeating: "d", count: 64)
|
||||||
|
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
|
||||||
|
|
||||||
|
let blockResult = await withSelectedChannel(.mesh) {
|
||||||
|
processor.process("/block carol")
|
||||||
|
}
|
||||||
|
switch blockResult {
|
||||||
|
case .success(let message):
|
||||||
|
#expect(message == "blocked carol in geohash chats")
|
||||||
|
default:
|
||||||
|
Issue.record("Expected success result")
|
||||||
|
}
|
||||||
|
#expect(identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
|
||||||
|
|
||||||
|
let unblockResult = await withSelectedChannel(.mesh) {
|
||||||
|
processor.process("/unblock @carol")
|
||||||
|
}
|
||||||
|
switch unblockResult {
|
||||||
|
case .success(let message):
|
||||||
|
#expect(message == "unblocked carol in geohash chats")
|
||||||
|
default:
|
||||||
|
Issue.record("Expected success result")
|
||||||
|
}
|
||||||
|
#expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func favoriteCommandIsRejectedOutsideMesh() async {
|
||||||
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
|
let processor = CommandProcessor(
|
||||||
|
contextProvider: MockCommandContextProvider(),
|
||||||
|
meshService: MockTransport(),
|
||||||
|
identityManager: identityManager
|
||||||
|
)
|
||||||
|
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pruy"))
|
||||||
|
|
||||||
|
let result = await withSelectedChannel(channel) {
|
||||||
|
processor.process("/fav alice")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .error(let message):
|
||||||
|
#expect(message == "favorites are only for mesh peers in #mesh")
|
||||||
|
default:
|
||||||
|
Issue.record("Expected error result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func withSelectedChannel<T>(_ channel: ChannelID, perform work: @escaping () throws -> T) async rethrows -> T {
|
||||||
|
let originalChannel = LocationChannelManager.shared.selectedChannel
|
||||||
|
await setSelectedChannel(channel)
|
||||||
|
do {
|
||||||
|
let result = try work()
|
||||||
|
await setSelectedChannel(originalChannel)
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
await setSelectedChannel(originalChannel)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func setSelectedChannel(_ channel: ChannelID) async {
|
||||||
|
LocationChannelManager.shared.select(channel)
|
||||||
|
for _ in 0..<40 {
|
||||||
|
if LocationChannelManager.shared.selectedChannel == channel {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await Task.yield()
|
||||||
|
try? await Task.sleep(nanoseconds: 5_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeMessage(sender: String, content: String) -> BitchatMessage {
|
||||||
|
BitchatMessage(
|
||||||
|
sender: sender,
|
||||||
|
content: content,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class MockCommandContextProvider: CommandContextProvider {
|
||||||
|
var nickname: String
|
||||||
|
var selectedPrivateChatPeer: PeerID?
|
||||||
|
var blockedUsers: Set<String> = []
|
||||||
|
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||||
|
let idBridge: NostrIdentityBridge
|
||||||
|
|
||||||
|
var nicknameToPeerID: [String: PeerID] = [:]
|
||||||
|
var visibleGeoParticipants: [CommandGeoParticipant] = []
|
||||||
|
var displayNameToNostrPubkey: [String: String] = [:]
|
||||||
|
|
||||||
|
private(set) var startedPrivateChats: [PeerID] = []
|
||||||
|
private(set) var sentPrivateMessages: [(content: String, peerID: PeerID)] = []
|
||||||
|
private(set) var clearCurrentPublicTimelineCallCount = 0
|
||||||
|
private(set) var sentPublicRawMessages: [String] = []
|
||||||
|
private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = []
|
||||||
|
private(set) var publicSystemMessages: [String] = []
|
||||||
|
private(set) var toggledFavorites: [PeerID] = []
|
||||||
|
private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
||||||
|
|
||||||
|
init(nickname: String = "tester", idBridge: NostrIdentityBridge = NostrIdentityBridge(keychain: MockKeychain())) {
|
||||||
|
self.nickname = nickname
|
||||||
|
self.idBridge = idBridge
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
|
||||||
|
nicknameToPeerID[nickname]
|
||||||
|
}
|
||||||
|
|
||||||
|
func getVisibleGeoParticipants() -> [CommandGeoParticipant] {
|
||||||
|
visibleGeoParticipants
|
||||||
|
}
|
||||||
|
|
||||||
|
func nostrPubkeyForDisplayName(_ displayName: String) -> String? {
|
||||||
|
displayNameToNostrPubkey[displayName]
|
||||||
|
}
|
||||||
|
|
||||||
|
func startPrivateChat(with peerID: PeerID) {
|
||||||
|
startedPrivateChats.append(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
|
||||||
|
sentPrivateMessages.append((content, peerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearCurrentPublicTimeline() {
|
||||||
|
clearCurrentPublicTimelineCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPublicRaw(_ content: String) {
|
||||||
|
sentPublicRawMessages.append(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {
|
||||||
|
localPrivateSystemMessages.append((content, peerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func addPublicSystemMessage(_ content: String) {
|
||||||
|
publicSystemMessages.append(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleFavorite(peerID: PeerID) {
|
||||||
|
toggledFavorites.append(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
|
favoriteNotifications.append((peerID, isFavorite))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ struct FragmentationTests {
|
|||||||
let ble = BLEService(
|
let ble = BLEService(
|
||||||
keychain: mockKeychain,
|
keychain: mockKeychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
identityManager: mockIdentityManager
|
identityManager: mockIdentityManager,
|
||||||
|
initializeBluetoothManagers: false
|
||||||
)
|
)
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
@@ -63,7 +64,8 @@ struct FragmentationTests {
|
|||||||
let ble = BLEService(
|
let ble = BLEService(
|
||||||
keychain: mockKeychain,
|
keychain: mockKeychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
identityManager: mockIdentityManager
|
identityManager: mockIdentityManager,
|
||||||
|
initializeBluetoothManagers: false
|
||||||
)
|
)
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
@@ -97,7 +99,8 @@ struct FragmentationTests {
|
|||||||
let ble = BLEService(
|
let ble = BLEService(
|
||||||
keychain: mockKeychain,
|
keychain: mockKeychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
identityManager: mockIdentityManager
|
identityManager: mockIdentityManager,
|
||||||
|
initializeBluetoothManagers: false
|
||||||
)
|
)
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
@@ -153,7 +156,8 @@ struct FragmentationTests {
|
|||||||
let ble = BLEService(
|
let ble = BLEService(
|
||||||
keychain: mockKeychain,
|
keychain: mockKeychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
identityManager: mockIdentityManager
|
identityManager: mockIdentityManager,
|
||||||
|
initializeBluetoothManagers: false
|
||||||
)
|
)
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|||||||
@@ -12,6 +12,108 @@ import SwiftUI
|
|||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct MessageFormattingEngineTests {
|
struct MessageFormattingEngineTests {
|
||||||
|
// MARK: - Formatting Behavior Tests
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func formatMessage_regularMessageFormatsHeaderContentAndTimestamp() {
|
||||||
|
let senderPeerID = PeerID(str: "abcdef1234567890")
|
||||||
|
let context = MockMessageFormattingContext(
|
||||||
|
nickname: "carol",
|
||||||
|
peerURLs: [senderPeerID: URL(string: "https://example.com/peers/alice")!]
|
||||||
|
)
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "message-1",
|
||||||
|
sender: "alice#a1b2",
|
||||||
|
content: "hello #mesh https://example.com",
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
|
||||||
|
isRelay: false,
|
||||||
|
senderPeerID: senderPeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
|
||||||
|
|
||||||
|
#expect(String(formatted.characters) == "<@alice#a1b2> hello #mesh https://example.com [\(message.formattedTimestamp)]")
|
||||||
|
#expect(message.getCachedFormattedText(isDark: false, isSelf: false) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func formatMessage_systemMessageUsesSystemLayout() {
|
||||||
|
let context = MockMessageFormattingContext(nickname: "carol")
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "system-1",
|
||||||
|
sender: "system",
|
||||||
|
content: "connected",
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_700_000_123),
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
|
||||||
|
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .dark)
|
||||||
|
|
||||||
|
#expect(String(formatted.characters) == "* connected * [\(message.formattedTimestamp)]")
|
||||||
|
#expect(message.getCachedFormattedText(isDark: true, isSelf: false) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func formatMessage_longSelfMessageFallsBackToPlainContentPath() {
|
||||||
|
let context = MockMessageFormattingContext(
|
||||||
|
nickname: "me",
|
||||||
|
selfMessageIDs: ["self-1"]
|
||||||
|
)
|
||||||
|
let longContent = String(repeating: "a", count: 4_500)
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "self-1",
|
||||||
|
sender: "me#cafe",
|
||||||
|
content: longContent,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_700_000_456),
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
|
||||||
|
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
|
||||||
|
|
||||||
|
#expect(String(formatted.characters) == "<@me#cafe> \(longContent) [\(message.formattedTimestamp)]")
|
||||||
|
#expect(message.getCachedFormattedText(isDark: false, isSelf: true) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func formatMessage_mentionsAreRenderedThroughMentionFormatter() {
|
||||||
|
let context = MockMessageFormattingContext(nickname: "carol")
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "message-mention",
|
||||||
|
sender: "alice",
|
||||||
|
content: "hi @bob#a1b2",
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_700_000_789),
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
|
||||||
|
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
|
||||||
|
|
||||||
|
#expect(String(formatted.characters) == "<@alice> hi bob#a1b2 [\(message.formattedTimestamp)]")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func formatHeader_formatsNormalAndSystemSenders() {
|
||||||
|
let context = MockMessageFormattingContext(nickname: "carol")
|
||||||
|
let normalMessage = BitchatMessage(
|
||||||
|
id: "header-1",
|
||||||
|
sender: "alice#a1b2",
|
||||||
|
content: "hello",
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_700_001_000),
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
let systemMessage = BitchatMessage(
|
||||||
|
id: "header-2",
|
||||||
|
sender: "system",
|
||||||
|
content: "notice",
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_700_001_111),
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
|
||||||
|
let normalHeader = MessageFormattingEngine.formatHeader(normalMessage, context: context, colorScheme: .light)
|
||||||
|
let systemHeader = MessageFormattingEngine.formatHeader(systemMessage, context: context, colorScheme: .dark)
|
||||||
|
|
||||||
|
#expect(String(normalHeader.characters) == "<@alice#a1b2> ")
|
||||||
|
#expect(String(systemHeader.characters) == "system")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Mention Extraction Tests
|
// MARK: - Mention Extraction Tests
|
||||||
|
|
||||||
@@ -221,3 +323,32 @@ struct MessageFormattingEngineTests {
|
|||||||
#expect(content.hasVeryLongToken(threshold: 50))
|
#expect(content.hasVeryLongToken(threshold: 50))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class MockMessageFormattingContext: MessageFormattingContext {
|
||||||
|
let nickname: String
|
||||||
|
private let selfMessageIDs: Set<String>
|
||||||
|
private let peerURLs: [PeerID: URL]
|
||||||
|
|
||||||
|
init(
|
||||||
|
nickname: String,
|
||||||
|
selfMessageIDs: Set<String> = [],
|
||||||
|
peerURLs: [PeerID: URL] = [:]
|
||||||
|
) {
|
||||||
|
self.nickname = nickname
|
||||||
|
self.selfMessageIDs = selfMessageIDs
|
||||||
|
self.peerURLs = peerURLs
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSelfMessage(_ message: BitchatMessage) -> Bool {
|
||||||
|
selfMessageIDs.contains(message.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
||||||
|
.red
|
||||||
|
}
|
||||||
|
|
||||||
|
func peerURL(for peerID: PeerID) -> URL? {
|
||||||
|
peerURLs[peerID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class NoiseRateLimiterTests: XCTestCase {
|
||||||
|
func test_allowHandshake_blocksAfterPerPeerLimit() {
|
||||||
|
let limiter = NoiseRateLimiter()
|
||||||
|
let peerID = makePeerID(1)
|
||||||
|
|
||||||
|
for _ in 0..<NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||||
|
XCTAssertTrue(limiter.allowHandshake(from: peerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertFalse(limiter.allowHandshake(from: peerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_allowHandshake_blocksAfterGlobalLimitAcrossPeers() {
|
||||||
|
let limiter = NoiseRateLimiter()
|
||||||
|
|
||||||
|
for index in 0..<NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||||
|
XCTAssertTrue(limiter.allowHandshake(from: makePeerID(index)))
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertFalse(limiter.allowHandshake(from: makePeerID(10_000)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_reset_clearsPerPeerHandshakeLimit() async {
|
||||||
|
let limiter = NoiseRateLimiter()
|
||||||
|
let peerID = makePeerID(7)
|
||||||
|
|
||||||
|
for _ in 0..<NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||||
|
XCTAssertTrue(limiter.allowHandshake(from: peerID))
|
||||||
|
}
|
||||||
|
XCTAssertFalse(limiter.allowHandshake(from: peerID))
|
||||||
|
|
||||||
|
limiter.reset(for: peerID)
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
|
||||||
|
XCTAssertTrue(limiter.allowHandshake(from: peerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_allowMessage_blocksAfterPerPeerLimit() {
|
||||||
|
let limiter = NoiseRateLimiter()
|
||||||
|
let peerID = makePeerID(9)
|
||||||
|
|
||||||
|
for _ in 0..<NoiseSecurityConstants.maxMessagesPerSecond {
|
||||||
|
XCTAssertTrue(limiter.allowMessage(from: peerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertFalse(limiter.allowMessage(from: peerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_resetAll_clearsGlobalHandshakeLimit() async {
|
||||||
|
let limiter = NoiseRateLimiter()
|
||||||
|
|
||||||
|
for index in 0..<NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||||
|
XCTAssertTrue(limiter.allowHandshake(from: makePeerID(index)))
|
||||||
|
}
|
||||||
|
XCTAssertFalse(limiter.allowHandshake(from: makePeerID(20_000)))
|
||||||
|
|
||||||
|
limiter.resetAll()
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
|
||||||
|
XCTAssertTrue(limiter.allowHandshake(from: makePeerID(20_001)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePeerID(_ value: Int) -> PeerID {
|
||||||
|
PeerID(str: String(format: "%016x", value))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class BinaryEncodingUtilsTests: XCTestCase {
|
||||||
|
func test_appendAndReadPrimitiveValues_roundTrip() throws {
|
||||||
|
var data = Data()
|
||||||
|
data.appendUInt8(0x12)
|
||||||
|
data.appendUInt16(0x3456)
|
||||||
|
data.appendUInt32(0x789ABCDE)
|
||||||
|
data.appendUInt64(0x0123456789ABCDEF)
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
XCTAssertEqual(data.readUInt8(at: &offset), 0x12)
|
||||||
|
XCTAssertEqual(data.readUInt16(at: &offset), 0x3456)
|
||||||
|
XCTAssertEqual(data.readUInt32(at: &offset), 0x789ABCDE)
|
||||||
|
XCTAssertEqual(data.readUInt64(at: &offset), 0x0123456789ABCDEF)
|
||||||
|
XCTAssertEqual(offset, data.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_appendAndReadStringDataAndDate_roundTrip() throws {
|
||||||
|
let expectedDate = Date(timeIntervalSince1970: 1_700_000_000.123)
|
||||||
|
let expectedPayload = Data([0xAA, 0xBB, 0xCC, 0xDD])
|
||||||
|
var data = Data()
|
||||||
|
|
||||||
|
data.appendString("hello")
|
||||||
|
data.appendData(expectedPayload)
|
||||||
|
data.appendDate(expectedDate)
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
XCTAssertEqual(data.readString(at: &offset), "hello")
|
||||||
|
XCTAssertEqual(data.readData(at: &offset), expectedPayload)
|
||||||
|
let decodedDate = try XCTUnwrap(data.readDate(at: &offset))
|
||||||
|
XCTAssertEqual(decodedDate.timeIntervalSince1970, expectedDate.timeIntervalSince1970, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_appendUUID_and_readUUID_roundTrip() throws {
|
||||||
|
let uuid = "12345678-90ab-cdef-1234-567890abcdef"
|
||||||
|
var data = Data()
|
||||||
|
|
||||||
|
data.appendUUID(uuid)
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
XCTAssertEqual(data.readUUID(at: &offset), uuid.uppercased())
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_appendStringAndData_truncateToConfiguredMaxLength() throws {
|
||||||
|
var data = Data()
|
||||||
|
data.appendString("abcdef", maxLength: 4)
|
||||||
|
data.appendData(Data([1, 2, 3, 4, 5]), maxLength: 3)
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
XCTAssertEqual(data.readString(at: &offset), "abcd")
|
||||||
|
XCTAssertEqual(data.readData(at: &offset, maxLength: 3), Data([1, 2, 3]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_readMethods_returnNilWhenOutOfBounds() {
|
||||||
|
var offset = 0
|
||||||
|
let shortData = Data([0x01])
|
||||||
|
|
||||||
|
XCTAssertNil(shortData.readUInt16(at: &offset))
|
||||||
|
XCTAssertEqual(offset, 0)
|
||||||
|
|
||||||
|
offset = 0
|
||||||
|
XCTAssertNil(shortData.readString(at: &offset))
|
||||||
|
XCTAssertEqual(offset, 1)
|
||||||
|
|
||||||
|
offset = 0
|
||||||
|
XCTAssertNil(shortData.readFixedBytes(at: &offset, count: 2))
|
||||||
|
XCTAssertEqual(offset, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class FavoritesPersistenceServiceTests: XCTestCase {
|
||||||
|
private let storageKey = "chat.bitchat.favorites"
|
||||||
|
private let serviceKey = "chat.bitchat.favorites"
|
||||||
|
|
||||||
|
func test_addFavorite_persistsAndPostsNotification() throws {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let service = FavoritesPersistenceService(keychain: keychain)
|
||||||
|
let peerKey = Data((0..<32).map(UInt8.init))
|
||||||
|
let expectation = expectation(forNotification: .favoriteStatusChanged, object: nil)
|
||||||
|
|
||||||
|
service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1alice", peerNickname: "Alice")
|
||||||
|
|
||||||
|
wait(for: [expectation], timeout: 1.0)
|
||||||
|
XCTAssertTrue(service.isFavorite(peerKey))
|
||||||
|
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Alice")
|
||||||
|
XCTAssertNotNil(keychain.load(key: storageKey, service: serviceKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_removeFavorite_preservesRelationshipWhenPeerStillFavoritesUs() {
|
||||||
|
let service = FavoritesPersistenceService(keychain: MockKeychain())
|
||||||
|
let peerKey = Data((32..<64).map(UInt8.init))
|
||||||
|
|
||||||
|
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Bob")
|
||||||
|
service.addFavorite(peerNoisePublicKey: peerKey, peerNickname: "Bob")
|
||||||
|
service.removeFavorite(peerNoisePublicKey: peerKey)
|
||||||
|
|
||||||
|
let relationship = service.getFavoriteStatus(for: peerKey)
|
||||||
|
XCTAssertNotNil(relationship)
|
||||||
|
XCTAssertEqual(relationship?.peerNickname, "Bob")
|
||||||
|
XCTAssertFalse(relationship?.isFavorite ?? true)
|
||||||
|
XCTAssertTrue(relationship?.theyFavoritedUs ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_updatePeerFavoritedUs_removesRelationshipWhenNeitherSideFavorites() {
|
||||||
|
let service = FavoritesPersistenceService(keychain: MockKeychain())
|
||||||
|
let peerKey = Data((64..<96).map(UInt8.init))
|
||||||
|
|
||||||
|
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Carol")
|
||||||
|
XCTAssertNotNil(service.getFavoriteStatus(for: peerKey))
|
||||||
|
|
||||||
|
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: false, peerNickname: "Carol")
|
||||||
|
|
||||||
|
XCTAssertNil(service.getFavoriteStatus(for: peerKey))
|
||||||
|
XCTAssertFalse(service.isMutualFavorite(peerKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_getFavoriteStatus_forPeerID_returnsMutualFavorite() {
|
||||||
|
let service = FavoritesPersistenceService(keychain: MockKeychain())
|
||||||
|
let peerKey = Data((96..<128).map(UInt8.init))
|
||||||
|
|
||||||
|
service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1dan", peerNickname: "Dan")
|
||||||
|
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Dan")
|
||||||
|
|
||||||
|
let relationship = service.getFavoriteStatus(forPeerID: PeerID(publicKey: peerKey))
|
||||||
|
XCTAssertEqual(relationship?.peerNickname, "Dan")
|
||||||
|
XCTAssertTrue(service.isMutualFavorite(peerKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_init_deduplicatesPersistedRelationshipsByPublicKey() throws {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerKey = Data((128..<160).map(UInt8.init))
|
||||||
|
let older = FavoritesPersistenceService.FavoriteRelationship(
|
||||||
|
peerNoisePublicKey: peerKey,
|
||||||
|
peerNostrPublicKey: nil,
|
||||||
|
peerNickname: "Older",
|
||||||
|
isFavorite: true,
|
||||||
|
theyFavoritedUs: false,
|
||||||
|
favoritedAt: Date(timeIntervalSince1970: 100),
|
||||||
|
lastUpdated: Date(timeIntervalSince1970: 100)
|
||||||
|
)
|
||||||
|
let newer = FavoritesPersistenceService.FavoriteRelationship(
|
||||||
|
peerNoisePublicKey: peerKey,
|
||||||
|
peerNostrPublicKey: "npub1newer",
|
||||||
|
peerNickname: "Newer",
|
||||||
|
isFavorite: true,
|
||||||
|
theyFavoritedUs: true,
|
||||||
|
favoritedAt: Date(timeIntervalSince1970: 100),
|
||||||
|
lastUpdated: Date(timeIntervalSince1970: 200)
|
||||||
|
)
|
||||||
|
let encoded = try JSONEncoder().encode([older, newer])
|
||||||
|
keychain.save(key: storageKey, data: encoded, service: serviceKey, accessible: nil)
|
||||||
|
|
||||||
|
let service = FavoritesPersistenceService(keychain: keychain)
|
||||||
|
|
||||||
|
XCTAssertEqual(service.favorites.count, 1)
|
||||||
|
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Newer")
|
||||||
|
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNostrPublicKey, "npub1newer")
|
||||||
|
|
||||||
|
let cleaned = try XCTUnwrap(keychain.load(key: storageKey, service: serviceKey))
|
||||||
|
let decoded = try JSONDecoder().decode([FavoritesPersistenceService.FavoriteRelationship].self, from: cleaned)
|
||||||
|
XCTAssertEqual(decoded.count, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import Combine
|
||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class GeohashPresenceServiceTests: XCTestCase {
|
||||||
|
func test_start_schedulesHeartbeatUsingConfiguredInterval() {
|
||||||
|
let scheduler = MockGeohashPresenceScheduler()
|
||||||
|
let service = makeService(scheduler: scheduler, loopMinInterval: 42, loopMaxInterval: 42)
|
||||||
|
|
||||||
|
service.start()
|
||||||
|
|
||||||
|
XCTAssertEqual(scheduler.intervals, [42])
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_handleLocationChange_invalidatesExistingTimerAndSchedulesQuickRefresh() {
|
||||||
|
let scheduler = MockGeohashPresenceScheduler()
|
||||||
|
let service = makeService(scheduler: scheduler, loopMinInterval: 40, loopMaxInterval: 40)
|
||||||
|
|
||||||
|
service.start()
|
||||||
|
let originalTimer = scheduler.timers.first
|
||||||
|
|
||||||
|
service.handleLocationChange()
|
||||||
|
|
||||||
|
XCTAssertEqual(scheduler.intervals, [40, 5])
|
||||||
|
XCTAssertEqual(originalTimer?.invalidateCallCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_handleConnectivityChange_onlySchedulesWhenExistingTimerIsMissingOrInvalid() {
|
||||||
|
let scheduler = MockGeohashPresenceScheduler()
|
||||||
|
let service = makeService(scheduler: scheduler, loopMinInterval: 33, loopMaxInterval: 33)
|
||||||
|
|
||||||
|
service.start()
|
||||||
|
service.handleConnectivityChange()
|
||||||
|
XCTAssertEqual(scheduler.intervals, [33])
|
||||||
|
|
||||||
|
scheduler.timers.last?.invalidate()
|
||||||
|
service.handleConnectivityChange()
|
||||||
|
XCTAssertEqual(scheduler.intervals, [33, 33])
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_performHeartbeat_broadcastsOnlyAllowedPrecisionChannels() async throws {
|
||||||
|
let identity = try NostrIdentity.generate()
|
||||||
|
let scheduler = MockGeohashPresenceScheduler()
|
||||||
|
var sentGeohashes: [String] = []
|
||||||
|
var lookedUpGeohashes: [String] = []
|
||||||
|
var sleptNanoseconds: [UInt64] = []
|
||||||
|
let channels = [
|
||||||
|
GeohashChannel(level: .region, geohash: "9q"),
|
||||||
|
GeohashChannel(level: .province, geohash: "9q8y"),
|
||||||
|
GeohashChannel(level: .city, geohash: "9q8yy"),
|
||||||
|
GeohashChannel(level: .neighborhood, geohash: "9q8yyk"),
|
||||||
|
GeohashChannel(level: .block, geohash: "9q8yyk8"),
|
||||||
|
GeohashChannel(level: .building, geohash: "9q8yyk8y")
|
||||||
|
]
|
||||||
|
let service = makeService(
|
||||||
|
scheduler: scheduler,
|
||||||
|
availableChannels: channels,
|
||||||
|
deriveIdentity: { _ in identity },
|
||||||
|
relayLookup: { geohash, _ in
|
||||||
|
lookedUpGeohashes.append(geohash)
|
||||||
|
return ["wss://\(geohash).example"]
|
||||||
|
},
|
||||||
|
relaySender: { event, _ in
|
||||||
|
let geohash = event.tags.first(where: { $0.first == "g" })?[1]
|
||||||
|
if let geohash {
|
||||||
|
sentGeohashes.append(geohash)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sleeper: { nanoseconds in
|
||||||
|
sleptNanoseconds.append(nanoseconds)
|
||||||
|
},
|
||||||
|
loopMinInterval: 17,
|
||||||
|
loopMaxInterval: 17,
|
||||||
|
burstMinDelay: 0,
|
||||||
|
burstMaxDelay: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
service.performHeartbeat()
|
||||||
|
|
||||||
|
let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 }
|
||||||
|
XCTAssertTrue(sentAllAllowedChannels)
|
||||||
|
XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
||||||
|
XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
||||||
|
XCTAssertEqual(sleptNanoseconds.count, 3)
|
||||||
|
XCTAssertEqual(scheduler.intervals, [17])
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async {
|
||||||
|
let scheduler = MockGeohashPresenceScheduler()
|
||||||
|
var sendCount = 0
|
||||||
|
let service = makeService(
|
||||||
|
scheduler: scheduler,
|
||||||
|
torIsReady: { false },
|
||||||
|
relaySender: { _, _ in sendCount += 1 },
|
||||||
|
loopMinInterval: 21,
|
||||||
|
loopMaxInterval: 21
|
||||||
|
)
|
||||||
|
|
||||||
|
service.performHeartbeat()
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
|
XCTAssertEqual(sendCount, 0)
|
||||||
|
XCTAssertEqual(scheduler.intervals, [21])
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async {
|
||||||
|
let scheduler = MockGeohashPresenceScheduler()
|
||||||
|
var sendCount = 0
|
||||||
|
let service = makeService(
|
||||||
|
scheduler: scheduler,
|
||||||
|
torIsForeground: { false },
|
||||||
|
relaySender: { _, _ in sendCount += 1 },
|
||||||
|
loopMinInterval: 22,
|
||||||
|
loopMaxInterval: 22
|
||||||
|
)
|
||||||
|
|
||||||
|
service.performHeartbeat()
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
|
XCTAssertEqual(sendCount, 0)
|
||||||
|
XCTAssertEqual(scheduler.intervals, [22])
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws {
|
||||||
|
let identity = try NostrIdentity.generate()
|
||||||
|
var sendCount = 0
|
||||||
|
let service = makeService(
|
||||||
|
scheduler: MockGeohashPresenceScheduler(),
|
||||||
|
deriveIdentity: { _ in identity },
|
||||||
|
relayLookup: { _, _ in [] },
|
||||||
|
relaySender: { _, _ in sendCount += 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
service.broadcastPresence(for: "9q8yy")
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
|
XCTAssertEqual(sendCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_broadcastPresence_skipsSendWhenIdentityDerivationFails() async {
|
||||||
|
enum PresenceError: Error { case failed }
|
||||||
|
|
||||||
|
var sendCount = 0
|
||||||
|
let service = makeService(
|
||||||
|
scheduler: MockGeohashPresenceScheduler(),
|
||||||
|
deriveIdentity: { _ in throw PresenceError.failed },
|
||||||
|
relaySender: { _, _ in sendCount += 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
service.broadcastPresence(for: "9q8yy")
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
|
XCTAssertEqual(sendCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeService(
|
||||||
|
scheduler: MockGeohashPresenceScheduler,
|
||||||
|
availableChannels: [GeohashChannel] = [
|
||||||
|
GeohashChannel(level: .city, geohash: "9q8yy")
|
||||||
|
],
|
||||||
|
torIsReady: @escaping () -> Bool = { true },
|
||||||
|
torIsForeground: @escaping () -> Bool = { true },
|
||||||
|
deriveIdentity: @escaping (String) throws -> NostrIdentity = { _ in try NostrIdentity.generate() },
|
||||||
|
relayLookup: @escaping (String, Int) -> [String] = { geohash, _ in ["wss://\(geohash).example"] },
|
||||||
|
relaySender: @escaping (NostrEvent, [String]) -> Void = { _, _ in },
|
||||||
|
sleeper: @escaping (UInt64) async -> Void = { _ in },
|
||||||
|
loopMinInterval: TimeInterval = 40,
|
||||||
|
loopMaxInterval: TimeInterval = 40,
|
||||||
|
burstMinDelay: TimeInterval = 0,
|
||||||
|
burstMaxDelay: TimeInterval = 0
|
||||||
|
) -> GeohashPresenceService {
|
||||||
|
let locationSubject = PassthroughSubject<[GeohashChannel], Never>()
|
||||||
|
let torReadySubject = PassthroughSubject<Void, Never>()
|
||||||
|
return GeohashPresenceService(
|
||||||
|
availableChannelsProvider: { availableChannels },
|
||||||
|
locationChanges: locationSubject.eraseToAnyPublisher(),
|
||||||
|
torReadyPublisher: torReadySubject.eraseToAnyPublisher(),
|
||||||
|
torIsReady: torIsReady,
|
||||||
|
torIsForeground: torIsForeground,
|
||||||
|
deriveIdentity: deriveIdentity,
|
||||||
|
relayLookup: relayLookup,
|
||||||
|
relaySender: relaySender,
|
||||||
|
sleeper: sleeper,
|
||||||
|
scheduleTimer: scheduler.schedule(interval:handler:),
|
||||||
|
loopMinInterval: loopMinInterval,
|
||||||
|
loopMaxInterval: loopMaxInterval,
|
||||||
|
burstMinDelay: burstMinDelay,
|
||||||
|
burstMaxDelay: burstMaxDelay
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MockGeohashPresenceScheduler {
|
||||||
|
private(set) var intervals: [TimeInterval] = []
|
||||||
|
private(set) var timers: [MockGeohashPresenceTimer] = []
|
||||||
|
|
||||||
|
func schedule(interval: TimeInterval, handler: @escaping () -> Void) -> GeohashPresenceTimerProtocol {
|
||||||
|
intervals.append(interval)
|
||||||
|
let timer = MockGeohashPresenceTimer(handler: handler)
|
||||||
|
timers.append(timer)
|
||||||
|
return timer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MockGeohashPresenceTimer: GeohashPresenceTimerProtocol {
|
||||||
|
private let handler: () -> Void
|
||||||
|
private(set) var isValid = true
|
||||||
|
private(set) var invalidateCallCount = 0
|
||||||
|
|
||||||
|
init(handler: @escaping () -> Void) {
|
||||||
|
self.handler = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidate() {
|
||||||
|
invalidateCallCount += 1
|
||||||
|
isValid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func fire() {
|
||||||
|
handler()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import CoreLocation
|
||||||
|
import MapKit
|
||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LocationStateManagerTests: XCTestCase {
|
||||||
|
func test_loadPersistedState_normalizesBookmarksAndRestoresTeleportedSelection() async throws {
|
||||||
|
let storage = makeStorage()
|
||||||
|
let selected = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pru"))
|
||||||
|
storage.set(try JSONEncoder().encode(selected), forKey: "locationChannel.selected")
|
||||||
|
storage.set(try JSONEncoder().encode(["u4pru"]), forKey: "locationChannel.teleportedSet")
|
||||||
|
storage.set(try JSONEncoder().encode(["#U4PRU", "u4pru", ""]), forKey: "locationChannel.bookmarks")
|
||||||
|
|
||||||
|
let manager = LocationStateManager(
|
||||||
|
storage: storage,
|
||||||
|
locationManager: MockLocationManager(authorizationStatus: .denied),
|
||||||
|
geocoder: MockLocationGeocoder(),
|
||||||
|
shouldInitializeCoreLocation: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let deniedLoaded = await waitUntil { manager.permissionState == .denied }
|
||||||
|
XCTAssertTrue(deniedLoaded)
|
||||||
|
XCTAssertEqual(manager.bookmarks, ["u4pru"])
|
||||||
|
XCTAssertEqual(manager.selectedChannel, selected)
|
||||||
|
let teleportedLoaded = await waitUntil { manager.teleported }
|
||||||
|
XCTAssertTrue(teleportedLoaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_enableLocationChannels_requestsAuthorizationWhenStatusIsUndetermined() {
|
||||||
|
let locationManager = MockLocationManager(authorizationStatus: .notDetermined)
|
||||||
|
let manager = LocationStateManager(
|
||||||
|
storage: makeStorage(),
|
||||||
|
locationManager: locationManager,
|
||||||
|
geocoder: MockLocationGeocoder(),
|
||||||
|
shouldInitializeCoreLocation: true
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.enableLocationChannels()
|
||||||
|
|
||||||
|
XCTAssertEqual(locationManager.requestAuthorizationCallCount, 1)
|
||||||
|
XCTAssertEqual(locationManager.requestLocationCallCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_enableLocationChannels_requestsOneShotLocationWhenAuthorized() async {
|
||||||
|
let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways)
|
||||||
|
let manager = LocationStateManager(
|
||||||
|
storage: makeStorage(),
|
||||||
|
locationManager: locationManager,
|
||||||
|
geocoder: MockLocationGeocoder(),
|
||||||
|
shouldInitializeCoreLocation: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let authorizedLoaded = await waitUntil { manager.permissionState == .authorized }
|
||||||
|
XCTAssertTrue(authorizedLoaded)
|
||||||
|
|
||||||
|
manager.enableLocationChannels()
|
||||||
|
|
||||||
|
XCTAssertEqual(locationManager.requestLocationCallCount, 1)
|
||||||
|
XCTAssertEqual(manager.permissionState, .authorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_beginAndEndLiveRefresh_adjustLocationManagerMode() async {
|
||||||
|
let locationManager = MockLocationManager(authorizationStatus: .authorizedAlways)
|
||||||
|
let manager = LocationStateManager(
|
||||||
|
storage: makeStorage(),
|
||||||
|
locationManager: locationManager,
|
||||||
|
geocoder: MockLocationGeocoder(),
|
||||||
|
shouldInitializeCoreLocation: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let authorizedLoaded = await waitUntil { manager.permissionState == .authorized }
|
||||||
|
XCTAssertTrue(authorizedLoaded)
|
||||||
|
|
||||||
|
manager.beginLiveRefresh()
|
||||||
|
|
||||||
|
XCTAssertEqual(locationManager.startUpdatingLocationCallCount, 1)
|
||||||
|
XCTAssertEqual(locationManager.requestLocationCallCount, 1)
|
||||||
|
XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyNearestTenMeters)
|
||||||
|
XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterLiveMeters)
|
||||||
|
|
||||||
|
manager.endLiveRefresh()
|
||||||
|
|
||||||
|
XCTAssertEqual(locationManager.stopUpdatingLocationCallCount, 1)
|
||||||
|
XCTAssertEqual(locationManager.desiredAccuracy, kCLLocationAccuracyHundredMeters)
|
||||||
|
XCTAssertEqual(locationManager.distanceFilter, TransportConfig.locationDistanceFilterMeters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_didUpdateLocations_computesChannelsAndReverseGeocodesFriendlyNames() async {
|
||||||
|
let geocoder = MockLocationGeocoder()
|
||||||
|
geocoder.enqueue(
|
||||||
|
placemarks: [
|
||||||
|
makePlacemark(
|
||||||
|
country: "United States",
|
||||||
|
administrativeArea: "Hawaii",
|
||||||
|
locality: "Honolulu",
|
||||||
|
subLocality: "Waikiki",
|
||||||
|
name: "Hilton Hawaiian Village"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
let manager = LocationStateManager(
|
||||||
|
storage: makeStorage(),
|
||||||
|
locationManager: MockLocationManager(authorizationStatus: .authorizedAlways),
|
||||||
|
geocoder: geocoder,
|
||||||
|
shouldInitializeCoreLocation: true
|
||||||
|
)
|
||||||
|
let location = CLLocation(latitude: 21.2850, longitude: -157.8357)
|
||||||
|
|
||||||
|
manager.locationManager(CLLocationManager(), didUpdateLocations: [location])
|
||||||
|
|
||||||
|
let channelsAndNamesLoaded = await waitUntil {
|
||||||
|
manager.availableChannels.count == GeohashChannelLevel.allCases.count &&
|
||||||
|
manager.locationNames[.city] == "Honolulu" &&
|
||||||
|
manager.locationNames[.building] == "Hilton Hawaiian Village"
|
||||||
|
}
|
||||||
|
XCTAssertTrue(channelsAndNamesLoaded)
|
||||||
|
XCTAssertEqual(geocoder.cancelCallCount, 1)
|
||||||
|
XCTAssertEqual(geocoder.reverseRequests.count, 1)
|
||||||
|
XCTAssertEqual(manager.availableChannels.map(\.geohash.count), GeohashChannelLevel.allCases.map(\.precision))
|
||||||
|
XCTAssertEqual(manager.locationNames[.region], "United States")
|
||||||
|
XCTAssertEqual(manager.locationNames[.province], "Hawaii")
|
||||||
|
XCTAssertEqual(manager.locationNames[.city], "Honolulu")
|
||||||
|
XCTAssertEqual(manager.locationNames[.neighborhood], "Waikiki")
|
||||||
|
XCTAssertEqual(manager.locationNames[.block], "Waikiki")
|
||||||
|
XCTAssertEqual(manager.locationNames[.building], "Hilton Hawaiian Village")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_selectingInRegionChannel_clearsTeleportedPersistence() async {
|
||||||
|
let storage = makeStorage()
|
||||||
|
let manager = LocationStateManager(
|
||||||
|
storage: storage,
|
||||||
|
locationManager: MockLocationManager(authorizationStatus: .authorizedAlways),
|
||||||
|
geocoder: MockLocationGeocoder(),
|
||||||
|
shouldInitializeCoreLocation: true
|
||||||
|
)
|
||||||
|
let coordinate = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
|
||||||
|
let cityGeohash = Geohash.encode(
|
||||||
|
latitude: coordinate.latitude,
|
||||||
|
longitude: coordinate.longitude,
|
||||||
|
precision: GeohashChannelLevel.city.precision
|
||||||
|
)
|
||||||
|
let channel = GeohashChannel(level: .city, geohash: cityGeohash)
|
||||||
|
|
||||||
|
manager.locationManager(CLLocationManager(), didUpdateLocations: [CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)])
|
||||||
|
let channelAvailable = await waitUntil { manager.availableChannels.contains(channel) }
|
||||||
|
XCTAssertTrue(channelAvailable)
|
||||||
|
|
||||||
|
manager.markTeleported(for: cityGeohash, true)
|
||||||
|
manager.select(.location(channel))
|
||||||
|
|
||||||
|
let selectionSettled = await waitUntil {
|
||||||
|
manager.selectedChannel == .location(channel) && !manager.teleported
|
||||||
|
}
|
||||||
|
XCTAssertTrue(selectionSettled)
|
||||||
|
|
||||||
|
let reloaded = LocationStateManager(
|
||||||
|
storage: storage,
|
||||||
|
locationManager: MockLocationManager(authorizationStatus: .denied),
|
||||||
|
geocoder: MockLocationGeocoder(),
|
||||||
|
shouldInitializeCoreLocation: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let reloadedDenied = await waitUntil { reloaded.permissionState == .denied }
|
||||||
|
XCTAssertTrue(reloadedDenied)
|
||||||
|
XCTAssertEqual(reloaded.selectedChannel, .location(channel))
|
||||||
|
XCTAssertFalse(reloaded.teleported)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_addBookmark_lowPrecisionResolvesCompositeAdminName() async {
|
||||||
|
let geocoder = MockLocationGeocoder()
|
||||||
|
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")])
|
||||||
|
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Nevada")])
|
||||||
|
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "California")])
|
||||||
|
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Arizona")])
|
||||||
|
geocoder.enqueue(placemarks: [makePlacemark(country: "United States", administrativeArea: "Nevada")])
|
||||||
|
let manager = LocationStateManager(
|
||||||
|
storage: makeStorage(),
|
||||||
|
locationManager: MockLocationManager(authorizationStatus: .denied),
|
||||||
|
geocoder: geocoder,
|
||||||
|
shouldInitializeCoreLocation: false
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.addBookmark("9q")
|
||||||
|
|
||||||
|
let bookmarkResolved = await waitUntil { manager.bookmarkNames["9q"] == "California and Nevada" }
|
||||||
|
XCTAssertTrue(bookmarkResolved)
|
||||||
|
XCTAssertEqual(geocoder.reverseRequests.count, 5)
|
||||||
|
XCTAssertEqual(manager.bookmarks, ["9q"])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeStorage() -> UserDefaults {
|
||||||
|
let suiteName = "LocationStateManagerTests-\(UUID().uuidString)"
|
||||||
|
let storage = UserDefaults(suiteName: suiteName)!
|
||||||
|
storage.removePersistentDomain(forName: suiteName)
|
||||||
|
addTeardownBlock {
|
||||||
|
storage.removePersistentDomain(forName: suiteName)
|
||||||
|
}
|
||||||
|
return storage
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePlacemark(
|
||||||
|
country: String? = nil,
|
||||||
|
administrativeArea: String? = nil,
|
||||||
|
locality: String? = nil,
|
||||||
|
subLocality: String? = nil,
|
||||||
|
name: String? = nil
|
||||||
|
) -> CLPlacemark {
|
||||||
|
var address: [String: Any] = [:]
|
||||||
|
if let country {
|
||||||
|
address["Country"] = country
|
||||||
|
}
|
||||||
|
if let administrativeArea {
|
||||||
|
address["State"] = administrativeArea
|
||||||
|
}
|
||||||
|
if let locality {
|
||||||
|
address["City"] = locality
|
||||||
|
}
|
||||||
|
if let subLocality {
|
||||||
|
address["SubLocality"] = subLocality
|
||||||
|
}
|
||||||
|
if let name {
|
||||||
|
address["Name"] = name
|
||||||
|
}
|
||||||
|
let placemark = MKPlacemark(
|
||||||
|
coordinate: CLLocationCoordinate2D(latitude: 21.2850, longitude: -157.8357),
|
||||||
|
addressDictionary: address
|
||||||
|
)
|
||||||
|
return CLPlacemark(placemark: placemark)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MockLocationManager: LocationStateManaging {
|
||||||
|
weak var delegate: CLLocationManagerDelegate?
|
||||||
|
var desiredAccuracy: CLLocationAccuracy = 0
|
||||||
|
var distanceFilter: CLLocationDistance = 0
|
||||||
|
var authorizationStatus: CLAuthorizationStatus
|
||||||
|
private(set) var requestAuthorizationCallCount = 0
|
||||||
|
private(set) var requestLocationCallCount = 0
|
||||||
|
private(set) var startUpdatingLocationCallCount = 0
|
||||||
|
private(set) var stopUpdatingLocationCallCount = 0
|
||||||
|
|
||||||
|
init(authorizationStatus: CLAuthorizationStatus) {
|
||||||
|
self.authorizationStatus = authorizationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestWhenInUseAuthorization() {
|
||||||
|
requestAuthorizationCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestLocation() {
|
||||||
|
requestLocationCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func startUpdatingLocation() {
|
||||||
|
startUpdatingLocationCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopUpdatingLocation() {
|
||||||
|
stopUpdatingLocationCallCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MockLocationGeocoder: LocationStateGeocoding {
|
||||||
|
private struct Response {
|
||||||
|
let placemarks: [CLPlacemark]?
|
||||||
|
let error: Error?
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var cancelCallCount = 0
|
||||||
|
private(set) var reverseRequests: [CLLocation] = []
|
||||||
|
private var responses: [Response] = []
|
||||||
|
|
||||||
|
func enqueue(placemarks: [CLPlacemark]?, error: Error? = nil) {
|
||||||
|
responses.append(Response(placemarks: placemarks, error: error))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelGeocode() {
|
||||||
|
cancelCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverseGeocodeLocation(
|
||||||
|
_ location: CLLocation,
|
||||||
|
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
|
||||||
|
) {
|
||||||
|
reverseRequests.append(location)
|
||||||
|
let response = responses.isEmpty ? Response(placemarks: nil, error: nil) : responses.removeFirst()
|
||||||
|
completionHandler(response.placemarks, response.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
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 },
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import Combine
|
||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NostrRelayManagerTests: XCTestCase {
|
||||||
|
func test_permissionPublisher_addsAndRemovesDefaultRelays() async {
|
||||||
|
let context = makeContext(permission: .denied, favorites: [])
|
||||||
|
|
||||||
|
XCTAssertEqual(context.manager.getRelayStatuses().count, 0)
|
||||||
|
|
||||||
|
context.permissionSubject.send(.authorized)
|
||||||
|
|
||||||
|
let defaultRelaysConnected = await waitUntil {
|
||||||
|
context.manager.getRelayStatuses().count == 5 &&
|
||||||
|
context.manager.relays.allSatisfy(\.isConnected)
|
||||||
|
}
|
||||||
|
XCTAssertTrue(defaultRelaysConnected)
|
||||||
|
|
||||||
|
context.permissionSubject.send(.denied)
|
||||||
|
|
||||||
|
let defaultRelaysRemoved = await waitUntil {
|
||||||
|
context.manager.getRelayStatuses().isEmpty
|
||||||
|
}
|
||||||
|
XCTAssertTrue(defaultRelaysRemoved)
|
||||||
|
XCTAssertEqual(context.sessionFactory.allConnections.count, 5)
|
||||||
|
XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_connect_waitsForTorReadinessBeforeCreatingSessions() async {
|
||||||
|
let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||||
|
|
||||||
|
context.manager.connect()
|
||||||
|
|
||||||
|
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||||
|
|
||||||
|
context.torWaiter.resolve(true)
|
||||||
|
|
||||||
|
let connectedAfterTorReady = await waitUntil {
|
||||||
|
context.sessionFactory.requestedURLs.count == 5 &&
|
||||||
|
context.manager.relays.allSatisfy(\.isConnected)
|
||||||
|
}
|
||||||
|
XCTAssertTrue(connectedAfterTorReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_connect_doesNothingWhenActivationIsDisallowed() {
|
||||||
|
let context = makeContext(permission: .authorized, activationAllowed: false)
|
||||||
|
|
||||||
|
context.manager.connect()
|
||||||
|
|
||||||
|
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||||
|
XCTAssertFalse(context.manager.isConnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_ensureConnections_deduplicatesRelayURLs() async {
|
||||||
|
let relayOne = "wss://relay-one.example"
|
||||||
|
let relayTwo = "wss://relay-two.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
|
||||||
|
context.manager.ensureConnections(to: [relayOne, relayOne, relayTwo])
|
||||||
|
|
||||||
|
let connected = await waitUntil {
|
||||||
|
Set(context.manager.getRelayStatuses().map(\.url)) == Set([relayOne, relayTwo]) &&
|
||||||
|
context.manager.relays.allSatisfy(\.isConnected)
|
||||||
|
}
|
||||||
|
XCTAssertTrue(connected)
|
||||||
|
XCTAssertEqual(context.sessionFactory.requestedURLs, [relayOne, relayTwo])
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_subscribe_coalescesRapidDuplicateRequests() async {
|
||||||
|
let relayURL = "wss://subscribe.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
let filter = makeFilter()
|
||||||
|
|
||||||
|
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
|
||||||
|
|
||||||
|
let firstSent = await waitUntil {
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||||
|
}
|
||||||
|
XCTAssertTrue(firstSent)
|
||||||
|
|
||||||
|
context.clock.now = context.clock.now.addingTimeInterval(0.5)
|
||||||
|
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
|
||||||
|
|
||||||
|
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_unsubscribe_allowsResubscribeWithSameID() async {
|
||||||
|
let relayURL = "wss://subscribe.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
let filter = makeFilter()
|
||||||
|
|
||||||
|
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
|
||||||
|
let initialSubscribeSent = await waitUntil {
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||||
|
}
|
||||||
|
XCTAssertTrue(initialSubscribeSent)
|
||||||
|
|
||||||
|
context.manager.unsubscribe(id: "sub")
|
||||||
|
let closeSent = await waitUntil {
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 2
|
||||||
|
}
|
||||||
|
XCTAssertTrue(closeSent)
|
||||||
|
|
||||||
|
context.clock.now = context.clock.now.addingTimeInterval(0.2)
|
||||||
|
context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in })
|
||||||
|
|
||||||
|
let resubscribed = await waitUntil {
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 3
|
||||||
|
}
|
||||||
|
XCTAssertTrue(resubscribed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_receiveEvent_deliversHandlerAndTracksReceivedCount() async throws {
|
||||||
|
let relayURL = "wss://events.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
let filter = makeFilter()
|
||||||
|
let event = try makeSignedEvent(content: "hello")
|
||||||
|
var receivedEvent: NostrEvent?
|
||||||
|
|
||||||
|
context.manager.subscribe(filter: filter, id: "events", relayUrls: [relayURL]) { event in
|
||||||
|
receivedEvent = event
|
||||||
|
}
|
||||||
|
let subscriptionSent = await waitUntil {
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||||
|
}
|
||||||
|
XCTAssertTrue(subscriptionSent)
|
||||||
|
|
||||||
|
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||||
|
|
||||||
|
let delivered = await waitUntil {
|
||||||
|
receivedEvent?.id == event.id &&
|
||||||
|
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
|
||||||
|
}
|
||||||
|
XCTAssertTrue(delivered)
|
||||||
|
XCTAssertEqual(receivedEvent?.id, event.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_eoseCallback_waitsForAllTargetedRelays() async throws {
|
||||||
|
let relayOne = "wss://one.example"
|
||||||
|
let relayTwo = "wss://two.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
var eoseCount = 0
|
||||||
|
|
||||||
|
context.manager.subscribe(
|
||||||
|
filter: makeFilter(),
|
||||||
|
id: "eose",
|
||||||
|
relayUrls: [relayOne, relayTwo],
|
||||||
|
handler: { _ in },
|
||||||
|
onEOSE: { eoseCount += 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
let bothConnected = await waitUntil {
|
||||||
|
context.sessionFactory.latestConnection(for: relayOne)?.sentStrings.count == 1 &&
|
||||||
|
context.sessionFactory.latestConnection(for: relayTwo)?.sentStrings.count == 1
|
||||||
|
}
|
||||||
|
XCTAssertTrue(bothConnected)
|
||||||
|
|
||||||
|
try context.sessionFactory.latestConnection(for: relayOne)?.emitEOSE(subscriptionID: "eose")
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
XCTAssertEqual(eoseCount, 0)
|
||||||
|
|
||||||
|
try context.sessionFactory.latestConnection(for: relayTwo)?.emitEOSE(subscriptionID: "eose")
|
||||||
|
|
||||||
|
let eoseCompleted = await waitUntil { eoseCount == 1 }
|
||||||
|
XCTAssertTrue(eoseCompleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_receiveFailure_schedulesReconnectWithBackoff() async {
|
||||||
|
let relayURL = "wss://retry.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
|
||||||
|
context.manager.ensureConnections(to: [relayURL])
|
||||||
|
let firstConnected = await waitUntil {
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL) != nil
|
||||||
|
}
|
||||||
|
XCTAssertTrue(firstConnected)
|
||||||
|
|
||||||
|
let firstConnection = context.sessionFactory.latestConnection(for: relayURL)
|
||||||
|
firstConnection?.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut))
|
||||||
|
|
||||||
|
let retryScheduled = await waitUntil {
|
||||||
|
context.scheduler.scheduled.count == 1 &&
|
||||||
|
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 1
|
||||||
|
}
|
||||||
|
XCTAssertTrue(retryScheduled)
|
||||||
|
XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrRelayInitialBackoffSeconds)
|
||||||
|
|
||||||
|
let initialRequestCount = context.sessionFactory.requestedURLs.count
|
||||||
|
context.scheduler.runNext()
|
||||||
|
|
||||||
|
let retried = await waitUntil {
|
||||||
|
context.sessionFactory.requestedURLs.count == initialRequestCount + 1
|
||||||
|
}
|
||||||
|
XCTAssertTrue(retried)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_disconnect_invalidatesScheduledReconnectGeneration() async {
|
||||||
|
let relayURL = "wss://disconnect.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
|
||||||
|
context.manager.ensureConnections(to: [relayURL])
|
||||||
|
let firstConnected = await waitUntil {
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL) != nil
|
||||||
|
}
|
||||||
|
XCTAssertTrue(firstConnected)
|
||||||
|
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)?.fail(
|
||||||
|
error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
|
||||||
|
)
|
||||||
|
let retryScheduled = await waitUntil { context.scheduler.scheduled.count == 1 }
|
||||||
|
XCTAssertTrue(retryScheduled)
|
||||||
|
|
||||||
|
let requestCountBeforeDisconnect = context.sessionFactory.requestedURLs.count
|
||||||
|
context.manager.disconnect()
|
||||||
|
context.scheduler.runNext()
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
|
XCTAssertEqual(context.sessionFactory.requestedURLs.count, requestCountBeforeDisconnect)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeContext(
|
||||||
|
permission: LocationChannelManager.PermissionState,
|
||||||
|
favorites: Set<Data> = [],
|
||||||
|
activationAllowed: Bool = true,
|
||||||
|
userTorEnabled: Bool = false,
|
||||||
|
torEnforced: Bool = false,
|
||||||
|
torIsReady: Bool = true
|
||||||
|
) -> RelayManagerTestContext {
|
||||||
|
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
|
||||||
|
let favoritesSubject = CurrentValueSubject<Set<Data>, Never>(favorites)
|
||||||
|
let sessionFactory = MockRelaySessionFactory()
|
||||||
|
let scheduler = MockRelayScheduler()
|
||||||
|
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
|
||||||
|
let torWaiter = MockTorWaiter(isReady: torIsReady)
|
||||||
|
let manager = NostrRelayManager(
|
||||||
|
dependencies: NostrRelayManagerDependencies(
|
||||||
|
activationAllowed: { activationAllowed },
|
||||||
|
userTorEnabled: { userTorEnabled },
|
||||||
|
hasMutualFavorites: { !favoritesSubject.value.isEmpty },
|
||||||
|
hasLocationPermission: { permissionSubject.value == .authorized },
|
||||||
|
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
||||||
|
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
||||||
|
torEnforced: { torEnforced },
|
||||||
|
torIsReady: { torWaiter.isReady },
|
||||||
|
torIsForeground: { true },
|
||||||
|
awaitTorReady: torWaiter.await(completion:),
|
||||||
|
makeSession: { sessionFactory },
|
||||||
|
scheduleAfter: scheduler.schedule(delay:action:),
|
||||||
|
now: { clock.now }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return RelayManagerTestContext(
|
||||||
|
manager: manager,
|
||||||
|
permissionSubject: permissionSubject,
|
||||||
|
favoritesSubject: favoritesSubject,
|
||||||
|
sessionFactory: sessionFactory,
|
||||||
|
scheduler: scheduler,
|
||||||
|
clock: clock,
|
||||||
|
torWaiter: torWaiter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeFilter() -> NostrFilter {
|
||||||
|
var filter = NostrFilter()
|
||||||
|
filter.kinds = [NostrProtocol.EventKind.textNote.rawValue]
|
||||||
|
filter.limit = 10
|
||||||
|
return filter
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSignedEvent(content: String) throws -> NostrEvent {
|
||||||
|
let identity = try NostrIdentity.generate()
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: identity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .textNote,
|
||||||
|
tags: [],
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
return try event.sign(with: identity.schnorrSigningKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 RelayManagerTestContext {
|
||||||
|
let manager: NostrRelayManager
|
||||||
|
let permissionSubject: CurrentValueSubject<LocationChannelManager.PermissionState, Never>
|
||||||
|
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
|
||||||
|
let sessionFactory: MockRelaySessionFactory
|
||||||
|
let scheduler: MockRelayScheduler
|
||||||
|
let clock: MutableClock
|
||||||
|
let torWaiter: MockTorWaiter
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MutableClock {
|
||||||
|
var now: Date
|
||||||
|
|
||||||
|
init(now: Date) {
|
||||||
|
self.now = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MockTorWaiter {
|
||||||
|
private var completions: [(Bool) -> Void] = []
|
||||||
|
var isReady: Bool
|
||||||
|
|
||||||
|
init(isReady: Bool) {
|
||||||
|
self.isReady = isReady
|
||||||
|
}
|
||||||
|
|
||||||
|
func await(completion: @escaping (Bool) -> Void) {
|
||||||
|
completions.append(completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolve(_ ready: Bool) {
|
||||||
|
isReady = ready
|
||||||
|
let pending = completions
|
||||||
|
completions.removeAll()
|
||||||
|
pending.forEach { $0(ready) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MockRelayScheduler {
|
||||||
|
struct ScheduledAction {
|
||||||
|
let delay: TimeInterval
|
||||||
|
let action: () -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var scheduled: [ScheduledAction] = []
|
||||||
|
|
||||||
|
func schedule(delay: TimeInterval, action: @escaping () -> Void) {
|
||||||
|
scheduled.append(ScheduledAction(delay: delay, action: action))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNext() {
|
||||||
|
guard !scheduled.isEmpty else { return }
|
||||||
|
let next = scheduled.removeFirst()
|
||||||
|
next.action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
|
||||||
|
private(set) var requestedURLs: [String] = []
|
||||||
|
private(set) var connectionsByURL: [String: [MockRelayConnection]] = [:]
|
||||||
|
|
||||||
|
var allConnections: [MockRelayConnection] {
|
||||||
|
connectionsByURL.values.flatMap { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
|
||||||
|
requestedURLs.append(url.absoluteString)
|
||||||
|
let connection = MockRelayConnection(url: url.absoluteString)
|
||||||
|
connectionsByURL[url.absoluteString, default: []].append(connection)
|
||||||
|
return connection
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestConnection(for url: String) -> MockRelayConnection? {
|
||||||
|
connectionsByURL[url]?.last
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||||
|
private let url: String
|
||||||
|
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||||
|
private(set) var resumeCallCount = 0
|
||||||
|
private(set) var cancelCallCount = 0
|
||||||
|
private(set) var sentMessages: [URLSessionWebSocketTask.Message] = []
|
||||||
|
|
||||||
|
var sentStrings: [String] {
|
||||||
|
sentMessages.compactMap {
|
||||||
|
switch $0 {
|
||||||
|
case .string(let string): string
|
||||||
|
case .data(let data): String(data: data, encoding: .utf8)
|
||||||
|
@unknown default: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(url: String) {
|
||||||
|
self.url = url
|
||||||
|
}
|
||||||
|
|
||||||
|
func resume() {
|
||||||
|
resumeCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||||
|
cancelCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
||||||
|
sentMessages.append(message)
|
||||||
|
completionHandler(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||||
|
receiveHandler = completionHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
|
||||||
|
pongReceiveHandler(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(error: Error) {
|
||||||
|
let handler = receiveHandler
|
||||||
|
receiveHandler = nil
|
||||||
|
handler?(.failure(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitEventMessage(subscriptionID: String, event: NostrEvent) throws {
|
||||||
|
let eventData = try JSONEncoder().encode(event)
|
||||||
|
let eventJSONObject = try JSONSerialization.jsonObject(with: eventData) as! [String: Any]
|
||||||
|
let payload: [Any] = ["EVENT", subscriptionID, eventJSONObject]
|
||||||
|
try emit(jsonObject: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitEOSE(subscriptionID: String) throws {
|
||||||
|
try emit(jsonObject: ["EOSE", subscriptionID])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emit(jsonObject: Any) throws {
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: jsonObject)
|
||||||
|
let handler = receiveHandler
|
||||||
|
receiveHandler = nil
|
||||||
|
handler?(.success(.data(data)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import XCTest
|
||||||
|
import UserNotifications
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class NotificationServiceTests: XCTestCase {
|
||||||
|
func test_requestAuthorization_skipsWhenRunningTests() {
|
||||||
|
let authorizer = RecordingNotificationAuthorizer()
|
||||||
|
let service = NotificationService(
|
||||||
|
isRunningTestsProvider: { true },
|
||||||
|
authorizer: authorizer,
|
||||||
|
requestDeliverer: RecordingNotificationRequestDeliverer()
|
||||||
|
)
|
||||||
|
|
||||||
|
service.requestAuthorization()
|
||||||
|
|
||||||
|
XCTAssertEqual(authorizer.requestCallCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_requestAuthorization_requestsAlertSoundAndBadgePermissions() {
|
||||||
|
let authorizer = RecordingNotificationAuthorizer()
|
||||||
|
let service = NotificationService(
|
||||||
|
isRunningTestsProvider: { false },
|
||||||
|
authorizer: authorizer,
|
||||||
|
requestDeliverer: RecordingNotificationRequestDeliverer()
|
||||||
|
)
|
||||||
|
|
||||||
|
service.requestAuthorization()
|
||||||
|
|
||||||
|
XCTAssertEqual(authorizer.requestCallCount, 1)
|
||||||
|
XCTAssertEqual(authorizer.lastOptions, [.alert, .sound, .badge])
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_sendLocalNotification_buildsImmediateRequestWithUserInfo() {
|
||||||
|
let deliverer = RecordingNotificationRequestDeliverer()
|
||||||
|
let service = NotificationService(
|
||||||
|
isRunningTestsProvider: { false },
|
||||||
|
authorizer: RecordingNotificationAuthorizer(),
|
||||||
|
requestDeliverer: deliverer
|
||||||
|
)
|
||||||
|
|
||||||
|
service.sendLocalNotification(
|
||||||
|
title: "Hello",
|
||||||
|
body: "World",
|
||||||
|
identifier: "custom-id",
|
||||||
|
userInfo: ["peerID": "abcd"],
|
||||||
|
interruptionLevel: .timeSensitive
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = deliverer.requests.singleValue
|
||||||
|
XCTAssertEqual(request?.identifier, "custom-id")
|
||||||
|
XCTAssertEqual(request?.content.title, "Hello")
|
||||||
|
XCTAssertEqual(request?.content.body, "World")
|
||||||
|
XCTAssertEqual(request?.content.userInfo["peerID"] as? String, "abcd")
|
||||||
|
XCTAssertEqual(request?.content.interruptionLevel, .timeSensitive)
|
||||||
|
XCTAssertNil(request?.trigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_sendPrivateMessageNotification_populatesPeerMetadata() {
|
||||||
|
let deliverer = RecordingNotificationRequestDeliverer()
|
||||||
|
let service = NotificationService(
|
||||||
|
isRunningTestsProvider: { false },
|
||||||
|
authorizer: RecordingNotificationAuthorizer(),
|
||||||
|
requestDeliverer: deliverer
|
||||||
|
)
|
||||||
|
let peerID = PeerID(str: "deadbeefdeadbeef")
|
||||||
|
|
||||||
|
service.sendPrivateMessageNotification(from: "Alice", message: "hi", peerID: peerID)
|
||||||
|
|
||||||
|
let request = deliverer.requests.singleValue
|
||||||
|
XCTAssertEqual(request?.content.title, "🔒 DM from Alice")
|
||||||
|
XCTAssertEqual(request?.content.body, "hi")
|
||||||
|
XCTAssertEqual(request?.content.userInfo["peerID"] as? String, peerID.id)
|
||||||
|
XCTAssertEqual(request?.content.userInfo["senderName"] as? String, "Alice")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_wrapperNotifications_setExpectedIdentifiersAndDeepLinks() {
|
||||||
|
let deliverer = RecordingNotificationRequestDeliverer()
|
||||||
|
let service = NotificationService(
|
||||||
|
isRunningTestsProvider: { false },
|
||||||
|
authorizer: RecordingNotificationAuthorizer(),
|
||||||
|
requestDeliverer: deliverer
|
||||||
|
)
|
||||||
|
|
||||||
|
service.sendGeohashActivityNotification(geohash: "87yv", bodyPreview: "Someone is here")
|
||||||
|
service.sendNetworkAvailableNotification(peerCount: 2)
|
||||||
|
|
||||||
|
XCTAssertEqual(deliverer.requests.count, 2)
|
||||||
|
XCTAssertEqual(deliverer.requests[0].content.userInfo["deeplink"] as? String, "bitchat://geohash/87yv")
|
||||||
|
XCTAssertTrue(deliverer.requests[0].identifier.hasPrefix("geo-activity-87yv-"))
|
||||||
|
XCTAssertEqual(deliverer.requests[1].identifier, "network-available")
|
||||||
|
XCTAssertEqual(deliverer.requests[1].content.interruptionLevel, .timeSensitive)
|
||||||
|
XCTAssertEqual(deliverer.requests[1].content.body, "2 people around")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class RecordingNotificationAuthorizer: NotificationAuthorizing {
|
||||||
|
private(set) var requestCallCount = 0
|
||||||
|
private(set) var lastOptions: UNAuthorizationOptions?
|
||||||
|
|
||||||
|
func requestAuthorization(
|
||||||
|
options: UNAuthorizationOptions,
|
||||||
|
completionHandler: @escaping (Bool, Error?) -> Void
|
||||||
|
) {
|
||||||
|
requestCallCount += 1
|
||||||
|
lastOptions = options
|
||||||
|
completionHandler(true, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class RecordingNotificationRequestDeliverer: NotificationRequestDelivering {
|
||||||
|
private(set) var requests: [UNNotificationRequest] = []
|
||||||
|
|
||||||
|
func add(_ request: UNNotificationRequest) {
|
||||||
|
requests.append(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Array {
|
||||||
|
var singleValue: Element? {
|
||||||
|
count == 1 ? self[0] : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class SecureIdentityStateManagerTests: XCTestCase {
|
||||||
|
func test_upsertCryptographicIdentity_tracksByPeerIDPrefixAndClaimedNickname() async {
|
||||||
|
let manager = SecureIdentityStateManager(MockKeychain())
|
||||||
|
let noisePublicKey = Data(repeating: 0x11, count: 32)
|
||||||
|
let signingPublicKey = Data(repeating: 0x22, count: 32)
|
||||||
|
let fingerprint = noisePublicKey.sha256Fingerprint()
|
||||||
|
|
||||||
|
manager.upsertCryptographicIdentity(
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
noisePublicKey: noisePublicKey,
|
||||||
|
signingPublicKey: signingPublicKey,
|
||||||
|
claimedNickname: "Alice"
|
||||||
|
)
|
||||||
|
|
||||||
|
let socialIdentityLoaded = await waitUntil {
|
||||||
|
manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "Alice"
|
||||||
|
}
|
||||||
|
XCTAssertTrue(socialIdentityLoaded)
|
||||||
|
let matches = manager.getCryptoIdentitiesByPeerIDPrefix(PeerID(publicKey: noisePublicKey))
|
||||||
|
XCTAssertEqual(matches.count, 1)
|
||||||
|
XCTAssertEqual(matches.first?.fingerprint, fingerprint)
|
||||||
|
XCTAssertEqual(matches.first?.publicKey, noisePublicKey)
|
||||||
|
XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_setBlocked_clearsFavoriteState() async {
|
||||||
|
let manager = SecureIdentityStateManager(MockKeychain())
|
||||||
|
let fingerprint = String(repeating: "ab", count: 32)
|
||||||
|
|
||||||
|
manager.setFavorite(fingerprint, isFavorite: true)
|
||||||
|
let favoriteSet = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
|
||||||
|
XCTAssertTrue(favoriteSet)
|
||||||
|
|
||||||
|
manager.setBlocked(fingerprint, isBlocked: true)
|
||||||
|
let blockedSet = await waitUntil { manager.isBlocked(fingerprint: fingerprint) }
|
||||||
|
XCTAssertTrue(blockedSet)
|
||||||
|
|
||||||
|
XCTAssertFalse(manager.isFavorite(fingerprint: fingerprint))
|
||||||
|
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_setVerified_updatesTrustLevelAndVerifiedSet() async {
|
||||||
|
let manager = SecureIdentityStateManager(MockKeychain())
|
||||||
|
let fingerprint = String(repeating: "cd", count: 32)
|
||||||
|
|
||||||
|
manager.setFavorite(fingerprint, isFavorite: false)
|
||||||
|
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
|
||||||
|
manager.setVerified(fingerprint: fingerprint, verified: true)
|
||||||
|
|
||||||
|
let verifiedSet = await waitUntil { manager.isVerified(fingerprint: fingerprint) }
|
||||||
|
XCTAssertTrue(verifiedSet)
|
||||||
|
XCTAssertTrue(manager.getVerifiedFingerprints().contains(fingerprint))
|
||||||
|
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.trustLevel, .verified)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_forceSave_persistsFavoriteStateAcrossReinit() async {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let manager = SecureIdentityStateManager(keychain)
|
||||||
|
let fingerprint = String(repeating: "ef", count: 32)
|
||||||
|
|
||||||
|
manager.setFavorite(fingerprint, isFavorite: true)
|
||||||
|
let favoriteSet = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
|
||||||
|
XCTAssertTrue(favoriteSet)
|
||||||
|
manager.forceSave()
|
||||||
|
|
||||||
|
let reloaded = SecureIdentityStateManager(keychain)
|
||||||
|
XCTAssertTrue(reloaded.isFavorite(fingerprint: fingerprint))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_setNostrBlocked_normalizesToLowercaseAndPersists() async {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let manager = SecureIdentityStateManager(keychain)
|
||||||
|
let pubkey = "ABCDEF1234"
|
||||||
|
|
||||||
|
manager.setNostrBlocked(pubkey, isBlocked: true)
|
||||||
|
let nostrBlocked = await waitUntil {
|
||||||
|
manager.isNostrBlocked(pubkeyHexLowercased: pubkey.lowercased())
|
||||||
|
}
|
||||||
|
XCTAssertTrue(nostrBlocked)
|
||||||
|
manager.forceSave()
|
||||||
|
|
||||||
|
let reloaded = SecureIdentityStateManager(keychain)
|
||||||
|
XCTAssertEqual(reloaded.getBlockedNostrPubkeys(), Set([pubkey.lowercased()]))
|
||||||
|
XCTAssertTrue(reloaded.isNostrBlocked(pubkeyHexLowercased: pubkey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_corruptPersistedCache_fallsBackToEmptyState() {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
_ = keychain.saveIdentityKey(Data(repeating: 0x01, count: 32), forKey: "identityCacheEncryptionKey")
|
||||||
|
_ = keychain.saveIdentityKey(Data([0xFF, 0x00, 0xAA]), forKey: "bitchat.identityCache.v2")
|
||||||
|
|
||||||
|
let manager = SecureIdentityStateManager(keychain)
|
||||||
|
|
||||||
|
XCTAssertTrue(manager.getFavorites().isEmpty)
|
||||||
|
XCTAssertTrue(manager.getVerifiedFingerprints().isEmpty)
|
||||||
|
XCTAssertTrue(manager.getBlockedNostrPubkeys().isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_clearAllIdentityData_removesCachedState() async {
|
||||||
|
let manager = SecureIdentityStateManager(MockKeychain())
|
||||||
|
let fingerprint = String(repeating: "12", count: 32)
|
||||||
|
|
||||||
|
manager.setFavorite(fingerprint, isFavorite: true)
|
||||||
|
manager.setVerified(fingerprint: fingerprint, verified: true)
|
||||||
|
manager.setNostrBlocked("ABCD", isBlocked: true)
|
||||||
|
let primed = await waitUntil {
|
||||||
|
manager.isFavorite(fingerprint: fingerprint) &&
|
||||||
|
manager.isVerified(fingerprint: fingerprint)
|
||||||
|
}
|
||||||
|
XCTAssertTrue(primed)
|
||||||
|
|
||||||
|
manager.clearAllIdentityData()
|
||||||
|
let cleared = await waitUntil {
|
||||||
|
!manager.isFavorite(fingerprint: fingerprint) &&
|
||||||
|
!manager.isVerified(fingerprint: fingerprint) &&
|
||||||
|
manager.getBlockedNostrPubkeys().isEmpty
|
||||||
|
}
|
||||||
|
XCTAssertTrue(cleared)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitUntil(
|
||||||
|
timeout: TimeInterval = 1.0,
|
||||||
|
condition: @escaping () -> 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class VerificationServiceTests: XCTestCase {
|
||||||
|
func test_buildMyQRString_roundTripsSuccessfully() throws {
|
||||||
|
let (service, noise) = makeService()
|
||||||
|
let nickname = "alice-\(UUID().uuidString)"
|
||||||
|
let npub = "npub1testvalue"
|
||||||
|
|
||||||
|
let qrString = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: npub))
|
||||||
|
let parsed = try XCTUnwrap(service.verifyScannedQR(qrString))
|
||||||
|
|
||||||
|
XCTAssertEqual(parsed.nickname, nickname)
|
||||||
|
XCTAssertEqual(parsed.npub, npub)
|
||||||
|
XCTAssertEqual(parsed.noiseKeyHex, noise.getStaticPublicKeyData().hexEncodedString())
|
||||||
|
XCTAssertEqual(parsed.signKeyHex, noise.getSigningPublicKeyData().hexEncodedString())
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_buildMyQRString_returnsCachedValueForSameInputs() throws {
|
||||||
|
let (service, _) = makeService()
|
||||||
|
let nickname = "cache-\(UUID().uuidString)"
|
||||||
|
|
||||||
|
let first = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: nil))
|
||||||
|
let second = try XCTUnwrap(service.buildMyQRString(nickname: nickname, npub: nil))
|
||||||
|
|
||||||
|
XCTAssertEqual(first, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_verifyScannedQR_rejectsExpiredPayload() throws {
|
||||||
|
let (service, noise) = makeService()
|
||||||
|
let oldTimestamp = Int64(Date().addingTimeInterval(-3600).timeIntervalSince1970)
|
||||||
|
let qrString = try makeSignedQR(
|
||||||
|
noise: noise,
|
||||||
|
nickname: "expired-\(UUID().uuidString)",
|
||||||
|
npub: nil,
|
||||||
|
ts: oldTimestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_verifyScannedQR_rejectsTamperedSignature() throws {
|
||||||
|
let (service, noise) = makeService()
|
||||||
|
let badSignature = Data(repeating: 0xAA, count: 64)
|
||||||
|
let qrString = try makeSignedQR(
|
||||||
|
noise: noise,
|
||||||
|
nickname: "tampered-\(UUID().uuidString)",
|
||||||
|
npub: nil,
|
||||||
|
ts: Int64(Date().timeIntervalSince1970),
|
||||||
|
signatureOverride: badSignature
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertNil(service.verifyScannedQR(qrString))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_buildVerifyChallenge_roundTripsThroughNoisePayload() throws {
|
||||||
|
let (service, _) = makeService()
|
||||||
|
let noiseKeyHex = String(repeating: "ab", count: 32)
|
||||||
|
let nonce = Data([0x01, 0x02, 0x03, 0x04])
|
||||||
|
|
||||||
|
let encoded = service.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonce)
|
||||||
|
let payload = try XCTUnwrap(NoisePayload.decode(encoded))
|
||||||
|
let parsed = try XCTUnwrap(service.parseVerifyChallenge(payload.data))
|
||||||
|
|
||||||
|
XCTAssertEqual(payload.type, .verifyChallenge)
|
||||||
|
XCTAssertEqual(parsed.noiseKeyHex, noiseKeyHex)
|
||||||
|
XCTAssertEqual(parsed.nonceA, nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_buildVerifyResponse_roundTripsAndVerifiesSignature() throws {
|
||||||
|
let (service, noise) = makeService()
|
||||||
|
let noiseKeyHex = String(repeating: "cd", count: 32)
|
||||||
|
let nonce = Data([0x10, 0x20, 0x30, 0x40, 0x50])
|
||||||
|
|
||||||
|
let encoded = try XCTUnwrap(service.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonce))
|
||||||
|
let payload = try XCTUnwrap(NoisePayload.decode(encoded))
|
||||||
|
let parsed = try XCTUnwrap(service.parseVerifyResponse(payload.data))
|
||||||
|
|
||||||
|
XCTAssertEqual(payload.type, .verifyResponse)
|
||||||
|
XCTAssertEqual(parsed.noiseKeyHex, noiseKeyHex)
|
||||||
|
XCTAssertEqual(parsed.nonceA, nonce)
|
||||||
|
XCTAssertTrue(
|
||||||
|
service.verifyResponseSignature(
|
||||||
|
noiseKeyHex: parsed.noiseKeyHex,
|
||||||
|
nonceA: parsed.nonceA,
|
||||||
|
signature: parsed.signature,
|
||||||
|
signerPublicKeyHex: noise.getSigningPublicKeyData().hexEncodedString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
XCTAssertFalse(
|
||||||
|
service.verifyResponseSignature(
|
||||||
|
noiseKeyHex: parsed.noiseKeyHex,
|
||||||
|
nonceA: Data([0xFF]),
|
||||||
|
signature: parsed.signature,
|
||||||
|
signerPublicKeyHex: noise.getSigningPublicKeyData().hexEncodedString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeService() -> (VerificationService, NoiseEncryptionService) {
|
||||||
|
let noise = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let service = VerificationService()
|
||||||
|
service.configure(with: noise)
|
||||||
|
return (service, noise)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSignedQR(
|
||||||
|
noise: NoiseEncryptionService,
|
||||||
|
nickname: String,
|
||||||
|
npub: String?,
|
||||||
|
ts: Int64,
|
||||||
|
signatureOverride: Data? = nil
|
||||||
|
) throws -> String {
|
||||||
|
var payload = VerificationService.VerificationQR(
|
||||||
|
v: 1,
|
||||||
|
noiseKeyHex: noise.getStaticPublicKeyData().hexEncodedString(),
|
||||||
|
signKeyHex: noise.getSigningPublicKeyData().hexEncodedString(),
|
||||||
|
npub: npub,
|
||||||
|
nickname: nickname,
|
||||||
|
ts: ts,
|
||||||
|
nonceB64: Data((0..<16).map(UInt8.init)).base64EncodedString(),
|
||||||
|
sigHex: ""
|
||||||
|
)
|
||||||
|
let signature = try XCTUnwrap(signatureOverride ?? noise.signData(payload.canonicalBytes()))
|
||||||
|
payload.sigHex = signature.hexEncodedString()
|
||||||
|
return payload.toURLString()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user