mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 06:45:20 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f20e65b0d8 | ||
|
|
3e137d784c | ||
|
|
452e9e74fd | ||
|
|
3afd74ffc8 | ||
|
|
951e056a55 | ||
|
|
d265cfc765 | ||
|
|
473f5c7cce | ||
|
|
b5834cfc76 | ||
|
|
097d916da7 | ||
|
|
4b000b0785 | ||
|
|
19437a0bc6 | ||
|
|
34bae4a89d | ||
|
|
0331871980 | ||
|
|
f9f6ac92b8 | ||
|
|
ca374fd823 | ||
|
|
96f2986d65 | ||
|
|
b6e45e3228 | ||
|
|
c88ab3dd05 | ||
|
|
7d83310bc2 | ||
|
|
264a95b61a | ||
|
|
8562a76367 | ||
|
|
7e86d2061f | ||
|
|
a136b5b7e9 | ||
|
|
c043cf6354 | ||
|
|
72093f9648 | ||
|
|
187a5c3195 | ||
|
|
02b2a006c5 | ||
|
|
2403a6eb90 | ||
|
|
e47c5ae7b3 | ||
|
|
c1f5868d2d | ||
|
|
0515f4c6e8 | ||
|
|
8c7e3e7b9b | ||
|
|
90a5e8ba9d | ||
|
|
6206184862 | ||
|
|
74cf0d89cc |
@@ -5,14 +5,21 @@ on:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Swift Tests
|
||||
name: Run Swift Tests (${{ matrix.name }})
|
||||
runs-on: macos-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false # Don't cancel other matrix jobs when one fails
|
||||
matrix:
|
||||
include:
|
||||
- name: app
|
||||
path: .
|
||||
- name: BitLogger
|
||||
path: localPackages/BitLogger
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
@@ -20,8 +27,14 @@ jobs:
|
||||
- name: Set up Swift
|
||||
uses: swift-actions/setup-swift@v2
|
||||
|
||||
- name: Build the package
|
||||
run: swift build
|
||||
- name: Cache build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.path }}/.build
|
||||
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
${{ runner.os }}-${{ matrix.name }}-
|
||||
|
||||
- name: Run Tests
|
||||
run: swift test --parallel
|
||||
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
|
||||
|
||||
@@ -8,6 +8,7 @@ plans/
|
||||
## AI
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
.claude/
|
||||
|
||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||
*.xcscmblueprint
|
||||
|
||||
@@ -9,3 +9,4 @@ DEVELOPMENT_TEAM = L3N5LHJD5Y
|
||||
CODE_SIGN_STYLE = Automatic
|
||||
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
|
||||
APP_GROUP_ID = group.chat.bitchat
|
||||
|
||||
@@ -32,6 +32,7 @@ let package = Package(
|
||||
exclude: [
|
||||
"Info.plist",
|
||||
"Assets.xcassets",
|
||||
"_PreviewHelpers/PreviewAssets.xcassets",
|
||||
"bitchat.entitlements",
|
||||
"bitchat-macOS.entitlements",
|
||||
"LaunchScreen.storyboard",
|
||||
|
||||
Generated
+6
-4
@@ -548,6 +548,7 @@
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = NO;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
@@ -558,7 +559,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -608,6 +609,7 @@
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
@@ -618,7 +620,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -654,7 +656,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -746,7 +748,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -9,6 +9,18 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
@Published private(set) var duration: TimeInterval = 0
|
||||
@Published private(set) var progress: Double = 0
|
||||
|
||||
/// rounded so 4.9s shows "00:05"
|
||||
var roundedDuration: Int {
|
||||
guard duration.isFinite else { return 0 }
|
||||
return Int(duration.rounded())
|
||||
}
|
||||
|
||||
/// ceil so "00:01" stays visible until playback ends, capped to rounded duration
|
||||
var remainingSeconds: Int {
|
||||
let remaining = max(0, duration - currentTime)
|
||||
return min(roundedDuration, Int(ceil(remaining)))
|
||||
}
|
||||
|
||||
private var player: AVAudioPlayer?
|
||||
private var timer: Timer?
|
||||
private var url: URL
|
||||
|
||||
@@ -2,8 +2,7 @@ import Foundation
|
||||
import AVFoundation
|
||||
|
||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
||||
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
actor VoiceRecorder {
|
||||
enum RecorderError: Error {
|
||||
case microphoneAccessDenied
|
||||
case recorderInitializationFailed
|
||||
@@ -12,21 +11,16 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
|
||||
static let shared = VoiceRecorder()
|
||||
|
||||
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||
private let paddingInterval: TimeInterval = 0.5
|
||||
private let maxRecordingDuration: TimeInterval = 120
|
||||
static let minRecordingDuration: TimeInterval = 1
|
||||
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var currentURL: URL?
|
||||
private var stopWorkItem: DispatchWorkItem?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Permissions
|
||||
|
||||
@discardableResult
|
||||
nonisolated
|
||||
func requestPermission() async -> Bool {
|
||||
#if os(iOS)
|
||||
return await withCheckedContinuation { continuation in
|
||||
@@ -47,106 +41,88 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
|
||||
// MARK: - Recording Lifecycle
|
||||
|
||||
@discardableResult
|
||||
func startRecording() throws -> URL {
|
||||
try queue.sync {
|
||||
if recorder?.isRecording == true {
|
||||
throw RecorderError.recordingInProgress
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
guard session.recordPermission == .granted else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#if targetEnvironment(simulator)
|
||||
// allowBluetoothHFP is not available on iOS Simulator
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
||||
)
|
||||
#else
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||
)
|
||||
#endif
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#endif
|
||||
|
||||
let outputURL = try makeOutputURL()
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
]
|
||||
|
||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||
audioRecorder.delegate = self
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
audioRecorder.prepareToRecord()
|
||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||
|
||||
recorder = audioRecorder
|
||||
currentURL = outputURL
|
||||
stopWorkItem?.cancel()
|
||||
stopWorkItem = nil
|
||||
return outputURL
|
||||
if recorder?.isRecording == true {
|
||||
throw RecorderError.recordingInProgress
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
guard session.recordPermission == .granted else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#if targetEnvironment(simulator)
|
||||
// allowBluetoothHFP is not available on iOS Simulator
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
||||
)
|
||||
#else
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||
)
|
||||
#endif
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#endif
|
||||
|
||||
let outputURL = try makeOutputURL()
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
]
|
||||
|
||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
audioRecorder.prepareToRecord()
|
||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||
|
||||
recorder = audioRecorder
|
||||
currentURL = outputURL
|
||||
return outputURL
|
||||
}
|
||||
|
||||
func stopRecording(completion: @escaping (URL?) -> Void) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
||||
completion(self?.currentURL)
|
||||
return
|
||||
}
|
||||
|
||||
let item = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
recorder.stop()
|
||||
self.cleanupSession()
|
||||
let url = self.currentURL
|
||||
self.recorder = nil
|
||||
self.currentURL = url
|
||||
completion(url)
|
||||
}
|
||||
self.stopWorkItem = item
|
||||
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
|
||||
func stopRecording() async -> URL? {
|
||||
guard let recorder, recorder.isRecording else {
|
||||
return currentURL
|
||||
}
|
||||
|
||||
let sessionURL = currentURL
|
||||
|
||||
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
||||
|
||||
recorder.stop()
|
||||
|
||||
// A new session may have started during the sleep — don't touch its state
|
||||
if self.recorder === recorder {
|
||||
cleanupSession()
|
||||
self.recorder = nil
|
||||
currentURL = nil
|
||||
}
|
||||
|
||||
return sessionURL
|
||||
}
|
||||
|
||||
func cancelRecording() {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.stopWorkItem?.cancel()
|
||||
self.stopWorkItem = nil
|
||||
if let recorder = self.recorder, recorder.isRecording {
|
||||
recorder.stop()
|
||||
}
|
||||
self.cleanupSession()
|
||||
if let url = self.currentURL {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
self.recorder = nil
|
||||
self.currentURL = nil
|
||||
if let recorder, recorder.isRecording {
|
||||
recorder.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metering
|
||||
|
||||
func currentAveragePower() -> Float {
|
||||
queue.sync {
|
||||
recorder?.updateMeters()
|
||||
return recorder?.averagePower(forChannel: 0) ?? -160
|
||||
cleanupSession()
|
||||
if let currentURL {
|
||||
try? FileManager.default.removeItem(at: currentURL)
|
||||
}
|
||||
recorder = nil
|
||||
currentURL = nil
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@@ -331,16 +331,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
queue.async(flags: .barrier) {
|
||||
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||
|
||||
// Update nickname index
|
||||
if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] {
|
||||
// Remove old nickname from index if changed
|
||||
if existingIdentity.claimedNickname != identity.claimedNickname {
|
||||
self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint)
|
||||
if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true {
|
||||
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
|
||||
}
|
||||
if let previousClaimedNickname,
|
||||
previousClaimedNickname != identity.claimedNickname {
|
||||
self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)
|
||||
if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {
|
||||
self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,4 +531,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
return cache.verifiedFingerprints
|
||||
}
|
||||
}
|
||||
|
||||
var debugNicknameIndex: [String: Set<String>] {
|
||||
queue.sync { cache.nicknameIndex }
|
||||
}
|
||||
|
||||
func debugEphemeralSession(for peerID: PeerID) -> EphemeralIdentity? {
|
||||
queue.sync { ephemeralSessions[peerID] }
|
||||
}
|
||||
|
||||
func debugLastInteraction(for fingerprint: String) -> Date? {
|
||||
queue.sync { cache.lastInteractions[fingerprint] }
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AppGroupID</key>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -37,12 +39,12 @@
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// BitchatMessage+Media.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension BitchatMessage {
|
||||
enum Media {
|
||||
case voice(URL)
|
||||
case image(URL)
|
||||
|
||||
var url: URL {
|
||||
switch self {
|
||||
case .voice(let url), .image(let url):
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||
private struct Cache {
|
||||
let filesDir: URL?
|
||||
|
||||
static let shared = Cache()
|
||||
private init() {
|
||||
do {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
self.filesDir = filesDir
|
||||
} catch {
|
||||
filesDir = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mediaAttachment(for nickname: String) -> Media? {
|
||||
guard let baseDirectory = Cache.shared.filesDir else { return nil }
|
||||
|
||||
func url(for category: MimeType.Category) -> URL? {
|
||||
guard content.hasPrefix(category.messagePrefix),
|
||||
let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check outgoing first for sent messages, incoming for received
|
||||
let subdir = sender == nickname ? "\(category.mediaDir)/outgoing" : "\(category.mediaDir)/incoming"
|
||||
|
||||
// Construct URL directly without fileExists check (avoids blocking disk I/O in view body)
|
||||
// Files are checked during playback/display, so missing files fail gracefully
|
||||
let directory = baseDirectory.appendingPathComponent(subdir, isDirectory: true)
|
||||
return directory.appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
if let url = url(for: .audio) {
|
||||
return .voice(url)
|
||||
}
|
||||
if let url = url(for: .image) {
|
||||
return .image(url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -338,7 +338,7 @@ extension BitchatMessage {
|
||||
extension Array where Element == BitchatMessage {
|
||||
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
||||
func cleanedAndDeduped() -> [Element] {
|
||||
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
|
||||
let arr = filter { $0.content.trimmed.isEmpty == false }
|
||||
guard arr.count > 1 else {
|
||||
return arr
|
||||
}
|
||||
|
||||
@@ -384,6 +384,16 @@ final class NoiseCipherState {
|
||||
replayWindow[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func setNonceForTesting(_ nonce: UInt64) {
|
||||
self.nonce = nonce
|
||||
}
|
||||
|
||||
func extractNonceFromCiphertextPayloadForTesting(_ combinedPayload: Data) throws -> (nonce: UInt64, ciphertext: Data)? {
|
||||
try extractNonceFromCiphertextPayload(combinedPayload)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Symmetric State
|
||||
@@ -585,8 +595,9 @@ final class NoiseHandshakeState {
|
||||
break // No pre-message keys
|
||||
case .IK, .NK:
|
||||
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
||||
_ = symmetricState.getHandshakeHash()
|
||||
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
||||
} else if role == .responder, let localStatic = localStaticPublic {
|
||||
symmetricState.mixHash(localStatic.rawRepresentation)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -861,6 +872,20 @@ final class NoiseHandshakeState {
|
||||
func getHandshakeHash() -> Data {
|
||||
return symmetricState.getHandshakeHash()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func performDHOperationForTesting(_ pattern: NoiseMessagePattern) throws {
|
||||
try performDHOperation(pattern)
|
||||
}
|
||||
|
||||
func setCurrentPatternForTesting(_ currentPattern: Int) {
|
||||
self.currentPattern = currentPattern
|
||||
}
|
||||
|
||||
func setRemoteEphemeralPublicKeyForTesting(_ key: Curve25519.KeyAgreement.PublicKey?) {
|
||||
self.remoteEphemeralPublic = key
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Pattern Extensions
|
||||
|
||||
@@ -14,6 +14,7 @@ final class NoiseSessionManager {
|
||||
private var sessions: [PeerID: NoiseSession] = [:]
|
||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
@@ -23,7 +24,27 @@ final class NoiseSessionManager {
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
self.sessionFactory = { peerID, role in
|
||||
SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: role,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
init(
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
keychain: KeychainManagerProtocol,
|
||||
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
||||
) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
self.sessionFactory = sessionFactory
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
@@ -66,12 +87,7 @@ final class NoiseSessionManager {
|
||||
}
|
||||
|
||||
// Create new initiator session
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
let session = sessionFactory(peerID, .initiator)
|
||||
sessions[peerID] = session
|
||||
|
||||
do {
|
||||
@@ -117,12 +133,7 @@ final class NoiseSessionManager {
|
||||
// Get or create session
|
||||
let session: NoiseSession
|
||||
if shouldCreateNew {
|
||||
let newSession = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .responder,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
let newSession = sessionFactory(peerID, .responder)
|
||||
sessions[peerID] = newSession
|
||||
session = newSession
|
||||
} else {
|
||||
|
||||
@@ -10,7 +10,7 @@ import Foundation
|
||||
|
||||
final class SecureNoiseSession: NoiseSession {
|
||||
private(set) var messageCount: UInt64 = 0
|
||||
private let sessionStartTime = Date()
|
||||
private var sessionStartTime = Date()
|
||||
private(set) var lastActivityTime = Date()
|
||||
|
||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||
@@ -77,5 +77,9 @@ final class SecureNoiseSession: NoiseSession {
|
||||
func setMessageCountForTesting(_ count: UInt64) {
|
||||
messageCount = count
|
||||
}
|
||||
|
||||
func setSessionStartTimeForTesting(_ date: Date) {
|
||||
sessionStartTime = date
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -8,39 +8,154 @@ import AppKit
|
||||
#endif
|
||||
|
||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||
struct GeoRelayDirectoryDependencies {
|
||||
var userDefaults: UserDefaults
|
||||
var notificationCenter: NotificationCenter
|
||||
var now: () -> Date
|
||||
var remoteURL: URL
|
||||
var fetchInterval: TimeInterval
|
||||
var refreshCheckInterval: TimeInterval
|
||||
var retryInitialSeconds: TimeInterval
|
||||
var retryMaxSeconds: TimeInterval
|
||||
var awaitTorReady: @Sendable () async -> Bool
|
||||
var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
|
||||
var readData: (URL) -> Data?
|
||||
var writeData: (Data, URL) throws -> Void
|
||||
var cacheURL: () -> URL?
|
||||
var bundledCSVURLs: () -> [URL]
|
||||
var currentDirectoryPath: () -> String?
|
||||
var retrySleep: (TimeInterval) async -> Void
|
||||
var activeNotificationName: Notification.Name?
|
||||
var autoStart: Bool
|
||||
}
|
||||
|
||||
private extension GeoRelayDirectoryDependencies {
|
||||
@MainActor
|
||||
static func live() -> Self {
|
||||
#if os(iOS)
|
||||
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
|
||||
#elseif os(macOS)
|
||||
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
|
||||
#else
|
||||
let activeNotificationName: Notification.Name? = nil
|
||||
#endif
|
||||
|
||||
return Self(
|
||||
userDefaults: .standard,
|
||||
notificationCenter: .default,
|
||||
now: Date.init,
|
||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
||||
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
||||
awaitTorReady: { await TorManager.shared.awaitReady() },
|
||||
makeFetchData: {
|
||||
let session = TorURLSession.shared.session
|
||||
return { request in
|
||||
let (data, _) = try await session.data(for: request)
|
||||
return data
|
||||
}
|
||||
},
|
||||
readData: { try? Data(contentsOf: $0) },
|
||||
writeData: { data, url in
|
||||
try data.write(to: url, options: .atomic)
|
||||
},
|
||||
cacheURL: {
|
||||
do {
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent("georelays_cache.csv")
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
},
|
||||
bundledCSVURLs: {
|
||||
[
|
||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||
].compactMap { $0 }
|
||||
},
|
||||
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
|
||||
retrySleep: { delay in
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
},
|
||||
activeNotificationName: activeNotificationName,
|
||||
autoStart: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class GeoRelayDirectory {
|
||||
struct Entry: Hashable {
|
||||
private final class CleanupState {
|
||||
let notificationCenter: NotificationCenter
|
||||
var observers: [NSObjectProtocol] = []
|
||||
var refreshTimer: Timer?
|
||||
var retryTask: Task<Void, Never>?
|
||||
|
||||
init(notificationCenter: NotificationCenter) {
|
||||
self.notificationCenter = notificationCenter
|
||||
}
|
||||
|
||||
deinit {
|
||||
observers.forEach { notificationCenter.removeObserver($0) }
|
||||
refreshTimer?.invalidate()
|
||||
retryTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
struct Entry: Hashable, Sendable {
|
||||
let host: String
|
||||
let lat: Double
|
||||
let lon: Double
|
||||
}
|
||||
|
||||
private enum DetachedFetchOutcome: Sendable {
|
||||
case success(entries: [Entry], csv: String)
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(String)
|
||||
}
|
||||
|
||||
static let shared = GeoRelayDirectory()
|
||||
|
||||
private(set) var entries: [Entry] = []
|
||||
private let cacheFileName = "georelays_cache.csv"
|
||||
private let lastFetchKey = "georelay.lastFetchAt"
|
||||
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
||||
private let dependencies: GeoRelayDirectoryDependencies
|
||||
private let cleanupState: CleanupState
|
||||
|
||||
private var refreshTimer: Timer?
|
||||
private var retryTask: Task<Void, Never>?
|
||||
private var retryAttempt: Int = 0
|
||||
private var isFetching: Bool = false
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
private init() {
|
||||
self.dependencies = .live()
|
||||
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
||||
entries = loadLocalEntries()
|
||||
registerObservers()
|
||||
startRefreshTimer()
|
||||
prefetchIfNeeded()
|
||||
if dependencies.autoStart {
|
||||
registerObservers()
|
||||
startRefreshTimer()
|
||||
prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
refreshTimer?.invalidate()
|
||||
retryTask?.cancel()
|
||||
internal init(dependencies: GeoRelayDirectoryDependencies) {
|
||||
self.dependencies = dependencies
|
||||
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
||||
entries = loadLocalEntries()
|
||||
if dependencies.autoStart {
|
||||
registerObservers()
|
||||
startRefreshTimer()
|
||||
prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||
@@ -83,13 +198,13 @@ final class GeoRelayDirectory {
|
||||
func prefetchIfNeeded(force: Bool = false) {
|
||||
guard !isFetching else { return }
|
||||
|
||||
let now = Date()
|
||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||
let now = dependencies.now()
|
||||
let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||
|
||||
if !force {
|
||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
|
||||
} else if last != .distantPast,
|
||||
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
|
||||
now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
|
||||
// Skip forced fetches if we just refreshed moments ago.
|
||||
return
|
||||
}
|
||||
@@ -103,51 +218,73 @@ final class GeoRelayDirectory {
|
||||
isFetching = true
|
||||
|
||||
let request = URLRequest(
|
||||
url: remoteURL,
|
||||
url: dependencies.remoteURL,
|
||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
||||
timeoutInterval: 15
|
||||
)
|
||||
let awaitTorReady = dependencies.awaitTorReady
|
||||
let fetchData = dependencies.makeFetchData()
|
||||
|
||||
Task.detached { [weak self] in
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
if !ready {
|
||||
await self.handleFetchFailure(.torNotReady)
|
||||
return
|
||||
}
|
||||
let outcome = await Self.fetchRemoteOutcome(
|
||||
request: request,
|
||||
awaitTorReady: awaitTorReady,
|
||||
fetchData: fetchData
|
||||
)
|
||||
|
||||
do {
|
||||
let (data, _) = try await TorURLSession.shared.session.data(for: request)
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
await self.handleFetchFailure(.invalidData)
|
||||
return
|
||||
}
|
||||
|
||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||
guard !parsed.isEmpty else {
|
||||
await self.handleFetchFailure(.invalidData)
|
||||
return
|
||||
}
|
||||
|
||||
await self.handleFetchSuccess(entries: parsed, csv: text)
|
||||
} catch {
|
||||
await self.handleFetchFailure(.network(error))
|
||||
switch outcome {
|
||||
case .success(let parsed, let csv):
|
||||
self.handleFetchSuccess(entries: parsed, csv: csv)
|
||||
case .torNotReady:
|
||||
self.handleFetchFailure(.torNotReady)
|
||||
case .invalidData:
|
||||
self.handleFetchFailure(.invalidData)
|
||||
case .network(let description):
|
||||
self.handleFetchFailure(.network(description))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func fetchRemoteOutcome(
|
||||
request: URLRequest,
|
||||
awaitTorReady: @escaping @Sendable () async -> Bool,
|
||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
|
||||
) async -> DetachedFetchOutcome {
|
||||
await Task.detached(priority: .utility) {
|
||||
let ready = await awaitTorReady()
|
||||
guard ready else { return .torNotReady }
|
||||
|
||||
do {
|
||||
let data = try await fetchData(request)
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
return .invalidData
|
||||
}
|
||||
|
||||
let parsed = Self.parseCSV(text)
|
||||
guard !parsed.isEmpty else {
|
||||
return .invalidData
|
||||
}
|
||||
|
||||
return .success(entries: parsed, csv: text)
|
||||
} catch {
|
||||
return .network(error.localizedDescription)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
private enum FetchFailure {
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(Error)
|
||||
case network(String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||
entries = parsed
|
||||
persistCache(csv)
|
||||
UserDefaults.standard.set(Date(), forKey: lastFetchKey)
|
||||
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||
isFetching = false
|
||||
retryAttempt = 0
|
||||
@@ -161,8 +298,8 @@ final class GeoRelayDirectory {
|
||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
||||
case .invalidData:
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
||||
case .network(let error):
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
|
||||
case .network(let errorDescription):
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(errorDescription)", category: .session)
|
||||
}
|
||||
isFetching = false
|
||||
scheduleRetry()
|
||||
@@ -171,32 +308,34 @@ final class GeoRelayDirectory {
|
||||
@MainActor
|
||||
private func scheduleRetry() {
|
||||
retryAttempt = min(retryAttempt + 1, 10)
|
||||
let base = TransportConfig.geoRelayRetryInitialSeconds
|
||||
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
|
||||
let base = dependencies.retryInitialSeconds
|
||||
let maxDelay = dependencies.retryMaxSeconds
|
||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
||||
let calculated = base * multiplier
|
||||
let delay = min(maxDelay, max(base, calculated))
|
||||
|
||||
cancelRetry()
|
||||
retryTask = Task { [weak self] in
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
cleanupState.retryTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.dependencies.retrySleep(delay)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
self?.prefetchIfNeeded(force: true)
|
||||
self.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func cancelRetry() {
|
||||
retryTask?.cancel()
|
||||
retryTask = nil
|
||||
cleanupState.retryTask?.cancel()
|
||||
cleanupState.retryTask = nil
|
||||
}
|
||||
|
||||
private func persistCache(_ text: String) {
|
||||
guard let url = cacheURL() else { return }
|
||||
guard let url = dependencies.cacheURL() else { return }
|
||||
guard let data = text.data(using: .utf8) else { return }
|
||||
do {
|
||||
try text.data(using: .utf8)?.write(to: url, options: .atomic)
|
||||
try dependencies.writeData(data, url)
|
||||
} catch {
|
||||
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
||||
}
|
||||
@@ -205,22 +344,18 @@ final class GeoRelayDirectory {
|
||||
// MARK: - Loading
|
||||
private func loadLocalEntries() -> [Entry] {
|
||||
// Prefer cached file if present
|
||||
if let cache = cacheURL(),
|
||||
let data = try? Data(contentsOf: cache),
|
||||
if let cache = dependencies.cacheURL(),
|
||||
let data = dependencies.readData(cache),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
|
||||
// Try bundled resource(s)
|
||||
let bundleCandidates = [
|
||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||
].compactMap { $0 }
|
||||
let bundleCandidates = dependencies.bundledCSVURLs()
|
||||
|
||||
for url in bundleCandidates {
|
||||
if let data = try? Data(contentsOf: url),
|
||||
if let data = dependencies.readData(url),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
@@ -228,8 +363,8 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
// Try filesystem path (development/test)
|
||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||
if let cwd = dependencies.currentDirectoryPath(),
|
||||
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
return Self.parseCSV(text)
|
||||
}
|
||||
@@ -242,10 +377,9 @@ final class GeoRelayDirectory {
|
||||
var result: Set<Entry> = []
|
||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||
for (idx, raw) in lines.enumerated() {
|
||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if line.isEmpty { continue }
|
||||
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||
let parts = line.split(separator: ",").map { $0.trimmed }
|
||||
guard parts.count >= 3 else { continue }
|
||||
var host = parts[0]
|
||||
host = host.replacingOccurrences(of: "https://", with: "")
|
||||
@@ -259,25 +393,9 @@ final class GeoRelayDirectory {
|
||||
return Array(result)
|
||||
}
|
||||
|
||||
private func cacheURL() -> URL? {
|
||||
do {
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent(cacheFileName)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observers & Timers
|
||||
private func registerObservers() {
|
||||
let center = NotificationCenter.default
|
||||
let center = dependencies.notificationCenter
|
||||
|
||||
let torReady = center.addObserver(
|
||||
forName: .TorDidBecomeReady,
|
||||
@@ -289,38 +407,26 @@ final class GeoRelayDirectory {
|
||||
self.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
observers.append(torReady)
|
||||
cleanupState.observers.append(torReady)
|
||||
|
||||
#if os(iOS)
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: UIApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
if let activeNotificationName = dependencies.activeNotificationName {
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: activeNotificationName,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
cleanupState.observers.append(didBecomeActive)
|
||||
}
|
||||
observers.append(didBecomeActive)
|
||||
#elseif os(macOS)
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: NSApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
observers.append(didBecomeActive)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func startRefreshTimer() {
|
||||
refreshTimer?.invalidate()
|
||||
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
|
||||
cleanupState.refreshTimer?.invalidate()
|
||||
let interval = dependencies.refreshCheckInterval
|
||||
guard interval > 0 else { return }
|
||||
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
@@ -329,9 +435,13 @@ final class GeoRelayDirectory {
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
refreshTimer = timer
|
||||
cleanupState.refreshTimer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
|
||||
var debugRetryAttempt: Int { retryAttempt }
|
||||
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
|
||||
var debugObserverCount: Int { cleanupState.observers.count }
|
||||
}
|
||||
|
||||
// MARK: - Distance
|
||||
|
||||
@@ -109,7 +109,7 @@ struct NostrProtocol {
|
||||
teleported: Bool = false
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
if teleported {
|
||||
@@ -152,7 +152,7 @@ struct NostrProtocol {
|
||||
nickname: String? = nil
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
let event = NostrEvent(
|
||||
@@ -528,6 +528,26 @@ struct NostrEvent: Codable {
|
||||
signed.sig = signatureHex
|
||||
return signed
|
||||
}
|
||||
|
||||
/// Validate that the event ID and Schnorr signature match the content and pubkey.
|
||||
/// Returns false when the signature is missing, malformed, or does not verify.
|
||||
func isValidSignature() -> Bool {
|
||||
guard let sig = sig,
|
||||
let sigData = Data(hexString: sig),
|
||||
let pubData = Data(hexString: pubkey),
|
||||
sigData.count == 64,
|
||||
pubData.count == 32,
|
||||
let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData),
|
||||
let (expectedId, eventHash) = try? calculateEventId(),
|
||||
expectedId == id
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
var messageBytes = [UInt8](eventHash)
|
||||
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
|
||||
return xonly.isValid(signature, for: &messageBytes)
|
||||
}
|
||||
|
||||
private func calculateEventId() throws -> (String, Data) {
|
||||
let serialized = [
|
||||
|
||||
@@ -4,6 +4,100 @@ import Network
|
||||
import Combine
|
||||
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: @Sendable (TimeInterval, @escaping @Sendable () -> 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
|
||||
@MainActor
|
||||
final class NostrRelayManager: ObservableObject {
|
||||
@@ -41,10 +135,11 @@ final class NostrRelayManager: ObservableObject {
|
||||
@Published private(set) var relays: [Relay] = []
|
||||
@Published private(set) var isConnected = false
|
||||
|
||||
private let dependencies: NostrRelayManagerDependencies
|
||||
private var allowDefaultRelays: Bool = false
|
||||
private var hasMutualFavorites: 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 pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
||||
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
|
||||
@@ -69,8 +164,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
private var messageQueue: [PendingSend] = []
|
||||
private let messageQueueLock = NSLock()
|
||||
private let encoder = JSONEncoder()
|
||||
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
||||
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
||||
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
|
||||
|
||||
// Exponential backoff configuration
|
||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||
@@ -82,12 +176,13 @@ final class NostrRelayManager: ObservableObject {
|
||||
private var connectionGeneration: Int = 0
|
||||
|
||||
init() {
|
||||
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
|
||||
self.dependencies = .live()
|
||||
hasMutualFavorites = dependencies.hasMutualFavorites()
|
||||
hasLocationPermission = dependencies.hasLocationPermission()
|
||||
applyDefaultRelayPolicy(force: true)
|
||||
// Deterministic JSON shape for outbound requests
|
||||
self.encoder.outputFormatting = .sortedKeys
|
||||
FavoritesPersistenceService.shared.$mutualFavorites
|
||||
dependencies.mutualFavoritesPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] favorites in
|
||||
guard let self = self else { return }
|
||||
@@ -95,7 +190,34 @@ final class NostrRelayManager: ObservableObject {
|
||||
self.applyDefaultRelayPolicy()
|
||||
}
|
||||
.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)
|
||||
.sink { [weak self] state in
|
||||
guard let self = self else { return }
|
||||
@@ -110,20 +232,18 @@ final class NostrRelayManager: ObservableObject {
|
||||
/// Connect to all configured relays
|
||||
func connect() {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
if shouldUseTor {
|
||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
if !ready {
|
||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||
for relay in self.relays {
|
||||
self.connectToRelay(relay.url)
|
||||
}
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
if !ready {
|
||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||
for relay in self.relays {
|
||||
self.connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -150,15 +270,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||
func ensureConnections(to relayUrls: [String]) {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
let targets = allowedRelayList(from: relayUrls)
|
||||
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
|
||||
Task.detached { [weak self] in
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run { if ready { self.ensureConnections(to: relayUrls) } }
|
||||
if ready { self.ensureConnections(to: relayUrls) }
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -175,13 +294,12 @@ final class NostrRelayManager: ObservableObject {
|
||||
/// Send an event to specified relays (or all if none specified)
|
||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
// 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 }
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run { if ready { self.sendEvent(event, to: relayUrls) } }
|
||||
if ready { self.sendEvent(event, to: relayUrls) }
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -253,24 +371,21 @@ final class NostrRelayManager: ObservableObject {
|
||||
onEOSE: (() -> Void)? = nil
|
||||
) {
|
||||
// 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
|
||||
let now = Date()
|
||||
let now = dependencies.now()
|
||||
if messageHandlers[id] != nil {
|
||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
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
|
||||
Task.detached { [weak self] in
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
if ready {
|
||||
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler)
|
||||
}
|
||||
if ready {
|
||||
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE)
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -297,7 +412,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
for url in urls where !existingSet.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
}
|
||||
for url in candidateUrls {
|
||||
for url in urls {
|
||||
var map = self.pendingSubscriptions[url] ?? [:]
|
||||
map[id] = messageString
|
||||
self.pendingSubscriptions[url] = map
|
||||
@@ -346,7 +461,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
relays.append(Relay(url: url))
|
||||
existing.insert(url)
|
||||
}
|
||||
if networkService.activationAllowed {
|
||||
if dependencies.activationAllowed() {
|
||||
ensureConnections(to: Self.defaultRelays)
|
||||
}
|
||||
} else {
|
||||
@@ -356,6 +471,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
connections.removeValue(forKey: url)
|
||||
subscriptions.removeValue(forKey: url)
|
||||
pendingSubscriptions.removeValue(forKey: url)
|
||||
}
|
||||
messageQueueLock.lock()
|
||||
for index in (0..<messageQueue.count).reversed() {
|
||||
@@ -400,10 +516,9 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Send unsubscribe to all relays
|
||||
for (relayUrl, connection) in connections {
|
||||
if subscriptions[relayUrl]?.contains(id) == true {
|
||||
subscriptions[relayUrl]?.remove(id)
|
||||
connection.send(.string(messageString)) { _ in
|
||||
Task { @MainActor in
|
||||
self.subscriptions[relayUrl]?.remove(id)
|
||||
}
|
||||
// Local state is cleared before sending so callers can re-subscribe immediately.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,14 +528,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
private func connectToRelay(_ urlString: String) {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
guard let url = URL(string: urlString) else {
|
||||
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -435,19 +550,16 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Attempting to connect to Nostr relay via the proxied session
|
||||
|
||||
// If Tor is enforced but not ready, delay connection until it is.
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
Task.detached { [weak self] in
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
if ready { self.connectToRelay(urlString) }
|
||||
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
|
||||
}
|
||||
|
||||
let session = TorURLSession.shared.session
|
||||
let session = dependencies.makeSession()
|
||||
let task = session.webSocketTask(with: url)
|
||||
|
||||
connections[urlString] = task
|
||||
@@ -495,7 +607,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
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
|
||||
guard let self = self else { return }
|
||||
|
||||
@@ -505,7 +617,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
Task.detached(priority: .utility) {
|
||||
guard let parsed = ParsedInbound(message) else { return }
|
||||
await MainActor.run {
|
||||
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
|
||||
self.handleParsedMessage(parsed, from: relayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,7 +681,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)
|
||||
|
||||
do {
|
||||
@@ -601,11 +713,11 @@ final class NostrRelayManager: ObservableObject {
|
||||
relays[index].isConnected = isConnected
|
||||
relays[index].lastError = error
|
||||
if isConnected {
|
||||
relays[index].lastConnectedAt = Date()
|
||||
relays[index].lastConnectedAt = dependencies.now()
|
||||
relays[index].reconnectAttempts = 0 // Reset on successful connection
|
||||
relays[index].nextReconnectTime = nil
|
||||
} else {
|
||||
relays[index].lastDisconnectedAt = Date()
|
||||
relays[index].lastDisconnectedAt = dependencies.now()
|
||||
}
|
||||
}
|
||||
updateConnectionStatus()
|
||||
@@ -621,7 +733,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||
// If networking is disallowed, do not schedule reconnection
|
||||
if !networkService.activationAllowed {
|
||||
if !dependencies.activationAllowed() {
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||
@@ -666,19 +778,21 @@ final class NostrRelayManager: ObservableObject {
|
||||
maxBackoffInterval
|
||||
)
|
||||
|
||||
let nextReconnectTime = Date().addingTimeInterval(backoffInterval)
|
||||
let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)
|
||||
relays[index].nextReconnectTime = nextReconnectTime
|
||||
|
||||
|
||||
// Schedule reconnection with exponential backoff
|
||||
let gen = connectionGeneration
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
// Ignore stale scheduled reconnects from a previous generation
|
||||
guard gen == self.connectionGeneration else { return }
|
||||
// Check if we should still reconnect (relay might have been removed)
|
||||
if self.relays.contains(where: { $0.url == relayUrl }) {
|
||||
self.connectToRelay(relayUrl)
|
||||
dependencies.scheduleAfter(backoffInterval) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self else { return }
|
||||
// Ignore stale scheduled reconnects from a previous generation
|
||||
guard gen == self.connectionGeneration else { return }
|
||||
// Check if we should still reconnect (relay might have been removed)
|
||||
if self.relays.contains(where: { $0.url == relayUrl }) {
|
||||
self.connectToRelay(relayUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -692,6 +806,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Reset reconnection attempts
|
||||
relays[index].reconnectAttempts = 0
|
||||
relays[index].nextReconnectTime = nil
|
||||
relays[index].lastError = nil
|
||||
|
||||
// Disconnect if connected
|
||||
if let connection = connections[relayUrl] {
|
||||
@@ -712,6 +827,20 @@ final class NostrRelayManager: ObservableObject {
|
||||
nextReconnectTime: relay.nextReconnectTime)
|
||||
}
|
||||
}
|
||||
|
||||
var debugPendingMessageQueueCount: Int {
|
||||
messageQueueLock.lock()
|
||||
defer { messageQueueLock.unlock() }
|
||||
return messageQueue.count
|
||||
}
|
||||
|
||||
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
|
||||
pendingSubscriptions[relayUrl]?.count ?? 0
|
||||
}
|
||||
|
||||
func debugFlushMessageQueue() {
|
||||
flushMessageQueue(for: nil)
|
||||
}
|
||||
|
||||
/// Reset all relay connections
|
||||
func resetAllConnections() {
|
||||
@@ -764,7 +893,8 @@ private enum ParsedInbound {
|
||||
if array.count >= 3,
|
||||
let subId = array[1] as? String,
|
||||
let eventDict = array[2] as? [String: Any],
|
||||
let event = try? NostrEvent(from: eventDict) {
|
||||
let event = try? NostrEvent(from: eventDict),
|
||||
event.isValidSignature() {
|
||||
self = .event(subId: subId, event: event)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ extension Data {
|
||||
/// Whitespace is trimmed. Must have even length after prefix removal.
|
||||
/// - Returns: nil if the string has odd length or contains invalid hex characters.
|
||||
init?(hexString: String) {
|
||||
var hex = hexString.trimmingCharacters(in: .whitespaces)
|
||||
var hex = hexString.trimmed
|
||||
|
||||
// Remove optional 0x prefix
|
||||
if hex.hasPrefix("0x") || hex.hasPrefix("0X") {
|
||||
|
||||
@@ -343,6 +343,7 @@ struct BinaryProtocol {
|
||||
}
|
||||
|
||||
guard payloadLength >= 0 else { return nil }
|
||||
guard payloadLength <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
||||
|
||||
guard let senderID = readData(senderIDSize) else { return nil }
|
||||
|
||||
|
||||
@@ -251,7 +251,8 @@ final class BLEService: NSObject {
|
||||
init(
|
||||
keychain: KeychainManagerProtocol,
|
||||
idBridge: NostrIdentityBridge,
|
||||
identityManager: SecureIdentityStateManagerProtocol
|
||||
identityManager: SecureIdentityStateManagerProtocol,
|
||||
initializeBluetoothManagers: Bool = true
|
||||
) {
|
||||
self.keychain = keychain
|
||||
self.idBridge = idBridge
|
||||
@@ -294,22 +295,23 @@ final class BLEService: NSObject {
|
||||
// Tag BLE queue for re-entrancy detection
|
||||
bleQueue.setSpecific(key: bleQueueKey, value: ())
|
||||
|
||||
// Initialize BLE on background queue to prevent main thread blocking
|
||||
// This prevents app freezes during BLE operations
|
||||
#if os(iOS)
|
||||
let centralOptions: [String: Any] = [
|
||||
CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID
|
||||
]
|
||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions)
|
||||
if initializeBluetoothManagers {
|
||||
// Initialize BLE on background queue to prevent main thread blocking.
|
||||
#if os(iOS)
|
||||
let centralOptions: [String: Any] = [
|
||||
CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID
|
||||
]
|
||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions)
|
||||
|
||||
let peripheralOptions: [String: Any] = [
|
||||
CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID
|
||||
]
|
||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions)
|
||||
#else
|
||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
|
||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
|
||||
#endif
|
||||
let peripheralOptions: [String: Any] = [
|
||||
CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID
|
||||
]
|
||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions)
|
||||
#else
|
||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
|
||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
|
||||
#endif
|
||||
}
|
||||
|
||||
// Single maintenance timer for all periodic tasks (dispatch-based for determinism)
|
||||
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
||||
@@ -523,7 +525,7 @@ final class BLEService: NSObject {
|
||||
|
||||
func stopServices() {
|
||||
// Send leave message synchronously to ensure delivery
|
||||
let leavePacket = BitchatPacket(
|
||||
var leavePacket = BitchatPacket(
|
||||
type: MessageType.leave.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: nil,
|
||||
@@ -533,6 +535,10 @@ final class BLEService: NSObject {
|
||||
ttl: messageTTL
|
||||
)
|
||||
|
||||
if let signed = noiseService.signPacket(leavePacket) {
|
||||
leavePacket = signed
|
||||
}
|
||||
|
||||
// Send immediately to all connected peers (synchronized access to BLE state)
|
||||
if let data = leavePacket.toBinaryData(padding: false) {
|
||||
let leavePriority = priority(for: leavePacket, data: data)
|
||||
@@ -729,7 +735,7 @@ final class BLEService: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
let packet = BitchatPacket(
|
||||
var packet = BitchatPacket(
|
||||
type: MessageType.fileTransfer.rawValue,
|
||||
senderID: self.myPeerIDData,
|
||||
recipientID: nil,
|
||||
@@ -740,6 +746,13 @@ final class BLEService: NSObject {
|
||||
version: 2
|
||||
)
|
||||
|
||||
if let signed = self.noiseService.signPacket(packet) {
|
||||
packet = signed
|
||||
} else {
|
||||
SecureLogger.error("❌ Failed to sign file broadcast packet", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
let senderHex = packet.senderID.hexEncodedString()
|
||||
let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)"
|
||||
self.messageDeduplicator.markProcessed(dedupID)
|
||||
@@ -1209,38 +1222,16 @@ final class BLEService: NSObject {
|
||||
// BCH-01-002: Enforce storage quota before saving
|
||||
enforceIncomingFilesQuota(reservingBytes: filePacket.content.count)
|
||||
|
||||
let fallbackExt = mime.defaultExtension
|
||||
let subdirectory: String
|
||||
switch mime.category {
|
||||
case .audio:
|
||||
subdirectory = "voicenotes/incoming"
|
||||
case .image:
|
||||
subdirectory = "images/incoming"
|
||||
case .file:
|
||||
subdirectory = "files/incoming"
|
||||
}
|
||||
|
||||
guard let destination = saveIncomingFile(
|
||||
data: filePacket.content,
|
||||
preferredName: filePacket.fileName,
|
||||
subdirectory: subdirectory,
|
||||
fallbackExtension: fallbackExt,
|
||||
subdirectory: "\(mime.category.mediaDir)/incoming",
|
||||
fallbackExtension: mime.defaultExtension,
|
||||
defaultPrefix: mime.category.rawValue
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
let marker: String
|
||||
let fileName = destination.lastPathComponent
|
||||
switch mime.category {
|
||||
case .audio:
|
||||
marker = "[voice] \(fileName)"
|
||||
case .image:
|
||||
marker = "[image] \(fileName)"
|
||||
case .file:
|
||||
marker = "[file] \(fileName)"
|
||||
}
|
||||
|
||||
let isPrivateMessage = PeerID(hexData: packet.recipientID) == myPeerID
|
||||
|
||||
if isPrivateMessage {
|
||||
@@ -1250,7 +1241,7 @@ final class BLEService: NSObject {
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
let message = BitchatMessage(
|
||||
sender: senderNickname,
|
||||
content: marker,
|
||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
||||
timestamp: ts,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
@@ -1363,7 +1354,7 @@ final class BLEService: NSObject {
|
||||
let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters)
|
||||
candidate = candidate.components(separatedBy: invalid).joined(separator: "_")
|
||||
|
||||
candidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
candidate = candidate.trimmed
|
||||
if candidate.isEmpty { candidate = defaultName }
|
||||
|
||||
// Security: Reject dotfiles (hidden file attacks)
|
||||
@@ -2038,26 +2029,28 @@ extension BLEService {
|
||||
#if DEBUG
|
||||
// Test-only helper to inject packets into the receive pipeline
|
||||
extension BLEService {
|
||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID) {
|
||||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
||||
let normalizedID = PeerID(hexData: packet.senderID)
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
if peers[normalizedID] == nil {
|
||||
peers[normalizedID] = PeerInfo(
|
||||
peerID: normalizedID,
|
||||
nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
|
||||
isConnected: true,
|
||||
noisePublicKey: packet.senderID,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: Date()
|
||||
)
|
||||
} else {
|
||||
var p = peers[normalizedID]!
|
||||
p.isConnected = true
|
||||
p.isVerifiedNickname = true
|
||||
p.lastSeen = Date()
|
||||
peers[normalizedID] = p
|
||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true) {
|
||||
if preseedPeer {
|
||||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
||||
let normalizedID = PeerID(hexData: packet.senderID)
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
if peers[normalizedID] == nil {
|
||||
peers[normalizedID] = PeerInfo(
|
||||
peerID: normalizedID,
|
||||
nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
|
||||
isConnected: true,
|
||||
noisePublicKey: packet.senderID,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: Date()
|
||||
)
|
||||
} else {
|
||||
var p = peers[normalizedID]!
|
||||
p.isConnected = true
|
||||
p.isVerifiedNickname = true
|
||||
p.lastSeen = Date()
|
||||
peers[normalizedID] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
handleReceivedPacket(packet, from: fromPeerID)
|
||||
@@ -3839,7 +3832,7 @@ extension BLEService {
|
||||
let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey)
|
||||
if derivedFromKey != peerID {
|
||||
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Don't add ourselves as a peer
|
||||
|
||||
@@ -191,5 +191,22 @@ enum MimeType: CaseIterable, Hashable {
|
||||
extension MimeType {
|
||||
enum Category: String {
|
||||
case audio, image, file
|
||||
|
||||
/// Ends with a space
|
||||
var messagePrefix: String {
|
||||
switch self {
|
||||
case .audio: "[voice] "
|
||||
case .image: "[image] "
|
||||
case .file: "[file] "
|
||||
}
|
||||
}
|
||||
|
||||
var mediaDir: String {
|
||||
switch self {
|
||||
case .audio: "voicenotes"
|
||||
case .image: "images"
|
||||
case .file: "files"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /\(command) <nickname>")
|
||||
}
|
||||
@@ -209,7 +209,7 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleBlock(_ args: String) -> CommandResult {
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
let targetName = args.trimmed
|
||||
|
||||
if targetName.isEmpty {
|
||||
// List blocked users (mesh) and geohash (Nostr) blocks
|
||||
@@ -284,7 +284,7 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleUnblock(_ args: String) -> CommandResult {
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /unblock <nickname>")
|
||||
}
|
||||
@@ -311,7 +311,7 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,25 @@ import Combine
|
||||
import BitLogger
|
||||
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.
|
||||
///
|
||||
/// Behavior:
|
||||
@@ -25,18 +44,27 @@ final class GeohashPresenceService: ObservableObject {
|
||||
static let shared = GeohashPresenceService()
|
||||
|
||||
private var subscriptions = Set<AnyCancellable>()
|
||||
private var heartbeatTimer: Timer?
|
||||
private let idBridge = NostrIdentityBridge()
|
||||
private var heartbeatTimer: GeohashPresenceTimerProtocol?
|
||||
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
|
||||
|
||||
// Loop interval range in seconds
|
||||
private let loopMinInterval: TimeInterval = 40.0
|
||||
private let loopMaxInterval: TimeInterval = 80.0
|
||||
private let loopMinInterval: TimeInterval
|
||||
private let loopMaxInterval: TimeInterval
|
||||
|
||||
// Per-broadcast decorrelation delay range in seconds
|
||||
private let burstMinDelay: TimeInterval = 2.0
|
||||
private let burstMaxDelay: TimeInterval = 5.0
|
||||
private let burstMinDelay: TimeInterval
|
||||
private let burstMaxDelay: TimeInterval
|
||||
|
||||
// Privacy: Only broadcast to these levels
|
||||
private let allowedPrecisions: Set<Int> = [
|
||||
@@ -46,6 +74,74 @@ final class GeohashPresenceService: ObservableObject {
|
||||
]
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -57,7 +153,7 @@ final class GeohashPresenceService: ObservableObject {
|
||||
|
||||
private func setupObservers() {
|
||||
// Monitor location channel changes
|
||||
LocationStateManager.shared.$availableChannels
|
||||
locationChanges
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
self?.handleLocationChange()
|
||||
@@ -65,28 +161,28 @@ final class GeohashPresenceService: ObservableObject {
|
||||
.store(in: &subscriptions)
|
||||
|
||||
// Monitor Tor readiness to kick off heartbeat if it was stalled
|
||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||
torReadyPublisher
|
||||
.sink { [weak self] _ in
|
||||
self?.handleConnectivityChange()
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func handleLocationChange() {
|
||||
func handleLocationChange() {
|
||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
||||
// to announce presence in the new zone, then reset the loop.
|
||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
||||
heartbeatTimer?.invalidate()
|
||||
|
||||
// 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
|
||||
self?.performHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleConnectivityChange() {
|
||||
func handleConnectivityChange() {
|
||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
||||
// If we were waiting for network, do it now
|
||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
||||
@@ -94,33 +190,33 @@ final class GeohashPresenceService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleNextHeartbeat() {
|
||||
func scheduleNextHeartbeat() {
|
||||
heartbeatTimer?.invalidate()
|
||||
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
|
||||
self?.performHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performHeartbeat() {
|
||||
func performHeartbeat() {
|
||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
||||
defer { scheduleNextHeartbeat() }
|
||||
|
||||
// 1. Check preconditions
|
||||
guard TorManager.shared.isReady else {
|
||||
guard torIsReady() else {
|
||||
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// App must be active (or at least we shouldn't broadcast if in background, usually)
|
||||
if !TorManager.shared.isForeground() {
|
||||
if !torIsForeground() {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Get channels
|
||||
let channels = LocationStateManager.shared.availableChannels
|
||||
let channels = availableChannelsProvider()
|
||||
guard !channels.isEmpty else { return }
|
||||
|
||||
// 3. Filter and broadcast
|
||||
@@ -136,16 +232,16 @@ final class GeohashPresenceService: ObservableObject {
|
||||
// Random delay for decorrelation
|
||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
await self.sleeper(nanoseconds)
|
||||
|
||||
self.broadcastPresence(for: channel.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func broadcastPresence(for geohash: String) {
|
||||
func broadcastPresence(for geohash: String) {
|
||||
do {
|
||||
guard let identity = try? idBridge.deriveIdentity(forGeohash: geohash) else {
|
||||
guard let identity = try? deriveIdentity(geohash) else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -155,13 +251,10 @@ final class GeohashPresenceService: ObservableObject {
|
||||
)
|
||||
|
||||
// Send via RelayManager
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: geohash,
|
||||
count: TransportConfig.nostrGeoRelayCount
|
||||
)
|
||||
let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||
|
||||
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)
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -63,7 +63,7 @@ final class LocationNotesManager: ObservableObject {
|
||||
|
||||
var displayName: String {
|
||||
let suffix = String(pubkey.suffix(4))
|
||||
if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if let nick = nickname?.trimmedOrNilIfEmpty {
|
||||
return "\(nick)#\(suffix)"
|
||||
}
|
||||
return "anon#\(suffix)"
|
||||
@@ -199,8 +199,7 @@ final class LocationNotesManager: ObservableObject {
|
||||
|
||||
/// Send a location note for the current geohash using the per-geohash identity.
|
||||
func send(content: String, nickname: String) {
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
state = .noRelays
|
||||
|
||||
@@ -5,6 +5,79 @@ import Combine
|
||||
#if os(iOS) || os(macOS)
|
||||
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 {
|
||||
base.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:
|
||||
/// - CoreLocation permissions and one-shot location retrieval
|
||||
/// - Geohash channel computation from coordinates
|
||||
@@ -26,8 +99,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
|
||||
// MARK: - Private Properties (CoreLocation)
|
||||
|
||||
private let cl = CLLocationManager()
|
||||
private let geocoder = CLGeocoder()
|
||||
private let cl: LocationStateManaging
|
||||
private let geocoder: LocationStateGeocoding
|
||||
private var lastLocation: CLLocation?
|
||||
private var refreshTimer: Timer?
|
||||
private var isGeocoding: Bool = false
|
||||
@@ -73,6 +146,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
|
||||
private override init() {
|
||||
self.storage = .standard
|
||||
self.cl = CLLocationManagerAdapter()
|
||||
self.geocoder = CLGeocoderAdapter()
|
||||
super.init()
|
||||
|
||||
// Skip CoreLocation setup in test environments
|
||||
@@ -92,10 +167,30 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
/// Internal initializer for testing with custom storage
|
||||
init(storage: UserDefaults) {
|
||||
self.storage = storage
|
||||
self.cl = CLLocationManagerAdapter()
|
||||
self.geocoder = CLGeocoderAdapter()
|
||||
super.init()
|
||||
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() {
|
||||
// Load selected channel
|
||||
if let data = storage.data(forKey: selectedChannelKey),
|
||||
@@ -132,12 +227,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
}
|
||||
|
||||
private func initializePermissionState() {
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
let status = cl.authorizationStatus
|
||||
updatePermissionState(from: status)
|
||||
|
||||
// Fall back to persisted teleport state if no location authorization
|
||||
@@ -156,12 +246,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
// MARK: - Public API (Permissions & Location)
|
||||
|
||||
func enableLocationChannels() {
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
let status = cl.authorizationStatus
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
cl.requestWhenInUseAuthorization()
|
||||
@@ -512,7 +597,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
||||
private static func normalizeGeohash(_ s: String) -> String {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
return s
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmed
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
|
||||
@@ -145,7 +145,7 @@ enum ContentNormalizer {
|
||||
}
|
||||
|
||||
// Trim and collapse whitespace
|
||||
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmed = simplified.trimmed
|
||||
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
|
||||
// Take prefix and hash
|
||||
|
||||
@@ -3,6 +3,27 @@ import BitLogger
|
||||
import Combine
|
||||
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.
|
||||
/// Policy: permit start when either location permissions are authorized OR
|
||||
/// there exists at least one mutual favorite. Otherwise, do not start.
|
||||
@@ -17,14 +38,55 @@ final class NetworkActivationService: ObservableObject {
|
||||
private var started = false
|
||||
private let torPreferenceKey = "networkActivationService.userTorEnabled"
|
||||
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() {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
|
||||
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
|
||||
if let stored = storage.object(forKey: torPreferenceKey) as? Bool {
|
||||
userTorEnabled = stored
|
||||
} else {
|
||||
userTorEnabled = true
|
||||
@@ -34,16 +96,16 @@ final class NetworkActivationService: ObservableObject {
|
||||
let allowed = basePolicyAllowed()
|
||||
activationAllowed = allowed
|
||||
torAutoStartDesired = allowed && userTorEnabled
|
||||
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
|
||||
torController.setAutoStartAllowed(torAutoStartDesired)
|
||||
applyTorState(torDesired: torAutoStartDesired)
|
||||
if allowed {
|
||||
NostrRelayManager.shared.connect()
|
||||
relayController.connect()
|
||||
} else {
|
||||
NostrRelayManager.shared.disconnect()
|
||||
relayController.disconnect()
|
||||
}
|
||||
|
||||
// React to location permission changes
|
||||
LocationChannelManager.shared.$permissionState
|
||||
locationPermissionPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.reevaluate()
|
||||
@@ -51,7 +113,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
.store(in: &cancellables)
|
||||
|
||||
// React to mutual favorites changes
|
||||
FavoritesPersistenceService.shared.$mutualFavorites
|
||||
mutualFavoritesPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.reevaluate()
|
||||
@@ -62,8 +124,8 @@ final class NetworkActivationService: ObservableObject {
|
||||
func setUserTorEnabled(_ enabled: Bool) {
|
||||
guard enabled != userTorEnabled else { return }
|
||||
userTorEnabled = enabled
|
||||
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
|
||||
NotificationCenter.default.post(
|
||||
storage.set(enabled, forKey: torPreferenceKey)
|
||||
notificationCenter.post(
|
||||
name: .TorUserPreferenceChanged,
|
||||
object: nil,
|
||||
userInfo: ["enabled": enabled]
|
||||
@@ -82,33 +144,33 @@ final class NetworkActivationService: ObservableObject {
|
||||
}
|
||||
if statusChanged || torChanged {
|
||||
torAutoStartDesired = torDesired
|
||||
TorManager.shared.setAutoStartAllowed(torDesired)
|
||||
torController.setAutoStartAllowed(torDesired)
|
||||
applyTorState(torDesired: torDesired)
|
||||
}
|
||||
|
||||
if allowed {
|
||||
if torChanged {
|
||||
// Reset relay sockets when switching transport path (Tor ↔︎ direct)
|
||||
NostrRelayManager.shared.disconnect()
|
||||
relayController.disconnect()
|
||||
}
|
||||
NostrRelayManager.shared.connect()
|
||||
relayController.connect()
|
||||
} else if statusChanged {
|
||||
NostrRelayManager.shared.disconnect()
|
||||
relayController.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func basePolicyAllowed() -> Bool {
|
||||
let permOK = LocationChannelManager.shared.permissionState == .authorized
|
||||
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||
let permOK = permissionProvider() == .authorized
|
||||
let hasMutual = !mutualFavoritesProvider().isEmpty
|
||||
return permOK || hasMutual
|
||||
}
|
||||
|
||||
private func applyTorState(torDesired: Bool) {
|
||||
TorURLSession.shared.setProxyMode(useTor: torDesired)
|
||||
proxyController.setProxyMode(useTor: torDesired)
|
||||
if torDesired {
|
||||
TorManager.shared.startIfNeeded()
|
||||
torController.startIfNeeded()
|
||||
} else {
|
||||
TorManager.shared.shutdownCompletely()
|
||||
torController.shutdownCompletely()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,32 @@ import Combine
|
||||
|
||||
// Minimal Nostr transport conforming to Transport for offline sending
|
||||
final class NostrTransport: Transport, @unchecked Sendable {
|
||||
struct Dependencies {
|
||||
let notificationCenter: NotificationCenter
|
||||
let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship]
|
||||
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
||||
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
||||
|
||||
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
|
||||
Dependencies(
|
||||
notificationCenter: .default,
|
||||
loadFavorites: { FavoritesPersistenceService.shared.favorites },
|
||||
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
|
||||
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
||||
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
||||
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
|
||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Provide BLE short peer ID for BitChat embedding
|
||||
var senderPeerID = PeerID(str: "")
|
||||
|
||||
@@ -17,20 +43,27 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private let dependencies: Dependencies
|
||||
private var favoriteStatusObserver: NSObjectProtocol?
|
||||
|
||||
// Reachability Cache (thread-safe)
|
||||
private var reachablePeers: Set<PeerID> = []
|
||||
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
||||
|
||||
@MainActor
|
||||
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
||||
init(
|
||||
keychain: KeychainManagerProtocol,
|
||||
idBridge: NostrIdentityBridge,
|
||||
dependencies: Dependencies? = nil
|
||||
) {
|
||||
self.keychain = keychain
|
||||
self.idBridge = idBridge
|
||||
self.dependencies = dependencies ?? .live(idBridge: idBridge)
|
||||
|
||||
setupObservers()
|
||||
|
||||
// Synchronously warm the cache to avoid startup race
|
||||
let favorites = FavoritesPersistenceService.shared.favorites
|
||||
let favorites = self.dependencies.loadFavorites()
|
||||
let reachable = favorites.values
|
||||
.filter { $0.peerNostrPublicKey != nil }
|
||||
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
||||
@@ -40,8 +73,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let favoriteStatusObserver {
|
||||
dependencies.notificationCenter.removeObserver(favoriteStatusObserver)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupObservers() {
|
||||
NotificationCenter.default.addObserver(
|
||||
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
||||
forName: .favoriteStatusChanged,
|
||||
object: nil,
|
||||
queue: nil
|
||||
@@ -52,7 +91,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
|
||||
private func refreshReachablePeers() {
|
||||
Task { @MainActor in
|
||||
let favorites = FavoritesPersistenceService.shared.favorites
|
||||
let favorites = dependencies.loadFavorites()
|
||||
let reachable = favorites.values
|
||||
.filter { $0.peerNostrPublicKey != nil }
|
||||
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
||||
@@ -120,7 +159,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||
@@ -143,7 +182,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
@@ -159,7 +198,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
Task { @MainActor in
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
@@ -229,9 +268,9 @@ extension NostrTransport {
|
||||
return
|
||||
}
|
||||
if registerPending {
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
dependencies.registerPendingGiftWrap(event.id)
|
||||
}
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
dependencies.sendEvent(event)
|
||||
}
|
||||
|
||||
/// Must be called within a barrier on `queue`
|
||||
@@ -249,7 +288,7 @@ extension NostrTransport {
|
||||
defer { scheduleNextReadAck() }
|
||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
||||
let recipientHex = npubToHex(recipientNpub),
|
||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||
@@ -260,7 +299,7 @@ extension NostrTransport {
|
||||
}
|
||||
|
||||
private func scheduleNextReadAck() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in
|
||||
dependencies.scheduleAfter(readAckInterval) { [weak self] in
|
||||
self?.queue.async(flags: .barrier) { [weak self] in
|
||||
self?.isSendingReadAcks = false
|
||||
self?.processReadQueueIfNeeded()
|
||||
@@ -271,12 +310,12 @@ extension NostrTransport {
|
||||
@MainActor
|
||||
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
|
||||
if let noiseKey = Data(hexString: peerID.id),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
let fav = dependencies.favoriteStatusForNoiseKey(noiseKey),
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
}
|
||||
if peerID.id.count == 16,
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
let fav = dependencies.favoriteStatusForPeerID(peerID),
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
}
|
||||
|
||||
@@ -14,24 +14,103 @@ import UIKit
|
||||
import AppKit
|
||||
#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 {
|
||||
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)
|
||||
private var isRunningTests: Bool {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
return NSClassFromString("XCTestCase") != nil ||
|
||||
env["XCTestConfigurationFilePath"] != nil ||
|
||||
env["XCTestBundlePath"] != nil ||
|
||||
env["GITHUB_ACTIONS"] != nil ||
|
||||
env["CI"] != nil
|
||||
isRunningTestsProvider()
|
||||
}
|
||||
|
||||
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() {
|
||||
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 {
|
||||
// Permission granted
|
||||
} else {
|
||||
@@ -64,7 +143,7 @@ final class NotificationService {
|
||||
trigger: nil // Deliver immediately
|
||||
)
|
||||
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
requestDeliverer.add(request)
|
||||
}
|
||||
|
||||
func sendMentionNotification(from sender: String, message: String) {
|
||||
@@ -96,7 +175,8 @@ final class NotificationService {
|
||||
func sendNetworkAvailableNotification(peerCount: Int) {
|
||||
let title = "👥 bitchatters nearby!"
|
||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
||||
// Fixed identifier so iOS updates the existing notification instead of creating new ones
|
||||
let identifier = "network-available"
|
||||
|
||||
sendLocalNotification(
|
||||
title: title,
|
||||
|
||||
@@ -21,6 +21,7 @@ enum TransportConfig {
|
||||
|
||||
// Timers
|
||||
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
|
||||
static let networkNotificationCooldownSeconds: TimeInterval = 300 // 5 minutes
|
||||
static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
|
||||
|
||||
// BLE duty/announce/connect
|
||||
|
||||
@@ -17,14 +17,21 @@ final class RequestSyncManager {
|
||||
|
||||
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
|
||||
private var pendingRequests: [PeerID: TimeInterval] = [:]
|
||||
private let responseWindow: TimeInterval
|
||||
private let now: () -> TimeInterval
|
||||
|
||||
// Allow responses for 30s after request
|
||||
private let responseWindow: TimeInterval = 30.0
|
||||
init(
|
||||
responseWindow: TimeInterval = 30.0,
|
||||
now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }
|
||||
) {
|
||||
self.responseWindow = responseWindow
|
||||
self.now = now
|
||||
}
|
||||
|
||||
/// Register that we are sending a sync request to a peer.
|
||||
/// - Parameter peerID: The peer we are requesting sync from
|
||||
func registerRequest(to peerID: PeerID) {
|
||||
let now = Date().timeIntervalSince1970
|
||||
let now = self.now()
|
||||
queue.async(flags: .barrier) {
|
||||
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))…", category: .sync)
|
||||
self.pendingRequests[peerID] = now
|
||||
@@ -46,7 +53,7 @@ final class RequestSyncManager {
|
||||
return false
|
||||
}
|
||||
|
||||
let now = Date().timeIntervalSince1970
|
||||
let now = self.now()
|
||||
if now - requestTime > responseWindow {
|
||||
SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security)
|
||||
// We don't remove here because we might receive multiple packets for one request
|
||||
@@ -59,7 +66,7 @@ final class RequestSyncManager {
|
||||
|
||||
/// Periodic cleanup of expired requests
|
||||
func cleanup() {
|
||||
let now = Date().timeIntervalSince1970
|
||||
let now = self.now()
|
||||
queue.async(flags: .barrier) {
|
||||
let originalCount = self.pendingRequests.count
|
||||
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
|
||||
@@ -71,4 +78,8 @@ final class RequestSyncManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var debugPendingRequestCount: Int {
|
||||
queue.sync { pendingRequests.count }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,7 @@ struct InputValidator {
|
||||
/// Rejects strings containing control characters to prevent potential security issues
|
||||
/// and UI rendering problems. This strict approach ensures data integrity at input time.
|
||||
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard trimmed.count <= maxLength else { return nil }
|
||||
guard let trimmed = string.trimmedOrNilIfEmpty, trimmed.count <= maxLength else { return nil }
|
||||
|
||||
// Reject control characters outright instead of rewriting the string.
|
||||
// This prevents injection attacks and ensures consistent UI rendering.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// String+Ext.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
extension StringProtocol {
|
||||
var trimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
var trimmedOrNilIfEmpty: String? {
|
||||
let trimmed = self.trimmed
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
var nilIfEmpty: Self? {
|
||||
isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// SystemSettings.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
enum SystemSettings {
|
||||
case bluetooth
|
||||
case location
|
||||
case microphone
|
||||
|
||||
#if os(macOS)
|
||||
private static let baseURL = "x-apple.systempreferences:com.apple.preference.security"
|
||||
|
||||
private var macPrivacyAnchor: String {
|
||||
switch self {
|
||||
case .bluetooth: "Privacy_Bluetooth"
|
||||
case .location: "Privacy_LocationServices"
|
||||
case .microphone: "Privacy_Microphone"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func open() {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
let urlString = "\(Self.baseURL)?\(macPrivacyAnchor)"
|
||||
if let url = URL(string: urlString) {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -144,10 +144,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
|
||||
@Published var nickname: String = "" {
|
||||
didSet {
|
||||
// Trim whitespace whenever nickname is set
|
||||
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
|
||||
let trimmed = nickname.trimmedOrNilIfEmpty ?? ""
|
||||
if trimmed != nickname {
|
||||
nickname = trimmed
|
||||
return
|
||||
}
|
||||
// Update mesh service nickname if it's initialized
|
||||
if !meshService.myPeerID.isEmpty {
|
||||
@@ -747,8 +748,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
|
||||
private func loadNickname() {
|
||||
if let savedNickname = userDefaults.string(forKey: nicknameKey) {
|
||||
// Trim whitespace when loading
|
||||
nickname = savedNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
nickname = savedNickname.trimmed
|
||||
} else {
|
||||
nickname = "anon\(Int.random(in: 1000...9999))"
|
||||
saveNickname()
|
||||
@@ -764,20 +764,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
}
|
||||
|
||||
func validateAndSaveNickname() {
|
||||
// Trim whitespace from nickname
|
||||
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Check if nickname is empty after trimming
|
||||
if trimmed.isEmpty {
|
||||
nickname = "anon\(Int.random(in: 1000...9999))"
|
||||
} else {
|
||||
nickname = trimmed
|
||||
}
|
||||
nickname = nickname.trimmedOrNilIfEmpty ?? "anon\(Int.random(in: 1000...9999))"
|
||||
saveNickname()
|
||||
}
|
||||
|
||||
// MARK: - Favorites Management
|
||||
|
||||
|
||||
// MARK: - Blocked Users Management (Delegated to PeerStateManager)
|
||||
|
||||
|
||||
@@ -998,8 +988,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
@MainActor
|
||||
func sendMessage(_ content: String) {
|
||||
// Ignore messages that are empty or whitespace-only to prevent blank lines
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||
|
||||
// Check for commands
|
||||
if content.hasPrefix("/") {
|
||||
@@ -1810,7 +1799,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func getMessages(for peerID: PeerID?) -> [BitchatMessage] {
|
||||
if let peerID {
|
||||
return getPrivateChatMessages(for: peerID)
|
||||
} else {
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||
var combined: [BitchatMessage] = []
|
||||
@@ -3002,7 +2999,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
Task { @MainActor in
|
||||
// Early validation
|
||||
guard !isMessageBlocked(message) else { return }
|
||||
guard !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.isPrivate else { return }
|
||||
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
|
||||
|
||||
// Route to appropriate handler
|
||||
if message.isPrivate {
|
||||
@@ -3175,7 +3172,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
Task { @MainActor in
|
||||
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalized = content.trimmed
|
||||
let publicMentions = parseMentions(from: normalized)
|
||||
let msg = BitchatMessage(
|
||||
id: messageID,
|
||||
@@ -3348,18 +3345,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
self.scheduleNetworkEmptyTimer()
|
||||
} else {
|
||||
self.invalidateNetworkEmptyTimer()
|
||||
// Trim out peers we no longer observe before comparing for new arrivals
|
||||
self.recentlySeenPeers.formIntersection(meshPeerSet)
|
||||
// Don't trim recentlySeenPeers here - let timers handle cleanup.
|
||||
// Trimming immediately causes peers to be treated as "new" when they
|
||||
// briefly drop and reconnect, triggering notification floods.
|
||||
let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers)
|
||||
|
||||
|
||||
if !newPeers.isEmpty {
|
||||
self.lastNetworkNotificationTime = Date()
|
||||
self.recentlySeenPeers.formUnion(newPeers)
|
||||
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
|
||||
SecureLogger.info(
|
||||
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
|
||||
category: .session
|
||||
)
|
||||
// Rate limit: max one notification per 5 minutes
|
||||
let cooldown = TransportConfig.networkNotificationCooldownSeconds
|
||||
if Date().timeIntervalSince(self.lastNetworkNotificationTime) >= cooldown {
|
||||
// Only mark peers as seen when we actually notify about them
|
||||
// This ensures peers arriving during cooldown will be included in the next notification
|
||||
self.recentlySeenPeers.formUnion(newPeers)
|
||||
self.lastNetworkNotificationTime = Date()
|
||||
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
|
||||
SecureLogger.info(
|
||||
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
self.scheduleNetworkResetTimer()
|
||||
}
|
||||
}
|
||||
@@ -3776,10 +3780,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
||||
// Removed background nudge notification for generic "new chats!"
|
||||
|
||||
// Append via batching buffer (skip empty content) with simple dedup by ID
|
||||
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if !messages.contains(where: { $0.id == finalMessage.id }) {
|
||||
publicMessagePipeline.enqueue(finalMessage)
|
||||
}
|
||||
if !finalMessage.content.trimmed.isEmpty, !messages.contains(where: { $0.id == finalMessage.id }) {
|
||||
publicMessagePipeline.enqueue(finalMessage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||
guard event.isValidSignature() else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
||||
!deduplicationService.hasProcessedNostrEvent(event.id)
|
||||
@@ -76,7 +77,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let nick = nickTag[1].trimmed
|
||||
geoNicknames[event.pubkey.lowercased()] = nick
|
||||
}
|
||||
|
||||
@@ -114,8 +115,8 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let content = event.content.trimmed
|
||||
|
||||
// Clamp future timestamps to now to avoid future-dated messages skewing order
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let timestamp = min(rawTs, Date())
|
||||
@@ -146,13 +147,12 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||
deduplicationService.recordNostrEvent(giftWrap.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id),
|
||||
content.hasPrefix("bitchat1:"),
|
||||
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData),
|
||||
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let noisePayload = NoisePayload.decode(packet.payload)
|
||||
else {
|
||||
@@ -192,7 +192,7 @@ extension ChatViewModel {
|
||||
case .mesh:
|
||||
refreshVisibleMessages(from: .mesh)
|
||||
// Debug: log if any empty messages are present
|
||||
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
|
||||
let emptyMesh = messages.filter { $0.content.trimmed.isEmpty }.count
|
||||
if emptyMesh > 0 {
|
||||
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
|
||||
}
|
||||
@@ -254,6 +254,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func handleNostrEvent(_ event: NostrEvent) {
|
||||
guard event.isValidSignature() else { return }
|
||||
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
||||
@@ -307,8 +308,7 @@ extension ChatViewModel {
|
||||
|
||||
// Cache nickname from tag if present
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
geoNicknames[event.pubkey.lowercased()] = nick
|
||||
geoNicknames[event.pubkey.lowercased()] = nickTag[1].trimmed
|
||||
}
|
||||
|
||||
// Store mapping for geohash DM initiation
|
||||
@@ -324,8 +324,10 @@ extension ChatViewModel {
|
||||
let content = event.content
|
||||
|
||||
// If this is a teleport presence event (no content), don't add to timeline
|
||||
if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"),
|
||||
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if let teleTag = event.tags.first(where: { $0.first == "t" }),
|
||||
teleTag.count >= 2,
|
||||
teleTag[1] == "teleport",
|
||||
content.trimmed.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -367,6 +369,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
|
||||
return
|
||||
}
|
||||
@@ -380,9 +383,7 @@ extension ChatViewModel {
|
||||
|
||||
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
|
||||
|
||||
guard content.hasPrefix("bitchat1:"),
|
||||
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData),
|
||||
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload)
|
||||
else {
|
||||
@@ -485,6 +486,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
||||
guard event.isValidSignature() else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
||||
|
||||
@@ -495,9 +497,8 @@ extension ChatViewModel {
|
||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||
|
||||
// Notify only on rising-edge: previously zero people, now someone sends a chat
|
||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty else { return }
|
||||
|
||||
guard let content = event.content.trimmedOrNilIfEmpty else { return }
|
||||
|
||||
// Respect geohash blocks
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||
|
||||
@@ -602,6 +603,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||
|
||||
do {
|
||||
@@ -619,8 +621,7 @@ extension ChatViewModel {
|
||||
|
||||
// Check if it's a BitChat packet embedded in the content (bitchat1:...)
|
||||
if content.hasPrefix("bitchat1:") {
|
||||
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData) else {
|
||||
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content) else {
|
||||
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -631,24 +632,23 @@ extension ChatViewModel {
|
||||
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
|
||||
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
|
||||
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||
if let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
// Store Nostr mapping
|
||||
await MainActor.run {
|
||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
|
||||
// Handle packet types
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
// Store Nostr mapping
|
||||
await MainActor.run {
|
||||
nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
|
||||
// Handle packet types
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -801,6 +801,19 @@ extension ChatViewModel {
|
||||
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
}
|
||||
|
||||
private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
||||
guard content.hasPrefix("bitchat1:") else { return nil }
|
||||
let encoded = String(content.dropFirst("bitchat1:".count))
|
||||
let maxBytes = FileTransferLimits.maxFramedFileBytes
|
||||
// Base64url length upper bound for maxBytes (padded length; unpadded is <= this).
|
||||
let maxEncoded = ((maxBytes + 2) / 3) * 4
|
||||
guard encoded.count <= maxEncoded else { return nil }
|
||||
guard let packetData = Self.base64URLDecode(encoded),
|
||||
packetData.count <= maxBytes
|
||||
else { return nil }
|
||||
return BitchatPacket.from(packetData)
|
||||
}
|
||||
|
||||
// MARK: - Geohash Nickname Resolution (for /block in geohash)
|
||||
|
||||
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
||||
|
||||
@@ -319,7 +319,7 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
let targetPeer = selectedPrivateChatPeer
|
||||
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer)
|
||||
let message = enqueueMediaMessage(content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", targetPeer: targetPeer)
|
||||
let messageID = message.id
|
||||
let transferId = makeTransferID(messageID: messageID)
|
||||
|
||||
@@ -364,6 +364,36 @@ extension ChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func processThenSendImage(_ image: UIImage?) {
|
||||
guard let image else { return }
|
||||
Task.detached {
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(image)
|
||||
await MainActor.run {
|
||||
self.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
func processThenSendImage(from url: URL?) {
|
||||
guard let url else { return }
|
||||
Task.detached {
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(at: url)
|
||||
await MainActor.run {
|
||||
self.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
|
||||
guard canSendMediaInCurrentContext else {
|
||||
@@ -398,7 +428,7 @@ extension ChatViewModel {
|
||||
)
|
||||
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
|
||||
await MainActor.run {
|
||||
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer)
|
||||
let message = self.enqueueMediaMessage(content: "\(MimeType.Category.image.messagePrefix)\(outputURL.lastPathComponent)", targetPeer: targetPeer)
|
||||
let messageID = message.id
|
||||
let transferId = self.makeTransferID(messageID: messageID)
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
@@ -517,20 +547,19 @@ extension ChatViewModel {
|
||||
|
||||
func cleanupLocalFile(forMessage message: BitchatMessage) {
|
||||
// Check both outgoing and incoming directories for thorough cleanup
|
||||
let prefixes = ["[voice] ", "[image] ", "[file] "]
|
||||
let subdirs = ["voicenotes/outgoing", "voicenotes/incoming",
|
||||
"images/outgoing", "images/incoming",
|
||||
"files/outgoing", "files/incoming"]
|
||||
|
||||
guard let prefix = prefixes.first(where: { message.content.hasPrefix($0) }) else { return }
|
||||
let rawFilename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return }
|
||||
|
||||
// Security: Extract only the last path component to prevent directory traversal
|
||||
let safeFilename = (rawFilename as NSString).lastPathComponent
|
||||
guard !safeFilename.isEmpty && safeFilename != "." && safeFilename != ".." else { return }
|
||||
let categories: [MimeType.Category] = [.audio, .image, .file]
|
||||
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
|
||||
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
|
||||
let base = try? applicationFilesDirectory(),
|
||||
// Security: Extract only the last path component to prevent directory traversal
|
||||
let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty,
|
||||
safeFilename != "." && safeFilename != ".."
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
// Try all possible locations (outgoing and incoming)
|
||||
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] }
|
||||
for subdir in subdirs {
|
||||
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//
|
||||
// VoiceRecordingViewModel.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class VoiceRecordingViewModel: ObservableObject {
|
||||
enum State: Equatable {
|
||||
case idle
|
||||
case requestingPermission
|
||||
case permissionDenied
|
||||
case preparing
|
||||
case recording(startDate: Date)
|
||||
case error(message: String)
|
||||
|
||||
var isActive: Bool {
|
||||
switch self {
|
||||
case .preparing, .recording: true
|
||||
case .idle, .requestingPermission, .permissionDenied, .error: false
|
||||
}
|
||||
}
|
||||
|
||||
var alertMessage: String {
|
||||
switch self {
|
||||
case .error(let message): message
|
||||
case .permissionDenied: "Microphone access is required to record voice notes."
|
||||
case .idle, .requestingPermission, .preparing, .recording: ""
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func duration(for date: Date) -> TimeInterval {
|
||||
switch self {
|
||||
case .idle, .requestingPermission, .preparing, .permissionDenied, .error: 0
|
||||
case .recording(let startDate): date.timeIntervalSince(startDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var showAlert: Bool {
|
||||
get {
|
||||
switch state {
|
||||
case .permissionDenied, .error: true
|
||||
case .idle, .requestingPermission, .preparing, .recording: false
|
||||
}
|
||||
}
|
||||
set {
|
||||
if !newValue { state = .idle }
|
||||
}
|
||||
}
|
||||
|
||||
@Published private(set) var state = State.idle
|
||||
|
||||
func formattedDuration(for date: Date) -> String {
|
||||
let clamped = max(0, state.duration(for: date))
|
||||
let totalMilliseconds = Int(clamped * 1000)
|
||||
let minutes = totalMilliseconds / 60_000
|
||||
let seconds = (totalMilliseconds % 60_000) / 1_000
|
||||
let centiseconds = (totalMilliseconds % 1_000) / 10
|
||||
return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds)
|
||||
}
|
||||
|
||||
func start(shouldShow: Bool) {
|
||||
guard shouldShow, state == .idle else { return }
|
||||
state = .requestingPermission
|
||||
Task {
|
||||
let granted = await VoiceRecorder.shared.requestPermission()
|
||||
guard state == .requestingPermission else { return }
|
||||
guard granted else {
|
||||
state = .permissionDenied
|
||||
return
|
||||
}
|
||||
state = .preparing
|
||||
do {
|
||||
try await VoiceRecorder.shared.startRecording()
|
||||
guard state == .preparing else {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
state = .recording(startDate: Date())
|
||||
} catch {
|
||||
SecureLogger.error("Voice recording failed to start: \(error)", category: .session)
|
||||
await VoiceRecorder.shared.cancelRecording()
|
||||
guard state == .preparing else { return }
|
||||
state = .error(message: "Could not start recording.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func finish(completion: ((URL) -> Void)?) {
|
||||
let previousState = state
|
||||
|
||||
switch previousState {
|
||||
case .permissionDenied, .error:
|
||||
return
|
||||
case .idle, .requestingPermission, .preparing, .recording:
|
||||
break
|
||||
}
|
||||
|
||||
state = .idle
|
||||
|
||||
guard case .recording(let startDate) = previousState, let completion else {
|
||||
Task { await VoiceRecorder.shared.cancelRecording() }
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
let finalDuration = Date().timeIntervalSince(startDate)
|
||||
if let url = await VoiceRecorder.shared.stopRecording(),
|
||||
isValidRecording(at: url, duration: finalDuration) {
|
||||
completion(url)
|
||||
} else {
|
||||
guard state == .idle else { return }
|
||||
state = .error(
|
||||
message: finalDuration < VoiceRecorder.minRecordingDuration
|
||||
? "Recording is too short."
|
||||
: "Recording failed to save."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
finish(completion: nil)
|
||||
}
|
||||
|
||||
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
|
||||
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let fileSize = attributes[.size] as? NSNumber,
|
||||
fileSize.intValue > 0,
|
||||
duration >= VoiceRecorder.minRecordingDuration {
|
||||
return true
|
||||
}
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ struct TextMessageView: View {
|
||||
@EnvironmentObject private var viewModel: ChatViewModel
|
||||
|
||||
let message: BitchatMessage
|
||||
@Binding var expandedMessageIDs: Set<String>
|
||||
@State private var expandedMessageIDs: Set<String> = []
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -66,14 +66,12 @@ struct TextMessageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 14, iOS 17, *)
|
||||
#Preview {
|
||||
@Previewable @State var ids: Set<String> = []
|
||||
let keychain = PreviewKeychainManager()
|
||||
|
||||
Group {
|
||||
List {
|
||||
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||
TextMessageView(message: .preview)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
@@ -81,7 +79,7 @@ struct TextMessageView: View {
|
||||
.environment(\.colorScheme, .light)
|
||||
|
||||
List {
|
||||
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||
TextMessageView(message: .preview)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(EmptyView())
|
||||
|
||||
+96
-1009
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// ImagePickerView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Camera or Photo Library
|
||||
struct ImagePickerView: UIViewControllerRepresentable {
|
||||
let sourceType: UIImagePickerController.SourceType
|
||||
let completion: (UIImage?) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||
let picker = UIImagePickerController()
|
||||
picker.sourceType = sourceType
|
||||
picker.delegate = context.coordinator
|
||||
picker.allowsEditing = false
|
||||
|
||||
// Use standard full screen - iOS handles safe areas automatically
|
||||
picker.modalPresentationStyle = .fullScreen
|
||||
|
||||
// Force dark mode to make safe area bars black instead of white
|
||||
picker.overrideUserInterfaceStyle = .dark
|
||||
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(completion: completion)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||
let completion: (UIImage?) -> Void
|
||||
|
||||
init(completion: @escaping (UIImage?) -> Void) {
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||
let image = info[.originalImage] as? UIImage
|
||||
completion(image)
|
||||
}
|
||||
|
||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17, *)
|
||||
#Preview {
|
||||
@Previewable @State var isPresented = true
|
||||
@Previewable @State var selectedImage: UIImage?
|
||||
VStack {
|
||||
if let selectedImage {
|
||||
Image(uiImage: selectedImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else {
|
||||
Text("No image selected")
|
||||
}
|
||||
Button("Show") { isPresented = true }
|
||||
}
|
||||
.sheet(isPresented: $isPresented) {
|
||||
ImagePickerView(sourceType: .photoLibrary) { image in
|
||||
selectedImage = image
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,147 @@
|
||||
//
|
||||
// ImagePreviewView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import BitLogger
|
||||
#endif
|
||||
|
||||
struct ImagePreviewView: View {
|
||||
let url: URL
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
#if os(iOS)
|
||||
@State private var showExporter = false
|
||||
@State private var platformImage: UIImage?
|
||||
#else
|
||||
@State private var platformImage: NSImage?
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
VStack {
|
||||
Spacer()
|
||||
if let image = platformImage {
|
||||
#if os(iOS)
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding()
|
||||
#else
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding()
|
||||
#endif
|
||||
} else {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.tint(.white)
|
||||
}
|
||||
Spacer()
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Text("close", comment: "Button to dismiss fullscreen media viewer")
|
||||
.font(.bitchatSystem(size: 15, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(RoundedRectangle(cornerRadius: 12).stroke(Color.white.opacity(0.5), lineWidth: 1))
|
||||
}
|
||||
Spacer()
|
||||
Button(action: saveCopy) {
|
||||
Text("save", comment: "Button to save media to device")
|
||||
.font(.bitchatSystem(size: 15, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.blue.opacity(0.6)))
|
||||
}
|
||||
}
|
||||
.padding([.horizontal, .bottom], 24)
|
||||
}
|
||||
}
|
||||
.onAppear(perform: loadImage)
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $showExporter) {
|
||||
FileExportWrapper(url: url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func loadImage() {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
#if os(iOS)
|
||||
guard let image = UIImage(contentsOfFile: url.path) else { return }
|
||||
#else
|
||||
guard let image = NSImage(contentsOf: url) else { return }
|
||||
#endif
|
||||
DispatchQueue.main.async {
|
||||
self.platformImage = image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveCopy() {
|
||||
#if os(iOS)
|
||||
showExporter = true
|
||||
#else
|
||||
Task { @MainActor in
|
||||
let panel = NSSavePanel()
|
||||
panel.canCreateDirectories = true
|
||||
panel.nameFieldStringValue = url.lastPathComponent
|
||||
panel.prompt = "save"
|
||||
if panel.runModal() == .OK, let destination = panel.url {
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
try FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
try FileManager.default.copyItem(at: url, to: destination)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to save image preview copy: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private struct FileExportWrapper: UIViewControllerRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
let controller = UIDocumentPickerViewController(forExporting: [url])
|
||||
controller.shouldShowFileExtensions = true
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("dummy.jpg")
|
||||
if !FileManager.default.fileExists(atPath: tempURL.path(percentEncoded: false)) {
|
||||
#if os(iOS)
|
||||
let image = UIImage(named: "dummy")
|
||||
let data = image?.jpegData(compressionQuality: 0.8)
|
||||
let _ = try? data?.write(to: tempURL)
|
||||
#elseif os(macOS)
|
||||
let image = NSImage(named: "dummy")
|
||||
var rect = NSRect(origin: .zero, size: image?.size ?? .zero)
|
||||
if let cgImage = image?.cgImage(forProposedRect: &rect, context: nil, hints: nil) {
|
||||
let rep = NSBitmapImageRep(cgImage: cgImage)
|
||||
let jpegData = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.8])
|
||||
let _ = try? jpegData?.write(to: tempURL)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
ImagePreviewView(url: tempURL)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// MacImagePickerView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MacImagePickerView: View {
|
||||
let completion: (URL?) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("Choose an image")
|
||||
.font(.headline)
|
||||
|
||||
Button("Select Image") {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.allowedContentTypes = [.image, .png, .jpeg, .heic]
|
||||
panel.message = "Choose an image to send"
|
||||
|
||||
if panel.runModal() == .OK {
|
||||
completion(panel.url)
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Cancel") {
|
||||
completion(nil)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(40)
|
||||
.frame(minWidth: 300, minHeight: 150)
|
||||
}
|
||||
}
|
||||
|
||||
@available(OSX 14, *)
|
||||
#Preview {
|
||||
@Previewable @State var isPresented = true
|
||||
@Previewable @State var selectedImage: NSImage?
|
||||
|
||||
VStack {
|
||||
if let selectedImage {
|
||||
Image(nsImage: selectedImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else {
|
||||
Text("No image selected")
|
||||
}
|
||||
Button("Show") { isPresented = true }
|
||||
}
|
||||
.sheet(isPresented: $isPresented) {
|
||||
MacImagePickerView { url in
|
||||
selectedImage = url.map(NSImage.init)
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -125,7 +125,7 @@ struct LocationChannelsSheet: View {
|
||||
Text(Strings.permissionDenied)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Button(Strings.openSettings) { openSystemLocationSettings() }
|
||||
Button(Strings.openSettings, action: SystemSettings.location.open)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
case LocationChannelManager.PermissionState.authorized:
|
||||
@@ -246,9 +246,7 @@ struct LocationChannelsSheet: View {
|
||||
sectionDivider
|
||||
torToggleSection
|
||||
.padding(.top, 12)
|
||||
Button(action: {
|
||||
openSystemLocationSettings()
|
||||
}) {
|
||||
Button(action: SystemSettings.location.open) {
|
||||
Text(Strings.removeAccess)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
||||
@@ -304,7 +302,7 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
}
|
||||
let normalized = customGeohash
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmed
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
let isValid = validateGeohash(normalized)
|
||||
@@ -451,7 +449,7 @@ struct LocationChannelsSheet: View {
|
||||
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
|
||||
private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) {
|
||||
guard let idx = s.lastIndex(of: "[") else { return (s, nil) }
|
||||
let prefix = String(s[..<idx]).trimmingCharacters(in: .whitespaces)
|
||||
let prefix = String(s[..<idx]).trimmed
|
||||
let suffix = String(s[idx...])
|
||||
return (prefix, suffix)
|
||||
}
|
||||
@@ -622,18 +620,3 @@ extension LocationChannelsSheet {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Open Settings helper
|
||||
private func openSystemLocationSettings() {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
#else
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") {
|
||||
NSWorkspace.shared.open(url)
|
||||
} else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -12,11 +12,15 @@ struct LocationNotesView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var draft: String = ""
|
||||
|
||||
init(geohash: String, onNotesCountChanged: ((Int) -> Void)? = nil) {
|
||||
init(
|
||||
geohash: String,
|
||||
onNotesCountChanged: ((Int) -> Void)? = nil,
|
||||
manager: LocationNotesManager? = nil
|
||||
) {
|
||||
let gh = geohash.lowercased()
|
||||
self.geohash = gh
|
||||
self.onNotesCountChanged = onNotesCountChanged
|
||||
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
|
||||
_manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
|
||||
}
|
||||
|
||||
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
||||
@@ -260,14 +264,13 @@ struct LocationNotesView: View {
|
||||
}
|
||||
|
||||
private func send() {
|
||||
let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty else { return }
|
||||
guard let content = draft.trimmedOrNilIfEmpty else { return }
|
||||
manager.send(content: content, nickname: viewModel.nickname)
|
||||
draft = ""
|
||||
}
|
||||
|
||||
private var sendButtonEnabled: Bool {
|
||||
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
|
||||
!draft.trimmed.isEmpty && manager.state != .noRelays
|
||||
}
|
||||
|
||||
// MARK: - Timestamp Formatting
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//
|
||||
// MediaMessageView.swift
|
||||
// bitchat
|
||||
//
|
||||
// Created by Islam on 30/03/2026.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MediaMessageView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
let message: BitchatMessage
|
||||
let media: BitchatMessage.Media
|
||||
|
||||
@Binding var imagePreviewURL: URL?
|
||||
|
||||
var body: some View {
|
||||
let state = mediaSendState(for: message)
|
||||
let isFromMe = message.sender == viewModel.nickname || message.senderPeerID == viewModel.meshService.myPeerID
|
||||
let cancelAction: (() -> Void)? = state.canCancel ? { viewModel.cancelMediaSend(messageID: message.id) } : nil
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Text(viewModel.formatMessageHeader(message, colorScheme: colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
switch media {
|
||||
case .voice(let url):
|
||||
VoiceNoteView(
|
||||
url: url,
|
||||
isSending: state.isSending,
|
||||
sendProgress: state.progress,
|
||||
onCancel: cancelAction
|
||||
)
|
||||
case .image(let url):
|
||||
BlockRevealImageView(
|
||||
url: url,
|
||||
revealProgress: state.progress,
|
||||
isSending: state.isSending,
|
||||
onCancel: cancelAction,
|
||||
initiallyBlurred: !isFromMe,
|
||||
onOpen: {
|
||||
if !state.isSending {
|
||||
imagePreviewURL = url
|
||||
}
|
||||
},
|
||||
onDelete: !isFromMe ? { viewModel.deleteMediaMessage(messageID: message.id) } : nil
|
||||
)
|
||||
.frame(maxWidth: 280)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func mediaSendState(for message: BitchatMessage) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
|
||||
var isSending = false
|
||||
var progress: Double?
|
||||
if let status = message.deliveryStatus {
|
||||
switch status {
|
||||
case .sending:
|
||||
isSending = true
|
||||
progress = 0
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
if total > 0 {
|
||||
isSending = true
|
||||
progress = Double(reached) / Double(total)
|
||||
}
|
||||
case .sent, .read, .delivered, .failed:
|
||||
break
|
||||
}
|
||||
}
|
||||
let canCancel = isSending && message.sender == viewModel.nickname
|
||||
let clamped = progress.map { max(0, min(1, $0)) }
|
||||
return (isSending, isSending ? clamped : nil, canCancel)
|
||||
}
|
||||
}
|
||||
@@ -34,24 +34,10 @@ struct VoiceNoteView: View {
|
||||
colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2)
|
||||
}
|
||||
|
||||
private var durationText: String {
|
||||
let duration = playback.duration
|
||||
guard duration.isFinite, duration > 0 else { return "--:--" }
|
||||
let minutes = Int(duration) / 60
|
||||
let seconds = Int(duration) % 60
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
private var currentText: String {
|
||||
let current = playback.currentTime
|
||||
guard current.isFinite, current > 0 else { return "00:00" }
|
||||
let minutes = Int(current) / 60
|
||||
let seconds = Int(current) % 60
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
private var playbackLabel: String {
|
||||
playback.isPlaying ? currentText + "/" + durationText : durationText
|
||||
guard playback.duration.isFinite else { return "--:--" }
|
||||
let seconds = playback.isPlaying ? playback.remainingSeconds : playback.roundedDuration
|
||||
return String(format: "%02d:%02d", seconds / 60, seconds % 60)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
//
|
||||
// MessageListView.swift
|
||||
// bitchat
|
||||
//
|
||||
// Created by Islam on 30/03/2026.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
private struct MessageDisplayItem: Identifiable {
|
||||
let id: String
|
||||
let message: BitchatMessage
|
||||
}
|
||||
|
||||
struct MessageListView: View {
|
||||
@EnvironmentObject private var viewModel: ChatViewModel
|
||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let privatePeer: PeerID?
|
||||
@Binding var isAtBottom: Bool
|
||||
@Binding var messageText: String
|
||||
@Binding var selectedMessageSender: String?
|
||||
@Binding var selectedMessageSenderID: PeerID?
|
||||
@Binding var imagePreviewURL: URL?
|
||||
@Binding var windowCountPublic: Int
|
||||
@Binding var windowCountPrivate: [PeerID: Int]
|
||||
@Binding var showSidebar: Bool
|
||||
|
||||
var isTextFieldFocused: FocusState<Bool>.Binding
|
||||
|
||||
@State private var showMessageActions = false
|
||||
@State private var lastScrollTime: Date = .distantPast
|
||||
@State private var scrollThrottleTimer: Timer?
|
||||
|
||||
var body: some View {
|
||||
let currentWindowCount: Int = {
|
||||
if let peer = privatePeer {
|
||||
return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
|
||||
}
|
||||
return windowCountPublic
|
||||
}()
|
||||
|
||||
let messages = viewModel.getMessages(for: privatePeer)
|
||||
let windowedMessages = Array(messages.suffix(currentWindowCount))
|
||||
|
||||
let contextKey: String = {
|
||||
if let peer = privatePeer {
|
||||
"dm:\(peer)"
|
||||
} else {
|
||||
locationManager.selectedChannel.contextKey
|
||||
}
|
||||
}()
|
||||
|
||||
let messageItems: [MessageDisplayItem] = windowedMessages.compactMap { message in
|
||||
guard !message.content.trimmed.isEmpty else { return nil }
|
||||
return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message)
|
||||
}
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(messageItems) { item in
|
||||
let message = item.message
|
||||
messageRow(for: message)
|
||||
.onAppear {
|
||||
if message.id == windowedMessages.last?.id {
|
||||
isAtBottom = true
|
||||
}
|
||||
if message.id == windowedMessages.first?.id,
|
||||
messages.count > windowedMessages.count {
|
||||
expandWindow(
|
||||
ifNeededFor: message,
|
||||
allMessages: messages,
|
||||
privatePeer: privatePeer,
|
||||
proxy: proxy
|
||||
)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if message.id == windowedMessages.last?.id {
|
||||
isAtBottom = false
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if message.sender != "system" {
|
||||
messageText = "@\(message.sender) "
|
||||
isTextFieldFocused.wrappedValue = true
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button("content.message.copy") {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = message.content
|
||||
#else
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
pb.setString(message.content, forType: .string)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 1)
|
||||
}
|
||||
}
|
||||
.transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.onOpenURL(perform: handleOpenURL)
|
||||
.onTapGesture(count: 3) {
|
||||
viewModel.sendMessage("/clear")
|
||||
}
|
||||
.onAppear {
|
||||
scrollToBottom(on: proxy)
|
||||
}
|
||||
.onChange(of: privatePeer) { _ in
|
||||
scrollToBottom(on: proxy)
|
||||
}
|
||||
.onChange(of: viewModel.messages.count) { _ in
|
||||
onMessagesChange(proxy: proxy)
|
||||
}
|
||||
.onChange(of: viewModel.privateChats) { _ in
|
||||
onPrivateChatsChange(proxy: proxy)
|
||||
}
|
||||
.onChange(of: locationManager.selectedChannel) { newChannel in
|
||||
onSelectedChannelChange(newChannel, proxy: proxy)
|
||||
}
|
||||
.confirmationDialog(
|
||||
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
|
||||
isPresented: $showMessageActions,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("content.actions.mention") {
|
||||
if let sender = selectedMessageSender {
|
||||
// Pre-fill the input with an @mention and focus the field
|
||||
messageText = "@\(sender) "
|
||||
isTextFieldFocused.wrappedValue = true
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.direct_message") {
|
||||
if let peerID = selectedMessageSenderID {
|
||||
if peerID.isGeoChat {
|
||||
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||
viewModel.startGeohashDM(withPubkeyHex: full)
|
||||
}
|
||||
} else {
|
||||
viewModel.startPrivateChat(with: peerID)
|
||||
}
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
showSidebar = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.hug") {
|
||||
if let sender = selectedMessageSender {
|
||||
viewModel.sendMessage("/hug @\(sender)")
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.slap") {
|
||||
if let sender = selectedMessageSender {
|
||||
viewModel.sendMessage("/slap @\(sender)")
|
||||
}
|
||||
}
|
||||
|
||||
Button("content.actions.block", role: .destructive) {
|
||||
// Prefer direct geohash block when we have a Nostr sender ID
|
||||
if let peerID = selectedMessageSenderID, peerID.isGeoChat,
|
||||
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
||||
let sender = selectedMessageSender {
|
||||
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
||||
} else if let sender = selectedMessageSender {
|
||||
viewModel.sendMessage("/block \(sender)")
|
||||
}
|
||||
}
|
||||
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
}
|
||||
.onAppear {
|
||||
// Also check when view appears
|
||||
if let peerID = privatePeer {
|
||||
// Try multiple times to ensure read receipts are sent
|
||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
|
||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
|
||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
scrollThrottleTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
// Intercept custom cashu: links created in attributed text
|
||||
if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.open(url)
|
||||
return .handled
|
||||
#else
|
||||
// On non-iOS platforms, let the system handle or ignore
|
||||
return .systemAction
|
||||
#endif
|
||||
}
|
||||
return .systemAction
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private extension MessageListView {
|
||||
@ViewBuilder
|
||||
func messageRow(for message: BitchatMessage) -> some View {
|
||||
Group {
|
||||
if message.sender == "system" {
|
||||
systemMessageRow(message)
|
||||
} else if let media = message.mediaAttachment(for: viewModel.nickname) {
|
||||
MediaMessageView(message: message, media: media, imagePreviewURL: $imagePreviewURL)
|
||||
} else {
|
||||
TextMessageView(message: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func systemMessageRow(_ message: BitchatMessage) -> some View {
|
||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
func expandWindow(ifNeededFor message: BitchatMessage,
|
||||
allMessages: [BitchatMessage],
|
||||
privatePeer: PeerID?,
|
||||
proxy: ScrollViewProxy) {
|
||||
let step = TransportConfig.uiWindowStepCount
|
||||
let contextKey: String = {
|
||||
if let peer = privatePeer {
|
||||
"dm:\(peer)"
|
||||
} else {
|
||||
locationManager.selectedChannel.contextKey
|
||||
}
|
||||
}()
|
||||
let preserveID = "\(contextKey)|\(message.id)"
|
||||
|
||||
if let peer = privatePeer {
|
||||
let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
|
||||
let newCount = min(allMessages.count, current + step)
|
||||
guard newCount != current else { return }
|
||||
windowCountPrivate[peer] = newCount
|
||||
DispatchQueue.main.async {
|
||||
proxy.scrollTo(preserveID, anchor: .top)
|
||||
}
|
||||
} else {
|
||||
let current = windowCountPublic
|
||||
let newCount = min(allMessages.count, current + step)
|
||||
guard newCount != current else { return }
|
||||
windowCountPublic = newCount
|
||||
DispatchQueue.main.async {
|
||||
proxy.scrollTo(preserveID, anchor: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleOpenURL(_ url: URL) {
|
||||
guard url.scheme == "bitchat" else { return }
|
||||
switch url.host {
|
||||
case "user":
|
||||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
||||
selectedMessageSenderID = peerID
|
||||
|
||||
if peerID.isGeoDM || peerID.isGeoChat {
|
||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
||||
} else if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
||||
selectedMessageSender = name
|
||||
} else {
|
||||
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||
}
|
||||
|
||||
if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
|
||||
selectedMessageSender = nil
|
||||
selectedMessageSenderID = nil
|
||||
} else {
|
||||
showMessageActions = true
|
||||
}
|
||||
|
||||
case "geohash":
|
||||
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
|
||||
|
||||
func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
||||
switch len {
|
||||
case 0...2: return .region
|
||||
case 3...4: return .province
|
||||
case 5: return .city
|
||||
case 6: return .neighborhood
|
||||
case 7: return .block
|
||||
default: return .block
|
||||
}
|
||||
}
|
||||
|
||||
let level = levelForLength(gh.count)
|
||||
let channel = GeohashChannel(level: level, geohash: gh)
|
||||
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
|
||||
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||
}
|
||||
LocationChannelManager.shared.select(ChannelID.location(channel))
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func scrollToBottom(on proxy: ScrollViewProxy) {
|
||||
isAtBottom = true
|
||||
if let targetPeerID {
|
||||
proxy.scrollTo(targetPeerID, anchor: .bottom)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
if let secondTarget = self.targetPeerID {
|
||||
proxy.scrollTo(secondTarget, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var targetPeerID: String? {
|
||||
if let peer = privatePeer,
|
||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||
return "dm:\(peer)|\(last)"
|
||||
}
|
||||
if let last = viewModel.messages.suffix(300).last?.id {
|
||||
return "\(locationManager.selectedChannel.contextKey)|\(last)"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func onMessagesChange(proxy: ScrollViewProxy) {
|
||||
guard privatePeer == nil, let lastMsg = viewModel.messages.last else { return }
|
||||
|
||||
// If the newest message is from me, always scroll to bottom
|
||||
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
||||
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
|
||||
return
|
||||
} else { // Ensure we consider ourselves at bottom for subsequent messages
|
||||
isAtBottom = true
|
||||
}
|
||||
|
||||
func scrollIfNeeded(date: Date) {
|
||||
lastScrollTime = date
|
||||
let contextKey = locationManager.selectedChannel.contextKey
|
||||
if let target = viewModel.messages.suffix(windowCountPublic).last.map({ "\(contextKey)|\($0.id)" }) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle scroll animations to prevent excessive UI updates
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
||||
// Immediate scroll if enough time has passed
|
||||
scrollIfNeeded(date: now)
|
||||
} else {
|
||||
// Schedule a delayed scroll
|
||||
scrollThrottleTimer?.invalidate()
|
||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
||||
Task { @MainActor in
|
||||
scrollIfNeeded(date: Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onPrivateChatsChange(proxy: ScrollViewProxy) {
|
||||
guard let peerID = privatePeer, let messages = viewModel.privateChats[peerID], let lastMsg = messages.last else {
|
||||
return
|
||||
}
|
||||
|
||||
// If the newest private message is from me, always scroll
|
||||
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
||||
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
|
||||
return
|
||||
} else {
|
||||
isAtBottom = true
|
||||
}
|
||||
|
||||
func scrollIfNeeded(date: Date) {
|
||||
lastScrollTime = date
|
||||
let contextKey = "dm:\(peerID)"
|
||||
let count = windowCountPrivate[peerID] ?? 300
|
||||
if let target = messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }){
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
// Same throttling for private chats
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
||||
scrollIfNeeded(date: now)
|
||||
} else {
|
||||
scrollThrottleTimer?.invalidate()
|
||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
||||
Task { @MainActor in
|
||||
scrollIfNeeded(date: Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) {
|
||||
// When switching to a new geohash channel, scroll to the bottom
|
||||
guard privatePeer == nil else { return }
|
||||
switch channel {
|
||||
case .mesh:
|
||||
break
|
||||
case .location(let ch):
|
||||
// Reset window size
|
||||
isAtBottom = true
|
||||
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
|
||||
let contextKey = "geo:\(ch.geohash)"
|
||||
if let target = viewModel.messages.suffix(windowCountPublic).last?.id.map({ "\(contextKey)|\($0)" }) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChannelID {
|
||||
var contextKey: String {
|
||||
switch self {
|
||||
case .mesh: "mesh"
|
||||
case .location(let ch): "geo:\(ch.geohash)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// MessageListView()
|
||||
//}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "dummy.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -6,7 +6,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.chat.bitchat</string>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.chat.bitchat</string>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AppGroupID</key>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
|
||||
@@ -13,7 +13,7 @@ import UniformTypeIdentifiers
|
||||
/// Avoids deprecated Social framework and SLComposeServiceViewController.
|
||||
final class ShareViewController: UIViewController {
|
||||
// Bundle.main.bundleIdentifier would get the extension's bundleID
|
||||
private static let groupID = "group.chat.bitchat"
|
||||
private static let groupID = Bundle.main.object(forInfoDictionaryKey: "AppGroupID") as? String ?? "group.chat.bitchat"
|
||||
|
||||
private enum Strings {
|
||||
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.chat.bitchat</string>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -23,10 +23,18 @@ struct BLEServiceCoreTests {
|
||||
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
|
||||
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
let receivedFirst = await TestHelpers.waitUntil(
|
||||
{ delegate.publicMessagesSnapshot().count == 1 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(receivedFirst)
|
||||
|
||||
_ = await TestHelpers.waitUntil({ delegate.publicMessagesSnapshot().count == 1 },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
ble._test_handlePacket(packet, fromPeerID: sender)
|
||||
let receivedDuplicate = await TestHelpers.waitUntil(
|
||||
{ delegate.publicMessagesSnapshot().count > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!receivedDuplicate)
|
||||
|
||||
let messages = delegate.publicMessagesSnapshot()
|
||||
#expect(messages.count == 1)
|
||||
@@ -49,13 +57,52 @@ struct BLEServiceCoreTests {
|
||||
#expect(!didReceive)
|
||||
#expect(delegate.publicMessagesSnapshot().isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func announceSenderMismatch_isRejected() async throws {
|
||||
let ble = makeService()
|
||||
|
||||
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: "Spoof",
|
||||
noisePublicKey: signer.getStaticPublicKeyData(),
|
||||
signingPublicKey: signer.getSigningPublicKeyData(),
|
||||
directNeighbors: nil
|
||||
)
|
||||
let payload = try #require(announcement.encode(), "Failed to encode announcement")
|
||||
|
||||
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
||||
let wrongFirst = derivedPeerID.bare.first == "0" ? "1" : "0"
|
||||
let wrongBare = String(wrongFirst) + String(derivedPeerID.bare.dropFirst())
|
||||
let wrongPeerID = PeerID(str: wrongBare)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data(hexString: wrongPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
let signed = try #require(signer.signPacket(packet), "Failed to sign announce packet")
|
||||
|
||||
ble._test_handlePacket(signed, fromPeerID: wrongPeerID, preseedPeer: false)
|
||||
|
||||
_ = await TestHelpers.waitUntil({ !ble.currentPeerSnapshots().isEmpty }, timeout: 0.3)
|
||||
#expect(ble.currentPeerSnapshots().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeService() -> BLEService {
|
||||
let keychain = MockKeychain()
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BitchatPeer Tests")
|
||||
struct BitchatPeerTests {
|
||||
typealias FavoriteRelationship = FavoritesPersistenceService.FavoriteRelationship
|
||||
|
||||
@Test("Connection state prioritizes bluetooth, mesh, nostr, then offline")
|
||||
func connectionStatePriorityIsCorrect() {
|
||||
let peerID = PeerID(str: "0123456789abcdef")
|
||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||
let mutual = makeRelationship(isFavorite: true, theyFavoritedUs: true)
|
||||
|
||||
let bluetooth = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: true, isReachable: true)
|
||||
let mesh = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: true)
|
||||
var nostr = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false)
|
||||
nostr.favoriteStatus = mutual
|
||||
let offline = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false)
|
||||
|
||||
#expect(bluetooth.connectionState == .bluetoothConnected)
|
||||
#expect(mesh.connectionState == .meshReachable)
|
||||
#expect(nostr.connectionState == .nostrAvailable)
|
||||
#expect(offline.connectionState == .offline)
|
||||
}
|
||||
|
||||
@Test("Display name falls back to peer prefix and offline icon reflects inbound favorite")
|
||||
func displayNameAndOfflineIconUseDerivedState() {
|
||||
let peerID = PeerID(str: "fedcba9876543210")
|
||||
let noiseKey = Data((32..<64).map(UInt8.init))
|
||||
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "", isConnected: false, isReachable: false)
|
||||
peer.favoriteStatus = makeRelationship(isFavorite: false, theyFavoritedUs: true)
|
||||
|
||||
#expect(peer.displayName == String(peerID.id.prefix(8)))
|
||||
#expect(peer.statusIcon == "🌙")
|
||||
}
|
||||
|
||||
@Test("Mutual offline peers show Nostr icon")
|
||||
func mutualFavoriteOfflinePeerShowsNostrIcon() {
|
||||
let peerID = PeerID(str: "0011223344556677")
|
||||
let noiseKey = Data((64..<96).map(UInt8.init))
|
||||
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "Peer", isConnected: false, isReachable: false)
|
||||
peer.favoriteStatus = makeRelationship(isFavorite: true, theyFavoritedUs: true)
|
||||
|
||||
#expect(peer.statusIcon == "🌐")
|
||||
#expect(peer.isFavorite)
|
||||
#expect(peer.isMutualFavorite)
|
||||
#expect(peer.theyFavoritedUs)
|
||||
}
|
||||
|
||||
@Test("Equality is based only on peer ID")
|
||||
func equalityUsesPeerIDOnly() {
|
||||
let peerID = PeerID(str: "8899aabbccddeeff")
|
||||
let first = BitchatPeer(
|
||||
peerID: peerID,
|
||||
noisePublicKey: Data(repeating: 1, count: 32),
|
||||
nickname: "First",
|
||||
isConnected: false,
|
||||
isReachable: false
|
||||
)
|
||||
let second = BitchatPeer(
|
||||
peerID: peerID,
|
||||
noisePublicKey: Data(repeating: 2, count: 32),
|
||||
nickname: "Second",
|
||||
isConnected: true,
|
||||
isReachable: true
|
||||
)
|
||||
|
||||
#expect(first == second)
|
||||
}
|
||||
|
||||
private func makeRelationship(isFavorite: Bool, theyFavoritedUs: Bool) -> FavoriteRelationship {
|
||||
FavoriteRelationship(
|
||||
peerNoisePublicKey: Data(repeating: 7, count: 32),
|
||||
peerNostrPublicKey: "npub1example",
|
||||
peerNickname: "Peer",
|
||||
isFavorite: isFavorite,
|
||||
theyFavoritedUs: theyFavoritedUs,
|
||||
favoritedAt: Date(timeIntervalSince1970: 1),
|
||||
lastUpdated: Date(timeIntervalSince1970: 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import Combine
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
@testable import bitchat
|
||||
|
||||
// MARK: - Test Helpers
|
||||
@@ -265,7 +270,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async {
|
||||
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async throws {
|
||||
let geohash = "u4pruydq"
|
||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
||||
|
||||
@@ -278,17 +283,16 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
|
||||
|
||||
var event = NostrEvent(
|
||||
pubkey: "pub1",
|
||||
let signer = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: signer.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Hello Geo"
|
||||
)
|
||||
event.id = "evt1"
|
||||
event.sig = "sig"
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
let signed = try event.sign(with: signer.schnorrSigningKey())
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
let didAppend = await TestHelpers.waitUntil({
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
@@ -305,16 +309,15 @@ struct ChatViewModelNostrExtensionTests {
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
|
||||
|
||||
var event = NostrEvent(
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Self echo"
|
||||
)
|
||||
event.id = "evt-self"
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
@@ -323,24 +326,24 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_skipsBlockedSender() async {
|
||||
func handleNostrEvent_skipsBlockedSender() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let blockedPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
let blockedIdentity = try NostrIdentity.generate()
|
||||
let blockedPubkey = blockedIdentity.publicKeyHex
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true)
|
||||
|
||||
var event = NostrEvent(
|
||||
let event = NostrEvent(
|
||||
pubkey: blockedPubkey,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Blocked"
|
||||
)
|
||||
event.id = "evt-blocked"
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
let signed = try event.sign(with: blockedIdentity.schnorrSigningKey())
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
@@ -348,6 +351,52 @@ struct ChatViewModelNostrExtensionTests {
|
||||
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_rejectsInvalidSignature() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let identity = try NostrIdentity.generate()
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Valid"
|
||||
)
|
||||
var signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
signed.id = "deadbeef"
|
||||
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
|
||||
#expect(!viewModel.messages.contains { $0.content == "Tampered" })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
|
||||
let content = "bitchat1:" + base64URLEncode(oversized)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
#expect(viewModel.privateChats.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func switchLocationChannel_clearsNostrDedupCache() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
@@ -360,6 +409,274 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_presenceTracksParticipantWithoutTimelineMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let identity = try NostrIdentity.generate()
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", geohash]],
|
||||
content: ""
|
||||
)
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
#expect(viewModel.geohashParticipantCount(for: geohash) >= 1)
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
#expect(viewModel.messages.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
let messageID = "geo-ack-delivered"
|
||||
|
||||
viewModel.privateChats[convKey] = [
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Friend",
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
]
|
||||
|
||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
timeout: 0.5
|
||||
)
|
||||
#expect(didUpdate)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
let messageID = "geo-ack-read"
|
||||
|
||||
viewModel.privateChats[convKey] = [
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Friend",
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
deliveryStatus: .delivered(to: "Friend", at: Date())
|
||||
)
|
||||
]
|
||||
|
||||
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
timeout: 0.5
|
||||
)
|
||||
#expect(didUpdate)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let messageID = "gift-private"
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
|
||||
let content = try privateMessageContent(
|
||||
text: "Hello from gift wrap",
|
||||
messageID: messageID,
|
||||
senderPeerID: PeerID(str: "0123456789abcdef")
|
||||
)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
|
||||
let didStore = await TestHelpers.waitUntil(
|
||||
{ viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
|
||||
timeout: 0.5
|
||||
)
|
||||
#expect(didStore)
|
||||
#expect(viewModel.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let messageID = "gift-blocked"
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
|
||||
viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)
|
||||
|
||||
let content = try privateMessageContent(
|
||||
text: "Blocked",
|
||||
messageID: messageID,
|
||||
senderPeerID: PeerID(str: "0123456789abcdef")
|
||||
)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
#expect(viewModel.privateChats[convKey] == nil)
|
||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
let messageID = "gift-delivered"
|
||||
|
||||
viewModel.privateChats[convKey] = [
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Friend",
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
]
|
||||
|
||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
timeout: 0.5
|
||||
)
|
||||
#expect(didUpdate)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func findNoiseKey_matchesFavoriteStoredAsNpub() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let identity = try NostrIdentity.generate()
|
||||
let noiseKey = Data((0..<32).map { UInt8(($0 + 80) & 0xFF) })
|
||||
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: identity.npub,
|
||||
peerNickname: "Alice"
|
||||
)
|
||||
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
|
||||
|
||||
#expect(viewModel.findNoiseKey(for: identity.publicKeyHex) == noiseKey)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func findNoiseKey_matchesFavoriteStoredAsHex() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let nostrHex = String(repeating: "ab", count: 32)
|
||||
let noiseKey = Data((0..<32).map { UInt8(($0 + 112) & 0xFF) })
|
||||
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: nostrHex,
|
||||
peerNickname: "Bob"
|
||||
)
|
||||
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
|
||||
|
||||
#expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleFavoriteNotification_updatesFavoriteAssociation() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let identity = try NostrIdentity.generate()
|
||||
let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) })
|
||||
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: identity.npub,
|
||||
peerNickname: "Before"
|
||||
)
|
||||
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
|
||||
|
||||
viewModel.handleFavoriteNotification(
|
||||
content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice",
|
||||
from: identity.publicKeyHex
|
||||
)
|
||||
|
||||
let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
||||
#expect(relationship?.peerNickname == "Alice")
|
||||
#expect(relationship?.peerNostrPublicKey == identity.npub)
|
||||
#expect(relationship?.isFavorite == true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func geohashDMHelpers_exposeMappingAndDisplayName() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let nostrHex = String(repeating: "cd", count: 32)
|
||||
let convKey = PeerID(nostr_: nostrHex)
|
||||
|
||||
viewModel.geoNicknames[nostrHex] = "Alice"
|
||||
viewModel.startGeohashDM(withPubkeyHex: nostrHex)
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == convKey)
|
||||
#expect(viewModel.fullNostrHex(forSenderPeerID: convKey) == nostrHex)
|
||||
#expect(viewModel.geohashDisplayName(for: convKey).hasPrefix("Alice"))
|
||||
#expect(viewModel.nostrPubkeyForDisplayName("Alice") == nostrHex)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash Queue Tests
|
||||
@@ -405,4 +722,420 @@ struct ChatViewModelGeoDMTests {
|
||||
#expect(viewModel.privateChats[convKey]?.count == 1)
|
||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendGeohashDM_requiresActiveLocationChannel() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let convKey = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000001")
|
||||
|
||||
viewModel.sendGeohashDM("hello", to: convKey)
|
||||
|
||||
#expect(viewModel.privateChats[convKey] == nil)
|
||||
#expect(viewModel.messages.count == 1)
|
||||
#expect(viewModel.messages.last?.sender == "system")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendGeohashDM_missingRecipientMapping_marksFailed() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let convKey = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000002")
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
viewModel.sendGeohashDM("hello", to: convKey)
|
||||
|
||||
#expect(viewModel.privateChats[convKey]?.count == 1)
|
||||
#expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessage() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let recipientHex = "0000000000000000000000000000000000000000000000000000000000000003"
|
||||
let convKey = PeerID(nostr_: recipientHex)
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
viewModel.nostrKeyMapping[convKey] = recipientHex
|
||||
viewModel.identityManager.setNostrBlocked(recipientHex, isBlocked: true)
|
||||
|
||||
viewModel.sendGeohashDM("hello", to: convKey)
|
||||
|
||||
#expect(viewModel.privateChats[convKey]?.count == 1)
|
||||
#expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))
|
||||
#expect(viewModel.messages.contains(where: { $0.sender == "system" }))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handlePrivateMessage_geohashViewingConversationRecordsReadReceipt() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000004"
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
let messageID = "pm-viewing"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
viewModel.selectedPrivateChatPeer = convKey
|
||||
|
||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
|
||||
let packet = PrivateMessagePacket(messageID: messageID, content: "Hello")
|
||||
let payloadData = try #require(packet.encode(), "Failed to encode private message")
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
|
||||
viewModel.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
id: identity,
|
||||
messageTimestamp: Date()
|
||||
)
|
||||
|
||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
||||
#expect(viewModel.sentReadReceipts.contains(messageID))
|
||||
#expect(!viewModel.unreadPrivateMessages.contains(convKey))
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatViewModelMediaTransferTests {
|
||||
|
||||
@Test @MainActor
|
||||
func handleTransferEvent_updatesPrivateMessageProgressAndClearsMappingOnCompletion() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10")
|
||||
let message = viewModel.enqueueMediaMessage(content: "[voice] clip.m4a", targetPeer: peerID)
|
||||
let transferID = "transfer-1"
|
||||
|
||||
viewModel.registerTransfer(transferId: transferID, messageID: message.id)
|
||||
viewModel.handleTransferEvent(.started(id: transferID, totalFragments: 4))
|
||||
#expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 0, total: 4))
|
||||
|
||||
viewModel.handleTransferEvent(.updated(id: transferID, sentFragments: 2, totalFragments: 4))
|
||||
#expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 2, total: 4))
|
||||
|
||||
viewModel.handleTransferEvent(.completed(id: transferID, totalFragments: 4))
|
||||
#expect(isSent(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id)))
|
||||
#expect(viewModel.messageIDToTransferId[message.id] == nil)
|
||||
#expect(viewModel.transferIdToMessageIDs[transferID] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTransferEvent_cancelledRemovesOutgoingMessage() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "1111111111111111111111111111111111111111111111111111111111111111")
|
||||
let message = viewModel.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: peerID)
|
||||
let transferID = "transfer-2"
|
||||
|
||||
viewModel.registerTransfer(transferId: transferID, messageID: message.id)
|
||||
viewModel.handleTransferEvent(.cancelled(id: transferID, sentFragments: 1, totalFragments: 3))
|
||||
|
||||
#expect(viewModel.privateChats[peerID]?.contains(where: { $0.id == message.id }) != true)
|
||||
#expect(viewModel.messageIDToTransferId[message.id] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendVoiceNote_outsideAllowedContextDeletesTempFile() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
|
||||
|
||||
try Data("voice".utf8).write(to: url)
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
viewModel.sendVoiceNote(at: url)
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
#expect(viewModel.messages.contains(where: { $0.sender == "system" }))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendImage_outsideAllowedContextRunsCleanup() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
var cleanupCalled = false
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
viewModel.sendImage(from: URL(fileURLWithPath: "/tmp/ignored.jpg")) {
|
||||
cleanupCalled = true
|
||||
}
|
||||
|
||||
#expect(cleanupCalled)
|
||||
#expect(viewModel.messages.contains(where: { $0.sender == "system" }))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendVoiceNote_privateChatUsesPrivateFileTransfer() async throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "2222222222222222222222222222222222222222222222222222222222222222")
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
|
||||
try Data("voice payload".utf8).write(to: url, options: .atomic)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.sendVoiceNote(at: url)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 0.5)
|
||||
#expect(didSend)
|
||||
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
|
||||
#expect(viewModel.messageIDToTransferId.count == 1)
|
||||
#expect(viewModel.transferIdToMessageIDs.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "3333333333333333333333333333333333333333333333333333333333333333")
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-too-large-\(UUID().uuidString).m4a")
|
||||
try Data(repeating: 0x55, count: FileTransferLimits.maxVoiceNoteBytes + 1).write(to: url, options: .atomic)
|
||||
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.sendVoiceNote(at: url)
|
||||
|
||||
let didFail = await TestHelpers.waitUntil({
|
||||
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
|
||||
}, timeout: 0.5)
|
||||
#expect(didFail)
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
#expect(transport.sentPrivateFiles.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendImage_privateChatProcessesAndTransfersImage() async throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "4444444444444444444444444444444444444444444444444444444444444444")
|
||||
let sourceURL = try makeTemporaryImageURL()
|
||||
defer { try? FileManager.default.removeItem(at: sourceURL) }
|
||||
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.sendImage(from: sourceURL)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 1.0)
|
||||
#expect(didSend)
|
||||
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
|
||||
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[image]") == true)
|
||||
#expect(viewModel.messageIDToTransferId.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendImage_invalidSourceAddsFailureSystemMessage() async throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "5555555555555555555555555555555555555555555555555555555555555555")
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("invalid-\(UUID().uuidString).jpg")
|
||||
try Data("not-an-image".utf8).write(to: url, options: .atomic)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.sendImage(from: url)
|
||||
|
||||
let didNotify = await TestHelpers.waitUntil({
|
||||
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
|
||||
}, timeout: 2.0)
|
||||
#expect(didNotify)
|
||||
#expect(transport.sentPrivateFiles.isEmpty)
|
||||
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearTransferMapping_promotesQueuedTransferForSameID() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
viewModel.registerTransfer(transferId: "transfer-queue", messageID: "first")
|
||||
viewModel.registerTransfer(transferId: "transfer-queue", messageID: "second")
|
||||
|
||||
viewModel.clearTransferMapping(for: "first")
|
||||
|
||||
#expect(viewModel.messageIDToTransferId["first"] == nil)
|
||||
#expect(viewModel.transferIdToMessageIDs["transfer-queue"] == ["second"])
|
||||
#expect(viewModel.messageIDToTransferId["second"] == "transfer-queue")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func cancelMediaSend_cancelsActiveTransferRemovesMessageAndDeletesFile() async throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "6666666666666666666666666666666666666666666666666666666666666666")
|
||||
let fileName = "cancel-\(UUID().uuidString).m4a"
|
||||
let fileURL = try mediaFileURL(subdirectory: "voicenotes/outgoing", fileName: fileName)
|
||||
try Data("cancel me".utf8).write(to: fileURL, options: .atomic)
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "cancel-msg",
|
||||
sender: viewModel.nickname,
|
||||
content: "[voice] \(fileName)",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.registerTransfer(transferId: "transfer-cancel", messageID: message.id)
|
||||
|
||||
viewModel.cancelMediaSend(messageID: message.id)
|
||||
|
||||
#expect(transport.cancelledTransfers == ["transfer-cancel"])
|
||||
#expect(viewModel.privateChats[peerID] == nil)
|
||||
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deleteMediaMessage_removesStoredMessageAndCleansImageFile() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "7777777777777777777777777777777777777777777777777777777777777777")
|
||||
let fileName = "delete-\(UUID().uuidString).jpg"
|
||||
let fileURL = try mediaFileURL(subdirectory: "images/outgoing", fileName: fileName)
|
||||
try Data("image bytes".utf8).write(to: fileURL, options: .atomic)
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "delete-msg",
|
||||
sender: viewModel.nickname,
|
||||
content: "[image] \(fileName)",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.registerTransfer(transferId: "transfer-delete", messageID: message.id)
|
||||
|
||||
viewModel.deleteMediaMessage(messageID: message.id)
|
||||
|
||||
#expect(viewModel.privateChats[peerID] == nil)
|
||||
#expect(viewModel.messageIDToTransferId[message.id] == nil)
|
||||
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func makeTransferID_isPrefixedByMessageIDAndUnique() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
let first = viewModel.makeTransferID(messageID: "base")
|
||||
let second = viewModel.makeTransferID(messageID: "base")
|
||||
|
||||
#expect(first.hasPrefix("base-"))
|
||||
#expect(second.hasPrefix("base-"))
|
||||
#expect(first != second)
|
||||
}
|
||||
}
|
||||
|
||||
private func base64URLEncode(_ data: Data) -> String {
|
||||
data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
private func ackContent(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) throws -> String {
|
||||
if let content = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
||||
type: type,
|
||||
messageID: messageID,
|
||||
senderPeerID: senderPeerID
|
||||
) {
|
||||
return content
|
||||
}
|
||||
throw ChatViewModelExtensionsTestError.invalidAckContent
|
||||
}
|
||||
|
||||
private func privateMessageContent(text: String, messageID: String, senderPeerID: PeerID) throws -> String {
|
||||
if let content = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||
content: text,
|
||||
messageID: messageID,
|
||||
senderPeerID: senderPeerID
|
||||
) {
|
||||
return content
|
||||
}
|
||||
throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func deliveryStatus(in viewModel: ChatViewModel, peerID: PeerID, messageID: String) -> DeliveryStatus? {
|
||||
viewModel.privateChats[peerID]?.first(where: { $0.id == messageID })?.deliveryStatus
|
||||
}
|
||||
|
||||
private func isFailed(status: DeliveryStatus?) -> Bool {
|
||||
if case .failed = status {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isDelivered(status: DeliveryStatus?) -> Bool {
|
||||
if case .delivered = status {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isRead(status: DeliveryStatus?) -> Bool {
|
||||
if case .read = status {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isSent(status: DeliveryStatus?) -> Bool {
|
||||
if case .sent = status {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isPartiallyDelivered(status: DeliveryStatus?, reached: Int, total: Int) -> Bool {
|
||||
if case .partiallyDelivered(let actualReached, let actualTotal) = status {
|
||||
return actualReached == reached && actualTotal == total
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private enum ChatViewModelExtensionsTestError: Error {
|
||||
case invalidAckContent
|
||||
case invalidPrivateMessageContent
|
||||
}
|
||||
|
||||
private func mediaFileURL(subdirectory: String, fileName: String) throws -> URL {
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
).appendingPathComponent("files", isDirectory: true)
|
||||
let directory = base.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
private func makeTemporaryImageURL() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("image-\(UUID().uuidString).png")
|
||||
let data = try makeImageData()
|
||||
try data.write(to: url, options: .atomic)
|
||||
return url
|
||||
}
|
||||
|
||||
private func makeImageData() throws -> Data {
|
||||
#if os(iOS)
|
||||
let image = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64)).image { context in
|
||||
UIColor.systemTeal.setFill()
|
||||
context.fill(CGRect(x: 0, y: 0, width: 64, height: 64))
|
||||
}
|
||||
guard let data = image.pngData() else {
|
||||
throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
|
||||
}
|
||||
return data
|
||||
#else
|
||||
let image = NSImage(size: CGSize(width: 64, height: 64))
|
||||
image.lockFocus()
|
||||
NSColor.systemTeal.setFill()
|
||||
NSBezierPath(rect: CGRect(x: 0, y: 0, width: 64, height: 64)).fill()
|
||||
image.unlockFocus()
|
||||
guard
|
||||
let tiffData = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiffData),
|
||||
let data = bitmap.representation(using: .png, properties: [:])
|
||||
else {
|
||||
throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
|
||||
}
|
||||
return data
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -125,26 +125,20 @@ struct ChatViewModelRefactoringTests {
|
||||
let (viewModel, _, _) = makePinnedViewModel()
|
||||
let senderID = PeerID(str: "sender_2")
|
||||
|
||||
// Setup
|
||||
let message = BitchatMessage(
|
||||
id: "msg_2",
|
||||
sender: "charlie",
|
||||
// Action
|
||||
viewModel.didReceivePublicMessage(
|
||||
from: senderID,
|
||||
nickname: "charlie",
|
||||
content: "Public Hi",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: senderID,
|
||||
mentions: nil
|
||||
messageID: "msg_2"
|
||||
)
|
||||
|
||||
// Action
|
||||
viewModel.didReceiveMessage(message)
|
||||
|
||||
// Wait for async processing with proper timeout
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@@ -195,10 +195,11 @@ struct ChatViewModelReceivingTests {
|
||||
messageID: "pub-001"
|
||||
)
|
||||
|
||||
// Give time for async Task and pipeline processing
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
let found = await TestHelpers.waitUntil({
|
||||
viewModel.timelineStore.messages(for: .mesh).contains { $0.content == "Public hello from Bob" }
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#expect(viewModel.messages.contains { $0.content == "Public hello from Bob" })
|
||||
#expect(found)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite(.serialized)
|
||||
struct CommandProcessorTests {
|
||||
private var identityManager = MockIdentityManager(MockKeychain())
|
||||
|
||||
@MainActor
|
||||
@Test func slapNotFoundGrammar() {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
||||
let result = processor.process("/slap @system")
|
||||
switch result {
|
||||
@@ -18,6 +20,7 @@ struct CommandProcessorTests {
|
||||
|
||||
@MainActor
|
||||
@Test func hugNotFoundGrammar() {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
||||
let result = processor.process("/hug @system")
|
||||
switch result {
|
||||
@@ -30,6 +33,7 @@ struct CommandProcessorTests {
|
||||
|
||||
@MainActor
|
||||
@Test func slapUsageMessage() {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
|
||||
let result = processor.process("/slap")
|
||||
switch result {
|
||||
@@ -39,4 +43,389 @@ struct CommandProcessorTests {
|
||||
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 = 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
@testable import bitchat
|
||||
|
||||
private func makeTemporaryFileURL(_ name: String) -> URL {
|
||||
FileManager.default.temporaryDirectory.appendingPathComponent(name)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func makePlatformImage(size: CGSize) -> UIImage {
|
||||
UIGraphicsImageRenderer(size: size).image { context in
|
||||
UIColor.systemTeal.setFill()
|
||||
context.fill(CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}
|
||||
#else
|
||||
private func makePlatformImage(size: CGSize) -> NSImage {
|
||||
let image = NSImage(size: size)
|
||||
image.lockFocus()
|
||||
NSColor.systemTeal.setFill()
|
||||
NSBezierPath(rect: CGRect(origin: .zero, size: size)).fill()
|
||||
image.unlockFocus()
|
||||
return image
|
||||
}
|
||||
#endif
|
||||
|
||||
struct ImageUtilsTests {
|
||||
@Test
|
||||
func processImage_rejectsOversizedSourceFile() throws {
|
||||
let url = makeTemporaryFileURL("image-too-large.bin")
|
||||
try Data(repeating: 0xFF, count: 10 * 1024 * 1024 + 1).write(to: url, options: .atomic)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
#expect(throws: ImageUtilsError.self) {
|
||||
try ImageUtils.processImage(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func processImage_rejectsInvalidImageData() throws {
|
||||
let url = makeTemporaryFileURL("image-invalid.bin")
|
||||
try Data("not-an-image".utf8).write(to: url, options: .atomic)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
#expect(throws: ImageUtilsError.self) {
|
||||
try ImageUtils.processImage(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func processImage_writesCompressedJpeg() throws {
|
||||
let image = makePlatformImage(size: CGSize(width: 1024, height: 768))
|
||||
let outputURL = try ImageUtils.processImage(image, maxDimension: 256)
|
||||
defer { try? FileManager.default.removeItem(at: outputURL) }
|
||||
|
||||
let data = try Data(contentsOf: outputURL)
|
||||
|
||||
#expect(outputURL.pathExtension.lowercased() == "jpg")
|
||||
#expect(data.starts(with: Data([0xFF, 0xD8])))
|
||||
#expect(data.count > 0)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,8 @@ struct FragmentationTests {
|
||||
let ble = BLEService(
|
||||
keychain: mockKeychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: mockIdentityManager
|
||||
identityManager: mockIdentityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
@@ -63,7 +64,8 @@ struct FragmentationTests {
|
||||
let ble = BLEService(
|
||||
keychain: mockKeychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: mockIdentityManager
|
||||
identityManager: mockIdentityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
@@ -97,7 +99,8 @@ struct FragmentationTests {
|
||||
let ble = BLEService(
|
||||
keychain: mockKeychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: mockIdentityManager
|
||||
identityManager: mockIdentityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
@@ -153,7 +156,8 @@ struct FragmentationTests {
|
||||
let ble = BLEService(
|
||||
keychain: mockKeychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: mockIdentityManager
|
||||
identityManager: mockIdentityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
let capture = CaptureDelegate()
|
||||
ble.delegate = capture
|
||||
@@ -205,16 +209,18 @@ extension FragmentationTests {
|
||||
private var expectedPublicMessageCount: Int = 0
|
||||
private var expectedReceivedMessageCount: Int = 0
|
||||
|
||||
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] {
|
||||
private func withLock<T>(_ body: () -> T) -> T {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _publicMessages
|
||||
return body()
|
||||
}
|
||||
|
||||
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] {
|
||||
withLock { _publicMessages }
|
||||
}
|
||||
|
||||
var receivedMessages: [BitchatMessage] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _receivedMessages
|
||||
withLock { _receivedMessages }
|
||||
}
|
||||
|
||||
func didReceiveMessage(_ message: BitchatMessage) {
|
||||
@@ -251,27 +257,32 @@ extension FragmentationTests {
|
||||
|
||||
/// Waits for the specified number of public messages to be received
|
||||
func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
|
||||
lock.lock()
|
||||
if _publicMessages.count >= count {
|
||||
lock.unlock()
|
||||
let isAlreadySatisfied = withLock { () -> Bool in
|
||||
if _publicMessages.count >= count {
|
||||
return true
|
||||
}
|
||||
expectedPublicMessageCount = count
|
||||
return false
|
||||
}
|
||||
if isAlreadySatisfied {
|
||||
return
|
||||
}
|
||||
expectedPublicMessageCount = count
|
||||
lock.unlock()
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.lock.lock()
|
||||
// Recheck count after acquiring lock to avoid race condition
|
||||
// where message arrives between initial check and continuation install
|
||||
if self._publicMessages.count >= count {
|
||||
self.lock.unlock()
|
||||
let shouldResumeImmediately = self.withLock {
|
||||
// Recheck count after acquiring lock to avoid race condition
|
||||
// where message arrives between initial check and continuation install
|
||||
if self._publicMessages.count >= count {
|
||||
return true
|
||||
}
|
||||
self.publicMessageContinuation = continuation
|
||||
return false
|
||||
}
|
||||
if shouldResumeImmediately {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
self.publicMessageContinuation = continuation
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
group.addTask {
|
||||
@@ -285,27 +296,32 @@ extension FragmentationTests {
|
||||
|
||||
/// Waits for the specified number of received messages
|
||||
func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
|
||||
lock.lock()
|
||||
if _receivedMessages.count >= count {
|
||||
lock.unlock()
|
||||
let isAlreadySatisfied = withLock { () -> Bool in
|
||||
if _receivedMessages.count >= count {
|
||||
return true
|
||||
}
|
||||
expectedReceivedMessageCount = count
|
||||
return false
|
||||
}
|
||||
if isAlreadySatisfied {
|
||||
return
|
||||
}
|
||||
expectedReceivedMessageCount = count
|
||||
lock.unlock()
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.lock.lock()
|
||||
// Recheck count after acquiring lock to avoid race condition
|
||||
// where message arrives between initial check and continuation install
|
||||
if self._receivedMessages.count >= count {
|
||||
self.lock.unlock()
|
||||
let shouldResumeImmediately = self.withLock {
|
||||
// Recheck count after acquiring lock to avoid race condition
|
||||
// where message arrives between initial check and continuation install
|
||||
if self._receivedMessages.count >= count {
|
||||
return true
|
||||
}
|
||||
self.receivedMessageContinuation = continuation
|
||||
return false
|
||||
}
|
||||
if shouldResumeImmediately {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
self.receivedMessageContinuation = continuation
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
group.addTask {
|
||||
|
||||
@@ -131,7 +131,7 @@ struct NostrFilterPresenceTests {
|
||||
@MainActor
|
||||
struct ChatViewModelPresenceHandlingTests {
|
||||
|
||||
@Test func handleNostrEvent_presenceUpdatesParticipantTracker() async {
|
||||
@Test func handleNostrEvent_presenceUpdatesParticipantTracker() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
@@ -139,18 +139,18 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
// Create a presence event (kind 20001)
|
||||
var event = NostrEvent(
|
||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", geohash]],
|
||||
content: ""
|
||||
)
|
||||
event.id = "presence_evt_1"
|
||||
event.sig = "sig"
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
// Handle the event
|
||||
viewModel.handleNostrEvent(event)
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
// Allow async processing
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
@@ -160,7 +160,7 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
#expect(count >= 1)
|
||||
}
|
||||
|
||||
@Test func handleNostrEvent_presenceDoesNotAddToTimeline() async {
|
||||
@Test func handleNostrEvent_presenceDoesNotAddToTimeline() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
@@ -169,17 +169,17 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
let initialMessageCount = viewModel.messages.count
|
||||
|
||||
// Create a presence event (kind 20001)
|
||||
var event = NostrEvent(
|
||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", geohash]],
|
||||
content: ""
|
||||
)
|
||||
event.id = "presence_evt_2"
|
||||
event.sig = "sig"
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
@@ -187,24 +187,24 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
#expect(viewModel.messages.count == initialMessageCount)
|
||||
}
|
||||
|
||||
@Test func handleNostrEvent_chatMessageUpdatesParticipant() async {
|
||||
@Test func handleNostrEvent_chatMessageUpdatesParticipant() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
// Create a chat event (kind 20000) - NOT presence
|
||||
var event = NostrEvent(
|
||||
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Hello world"
|
||||
)
|
||||
event.id = "chat_evt_1"
|
||||
event.sig = "sig"
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
viewModel.handleNostrEvent(event)
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
@@ -223,25 +223,25 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
#expect(chatKind == 20000)
|
||||
}
|
||||
|
||||
@Test func subscribeNostrEvent_acceptsPresenceKind() async {
|
||||
@Test func subscribeNostrEvent_acceptsPresenceKind() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
// Create presence event
|
||||
var event = NostrEvent(
|
||||
pubkey: "test1234test1234test1234test1234test1234test1234test1234test1234",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", geohash]],
|
||||
content: ""
|
||||
)
|
||||
event.id = "subscribe_presence_evt"
|
||||
event.sig = "sig"
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
// subscribeNostrEvent should accept kind 20001
|
||||
viewModel.subscribeNostrEvent(event)
|
||||
viewModel.subscribeNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
@@ -250,7 +250,7 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
#expect(count >= 1)
|
||||
}
|
||||
|
||||
@Test func subscribeNostrEvent_presenceForNonActiveGeohash() async {
|
||||
@Test func subscribeNostrEvent_presenceForNonActiveGeohash() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let activeGeohash = "u4pruydq"
|
||||
let otherGeohash = "87yw7"
|
||||
@@ -258,18 +258,18 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash)))
|
||||
|
||||
// Create presence event for a DIFFERENT geohash
|
||||
var event = NostrEvent(
|
||||
pubkey: "other1234other1234other1234other1234other1234other1234other1234",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", otherGeohash]],
|
||||
content: ""
|
||||
)
|
||||
event.id = "other_geohash_presence"
|
||||
event.sig = "sig"
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
// Use subscribeNostrEvent with geohash parameter
|
||||
viewModel.subscribeNostrEvent(event, gh: otherGeohash)
|
||||
viewModel.subscribeNostrEvent(signed, gh: otherGeohash)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
|
||||
@@ -44,4 +44,12 @@ struct LocationChannelsTests {
|
||||
let id2 = try idBridge.deriveIdentity(forGeohash: gh)
|
||||
#expect(id1.publicKeyHex == id2.publicKeyHex)
|
||||
}
|
||||
|
||||
@Test func geohashNeighborsNearPoleSkipOutOfBoundsCells() {
|
||||
let nearPole = Geohash.encode(latitude: 89.9999, longitude: 0.0, precision: 8)
|
||||
let neighbors = Geohash.neighbors(of: nearPole)
|
||||
|
||||
#expect(neighbors.isEmpty == false)
|
||||
#expect(neighbors.count < 8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,51 +4,49 @@ import Foundation
|
||||
|
||||
@MainActor
|
||||
struct LocationNotesManagerTests {
|
||||
// func testSubscribeWithoutRelaysSetsNoRelaysState() {
|
||||
// var subscribeCalled = false
|
||||
// let deps = LocationNotesDependencies(
|
||||
// relayLookup: { _, _ in [] },
|
||||
// subscribe: { _, _, _, _, _ in
|
||||
// subscribeCalled = true
|
||||
// },
|
||||
// unsubscribe: { _ in },
|
||||
// sendEvent: { _, _ in },
|
||||
// deriveIdentity: { _ in fatalError("should not derive identity") },
|
||||
// now: { Date() }
|
||||
// )
|
||||
//
|
||||
// let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
//
|
||||
// XCTAssertFalse(subscribeCalled)
|
||||
// XCTAssertEqual(manager.state, .noRelays)
|
||||
// XCTAssertTrue(manager.initialLoadComplete)
|
||||
// XCTAssertEqual(manager.errorMessage, String(localized: "location_notes.error.no_relays"))
|
||||
// // Make sure we're getting an actual translated value and not the localization key
|
||||
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
||||
// }
|
||||
//
|
||||
// func testSendWhenNoRelaysSurfacesError() {
|
||||
// var sendCalled = false
|
||||
// let deps = LocationNotesDependencies(
|
||||
// relayLookup: { _, _ in [] },
|
||||
// subscribe: { _, _, _, _, _ in },
|
||||
// unsubscribe: { _ in },
|
||||
// sendEvent: { _, _ in sendCalled = true },
|
||||
// deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
||||
// now: { Date() }
|
||||
// )
|
||||
//
|
||||
// let manager = LocationNotesManager(geohash: "zzzzzzzz", dependencies: deps)
|
||||
// manager.send(content: "hello", nickname: "tester")
|
||||
//
|
||||
// XCTAssertFalse(sendCalled)
|
||||
// XCTAssertEqual(manager.state, .noRelays)
|
||||
// XCTAssertEqual(manager.errorMessage, String(localized: "location_notes.error.no_relays"))
|
||||
// // Make sure we're getting an actual translated value and not the localization key
|
||||
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
||||
// }
|
||||
@Test
|
||||
func subscribeWithoutRelays_setsNoRelaysState() {
|
||||
var subscribeCalled = false
|
||||
let deps = LocationNotesDependencies(
|
||||
relayLookup: { _, _ in [] },
|
||||
subscribe: { _, _, _, _, _ in
|
||||
subscribeCalled = true
|
||||
},
|
||||
unsubscribe: { _ in },
|
||||
sendEvent: { _, _ in },
|
||||
deriveIdentity: { _ in try NostrIdentity.generate() },
|
||||
now: { Date() }
|
||||
)
|
||||
|
||||
@Test func subscribeUsesGeoRelaysAndAppendsNotes() {
|
||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
|
||||
#expect(subscribeCalled == false)
|
||||
#expect(manager.state == .noRelays)
|
||||
#expect(manager.initialLoadComplete)
|
||||
#expect(manager.errorMessage == String(localized: "location_notes.error.no_relays"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func sendWithoutRelays_surfacesNoRelaysError() {
|
||||
var sendCalled = false
|
||||
let deps = LocationNotesDependencies(
|
||||
relayLookup: { _, _ in [] },
|
||||
subscribe: { _, _, _, _, _ in },
|
||||
unsubscribe: { _ in },
|
||||
sendEvent: { _, _ in sendCalled = true },
|
||||
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
||||
now: { Date() }
|
||||
)
|
||||
|
||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
manager.send(content: "hello", nickname: "tester")
|
||||
|
||||
#expect(sendCalled == false)
|
||||
#expect(manager.state == .noRelays)
|
||||
#expect(manager.errorMessage == String(localized: "location_notes.error.no_relays"))
|
||||
}
|
||||
|
||||
@Test func subscribeUsesGeoRelaysAndAppendsNotes() throws {
|
||||
var relaysCaptured: [String] = []
|
||||
var storedHandler: ((NostrEvent) -> Void)?
|
||||
var storedEOSE: (() -> Void)?
|
||||
@@ -71,15 +69,16 @@ struct LocationNotesManagerTests {
|
||||
#expect(relaysCaptured == ["wss://relay.one"])
|
||||
#expect(manager.state == .loading)
|
||||
|
||||
var event = NostrEvent(
|
||||
pubkey: "pub",
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
tags: [["g", "u4pruydq"]],
|
||||
content: "hi"
|
||||
)
|
||||
event.id = "event1"
|
||||
storedHandler?(event)
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
storedHandler?(signed)
|
||||
storedEOSE?()
|
||||
|
||||
#expect(manager.state == .ready)
|
||||
@@ -87,6 +86,100 @@ struct LocationNotesManagerTests {
|
||||
#expect(manager.notes.first?.content == "hi")
|
||||
}
|
||||
|
||||
@Test
|
||||
func setGeohash_invalidValueIsIgnored() {
|
||||
var subscribeCount = 0
|
||||
let deps = LocationNotesDependencies(
|
||||
relayLookup: { _, _ in ["wss://relay.one"] },
|
||||
subscribe: { _, _, _, _, _ in
|
||||
subscribeCount += 1
|
||||
},
|
||||
unsubscribe: { _ in },
|
||||
sendEvent: { _, _ in },
|
||||
deriveIdentity: { _ in try NostrIdentity.generate() },
|
||||
now: { Date() }
|
||||
)
|
||||
|
||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
manager.setGeohash("not-valid")
|
||||
|
||||
#expect(manager.geohash == "u4pruydq")
|
||||
#expect(subscribeCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func refreshAndCancel_manageSubscriptions() {
|
||||
var subscribeIDs: [String] = []
|
||||
var unsubscribedIDs: [String] = []
|
||||
let deps = LocationNotesDependencies(
|
||||
relayLookup: { _, _ in ["wss://relay.one"] },
|
||||
subscribe: { _, id, _, _, _ in
|
||||
subscribeIDs.append(id)
|
||||
},
|
||||
unsubscribe: { id in
|
||||
unsubscribedIDs.append(id)
|
||||
},
|
||||
sendEvent: { _, _ in },
|
||||
deriveIdentity: { _ in try NostrIdentity.generate() },
|
||||
now: { Date() }
|
||||
)
|
||||
|
||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
manager.refresh()
|
||||
manager.cancel()
|
||||
|
||||
#expect(subscribeIDs.count == 2)
|
||||
#expect(unsubscribedIDs.count == 2)
|
||||
#expect(manager.state == .idle)
|
||||
#expect(manager.errorMessage == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func send_successCreatesLocalEchoAndClearsError() throws {
|
||||
var sentEvents: [NostrEvent] = []
|
||||
let identity = try NostrIdentity.generate()
|
||||
let deps = LocationNotesDependencies(
|
||||
relayLookup: { _, _ in ["wss://relay.one"] },
|
||||
subscribe: { _, _, _, _, _ in },
|
||||
unsubscribe: { _ in },
|
||||
sendEvent: { event, _ in
|
||||
sentEvents.append(event)
|
||||
},
|
||||
deriveIdentity: { _ in identity },
|
||||
now: { Date(timeIntervalSince1970: 123_456) }
|
||||
)
|
||||
|
||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
manager.send(content: " hello note ", nickname: "Builder")
|
||||
|
||||
#expect(sentEvents.count == 1)
|
||||
#expect(manager.state == .ready)
|
||||
#expect(manager.errorMessage == nil)
|
||||
#expect(manager.notes.first?.content == "hello note")
|
||||
#expect(manager.notes.first?.displayName.hasPrefix("Builder#") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func send_failureFormatsErrorMessageAndClearErrorRemovesIt() {
|
||||
let deps = LocationNotesDependencies(
|
||||
relayLookup: { _, _ in ["wss://relay.one"] },
|
||||
subscribe: { _, _, _, _, _ in },
|
||||
unsubscribe: { _ in },
|
||||
sendEvent: { _, _ in },
|
||||
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
||||
now: { Date() }
|
||||
)
|
||||
|
||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
manager.send(content: "hello", nickname: "Builder")
|
||||
|
||||
#expect(manager.errorMessage?.isEmpty == false)
|
||||
|
||||
manager.clearError()
|
||||
|
||||
#expect(manager.errorMessage == nil)
|
||||
}
|
||||
|
||||
private enum TestError: Error {
|
||||
case shouldNotDerive
|
||||
}
|
||||
|
||||
@@ -12,6 +12,108 @@ import SwiftUI
|
||||
@testable import bitchat
|
||||
|
||||
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
|
||||
|
||||
@@ -221,3 +323,32 @@ struct MessageFormattingEngineTests {
|
||||
#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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,11 @@ final class MockBLEService: NSObject {
|
||||
func getPeers() -> [PeerID: String] {
|
||||
return getPeerNicknames()
|
||||
}
|
||||
|
||||
/// Keep local echo synchronous so Swift Testing confirmations observe it deterministically.
|
||||
private func deliverLocalEcho(_ message: BitchatMessage) {
|
||||
delegate?.didReceiveMessage(message)
|
||||
}
|
||||
|
||||
func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) {
|
||||
let message = BitchatMessage(
|
||||
@@ -137,10 +142,7 @@ final class MockBLEService: NSObject {
|
||||
sentMessages.append((message, packet))
|
||||
sentPackets.append(packet)
|
||||
|
||||
// Simulate local echo
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.delegate?.didReceiveMessage(message)
|
||||
}
|
||||
deliverLocalEcho(message)
|
||||
|
||||
// Surface raw packet to tests that intercept/relay/encrypt
|
||||
packetDeliveryHandler?(packet)
|
||||
@@ -190,10 +192,7 @@ final class MockBLEService: NSObject {
|
||||
sentMessages.append((message, packet))
|
||||
sentPackets.append(packet)
|
||||
|
||||
// Simulate local echo
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.delegate?.didReceiveMessage(message)
|
||||
}
|
||||
deliverLocalEcho(message)
|
||||
|
||||
// Surface raw packet to tests that intercept/relay/encrypt
|
||||
packetDeliveryHandler?(packet)
|
||||
|
||||
@@ -35,6 +35,9 @@ final class MockTransport: Transport {
|
||||
private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
|
||||
private(set) var sentDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||
private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
||||
private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = []
|
||||
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
|
||||
private(set) var cancelledTransfers: [String] = []
|
||||
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
private(set) var startServicesCallCount = 0
|
||||
@@ -139,15 +142,15 @@ final class MockTransport: Transport {
|
||||
}
|
||||
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||
// Not tracked for current tests
|
||||
sentBroadcastFiles.append((packet, transferId))
|
||||
}
|
||||
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||
// Not tracked for current tests
|
||||
sentPrivateFiles.append((packet, peerID, transferId))
|
||||
}
|
||||
|
||||
func cancelTransfer(_ transferId: String) {
|
||||
// Not tracked for current tests
|
||||
cancelledTransfers.append(transferId)
|
||||
}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
@@ -167,6 +170,9 @@ final class MockTransport: Transport {
|
||||
sentReadReceipts.removeAll()
|
||||
sentDeliveryAcks.removeAll()
|
||||
sentFavoriteNotifications.removeAll()
|
||||
sentBroadcastFiles.removeAll()
|
||||
sentPrivateFiles.removeAll()
|
||||
cancelledTransfers.removeAll()
|
||||
sentVerifyChallenges.removeAll()
|
||||
sentVerifyResponses.removeAll()
|
||||
startServicesCallCount = 0
|
||||
|
||||
@@ -0,0 +1,867 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("Noise Coverage Tests")
|
||||
struct NoiseCoverageTests {
|
||||
private let keychain = MockKeychain()
|
||||
private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
private let alicePeerID = PeerID(str: "0011223344556677")
|
||||
private let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
||||
private let charliePeerID = PeerID(str: "fedcba9876543210")
|
||||
|
||||
@Test("Protocol metadata and handshake patterns expose expected values")
|
||||
func protocolMetadataAndHandshakePatterns() {
|
||||
let ikName = NoiseProtocolName(pattern: NoisePattern.IK.patternName)
|
||||
#expect(ikName.pattern == "IK")
|
||||
#expect(ikName.dh == "25519")
|
||||
#expect(ikName.cipher == "ChaChaPoly")
|
||||
#expect(ikName.hash == "SHA256")
|
||||
#expect(ikName.fullName == "Noise_IK_25519_ChaChaPoly_SHA256")
|
||||
|
||||
#expect(NoisePattern.XX.patternName == "XX")
|
||||
#expect(NoisePattern.IK.patternName == "IK")
|
||||
#expect(NoisePattern.NK.patternName == "NK")
|
||||
|
||||
let ikPatterns = NoisePattern.IK.messagePatterns
|
||||
#expect(ikPatterns.count == 2)
|
||||
#expect(ikPatterns[0] == [.e, .es, .s, .ss])
|
||||
#expect(ikPatterns[1] == [.e, .ee, .se])
|
||||
|
||||
let nkPatterns = NoisePattern.NK.messagePatterns
|
||||
#expect(nkPatterns.count == 2)
|
||||
#expect(nkPatterns[0] == [.e, .es])
|
||||
#expect(nkPatterns[1] == [.e, .ee])
|
||||
}
|
||||
|
||||
@Test("Symmetric state supports long protocol names and mixKeyAndHash")
|
||||
func symmetricStateLongNameAndMixKeyAndHash() {
|
||||
let longName = String(repeating: "NoiseProtocol_", count: 3)
|
||||
let symmetricState = NoiseSymmetricState(protocolName: longName)
|
||||
let initialHash = symmetricState.getHandshakeHash()
|
||||
|
||||
#expect(initialHash.count == 32)
|
||||
#expect(!symmetricState.hasCipherKey())
|
||||
|
||||
symmetricState.mixKeyAndHash(Data("input-key-material".utf8))
|
||||
|
||||
#expect(symmetricState.hasCipherKey())
|
||||
#expect(symmetricState.getHandshakeHash() != initialHash)
|
||||
}
|
||||
|
||||
@Test("Cipher state rejects duplicate and stale extracted nonces")
|
||||
func cipherStateRejectsDuplicateAndStaleNonces() throws {
|
||||
let key = SymmetricKey(size: .bits256)
|
||||
let receiver = NoiseCipherState(key: key, useExtractedNonce: true)
|
||||
let initialPayload = try makeExtractedNoncePayload(
|
||||
key: key,
|
||||
nonce: 0,
|
||||
plaintext: Data("nonce-0".utf8)
|
||||
)
|
||||
|
||||
let initialPlaintext = try receiver.decrypt(ciphertext: initialPayload)
|
||||
#expect(initialPlaintext == Data("nonce-0".utf8))
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
try receiver.decrypt(ciphertext: initialPayload)
|
||||
}
|
||||
|
||||
for nonce in 1...1024 {
|
||||
let payload = try makeExtractedNoncePayload(
|
||||
key: key,
|
||||
nonce: UInt64(nonce),
|
||||
plaintext: Data("nonce-\(nonce)".utf8)
|
||||
)
|
||||
let plaintext = try receiver.decrypt(ciphertext: payload)
|
||||
#expect(plaintext == Data("nonce-\(nonce)".utf8))
|
||||
}
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
try receiver.decrypt(ciphertext: initialPayload)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Cipher state handles large nonce jumps and associated-data mismatches")
|
||||
func cipherStateHandlesLargeJumpsAndAADMismatch() throws {
|
||||
let key = SymmetricKey(size: .bits256)
|
||||
let extractedReceiver = NoiseCipherState(key: key, useExtractedNonce: true)
|
||||
|
||||
let jumped = try makeExtractedNoncePayload(
|
||||
key: key,
|
||||
nonce: 1500,
|
||||
plaintext: Data("future".utf8)
|
||||
)
|
||||
let slightlyOlder = try makeExtractedNoncePayload(
|
||||
key: key,
|
||||
nonce: 1499,
|
||||
plaintext: Data("older".utf8)
|
||||
)
|
||||
let tooOld = try makeExtractedNoncePayload(
|
||||
key: key,
|
||||
nonce: 100,
|
||||
plaintext: Data("ancient".utf8)
|
||||
)
|
||||
|
||||
#expect(try extractedReceiver.decrypt(ciphertext: jumped) == Data("future".utf8))
|
||||
#expect(try extractedReceiver.decrypt(ciphertext: slightlyOlder) == Data("older".utf8))
|
||||
#expect(throws: (any Error).self) {
|
||||
try extractedReceiver.decrypt(ciphertext: tooOld)
|
||||
}
|
||||
|
||||
let sender = NoiseCipherState(key: key)
|
||||
let receiver = NoiseCipherState(key: key)
|
||||
let plaintext = Data("associated-data".utf8)
|
||||
let aad = Data("good-aad".utf8)
|
||||
let ciphertext = try sender.encrypt(plaintext: plaintext, associatedData: aad)
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
try receiver.decrypt(ciphertext: ciphertext, associatedData: Data("bad-aad".utf8))
|
||||
}
|
||||
#expect(try receiver.decrypt(ciphertext: ciphertext, associatedData: aad) == plaintext)
|
||||
#expect(throws: (any Error).self) {
|
||||
try receiver.decrypt(ciphertext: Data(repeating: 0xAA, count: 15))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Cipher state covers nonce guard rails and extracted payload bounds")
|
||||
func cipherStateCoversNonceGuardRailsAndExtractedPayloadBounds() throws {
|
||||
let uninitializedCipher = NoiseCipherState()
|
||||
#expect(throws: NoiseError.uninitializedCipher) {
|
||||
try uninitializedCipher.encrypt(plaintext: Data("missing-key".utf8))
|
||||
}
|
||||
#expect(throws: NoiseError.uninitializedCipher) {
|
||||
try uninitializedCipher.decrypt(ciphertext: Data(repeating: 0x00, count: 16))
|
||||
}
|
||||
#expect(try uninitializedCipher.extractNonceFromCiphertextPayloadForTesting(Data([0x00, 0x01, 0x02])) == nil)
|
||||
|
||||
let key = SymmetricKey(size: .bits256)
|
||||
|
||||
let highNonceCipher = NoiseCipherState(key: key)
|
||||
highNonceCipher.setNonceForTesting(1_000_000_001)
|
||||
#expect(throws: Never.self) {
|
||||
_ = try highNonceCipher.encrypt(plaintext: Data("high-nonce".utf8))
|
||||
}
|
||||
|
||||
let exhaustedCipher = NoiseCipherState(key: key)
|
||||
exhaustedCipher.setNonceForTesting(UInt64(UInt32.max))
|
||||
#expect(throws: NoiseError.nonceExceeded) {
|
||||
try exhaustedCipher.encrypt(plaintext: Data("nonce-limit".utf8))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Handshake validation rejects malformed keys and messages")
|
||||
func handshakeValidationRejectsMalformedInputs() throws {
|
||||
let responder = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
try responder.readMessage(Data(repeating: 0x00, count: 31))
|
||||
}
|
||||
|
||||
let invalidKeys = [
|
||||
Data(),
|
||||
Data(repeating: 0x00, count: 32),
|
||||
Data([0x01] + Array(repeating: 0x00, count: 31)),
|
||||
Data(repeating: 0xFF, count: 32),
|
||||
]
|
||||
|
||||
for invalidKey in invalidKeys {
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try NoiseHandshakeState.validatePublicKey(invalidKey)
|
||||
}
|
||||
}
|
||||
|
||||
let valid = aliceStaticKey.publicKey.rawRepresentation
|
||||
let roundTripped = try NoiseHandshakeState.validatePublicKey(valid)
|
||||
#expect(roundTripped.rawRepresentation == valid)
|
||||
|
||||
let initiator = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: aliceStaticKey
|
||||
)
|
||||
let responderForTamper = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
|
||||
let message1 = try initiator.writeMessage()
|
||||
_ = try responderForTamper.readMessage(message1)
|
||||
var message2 = try responderForTamper.writeMessage()
|
||||
message2[40] ^= 0x01
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
try initiator.readMessage(message2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Handshake readers reject invalid ephemeral and truncated static payloads")
|
||||
func handshakeReadersRejectInvalidEphemeralAndTruncatedStaticPayloads() throws {
|
||||
let invalidEphemeralResponder = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
|
||||
#expect(throws: NoiseError.invalidMessage) {
|
||||
try invalidEphemeralResponder.readMessage(Data(repeating: 0x00, count: 32))
|
||||
}
|
||||
|
||||
let truncatedStaticInitiator = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: aliceStaticKey
|
||||
)
|
||||
_ = try truncatedStaticInitiator.writeMessage()
|
||||
let responderEphemeralOnly = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
|
||||
|
||||
#expect(throws: NoiseError.invalidMessage) {
|
||||
try truncatedStaticInitiator.readMessage(responderEphemeralOnly)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("IK handshake completes and supports transport messages")
|
||||
func ikHandshakeCompletesAndSupportsTransportMessages() throws {
|
||||
let initiator = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .IK,
|
||||
keychain: keychain,
|
||||
localStaticKey: aliceStaticKey,
|
||||
remoteStaticKey: bobStaticKey.publicKey
|
||||
)
|
||||
let responder = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .IK,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
|
||||
let outboundPayload = Data("ik-outbound".utf8)
|
||||
let returnPayload = Data("ik-return".utf8)
|
||||
let message1 = try initiator.writeMessage(payload: outboundPayload)
|
||||
|
||||
#expect(try responder.readMessage(message1) == outboundPayload)
|
||||
|
||||
let message2 = try responder.writeMessage(payload: returnPayload)
|
||||
#expect(try initiator.readMessage(message2) == returnPayload)
|
||||
|
||||
#expect(initiator.isHandshakeComplete())
|
||||
#expect(responder.isHandshakeComplete())
|
||||
|
||||
let (initiatorSend, initiatorReceive, initiatorHash) = try initiator.getTransportCiphers(
|
||||
useExtractedNonce: true
|
||||
)
|
||||
let (responderSend, responderReceive, responderHash) = try responder.getTransportCiphers(
|
||||
useExtractedNonce: true
|
||||
)
|
||||
|
||||
#expect(initiatorHash == responderHash)
|
||||
|
||||
let clientCiphertext = try initiatorSend.encrypt(plaintext: Data("ik-transport".utf8))
|
||||
#expect(try responderReceive.decrypt(ciphertext: clientCiphertext) == Data("ik-transport".utf8))
|
||||
|
||||
let serverCiphertext = try responderSend.encrypt(plaintext: Data("ik-response".utf8))
|
||||
#expect(try initiatorReceive.decrypt(ciphertext: serverCiphertext) == Data("ik-response".utf8))
|
||||
}
|
||||
|
||||
@Test("NK handshake requires a responder static key and supports transport messages")
|
||||
func nkHandshakeRequiresStaticAndSupportsTransportMessages() throws {
|
||||
let missingStaticInitiator = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .NK,
|
||||
keychain: keychain,
|
||||
localStaticKey: aliceStaticKey
|
||||
)
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
try missingStaticInitiator.writeMessage()
|
||||
}
|
||||
|
||||
let initiator = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .NK,
|
||||
keychain: keychain,
|
||||
localStaticKey: aliceStaticKey,
|
||||
remoteStaticKey: bobStaticKey.publicKey
|
||||
)
|
||||
let responder = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .NK,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
|
||||
let outboundPayload = Data("nk-outbound".utf8)
|
||||
let returnPayload = Data("nk-return".utf8)
|
||||
let message1 = try initiator.writeMessage(payload: outboundPayload)
|
||||
#expect(try responder.readMessage(message1) == outboundPayload)
|
||||
|
||||
let message2 = try responder.writeMessage(payload: returnPayload)
|
||||
#expect(try initiator.readMessage(message2) == returnPayload)
|
||||
|
||||
#expect(initiator.isHandshakeComplete())
|
||||
#expect(responder.isHandshakeComplete())
|
||||
|
||||
let (initiatorSend, initiatorReceive, initiatorHash) = try initiator.getTransportCiphers(
|
||||
useExtractedNonce: true
|
||||
)
|
||||
let (responderSend, responderReceive, responderHash) = try responder.getTransportCiphers(
|
||||
useExtractedNonce: true
|
||||
)
|
||||
|
||||
#expect(initiatorHash == responderHash)
|
||||
|
||||
let clientCiphertext = try initiatorSend.encrypt(plaintext: Data("nk-transport".utf8))
|
||||
#expect(try responderReceive.decrypt(ciphertext: clientCiphertext) == Data("nk-transport".utf8))
|
||||
|
||||
let serverCiphertext = try responderSend.encrypt(plaintext: Data("nk-response".utf8))
|
||||
#expect(try initiatorReceive.decrypt(ciphertext: serverCiphertext) == Data("nk-response".utf8))
|
||||
}
|
||||
|
||||
@Test("Responder-side NK writes require peer ephemeral input")
|
||||
func responderWritesRequirePeerEphemeralInput() {
|
||||
let nkResponder = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .NK,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
|
||||
#expect(throws: NoiseError.missingKeys) {
|
||||
try nkResponder.writeMessage()
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Direct DH helpers reject missing keys across all patterns")
|
||||
func directDHHelpersRejectMissingKeysAcrossAllPatterns() throws {
|
||||
let eeState = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: aliceStaticKey
|
||||
)
|
||||
#expect(throws: NoiseError.missingKeys) {
|
||||
try eeState.performDHOperationForTesting(.ee)
|
||||
}
|
||||
|
||||
let esInitiator = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: aliceStaticKey
|
||||
)
|
||||
#expect(throws: NoiseError.missingKeys) {
|
||||
try esInitiator.performDHOperationForTesting(.es)
|
||||
}
|
||||
|
||||
let esResponder = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: nil
|
||||
)
|
||||
#expect(throws: NoiseError.missingKeys) {
|
||||
try esResponder.performDHOperationForTesting(.es)
|
||||
}
|
||||
|
||||
let seInitiator = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: nil
|
||||
)
|
||||
#expect(throws: NoiseError.missingKeys) {
|
||||
try seInitiator.performDHOperationForTesting(.se)
|
||||
}
|
||||
|
||||
let seResponder = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
#expect(throws: NoiseError.missingKeys) {
|
||||
try seResponder.performDHOperationForTesting(.se)
|
||||
}
|
||||
|
||||
let ssState = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: nil
|
||||
)
|
||||
#expect(throws: NoiseError.missingKeys) {
|
||||
try ssState.performDHOperationForTesting(.ss)
|
||||
}
|
||||
|
||||
#expect(throws: Never.self) {
|
||||
try eeState.performDHOperationForTesting(.e)
|
||||
try eeState.performDHOperationForTesting(.s)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Prepared handshake writers cover remaining missing-key branches")
|
||||
func preparedHandshakeWritersCoverRemainingMissingKeyBranches() {
|
||||
let eeResponder = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .NK,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
eeResponder.setCurrentPatternForTesting(1)
|
||||
#expect(throws: NoiseError.missingKeys) {
|
||||
try eeResponder.writeMessage()
|
||||
}
|
||||
|
||||
let seInitiator = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: aliceStaticKey
|
||||
)
|
||||
seInitiator.setCurrentPatternForTesting(2)
|
||||
#expect(throws: NoiseError.missingKeys) {
|
||||
try seInitiator.writeMessage()
|
||||
}
|
||||
|
||||
let seResponder = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .IK,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
seResponder.setCurrentPatternForTesting(1)
|
||||
seResponder.setRemoteEphemeralPublicKeyForTesting(Curve25519.KeyAgreement.PrivateKey().publicKey)
|
||||
#expect(throws: NoiseError.missingKeys) {
|
||||
try seResponder.writeMessage()
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Completed handshakes reject additional reads and writes")
|
||||
func completedHandshakesRejectAdditionalReadsAndWrites() throws {
|
||||
let initiator = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .IK,
|
||||
keychain: keychain,
|
||||
localStaticKey: aliceStaticKey,
|
||||
remoteStaticKey: bobStaticKey.publicKey
|
||||
)
|
||||
let responder = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .IK,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
|
||||
let message1 = try initiator.writeMessage(payload: Data("first".utf8))
|
||||
_ = try responder.readMessage(message1)
|
||||
let message2 = try responder.writeMessage(payload: Data("second".utf8))
|
||||
_ = try initiator.readMessage(message2)
|
||||
|
||||
#expect(throws: NoiseError.handshakeComplete) {
|
||||
try initiator.writeMessage()
|
||||
}
|
||||
#expect(throws: NoiseError.handshakeComplete) {
|
||||
try responder.readMessage(message1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("XX final message requires a local static key")
|
||||
func xxFinalMessageRequiresLocalStaticKey() throws {
|
||||
let initiator = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: nil
|
||||
)
|
||||
let responder = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
|
||||
let message1 = try initiator.writeMessage()
|
||||
_ = try responder.readMessage(message1)
|
||||
let message2 = try responder.writeMessage()
|
||||
_ = try initiator.readMessage(message2)
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
try initiator.writeMessage()
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Responder start handshake is empty and transport ciphers require completion")
|
||||
func responderStartHandshakeAndIncompleteTransportCiphers() throws {
|
||||
let responderSession = NoiseSession(
|
||||
peerID: bobPeerID,
|
||||
role: .responder,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
let incompleteHandshake = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: aliceStaticKey
|
||||
)
|
||||
|
||||
#expect(try responderSession.startHandshake().isEmpty)
|
||||
#expect(responderSession.getState() == .handshaking)
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try incompleteHandshake.getTransportCiphers(useExtractedNonce: true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Session manager callbacks establish and failed handshakes clean up state")
|
||||
func sessionManagerCallbacksAndFailureCleanup() async throws {
|
||||
let establishedRecorder = SessionCallbackRecorder()
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||
|
||||
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
|
||||
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
|
||||
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
let didEstablish = await TestHelpers.waitUntil(
|
||||
{ establishedRecorder.establishedCount == 2 },
|
||||
timeout: 0.5
|
||||
)
|
||||
#expect(didEstablish)
|
||||
#expect(establishedRecorder.establishedPeerIDs.contains(alicePeerID))
|
||||
#expect(establishedRecorder.establishedPeerIDs.contains(bobPeerID))
|
||||
|
||||
let failureRecorder = SessionCallbackRecorder()
|
||||
let failingManager = NoiseSessionManager(localStaticKey: charlieStaticKey, keychain: keychain)
|
||||
failingManager.onSessionFailed = failureRecorder.recordFailure(peerID:error:)
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
try failingManager.handleIncomingHandshake(
|
||||
from: charliePeerID,
|
||||
message: Data(repeating: 0x00, count: 31)
|
||||
)
|
||||
}
|
||||
|
||||
let didFail = await TestHelpers.waitUntil(
|
||||
{ failureRecorder.failureCount == 1 },
|
||||
timeout: 0.5
|
||||
)
|
||||
#expect(didFail)
|
||||
#expect(failingManager.getSession(for: charliePeerID) == nil)
|
||||
}
|
||||
|
||||
@Test("Session manager cleans up initiator sessions after start-handshake failures")
|
||||
func sessionManagerCleansUpInitiatorSessionsAfterStartHandshakeFailures() {
|
||||
let manager = NoiseSessionManager(
|
||||
localStaticKey: aliceStaticKey,
|
||||
keychain: keychain,
|
||||
sessionFactory: { peerID, role in
|
||||
FailingNoiseSession(
|
||||
peerID: peerID,
|
||||
role: role,
|
||||
keychain: self.keychain,
|
||||
localStaticKey: self.aliceStaticKey
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(throws: FailingNoiseSession.Error.synthetic) {
|
||||
try manager.initiateHandshake(with: alicePeerID)
|
||||
}
|
||||
#expect(manager.getSession(for: alicePeerID) == nil)
|
||||
}
|
||||
|
||||
@Test("Session manager rekeys established sessions and replaces partial handshakes")
|
||||
func sessionManagerRekeysAndReplacesSessions() throws {
|
||||
let manager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
||||
|
||||
#expect(throws: NoiseSessionError.sessionNotFound) {
|
||||
try manager.encrypt(Data("missing".utf8), for: alicePeerID)
|
||||
}
|
||||
#expect(throws: NoiseSessionError.sessionNotFound) {
|
||||
try manager.decrypt(Data("missing".utf8), from: alicePeerID)
|
||||
}
|
||||
|
||||
let initialHandshake = try manager.initiateHandshake(with: alicePeerID)
|
||||
#expect(!initialHandshake.isEmpty)
|
||||
let firstSession = try #require(manager.getSession(for: alicePeerID))
|
||||
|
||||
let restartedHandshake = try manager.initiateHandshake(with: alicePeerID)
|
||||
let restartedSession = try #require(manager.getSession(for: alicePeerID))
|
||||
|
||||
#expect(!restartedHandshake.isEmpty)
|
||||
#expect(restartedSession !== firstSession)
|
||||
|
||||
let restartedInitiator = NoiseSession(
|
||||
peerID: alicePeerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
let replacementMessage = try restartedInitiator.startHandshake()
|
||||
let replacementResponse = try manager.handleIncomingHandshake(
|
||||
from: alicePeerID,
|
||||
message: replacementMessage
|
||||
)
|
||||
let replacementSession = try #require(manager.getSession(for: alicePeerID))
|
||||
|
||||
#expect(replacementResponse != nil)
|
||||
#expect(replacementSession !== restartedSession)
|
||||
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
let establishedSession = try #require(
|
||||
aliceManager.getSession(for: alicePeerID) as? SecureNoiseSession
|
||||
)
|
||||
establishedSession.setMessageCountForTesting(
|
||||
UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
||||
)
|
||||
|
||||
let sessionsNeedingRekey = aliceManager.getSessionsNeedingRekey()
|
||||
#expect(sessionsNeedingRekey.contains { $0.peerID == alicePeerID && $0.needsRekey })
|
||||
|
||||
#expect(throws: NoiseSessionError.alreadyEstablished) {
|
||||
try aliceManager.initiateHandshake(with: alicePeerID)
|
||||
}
|
||||
|
||||
try aliceManager.initiateRekey(for: alicePeerID)
|
||||
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
|
||||
|
||||
#expect(rekeyedSession !== establishedSession)
|
||||
#expect(rekeyedSession.getState() == .handshaking)
|
||||
}
|
||||
|
||||
@Test("Secure noise sessions enforce limits and renegotiation thresholds")
|
||||
func secureNoiseSessionsEnforceLimitsAndThresholds() throws {
|
||||
let initiator = SecureNoiseSession(
|
||||
peerID: alicePeerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: aliceStaticKey
|
||||
)
|
||||
let responder = SecureNoiseSession(
|
||||
peerID: bobPeerID,
|
||||
role: .responder,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
|
||||
try establishSessions(initiator: initiator, responder: responder)
|
||||
|
||||
responder.setMessageCountForTesting(0)
|
||||
responder.setLastActivityTimeForTesting(Date())
|
||||
#expect(!responder.needsRenegotiation())
|
||||
|
||||
responder.setMessageCountForTesting(
|
||||
UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
||||
)
|
||||
#expect(responder.needsRenegotiation())
|
||||
|
||||
responder.setMessageCountForTesting(0)
|
||||
responder.setLastActivityTimeForTesting(
|
||||
Date().addingTimeInterval(-(NoiseSecurityConstants.sessionTimeout + 1))
|
||||
)
|
||||
#expect(responder.needsRenegotiation())
|
||||
|
||||
initiator.setMessageCountForTesting(NoiseSecurityConstants.maxMessagesPerSession)
|
||||
#expect(throws: (any Error).self) {
|
||||
try initiator.encrypt(Data("exhausted".utf8))
|
||||
}
|
||||
|
||||
initiator.setMessageCountForTesting(0)
|
||||
#expect(throws: (any Error).self) {
|
||||
try initiator.encrypt(Data(repeating: 0xAB, count: NoiseSecurityConstants.maxMessageSize + 1))
|
||||
}
|
||||
|
||||
responder.setLastActivityTimeForTesting(Date())
|
||||
#expect(throws: (any Error).self) {
|
||||
try responder.decrypt(
|
||||
Data(repeating: 0xCD, count: NoiseSecurityConstants.maxMessageSize + 1)
|
||||
)
|
||||
}
|
||||
|
||||
let transportCiphertext = try initiator.encrypt(Data("secure-session".utf8))
|
||||
#expect(try responder.decrypt(transportCiphertext) == Data("secure-session".utf8))
|
||||
}
|
||||
|
||||
@Test("Secure noise sessions expire based on session start time")
|
||||
func secureNoiseSessionsExpireBasedOnSessionStartTime() throws {
|
||||
let initiator = SecureNoiseSession(
|
||||
peerID: alicePeerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: aliceStaticKey
|
||||
)
|
||||
let responder = SecureNoiseSession(
|
||||
peerID: bobPeerID,
|
||||
role: .responder,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
|
||||
try establishSessions(initiator: initiator, responder: responder)
|
||||
|
||||
initiator.setSessionStartTimeForTesting(
|
||||
Date().addingTimeInterval(-(NoiseSecurityConstants.sessionTimeout + 1))
|
||||
)
|
||||
#expect(throws: (any Error).self) {
|
||||
try initiator.encrypt(Data("expired".utf8))
|
||||
}
|
||||
|
||||
responder.setSessionStartTimeForTesting(
|
||||
Date().addingTimeInterval(-(NoiseSecurityConstants.sessionTimeout + 1))
|
||||
)
|
||||
#expect(throws: (any Error).self) {
|
||||
try responder.decrypt(Data())
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Rate limiter handles global message caps and per-peer resets")
|
||||
func rateLimiterGlobalMessageCapAndReset() async throws {
|
||||
let globalLimiter = NoiseRateLimiter()
|
||||
for index in 0..<NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||
#expect(globalLimiter.allowMessage(from: PeerID(str: "peer-\(index)")))
|
||||
}
|
||||
#expect(!globalLimiter.allowMessage(from: charliePeerID))
|
||||
|
||||
let peerLimiter = NoiseRateLimiter()
|
||||
for _ in 0..<NoiseSecurityConstants.maxMessagesPerSecond {
|
||||
#expect(peerLimiter.allowMessage(from: alicePeerID))
|
||||
}
|
||||
#expect(!peerLimiter.allowMessage(from: alicePeerID))
|
||||
|
||||
peerLimiter.reset(for: alicePeerID)
|
||||
try await sleep(0.05)
|
||||
#expect(peerLimiter.allowMessage(from: alicePeerID))
|
||||
}
|
||||
|
||||
@Test("Cipher state decrypts high extracted nonces and rejects truncated extracted payloads")
|
||||
func cipherStateDecryptsHighExtractedNoncesAndRejectsTruncatedPayloads() throws {
|
||||
let key = SymmetricKey(size: .bits256)
|
||||
let receiver = NoiseCipherState(key: key, useExtractedNonce: true)
|
||||
let highNoncePayload = try makeExtractedNoncePayload(
|
||||
key: key,
|
||||
nonce: 1_000_000_001,
|
||||
plaintext: Data("high-nonce".utf8)
|
||||
)
|
||||
|
||||
#expect(try receiver.decrypt(ciphertext: highNoncePayload) == Data("high-nonce".utf8))
|
||||
#expect(throws: NoiseError.invalidCiphertext) {
|
||||
try receiver.decrypt(ciphertext: extractedNoncePrefix(7))
|
||||
}
|
||||
}
|
||||
|
||||
private func establishSessions(initiator: NoiseSession, responder: NoiseSession) throws {
|
||||
let message1 = try initiator.startHandshake()
|
||||
let response2 = try responder.processHandshakeMessage(message1)
|
||||
let message2 = try #require(response2)
|
||||
let response3 = try initiator.processHandshakeMessage(message2)
|
||||
let message3 = try #require(response3)
|
||||
let final = try responder.processHandshakeMessage(message3)
|
||||
#expect(final == nil)
|
||||
}
|
||||
|
||||
private func establishManagerSessions(
|
||||
aliceManager: NoiseSessionManager,
|
||||
bobManager: NoiseSessionManager
|
||||
) throws {
|
||||
let message1 = try aliceManager.initiateHandshake(with: alicePeerID)
|
||||
let response2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message1)
|
||||
let message2 = try #require(response2)
|
||||
let response3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message2)
|
||||
let message3 = try #require(response3)
|
||||
let final = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message3)
|
||||
#expect(final == nil)
|
||||
}
|
||||
|
||||
private func makeExtractedNoncePayload(
|
||||
key: SymmetricKey,
|
||||
nonce: UInt64,
|
||||
plaintext: Data,
|
||||
associatedData: Data = Data()
|
||||
) throws -> Data {
|
||||
var fullNonce = Data(count: 12)
|
||||
withUnsafeBytes(of: nonce.littleEndian) { bytes in
|
||||
fullNonce.replaceSubrange(4..<12, with: bytes)
|
||||
}
|
||||
|
||||
let sealedBox = try ChaChaPoly.seal(
|
||||
plaintext,
|
||||
using: key,
|
||||
nonce: ChaChaPoly.Nonce(data: fullNonce),
|
||||
authenticating: associatedData
|
||||
)
|
||||
|
||||
return extractedNoncePrefix(nonce) + sealedBox.ciphertext + sealedBox.tag
|
||||
}
|
||||
|
||||
private func extractedNoncePrefix(_ nonce: UInt64) -> Data {
|
||||
withUnsafeBytes(of: nonce.bigEndian) { bytes in
|
||||
Data(bytes.suffix(4))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class SessionCallbackRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var establishedEntries: [(PeerID, Data)] = []
|
||||
private var failureEntries: [(PeerID, String)] = []
|
||||
|
||||
var establishedCount: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return establishedEntries.count
|
||||
}
|
||||
|
||||
var failureCount: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return failureEntries.count
|
||||
}
|
||||
|
||||
var establishedPeerIDs: [PeerID] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return establishedEntries.map(\.0)
|
||||
}
|
||||
|
||||
func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) {
|
||||
lock.lock()
|
||||
establishedEntries.append((peerID, remoteKey.rawRepresentation))
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func recordFailure(peerID: PeerID, error: Error) {
|
||||
lock.lock()
|
||||
failureEntries.append((peerID, String(describing: error)))
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private final class FailingNoiseSession: NoiseSession {
|
||||
enum Error: Swift.Error {
|
||||
case synthetic
|
||||
}
|
||||
|
||||
override func startHandshake() throws -> Data {
|
||||
throw Error.synthetic
|
||||
}
|
||||
}
|
||||
@@ -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,414 @@
|
||||
import Foundation
|
||||
import Tor
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
final class GeoRelayDirectoryTests: XCTestCase {
|
||||
func test_parseCSV_normalizesRelaySchemesAndDeduplicatesEntries() {
|
||||
let csv = """
|
||||
relay url,lat,lon
|
||||
wss://one.example/,10,20
|
||||
https://one.example,10,20
|
||||
http://two.example/,11,21
|
||||
invalid row
|
||||
ws://three.example,not-a-lat,22
|
||||
"""
|
||||
|
||||
let parsed = Set(GeoRelayDirectory.parseCSV(csv))
|
||||
|
||||
XCTAssertEqual(
|
||||
parsed,
|
||||
Set([
|
||||
GeoRelayDirectory.Entry(host: "one.example", lat: 10, lon: 20),
|
||||
GeoRelayDirectory.Entry(host: "two.example", lat: 11, lon: 21)
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
func test_closestRelays_sortsByDistanceForLatLonAndGeohash() {
|
||||
let harness = makeHarness(
|
||||
cacheCSV: """
|
||||
relay url,lat,lon
|
||||
close.example,37.7749,-122.4194
|
||||
medium.example,34.0522,-118.2437
|
||||
far.example,40.7128,-74.0060
|
||||
"""
|
||||
)
|
||||
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
|
||||
|
||||
XCTAssertEqual(
|
||||
directory.closestRelays(toLat: 37.78, lon: -122.41, count: 2),
|
||||
["wss://close.example", "wss://medium.example"]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
directory.closestRelays(toLat: 37.78, lon: -122.41, count: 10),
|
||||
["wss://close.example", "wss://medium.example", "wss://far.example"]
|
||||
)
|
||||
|
||||
let geohash = Geohash.encode(latitude: 37.78, longitude: -122.41, precision: 6)
|
||||
XCTAssertEqual(
|
||||
directory.closestRelays(toGeohash: geohash, count: 2),
|
||||
["wss://close.example", "wss://medium.example"]
|
||||
)
|
||||
}
|
||||
|
||||
func test_loadLocalEntries_prefersCacheThenBundleThenWorkingDirectory() {
|
||||
let cacheHarness = makeHarness(
|
||||
cacheCSV: """
|
||||
relay url,lat,lon
|
||||
cache.example,1,1
|
||||
""",
|
||||
bundleCSV: """
|
||||
relay url,lat,lon
|
||||
bundle.example,2,2
|
||||
""",
|
||||
workingDirectoryCSV: """
|
||||
relay url,lat,lon
|
||||
cwd.example,3,3
|
||||
"""
|
||||
)
|
||||
XCTAssertEqual(
|
||||
GeoRelayDirectory(dependencies: cacheHarness.dependencies).entries,
|
||||
[GeoRelayDirectory.Entry(host: "cache.example", lat: 1, lon: 1)]
|
||||
)
|
||||
|
||||
let bundleHarness = makeHarness(
|
||||
cacheCSV: "invalid",
|
||||
bundleCSV: """
|
||||
relay url,lat,lon
|
||||
bundle.example,2,2
|
||||
""",
|
||||
workingDirectoryCSV: """
|
||||
relay url,lat,lon
|
||||
cwd.example,3,3
|
||||
"""
|
||||
)
|
||||
XCTAssertEqual(
|
||||
GeoRelayDirectory(dependencies: bundleHarness.dependencies).entries,
|
||||
[GeoRelayDirectory.Entry(host: "bundle.example", lat: 2, lon: 2)]
|
||||
)
|
||||
|
||||
let cwdHarness = makeHarness(
|
||||
cacheCSV: nil,
|
||||
bundleCSV: "invalid",
|
||||
workingDirectoryCSV: """
|
||||
relay url,lat,lon
|
||||
cwd.example,3,3
|
||||
"""
|
||||
)
|
||||
XCTAssertEqual(
|
||||
GeoRelayDirectory(dependencies: cwdHarness.dependencies).entries,
|
||||
[GeoRelayDirectory.Entry(host: "cwd.example", lat: 3, lon: 3)]
|
||||
)
|
||||
}
|
||||
|
||||
func test_prefetchIfNeeded_skipsWhenFetchIntervalHasNotElapsed() async {
|
||||
let harness = makeHarness(fetchCSV: """
|
||||
relay url,lat,lon
|
||||
one.example,1,1
|
||||
""")
|
||||
harness.userDefaults.set(harness.clock.now, forKey: "georelay.lastFetchAt")
|
||||
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
|
||||
|
||||
directory.prefetchIfNeeded()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
let requestCount = await harness.fetcher.recordedRequestCount()
|
||||
XCTAssertEqual(requestCount, 0)
|
||||
XCTAssertFalse(directory.debugHasRetryTask)
|
||||
}
|
||||
|
||||
func test_prefetchIfNeeded_successUpdatesEntriesPersistsCacheAndSkipsImmediateForcedRefetch() async {
|
||||
let csv = """
|
||||
relay url,lat,lon
|
||||
refreshed.example,12,34
|
||||
"""
|
||||
let harness = makeHarness(fetchCSV: csv)
|
||||
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
|
||||
|
||||
directory.prefetchIfNeeded()
|
||||
let refreshed = await waitUntil {
|
||||
directory.entries == [GeoRelayDirectory.Entry(host: "refreshed.example", lat: 12, lon: 34)]
|
||||
}
|
||||
XCTAssertTrue(refreshed)
|
||||
let requestCount = await harness.fetcher.recordedRequestCount()
|
||||
XCTAssertEqual(requestCount, 1)
|
||||
XCTAssertEqual(harness.fileStore.dataByURL[harness.cacheURL], csv.data(using: .utf8))
|
||||
XCTAssertEqual(harness.userDefaults.object(forKey: "georelay.lastFetchAt") as? Date, harness.clock.now)
|
||||
XCTAssertEqual(directory.debugRetryAttempt, 0)
|
||||
XCTAssertFalse(directory.debugHasRetryTask)
|
||||
|
||||
directory.prefetchIfNeeded(force: true)
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
let forcedRequestCount = await harness.fetcher.recordedRequestCount()
|
||||
XCTAssertEqual(forcedRequestCount, 1)
|
||||
}
|
||||
|
||||
func test_prefetchIfNeeded_runsRemoteFetchOffMainThread() async {
|
||||
var factoryThreadFlags: [Bool] = []
|
||||
let threadRecorder = MainThreadRecorder()
|
||||
let harness = makeHarness(
|
||||
fetchCSV: """
|
||||
relay url,lat,lon
|
||||
background.example,8,9
|
||||
""",
|
||||
fetchFactoryObserver: {
|
||||
factoryThreadFlags.append(isExecutingOnMainThread())
|
||||
},
|
||||
fetchObserver: {
|
||||
await threadRecorder.record(isExecutingOnMainThread())
|
||||
}
|
||||
)
|
||||
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
|
||||
|
||||
directory.prefetchIfNeeded()
|
||||
|
||||
let refreshed = await waitUntil {
|
||||
directory.entries == [GeoRelayDirectory.Entry(host: "background.example", lat: 8, lon: 9)]
|
||||
}
|
||||
XCTAssertTrue(refreshed)
|
||||
XCTAssertEqual(factoryThreadFlags, [true])
|
||||
let recordedValues = await threadRecorder.recordedValues()
|
||||
XCTAssertEqual(recordedValues, [false])
|
||||
}
|
||||
|
||||
func test_prefetchIfNeeded_failureSchedulesRetryAndRecoversOnNextFetch() async {
|
||||
let csv = """
|
||||
relay url,lat,lon
|
||||
retry.example,5,6
|
||||
"""
|
||||
let harness = makeHarness(
|
||||
fetchResults: [
|
||||
.failure(GeoRelayTestError.network),
|
||||
.success(csv.data(using: .utf8)!)
|
||||
]
|
||||
)
|
||||
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
|
||||
|
||||
directory.prefetchIfNeeded()
|
||||
|
||||
let recovered = await waitUntil {
|
||||
directory.entries == [GeoRelayDirectory.Entry(host: "retry.example", lat: 5, lon: 6)]
|
||||
}
|
||||
XCTAssertTrue(recovered)
|
||||
let requestCount = await harness.fetcher.recordedRequestCount()
|
||||
let retryDelays = await harness.retryRecorder.recordedDelays()
|
||||
XCTAssertEqual(requestCount, 2)
|
||||
XCTAssertEqual(retryDelays, [5])
|
||||
XCTAssertEqual(directory.debugRetryAttempt, 0)
|
||||
XCTAssertFalse(directory.debugHasRetryTask)
|
||||
}
|
||||
|
||||
func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async {
|
||||
let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive")
|
||||
let harness = makeHarness(
|
||||
fetchCSV: """
|
||||
relay url,lat,lon
|
||||
observer.example,1,2
|
||||
""",
|
||||
autoStart: true,
|
||||
activeNotificationName: activeNotification
|
||||
)
|
||||
var directory: GeoRelayDirectory? = GeoRelayDirectory(dependencies: harness.dependencies)
|
||||
let initialFetch = await waitUntil {
|
||||
await harness.fetcher.recordedRequestCount() == 1
|
||||
}
|
||||
XCTAssertTrue(initialFetch)
|
||||
XCTAssertEqual(directory?.debugObserverCount, 2)
|
||||
|
||||
harness.clock.now = harness.clock.now.addingTimeInterval(6)
|
||||
harness.notificationCenter.post(name: .TorDidBecomeReady, object: nil)
|
||||
let torTriggered = await waitUntil {
|
||||
await harness.fetcher.recordedRequestCount() == 2
|
||||
}
|
||||
XCTAssertTrue(torTriggered)
|
||||
|
||||
harness.clock.now = harness.clock.now.addingTimeInterval(61)
|
||||
harness.notificationCenter.post(name: activeNotification, object: nil)
|
||||
let activeTriggered = await waitUntil {
|
||||
await harness.fetcher.recordedRequestCount() == 3
|
||||
}
|
||||
XCTAssertTrue(activeTriggered)
|
||||
|
||||
weak var weakDirectory: GeoRelayDirectory?
|
||||
weakDirectory = directory
|
||||
directory = nil
|
||||
XCTAssertNil(weakDirectory)
|
||||
}
|
||||
|
||||
private func makeHarness(
|
||||
cacheCSV: String? = nil,
|
||||
bundleCSV: String? = nil,
|
||||
workingDirectoryCSV: String? = nil,
|
||||
fetchCSV: String? = nil,
|
||||
fetchResults: [Result<Data, Error>] = [],
|
||||
fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil,
|
||||
fetchObserver: (@Sendable () async -> Void)? = nil,
|
||||
autoStart: Bool = false,
|
||||
activeNotificationName: Notification.Name? = nil
|
||||
) -> GeoRelayHarness {
|
||||
let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)"
|
||||
let userDefaults = UserDefaults(suiteName: userDefaultsSuite)!
|
||||
userDefaults.removePersistentDomain(forName: userDefaultsSuite)
|
||||
|
||||
let notificationCenter = NotificationCenter()
|
||||
let clock = MutableGeoClock(now: Date(timeIntervalSince1970: 1_700_000_000))
|
||||
let fileStore = InMemoryFileStore()
|
||||
let cacheURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)-cache.csv")
|
||||
let bundleURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)-bundle.csv")
|
||||
let cwd = "/tmp/\(UUID().uuidString)-cwd"
|
||||
let cwdURL = URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")
|
||||
|
||||
if let cacheCSV {
|
||||
fileStore.dataByURL[cacheURL] = Data(cacheCSV.utf8)
|
||||
}
|
||||
if let bundleCSV {
|
||||
fileStore.dataByURL[bundleURL] = Data(bundleCSV.utf8)
|
||||
}
|
||||
if let workingDirectoryCSV {
|
||||
fileStore.dataByURL[cwdURL] = Data(workingDirectoryCSV.utf8)
|
||||
}
|
||||
|
||||
let defaultFetchData = Data((fetchCSV ?? bundleCSV ?? cacheCSV ?? "relay url,lat,lon\nfallback.example,0,0\n").utf8)
|
||||
let fetcher = FetchProbe(responses: fetchResults, defaultData: defaultFetchData)
|
||||
let retryRecorder = RetryDelayRecorder()
|
||||
|
||||
let dependencies = GeoRelayDirectoryDependencies(
|
||||
userDefaults: userDefaults,
|
||||
notificationCenter: notificationCenter,
|
||||
now: { clock.now },
|
||||
remoteURL: URL(string: "https://example.com/nostr_relays.csv")!,
|
||||
fetchInterval: 60,
|
||||
refreshCheckInterval: 0,
|
||||
retryInitialSeconds: 5,
|
||||
retryMaxSeconds: 40,
|
||||
awaitTorReady: { true },
|
||||
makeFetchData: {
|
||||
fetchFactoryObserver?()
|
||||
return { request in
|
||||
await fetchObserver?()
|
||||
return try await fetcher.fetch(request)
|
||||
}
|
||||
},
|
||||
readData: { url in
|
||||
fileStore.dataByURL[url]
|
||||
},
|
||||
writeData: { data, url in
|
||||
fileStore.dataByURL[url] = data
|
||||
},
|
||||
cacheURL: { cacheURL },
|
||||
bundledCSVURLs: bundleCSV == nil ? { [] } : { [bundleURL] },
|
||||
currentDirectoryPath: workingDirectoryCSV == nil ? { nil } : { cwd },
|
||||
retrySleep: { delay in
|
||||
await retryRecorder.record(delay)
|
||||
},
|
||||
activeNotificationName: activeNotificationName,
|
||||
autoStart: autoStart
|
||||
)
|
||||
|
||||
return GeoRelayHarness(
|
||||
dependencies: dependencies,
|
||||
clock: clock,
|
||||
fileStore: fileStore,
|
||||
fetcher: fetcher,
|
||||
retryRecorder: retryRecorder,
|
||||
userDefaults: userDefaults,
|
||||
notificationCenter: notificationCenter,
|
||||
cacheURL: cacheURL
|
||||
)
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping @MainActor () async -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if await condition() {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
return await condition()
|
||||
}
|
||||
}
|
||||
|
||||
private struct GeoRelayHarness {
|
||||
let dependencies: GeoRelayDirectoryDependencies
|
||||
let clock: MutableGeoClock
|
||||
let fileStore: InMemoryFileStore
|
||||
let fetcher: FetchProbe
|
||||
let retryRecorder: RetryDelayRecorder
|
||||
let userDefaults: UserDefaults
|
||||
let notificationCenter: NotificationCenter
|
||||
let cacheURL: URL
|
||||
}
|
||||
|
||||
private final class MutableGeoClock {
|
||||
var now: Date
|
||||
|
||||
init(now: Date) {
|
||||
self.now = now
|
||||
}
|
||||
}
|
||||
|
||||
private final class InMemoryFileStore {
|
||||
var dataByURL: [URL: Data] = [:]
|
||||
}
|
||||
|
||||
private actor FetchProbe {
|
||||
private var responses: [Result<Data, Error>]
|
||||
private let defaultData: Data
|
||||
private(set) var requestCount = 0
|
||||
|
||||
init(responses: [Result<Data, Error>], defaultData: Data) {
|
||||
self.responses = responses
|
||||
self.defaultData = defaultData
|
||||
}
|
||||
|
||||
func fetch(_ request: URLRequest) async throws -> Data {
|
||||
_ = request
|
||||
requestCount += 1
|
||||
if !responses.isEmpty {
|
||||
return try responses.removeFirst().get()
|
||||
}
|
||||
return defaultData
|
||||
}
|
||||
|
||||
func recordedRequestCount() -> Int {
|
||||
requestCount
|
||||
}
|
||||
}
|
||||
|
||||
private actor RetryDelayRecorder {
|
||||
private(set) var delays: [TimeInterval] = []
|
||||
|
||||
func record(_ delay: TimeInterval) {
|
||||
delays.append(delay)
|
||||
}
|
||||
|
||||
func recordedDelays() -> [TimeInterval] {
|
||||
delays
|
||||
}
|
||||
}
|
||||
|
||||
private actor MainThreadRecorder {
|
||||
private var values: [Bool] = []
|
||||
|
||||
func record(_ value: Bool) {
|
||||
values.append(value)
|
||||
}
|
||||
|
||||
func recordedValues() -> [Bool] {
|
||||
values
|
||||
}
|
||||
}
|
||||
|
||||
private enum GeoRelayTestError: Error {
|
||||
case network
|
||||
}
|
||||
|
||||
private func isExecutingOnMainThread() -> Bool {
|
||||
Thread.isMainThread
|
||||
}
|
||||
@@ -213,6 +213,45 @@ struct NostrProtocolTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func nostrEventSignatureVerification_roundTrip() throws {
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [],
|
||||
content: "Signed event"
|
||||
)
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
#expect(signed.isValidSignature())
|
||||
}
|
||||
|
||||
@Test func nostrEventSignatureVerification_detectsTamper() throws {
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [],
|
||||
content: "Original"
|
||||
)
|
||||
var signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
signed.id = "deadbeef"
|
||||
#expect(!signed.isValidSignature())
|
||||
}
|
||||
|
||||
@Test func geohashNotesSingleFilter_encodesExpectedTagShape() throws {
|
||||
let since = Date(timeIntervalSince1970: 1_234_567)
|
||||
let filter = NostrFilter.geohashNotes("u4pruyd", since: since, limit: 42)
|
||||
let data = try JSONEncoder().encode(filter)
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
|
||||
#expect(object["kinds"] as? [Int] == [1])
|
||||
#expect(object["#g"] as? [String] == ["u4pruyd"])
|
||||
#expect(object["since"] as? Int == 1_234_567)
|
||||
#expect(object["limit"] as? Int == 42)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private static func base64URLDecode(_ s: String) -> Data? {
|
||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("PreviewKeychainManager Tests")
|
||||
struct PreviewKeychainManagerTests {
|
||||
|
||||
@Test("Preview keychain manager stores identity and service-scoped data in memory")
|
||||
func previewKeychainManagerRoundTripsData() {
|
||||
let manager = PreviewKeychainManager()
|
||||
let identityKey = Data([1, 2, 3, 4])
|
||||
let serviceKey = "preview-service"
|
||||
let scopedData = Data([9, 8, 7, 6])
|
||||
|
||||
#expect(!manager.verifyIdentityKeyExists())
|
||||
#expect(manager.saveIdentityKey(identityKey, forKey: "noiseStaticKey"))
|
||||
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == identityKey)
|
||||
#expect(manager.saveIdentityKey(identityKey, forKey: "identity_noiseStaticKey"))
|
||||
#expect(manager.verifyIdentityKeyExists())
|
||||
|
||||
if case .success(let stored) = manager.getIdentityKeyWithResult(forKey: "noiseStaticKey") {
|
||||
#expect(stored == identityKey)
|
||||
} else {
|
||||
Issue.record("Expected stored preview identity key")
|
||||
}
|
||||
|
||||
if case .success = manager.saveIdentityKeyWithResult(Data([5, 6, 7]), forKey: "ed25519SigningKey") {
|
||||
} else {
|
||||
Issue.record("Expected preview keychain save to succeed")
|
||||
}
|
||||
|
||||
manager.save(key: "blob", data: scopedData, service: serviceKey, accessible: nil)
|
||||
#expect(manager.load(key: "blob", service: serviceKey) == scopedData)
|
||||
manager.delete(key: "blob", service: serviceKey)
|
||||
#expect(manager.load(key: "blob", service: serviceKey) == nil)
|
||||
|
||||
var secretData = Data([4, 3, 2, 1])
|
||||
var secretString = "secret"
|
||||
manager.secureClear(&secretData)
|
||||
manager.secureClear(&secretString)
|
||||
#expect(secretData == Data([4, 3, 2, 1]))
|
||||
#expect(secretString == "secret")
|
||||
|
||||
#expect(manager.deleteIdentityKey(forKey: "noiseStaticKey"))
|
||||
#expect(manager.deleteIdentityKey(forKey: "identity_noiseStaticKey"))
|
||||
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
|
||||
#expect(manager.deleteAllKeychainData())
|
||||
|
||||
if case .itemNotFound = manager.getIdentityKeyWithResult(forKey: "ed25519SigningKey") {
|
||||
} else {
|
||||
Issue.record("Expected preview keychain to be empty after deleteAllKeychainData")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,11 @@ struct BinaryProtocolTests {
|
||||
let decodedSenderID = decodedPacket.senderID.trimmingNullBytes()
|
||||
#expect(decodedSenderID == originalSenderID)
|
||||
}
|
||||
|
||||
@Test func trimmingNullBytesReturnsOriginalDataWhenNoNullsPresent() {
|
||||
let raw = Data([0x41, 0x42, 0x43])
|
||||
#expect(raw.trimmingNullBytes() == raw)
|
||||
}
|
||||
|
||||
@Test func packetWithRecipient() throws {
|
||||
let recipientID = PeerID(str: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789")
|
||||
@@ -315,6 +320,34 @@ struct BinaryProtocolTests {
|
||||
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet")
|
||||
#expect(decodedPacket.payload == smallPayload)
|
||||
}
|
||||
|
||||
@Test("Reject payloads larger than the framed file cap")
|
||||
func oversizedPayloadIsRejected() throws {
|
||||
let targetSize = FileTransferLimits.maxFramedFileBytes + 1
|
||||
var oversized = Data()
|
||||
oversized.reserveCapacity(targetSize)
|
||||
let byteRun = Data((0...255).map { UInt8($0) })
|
||||
while oversized.count < targetSize {
|
||||
let remaining = targetSize - oversized.count
|
||||
if remaining >= byteRun.count {
|
||||
oversized.append(byteRun)
|
||||
} else {
|
||||
oversized.append(byteRun.prefix(remaining))
|
||||
}
|
||||
}
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data(hexString: "0011223344556677") ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: oversized,
|
||||
signature: nil,
|
||||
ttl: 1,
|
||||
version: 2
|
||||
)
|
||||
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode oversized packet")
|
||||
#expect(BinaryProtocol.decode(encoded) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Message Padding Tests
|
||||
|
||||
@@ -655,6 +688,33 @@ struct BinaryProtocolTests {
|
||||
let result = BinaryProtocol.decode(malformedData)
|
||||
#expect(result == nil, "Compressed packet with invalid original size should return nil, not crash")
|
||||
}
|
||||
|
||||
@Test("Test compressed packet with suspicious compression ratio")
|
||||
func compressedPacketWithSuspiciousCompressionRatio() {
|
||||
var malformedData = Data()
|
||||
|
||||
malformedData.append(1) // version
|
||||
malformedData.append(1) // type
|
||||
malformedData.append(10) // ttl
|
||||
|
||||
for _ in 0..<8 {
|
||||
malformedData.append(0)
|
||||
}
|
||||
|
||||
malformedData.append(0x04) // isCompressed
|
||||
malformedData.append(0x00)
|
||||
malformedData.append(0x03) // payloadLength = 3 (2 original-size bytes + 1 compressed byte)
|
||||
|
||||
for _ in 0..<8 {
|
||||
malformedData.append(0x01)
|
||||
}
|
||||
|
||||
malformedData.append(0xFF)
|
||||
malformedData.append(0xFF) // originalSize = 65535
|
||||
malformedData.append(0x99) // compressed payload length = 1 => ratio > 50_000
|
||||
|
||||
#expect(BinaryProtocol.decode(malformedData) == nil)
|
||||
}
|
||||
|
||||
@Test("Test packet designed to cause integer overflow")
|
||||
func maliciousPacketWithIntegerOverflow() throws {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreBluetooth
|
||||
@testable import bitchat
|
||||
|
||||
private final class DefaultDelegateProbe: BitchatDelegate {
|
||||
func didReceiveMessage(_ message: BitchatMessage) {}
|
||||
func didConnectToPeer(_ peerID: PeerID) {}
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
}
|
||||
|
||||
private final class DefaultTransportProbe: Transport {
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
|
||||
let subject = CurrentValueSubject<[TransportPeerSnapshot], Never>([])
|
||||
let myPeerID = PeerID(str: "0011223344556677")
|
||||
var myNickname = "Tester"
|
||||
private let keychain = MockKeychain()
|
||||
private(set) var sentMessages: [(content: String, mentions: [String])] = []
|
||||
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
subject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { subject.value }
|
||||
func setNickname(_ nickname: String) { myNickname = nickname }
|
||||
func startServices() {}
|
||||
func stopServices() {}
|
||||
func emergencyDisconnectAll() {}
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool { false }
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID: String] { [:] }
|
||||
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||
func triggerHandshake(with peerID: PeerID) {}
|
||||
func getNoiseService() -> NoiseEncryptionService { NoiseEncryptionService(keychain: keychain) }
|
||||
func sendMessage(_ content: String, mentions: [String]) { sentMessages.append((content, mentions)) }
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {}
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {}
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {}
|
||||
func sendBroadcastAnnounce() {}
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {}
|
||||
}
|
||||
|
||||
struct ProtocolContractTests {
|
||||
@Test
|
||||
func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() {
|
||||
#expect(CommandInfo.message.id == "dm")
|
||||
#expect(CommandInfo.message.alias == "/dm")
|
||||
#expect(CommandInfo.message.placeholder != nil)
|
||||
#expect(CommandInfo.clear.placeholder == nil)
|
||||
#expect(CommandInfo.favorite.description.isEmpty == false)
|
||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite) == false)
|
||||
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite))
|
||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite))
|
||||
}
|
||||
|
||||
@Test
|
||||
func protocolEnums_andDelegateDefaults_haveStableContracts() {
|
||||
let delegate = DefaultDelegateProbe()
|
||||
let peerID = PeerID(str: "8899aabbccddeeff")
|
||||
|
||||
#expect(MessageType.requestSync.description == "requestSync")
|
||||
#expect(NoisePayloadType.verifyResponse.description == "verifyResponse")
|
||||
#expect(DeliveryStatus.sending.displayText == "Sending...")
|
||||
#expect(DeliveryStatus.sent.displayText == "Sent")
|
||||
#expect(DeliveryStatus.delivered(to: "Alice", at: Date()).displayText == "Delivered to Alice")
|
||||
#expect(DeliveryStatus.read(by: "Bob", at: Date()).displayText == "Read by Bob")
|
||||
#expect(DeliveryStatus.failed(reason: "oops").displayText == "Failed: oops")
|
||||
#expect(DeliveryStatus.partiallyDelivered(reached: 1, total: 3).displayText == "Delivered to 1/3")
|
||||
#expect(delegate.isFavorite(fingerprint: "fp") == false)
|
||||
|
||||
delegate.didUpdateMessageDeliveryStatus("msg-1", status: .sent)
|
||||
delegate.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(), timestamp: Date())
|
||||
delegate.didReceivePublicMessage(from: peerID, nickname: "Alice", content: "hi", timestamp: Date(), messageID: "msg-1")
|
||||
}
|
||||
|
||||
@Test
|
||||
func transportDefaults_forwardOrNoOp() {
|
||||
let probe = DefaultTransportProbe()
|
||||
let peerID = PeerID(str: "0123456789abcdef")
|
||||
let filePacket = BitchatFilePacket(
|
||||
fileName: "voice.m4a",
|
||||
fileSize: 4,
|
||||
mimeType: "audio/mp4",
|
||||
content: Data([1, 2, 3, 4])
|
||||
)
|
||||
|
||||
probe.sendMessage("hello", mentions: ["@alice"], messageID: "msg-1", timestamp: Date())
|
||||
probe.sendVerifyChallenge(to: peerID, noiseKeyHex: "abcd", nonceA: Data([0x01]))
|
||||
probe.sendVerifyResponse(to: peerID, noiseKeyHex: "abcd", nonceA: Data([0x02]))
|
||||
probe.sendFileBroadcast(filePacket, transferId: "tx-1")
|
||||
probe.sendFilePrivate(filePacket, to: peerID, transferId: "tx-2")
|
||||
probe.cancelTransfer("tx-3")
|
||||
probe.declinePendingFile(id: "pending")
|
||||
|
||||
#expect(probe.sentMessages.count == 1)
|
||||
#expect(probe.sentMessages.first?.content == "hello")
|
||||
#expect(probe.acceptPendingFile(id: "pending") == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func previewMessage_exposesStableSampleShape() {
|
||||
let preview = BitchatMessage.preview
|
||||
|
||||
#expect(preview.sender == "John Doe")
|
||||
#expect(preview.content == "Hello")
|
||||
#expect(preview.deliveryStatus == .sent)
|
||||
#expect(preview.isPrivate == false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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)
|
||||
}
|
||||
|
||||
func test_sha256Hex_andExtendedLengthStringRoundTrip() throws {
|
||||
XCTAssertEqual(
|
||||
Data("abc".utf8).sha256Hex(),
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
)
|
||||
|
||||
var data = Data()
|
||||
data.appendString("hello", maxLength: 300)
|
||||
|
||||
var offset = 0
|
||||
XCTAssertEqual(data.readString(at: &offset, maxLength: 300), "hello")
|
||||
}
|
||||
|
||||
func test_readString_returnsNilForInvalidUTF8ExtendedPayload() {
|
||||
let invalidUTF8 = Data([0x00, 0x02, 0xFF, 0xFF])
|
||||
var offset = 0
|
||||
|
||||
XCTAssertNil(invalidUTF8.readString(at: &offset, maxLength: 300))
|
||||
XCTAssertEqual(offset, invalidUTF8.count)
|
||||
}
|
||||
}
|
||||
@@ -44,4 +44,33 @@ final class BitchatFilePacketTests: XCTestCase {
|
||||
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
|
||||
XCTAssertEqual(decoded.content, content)
|
||||
}
|
||||
|
||||
func testDecodeSupportsLegacyEightByteFileSizeTLV() throws {
|
||||
let content = Data([0x01, 0x02, 0x03, 0x04])
|
||||
var data = Data()
|
||||
|
||||
data.append(0x02)
|
||||
data.append(contentsOf: [0x00, 0x08])
|
||||
data.append(contentsOf: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00])
|
||||
data.append(0x04)
|
||||
data.append(contentsOf: [0x00, 0x00, 0x00, 0x04])
|
||||
data.append(content)
|
||||
|
||||
let decoded = try XCTUnwrap(BitchatFilePacket.decode(data))
|
||||
XCTAssertEqual(decoded.fileSize, 256)
|
||||
XCTAssertEqual(decoded.content, content)
|
||||
}
|
||||
|
||||
func testDecodeUsesContentCountWhenFileSizeTLVIsMissing() throws {
|
||||
let content = Data([0xAA, 0xBB, 0xCC])
|
||||
var data = Data()
|
||||
|
||||
data.append(0x04)
|
||||
data.append(contentsOf: [0x00, 0x00, 0x00, 0x03])
|
||||
data.append(content)
|
||||
|
||||
let decoded = try XCTUnwrap(BitchatFilePacket.decode(data))
|
||||
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
|
||||
XCTAssertEqual(decoded.content, content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import bitchat
|
||||
|
||||
struct LocationChannelTests {
|
||||
@Test
|
||||
func geohashChannelLevelDisplayNamesAndLegacyDecoding() throws {
|
||||
for level in GeohashChannelLevel.allCases {
|
||||
#expect(level.displayName.isEmpty == false)
|
||||
}
|
||||
|
||||
#expect(try decodeLevel(from: "\"building\"") == .building)
|
||||
#expect(try decodeLevel(from: "\"block\"") == .block)
|
||||
#expect(try decodeLevel(from: "\"neighborhood\"") == .neighborhood)
|
||||
#expect(try decodeLevel(from: "\"city\"") == .city)
|
||||
#expect(try decodeLevel(from: "\"province\"") == .province)
|
||||
#expect(try decodeLevel(from: "\"region\"") == .province)
|
||||
#expect(try decodeLevel(from: "\"country\"") == .region)
|
||||
#expect(try decodeLevel(from: "\"unknown\"") == .block)
|
||||
#expect(try decodeLevel(from: "8") == .building)
|
||||
#expect(try decodeLevel(from: "7") == .block)
|
||||
#expect(try decodeLevel(from: "6") == .neighborhood)
|
||||
#expect(try decodeLevel(from: "5") == .city)
|
||||
#expect(try decodeLevel(from: "4") == .province)
|
||||
#expect(try decodeLevel(from: "3") == .region)
|
||||
#expect(try decodeLevel(from: "0") == .region)
|
||||
#expect(try decodeLevel(from: "99") == .block)
|
||||
#expect(try decodeLevel(from: "true") == .block)
|
||||
}
|
||||
|
||||
@Test
|
||||
func geohashChannelAndChannelIDExposeStableAccessors() {
|
||||
let channel = GeohashChannel(level: .city, geohash: "u4pru")
|
||||
|
||||
#expect(channel.id == "city-u4pru")
|
||||
#expect(channel.displayName.contains("u4pru"))
|
||||
#expect(channel.displayName.contains(channel.level.displayName))
|
||||
|
||||
let mesh = ChannelID.mesh
|
||||
#expect(mesh.displayName == "Mesh")
|
||||
#expect(mesh.nostrGeohashTag == nil)
|
||||
#expect(mesh.isMesh)
|
||||
#expect(mesh.isLocation == false)
|
||||
|
||||
let location = ChannelID.location(channel)
|
||||
#expect(location.displayName == channel.displayName)
|
||||
#expect(location.nostrGeohashTag == "u4pru")
|
||||
#expect(location.isMesh == false)
|
||||
#expect(location.isLocation)
|
||||
}
|
||||
|
||||
private func decodeLevel(from json: String) throws -> GeohashChannelLevel {
|
||||
try JSONDecoder().decode(GeohashChannelLevel.self, from: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import bitchat
|
||||
|
||||
struct PacketsTests {
|
||||
@Test
|
||||
func announcementPacketRoundTripsNeighborsAndSkipsUnknownTLVs() throws {
|
||||
let neighbors = (0..<12).map { index in
|
||||
Data(repeating: UInt8(index), count: 8)
|
||||
}
|
||||
let packet = AnnouncementPacket(
|
||||
nickname: "alice",
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||
directNeighbors: neighbors
|
||||
)
|
||||
|
||||
var encoded = try #require(packet.encode())
|
||||
encoded.append(makeTLV(type: 0xFF, value: Data([0xAB])))
|
||||
|
||||
let decoded = try #require(AnnouncementPacket.decode(from: encoded))
|
||||
#expect(decoded.nickname == "alice")
|
||||
#expect(decoded.noisePublicKey == Data(repeating: 0x11, count: 32))
|
||||
#expect(decoded.signingPublicKey == Data(repeating: 0x22, count: 32))
|
||||
#expect(decoded.directNeighbors?.count == 10)
|
||||
#expect(decoded.directNeighbors?.first == neighbors.first)
|
||||
#expect(decoded.directNeighbors?.last == neighbors[9])
|
||||
}
|
||||
|
||||
@Test
|
||||
func announcementPacketEncodeRejectsOversizedFieldsAndInvalidNeighborGroups() {
|
||||
let oversizedNickname = String(repeating: "a", count: 256)
|
||||
let validKey = Data(repeating: 0x44, count: 32)
|
||||
|
||||
#expect(
|
||||
AnnouncementPacket(
|
||||
nickname: oversizedNickname,
|
||||
noisePublicKey: validKey,
|
||||
signingPublicKey: validKey,
|
||||
directNeighbors: nil
|
||||
).encode() == nil
|
||||
)
|
||||
|
||||
#expect(
|
||||
AnnouncementPacket(
|
||||
nickname: "alice",
|
||||
noisePublicKey: Data(repeating: 0x55, count: 256),
|
||||
signingPublicKey: validKey,
|
||||
directNeighbors: nil
|
||||
).encode() == nil
|
||||
)
|
||||
|
||||
#expect(
|
||||
AnnouncementPacket(
|
||||
nickname: "alice",
|
||||
noisePublicKey: validKey,
|
||||
signingPublicKey: Data(repeating: 0x66, count: 256),
|
||||
directNeighbors: nil
|
||||
).encode() == nil
|
||||
)
|
||||
|
||||
let invalidNeighborPacket = AnnouncementPacket(
|
||||
nickname: "alice",
|
||||
noisePublicKey: validKey,
|
||||
signingPublicKey: validKey,
|
||||
directNeighbors: [Data([0x01, 0x02, 0x03])]
|
||||
)
|
||||
let encodedWithoutNeighbors = AnnouncementPacket(
|
||||
nickname: "alice",
|
||||
noisePublicKey: validKey,
|
||||
signingPublicKey: validKey,
|
||||
directNeighbors: nil
|
||||
).encode()
|
||||
#expect(invalidNeighborPacket.encode() == encodedWithoutNeighbors)
|
||||
}
|
||||
|
||||
@Test
|
||||
func announcementPacketDecodeRejectsMissingFieldsAndTruncation() throws {
|
||||
let missingSigningKey = makeTLV(type: 0x01, value: Data("alice".utf8))
|
||||
+ makeTLV(type: 0x02, value: Data(repeating: 0x11, count: 32))
|
||||
#expect(AnnouncementPacket.decode(from: missingSigningKey) == nil)
|
||||
|
||||
let validPacket = try #require(
|
||||
AnnouncementPacket(
|
||||
nickname: "alice",
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||
directNeighbors: nil
|
||||
).encode()
|
||||
)
|
||||
#expect(AnnouncementPacket.decode(from: validPacket.dropLast()) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func announcementPacketDecodeIgnoresInvalidNeighborLengths() throws {
|
||||
var encoded = try #require(
|
||||
AnnouncementPacket(
|
||||
nickname: "alice",
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||
directNeighbors: nil
|
||||
).encode()
|
||||
)
|
||||
encoded.append(makeTLV(type: 0x04, value: Data(repeating: 0x99, count: 7)))
|
||||
|
||||
let decoded = try #require(AnnouncementPacket.decode(from: encoded))
|
||||
#expect(decoded.directNeighbors == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
|
||||
let unknownTLV = Data([0x7F, 0x01, 0x41])
|
||||
#expect(PrivateMessagePacket.decode(from: unknownTLV) == nil)
|
||||
|
||||
let truncated = Data([0x00, 0x05, 0x61])
|
||||
#expect(PrivateMessagePacket.decode(from: truncated) == nil)
|
||||
}
|
||||
|
||||
private func makeTLV(type: UInt8, value: Data) -> Data {
|
||||
var data = Data([type, UInt8(value.count)])
|
||||
data.append(value)
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("PublicTimelineStore Tests")
|
||||
struct PublicTimelineStoreTests {
|
||||
|
||||
@Test("Mesh timeline deduplicates and trims to cap")
|
||||
func meshTimelineDeduplicatesAndTrims() {
|
||||
var store = PublicTimelineStore(meshCap: 2, geohashCap: 2)
|
||||
let first = TestHelpers.createTestMessage(content: "one")
|
||||
let second = TestHelpers.createTestMessage(content: "two")
|
||||
let third = TestHelpers.createTestMessage(content: "three")
|
||||
|
||||
store.append(first, to: .mesh)
|
||||
store.append(second, to: .mesh)
|
||||
store.append(first, to: .mesh)
|
||||
store.append(third, to: .mesh)
|
||||
|
||||
let messages = store.messages(for: .mesh)
|
||||
#expect(messages.map(\.content) == ["two", "three"])
|
||||
}
|
||||
|
||||
@Test("Geohash appendIfAbsent remove and clear work together")
|
||||
func geohashStoreSupportsAppendRemoveAndClear() {
|
||||
var store = PublicTimelineStore(meshCap: 2, geohashCap: 3)
|
||||
let geohash = "u4pruydq"
|
||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
||||
let first = TestHelpers.createTestMessage(content: "geo one")
|
||||
let second = TestHelpers.createTestMessage(content: "geo two")
|
||||
|
||||
let didAppendFirst = store.appendIfAbsent(first, toGeohash: geohash)
|
||||
let didAppendDuplicate = store.appendIfAbsent(first, toGeohash: geohash)
|
||||
|
||||
#expect(didAppendFirst)
|
||||
#expect(!didAppendDuplicate)
|
||||
store.append(second, toGeohash: geohash)
|
||||
let removed = store.removeMessage(withID: first.id)
|
||||
|
||||
#expect(removed?.id == first.id)
|
||||
#expect(store.messages(for: channel).map(\.content) == ["geo two"])
|
||||
|
||||
store.clear(channel: channel)
|
||||
#expect(store.messages(for: channel).isEmpty)
|
||||
}
|
||||
|
||||
@Test("Mutate geohash updates stored messages in place")
|
||||
func mutateGeohashAppliesTransformation() {
|
||||
var store = PublicTimelineStore(meshCap: 2, geohashCap: 3)
|
||||
let geohash = "u4pruydq"
|
||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
||||
let first = TestHelpers.createTestMessage(content: "geo one")
|
||||
|
||||
store.append(first, toGeohash: geohash)
|
||||
store.mutateGeohash(geohash) { timeline in
|
||||
timeline.append(TestHelpers.createTestMessage(content: "geo two"))
|
||||
}
|
||||
|
||||
#expect(store.messages(for: channel).map(\.content) == ["geo one", "geo two"])
|
||||
}
|
||||
|
||||
@Test("Queued geohash system messages drain once")
|
||||
func pendingGeohashSystemMessagesDrainOnce() {
|
||||
var store = PublicTimelineStore(meshCap: 1, geohashCap: 1)
|
||||
|
||||
store.queueGeohashSystemMessage("first")
|
||||
store.queueGeohashSystemMessage("second")
|
||||
|
||||
#expect(store.drainPendingGeohashSystemMessages() == ["first", "second"])
|
||||
#expect(store.drainPendingGeohashSystemMessages().isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("ReadReceipt Tests")
|
||||
struct ReadReceiptTests {
|
||||
|
||||
@Test("JSON encode and decode round-trip stable fields")
|
||||
func jsonRoundTrip() throws {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: UUID().uuidString,
|
||||
readerID: PeerID(str: "0123456789abcdef"),
|
||||
readerNickname: "Alice"
|
||||
)
|
||||
|
||||
let encoded = try #require(receipt.encode(), "Receipt should encode to JSON")
|
||||
let decoded = try #require(ReadReceipt.decode(from: encoded), "Receipt should decode from JSON")
|
||||
|
||||
#expect(decoded.originalMessageID == receipt.originalMessageID)
|
||||
#expect(decoded.receiptID == receipt.receiptID)
|
||||
#expect(decoded.readerID == receipt.readerID)
|
||||
#expect(decoded.readerNickname == receipt.readerNickname)
|
||||
#expect(abs(decoded.timestamp.timeIntervalSince(receipt.timestamp)) < 0.001)
|
||||
}
|
||||
|
||||
@Test("Binary encode and decode round-trip stable fields")
|
||||
func binaryRoundTrip() throws {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: UUID().uuidString,
|
||||
readerID: PeerID(str: "fedcba9876543210"),
|
||||
readerNickname: "Bob"
|
||||
)
|
||||
|
||||
let decoded = try #require(
|
||||
ReadReceipt.fromBinaryData(receipt.toBinaryData()),
|
||||
"Receipt should decode from binary data"
|
||||
)
|
||||
|
||||
#expect(decoded.originalMessageID == receipt.originalMessageID.uppercased())
|
||||
#expect(decoded.receiptID == receipt.receiptID.uppercased())
|
||||
#expect(decoded.readerID == receipt.readerID)
|
||||
#expect(decoded.readerNickname == receipt.readerNickname)
|
||||
}
|
||||
|
||||
@Test("Binary decode rejects truncated data")
|
||||
func binaryDecodeRejectsTruncatedData() {
|
||||
#expect(ReadReceipt.fromBinaryData(Data()) == nil)
|
||||
#expect(ReadReceipt.fromBinaryData(Data(repeating: 0, count: 48)) == nil)
|
||||
}
|
||||
|
||||
@Test("Binary decode rejects stale timestamps")
|
||||
func binaryDecodeRejectsStaleTimestamp() {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: UUID().uuidString,
|
||||
readerID: PeerID(str: "0011223344556677"),
|
||||
readerNickname: "Carol"
|
||||
)
|
||||
var data = receipt.toBinaryData()
|
||||
|
||||
data.replaceSubrange(40..<48, with: Data(repeating: 0, count: 8))
|
||||
|
||||
#expect(ReadReceipt.fromBinaryData(data) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("AutocompleteService Tests")
|
||||
struct AutocompleteServiceTests {
|
||||
|
||||
@Test("Mention suggestions are sorted, capped, and include replacement range")
|
||||
func mentionSuggestionsAreSortedAndCapped() {
|
||||
let service = AutocompleteService()
|
||||
let text = "hi @al"
|
||||
|
||||
let result = service.getSuggestions(
|
||||
for: text,
|
||||
peers: ["zoe", "alice", "albert", "bob", "alex", "ally", "alpha"],
|
||||
cursorPosition: text.count
|
||||
)
|
||||
|
||||
#expect(result.suggestions == ["@albert", "@alex", "@alice", "@ally", "@alpha"])
|
||||
#expect(result.range == NSRange(location: 3, length: 3))
|
||||
}
|
||||
|
||||
@Test("Suggestions are empty when cursor is not at a trailing mention")
|
||||
func suggestionsRequireTrailingMentionContext() {
|
||||
let service = AutocompleteService()
|
||||
let text = "hi @al there"
|
||||
|
||||
let result = service.getSuggestions(
|
||||
for: text,
|
||||
peers: ["alice", "albert"],
|
||||
cursorPosition: text.count
|
||||
)
|
||||
|
||||
#expect(result.suggestions.isEmpty)
|
||||
#expect(result.range == nil)
|
||||
}
|
||||
|
||||
@Test("Applying suggestions replaces the range and adds command spacing only when needed")
|
||||
func applySuggestionReplacesRangeAndHandlesCommandSpacing() {
|
||||
let service = AutocompleteService()
|
||||
|
||||
let mentionResult = service.applySuggestion("@alice", to: "hi @al", range: NSRange(location: 3, length: 3))
|
||||
let msgCommand = service.applySuggestion("/msg", to: "/m", range: NSRange(location: 0, length: 2))
|
||||
let clearCommand = service.applySuggestion("/clear", to: "/c", range: NSRange(location: 0, length: 2))
|
||||
|
||||
#expect(mentionResult == "hi @alice")
|
||||
#expect(msgCommand == "/msg ")
|
||||
#expect(clearCommand == "/clear")
|
||||
}
|
||||
}
|
||||
@@ -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,231 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("NoiseEncryptionService Tests")
|
||||
struct NoiseEncryptionServiceTests {
|
||||
|
||||
@Test("Encryption status accessors cover all cases")
|
||||
func encryptionStatusAccessorsCoverAllCases() {
|
||||
#expect(EncryptionStatus.none.icon == "lock.slash")
|
||||
#expect(EncryptionStatus.noHandshake.icon == nil)
|
||||
#expect(EncryptionStatus.noiseHandshaking.icon == "lock.rotation")
|
||||
#expect(EncryptionStatus.noiseSecured.icon == "lock.fill")
|
||||
#expect(EncryptionStatus.noiseVerified.icon == "checkmark.seal.fill")
|
||||
|
||||
#expect(!EncryptionStatus.none.description.isEmpty)
|
||||
#expect(!EncryptionStatus.noHandshake.description.isEmpty)
|
||||
#expect(!EncryptionStatus.noiseHandshaking.description.isEmpty)
|
||||
#expect(!EncryptionStatus.noiseSecured.description.isEmpty)
|
||||
#expect(!EncryptionStatus.noiseVerified.description.isEmpty)
|
||||
|
||||
#expect(!EncryptionStatus.none.accessibilityDescription.isEmpty)
|
||||
#expect(!EncryptionStatus.noHandshake.accessibilityDescription.isEmpty)
|
||||
#expect(!EncryptionStatus.noiseHandshaking.accessibilityDescription.isEmpty)
|
||||
#expect(!EncryptionStatus.noiseSecured.accessibilityDescription.isEmpty)
|
||||
#expect(!EncryptionStatus.noiseVerified.accessibilityDescription.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Announce and packet signatures round-trip and detect tampering")
|
||||
func announceAndPacketSignaturesRoundTrip() throws {
|
||||
let service = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let signingPublicKey = service.getSigningPublicKeyData()
|
||||
let noisePublicKey = service.getStaticPublicKeyData()
|
||||
|
||||
let signature = try #require(
|
||||
service.buildAnnounceSignature(
|
||||
peerID: Data([0xAA, 0xBB]),
|
||||
noiseKey: noisePublicKey,
|
||||
ed25519Key: signingPublicKey,
|
||||
nickname: "Alice",
|
||||
timestampMs: 12345
|
||||
),
|
||||
"Expected announce signature"
|
||||
)
|
||||
|
||||
#expect(
|
||||
service.verifyAnnounceSignature(
|
||||
signature: signature,
|
||||
peerID: Data([0xAA, 0xBB]),
|
||||
noiseKey: noisePublicKey,
|
||||
ed25519Key: signingPublicKey,
|
||||
nickname: "Alice",
|
||||
timestampMs: 12345,
|
||||
publicKey: signingPublicKey
|
||||
)
|
||||
)
|
||||
#expect(
|
||||
!service.verifyAnnounceSignature(
|
||||
signature: signature,
|
||||
peerID: Data([0xAA, 0xBB]),
|
||||
noiseKey: noisePublicKey,
|
||||
ed25519Key: signingPublicKey,
|
||||
nickname: "Mallory",
|
||||
timestampMs: 12345,
|
||||
publicKey: signingPublicKey
|
||||
)
|
||||
)
|
||||
#expect(!service.verifySignature(signature, for: Data("data".utf8), publicKey: Data([1, 2, 3])))
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data([0, 1, 2, 3, 4, 5, 6, 7]),
|
||||
recipientID: nil,
|
||||
timestamp: 42,
|
||||
payload: Data("payload".utf8),
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
let signedPacket = try #require(service.signPacket(packet), "Expected signed packet")
|
||||
|
||||
#expect(service.verifyPacketSignature(signedPacket, publicKey: signingPublicKey))
|
||||
#expect(!service.verifyPacketSignature(packet, publicKey: signingPublicKey))
|
||||
|
||||
var tampered = signedPacket
|
||||
tampered.signature = Data(repeating: 0xFF, count: 64)
|
||||
#expect(!service.verifyPacketSignature(tampered, publicKey: signingPublicKey))
|
||||
}
|
||||
|
||||
@Test("Service-level handshake, encryption, and fingerprint lifecycle work")
|
||||
func handshakeEncryptionAndFingerprintLifecycle() async throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(str: "0011223344556677")
|
||||
let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
||||
let recorder = AuthenticationRecorder()
|
||||
|
||||
#expect(alice.onPeerAuthenticated == nil)
|
||||
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
|
||||
|
||||
try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID)
|
||||
|
||||
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 0.5)
|
||||
#expect(authenticated)
|
||||
#expect(alice.hasEstablishedSession(with: alicePeerID))
|
||||
#expect(bob.hasEstablishedSession(with: bobPeerID))
|
||||
#expect(alice.hasSession(with: alicePeerID))
|
||||
#expect(bob.hasSession(with: bobPeerID))
|
||||
#expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
||||
#expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32)
|
||||
#expect(alice.getPeerFingerprint(alicePeerID) != nil)
|
||||
#expect(bob.getPeerFingerprint(bobPeerID) != nil)
|
||||
|
||||
let plaintext = Data("secret payload".utf8)
|
||||
let ciphertext = try alice.encrypt(plaintext, for: alicePeerID)
|
||||
let decrypted = try bob.decrypt(ciphertext, from: bobPeerID)
|
||||
#expect(decrypted == plaintext)
|
||||
|
||||
alice.clearSession(for: alicePeerID)
|
||||
#expect(!alice.hasSession(with: alicePeerID))
|
||||
#expect(alice.getPeerFingerprint(alicePeerID) == nil)
|
||||
|
||||
bob.clearEphemeralStateForPanic()
|
||||
#expect(!bob.hasSession(with: bobPeerID))
|
||||
#expect(bob.getPeerFingerprint(bobPeerID) == nil)
|
||||
}
|
||||
|
||||
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
||||
func handshakeRequiredAndSessionNotEstablishedErrors() throws {
|
||||
let service = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let peerID = PeerID(str: "1021324354657687")
|
||||
var requestedPeerID: PeerID?
|
||||
|
||||
service.onHandshakeRequired = { requestedPeerID = $0 }
|
||||
|
||||
do {
|
||||
_ = try service.encrypt(Data("hello".utf8), for: peerID)
|
||||
Issue.record("Expected handshakeRequired error")
|
||||
} catch NoiseEncryptionError.handshakeRequired {
|
||||
#expect(requestedPeerID == peerID)
|
||||
} catch {
|
||||
Issue.record("Unexpected error: \(error)")
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try service.decrypt(Data("hello".utf8), from: peerID)
|
||||
Issue.record("Expected sessionNotEstablished error")
|
||||
} catch NoiseEncryptionError.sessionNotEstablished {
|
||||
// Expected
|
||||
} catch {
|
||||
Issue.record("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Clearing persistent identity removes saved keys")
|
||||
func clearPersistentIdentityRemovesSavedKeys() {
|
||||
let keychain = MockKeychain()
|
||||
let service = NoiseEncryptionService(keychain: keychain)
|
||||
|
||||
#expect(service.getStaticPublicKeyData().count == 32)
|
||||
#expect(service.getSigningPublicKeyData().count == 32)
|
||||
|
||||
service.clearPersistentIdentity()
|
||||
|
||||
if case .itemNotFound = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey") {
|
||||
} else {
|
||||
Issue.record("Expected noiseStaticKey to be removed")
|
||||
}
|
||||
|
||||
if case .itemNotFound = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey") {
|
||||
} else {
|
||||
Issue.record("Expected ed25519SigningKey to be removed")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("NoiseMessage JSON and binary encoding round-trip")
|
||||
func noiseMessageRoundTrips() throws {
|
||||
let message = NoiseMessage(
|
||||
type: .encryptedMessage,
|
||||
sessionID: UUID().uuidString,
|
||||
payload: Data([1, 2, 3, 4])
|
||||
)
|
||||
|
||||
let encoded = try #require(message.encode(), "Expected JSON encoding")
|
||||
let decoded = try #require(NoiseMessage.decode(from: encoded), "Expected JSON decode")
|
||||
#expect(decoded.type == message.type)
|
||||
#expect(decoded.sessionID == message.sessionID)
|
||||
#expect(decoded.payload == message.payload)
|
||||
|
||||
#expect(NoiseMessage.decodeWithError(from: Data("bad".utf8)) == nil)
|
||||
|
||||
let binary = message.toBinaryData()
|
||||
let roundTripped = try #require(NoiseMessage.fromBinaryData(binary), "Expected binary decode")
|
||||
#expect(roundTripped.type == message.type)
|
||||
#expect(roundTripped.sessionID == message.sessionID)
|
||||
#expect(roundTripped.payload == message.payload)
|
||||
#expect(NoiseMessage.fromBinaryData(Data()) == nil)
|
||||
}
|
||||
|
||||
private func establishSessions(
|
||||
alice: NoiseEncryptionService,
|
||||
bob: NoiseEncryptionService,
|
||||
alicePeerID: PeerID,
|
||||
bobPeerID: PeerID
|
||||
) throws {
|
||||
let message1 = try alice.initiateHandshake(with: alicePeerID)
|
||||
let response = try bob.processHandshakeMessage(from: bobPeerID, message: message1)
|
||||
let message2 = try #require(response, "Expected handshake response")
|
||||
let final = try alice.processHandshakeMessage(from: alicePeerID, message: message2)
|
||||
let message3 = try #require(final, "Expected handshake final")
|
||||
let finalMessage = try bob.processHandshakeMessage(from: bobPeerID, message: message3)
|
||||
#expect(finalMessage == nil)
|
||||
}
|
||||
}
|
||||
|
||||
private final class AuthenticationRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var entries: [(PeerID, String)] = []
|
||||
|
||||
var count: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return entries.count
|
||||
}
|
||||
|
||||
func record(peerID: PeerID, fingerprint: String) {
|
||||
lock.lock()
|
||||
entries.append((peerID, fingerprint))
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,959 @@
|
||||
import Combine
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
final class NostrRelayManagerTests: XCTestCase {
|
||||
func test_connect_directMode_connectsExistingDefaultRelaysWhenActivationBecomesAllowed() async {
|
||||
let context = makeContext(permission: .authorized, activationAllowed: false)
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
context.activationAllowed.value = true
|
||||
|
||||
context.manager.connect()
|
||||
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == 5 &&
|
||||
context.manager.relays.allSatisfy(\.isConnected)
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
}
|
||||
|
||||
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_whenTorReadinessFailsDoesNotCreateSessions() async {
|
||||
let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
|
||||
context.manager.connect()
|
||||
context.torWaiter.resolve(false)
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
XCTAssertFalse(context.manager.isConnected)
|
||||
}
|
||||
|
||||
func test_sendEvent_waitsForTorReadinessBeforeSending() async throws {
|
||||
let relayURL = "wss://tor-ready.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
let event = try makeSignedEvent(content: "deferred")
|
||||
|
||||
context.manager.sendEvent(event, to: [relayURL])
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
|
||||
context.torWaiter.resolve(true)
|
||||
|
||||
let sentAfterTorReady = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent == 1
|
||||
}
|
||||
XCTAssertTrue(sentAfterTorReady)
|
||||
}
|
||||
|
||||
func test_sendEvent_queuesWhileBackgroundedAndFlushesWhenForegrounded() async throws {
|
||||
let relayURL = "wss://queue-flush.example"
|
||||
let context = makeContext(
|
||||
permission: .denied,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: true,
|
||||
torIsForeground: false
|
||||
)
|
||||
let event = try makeSignedEvent(content: "queued")
|
||||
|
||||
context.manager.sendEvent(event, to: [relayURL])
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
context.torForeground.value = true
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
|
||||
let flushed = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent == 1
|
||||
}
|
||||
XCTAssertTrue(flushed)
|
||||
}
|
||||
|
||||
func test_sendEvent_sendFailureDoesNotIncrementMessageCount() async throws {
|
||||
let relayURL = "wss://send-failure.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
context.sessionFactory.sendErrorByURL[relayURL] = NSError(domain: "send", code: 1)
|
||||
let event = try makeSignedEvent(content: "send failure")
|
||||
|
||||
context.manager.sendEvent(event, to: [relayURL])
|
||||
|
||||
let attempted = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(attempted)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
XCTAssertEqual(context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent, 0)
|
||||
}
|
||||
|
||||
func test_sendEvent_queueIsPrunedWhenDefaultRelaysAreRevoked() async throws {
|
||||
let context = makeContext(
|
||||
permission: .authorized,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: true,
|
||||
torIsForeground: false
|
||||
)
|
||||
let event = try makeSignedEvent(content: "queued default")
|
||||
|
||||
context.manager.sendEvent(event)
|
||||
|
||||
let queued = await waitUntil {
|
||||
context.manager.debugPendingMessageQueueCount == 1
|
||||
}
|
||||
XCTAssertTrue(queued)
|
||||
|
||||
context.permissionSubject.send(.denied)
|
||||
|
||||
let cleared = await waitUntil {
|
||||
context.manager.debugPendingMessageQueueCount == 0 &&
|
||||
context.manager.relays.isEmpty
|
||||
}
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
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_subscribe_waitsForTorReadinessAndPreservesEOSECallback() async throws {
|
||||
let relayURL = "wss://tor-subscribe.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
var eoseCount = 0
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "tor-eose",
|
||||
relayUrls: [relayURL],
|
||||
handler: { _ in },
|
||||
onEOSE: { eoseCount += 1 }
|
||||
)
|
||||
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
|
||||
context.torWaiter.resolve(true)
|
||||
let subscribed = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscribed)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "tor-eose")
|
||||
let eoseCompleted = await waitUntil { eoseCount == 1 }
|
||||
XCTAssertTrue(eoseCompleted)
|
||||
}
|
||||
|
||||
func test_subscribe_withoutAllowedRelays_callsEOSEImmediatelyAndDoesNotFlushLater() async {
|
||||
let context = makeContext(permission: .denied)
|
||||
var eoseCount = 0
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "blocked-defaults",
|
||||
handler: { _ in },
|
||||
onEOSE: { eoseCount += 1 }
|
||||
)
|
||||
|
||||
XCTAssertEqual(eoseCount, 1)
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
|
||||
context.permissionSubject.send(.authorized)
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.allConnections.count == 5 &&
|
||||
context.manager.relays.allSatisfy(\.isConnected)
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.sentStrings.isEmpty })
|
||||
}
|
||||
|
||||
func test_permissionRevocation_clearsQueuedDefaultSubscriptions() async {
|
||||
let context = makeContext(
|
||||
permission: .authorized,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: true,
|
||||
torIsForeground: false
|
||||
)
|
||||
let defaultRelay = "wss://relay.damus.io"
|
||||
|
||||
context.manager.subscribe(filter: makeFilter(), id: "queued-default", handler: { _ in })
|
||||
|
||||
let queued = await waitUntil {
|
||||
context.manager.debugPendingSubscriptionCount(for: defaultRelay) == 1
|
||||
}
|
||||
XCTAssertTrue(queued)
|
||||
|
||||
context.permissionSubject.send(.denied)
|
||||
|
||||
let cleared = await waitUntil {
|
||||
context.manager.debugPendingSubscriptionCount(for: defaultRelay) == 0 &&
|
||||
context.manager.relays.isEmpty
|
||||
}
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
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_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws {
|
||||
let relayURL = "wss://missing-handler.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let event = try makeSignedEvent(content: "unhandled")
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL) != nil &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "missing", event: event)
|
||||
|
||||
let counted = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
|
||||
}
|
||||
XCTAssertTrue(counted)
|
||||
}
|
||||
|
||||
func test_noticeAndMalformedMessages_keepReceiveLoopAliveForLaterEvents() async throws {
|
||||
let relayURL = "wss://parser.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
var receivedIDs: [String] = []
|
||||
let firstEvent = try makeSignedEvent(content: "after notice")
|
||||
let secondEvent = try makeSignedEvent(content: "after malformed")
|
||||
|
||||
context.manager.subscribe(filter: makeFilter(), id: "parser", relayUrls: [relayURL]) { event in
|
||||
receivedIDs.append(event.id)
|
||||
}
|
||||
let subscribed = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscribed)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitNotice(message: "ignored")
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "parser", event: firstEvent)
|
||||
|
||||
let firstDelivered = await waitUntil {
|
||||
receivedIDs == [firstEvent.id]
|
||||
}
|
||||
XCTAssertTrue(firstDelivered)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitRawString("not-json")
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "parser", event: secondEvent)
|
||||
|
||||
let secondDelivered = await waitUntil {
|
||||
receivedIDs == [firstEvent.id, secondEvent.id]
|
||||
}
|
||||
XCTAssertTrue(secondDelivered)
|
||||
}
|
||||
|
||||
func test_okMessages_clearPendingGiftWrapIDs() async throws {
|
||||
let relayURL = "wss://ok.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let successID = "gift-wrap-success"
|
||||
let failureID = "gift-wrap-failure"
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL) != nil &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
NostrRelayManager.registerPendingGiftWrap(id: successID)
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok")
|
||||
let successCleared = await waitUntil {
|
||||
!NostrRelayManager.pendingGiftWrapIDs.contains(successID)
|
||||
}
|
||||
XCTAssertTrue(successCleared)
|
||||
|
||||
NostrRelayManager.registerPendingGiftWrap(id: failureID)
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected")
|
||||
let failureCleared = await waitUntil {
|
||||
!NostrRelayManager.pendingGiftWrapIDs.contains(failureID)
|
||||
}
|
||||
XCTAssertTrue(failureCleared)
|
||||
}
|
||||
|
||||
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_eoseTimeout_invokesCallbackOnceAndIgnoresLateEOSE() async throws {
|
||||
let relayURL = "wss://timeout.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
var eoseCount = 0
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "timeout",
|
||||
relayUrls: [relayURL],
|
||||
handler: { _ in },
|
||||
onEOSE: { eoseCount += 1 }
|
||||
)
|
||||
|
||||
let subscribed = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscribed)
|
||||
|
||||
let timedOut = await waitUntil(timeout: 3.0) { eoseCount == 1 }
|
||||
XCTAssertTrue(timedOut)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "timeout")
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
XCTAssertEqual(eoseCount, 1)
|
||||
}
|
||||
|
||||
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_receiveFailure_whenActivationBecomesDisallowedDoesNotScheduleReconnect() async {
|
||||
let relayURL = "wss://no-retry.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL) != nil &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
context.activationAllowed.value = false
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.fail(
|
||||
error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
|
||||
)
|
||||
|
||||
let disconnected = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == false
|
||||
}
|
||||
XCTAssertTrue(disconnected)
|
||||
XCTAssertTrue(context.scheduler.scheduled.isEmpty)
|
||||
XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func test_retryConnection_cancelsActiveConnectionBeforeReconnecting() async {
|
||||
let relayURL = "wss://retry-now.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL) != nil &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
guard let firstConnection = context.sessionFactory.latestConnection(for: relayURL) else {
|
||||
XCTFail("Expected initial connection")
|
||||
return
|
||||
}
|
||||
let initialRequestCount = context.sessionFactory.requestedURLs.count
|
||||
|
||||
context.manager.retryConnection(to: relayURL)
|
||||
|
||||
let reconnected = await waitUntil {
|
||||
guard let latest = context.sessionFactory.latestConnection(for: relayURL) else { return false }
|
||||
return context.sessionFactory.requestedURLs.count == initialRequestCount + 1 &&
|
||||
latest !== firstConnection
|
||||
}
|
||||
XCTAssertTrue(reconnected)
|
||||
XCTAssertEqual(firstConnection.cancelCallCount, 1)
|
||||
}
|
||||
|
||||
func test_retryConnection_whenTorReadinessFailsDoesNotReconnect() async {
|
||||
let relayURL = "wss://retry-tor.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: true)
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL) != nil &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
guard let firstConnection = context.sessionFactory.latestConnection(for: relayURL) else {
|
||||
XCTFail("Expected initial connection")
|
||||
return
|
||||
}
|
||||
|
||||
let initialRequestCount = context.sessionFactory.requestedURLs.count
|
||||
context.torWaiter.isReady = false
|
||||
context.manager.retryConnection(to: relayURL)
|
||||
|
||||
XCTAssertEqual(firstConnection.cancelCallCount, 1)
|
||||
XCTAssertEqual(context.sessionFactory.requestedURLs.count, initialRequestCount)
|
||||
|
||||
context.torWaiter.resolve(false)
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
XCTAssertEqual(context.sessionFactory.requestedURLs.count, initialRequestCount)
|
||||
}
|
||||
|
||||
func test_resetAllConnections_clearsRelayStateAndReconnects() async {
|
||||
let relayURL = "wss://reset.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL) != nil &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.fail(
|
||||
error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
|
||||
)
|
||||
let failed = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.lastError != nil
|
||||
}
|
||||
XCTAssertTrue(failed)
|
||||
|
||||
let requestCountBeforeReset = context.sessionFactory.requestedURLs.count
|
||||
context.manager.resetAllConnections()
|
||||
|
||||
let reset = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == requestCountBeforeReset + 1 &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 0 &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.nextReconnectTime == nil &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.lastError == nil
|
||||
}
|
||||
XCTAssertTrue(reset)
|
||||
}
|
||||
|
||||
func test_debugFlushMessageQueue_flushesAllConnectedRelays() async throws {
|
||||
let relayOne = "wss://flush-one.example"
|
||||
let relayTwo = "wss://flush-two.example"
|
||||
let context = makeContext(
|
||||
permission: .denied,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: true,
|
||||
torIsForeground: false
|
||||
)
|
||||
let event = try makeSignedEvent(content: "flush-all")
|
||||
|
||||
context.manager.sendEvent(event, to: [relayOne, relayTwo])
|
||||
let queued = await waitUntil {
|
||||
context.manager.debugPendingMessageQueueCount == 1
|
||||
}
|
||||
XCTAssertTrue(queued)
|
||||
|
||||
context.torForeground.value = true
|
||||
context.manager.ensureConnections(to: [relayOne, relayTwo])
|
||||
context.manager.debugFlushMessageQueue()
|
||||
|
||||
let flushed = await waitUntil {
|
||||
context.manager.debugPendingMessageQueueCount == 0 &&
|
||||
context.sessionFactory.latestConnection(for: relayOne)?.sentStrings.count == 1 &&
|
||||
context.sessionFactory.latestConnection(for: relayTwo)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(flushed)
|
||||
}
|
||||
|
||||
func test_dnsPingFailure_marksRelayPermanentCallsEOSEImmediatelyAndManualRetryReconnects() async {
|
||||
let relayURL = "wss://dns-failure.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
context.sessionFactory.pingErrorByURL[relayURL] = NSError(
|
||||
domain: NSURLErrorDomain,
|
||||
code: NSURLErrorCannotFindHost,
|
||||
userInfo: [NSLocalizedDescriptionKey: "DNS failure"]
|
||||
)
|
||||
|
||||
context.manager.subscribe(filter: makeFilter(), id: "dns-sub", relayUrls: [relayURL], handler: { _ in })
|
||||
let permanentlyFailed = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == TransportConfig.nostrRelayMaxReconnectAttempts &&
|
||||
context.scheduler.scheduled.isEmpty
|
||||
}
|
||||
XCTAssertTrue(permanentlyFailed)
|
||||
|
||||
var immediateEOSE = 0
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "dns-eose",
|
||||
relayUrls: [relayURL],
|
||||
handler: { _ in },
|
||||
onEOSE: { immediateEOSE += 1 }
|
||||
)
|
||||
XCTAssertEqual(immediateEOSE, 1)
|
||||
|
||||
context.sessionFactory.pingErrorByURL[relayURL] = nil
|
||||
let requestCountBeforeRetry = context.sessionFactory.requestedURLs.count
|
||||
context.manager.retryConnection(to: relayURL)
|
||||
|
||||
let reconnected = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == requestCountBeforeRetry + 1 &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true &&
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.reconnectAttempts == 0
|
||||
}
|
||||
XCTAssertTrue(reconnected)
|
||||
}
|
||||
|
||||
private func makeContext(
|
||||
permission: LocationChannelManager.PermissionState,
|
||||
favorites: Set<Data> = [],
|
||||
activationAllowed: Bool = true,
|
||||
userTorEnabled: Bool = false,
|
||||
torEnforced: Bool = false,
|
||||
torIsReady: Bool = true,
|
||||
torIsForeground: 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 torForeground = MutableBool(value: torIsForeground)
|
||||
let activationFlag = MutableBool(value: activationAllowed)
|
||||
let manager = NostrRelayManager(
|
||||
dependencies: NostrRelayManagerDependencies(
|
||||
activationAllowed: { activationFlag.value },
|
||||
userTorEnabled: { userTorEnabled },
|
||||
hasMutualFavorites: { !favoritesSubject.value.isEmpty },
|
||||
hasLocationPermission: { permissionSubject.value == .authorized },
|
||||
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
||||
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
||||
torEnforced: { torEnforced },
|
||||
torIsReady: { torWaiter.isReady },
|
||||
torIsForeground: { torForeground.value },
|
||||
awaitTorReady: torWaiter.await(completion:),
|
||||
makeSession: { sessionFactory },
|
||||
scheduleAfter: { delay, action in
|
||||
scheduler.schedule(delay: delay, action: action)
|
||||
},
|
||||
now: { clock.now }
|
||||
)
|
||||
)
|
||||
return RelayManagerTestContext(
|
||||
manager: manager,
|
||||
permissionSubject: permissionSubject,
|
||||
favoritesSubject: favoritesSubject,
|
||||
sessionFactory: sessionFactory,
|
||||
scheduler: scheduler,
|
||||
clock: clock,
|
||||
activationAllowed: activationFlag,
|
||||
torWaiter: torWaiter,
|
||||
torForeground: torForeground
|
||||
)
|
||||
}
|
||||
|
||||
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 activationAllowed: MutableBool
|
||||
let torWaiter: MockTorWaiter
|
||||
let torForeground: MutableBool
|
||||
}
|
||||
|
||||
private final class MutableClock {
|
||||
var now: Date
|
||||
|
||||
init(now: Date) {
|
||||
self.now = now
|
||||
}
|
||||
}
|
||||
|
||||
private final class MutableBool {
|
||||
var value: Bool
|
||||
|
||||
init(value: Bool) {
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
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: @unchecked Sendable {
|
||||
struct ScheduledAction {
|
||||
let delay: TimeInterval
|
||||
let action: @Sendable () -> Void
|
||||
}
|
||||
|
||||
private(set) var scheduled: [ScheduledAction] = []
|
||||
|
||||
func schedule(delay: TimeInterval, action: @escaping @Sendable () -> 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 pingErrorByURL: [String: Error?] = [:]
|
||||
var sendErrorByURL: [String: Error?] = [:]
|
||||
|
||||
var allConnections: [MockRelayConnection] {
|
||||
connectionsByURL.values.flatMap { $0 }
|
||||
}
|
||||
|
||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
|
||||
requestedURLs.append(url.absoluteString)
|
||||
let connection = MockRelayConnection(
|
||||
url: url.absoluteString,
|
||||
pingError: pingErrorByURL[url.absoluteString] ?? nil,
|
||||
sendError: sendErrorByURL[url.absoluteString] ?? nil
|
||||
)
|
||||
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 let pingError: Error?
|
||||
private let sendError: Error?
|
||||
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, pingError: Error? = nil, sendError: Error? = nil) {
|
||||
self.url = url
|
||||
self.pingError = pingError
|
||||
self.sendError = sendError
|
||||
}
|
||||
|
||||
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(sendError)
|
||||
}
|
||||
|
||||
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||
receiveHandler = completionHandler
|
||||
}
|
||||
|
||||
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
|
||||
pongReceiveHandler(pingError)
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
func emitOK(eventID: String, success: Bool, reason: String) throws {
|
||||
try emit(jsonObject: ["OK", eventID, success, reason])
|
||||
}
|
||||
|
||||
func emitNotice(message: String) throws {
|
||||
try emit(jsonObject: ["NOTICE", message])
|
||||
}
|
||||
|
||||
func emitRawString(_ string: String) throws {
|
||||
let handler = receiveHandler
|
||||
receiveHandler = nil
|
||||
handler?(.success(.string(string)))
|
||||
}
|
||||
|
||||
private func emit(jsonObject: Any) throws {
|
||||
let data = try JSONSerialization.data(withJSONObject: jsonObject)
|
||||
let handler = receiveHandler
|
||||
receiveHandler = nil
|
||||
handler?(.success(.data(data)))
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,302 @@ import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("NostrTransport Thread Safety Tests")
|
||||
@Suite("NostrTransport Tests")
|
||||
struct NostrTransportTests {
|
||||
typealias FavoriteRelationship = FavoritesPersistenceService.FavoriteRelationship
|
||||
|
||||
@Test("Warm cache marks full and short IDs reachable")
|
||||
@MainActor
|
||||
func reachabilityCacheWarmsFromFavorites() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||
let fullPeerID = PeerID(hexData: noiseKey)
|
||||
let shortPeerID = fullPeerID.toShort()
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Alice"
|
||||
)
|
||||
let favorites = [noiseKey: relationship]
|
||||
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
loadFavorites: { favorites },
|
||||
favoriteStatusForNoiseKey: { favorites[$0] },
|
||||
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
|
||||
currentIdentity: { nil }
|
||||
)
|
||||
)
|
||||
|
||||
#expect(!transport.isPeerReachable(fullPeerID))
|
||||
#expect(transport.isPeerReachable(shortPeerID))
|
||||
#expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed")))
|
||||
}
|
||||
|
||||
@Test("Favorite status notification refreshes reachability cache")
|
||||
@MainActor
|
||||
func favoriteStatusNotificationRefreshesReachability() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((32..<64).map(UInt8.init))
|
||||
let peerID = PeerID(hexData: noiseKey).toShort()
|
||||
let notificationCenter = NotificationCenter()
|
||||
var favorites: [Data: FavoriteRelationship] = [:]
|
||||
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
notificationCenter: notificationCenter,
|
||||
loadFavorites: { favorites },
|
||||
favoriteStatusForNoiseKey: { favorites[$0] },
|
||||
favoriteStatusForPeerID: { _ in favorites.values.first },
|
||||
currentIdentity: { nil }
|
||||
)
|
||||
)
|
||||
|
||||
#expect(!transport.isPeerReachable(peerID))
|
||||
|
||||
favorites[noiseKey] = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Bob"
|
||||
)
|
||||
notificationCenter.post(name: .favoriteStatusChanged, object: nil)
|
||||
|
||||
let didRefresh = await TestHelpers.waitUntil({ transport.isPeerReachable(peerID) }, timeout: 0.5)
|
||||
#expect(didRefresh)
|
||||
}
|
||||
|
||||
@Test("Private message resolves short peer ID and emits decryptable packet")
|
||||
@MainActor
|
||||
func sendPrivateMessageResolvesShortPeerID() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((64..<96).map(UInt8.init))
|
||||
let shortPeerID = PeerID(hexData: noiseKey).toShort()
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Carol"
|
||||
)
|
||||
let probe = NostrTransportProbe()
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { _ in nil },
|
||||
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
)
|
||||
)
|
||||
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
|
||||
transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1")
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5)
|
||||
#expect(didSend)
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||
|
||||
#expect(result.senderPubkey == sender.publicKeyHex)
|
||||
#expect(privateMessage.messageID == "pm-1")
|
||||
#expect(privateMessage.content == "hello over nostr")
|
||||
#expect(result.packet.recipientID == shortPeerID.routingData)
|
||||
#expect(probe.pendingGiftWrapIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Favorite notification embeds current npub")
|
||||
@MainActor
|
||||
func sendFavoriteNotificationEmbedsCurrentIdentity() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((96..<128).map(UInt8.init))
|
||||
let fullPeerID = PeerID(hexData: noiseKey)
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Dan"
|
||||
)
|
||||
let probe = NostrTransportProbe()
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
)
|
||||
)
|
||||
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
|
||||
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5)
|
||||
#expect(didSend)
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||
|
||||
#expect(privateMessage.content == "[FAVORITED]:\(sender.npub)")
|
||||
}
|
||||
|
||||
@Test("Delivery ACK encodes delivered payload type")
|
||||
@MainActor
|
||||
func sendDeliveryAckEmitsDeliveredAck() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((128..<160).map(UInt8.init))
|
||||
let fullPeerID = PeerID(hexData: noiseKey)
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Eve"
|
||||
)
|
||||
let probe = NostrTransportProbe()
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
)
|
||||
)
|
||||
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
|
||||
transport.sendDeliveryAck(for: "ack-1", to: fullPeerID)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5)
|
||||
#expect(didSend)
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
|
||||
#expect(result.payload.type == .delivered)
|
||||
#expect(String(data: result.payload.data, encoding: .utf8) == "ack-1")
|
||||
#expect(result.packet.recipientID == fullPeerID.toShort().routingData)
|
||||
}
|
||||
|
||||
@Test("Geohash private message registers pending gift wrap")
|
||||
@MainActor
|
||||
func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let probe = NostrTransportProbe()
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
)
|
||||
)
|
||||
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
|
||||
transport.sendPrivateMessageGeohash(
|
||||
content: "geo hello",
|
||||
toRecipientHex: recipient.publicKeyHex,
|
||||
from: sender,
|
||||
messageID: "geo-1"
|
||||
)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 0.5)
|
||||
#expect(didSend)
|
||||
let event = probe.sentEvents[0]
|
||||
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||
|
||||
#expect(privateMessage.messageID == "geo-1")
|
||||
#expect(privateMessage.content == "geo hello")
|
||||
#expect(result.packet.recipientID == nil)
|
||||
#expect(probe.pendingGiftWrapIDs == [event.id])
|
||||
}
|
||||
|
||||
@Test("Read receipt queue sends in order and waits for scheduler")
|
||||
@MainActor
|
||||
func readReceiptQueueThrottlesSequentially() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((160..<192).map(UInt8.init))
|
||||
let fullPeerID = PeerID(hexData: noiseKey)
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Frank"
|
||||
)
|
||||
let probe = NostrTransportProbe()
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
)
|
||||
)
|
||||
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
|
||||
let first = ReadReceipt(originalMessageID: "read-1", readerID: transport.myPeerID, readerNickname: "Me")
|
||||
let second = ReadReceipt(originalMessageID: "read-2", readerID: transport.myPeerID, readerNickname: "Me")
|
||||
|
||||
transport.sendReadReceipt(first, to: fullPeerID)
|
||||
transport.sendReadReceipt(second, to: fullPeerID)
|
||||
|
||||
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 1.5)
|
||||
try #require(sentFirst, "Expected first queued read receipt event")
|
||||
let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: 1.5)
|
||||
try #require(scheduledThrottle, "Expected queued throttle action after first read receipt")
|
||||
let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt event")
|
||||
let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload
|
||||
#expect(firstPayload.type == .readReceipt)
|
||||
#expect(String(data: firstPayload.data, encoding: .utf8) == "read-1")
|
||||
|
||||
try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt")
|
||||
|
||||
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 1.5)
|
||||
try #require(sentSecond, "Expected second read receipt after running throttle action")
|
||||
let secondEvent = try #require(probe.sentEvents.last, "Expected second queued read receipt event")
|
||||
let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload
|
||||
#expect(secondPayload.type == .readReceipt)
|
||||
#expect(String(data: secondPayload.data, encoding: .utf8) == "read-2")
|
||||
}
|
||||
|
||||
@Test("Concurrent read receipt enqueue does not crash")
|
||||
@MainActor
|
||||
@@ -19,8 +313,6 @@ struct NostrTransportTests {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
|
||||
// Create 100 concurrent read receipt submissions
|
||||
let iterations = 100
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
@@ -36,49 +328,6 @@ struct NostrTransportTests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here without crashing, the test passes
|
||||
// The concurrent enqueue operations completed without data races
|
||||
}
|
||||
|
||||
@Test("Read queue processes under concurrent load")
|
||||
@MainActor
|
||||
func readQueueProcessingUnderLoad() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
|
||||
// Rapidly enqueue many receipts from multiple concurrent sources
|
||||
let iterations = 50
|
||||
|
||||
// First batch - rapid fire
|
||||
for i in 0..<iterations {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: UUID().uuidString,
|
||||
readerID: PeerID(str: String(format: "%016x", i)),
|
||||
readerNickname: "Reader\(i)"
|
||||
)
|
||||
transport.sendReadReceipt(receipt, to: PeerID(str: String(format: "%016x", i)))
|
||||
}
|
||||
|
||||
// Give some time for processing to start
|
||||
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
||||
|
||||
// Second batch - while first might be processing
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for i in iterations..<(iterations * 2) {
|
||||
group.addTask {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: UUID().uuidString,
|
||||
readerID: PeerID(str: String(format: "%016x", i)),
|
||||
readerNickname: "Reader\(i)"
|
||||
)
|
||||
transport.sendReadReceipt(receipt, to: PeerID(str: String(format: "%016x", i)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here without crashing or deadlocking, test passes
|
||||
}
|
||||
|
||||
@Test("isPeerReachable is thread safe")
|
||||
@@ -87,10 +336,8 @@ struct NostrTransportTests {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
|
||||
let iterations = 100
|
||||
|
||||
// Concurrent reads on isPeerReachable
|
||||
await withTaskGroup(of: Bool.self) { group in
|
||||
for i in 0..<iterations {
|
||||
group.addTask {
|
||||
@@ -99,10 +346,149 @@ struct NostrTransportTests {
|
||||
}
|
||||
}
|
||||
|
||||
// Collect results (all should be false since no favorites configured)
|
||||
for await result in group {
|
||||
#expect(result == false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeDependencies(
|
||||
notificationCenter: NotificationCenter = NotificationCenter(),
|
||||
loadFavorites: @escaping @MainActor () -> [Data: FavoriteRelationship] = { [:] },
|
||||
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil },
|
||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
|
||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
||||
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
|
||||
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
|
||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in }
|
||||
) -> NostrTransport.Dependencies {
|
||||
NostrTransport.Dependencies(
|
||||
notificationCenter: notificationCenter,
|
||||
loadFavorites: loadFavorites,
|
||||
favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,
|
||||
favoriteStatusForPeerID: favoriteStatusForPeerID,
|
||||
currentIdentity: currentIdentity,
|
||||
registerPendingGiftWrap: registerPendingGiftWrap,
|
||||
sendEvent: sendEvent,
|
||||
scheduleAfter: scheduleAfter
|
||||
)
|
||||
}
|
||||
|
||||
private func makeRelationship(
|
||||
peerNoisePublicKey: Data,
|
||||
peerNostrPublicKey: String?,
|
||||
peerNickname: String
|
||||
) -> FavoriteRelationship {
|
||||
FavoriteRelationship(
|
||||
peerNoisePublicKey: peerNoisePublicKey,
|
||||
peerNostrPublicKey: peerNostrPublicKey,
|
||||
peerNickname: peerNickname,
|
||||
isFavorite: true,
|
||||
theyFavoritedUs: true,
|
||||
favoritedAt: Date(timeIntervalSince1970: 1),
|
||||
lastUpdated: Date(timeIntervalSince1970: 2)
|
||||
)
|
||||
}
|
||||
|
||||
private func decodeEmbeddedPayload(
|
||||
from event: NostrEvent,
|
||||
recipient: NostrIdentity
|
||||
) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) {
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: event,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
guard content.hasPrefix("bitchat1:") else {
|
||||
throw NostrTransportTestError.invalidEmbeddedContent
|
||||
}
|
||||
let encoded = String(content.dropFirst("bitchat1:".count))
|
||||
guard let packetData = base64URLDecode(encoded),
|
||||
let packet = BitchatPacket.from(packetData),
|
||||
let payload = NoisePayload.decode(packet.payload) else {
|
||||
throw NostrTransportTestError.invalidPacket
|
||||
}
|
||||
return (packet, payload, senderPubkey)
|
||||
}
|
||||
|
||||
private func decodePrivateMessage(from payload: NoisePayload) throws -> PrivateMessagePacket {
|
||||
guard payload.type == .privateMessage,
|
||||
let message = PrivateMessagePacket.decode(from: payload.data) else {
|
||||
throw NostrTransportTestError.invalidPrivateMessage
|
||||
}
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
private enum NostrTransportTestError: Error {
|
||||
case invalidEmbeddedContent
|
||||
case invalidPacket
|
||||
case invalidPrivateMessage
|
||||
}
|
||||
|
||||
private func base64URLDecode(_ string: String) -> Data? {
|
||||
var candidate = string
|
||||
let padding = (4 - (candidate.count % 4)) % 4
|
||||
if padding > 0 {
|
||||
candidate += String(repeating: "=", count: padding)
|
||||
}
|
||||
candidate = candidate
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
return Data(base64Encoded: candidate)
|
||||
}
|
||||
|
||||
private final class NostrTransportProbe: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var sentEventsStorage: [NostrEvent] = []
|
||||
private var pendingGiftWrapIDsStorage: [String] = []
|
||||
private var scheduledActionsStorage: [(@Sendable () -> Void)] = []
|
||||
|
||||
var sentEvents: [NostrEvent] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return sentEventsStorage
|
||||
}
|
||||
|
||||
var pendingGiftWrapIDs: [String] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return pendingGiftWrapIDsStorage
|
||||
}
|
||||
|
||||
var scheduledActionCount: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return scheduledActionsStorage.count
|
||||
}
|
||||
|
||||
func record(event: NostrEvent) {
|
||||
lock.lock()
|
||||
sentEventsStorage.append(event)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func recordPendingGiftWrap(id: String) {
|
||||
lock.lock()
|
||||
pendingGiftWrapIDsStorage.append(id)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func enqueueScheduledAction(delay: TimeInterval, action: @escaping @Sendable () -> Void) {
|
||||
_ = delay
|
||||
lock.lock()
|
||||
scheduledActionsStorage.append(action)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func runNextScheduledAction() -> Bool {
|
||||
let action: (@Sendable () -> Void)?
|
||||
lock.lock()
|
||||
action = scheduledActionsStorage.isEmpty ? nil : scheduledActionsStorage.removeFirst()
|
||||
lock.unlock()
|
||||
guard let action else { return false }
|
||||
action()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -69,4 +69,202 @@ struct PrivateChatManagerTests {
|
||||
#expect(manager.sentReadReceipts.contains("pm-2"))
|
||||
#expect(!manager.unreadMessages.contains(peerID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func markAsRead_withoutRouterFallsBackToTransport() async {
|
||||
let transport = MockTransport()
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
let peerID = PeerID(str: "00000000000000CC")
|
||||
|
||||
manager.privateChats[peerID] = [
|
||||
BitchatMessage(
|
||||
id: "pm-fallback",
|
||||
sender: "Peer",
|
||||
content: "Hi",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
]
|
||||
|
||||
manager.markAsRead(from: peerID)
|
||||
|
||||
#expect(transport.sentReadReceipts.count == 1)
|
||||
#expect(transport.sentReadReceipts.first?.receipt.originalMessageID == "pm-fallback")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func consolidateMessages_mergesStableNoiseKeyHistoryAndMarksUnread() async {
|
||||
let transport = MockTransport()
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let unifiedPeerService = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identityManager)
|
||||
manager.unifiedPeerService = unifiedPeerService
|
||||
|
||||
let peerID = PeerID(str: "0123456789abcdef")
|
||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||
let stablePeerID = PeerID(hexData: noiseKey)
|
||||
|
||||
transport.updatePeerSnapshots([
|
||||
TransportPeerSnapshot(
|
||||
peerID: peerID,
|
||||
nickname: "Alice",
|
||||
isConnected: true,
|
||||
noisePublicKey: noiseKey,
|
||||
lastSeen: Date()
|
||||
)
|
||||
])
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
manager.privateChats[stablePeerID] = [
|
||||
BitchatMessage(
|
||||
id: "stable-msg",
|
||||
sender: "Alice",
|
||||
content: "Hello from stable",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: stablePeerID
|
||||
)
|
||||
]
|
||||
manager.unreadMessages.insert(stablePeerID)
|
||||
|
||||
let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "Alice", persistedReadReceipts: [])
|
||||
|
||||
#expect(hadUnread)
|
||||
#expect(manager.privateChats[stablePeerID] == nil)
|
||||
#expect(manager.privateChats[peerID]?.count == 1)
|
||||
#expect(manager.privateChats[peerID]?.first?.senderPeerID == peerID)
|
||||
#expect(manager.unreadMessages.contains(peerID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func consolidateMessages_movesTemporaryGeoDMHistoryByNickname() async {
|
||||
let transport = MockTransport()
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
let peerID = PeerID(str: "0011223344556677")
|
||||
let tempPeerID = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000042")
|
||||
|
||||
manager.privateChats[tempPeerID] = [
|
||||
BitchatMessage(
|
||||
id: "geo-msg",
|
||||
sender: "Alice",
|
||||
content: "Geo hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: tempPeerID
|
||||
)
|
||||
]
|
||||
manager.unreadMessages.insert(tempPeerID)
|
||||
|
||||
let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "alice", persistedReadReceipts: [])
|
||||
|
||||
#expect(hadUnread)
|
||||
#expect(manager.privateChats[tempPeerID] == nil)
|
||||
#expect(manager.privateChats[peerID]?.count == 1)
|
||||
#expect(manager.privateChats[peerID]?.first?.senderPeerID == peerID)
|
||||
#expect(manager.unreadMessages.contains(peerID))
|
||||
#expect(!manager.unreadMessages.contains(tempPeerID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func syncReadReceiptsForSentMessages_onlyCopiesDeliveredAndRead() async {
|
||||
let transport = MockTransport()
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
let peerID = PeerID(str: "00000000000000DD")
|
||||
|
||||
manager.privateChats[peerID] = [
|
||||
BitchatMessage(
|
||||
id: "sent-read",
|
||||
sender: "Me",
|
||||
content: "One",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .read(by: "Peer", at: Date())
|
||||
),
|
||||
BitchatMessage(
|
||||
id: "sent-delivered",
|
||||
sender: "Me",
|
||||
content: "Two",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .delivered(to: "Peer", at: Date())
|
||||
),
|
||||
BitchatMessage(
|
||||
id: "sent-failed",
|
||||
sender: "Me",
|
||||
content: "Three",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .failed(reason: "nope")
|
||||
)
|
||||
]
|
||||
|
||||
var externalReceipts = Set<String>()
|
||||
manager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: "Me", externalReceipts: &externalReceipts)
|
||||
|
||||
#expect(externalReceipts == Set(["sent-read", "sent-delivered"]))
|
||||
#expect(manager.sentReadReceipts == Set(["sent-read", "sent-delivered"]))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sanitizeChat_sortsChronologicallyAndKeepsLatestDuplicate() async {
|
||||
let transport = MockTransport()
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
let peerID = PeerID(str: "00000000000000EE")
|
||||
let base = Date(timeIntervalSince1970: 10)
|
||||
|
||||
manager.privateChats[peerID] = [
|
||||
BitchatMessage(
|
||||
id: "same",
|
||||
sender: "Peer",
|
||||
content: "Older",
|
||||
timestamp: base.addingTimeInterval(10),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
),
|
||||
BitchatMessage(
|
||||
id: "first",
|
||||
sender: "Peer",
|
||||
content: "First",
|
||||
timestamp: base,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
),
|
||||
BitchatMessage(
|
||||
id: "same",
|
||||
sender: "Peer",
|
||||
content: "Newest",
|
||||
timestamp: base.addingTimeInterval(20),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
]
|
||||
|
||||
manager.sanitizeChat(for: peerID)
|
||||
|
||||
#expect(manager.privateChats[peerID]?.map(\.id) == ["first", "same"])
|
||||
#expect(manager.privateChats[peerID]?.last?.content == "Newest")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
func test_upsertCryptographicIdentity_withoutClaimedNicknameDoesNotCreateSocialIdentity() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "aa", count: 32)
|
||||
let peerID = PeerID(str: String(fingerprint.prefix(16)))
|
||||
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||
claimedNickname: nil
|
||||
)
|
||||
|
||||
let inserted = await waitUntil {
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).count == 1
|
||||
}
|
||||
XCTAssertTrue(inserted)
|
||||
XCTAssertNil(manager.getSocialIdentity(for: fingerprint))
|
||||
}
|
||||
|
||||
func test_upsertCryptographicIdentity_updatesExistingKeyAndPreservesSigningKey() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "ab", count: 32)
|
||||
let peerID = PeerID(str: String(fingerprint.prefix(16)))
|
||||
let originalNoiseKey = Data(repeating: 0x11, count: 32)
|
||||
let updatedNoiseKey = Data(repeating: 0x33, count: 32)
|
||||
let signingKey = Data(repeating: 0x22, count: 32)
|
||||
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: originalNoiseKey,
|
||||
signingPublicKey: signingKey,
|
||||
claimedNickname: nil
|
||||
)
|
||||
_ = await waitUntil {
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.publicKey == originalNoiseKey
|
||||
}
|
||||
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: updatedNoiseKey,
|
||||
signingPublicKey: nil,
|
||||
claimedNickname: nil
|
||||
)
|
||||
|
||||
let updated = await waitUntil {
|
||||
guard let identity = manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first else { return false }
|
||||
return identity.publicKey == updatedNoiseKey && identity.signingPublicKey == signingKey
|
||||
}
|
||||
XCTAssertTrue(updated)
|
||||
}
|
||||
|
||||
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_isBlocked_unknownFingerprintReturnsFalse() {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
|
||||
XCTAssertFalse(manager.isBlocked(fingerprint: String(repeating: "ff", count: 32)))
|
||||
}
|
||||
|
||||
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_updateSocialIdentity_reindexesClaimedNickname() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "34", count: 32)
|
||||
|
||||
manager.updateSocialIdentity(
|
||||
SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: "Alice",
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
)
|
||||
let initialIndexed = await waitUntil {
|
||||
manager.debugNicknameIndex["Alice"]?.contains(fingerprint) == true
|
||||
}
|
||||
XCTAssertTrue(initialIndexed)
|
||||
|
||||
manager.updateSocialIdentity(
|
||||
SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: "Friend",
|
||||
claimedNickname: "Bob",
|
||||
trustLevel: .trusted,
|
||||
isFavorite: true,
|
||||
isBlocked: false,
|
||||
notes: "updated"
|
||||
)
|
||||
)
|
||||
|
||||
let reindexed = await waitUntil {
|
||||
manager.debugNicknameIndex["Alice"]?.contains(fingerprint) != true &&
|
||||
manager.debugNicknameIndex["Bob"]?.contains(fingerprint) == true
|
||||
}
|
||||
XCTAssertTrue(reindexed)
|
||||
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Bob")
|
||||
}
|
||||
|
||||
func test_upsertCryptographicIdentity_sameClaimedNicknamePreservesExistingSocialIdentity() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "35", count: 32)
|
||||
|
||||
manager.updateSocialIdentity(
|
||||
SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: "Pal",
|
||||
claimedNickname: "Alice",
|
||||
trustLevel: .trusted,
|
||||
isFavorite: true,
|
||||
isBlocked: false,
|
||||
notes: "keep me"
|
||||
)
|
||||
)
|
||||
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
|
||||
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||
claimedNickname: "Alice"
|
||||
)
|
||||
|
||||
let inserted = await waitUntil {
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: String(fingerprint.prefix(16)))).count == 1
|
||||
}
|
||||
XCTAssertTrue(inserted)
|
||||
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.localPetname, "Pal")
|
||||
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.notes, "keep me")
|
||||
XCTAssertTrue(manager.getSocialIdentity(for: fingerprint)?.isFavorite == true)
|
||||
}
|
||||
|
||||
func test_getFavorites_returnsOnlyFavoritedFingerprints() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let favoriteOne = String(repeating: "45", count: 32)
|
||||
let favoriteTwo = String(repeating: "56", count: 32)
|
||||
let other = String(repeating: "67", count: 32)
|
||||
|
||||
manager.setFavorite(favoriteOne, isFavorite: true)
|
||||
manager.setFavorite(favoriteTwo, isFavorite: true)
|
||||
manager.setFavorite(other, isFavorite: false)
|
||||
|
||||
let favoritesLoaded = await waitUntil {
|
||||
manager.getFavorites() == Set([favoriteOne, favoriteTwo])
|
||||
}
|
||||
XCTAssertTrue(favoritesLoaded)
|
||||
}
|
||||
|
||||
func test_setFavorite_existingIdentityCanBeClearedWithoutChangingNickname() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "68", count: 32)
|
||||
|
||||
manager.updateSocialIdentity(
|
||||
SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: "Alice",
|
||||
trustLevel: .trusted,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
)
|
||||
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
|
||||
|
||||
manager.setFavorite(fingerprint, isFavorite: true)
|
||||
_ = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
|
||||
|
||||
manager.setFavorite(fingerprint, isFavorite: false)
|
||||
let cleared = await waitUntil {
|
||||
!manager.isFavorite(fingerprint: fingerprint) &&
|
||||
manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "Alice" &&
|
||||
manager.getSocialIdentity(for: fingerprint)?.trustLevel == .trusted
|
||||
}
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
func test_setBlocked_createsIdentityAndCanLaterUnblock() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "78", count: 32)
|
||||
|
||||
manager.setBlocked(fingerprint, isBlocked: true)
|
||||
let blocked = await waitUntil {
|
||||
manager.isBlocked(fingerprint: fingerprint)
|
||||
}
|
||||
XCTAssertTrue(blocked)
|
||||
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Unknown")
|
||||
|
||||
manager.setBlocked(fingerprint, isBlocked: false)
|
||||
let unblocked = await waitUntil {
|
||||
!manager.isBlocked(fingerprint: fingerprint)
|
||||
}
|
||||
XCTAssertTrue(unblocked)
|
||||
}
|
||||
|
||||
func test_setVerified_false_downgradesTrustLevelToCasual() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "89", count: 32)
|
||||
|
||||
manager.updateSocialIdentity(
|
||||
SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: "Verifier",
|
||||
trustLevel: .trusted,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
)
|
||||
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
|
||||
|
||||
manager.setVerified(fingerprint: fingerprint, verified: true)
|
||||
_ = await waitUntil { manager.isVerified(fingerprint: fingerprint) }
|
||||
|
||||
manager.setVerified(fingerprint: fingerprint, verified: false)
|
||||
let downgraded = await waitUntil {
|
||||
!manager.isVerified(fingerprint: fingerprint) &&
|
||||
manager.getSocialIdentity(for: fingerprint)?.trustLevel == .casual
|
||||
}
|
||||
XCTAssertTrue(downgraded)
|
||||
}
|
||||
|
||||
func test_ephemeralSessionLifecycle_tracksHandshakeProgressAndLastInteraction() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let peerID = PeerID(str: "1234567890abcdef")
|
||||
let fingerprint = String(repeating: "90", count: 32)
|
||||
|
||||
manager.registerEphemeralSession(peerID: peerID, handshakeState: .initiated)
|
||||
let registered = await waitUntil {
|
||||
if case .initiated? = manager.debugEphemeralSession(for: peerID)?.handshakeState {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
XCTAssertTrue(registered)
|
||||
|
||||
manager.updateHandshakeState(peerID: peerID, state: .inProgress)
|
||||
let progressed = await waitUntil {
|
||||
if case .inProgress? = manager.debugEphemeralSession(for: peerID)?.handshakeState {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
XCTAssertTrue(progressed)
|
||||
|
||||
manager.updateHandshakeState(peerID: peerID, state: .completed(fingerprint: fingerprint))
|
||||
let completed = await waitUntil {
|
||||
if case .completed(let completedFingerprint)? = manager.debugEphemeralSession(for: peerID)?.handshakeState {
|
||||
return completedFingerprint == fingerprint && manager.debugLastInteraction(for: fingerprint) != nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
XCTAssertTrue(completed)
|
||||
|
||||
manager.removeEphemeralSession(peerID: peerID)
|
||||
let removed = await waitUntil {
|
||||
manager.debugEphemeralSession(for: peerID) == nil
|
||||
}
|
||||
XCTAssertTrue(removed)
|
||||
}
|
||||
|
||||
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_setNostrBlocked_falseRemovesExistingKey() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let pubkey = "ABCDEF1234"
|
||||
|
||||
manager.setNostrBlocked(pubkey, isBlocked: true)
|
||||
_ = await waitUntil { manager.isNostrBlocked(pubkeyHexLowercased: pubkey) }
|
||||
|
||||
manager.setNostrBlocked(pubkey, isBlocked: false)
|
||||
let cleared = await waitUntil {
|
||||
!manager.isNostrBlocked(pubkeyHexLowercased: pubkey) &&
|
||||
manager.getBlockedNostrPubkeys().isEmpty
|
||||
}
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {
|
||||
let keychain = FailingCacheSaveKeychain()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
let fingerprint = String(repeating: "de", count: 32)
|
||||
|
||||
manager.setFavorite(fingerprint, isFavorite: true)
|
||||
let primed = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
|
||||
XCTAssertTrue(primed)
|
||||
|
||||
manager.forceSave()
|
||||
|
||||
let reloaded = SecureIdentityStateManager(keychain)
|
||||
XCTAssertFalse(reloaded.isFavorite(fingerprint: fingerprint))
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private final class FailingCacheSaveKeychain: KeychainManagerProtocol {
|
||||
private var storage: [String: Data] = [:]
|
||||
private var serviceStorage: [String: [String: Data]] = [:]
|
||||
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||
if key == "bitchat.identityCache.v2" {
|
||||
return false
|
||||
}
|
||||
storage[key] = keyData
|
||||
return true
|
||||
}
|
||||
|
||||
func getIdentityKey(forKey key: String) -> Data? {
|
||||
storage[key]
|
||||
}
|
||||
|
||||
func deleteIdentityKey(forKey key: String) -> Bool {
|
||||
storage.removeValue(forKey: key)
|
||||
return true
|
||||
}
|
||||
|
||||
func deleteAllKeychainData() -> Bool {
|
||||
storage.removeAll()
|
||||
serviceStorage.removeAll()
|
||||
return true
|
||||
}
|
||||
|
||||
func secureClear(_ data: inout Data) {
|
||||
data = Data()
|
||||
}
|
||||
|
||||
func secureClear(_ string: inout String) {
|
||||
string = ""
|
||||
}
|
||||
|
||||
func verifyIdentityKeyExists() -> Bool {
|
||||
storage["identity_noiseStaticKey"] != nil
|
||||
}
|
||||
|
||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
||||
if let data = storage[key] {
|
||||
return .success(data)
|
||||
}
|
||||
return .itemNotFound
|
||||
}
|
||||
|
||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
||||
if saveIdentityKey(keyData, forKey: key) {
|
||||
return .success
|
||||
}
|
||||
return .otherError(OSStatus(-1))
|
||||
}
|
||||
|
||||
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
||||
if serviceStorage[service] == nil {
|
||||
serviceStorage[service] = [:]
|
||||
}
|
||||
serviceStorage[service]?[key] = data
|
||||
}
|
||||
|
||||
func load(key: String, service: String) -> Data? {
|
||||
serviceStorage[service]?[key]
|
||||
}
|
||||
|
||||
func delete(key: String, service: String) {
|
||||
serviceStorage[service]?.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("TransferProgressManager Tests")
|
||||
struct TransferProgressManagerTests {
|
||||
|
||||
@Test("Start publishes started event and stores snapshot")
|
||||
@MainActor
|
||||
func startPublishesAndStoresSnapshot() async throws {
|
||||
let manager = TransferProgressManager()
|
||||
let transferID = "transfer-start"
|
||||
var cancellable: AnyCancellable?
|
||||
let recorder = EventRecorder()
|
||||
|
||||
cancellable = manager.publisher.sink { event in
|
||||
if case .started(let id, let total) = event {
|
||||
recorder.append("started:\(id):\(total)")
|
||||
}
|
||||
}
|
||||
|
||||
manager.start(id: transferID, totalFragments: 3)
|
||||
|
||||
let didReceive = await TestHelpers.waitUntil({
|
||||
recorder.values == ["started:\(transferID):3"]
|
||||
}, timeout: 0.5)
|
||||
#expect(didReceive)
|
||||
|
||||
#expect(recorder.values == ["started:\(transferID):3"])
|
||||
#expect(manager.snapshot(id: transferID)?.sent == 0)
|
||||
#expect(manager.snapshot(id: transferID)?.total == 3)
|
||||
_ = cancellable
|
||||
}
|
||||
|
||||
@Test("Sending final fragment publishes update and completion then clears snapshot")
|
||||
@MainActor
|
||||
func recordFragmentSentPublishesProgressAndCompletion() async throws {
|
||||
let manager = TransferProgressManager()
|
||||
let transferID = "transfer-complete"
|
||||
var cancellable: AnyCancellable?
|
||||
let recorder = EventRecorder()
|
||||
|
||||
cancellable = manager.publisher.sink { event in
|
||||
switch event {
|
||||
case .started(let id, let total):
|
||||
recorder.append("started:\(id):\(total)")
|
||||
case .updated(let id, let sent, let total):
|
||||
recorder.append("updated:\(id):\(sent):\(total)")
|
||||
case .completed(let id, let total):
|
||||
recorder.append("completed:\(id):\(total)")
|
||||
case .cancelled:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
manager.start(id: transferID, totalFragments: 1)
|
||||
manager.recordFragmentSent(id: transferID)
|
||||
|
||||
let didReceive = await TestHelpers.waitUntil({
|
||||
recorder.values.count == 3
|
||||
}, timeout: 0.5)
|
||||
#expect(didReceive)
|
||||
|
||||
#expect(recorder.values == [
|
||||
"started:\(transferID):1",
|
||||
"updated:\(transferID):1:1",
|
||||
"completed:\(transferID):1"
|
||||
])
|
||||
#expect(manager.snapshot(id: transferID) == nil)
|
||||
_ = cancellable
|
||||
}
|
||||
|
||||
@Test("Cancel publishes cancelled event and clears state")
|
||||
@MainActor
|
||||
func cancelPublishesAndClearsState() async throws {
|
||||
let manager = TransferProgressManager()
|
||||
let transferID = "transfer-cancel"
|
||||
var cancellable: AnyCancellable?
|
||||
let recorder = EventRecorder()
|
||||
|
||||
cancellable = manager.publisher.sink { event in
|
||||
switch event {
|
||||
case .started(let id, let total):
|
||||
recorder.append("started:\(id):\(total)")
|
||||
case .cancelled(let id, let sent, let total):
|
||||
recorder.append("cancelled:\(id):\(sent):\(total)")
|
||||
case .updated, .completed:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
manager.start(id: transferID, totalFragments: 4)
|
||||
manager.recordFragmentSent(id: transferID)
|
||||
manager.cancel(id: transferID)
|
||||
|
||||
let didReceive = await TestHelpers.waitUntil({
|
||||
recorder.values.contains("started:\(transferID):4") &&
|
||||
recorder.values.contains("cancelled:\(transferID):1:4")
|
||||
}, timeout: 0.5)
|
||||
#expect(didReceive)
|
||||
|
||||
#expect(recorder.values.contains("started:\(transferID):4"))
|
||||
#expect(recorder.values.contains("cancelled:\(transferID):1:4"))
|
||||
#expect(manager.snapshot(id: transferID) == nil)
|
||||
_ = cancellable
|
||||
}
|
||||
}
|
||||
|
||||
private final class EventRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var storage: [String] = []
|
||||
|
||||
var values: [String] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return storage
|
||||
}
|
||||
|
||||
func append(_ value: String) {
|
||||
lock.lock()
|
||||
storage.append(value)
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
final class RequestSyncManagerTests: XCTestCase {
|
||||
func test_isValidResponse_returnsFalseWhenPacketIsNotRSR() {
|
||||
let clock = MutableSyncClock(now: 1_000)
|
||||
let manager = RequestSyncManager(responseWindow: 30, now: { clock.now })
|
||||
|
||||
manager.registerRequest(to: PeerID(str: "aaaaaaaaaaaaaaaa"))
|
||||
XCTAssertFalse(manager.isValidResponse(from: PeerID(str: "aaaaaaaaaaaaaaaa"), isRSR: false))
|
||||
}
|
||||
|
||||
func test_isValidResponse_returnsFalseForUnsolicitedRSR() {
|
||||
let clock = MutableSyncClock(now: 1_000)
|
||||
let manager = RequestSyncManager(responseWindow: 30, now: { clock.now })
|
||||
|
||||
XCTAssertFalse(manager.isValidResponse(from: PeerID(str: "bbbbbbbbbbbbbbbb"), isRSR: true))
|
||||
}
|
||||
|
||||
func test_isValidResponse_acceptsRecentRequest() async {
|
||||
let clock = MutableSyncClock(now: 1_000)
|
||||
let manager = RequestSyncManager(responseWindow: 30, now: { clock.now })
|
||||
let peerID = PeerID(str: "cccccccccccccccc")
|
||||
|
||||
manager.registerRequest(to: peerID)
|
||||
let registered = await waitUntil {
|
||||
manager.debugPendingRequestCount == 1
|
||||
}
|
||||
XCTAssertTrue(registered)
|
||||
|
||||
clock.now = 1_020
|
||||
XCTAssertTrue(manager.isValidResponse(from: peerID, isRSR: true))
|
||||
}
|
||||
|
||||
func test_cleanup_removesExpiredRequestsAndPreservesFreshOnes() async {
|
||||
let clock = MutableSyncClock(now: 1_000)
|
||||
let manager = RequestSyncManager(responseWindow: 30, now: { clock.now })
|
||||
let expiredPeer = PeerID(str: "dddddddddddddddd")
|
||||
let freshPeer = PeerID(str: "eeeeeeeeeeeeeeee")
|
||||
|
||||
manager.registerRequest(to: expiredPeer)
|
||||
_ = await waitUntil { manager.debugPendingRequestCount == 1 }
|
||||
|
||||
clock.now = 1_010
|
||||
manager.registerRequest(to: freshPeer)
|
||||
let bothRegistered = await waitUntil {
|
||||
manager.debugPendingRequestCount == 2
|
||||
}
|
||||
XCTAssertTrue(bothRegistered)
|
||||
|
||||
clock.now = 1_035
|
||||
XCTAssertFalse(manager.isValidResponse(from: expiredPeer, isRSR: true))
|
||||
XCTAssertTrue(manager.isValidResponse(from: freshPeer, isRSR: true))
|
||||
|
||||
manager.cleanup()
|
||||
let cleaned = await waitUntil {
|
||||
manager.debugPendingRequestCount == 1
|
||||
}
|
||||
XCTAssertTrue(cleaned)
|
||||
XCTAssertFalse(manager.isValidResponse(from: expiredPeer, isRSR: true))
|
||||
XCTAssertTrue(manager.isValidResponse(from: freshPeer, isRSR: true))
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private final class MutableSyncClock {
|
||||
var now: TimeInterval
|
||||
|
||||
init(now: TimeInterval) {
|
||||
self.now = now
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import CoreGraphics
|
||||
import AVFoundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
private func makeSmokeViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identityManager: MockIdentityManager) {
|
||||
let keychain = MockKeychain()
|
||||
let keychainHelper = MockKeychainHelper()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
return (viewModel, transport, identityManager)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@discardableResult
|
||||
private func mount<V: View>(_ view: V) -> AnyObject {
|
||||
#if os(iOS)
|
||||
let host = UIHostingController(rootView: view)
|
||||
_ = host.view
|
||||
host.view.setNeedsLayout()
|
||||
host.view.layoutIfNeeded()
|
||||
return host
|
||||
#else
|
||||
let host = NSHostingView(rootView: view)
|
||||
host.layoutSubtreeIfNeeded()
|
||||
_ = host.fittingSize
|
||||
return host
|
||||
#endif
|
||||
}
|
||||
|
||||
private func makeSnapshot(
|
||||
peerID: PeerID,
|
||||
nickname: String,
|
||||
connected: Bool = true,
|
||||
noiseByte: UInt8
|
||||
) -> TransportPeerSnapshot {
|
||||
TransportPeerSnapshot(
|
||||
peerID: peerID,
|
||||
nickname: nickname,
|
||||
isConnected: connected,
|
||||
noisePublicKey: Data(repeating: noiseByte, count: 32),
|
||||
lastSeen: Date()
|
||||
)
|
||||
}
|
||||
|
||||
private func makeCGImage() throws -> CGImage {
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
let context = try #require(
|
||||
CGContext(
|
||||
data: nil,
|
||||
width: 8,
|
||||
height: 8,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
)
|
||||
)
|
||||
context.setFillColor(CGColor(red: 0.1, green: 0.7, blue: 0.2, alpha: 1))
|
||||
context.fill(CGRect(x: 0, y: 0, width: 8, height: 8))
|
||||
return try #require(context.makeImage())
|
||||
}
|
||||
|
||||
private func makeTemporaryAudioURL() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension("caf")
|
||||
let format = try #require(AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1))
|
||||
let frameCount: AVAudioFrameCount = 1_600
|
||||
let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount))
|
||||
buffer.frameLength = frameCount
|
||||
let channel = try #require(buffer.floatChannelData?[0])
|
||||
for index in 0..<Int(frameCount) {
|
||||
channel[index] = sinf(Float(index) * 0.2) * 0.5
|
||||
}
|
||||
|
||||
let file = try AVAudioFile(forWriting: url, settings: format.settings)
|
||||
try file.write(from: buffer)
|
||||
return url
|
||||
}
|
||||
|
||||
private func makeTemporaryImageURL() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension("png")
|
||||
let image = try makeCGImage()
|
||||
#if os(iOS)
|
||||
let data = try #require(UIImage(cgImage: image).pngData())
|
||||
#else
|
||||
let rep = NSBitmapImageRep(cgImage: image)
|
||||
let data = try #require(rep.representation(using: .png, properties: [:]))
|
||||
#endif
|
||||
try data.write(to: url)
|
||||
return url
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ViewSmokeTests {
|
||||
@Test
|
||||
func fingerprintView_renders_verifiedAndPendingStates() async {
|
||||
let (viewModel, transport, _) = makeSmokeViewModel()
|
||||
let verifiedPeer = PeerID(str: "0102030405060708")
|
||||
let pendingPeer = PeerID(str: "1112131415161718")
|
||||
let verifiedFingerprint = String(repeating: "ab", count: 32)
|
||||
|
||||
transport.peerFingerprints[verifiedPeer] = verifiedFingerprint
|
||||
transport.peerFingerprints[pendingPeer] = nil
|
||||
transport.updatePeerSnapshots([
|
||||
makeSnapshot(peerID: verifiedPeer, nickname: "Alice", noiseByte: 0x11),
|
||||
makeSnapshot(peerID: pendingPeer, nickname: "Bob", noiseByte: 0x22)
|
||||
])
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
viewModel.verifiedFingerprints.insert(verifiedFingerprint)
|
||||
|
||||
let verifiedView = FingerprintView(viewModel: viewModel, peerID: verifiedPeer)
|
||||
let pendingView = FingerprintView(viewModel: viewModel, peerID: pendingPeer)
|
||||
|
||||
_ = verifiedView.body
|
||||
_ = pendingView.body
|
||||
_ = mount(verifiedView)
|
||||
_ = mount(pendingView)
|
||||
|
||||
#expect(viewModel.verifiedFingerprints.contains(verifiedFingerprint))
|
||||
}
|
||||
|
||||
@Test
|
||||
func verificationViews_renderCoreBranches() throws {
|
||||
let (viewModel, transport, _) = makeSmokeViewModel()
|
||||
let peerID = PeerID(str: "2122232425262728")
|
||||
let fingerprint = String(repeating: "cd", count: 32)
|
||||
var isPresented = true
|
||||
|
||||
transport.peerFingerprints[peerID] = fingerprint
|
||||
transport.updatePeerSnapshots([makeSnapshot(peerID: peerID, nickname: "Verifier", noiseByte: 0x33)])
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.verifiedFingerprints.insert(fingerprint)
|
||||
|
||||
let image = try makeCGImage()
|
||||
|
||||
let myQR = MyQRView(qrString: "bitchat://verify?name=alice&npub=npub1test")
|
||||
let qrCode = QRCodeImage(data: "bitchat://verify?hello=world", size: 96)
|
||||
let imageWrapper = ImageWrapper(image: image)
|
||||
|
||||
_ = myQR.body
|
||||
_ = qrCode.body
|
||||
_ = imageWrapper.body
|
||||
_ = mount(myQR)
|
||||
_ = mount(qrCode)
|
||||
_ = mount(imageWrapper)
|
||||
_ = mount(
|
||||
VerificationSheetView(
|
||||
isPresented: Binding(
|
||||
get: { isPresented },
|
||||
set: { isPresented = $0 }
|
||||
)
|
||||
)
|
||||
.environmentObject(viewModel)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func meshPeerList_renders_emptyAndPopulatedStates() async {
|
||||
let (viewModel, transport, identityManager) = makeSmokeViewModel()
|
||||
let connectedPeer = PeerID(str: "3132333435363738")
|
||||
let blockedPeer = PeerID(str: "4142434445464748")
|
||||
let blockedFingerprint = String(repeating: "ef", count: 32)
|
||||
|
||||
_ = mount(
|
||||
MeshPeerList(
|
||||
viewModel: viewModel,
|
||||
textColor: .green,
|
||||
secondaryTextColor: .gray,
|
||||
onTapPeer: { _ in },
|
||||
onToggleFavorite: { _ in },
|
||||
onShowFingerprint: { _ in }
|
||||
)
|
||||
)
|
||||
_ = MeshPeerList(
|
||||
viewModel: viewModel,
|
||||
textColor: .green,
|
||||
secondaryTextColor: .gray,
|
||||
onTapPeer: { _ in },
|
||||
onToggleFavorite: { _ in },
|
||||
onShowFingerprint: { _ in }
|
||||
).body
|
||||
|
||||
transport.peerFingerprints[blockedPeer] = blockedFingerprint
|
||||
identityManager.setBlocked(blockedFingerprint, isBlocked: true)
|
||||
transport.updatePeerSnapshots([
|
||||
makeSnapshot(peerID: connectedPeer, nickname: "Alice", noiseByte: 0x44),
|
||||
makeSnapshot(peerID: blockedPeer, nickname: "Mallory", noiseByte: 0x55)
|
||||
])
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
viewModel.unreadPrivateMessages.insert(blockedPeer)
|
||||
|
||||
_ = mount(
|
||||
MeshPeerList(
|
||||
viewModel: viewModel,
|
||||
textColor: .green,
|
||||
secondaryTextColor: .gray,
|
||||
onTapPeer: { _ in },
|
||||
onToggleFavorite: { _ in },
|
||||
onShowFingerprint: { _ in }
|
||||
)
|
||||
)
|
||||
|
||||
#expect(viewModel.hasUnreadMessages(for: blockedPeer))
|
||||
}
|
||||
|
||||
@Test
|
||||
func commandSuggestionsAndLocationViews_render() {
|
||||
let (viewModel, _, _) = makeSmokeViewModel()
|
||||
let channel = GeohashChannel(level: .city, geohash: "u4pruy")
|
||||
var messageText = "/f"
|
||||
|
||||
LocationChannelManager.shared.select(.location(channel))
|
||||
|
||||
_ = mount(
|
||||
CommandSuggestionsView(
|
||||
messageText: Binding(
|
||||
get: { messageText },
|
||||
set: { messageText = $0 }
|
||||
),
|
||||
textColor: .green,
|
||||
backgroundColor: .black,
|
||||
secondaryTextColor: .gray
|
||||
)
|
||||
.environmentObject(viewModel)
|
||||
)
|
||||
|
||||
_ = mount(
|
||||
LocationChannelsSheet(isPresented: .constant(true))
|
||||
.environmentObject(viewModel)
|
||||
)
|
||||
|
||||
#expect(messageText == "/f")
|
||||
LocationChannelManager.shared.select(.mesh)
|
||||
LocationChannelManager.shared.endLiveRefresh()
|
||||
}
|
||||
|
||||
@Test
|
||||
func locationNotesView_rendersNoRelayAndLoadedStates() throws {
|
||||
let (viewModel, _, _) = makeSmokeViewModel()
|
||||
|
||||
let noRelayManager = LocationNotesManager(
|
||||
geohash: "u4pruydq",
|
||||
dependencies: LocationNotesDependencies(
|
||||
relayLookup: { _, _ in [] },
|
||||
subscribe: { _, _, _, _, _ in },
|
||||
unsubscribe: { _ in },
|
||||
sendEvent: { _, _ in },
|
||||
deriveIdentity: { _ in try NostrIdentity.generate() },
|
||||
now: { Date() }
|
||||
)
|
||||
)
|
||||
|
||||
var noteHandler: ((NostrEvent) -> Void)?
|
||||
var eose: (() -> Void)?
|
||||
let loadedManager = LocationNotesManager(
|
||||
geohash: "u4pruydq",
|
||||
dependencies: LocationNotesDependencies(
|
||||
relayLookup: { _, _ in ["wss://relay.one"] },
|
||||
subscribe: { _, _, _, handler, onEOSE in
|
||||
noteHandler = handler
|
||||
eose = onEOSE
|
||||
},
|
||||
unsubscribe: { _ in },
|
||||
sendEvent: { _, _ in },
|
||||
deriveIdentity: { _ in try NostrIdentity.generate() },
|
||||
now: { Date() }
|
||||
)
|
||||
)
|
||||
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = try NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
tags: [["g", "u4pruydq"], ["n", "Builder"]],
|
||||
content: "hello from a note"
|
||||
).sign(with: identity.schnorrSigningKey())
|
||||
noteHandler?(event)
|
||||
eose?()
|
||||
|
||||
_ = mount(
|
||||
LocationNotesView(geohash: "u4pruydq", manager: noRelayManager)
|
||||
.environmentObject(viewModel)
|
||||
)
|
||||
_ = mount(
|
||||
LocationNotesView(geohash: "u4pruydq", manager: loadedManager)
|
||||
.environmentObject(viewModel)
|
||||
)
|
||||
|
||||
#expect(loadedManager.notes.count == 1)
|
||||
#expect(noRelayManager.state == .noRelays)
|
||||
}
|
||||
|
||||
@Test
|
||||
func appInfoAndComponentViews_render() {
|
||||
let feature = AppInfoFeatureInfo(
|
||||
icon: "lock.fill",
|
||||
title: "app_info.privacy.title",
|
||||
description: "app_info.features.encryption.description"
|
||||
)
|
||||
|
||||
let appInfo = AppInfoView()
|
||||
let header = SectionHeader("app_info.features.title")
|
||||
let featureRow = FeatureRow(info: feature)
|
||||
let paymentCashu = PaymentChipView(paymentType: .cashu("cashuA_test-token"))
|
||||
let paymentLightning = PaymentChipView(paymentType: .lightning("lightning:lnbc1test"))
|
||||
|
||||
_ = appInfo.body
|
||||
_ = header.body
|
||||
_ = featureRow.body
|
||||
_ = paymentCashu.body
|
||||
_ = paymentLightning.body
|
||||
_ = DeliveryStatusView(status: .sending).body
|
||||
_ = DeliveryStatusView(status: .sent).body
|
||||
_ = DeliveryStatusView(status: .delivered(to: "Alice", at: Date())).body
|
||||
_ = DeliveryStatusView(status: .read(by: "Alice", at: Date())).body
|
||||
_ = DeliveryStatusView(status: .failed(reason: "offline")).body
|
||||
_ = DeliveryStatusView(status: .partiallyDelivered(reached: 2, total: 3)).body
|
||||
_ = mount(appInfo)
|
||||
_ = mount(header)
|
||||
_ = mount(featureRow)
|
||||
_ = mount(paymentCashu)
|
||||
_ = mount(paymentLightning)
|
||||
|
||||
#expect(PaymentChipView.PaymentType.cashu("cashuA_test-token").url?.scheme == "cashu")
|
||||
#expect(PaymentChipView.PaymentType.cashu("https://example.com/cashu").url?.absoluteString == "https://example.com/cashu")
|
||||
#expect(PaymentChipView.PaymentType.lightning("lightning:lnbc1test").url?.scheme == "lightning")
|
||||
}
|
||||
|
||||
@Test
|
||||
func geohashAndTextMessageViews_renderCoreBranches() {
|
||||
let (viewModel, _, _) = makeSmokeViewModel()
|
||||
let geohashPeopleList = GeohashPeopleList(
|
||||
viewModel: viewModel,
|
||||
textColor: .green,
|
||||
secondaryTextColor: .gray,
|
||||
onTapPerson: {}
|
||||
)
|
||||
let truncatableMessage = BitchatMessage(
|
||||
sender: viewModel.nickname,
|
||||
content: String(repeating: "verylongtoken ", count: 160),
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: false,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
let paymentMessage = BitchatMessage(
|
||||
sender: viewModel.nickname,
|
||||
content: "lightning:lnbc1test cashuA_test-token",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Bob",
|
||||
deliveryStatus: .partiallyDelivered(reached: 1, total: 2)
|
||||
)
|
||||
|
||||
_ = geohashPeopleList.body
|
||||
_ = mount(geohashPeopleList)
|
||||
_ = mount(TextMessageView(message: truncatableMessage).environmentObject(viewModel))
|
||||
_ = mount(TextMessageView(message: paymentMessage).environmentObject(viewModel))
|
||||
|
||||
#expect(truncatableMessage.content.count > TransportConfig.uiLongMessageLengthThreshold)
|
||||
#expect(paymentMessage.content.contains("lightning:") && paymentMessage.content.contains("cashu"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func voiceAndMediaViews_renderAndWarmCaches() async throws {
|
||||
let audioURL = try makeTemporaryAudioURL()
|
||||
let imageURL = try makeTemporaryImageURL()
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: audioURL)
|
||||
try? FileManager.default.removeItem(at: imageURL)
|
||||
WaveformCache.shared.purge(url: audioURL)
|
||||
}
|
||||
|
||||
let waveformView = WaveformView(
|
||||
samples: [0.1, 0.6, 0.3, 0.8],
|
||||
playbackProgress: 0.25,
|
||||
sendProgress: 0.75,
|
||||
onSeek: nil,
|
||||
isInteractive: false
|
||||
)
|
||||
let imageView = BlockRevealImageView(
|
||||
url: imageURL,
|
||||
revealProgress: 0.5,
|
||||
isSending: true,
|
||||
onCancel: {},
|
||||
initiallyBlurred: true,
|
||||
onOpen: {},
|
||||
onDelete: {}
|
||||
)
|
||||
let voiceNoteView = VoiceNoteView(
|
||||
url: audioURL,
|
||||
isSending: true,
|
||||
sendProgress: 0.4,
|
||||
onCancel: {}
|
||||
)
|
||||
let playback = VoiceNotePlaybackController(url: audioURL)
|
||||
|
||||
_ = waveformView.body
|
||||
_ = imageView.body
|
||||
_ = mount(waveformView)
|
||||
_ = mount(imageView)
|
||||
_ = mount(voiceNoteView)
|
||||
|
||||
let bins = await withCheckedContinuation { continuation in
|
||||
WaveformCache.shared.waveform(for: audioURL, bins: 16) { values in
|
||||
continuation.resume(returning: values)
|
||||
}
|
||||
}
|
||||
playback.loadDuration()
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
playback.seek(to: 1.25)
|
||||
playback.stop()
|
||||
VoiceNotePlaybackCoordinator.shared.activate(playback)
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(playback)
|
||||
await VoiceRecorder.shared.cancelRecording()
|
||||
|
||||
#expect(bins.count == 16)
|
||||
#expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16)
|
||||
#expect(playback.duration > 0)
|
||||
#expect(playback.progress == 0)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
@Test
|
||||
func cameraScannerView_previewAndCoordinatorSmoke() {
|
||||
let preview = CameraScannerView.PreviewView(frame: .zero)
|
||||
let coordinator = CameraScannerView.Coordinator()
|
||||
|
||||
_ = CameraScannerView.PreviewView.layerClass
|
||||
_ = preview.videoPreviewLayer
|
||||
coordinator.setup(sessionOwner: preview) { _ in }
|
||||
coordinator.setActive(false)
|
||||
|
||||
#expect(preview.videoPreviewLayer.videoGravity == .resizeAspectFill)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Vendored
+294
-261
@@ -1,271 +1,304 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
relay.angor.io,48.1046,11.6002
|
||||
black.nostrcity.club,48.8566,2.35222
|
||||
relay.damus.io,43.6532,-79.3832
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
relay.nuts.cash,34.0362,-118.443
|
||||
lightning.red,53.3498,-6.26031
|
||||
relay.davidebtc.me,43.6532,-79.3832
|
||||
nostr-02.czas.top,51.2277,6.77346
|
||||
inbox.azzamo.net,52.2633,21.0283
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
nostr.21crypto.ch,47.5356,8.73209
|
||||
r.bitcoinhold.net,43.6532,-79.3832
|
||||
nostr.self-determined.de,54.5847,10.0178
|
||||
relay.fr13nd5.com,52.5233,13.3426
|
||||
wons.calva.dev,37.3986,-121.964
|
||||
pyramid-relay.meows.lol,38.7475,-77.5317
|
||||
relay.pinseekr.golf,43.6532,-79.3832
|
||||
nostr-relay.online,43.6532,-79.3832
|
||||
relay.evanverma.com,40.8302,-74.1299
|
||||
relay.nostr.wirednet.jp,34.706,135.493
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
ynostr.yael.at,60.1699,24.9384
|
||||
testrelay.era21.space,43.6532,-79.3832
|
||||
nostr.islandarea.net,35.4669,-97.6473
|
||||
purpura.cloud,43.6532,-79.3832
|
||||
testnet-relay.samt.st,40.8302,-74.1299
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
nostr.jerrynya.fun,31.2304,121.474
|
||||
relay.bitcoindistrict.org,43.6532,-79.3832
|
||||
nostr.ps1829.com,33.8851,130.883
|
||||
nostr-2.21crypto.ch,47.5356,8.73209
|
||||
wheat.happytavern.co,43.6532,-79.3832
|
||||
plebchain.club,43.6532,-79.3832
|
||||
relay2.ngengine.org,43.6532,-79.3832
|
||||
relay01.lnfi.network,39.0997,-94.5786
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
relay.21e6.cz,50.7383,15.0648
|
||||
relay.erybody.com,41.4513,-81.7021
|
||||
nostr.commonshub.brussels,49.4543,11.0746
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
relay.ru.ac.th,13.7607,100.627
|
||||
relay.threenine.services,51.5524,-0.29686
|
||||
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
|
||||
nostr.zoracle.org,45.6018,-121.185
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
relay.satlantis.io,32.8769,-80.0114
|
||||
relay.malxte.de,52.52,13.405
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
nostr-relay.zimage.com,34.0549,-118.243
|
||||
schnorr.me,43.6532,-79.3832
|
||||
relay.nostr.place,32.7767,-96.797
|
||||
shu05.shugur.net,48.8566,2.35222
|
||||
nostrja-kari.heguro.com,43.6532,-79.3832
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
|
||||
nostr-relayrs.gateway.in.th,15.2634,100.344
|
||||
relay.nostrverse.net,43.6532,-79.3832
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
relay.bitcoinartclock.com,50.4754,12.3683
|
||||
0m0sef4nb45q6.cloreai.ru,43.6532,-79.3832
|
||||
relay.btcforplebs.com,43.6532,-79.3832
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
nostrcheck.tnsor.network,43.6532,-79.3832
|
||||
nostr-relay.amethyst.name,39.0438,-77.4874
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.jeffg.fyi,43.6532,-79.3832
|
||||
relay.seq1.net,43.6532,-79.3832
|
||||
relay.lumina.rocks,49.0291,8.35695
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
nostr.red5d.dev,43.6532,-79.3832
|
||||
slick.mjex.me,39.048,-77.4817
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
relay-freeharmonypeople.space,38.7223,-9.13934
|
||||
relay.routstr.com,43.6532,-79.3832
|
||||
garden.zap.cooking,40.9481,-79.7428
|
||||
relay.nostrops.com,33.7584,-84.6375
|
||||
spookstr2.nostr1.com,40.7128,-74.006
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
relay.coinos.io,43.6532,-79.3832
|
||||
shu02.shugur.net,21.4902,39.2246
|
||||
nostr.night7.space,50.4754,12.3683
|
||||
nostr.casa21.space,43.6532,-79.3832
|
||||
nostr.bgbitcoin.club,50.4754,12.3683
|
||||
strfry.felixzieger.de,50.1013,8.62643
|
||||
wot.brightbolt.net,47.6735,-116.781
|
||||
freelay.sovbit.host,64.1476,-21.9392
|
||||
relay-dev.satlantis.io,40.8302,-74.1299
|
||||
relay.vantis.ninja,43.6532,-79.3832
|
||||
relay.cyphernomad.com,60.1699,24.9384
|
||||
relay.thebluepulse.com,49.4521,11.0767
|
||||
fenrir-s.notoshi.win,43.6532,-79.3832
|
||||
nostr.rblb.it,43.7094,10.6582
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
wot.sebastix.social,51.1792,5.89444
|
||||
wotr.relatr.xyz,53.3498,-6.26031
|
||||
nostr.mehdibekhtaoui.com,49.4939,-1.54813
|
||||
relay.snort.social,53.3498,-6.26031
|
||||
librerelay.aaroniumii.com,43.6532,-79.3832
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||
adre.su,59.8845,30.3184
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
dev-relay.lnfi.network,39.0997,-94.5786
|
||||
nostr-03.dorafactory.org,1.35208,103.82
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
ragnar.nostrops.com,33.7946,-84.4488
|
||||
kitchen.zap.cooking,43.6532,-79.3832
|
||||
nos4smartnkind.tech,40.1872,44.5152
|
||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
||||
relay.origin.land,35.6673,139.751
|
||||
new.orly.dev,55.9349,23.3137
|
||||
strfry.ymir.cloud,34.0965,-117.585
|
||||
ribo.eu.nostria.app,52.3676,4.90414
|
||||
nostr.bilthon.dev,25.7975,-80.23
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
yabu.me,35.6092,139.73
|
||||
nostr.blankfors.se,60.1699,24.9384
|
||||
relay.guggero.org,47.3769,8.54169
|
||||
shu03.shugur.net,25.2048,55.2708
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
r.alphaama.com,60.1699,24.9384
|
||||
nostr.mom,50.4754,12.3683
|
||||
nostr.computingcache.com,34.0356,-118.442
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
nostr.faultables.net,43.6532,-79.3832
|
||||
relay.internationalright-wing.org,-22.5022,-48.7114
|
||||
nostr.bitczat.pl,60.1699,24.9384
|
||||
relay.endfiat.money,43.6532,-79.3832
|
||||
nostr.luisschwab.net,43.6532,-79.3832
|
||||
nostr-relay.gateway.in.th,15.2634,100.344
|
||||
nostr-02.yakihonne.com,1.32123,103.695
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
wot.nostr.place,32.7767,-96.797
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
czas.xyz,48.8566,2.35222
|
||||
premium.primal.net,43.6532,-79.3832
|
||||
pyramid.aaro.cc,32.7767,-96.797
|
||||
purplerelay.com,50.1109,8.68213
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
relay.agora.social,50.7383,15.0648
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
relay.nostrzh.org,43.6532,-79.3832
|
||||
nostr.n7ekb.net,47.4941,-122.294
|
||||
relay.fountain.fm,43.6532,-79.3832
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
nostr.mifen.me,43.6532,-79.3832
|
||||
relay.anmore.me,49.704,-124.918
|
||||
kotukonostr.onrender.com,37.7775,-122.397
|
||||
relay.0xchat.com,1.35208,103.82
|
||||
bitsat.molonlabe.holdings,51.4012,-1.3147
|
||||
relay.layer.systems,49.0291,8.35695
|
||||
api.freefrom.space/v1/ws,43.6532,-79.3832
|
||||
relay.nostrdice.com,-33.8688,151.209
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
bbw-nostr.xyz,41.5284,-87.4237
|
||||
relay.npubhaus.com,43.6532,-79.3832
|
||||
relay.artx.market,43.6532,-79.3832
|
||||
nostr.snowbla.de,60.1699,24.9384
|
||||
relay.bnos.space,43.6532,-79.3832
|
||||
wot.nostr.party,36.1512,-86.7835
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
strfry.openhoofd.nl,51.9229,4.40833
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
relay.chakany.systems,43.6532,-79.3832
|
||||
nostr.thaliyal.com,40.8218,-74.45
|
||||
relay.ryzizub.com,43.6532,-79.3832
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
bcast.seutoba.com.br,43.6532,-79.3832
|
||||
nostrelay.circum.space,52.3676,4.90414
|
||||
relay.janx.com,41.2054,-76.0049
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
relay.toastr.net,40.8054,-74.0241
|
||||
relay.puresignal.news,43.6532,-79.3832
|
||||
relay.magiccity.live,25.7975,-80.23
|
||||
satsage.xyz,37.3986,-121.964
|
||||
nostr.now,36.55,139.733
|
||||
offchain.pub,47.6743,-117.112
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
nostr.chaima.info,50.1109,8.68213
|
||||
freeben666.fr,43.7221,7.15296
|
||||
relay.siamdev.cc,13.8434,100.363
|
||||
wot.nostr.net,43.6532,-79.3832
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
relay.ngengine.org,43.6532,-79.3832
|
||||
relay.tagayasu.xyz,45.4215,-75.6972
|
||||
wot.yesnostr.net,50.9871,2.12554
|
||||
relay.wavefunc.live,34.0362,-118.443
|
||||
relay.snotr.nl:49999,52.0067,4.35556
|
||||
nostr.dler.com,25.0501,121.565
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
relay.comcomponent.com,34.7062,135.493
|
||||
relay.nosto.re,51.1792,5.89444
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
wot.sudocarlos.com,43.6532,-79.3832
|
||||
nostr.robosats.org,64.1476,-21.9392
|
||||
strfry.elswa-dev.online,50.1109,8.68213
|
||||
nos.lol,50.4754,12.3683
|
||||
aaa-api.freefrom.space/v1/ws,43.6532,-79.3832
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
prl.plus,42.6978,23.3246
|
||||
relay.illuminodes.com,47.6061,-122.333
|
||||
srtrelay.c-stellar.net,43.6532,-79.3832
|
||||
nostrelites.org,41.8781,-87.6298
|
||||
nostr.na.social,43.6532,-79.3832
|
||||
nostr.tadryanom.me,43.6532,-79.3832
|
||||
okn.czas.plus,50.1109,8.68213
|
||||
relay03.lnfi.network,39.0997,-94.5786
|
||||
cyberspace.nostr1.com,40.7128,-74.006
|
||||
notemine.io,52.2026,20.9397
|
||||
wot.sovbit.host,64.1466,-21.9426
|
||||
santo.iguanatech.net,40.8302,-74.1299
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
relay.chorus.community,50.1109,8.68213
|
||||
wot.codingarena.top,50.4754,12.3683
|
||||
relay.orangepill.ovh,49.1689,-0.358841
|
||||
wot.dtonon.com,43.6532,-79.3832
|
||||
nostr.parallel.hetu.org:8443,1.35208,103.82
|
||||
discovery.eu.nostria.app,52.3676,4.90414
|
||||
nostrcheck.me,43.6532,-79.3832
|
||||
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||
espelho.girino.org,43.6532,-79.3832
|
||||
nostr.superfriends.online,43.6532,-79.3832
|
||||
relay.keykeeper.world,40.7824,-74.0711
|
||||
offchain.pub,39.1585,-94.5728
|
||||
relay.veganostr.com,60.1699,24.9384
|
||||
strfry.atlantislabs.space,43.6532,-79.3832
|
||||
relay.plebeian.market,50.1109,8.68213
|
||||
kotukonostr.onrender.com,37.7775,-122.397
|
||||
rilo.nostria.app,43.6532,-79.3832
|
||||
nostr.2b9t.xyz,34.0549,-118.243
|
||||
nostr.ovia.to,43.6532,-79.3832
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
nostr-relay.corb.net,38.8353,-104.822
|
||||
relay.credenso.cafe,43.3601,-80.3127
|
||||
relayone.soundhsa.com,33.1384,-95.6011
|
||||
relay.notoshi.win,14.2046,101.213
|
||||
plebchain.club,43.6532,-79.3832
|
||||
nostr-relay.zeabur.app,25.0797,121.234
|
||||
relay.trustr.ing,49.8667,-125.133
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
lightning.red,53.3498,-6.26031
|
||||
nostr.mikoshi.de,50.1109,8.68213
|
||||
syb.lol,34.0549,-118.243
|
||||
relay.ngengine.org,43.6532,-79.3832
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
relay.openfarmtools.org,60.1699,24.9384
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
relay.bithome.site,52.3563,4.95714
|
||||
relay.puresignal.news,43.6532,-79.3832
|
||||
strfry.elswa-dev.online,50.1109,8.68213
|
||||
relay.nyves.nl,51.1792,5.89444
|
||||
strfry.apps3.slidestr.net,40.4167,-3.70329
|
||||
social.amanah.eblessing.co,48.1046,11.6002
|
||||
inbox.mycelium.social,38.627,-90.1994
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
nostr.rblb.it,43.7094,10.6582
|
||||
testrelay.era21.space,43.6532,-79.3832
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
nostr.mom,50.4754,12.3683
|
||||
relay.ohstr.com,43.6532,-79.3832
|
||||
nostr.ac,38.958,-77.3592
|
||||
relay.toastr.net,40.8054,-74.0241
|
||||
nostr.bitcoiner.social,47.6743,-117.112
|
||||
nostr.carroarmato0.be,51.0368,3.21186
|
||||
nostrbtc.com,43.6532,-79.3832
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
relay.notoshi.win,13.966,101.513
|
||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
nrs-02.darkcloudarcade.com,39.9526,-75.1652
|
||||
ephemeral.snowflare.cc,43.6532,-79.3832
|
||||
nostr.chrissexton.org,43.6532,-79.3832
|
||||
nostr.pbfs.io,50.4754,12.3683
|
||||
strfry.bonsai.com,37.8716,-122.273
|
||||
nostr-server-production.up.railway.app,45.5019,-73.5674
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
|
||||
librerelay.aaroniumii.com,43.6532,-79.3832
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
nostr.rblb.it:7777,43.7094,10.6582
|
||||
nostriches.club,43.6532,-79.3832
|
||||
purplerelay.com,43.6532,-79.3832
|
||||
relay.earthly.city,34.0362,-118.443
|
||||
wot.rejecttheframe.xyz,43.6532,-79.3832
|
||||
relay.olas.app,50.4754,12.3683
|
||||
nostr.mikoshi.de,47.8786,11.911
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
nostr.noones.com,50.1109,8.68213
|
||||
nostr.4rs.nl,49.0291,8.35696
|
||||
strfry.bonsai.com,37.8715,-122.273
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
relay.contextvm.org,53.3498,-6.26031
|
||||
relay.westernbtc.com,44.5401,-123.368
|
||||
relay.binaryrobot.com,43.6532,-79.3832
|
||||
nostr.huszonegy.world,47.4979,19.0402
|
||||
nostr.thalheim.io,60.1699,24.9384
|
||||
nostr.islandarea.net,35.4669,-97.6473
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
nostr-relay.zimage.com,34.0549,-118.243
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
|
||||
relay.devcsu.fr,48.8575,2.35138
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
inbox.scuba323.com,40.8218,-74.45
|
||||
ribo.us.nostria.app,43.6532,-79.3832
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
ms.chinacounty.com,39.9042,116.407
|
||||
relay.0xchat.com,43.6532,-79.3832
|
||||
nostr.twinkle.lol,51.902,7.6657
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
soloco.nl,43.6532,-79.3832
|
||||
relay.nostar.org,43.6532,-79.3832
|
||||
nostr.lkjsxc.com,43.6532,-79.3832
|
||||
theoutpost.life,64.1476,-21.9392
|
||||
relay.javi.space,43.4633,11.8796
|
||||
khatru.nostrver.se,51.1792,5.89444
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
bitcoiner.social,47.6743,-117.112
|
||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||
ribo.nostria.app,43.6532,-79.3832
|
||||
nostr.bgbitcoin.club,50.4754,12.3683
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
okn.czas.plus,50.1109,8.68213
|
||||
nostr-relay.corb.net,38.8353,-104.822
|
||||
nostr.robosats.org,64.1476,-21.9392
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
r.bitcoinhold.net,43.6532,-79.3832
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
relay.nostr-check.me,43.6532,-79.3832
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
relay.layer.systems,49.0291,8.35695
|
||||
premium.primal.net,43.6532,-79.3832
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
kasztanowa.bieda.it,43.6532,-79.3832
|
||||
relay.qstr.app,50.1109,8.68213
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
relay.paulstephenborile.com,49.4543,11.0746
|
||||
herbstmeister.com,34.0549,-118.243
|
||||
cache.trustr.ing,45.4473,-73.7533
|
||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||
relay.lab.rytswd.com,49.4543,11.0746
|
||||
strfry.bitsbytom.com,51.4968,-0.018337
|
||||
relay.btcforplebs.com,43.6532,-79.3832
|
||||
relay.snotr.nl:49999,52.0195,4.42946
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
relay.klabo.world,47.2343,-119.853
|
||||
relay.kubo.watch,51.1792,5.89444
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
blossom.gnostr.cloud,43.6532,-79.3832
|
||||
nostr.myshosholoza.co.za,52.3913,4.66545
|
||||
relay.bitmacro.cloud,43.6532,-79.3832
|
||||
relay.nostrverse.net,43.6532,-79.3832
|
||||
relay.nostrcheck.me,43.6532,-79.3832
|
||||
nostr.bond,50.1109,8.68213
|
||||
relay.cypherflow.ai,48.8566,2.35222
|
||||
nostr.luisschwab.net,43.6532,-79.3832
|
||||
relay.bikel.ink,60.1699,24.9384
|
||||
nostr.zoracle.org,45.6018,-121.185
|
||||
nostr.bitczat.pl,60.1699,24.9384
|
||||
relay.angor.io,48.1046,11.6002
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
relay.endfiat.money,59.3327,18.0656
|
||||
santo.iguanatech.net,40.8302,-74.1299
|
||||
nostr.88mph.life,52.1941,-2.21905
|
||||
nostr-03.dorafactory.org,1.35208,103.82
|
||||
relay01.lnfi.network,35.6764,139.65
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
npub1spxdug4m3y24hpx5crm0el4zhkk0wafs8kp6m0xu0wecygqej2xqq8gyhx.fips.network,43.6532,-79.3832
|
||||
nrs-01.darkcloudarcade.com,39.1008,-94.5811
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
ynostr.yael.at,60.1699,24.9384
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
relay.homeinhk.xyz,35.694,139.754
|
||||
relay.bnos.space,43.6532,-79.3832
|
||||
nittom.nostr1.com,38.6327,-90.1961
|
||||
relay.openresist.com,43.6532,-79.3832
|
||||
relay.mmwaves.de,48.8575,2.35138
|
||||
relay.artx.market,43.6548,-79.3885
|
||||
relay.lanavault.space,60.1699,24.9384
|
||||
eu.nostr.pikachat.org,49.4543,11.0746
|
||||
relay.satnam.pub,43.6532,-79.3832
|
||||
relay.keykeeper.world,40.7824,-74.0711
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
nostr.aruku.kro.kr,37.3589,127.115
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
relay.camelus.app,45.5201,-122.99
|
||||
purpura.cloud,43.6532,-79.3832
|
||||
relay.malxte.de,52.52,13.405
|
||||
relay.bao.network,43.6532,-79.3832
|
||||
relay.nostu.be,40.4167,-3.70329
|
||||
relay.bitcoindistrict.org,43.6532,-79.3832
|
||||
relay5.bitransfer.org,43.6532,-79.3832
|
||||
relay.tapestry.ninja,40.8054,-74.0241
|
||||
relay.edufeed.org,49.4521,11.0767
|
||||
relayone.soundhsa.com,39.1008,-94.5811
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
myvoiceourstory.org,37.3598,-121.981
|
||||
nostr.4rs.nl,49.0291,8.35696
|
||||
fanfares.nostr1.com,38.6327,-90.1961
|
||||
freelay.sovbit.host,64.1476,-21.9392
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
seed-options-few-cache.trycloudflare.com,43.6532,-79.3832
|
||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||
satsage.xyz,37.3986,-121.964
|
||||
relay.satlantis.io,32.8769,-80.0114
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
nostr.nadajnik.org,50.1109,8.68213
|
||||
nostr.na.social,43.6532,-79.3832
|
||||
wot.sudocarlos.com,43.6532,-79.3832
|
||||
relay.cypherflow.ai,48.8575,2.35138
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
relay.erybody.com,41.4513,-81.7021
|
||||
relay.agora.social,50.7383,15.0648
|
||||
nos.lol,50.4754,12.3683
|
||||
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
||||
relay.fckstate.net,59.3293,18.0686
|
||||
relay.bornheimer.app,51.5072,-0.127586
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
nostr.snowbla.de,60.1699,24.9384
|
||||
relay.jeffg.fyi,43.6532,-79.3832
|
||||
nostrcheck.me,43.6532,-79.3832
|
||||
bcast.seutoba.com.br,43.6532,-79.3832
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
nostr.dlcdevkit.com,40.0992,-83.1141
|
||||
nostr.n7ekb.net,36.1527,-95.9902
|
||||
cs-relay.nostrdev.com,50.4754,12.3683
|
||||
ribo.eu.nostria.app,43.6532,-79.3832
|
||||
nostr.agentcampfire.com,52.3676,4.90414
|
||||
wot.nostr.place,32.7767,-96.797
|
||||
relay.anmore.me,49.281,-123.117
|
||||
relay.mostr.pub,43.6532,-79.3832
|
||||
adre.su,59.9311,30.3609
|
||||
nostr.self-determined.de,53.495,10.2542
|
||||
nostr.wecsats.io,43.6532,-79.3832
|
||||
theoutpost.life,64.1476,-21.9392
|
||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||
nostr.chaima.info,50.1109,8.68213
|
||||
r.0kb.io,32.789,-96.7989
|
||||
nostrue.com,40.8054,-74.0241
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
relay.guggero.org,46.5971,9.59652
|
||||
us-east.nostr.pikachat.org,39.0438,-77.4874
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
nostrride.io,37.3986,-121.964
|
||||
relay.lacompagniemaximus.com,45.3147,-73.8785
|
||||
nostrelay.circum.space,52.3676,4.90414
|
||||
nostr.dpinkerton.com,39.1008,-94.5811
|
||||
nexus.libernet.app,43.6532,-79.3832
|
||||
dev.relay.edufeed.org,49.4521,11.0767
|
||||
nostr.defucc.me,50.1109,8.68213
|
||||
relay.npubhaus.com,43.6532,-79.3832
|
||||
strfry.openhoofd.nl,51.9229,4.40833
|
||||
nostr-relay.online,43.6532,-79.3832
|
||||
dev.relay.stream,43.6532,-79.3832
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
relay.illuminodes.com,47.6061,-122.333
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
wot.shaving.kiwi,43.6532,-79.3832
|
||||
relay.agentry.com,42.8864,-78.8784
|
||||
bitchat.nostr1.com,38.6327,-90.1961
|
||||
relay.nostrian-conquest.com,41.223,-111.974
|
||||
no.str.cr,10.074,-84.2155
|
||||
nostr.blankfors.se,60.1699,24.9384
|
||||
bridge.tagomago.me,3.139,101.687
|
||||
nostr.ps1829.com,33.8851,130.883
|
||||
wot.nostr.party,36.1627,-86.7816
|
||||
relay.threenine.services,51.5222,-0.62916
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
relay.trotters.cc,43.6532,-79.3832
|
||||
nos.xmark.cc,50.6924,3.20113
|
||||
relay2.ngengine.org,43.6532,-79.3832
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
relay.solife.me,43.6532,-79.3832
|
||||
relay.staging.commonshub.brussels,49.4543,11.0746
|
||||
orly-relay.imwald.eu,48.8575,2.35138
|
||||
prl.plus,55.7628,37.5983
|
||||
nostr.tadryanom.me,43.6532,-79.3832
|
||||
nstr.a0a1.space,52.3563,4.95714
|
||||
nostr.bond,50.1109,8.68213
|
||||
relay.islandbitcoin.com,12.8498,77.6545
|
||||
yabu.me,35.6092,139.73
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
relay.shadowbip.com,50.1109,8.68213
|
||||
slick.mjex.me,39.0418,-77.4744
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
nostr.night7.space,50.4754,12.3683
|
||||
relay2.liberbitworld.org,22.3193,114.169
|
||||
testnet-relay.samt.st,40.8302,-74.1299
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
dev.architectcontext.aarpia.net,51.5222,-0.62916
|
||||
nostr.aruku.ovh,1.27994,103.849
|
||||
relay.nostrdice.com,-33.8688,151.209
|
||||
nostr.liberty.fans,36.9104,-89.5875
|
||||
testr.nymble.world,40.8054,-74.0241
|
||||
relay.gulugulu.moe,43.6532,-79.3832
|
||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
||||
relay.flashapp.me,43.6548,-79.3885
|
||||
relay.dreamith.to,43.6532,-79.3832
|
||||
relay-nl.zombi.cloudrodion.com,50.8943,6.06237
|
||||
nostrcity-club.fly.dev,48.8566,2.35222
|
||||
srtrelay.c-stellar.net,43.6532,-79.3832
|
||||
relay.sharegap.net,43.6532,-79.3832
|
||||
relay.nostrzh.org,43.6532,-79.3832
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.nostriches.club,43.6532,-79.3832
|
||||
nostr.red5d.dev,43.6532,-79.3832
|
||||
relay.purplefrog.cloud,35.6916,139.768
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
relay-dev.satlantis.io,40.8302,-74.1299
|
||||
speakeasy.cellar.social,49.4543,11.0746
|
||||
relay.seq1.net,43.6532,-79.3832
|
||||
relay-freeharmonypeople.space,38.7223,-9.13934
|
||||
top.testrelay.top,43.6532,-79.3832
|
||||
relay.nostr.place,32.7767,-96.797
|
||||
holland-excited-charming-experiencing.trycloudflare.com,43.6532,-79.3832
|
||||
relay.cyphernomad.com,60.1699,24.9384
|
||||
relay.decentnewsroom.com,50.4754,12.3683
|
||||
spookstr2.nostr1.com,38.6327,-90.1961
|
||||
relay.wavefunc.live,41.8781,-87.6298
|
||||
wot.dtonon.com,43.6532,-79.3832
|
||||
nostr.simplex.icu,50.1109,8.68213
|
||||
relay.typedcypher.com,51.5072,-0.127586
|
||||
relay.bebond.net,43.6532,-79.3832
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
0x-nostr-relay.fly.dev,48.8566,2.35222
|
||||
relay.samcorn.synology.me,39.2904,-76.6122
|
||||
|
||||
|
Reference in New Issue
Block a user