Merge remote-tracking branch 'origin/main' into harden/radio-metadata

# Conflicts:
#	bitchat/Services/BLE/BLEService.swift
This commit is contained in:
jack 2026-07-31 15:56:29 +01:00
commit 4a2e06bac0
123 changed files with 6524 additions and 2887 deletions

View File

@ -1 +1 @@
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC18logBluetoothStatusyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat18BLERadioControllerC14candidateCountSivp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}

View File

@ -26,7 +26,7 @@ check-clean-safety:
check: check-clean-safety
@echo "Checking prerequisites..."
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install full Xcode." && exit 1)
@developer_dir="$$(xcode-select -p 2>/dev/null)"; case "$$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac
@developer_dir="$(xcode-select -p 2>/dev/null)"; case "$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac
@xcodebuild -version
@echo "✅ Development environment ready (a signing identity is not required for just build)"
@ -35,7 +35,7 @@ build: check
@xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build
run: build
@app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$$app" || (echo "❌ Built app not found at $$app" && exit 1); open "$$app"
@app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$app" || (echo "❌ Built app not found at $app" && exit 1); open "$app"
# Backward-compatible alias for the old quick-run recipe.
dev-run: run

View File

@ -8,6 +8,8 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
📲 [Play Store](https://play.google.com/store/apps/details?id=com.bitchat.droid)
### Getting a copy you can trust
Install from the App Store, or build from source you have verified. A compiled build from anywhere else cannot be verified — see [Verifying bitchat](docs/VERIFYING-A-BUILD.md) for how to check source against the per-release hash manifest, and for what to do if that is the only build you can get.
@ -49,7 +51,7 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
- **Global Reach**: Connect with users worldwide via internet relays
- **Location Channels**: Geographic chat rooms using geohash coordinates
- **290+ Relay Network**: Distributed across the globe for reliability
- **440+ Relay Network**: Distributed across the globe for reliability
- **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area

View File

@ -94,7 +94,6 @@
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
bitchatShareExtension.entitlements,
);
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
};
@ -379,6 +378,11 @@
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
);
};
7E9B64F63F93443FB7BA12DF /* Resources */ = {
isa = PBXResourcesBuildPhase;
files = (
);
};
C5E027A42ECCDFD700BD6012 /* Resources */ = {
isa = PBXResourcesBuildPhase;
files = (
@ -395,13 +399,6 @@
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
);
};
7E9B64F63F93443FB7BA12DF /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */

View File

@ -85,7 +85,8 @@ final class AppChromeModel: ObservableObject {
/// neighbor claim but never announced to us) fall back to a short ID.
func meshTopologyDisplayModel() -> MeshTopologyDisplayModel {
let mesh = chatViewModel.meshService
guard let snapshot = mesh.currentMeshTopology() else { return .empty }
guard let diagnostics = mesh as? MeshDiagnosing,
let snapshot = diagnostics.currentMeshTopology() else { return .empty }
let nicknames = mesh.getPeerNicknames()
let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in

View File

@ -152,18 +152,23 @@ final class AppRuntime: ObservableObject {
NetworkActivationService.shared.start()
GeohashPresenceService.shared.start()
checkForSharedContent()
expireAgedMedia()
performMediaMaintenance()
record(.launched)
record(.startupCompleted)
}
/// Drops media that has outlived the retention window. Off the main thread
/// and best-effort: the sweep walks the media tree, and nothing at launch
/// depends on its result.
private func expireAgedMedia() {
Task(priority: .utility) {
BLEIncomingFileStore().expireAgedMedia()
/// Drops media that has outlived the retention window, then applies the
/// explicit protection class to files that older builds wrote without
/// one. Expiry runs first so the migration never touches files the
/// sweep is about to delete. Detached because `AppRuntime` is
/// main-actor and both passes go file by file through the media tree;
/// best-effort, nothing at launch depends on their results.
private func performMediaMaintenance() {
Task.detached(priority: .utility) {
let store = BLEIncomingFileStore()
store.expireAgedMedia()
store.migrateFileProtectionIfNeeded()
}
}

View File

@ -230,8 +230,7 @@ final class Conversation: ObservableObject, Identifiable {
// MARK: Internals
static func shouldSkipStatusUpdate(current: DeliveryStatus?, new: DeliveryStatus) -> Bool {
guard let current else { return false }
static func shouldSkipStatusUpdate(current: DeliveryStatus, new: DeliveryStatus) -> Bool {
if current == new { return true }
// Never downgrade to a weaker delivery state. Ordering of certainty:
@ -254,6 +253,10 @@ final class Conversation: ObservableObject, Identifiable {
return true
case (.sent, .sending):
return true
case (_, .notSentYet):
// .notSentYet is the pre-transport initial state; once a message
// has any real status, resetting to it is always a downgrade.
return true
default:
return false
}

View File

@ -36,6 +36,7 @@ final class LocationPresenceStore: ObservableObject {
return
}
let nickname = nickname.normalizedNickname
let key = pubkeyHex.lowercased()
if geoNicknames[key] != nil {
geoNicknames[key] = nickname
@ -64,7 +65,7 @@ final class LocationPresenceStore: ObservableObject {
let lower = key.lowercased()
guard seen.insert(lower).inserted else { continue }
ordered.append(lower)
normalized[lower] = value
normalized[lower] = value.normalizedNickname
}
if ordered.count > geoNicknameCapacity {
let kept = Array(ordered.suffix(geoNicknameCapacity))

View File

@ -206,7 +206,7 @@ enum ImageUtils {
} else {
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
return directory.appendingPathComponent(fileName)
}

View File

@ -244,7 +244,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
}
}

View File

@ -300,7 +300,7 @@ actor VoiceRecorder {
let baseDirectory = try outputDirectory
?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
return baseDirectory.appendingPathComponent(fileName)
}

View File

@ -663,7 +663,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func removeEphemeralSession(peerID: PeerID) {
queue.sync(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peerID)
_ = self.ephemeralSessions.removeValue(forKey: peerID)
}
}

View File

@ -24,7 +24,7 @@ extension BitchatMessage {
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)
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
self.filesDir = filesDir
} catch {
filesDir = nil

View File

@ -1028,6 +1028,27 @@ final class NoiseSessionManager {
.cancel()
}
#if DEBUG
/// Fires a pending suppressed-initiation recovery immediately instead of
/// waiting out the completion-grace timer, so tests can inject a grace
/// period too large to lose against a starved runner and still exercise
/// the recovery path deterministically.
func _test_fireSuppressedInitiationRecovery(for peerID: PeerID) {
managerQueue.sync(flags: .barrier) {
guard let pending = suppressedInitiationRecoveryTimeouts
.removeValue(forKey: peerID) else {
return
}
pending.cancel()
guard let current = sessions[peerID],
current.isEstablished() else {
return
}
requestHandshakeRecovery(for: peerID)
}
}
#endif
private func requestHandshakeRecovery(
for peerID: PeerID,
after delay: TimeInterval = 0

View File

@ -153,14 +153,18 @@ final class NostrRelayManager: ObservableObject {
// Built-in relays carry private-message envelopes, so avoid relays known to
// reject the kinds they use.
private static let builtInRelays = [
nonisolated private static let builtInRelays = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
"wss://offchain.pub"
// For local testing, you can add: "ws://localhost:8080"
]
private static let builtInRelaySet = Set(builtInRelays.compactMap { NostrRelayURL.normalized($0) })
/// Exposed so the relay settings UI can reject re-adding a built-in.
/// `nonisolated` because it is an immutable constant with no actor state.
nonisolated static let builtInRelayURLs = Set(
builtInRelays.compactMap { NostrRelayURL.normalized($0) }
)
/// The relays private messages target: the built-in set plus any added by
/// hand. Four hardcoded hostnames are four names for a censor to block, so
@ -182,10 +186,6 @@ final class NostrRelayManager: ObservableObject {
defaultRelaySet = Set(defaultRelays)
}
/// Exposed so the relay settings UI can reject re-adding a built-in.
/// `nonisolated` because it is an immutable constant with no actor state.
nonisolated static var builtInRelayURLs: Set<String> { builtInRelaySet }
@Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false
/// Whether a relay that carries private messages is connected. DMs

View File

@ -55,10 +55,10 @@ final class AutocompleteService {
let fullRange = match.range(at: 0)
let captureRange = match.range(at: 1)
let prefix = nsText.substring(with: captureRange).lowercased()
let prefix = nsText.substring(with: captureRange).normalizedNickname.lowercased()
let suggestions = peers
.filter { $0.lowercased().hasPrefix(prefix) }
.filter { $0.normalizedNickname.lowercased().hasPrefix(prefix) }
.sorted()
.prefix(5)
.map { "@\($0)" }

View File

@ -37,4 +37,13 @@ final class BLEAnnounceThrottle: @unchecked Sendable {
return true
}
}
/// Forgets the last-sent timestamp. A panic rotation calls this so the
/// new identity's first announce cannot be swallowed by the old
/// identity's throttle debt otherwise a panic within the forced
/// minimum interval of the last announce leaves the rotated identity
/// invisible until the next maintenance cycle.
func reset() {
lock.withLock { lastSent = .distantPast }
}
}

View File

@ -0,0 +1,39 @@
import Foundation
/// Schedules deferred engine work: relay jitter, announce delays, protocol
/// deadlines (ping, capability proof), notification retry backoff, and
/// fragment pacing.
///
/// This is the transport's only source of engine-side delay. Production
/// wraps the engine queue's `asyncAfter`; tests inject a manually advanced
/// clock so timer-driven behavior is asserted deterministically instead of
/// racing the wall clock product constants used as deadlines are exactly
/// the hidden-elapsed-deadline flake class the test-timing hygiene rules
/// exist to contain.
protocol BLEEngineScheduling: AnyObject {
/// Called once by the transport with its engine queue. Scheduled work
/// always executes there: deferred bodies touch engine-confined state.
func activate(engineQueue: DispatchQueue)
/// Runs `work` on the engine queue after `delay`, honoring
/// `DispatchWorkItem` cancellation.
func schedule(after delay: TimeInterval, execute work: DispatchWorkItem)
}
extension BLEEngineScheduling {
func schedule(after delay: TimeInterval, _ body: @escaping () -> Void) {
schedule(after: delay, execute: DispatchWorkItem(block: body))
}
}
/// Production scheduler: a thin veneer over the engine queue.
final class BLEEngineDispatchScheduler: BLEEngineScheduling {
private var queue: DispatchQueue?
func activate(engineQueue: DispatchQueue) {
queue = engineQueue
}
func schedule(after delay: TimeInterval, execute work: DispatchWorkItem) {
queue?.asyncAfter(deadline: .now() + delay, execute: work)
}
}

View File

@ -140,6 +140,20 @@ struct BLEIncomingFileStore: @unchecked Sendable {
/// orphans a previous session left behind.
static let liveCapturePrefix = "voice_live_"
/// Media payloads follow the same at-rest posture as the app's other
/// persistence layers (courier, outbox, receipt index): protected until
/// first unlock, so the launch-time retention sweep can still run after
/// a reboot. Applied to the media directories so recordings that save
/// as they go (live captures, `AVAudioRecorder`) inherit it, and stated
/// explicitly at the payload write site like every other store.
static var mediaProtectionAttributes: [FileAttributeKey: Any]? {
#if os(iOS)
return [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication]
#else
return nil
#endif
}
/// Exposed so callers that write progressively into the store's
/// directories (live voice captures) share the same file manager.
let fileManager: FileManager
@ -223,7 +237,7 @@ struct BLEIncomingFileStore: @unchecked Sendable {
isDirectory: true
),
withIntermediateDirectories: true,
attributes: nil
attributes: Self.mediaProtectionAttributes
)
}
} catch {
@ -268,7 +282,7 @@ struct BLEIncomingFileStore: @unchecked Sendable {
/// write progressively instead of via `save` (live voice captures).
func incomingDirectory(subdirectory: String) throws -> URL {
let directory = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes)
return directory
}
@ -284,7 +298,7 @@ struct BLEIncomingFileStore: @unchecked Sendable {
do {
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes)
let sanitized = sanitizedFileName(
preferredName,
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
@ -306,7 +320,11 @@ struct BLEIncomingFileStore: @unchecked Sendable {
),
forceRandomizedName: reservedPaths == nil
)
try data.write(to: destination, options: .atomic)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
try data.write(to: destination, options: options)
payloadCoordination.pendingDeliveryPaths.insert(
destination.standardizedFileURL.path
)
@ -650,9 +668,90 @@ struct BLEIncomingFileStore: @unchecked Sendable {
return removed
}
/// Stamps the media directories and any resident payloads with the
/// explicit protection class, covering files written by builds that
/// relied on the container default. Runs every launch: re-stamping an
/// equal class is a metadata no-op, and anything carrying a stronger
/// class is left alone, so repetition is cheap and can never downgrade.
/// In-flight live captures are skipped for symmetry with the retention
/// sweep; they receive the class at creation and need no repair.
/// Best-effort like the sweep it runs alongside; a file that cannot be
/// stamped is logged, not fatal, and the migration moves on to the next
/// item. Returns the number of items stamped so the launch path and
/// tests can observe coverage.
@discardableResult
func migrateFileProtectionIfNeeded() -> Int {
#if os(iOS)
guard let attributes = Self.mediaProtectionAttributes else { return 0 }
var stamped = 0
guard let base = try? filesDirectory() else { return 0 }
for subdirectory in Self.mediaSubdirectories {
let dir = base.appendingPathComponent(subdirectory, isDirectory: true)
guard fileManager.fileExists(atPath: dir.path) else { continue }
let files = (try? fileManager.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey, .fileProtectionKey],
options: [.skipsHiddenFiles]
)) ?? []
stamped += stampProtectionIfWeaker(dir, requireRegularFile: false, attributes: attributes)
for fileURL in files {
guard !fileURL.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
stamped += stampProtectionIfWeaker(fileURL, requireRegularFile: true, attributes: attributes)
}
}
return stamped
#else
return 0
#endif
}
#if os(iOS)
/// Applies the class to one item, but only when the item currently sits
/// at the container default or weaker. The list names the classes that
/// are safe to replace; anything else, including classes added in later
/// iOS versions, is left alone. Only regular files are stamped when
/// `requireRegularFile` is set (and only real directories otherwise),
/// matching the caution the legacy-file removal path applies; symlinks
/// and other non-regular files are left untouched.
private func stampProtectionIfWeaker(
_ itemURL: URL,
requireRegularFile: Bool,
attributes: [FileAttributeKey: Any]
) -> Int {
let values = try? itemURL.resourceValues(
forKeys: [.isRegularFileKey, .isDirectoryKey, .fileProtectionKey]
)
if requireRegularFile {
guard values?.isRegularFile == true else { return 0 }
} else {
guard values?.isDirectory == true else { return 0 }
}
if let current = values?.fileProtection,
current != .none,
current != .completeUntilFirstUserAuthentication {
return 0
}
do {
try fileManager.setAttributes(attributes, ofItemAtPath: itemURL.path)
return 1
} catch let error as CocoaError where error.code == .fileNoSuchFile {
// Quota eviction or a deletion commit on another store instance
// can delete an item out from under this migration; that is not
// a failure.
return 0
} catch {
SecureLogger.warning(
"⚠️ Failed to migrate media file protection: \(error)",
category: .security
)
return 0
}
}
#endif
private func filesDirectory() throws -> URL {
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes)
return filesDir
}

View File

@ -124,3 +124,45 @@ struct BLEIngressLinkRegistry {
packet.isRSR && packet.ttl == 0
}
}
/// Lock-backed shared ownership of the ingress-link registry. Ingress is
/// recorded on bleQueue the moment a frame decodes (the link identity is
/// only known there, and the duplicate-ingress gate must answer before
/// the packet is handed to the engine), while relay and routing decisions
/// read it from the engine. Every registry mutation is a single
/// whole-transition method, so readers never observe a torn state.
final class BLEIngressLinkStore: @unchecked Sendable {
private let lock = NSLock()
private var registry = BLEIngressLinkRegistry()
var isEmpty: Bool {
lock.withLock { registry.isEmpty }
}
func removeAll() {
lock.withLock { registry.removeAll() }
}
func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? {
lock.withLock { registry.record(for: packet) }
}
func link(for packet: BitchatPacket) -> BLEIngressLinkID? {
lock.withLock { registry.link(for: packet) }
}
func recordIfNew(
_ packet: BitchatPacket,
link: BLEIngressLinkID,
peerID: PeerID,
lifetime: TimeInterval
) -> Bool {
lock.withLock {
registry.recordIfNew(packet, link: link, peerID: peerID, lifetime: lifetime)
}
}
func prune(before cutoff: Date) {
lock.withLock { registry.prune(before: cutoff) }
}
}

View File

@ -0,0 +1,115 @@
import BitFoundation
import Foundation
/// Per-link Noise authentication and rebind-containment state.
///
/// A peer ID can retain an established Noise session after its physical
/// link disappears, and link bindings heal on announces whose directness
/// is forgeable (TTL is unsigned). This state pins the stronger facts the
/// containment rules need: which exact ingress link a Noise handshake
/// completed on, each link's revalidation epoch, and the cooldowns that
/// stop a replayed announce from flip-flopping bindings or survivor
/// selection.
///
/// Engine-owned (option-B boundary, docs/BLE-ARCHITECTURE-V3.md),
/// alongside the link bindings it qualifies: BLEService debug-traps any
/// access off the engine queue.
struct BLELinkAuthState {
private var authenticatedOwners: [BLEIngressLinkID: PeerID] = [:]
private var reconnectPolicy = BLENoiseReconnectPolicy()
// Entries older than the cooldown are pruned on each check.
private var lastRebindAt: [String: Date] = [:]
private var lastRedundantRetirementAt: [PeerID: Date] = [:]
// MARK: - Authentication ownership
/// Whether `peerID`'s Noise session was established on this exact link.
func isAuthenticated(_ link: BLEIngressLinkID, for peerID: PeerID) -> Bool {
authenticatedOwners[link] == peerID
}
func links(ownedBy peerID: PeerID) -> [BLEIngressLinkID] {
authenticatedOwners.compactMap { link, owner in
owner == peerID ? link : nil
}
}
mutating func markAuthenticated(_ link: BLEIngressLinkID, owner peerID: PeerID) {
authenticatedOwners[link] = peerID
}
/// Retires a link's proof and closes its revalidation epoch the pair
/// every teardown path (disconnect, unsubscribe, timeout, rebind,
/// redundant retirement) must apply together.
mutating func retireLink(_ link: BLEIngressLinkID) {
authenticatedOwners.removeValue(forKey: link)
reconnectPolicy.endLinkEpoch(link)
}
/// Retires every link the departing peer's proofs still own; returns
/// the retired links.
mutating func retireLinks(ownedBy peerID: PeerID) -> [BLEIngressLinkID] {
let departed = links(ownedBy: peerID)
for link in departed {
retireLink(link)
}
return departed
}
/// Drops every link proof and revalidation epoch. The containment
/// cooldowns deliberately SURVIVE this: panic and emergency resets can
/// restart services well inside `bleLinkRebindCooldownSeconds`, and a
/// stable CoreBluetooth UUID must not get a fresh rebind/retirement
/// allowance just because the session state around it was wiped. The
/// maps stay time-pruned on each permit check.
mutating func removeAll() {
authenticatedOwners.removeAll()
reconnectPolicy.removeAll()
}
// MARK: - Session revalidation
/// Whether a fresh direct link warrants revalidating a cached
/// peer-level session with a new XX exchange.
mutating func shouldRevalidate(
on link: BLEIngressLinkID,
for peerID: PeerID,
hasEstablishedSession: Bool,
hasAuthenticatedPeerLink: Bool,
now: Date
) -> Bool {
reconnectPolicy.shouldRevalidate(
on: link,
hasEstablishedSession: hasEstablishedSession,
isNoiseAuthenticatedLink: isAuthenticated(link, for: peerID),
hasAuthenticatedPeerLink: hasAuthenticatedPeerLink,
now: now
)
}
// MARK: - Rebind containment cooldowns
/// At most one rotation rebind per link per cooldown window, so two
/// identities can't fight over a link in a replay flip-flop. Prunes,
/// checks, and records in one transition; true = permitted (recorded).
mutating func permitRebind(linkUUID: String, now: Date, cooldown: TimeInterval) -> Bool {
lastRebindAt = lastRebindAt.filter {
now.timeIntervalSince($0.value) < cooldown
}
guard lastRebindAt[linkUUID] == nil else { return false }
lastRebindAt[linkUUID] = now
return true
}
/// At most one redundant-link retirement per peer per cooldown window,
/// bounding how often a replayed announce could flip which duplicate
/// link survives. True = permitted (recorded).
mutating func permitRedundantRetirement(peerID: PeerID, now: Date, cooldown: TimeInterval) -> Bool {
lastRedundantRetirementAt = lastRedundantRetirementAt.filter {
now.timeIntervalSince($0.value) < cooldown
}
guard lastRedundantRetirementAt[peerID] == nil else { return false }
lastRedundantRetirementAt[peerID] = now
return true
}
}

View File

@ -0,0 +1,152 @@
import BitFoundation
import Foundation
/// Identitylink bindings: which peer each physical link currently
/// belongs to, in both roles, plus each peer's preferred peripheral link
/// for directed sends and fanout collapse.
///
/// Engine-owned (option-B boundary, docs/BLE-ARCHITECTURE-V3.md),
/// alongside `BLELinkAuthState`: *who owns a link* lives on the engine,
/// *what links exist* stays on bleQueue in the physical store. BLEService
/// debug-traps any access off the engine queue.
///
/// Lifecycle contract: bindings are only created for live physical links
/// (callers check liveness through `readLinkState`) and are retired
/// through `peripheralRemoved`/`centralRemoved`/`clear*` on an engine hop
/// queued by the physical teardown. A binding can therefore briefly
/// outlive its departed link; queries that need liveness join against the
/// physical store, and everything converges once the queued retirement
/// runs.
struct BLELinkBindings {
private var peripheralPeers: [String: PeerID] = [:]
private var centralPeers: [String: PeerID] = [:]
/// The peer's most recently bound peripheral link, kept so duplicate-
/// link fanout collapse stays deterministic (see BLEFanoutSelector).
private var preferredPeripheral: [PeerID: String] = [:]
// MARK: - Queries
func peer(forPeripheralID peripheralID: String) -> PeerID? {
peripheralPeers[peripheralID]
}
func peer(forCentralUUID centralUUID: String) -> PeerID? {
centralPeers[centralUUID]
}
func boundPeer(for link: BLEIngressLinkID) -> PeerID? {
switch link {
case .peripheral(let peripheralUUID):
return peripheralPeers[peripheralUUID]
case .central(let centralUUID):
return centralPeers[centralUUID]
}
}
/// Every link bound to the peer, both roles. After a state restoration
/// the same device can hold several live peripheral links bound to one
/// peer (it reappears under a fresh UUID while the restored connection
/// lives on), so this scans all bindings rather than the 1:1 preferred
/// map.
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
guard let peerID else { return [] }
var links: Set<BLEIngressLinkID> = []
for (peripheralUUID, boundPeer) in peripheralPeers where boundPeer == peerID {
links.insert(.peripheral(peripheralUUID))
}
for (centralUUID, boundPeer) in centralPeers where boundPeer == peerID {
links.insert(.central(centralUUID))
}
return links
}
func hasCentral(boundTo peerID: PeerID) -> Bool {
centralPeers.values.contains(peerID)
}
func preferredPeripheralUUID(for peerID: PeerID) -> String? {
preferredPeripheral[peerID]
}
/// The full preferred-peripheral map, for fanout collapse.
var preferredPeripheralBindings: [PeerID: String] {
preferredPeripheral
}
/// The full central binding map, for the subscribed-central snapshot.
var centralPeersByUUID: [String: PeerID] {
centralPeers
}
// MARK: - Binding transitions
mutating func bindCentral(_ centralUUID: String, to peerID: PeerID) {
centralPeers[centralUUID] = peerID
}
mutating func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
let previousPeerID = peripheralPeers[peripheralUUID]
peripheralPeers[peripheralUUID] = peerID
// Rebinding (peer-ID rotation): drop the retired ID's reverse
// mapping so the old peer no longer claims this link.
if let previousPeerID, previousPeerID != peerID,
preferredPeripheral[previousPeerID] == peripheralUUID {
preferredPeripheral.removeValue(forKey: previousPeerID)
}
preferredPeripheral[peerID] = peripheralUUID
}
/// Retires a peripheral link's binding. When the removed link was the
/// peer's preferred one, the reverse map is repaired onto a surviving
/// duplicate chosen by the caller from the peer's remaining bound links
/// (the caller knows physical liveness; prefer a writable survivor
/// repairing onto a link mid-service-rediscovery would strand directed
/// sends until its characteristic comes back).
mutating func peripheralRemoved(
_ peripheralUUID: String,
chooseSurvivor: (_ remainingBoundUUIDs: [String]) -> String?
) -> PeerID? {
guard let peerID = peripheralPeers.removeValue(forKey: peripheralUUID) else {
return nil
}
// Only clear (or repair) the reverse map when it points at the
// removed link: with duplicate links to one peer, removing a stale
// duplicate must not strand the peer's surviving bound link.
if preferredPeripheral[peerID] == peripheralUUID {
let remaining = peripheralPeers.compactMap { uuid, boundPeer in
boundPeer == peerID ? uuid : nil
}
if let survivorUUID = chooseSurvivor(remaining) {
preferredPeripheral[peerID] = survivorUUID
} else {
preferredPeripheral.removeValue(forKey: peerID)
}
}
return peerID
}
mutating func centralRemoved(_ centralUUID: String) -> PeerID? {
centralPeers.removeValue(forKey: centralUUID)
}
/// Drops every peripheral binding; returns the peers that held one.
mutating func clearPeripherals() -> [PeerID] {
let peerIDs = Array(peripheralPeers.values)
peripheralPeers.removeAll()
preferredPeripheral.removeAll()
return peerIDs
}
/// Drops every central binding; returns the peers that held one.
mutating func clearCentrals() -> [PeerID] {
let peerIDs = Array(centralPeers.values)
centralPeers.removeAll()
return peerIDs
}
mutating func removeAll() {
peripheralPeers.removeAll()
centralPeers.removeAll()
preferredPeripheral.removeAll()
}
}

View File

@ -0,0 +1,40 @@
import BitFoundation
import Foundation
/// The upward half of the link-layer port: everything the bleQueue link
/// layer tells the engine, as one enumerable surface with one engine
/// entry point (`BLEService.handleLinkEvent`). CoreBluetooth delegates
/// shrink to physical bookkeeping plus event emission, and the simulated
/// mesh drives the engine through exactly the same seam.
///
/// Naming follows the physical stores: a *peripheral link* is a
/// connection we own as central (keyed by the remote peripheral's UUID);
/// a *central link* is a remote central subscribed to our peripheral role
/// (keyed by its UUID).
enum BLELinkEvent {
/// A decoded frame arrived on a link. Attribution binding lookup,
/// spoof rejection, raw-announce binding, ingress recording is
/// engine work. Emission captures the panic lifecycle at the handoff.
case frameDecoded(BitchatPacket, link: BLEIngressLinkID, linkDescription: String)
/// One peripheral link ended (disconnect, connect failure, or radio
/// policy teardown). The engine retires the link's identity half
/// proof, epoch, binding with survivor repair and, when
/// `runPeerBookkeeping` is set (real disconnects), marks the peer
/// disconnected once its last live link is gone and republishes the
/// peer list.
case peripheralLinkEnded(peripheralID: String, runPeerBookkeeping: Bool)
/// A remote central unsubscribed. The engine retires the central
/// link's identity half and runs last-link peer bookkeeping.
case centralLinkEnded(centralUUID: String)
/// The central role reset and every peripheral link is gone
/// (power-off retires proofs and notifies peers; an authorization
/// loss only drops the bindings).
case allPeripheralLinksEnded(peripheralIDs: [String], retireProofsAndNotify: Bool)
/// The peripheral role reset and every central link is gone (same
/// power-off / authorization-loss split).
case allCentralLinksEnded(centralUUIDs: [String], retireProofsAndNotify: Bool)
}

View File

@ -5,10 +5,15 @@ import Foundation
struct BLEPeripheralLinkState {
let peripheral: CBPeripheral
var characteristic: CBCharacteristic?
var peerID: PeerID?
var isConnecting: Bool
var isConnected: Bool
var lastConnectionAttempt: Date?
/// When didConnect last fired for this link. Nil for links restored
/// already-connected (their connect predates this process), which is
/// exactly the signal redundant-link consolidation needs: a restored
/// link lives on an old BLE address the peer no longer advertises,
/// so it must never be kept over a freshly connected duplicate.
var lastConnectedAt: Date? = nil
var assembler: NotificationStreamAssembler
}
@ -26,17 +31,20 @@ struct BLESubscribedCentralSnapshot {
}
}
/// Owns all BLE link state (peripheral connections we hold as central, and
/// central subscriptions we serve as peripheral). The store has no internal
/// locking: every access must happen on the single owning queue (the BLE
/// queue). Other queues must go through BLEService's `readLinkState`, which
/// hops to that queue. Call `assumeOwnership(of:)` to have debug builds trap
/// any access from the wrong queue.
// BLEDirectLinkState and the identitylink binding queries live on
// BLELinkBindings; this store owns only physical link state.
/// Owns the PHYSICAL BLE link state (peripheral connections we hold as
/// central, and central subscriptions we serve as peripheral) CB object
/// handles, connect lifecycles, characteristics, and stream assemblers.
/// Identitylink bindings live on `BLELinkBindings`. The store has no
/// internal locking: every access must happen on the single owning queue
/// (the BLE queue). Other queues must go through BLEService's
/// `readLinkState`, which hops to that queue. Call `assumeOwnership(of:)`
/// to have debug builds trap any access from the wrong queue.
final class BLELinkStateStore {
private(set) var peripherals: [String: BLEPeripheralLinkState] = [:]
private(set) var peerToPeripheralUUID: [PeerID: String] = [:]
private(set) var subscribedCentrals: [CBCentral] = []
private(set) var centralToPeerID: [String: PeerID] = [:]
#if DEBUG
private var ownerQueue: DispatchQueue?
@ -64,14 +72,6 @@ final class BLELinkStateStore {
return Array(peripherals.values)
}
var subscribedCentralSnapshot: BLESubscribedCentralSnapshot {
assertOwned()
return BLESubscribedCentralSnapshot(
centrals: subscribedCentrals,
peerIDsByCentralUUID: centralToPeerID
)
}
var subscribedCentralCount: Int {
assertOwned()
return subscribedCentrals.count
@ -109,7 +109,6 @@ final class BLELinkStateStore {
BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: true,
isConnected: false,
lastConnectionAttempt: date,
@ -119,20 +118,21 @@ final class BLELinkStateStore {
)
}
func markConnected(_ peripheral: CBPeripheral) {
func markConnected(_ peripheral: CBPeripheral, at now: Date = Date()) {
let peripheralID = peripheral.identifier.uuidString
if updatePeripheral(peripheralID, {
$0.isConnecting = false
$0.isConnected = true
$0.lastConnectedAt = now
}) == nil {
setPeripheralState(
BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: false,
isConnected: true,
lastConnectionAttempt: nil,
lastConnectedAt: now,
assembler: NotificationStreamAssembler()
),
for: peripheralID
@ -146,130 +146,35 @@ final class BLELinkStateStore {
}
}
func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
assertOwned()
return peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
}
func directLinkState(for peerID: PeerID) -> BLEDirectLinkState {
assertOwned()
let peripheralUUID = peerToPeripheralUUID[peerID]
let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false
let hasCentral = centralToPeerID.values.contains(peerID)
return BLEDirectLinkState(hasPeripheral: hasPeripheral, hasCentral: hasCentral)
}
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
assertOwned()
guard let peerID else { return [] }
var links: Set<BLEIngressLinkID> = []
// Scan all states rather than the 1:1 reverse map: after a state
// restoration the same device can hold several live peripheral links
// bound to one peer (it reappears under a fresh UUID while the
// restored connection lives on).
for (peripheralUUID, state) in peripherals where state.peerID == peerID {
links.insert(.peripheral(peripheralUUID))
}
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
links.insert(.central(centralUUID))
}
return links
}
/// The peer's most recently bound peripheral link, per peer. Used to keep
/// duplicate-link fanout collapse deterministic (see BLEFanoutSelector).
var preferredPeripheralBindings: [PeerID: String] {
assertOwned()
return peerToPeripheralUUID
}
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
assertOwned()
return peripherals[peripheralID]?.peerID
}
func peerID(forCentralUUID centralUUID: String) -> PeerID? {
assertOwned()
return centralToPeerID[centralUUID]
}
func addSubscribedCentral(_ central: CBCentral) {
assertOwned()
guard !subscribedCentrals.contains(central) else { return }
subscribedCentrals.append(central)
}
func removeSubscribedCentral(_ central: CBCentral) -> PeerID? {
func removeSubscribedCentral(_ central: CBCentral) {
assertOwned()
let centralUUID = central.identifier.uuidString
subscribedCentrals.removeAll { $0.identifier == central.identifier }
return centralToPeerID.removeValue(forKey: centralUUID)
}
func bindCentral(_ centralUUID: String, to peerID: PeerID) {
func removePeripheral(_ peripheralID: String) {
assertOwned()
centralToPeerID[centralUUID] = peerID
peripherals.removeValue(forKey: peripheralID)
}
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
func clearPeripherals() {
assertOwned()
var previousPeerID: PeerID?
let updated = updatePeripheral(peripheralUUID) {
previousPeerID = $0.peerID
$0.peerID = peerID
}
guard updated != nil else { return }
// Rebinding (peer-ID rotation): drop the retired ID's reverse mapping
// so the old peer no longer claims this link.
if let previousPeerID, previousPeerID != peerID,
peerToPeripheralUUID[previousPeerID] == peripheralUUID {
peerToPeripheralUUID.removeValue(forKey: previousPeerID)
}
peerToPeripheralUUID[peerID] = peripheralUUID
}
func removePeripheral(_ peripheralID: String) -> PeerID? {
assertOwned()
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
// Only clear (or repair) the reverse map when it points at the removed
// link: with duplicate links to one peer, removing a stale duplicate
// must not strand the peer's surviving bound link.
if let peerID, peerToPeripheralUUID[peerID] == peripheralID {
// Prefer a writable survivor: repairing onto a link that is
// mid-service-rediscovery would strand directed sends until the
// characteristic comes back.
let survivors = peripherals.filter { $0.value.peerID == peerID && $0.value.isConnected }
if let survivorUUID = survivors.first(where: { $0.value.characteristic != nil })?.key ?? survivors.first?.key {
peerToPeripheralUUID[peerID] = survivorUUID
} else {
peerToPeripheralUUID.removeValue(forKey: peerID)
}
}
return peerID
}
func clearPeripherals() -> [PeerID] {
assertOwned()
let peerIDs = peripherals.compactMap { $0.value.peerID }
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
return peerIDs
}
func clearCentrals() -> [PeerID] {
func clearCentrals() {
assertOwned()
let peerIDs = Array(centralToPeerID.values)
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
return peerIDs
}
func clearAll() {
assertOwned()
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
}
}

View File

@ -5,6 +5,20 @@ struct BLELocalIdentitySnapshot: Equatable, Sendable {
let peerID: PeerID
let peerIDData: Data
let nickname: String
/// Runtime-toggled capability bits (e.g. the internet-gateway toggle)
/// ORed into `PeerCapabilities.localSupported` for every announce.
let runtimeCapabilities: PeerCapabilities
/// Rendezvous cell advertised while bridging; rides announces only
/// while the `.bridge` capability is enabled.
let bridgeGeohash: String?
var advertisedCapabilities: PeerCapabilities {
PeerCapabilities.localSupported.union(runtimeCapabilities)
}
var advertisedBridgeGeohash: String? {
runtimeCapabilities.contains(.bridge) ? bridgeGeohash : nil
}
}
/// Lock-backed local identity state shared by the transport's message,
@ -12,8 +26,8 @@ struct BLELocalIdentitySnapshot: Equatable, Sendable {
///
/// `peerID` and its binary wire representation must change as one unit during
/// panic rotation. A snapshot also gives announce construction one consistent
/// view of the nickname and identity instead of reading three independently
/// mutable properties across queues.
/// view of the nickname, identity, and advertised capabilities instead of
/// reading independently mutable properties across queues.
final class BLELocalIdentityStateStore: @unchecked Sendable {
private let lock = NSLock()
private var state: BLELocalIdentitySnapshot
@ -25,7 +39,9 @@ final class BLELocalIdentityStateStore: @unchecked Sendable {
state = BLELocalIdentitySnapshot(
peerID: peerID,
peerIDData: Data(hexString: peerID.id) ?? Data(),
nickname: nickname
nickname: nickname,
runtimeCapabilities: [],
bridgeGeohash: nil
)
}
@ -38,7 +54,9 @@ final class BLELocalIdentityStateStore: @unchecked Sendable {
state = BLELocalIdentitySnapshot(
peerID: state.peerID,
peerIDData: state.peerIDData,
nickname: nickname
nickname: nickname,
runtimeCapabilities: state.runtimeCapabilities,
bridgeGeohash: state.bridgeGeohash
)
}
}
@ -48,8 +66,48 @@ final class BLELocalIdentityStateStore: @unchecked Sendable {
state = BLELocalIdentitySnapshot(
peerID: peerID,
peerIDData: Data(hexString: peerID.id) ?? Data(),
nickname: state.nickname
nickname: state.nickname,
runtimeCapabilities: state.runtimeCapabilities,
bridgeGeohash: state.bridgeGeohash
)
}
}
/// Flips a runtime capability bit. Returns whether anything changed.
@discardableResult
func setCapability(_ capability: PeerCapabilities, enabled: Bool) -> Bool {
lock.withLock {
var capabilities = state.runtimeCapabilities
if enabled {
capabilities.insert(capability)
} else {
capabilities.remove(capability)
}
guard capabilities != state.runtimeCapabilities else { return false }
state = BLELocalIdentitySnapshot(
peerID: state.peerID,
peerIDData: state.peerIDData,
nickname: state.nickname,
runtimeCapabilities: capabilities,
bridgeGeohash: state.bridgeGeohash
)
return true
}
}
/// Sets the bridged rendezvous cell. Returns whether anything changed.
@discardableResult
func setBridgeGeohash(_ cell: String?) -> Bool {
lock.withLock {
guard cell != state.bridgeGeohash else { return false }
state = BLELocalIdentitySnapshot(
peerID: state.peerID,
peerIDData: state.peerIDData,
nickname: state.nickname,
runtimeCapabilities: state.runtimeCapabilities,
bridgeGeohash: cell
)
return true
}
}
}

View File

@ -0,0 +1,62 @@
import BitFoundation
import Foundation
struct BLEMeshPingProbe {
let peerID: PeerID
let sentAt: Date
let lifecycleGeneration: UInt64
let completion: @MainActor (MeshPingResult?) -> Void
let timeout: DispatchWorkItem
}
/// Engine-confined /ping diagnostics state: outstanding probes keyed by
/// their unguessable nonce, plus the inbound response budget.
///
/// The budget is keyed by the ingress link (the directly connected peer
/// that delivered the packet), never the packet-claimed sender: pings are
/// unsigned, so the claimed sender is attacker-controlled and rotating it
/// would reset the budget, turning a directed unencrypted probe into an
/// amplification primitive.
///
/// Pure state the transport owns packet I/O, timers, and main-actor
/// completion delivery around it.
struct BLEMeshPingTracker {
private var pendingProbes: [Data: BLEMeshPingProbe] = [:]
private var responseLimiter = SyncResponseRateLimiter(
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
window: TransportConfig.meshPingInboundWindowSeconds
)
mutating func register(_ probe: BLEMeshPingProbe, nonce: Data) {
pendingProbes[nonce] = probe
}
/// Resolves a pong against its outstanding probe. The echoed nonce plus
/// the sender check bind the reply to the probed peer.
mutating func resolve(nonce: Data, from peerID: PeerID) -> BLEMeshPingProbe? {
guard pendingProbes[nonce]?.peerID == peerID else { return nil }
return pendingProbes.removeValue(forKey: nonce)
}
/// Removes a timed-out probe so its completion can fire once with nil.
mutating func expire(nonce: Data) -> BLEMeshPingProbe? {
pendingProbes.removeValue(forKey: nonce)
}
/// Whether an inbound ping delivered by this link is within budget.
mutating func shouldRespond(toLink linkPeerID: PeerID, now: Date) -> Bool {
responseLimiter.shouldRespond(to: linkPeerID, now: now)
}
/// Drops all probes and restores a fresh response budget (panic wipe).
/// Returns the orphaned timeout work items for the caller to cancel.
mutating func reset() -> [DispatchWorkItem] {
let timeouts = pendingProbes.values.map(\.timeout)
pendingProbes.removeAll()
responseLimiter = SyncResponseRateLimiter(
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
window: TransportConfig.meshPingInboundWindowSeconds
)
return timeouts
}
}

View File

@ -261,8 +261,6 @@ struct BLEOutboundFragmentTransferScheduler {
continue
}
availableSlots -= 1
guard activeTransfers.count < maxConcurrentTransfers else {
pendingTransfers.insert(request, at: 0)
results.append(.queued(request: request, transferId: transferId, position: .front))
@ -270,11 +268,17 @@ struct BLEOutboundFragmentTransferScheduler {
}
guard activeTransfers[transferId] == nil else {
// Blocked on an already-active copy of this content: leave
// the slot budget untouched so a later, unrelated pending
// transfer can still start in this same pass instead of
// being starved until some other transfer happens to
// complete.
blockedFront.append(request)
results.append(.queued(request: request, transferId: transferId, position: .front))
continue
}
availableSlots -= 1
activeTransfers[transferId] = ActiveTransferState(
totalFragments: 0,
sentFragments: 0,

View File

@ -223,7 +223,7 @@ struct BLEPeerRegistry {
peers[peerID] = BLEPeerInfo(
peerID: existing?.peerID ?? peerID,
nickname: nickname,
nickname: nickname.normalizedNickname,
isConnected: isConnected,
noisePublicKey: noisePublicKey,
// Never drop an already-pinned signing key.

View File

@ -0,0 +1,84 @@
import BitFoundation
import Foundation
/// Lock-backed shared ownership of the peer registry, readable from any
/// queue or the main actor without hopping onto a transport queue.
///
/// Mutations come only from the transport's own serial queues the
/// engine, plus the bleQueue link-drop paths that mark a peer
/// disconnected and the lock serializes them against each other and
/// against readers, so the main actor answers questions like
/// `isPeerConnected` without blocking behind in-flight transport work.
/// Every `BLEPeerRegistry` mutation is a single whole-transition method,
/// so a reader between two mutations always observes a valid pre- or
/// post-state, never a torn one.
///
/// Closures passed to `read`/`mutate` run under the (non-recursive) lock
/// and must not call back into the store.
final class BLEPeerRegistryStore: @unchecked Sendable {
private let lock = NSLock()
private var registry = BLEPeerRegistry()
/// One consistent view across multiple registry reads.
func read<T>(_ body: (BLEPeerRegistry) -> T) -> T {
lock.withLock { body(registry) }
}
func mutate<T>(_ body: (inout BLEPeerRegistry) -> T) -> T {
lock.withLock { body(&registry) }
}
// MARK: - Single-question reads
var isEmpty: Bool { read { $0.isEmpty } }
var peerIDs: [PeerID] { read { $0.peerIDs } }
var connectedCount: Int { read { $0.connectedCount } }
var connectedPeerIDs: [PeerID] { read { $0.connectedPeerIDs } }
var connectedRoutingData: [Data] { read { $0.connectedRoutingData } }
var snapshotByID: [PeerID: BLEPeerInfo] { read { $0.snapshotByID } }
func info(for peerID: PeerID) -> BLEPeerInfo? {
read { $0.info(for: peerID) }
}
func isConnected(_ peerID: PeerID) -> Bool {
read { $0.isConnected(peerID) }
}
func isReachable(_ peerID: PeerID, now: Date) -> Bool {
read { $0.isReachable(peerID, now: now) }
}
func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? {
read { $0.nickname(for: peerID, connectedOnly: connectedOnly) }
}
func fingerprint(for peerID: PeerID) -> String? {
read { $0.fingerprint(for: peerID) }
}
func capabilities(for peerID: PeerID) -> PeerCapabilities {
read { $0.capabilities(for: peerID) }
}
func advertisedBridgeGeohash() -> String? {
read { $0.advertisedBridgeGeohash() }
}
func displayNicknames(selfNickname: String) -> [PeerID: String] {
read { $0.displayNicknames(selfNickname: selfNickname) }
}
func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] {
read { $0.transportSnapshots(selfNickname: selfNickname) }
}
/// Peers advertising `capability` that are reachable now, in one
/// consistent view.
func reachablePeers(advertising capability: PeerCapabilities, now: Date) -> [PeerID] {
read { registry in
registry.peers(advertising: capability)
.filter { registry.isReachable($0, now: now) }
}
}
}

View File

@ -0,0 +1,363 @@
import BitFoundation
import Foundation
struct BLEAuthenticatedPeerStateObservation {
let fingerprint: String
let sessionGeneration: UUID
let capabilities: PeerCapabilities
}
struct BLEPrivateMediaProofTimeoutMarker {
let fingerprint: String
let sessionGeneration: UUID?
}
struct BLEPrivateMediaProofWatchdog {
let fingerprint: String
let sessionGeneration: UUID
let timeoutNonce: UUID
}
struct BLEPendingPrivateMediaPolicyResolution {
let fingerprint: String
var sessionGeneration: UUID?
var timeoutNonce: UUID
var completions: [UUID: @MainActor (PrivateMediaSendPolicy) -> Void]
}
struct BLEAuthenticatedPeerStateSendProgress {
let sessionGeneration: UUID
var sentInitial = false
var sentEcho = false
}
/// Lock-backed private-media session state: which Noise generation each
/// peer's capability proof, peer-state exchange, and policy waiters are
/// bound to. A fresh Noise authentication rotates the generation UUID, so
/// stale proof timers and proof packets cannot classify a replacement
/// session.
///
/// Lock-backed rather than engine-confined for two reasons: the send
/// policy is answered synchronously on the main actor, and several
/// transitions run inside noise-manager critical sections that the engine
/// is sync-waiting on (where re-entering the engine would self-deadlock,
/// but taking a leaf lock is safe). Every method is one whole transition
/// under the lock, so no caller can observe a torn intermediate state.
final class BLEPrivateMediaSessionStore: @unchecked Sendable {
private let lock = NSLock()
private var sessionGenerations: [PeerID: UUID] = [:]
private var authenticatedStates: [PeerID: BLEAuthenticatedPeerStateObservation] = [:]
private var proofTimeoutMarkers: [PeerID: BLEPrivateMediaProofTimeoutMarker] = [:]
private var proofWatchdogs: [PeerID: BLEPrivateMediaProofWatchdog] = [:]
private var pendingPolicyResolutions: [PeerID: BLEPendingPrivateMediaPolicyResolution] = [:]
private var stateSendProgress: [PeerID: BLEAuthenticatedPeerStateSendProgress] = [:]
/// Peers whose parked outbound queues must stay parked until the
/// convergence retry re-authenticates: a timeout-restore brings back
/// keys the counterpart may have already discarded, so nothing not
/// even the capability-proof watchdog may drain the queues under
/// them. Set on the deferred restore transition, cleared by any
/// transition that is allowed to drain.
private var outboundConvergenceDeferred: Set<PeerID> = []
// MARK: Reads
func currentGeneration(for peerID: PeerID) -> UUID? {
lock.withLock { sessionGenerations[peerID] }
}
/// The exact current generation iff it authenticated both encrypted
/// private media (bit 8) and durable receipts/retry (bit 9).
func receiptSessionGeneration(for peerID: PeerID, currentNoiseGeneration: UUID?) -> UUID? {
lock.withLock {
guard let generation = sessionGenerations[peerID],
generation == currentNoiseGeneration,
let authenticated = authenticatedStates[peerID],
authenticated.sessionGeneration == generation,
authenticated.capabilities.contains(.privateMedia),
authenticated.capabilities.contains(.privateMediaReceipts) else {
return nil
}
return generation
}
}
/// One consistent view of the state the send-policy calculus needs.
func policyInputs(for peerID: PeerID) -> (
sessionGeneration: UUID?,
authenticatedState: BLEAuthenticatedPeerStateObservation?,
timedOut: BLEPrivateMediaProofTimeoutMarker?
) {
lock.withLock {
(
sessionGenerations[peerID],
authenticatedStates[peerID],
proofTimeoutMarkers[peerID]
)
}
}
func hasPendingPolicyResolution(for peerID: PeerID) -> Bool {
lock.withLock { pendingPolicyResolutions[peerID] != nil }
}
/// The live proof-timeout identity for a peer (watchdog first, then a
/// registered waiter) what a forced/expired timeout must present.
func proofTimeoutTarget(for peerID: PeerID) -> (fingerprint: String, generation: UUID?, nonce: UUID)? {
lock.withLock {
if let watchdog = proofWatchdogs[peerID] {
return (watchdog.fingerprint, watchdog.sessionGeneration, watchdog.timeoutNonce)
}
if let pending = pendingPolicyResolutions[peerID] {
return (pending.fingerprint, pending.sessionGeneration, pending.timeoutNonce)
}
return nil
}
}
// MARK: Generation transitions
/// Installs a freshly authenticated generation: rotates the proof
/// watchdog, resets peer-state send progress, and re-binds any pending
/// policy waiters whose fingerprint still matches (mismatched waiters
/// are rejected and returned for completion). Returns nil when the
/// generation is already current the same-generation reconciliation
/// path, which must not re-arm proof machinery.
func beginAuthenticatedGeneration(
for peerID: PeerID,
fingerprint: String,
generation: UUID
) -> (watchdogNonce: UUID, rejected: [@MainActor (PrivateMediaSendPolicy) -> Void])? {
lock.withLock {
guard sessionGenerations[peerID] != generation else { return nil }
let watchdogNonce = UUID()
sessionGenerations[peerID] = generation
authenticatedStates.removeValue(forKey: peerID)
proofTimeoutMarkers.removeValue(forKey: peerID)
proofWatchdogs[peerID] = BLEPrivateMediaProofWatchdog(
fingerprint: fingerprint,
sessionGeneration: generation,
timeoutNonce: watchdogNonce
)
stateSendProgress[peerID] =
BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation)
guard var pending = pendingPolicyResolutions[peerID] else {
return (watchdogNonce, [])
}
guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame else {
pendingPolicyResolutions.removeValue(forKey: peerID)
return (watchdogNonce, Array(pending.completions.values))
}
pending.sessionGeneration = generation
pending.timeoutNonce = watchdogNonce
pendingPolicyResolutions[peerID] = pending
return (watchdogNonce, [])
}
}
/// Records a verified authenticated-peer-state packet for the current
/// generation: pins the observation, retires proof timers, and releases
/// matching policy waiters. Returns nil when the generation is no longer
/// current (the caller's lease raced a replacement).
func applyAuthenticatedPeerState(
for peerID: PeerID,
fingerprint: String,
generation: UUID,
capabilities: PeerCapabilities
) -> [@MainActor (PrivateMediaSendPolicy) -> Void]? {
lock.withLock {
guard sessionGenerations[peerID] == generation else { return nil }
authenticatedStates[peerID] = BLEAuthenticatedPeerStateObservation(
fingerprint: fingerprint,
sessionGeneration: generation,
capabilities: capabilities
)
proofTimeoutMarkers.removeValue(forKey: peerID)
proofWatchdogs.removeValue(forKey: peerID)
guard let pending = pendingPolicyResolutions.removeValue(forKey: peerID),
pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
pending.sessionGeneration == generation else {
return []
}
return Array(pending.completions.values)
}
}
/// Consumes one peer-state send slot (initial or echo) for the current
/// generation. Returns whether the packet should actually go out.
func markPeerStateSend(for peerID: PeerID, echo: Bool) -> Bool {
lock.withLock {
guard let generation = sessionGenerations[peerID],
var progress = stateSendProgress[peerID],
progress.sessionGeneration == generation else { return false }
if echo {
guard !progress.sentEcho else { return false }
progress.sentEcho = true
} else {
guard !progress.sentInitial else { return false }
progress.sentInitial = true
}
stateSendProgress[peerID] = progress
return true
}
}
// MARK: Outbound convergence deferral
func setOutboundDeferredUntilConvergence(_ peerID: PeerID) {
lock.withLock { _ = outboundConvergenceDeferred.insert(peerID) }
}
func clearOutboundDeferredUntilConvergence(_ peerID: PeerID) {
lock.withLock { _ = outboundConvergenceDeferred.remove(peerID) }
}
// MARK: Proof timeout
/// Expires a proof deadline if its nonce/generation/fingerprint still
/// identify the live watchdog or waiter set. On expiry the timeout
/// marker is pinned and any waiters are returned for completion.
/// `deferredOutbound` reports whether the peer's parked queues must
/// stay parked (timeout-restore pending its convergence retry).
func expireProofDeadline(
for peerID: PeerID,
fingerprint: String,
sessionGeneration: UUID?,
nonce: UUID
) -> (expired: Bool, deferredOutbound: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) {
lock.withLock {
let pending = pendingPolicyResolutions[peerID]
let pendingMatches = pending?.timeoutNonce == nonce
&& pending?.sessionGeneration == sessionGeneration
&& pending?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame
let watchdog = proofWatchdogs[peerID]
let watchdogMatches = sessionGeneration != nil
&& watchdog?.timeoutNonce == nonce
&& watchdog?.sessionGeneration == sessionGeneration
&& watchdog?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame
guard pendingMatches || watchdogMatches else {
return (false, false, [])
}
var completions: [@MainActor (PrivateMediaSendPolicy) -> Void] = []
if pendingMatches, let pending {
completions = Array(pending.completions.values)
}
if pendingMatches {
pendingPolicyResolutions.removeValue(forKey: peerID)
}
if watchdogMatches {
proofWatchdogs.removeValue(forKey: peerID)
}
proofTimeoutMarkers[peerID] = BLEPrivateMediaProofTimeoutMarker(
fingerprint: fingerprint,
sessionGeneration: sessionGeneration
)
return (true, outboundConvergenceDeferred.contains(peerID), completions)
}
}
/// Registers a policy-resolution waiter for a peer still awaiting its
/// capability proof. Joins the existing waiter set when fingerprints
/// match (bounded), otherwise starts one, reusing the live watchdog's
/// deadline identity when it covers the same fingerprint/generation so
/// only one timeout is ever in flight. `shouldSchedule` tells the
/// caller to arm a fresh deadline.
func registerPolicyResolution(
for peerID: PeerID,
fingerprint: String,
requestID: UUID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) -> (registered: Bool, shouldSchedule: Bool, nonce: UUID, generation: UUID?) {
lock.withLock {
let generation = sessionGenerations[peerID]
if var pending = pendingPolicyResolutions[peerID] {
guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
pending.completions.count
< TransportConfig.privateMediaCapabilityProofWaitersPerPeerCap else {
return (false, false, UUID(), generation)
}
pending.completions[requestID] = completion
pendingPolicyResolutions[peerID] = pending
return (true, false, pending.timeoutNonce, pending.sessionGeneration)
}
guard pendingPolicyResolutions.count
< TransportConfig.privateMediaCapabilityProofPendingPeerCap else {
return (false, false, UUID(), generation)
}
let currentWatchdog = proofWatchdogs[peerID]
let reusesWatchdog = currentWatchdog?.fingerprint
.caseInsensitiveCompare(fingerprint) == .orderedSame
&& currentWatchdog?.sessionGeneration == generation
let nonce: UUID
if reusesWatchdog, let currentWatchdog {
nonce = currentWatchdog.timeoutNonce
} else {
nonce = UUID()
}
pendingPolicyResolutions[peerID] =
BLEPendingPrivateMediaPolicyResolution(
fingerprint: fingerprint,
sessionGeneration: generation,
timeoutNonce: nonce,
completions: [requestID: completion]
)
return (true, !reusesWatchdog, nonce, generation)
}
}
// MARK: Teardown
/// A session clear retires every generation-bound record. Waiters are
/// kept but rebased onto a nil generation with a fresh deadline nonce,
/// returned so the caller re-arms their timeout.
func clearSession(for peerID: PeerID) -> (fingerprint: String, nonce: UUID)? {
lock.withLock {
sessionGenerations.removeValue(forKey: peerID)
authenticatedStates.removeValue(forKey: peerID)
proofTimeoutMarkers.removeValue(forKey: peerID)
proofWatchdogs.removeValue(forKey: peerID)
stateSendProgress.removeValue(forKey: peerID)
outboundConvergenceDeferred.remove(peerID)
guard var pending = pendingPolicyResolutions[peerID] else {
return nil
}
let nonce = UUID()
pending.sessionGeneration = nil
pending.timeoutNonce = nonce
pendingPolicyResolutions[peerID] = pending
return (pending.fingerprint, nonce)
}
}
/// Panic wipe: these records belong to pre-panic transfer state, and
/// invoking their callbacks would let queued UI work recreate or resend
/// wiped media drop everything.
func panicReset() {
lock.withLock {
sessionGenerations.removeAll()
authenticatedStates.removeAll()
proofTimeoutMarkers.removeAll()
proofWatchdogs.removeAll()
pendingPolicyResolutions.removeAll()
stateSendProgress.removeAll()
outboundConvergenceDeferred.removeAll()
}
}
}
extension BLEPrivateMediaSessionStore {
/// The current generation iff its authenticated peer state proved the
/// private-media capability (and, when required, durable receipts).
func provenGeneration(for peerID: PeerID, requireReceipts: Bool) -> UUID? {
let inputs = policyInputs(for: peerID)
guard let generation = inputs.sessionGeneration,
let authenticated = inputs.authenticatedState,
authenticated.sessionGeneration == generation,
authenticated.capabilities.contains(.privateMedia) else { return nil }
if requireReceipts {
guard authenticated.capabilities.contains(.privateMediaReceipts) else { return nil }
}
return generation
}
}

View File

@ -0,0 +1,411 @@
import BitLogger
import CoreBluetooth
import Foundation
/// The radio's contact points back into the transport. All calls arrive on
/// bleQueue.
protocol BLERadioControllerDelegate: AnyObject {
/// Whether a panic wipe has quiesced the radio.
func radioIsPanicSuspended() -> Bool
/// iOS app-active snapshot (drives allow-duplicates scanning and
/// background connect deferral); always true on macOS.
func radioIsAppActive() -> Bool
/// A connect attempt died (timeout or foreground stale-reclaim): retire
/// the link's transport bookkeeping write buffers, link-auth proof,
/// reconnect epoch, and the link-state entry itself.
func radioTearDownPeripheralLink(_ peripheralID: String)
}
/// bleQueue-confined owner of the central-role radio policy: discovery
/// admission, the connection budget and queue, connect timeouts,
/// wake-on-proximity background connects, scan duty-cycling, RSSI
/// adaptation, and the advertising payload.
///
/// First slice of the link layer (docs/BLE-ARCHITECTURE-V3.md): this type
/// makes no peer decisions and owns no bindings or security state it
/// shares the bleQueue-confined link-state store for admission reads and
/// asks its delegate to tear down transport bookkeeping when an attempt
/// dies.
final class BLERadioController {
weak var delegate: BLERadioControllerDelegate?
/// The transport is every peripheral's CBPeripheralDelegate; connects
/// initiated here must point new peripherals at it.
weak var peripheralDelegate: CBPeripheralDelegate?
/// Attached when the transport creates (or restores) its managers.
weak var central: CBCentralManager?
private let queue: DispatchQueue
private let linkStateStore: BLELinkStateStore
private let recentTraffic: BLERecentTrafficMonitor
// Connection budget & scheduling (central role)
private var scheduler = BLEConnectionScheduler<CBPeripheral>()
// Recently seen peripherals retained for background wake-on-proximity
// connects
private let recentPeripheralCache = BLERecentPeripheralCache<CBPeripheral>()
// Adaptive scanning duty-cycle
private var scanDutyTimer: DispatchSourceTimer?
private var dutyEnabled: Bool = true
private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration
private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration
private var dutyActive: Bool = false
init(
queue: DispatchQueue,
linkStateStore: BLELinkStateStore,
recentTraffic: BLERecentTrafficMonitor
) {
self.queue = queue
self.linkStateStore = linkStateStore
self.recentTraffic = recentTraffic
}
// MARK: - Advertising
static func advertisementData() -> [String: Any] {
// No Local Name for privacy.
[CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID]]
}
// MARK: - Scanning
func startScanning() {
guard delegate?.radioIsPanicSuspended() == false,
let central,
central.state == .poweredOn,
!central.isScanning else { return }
// Allow duplicates while active for faster discovery: immediate
// discovery events instead of coalesced ones.
let allowDuplicates = delegate?.radioIsAppActive() ?? true
central.scanForPeripherals(
withServices: [BLEService.serviceUUID],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates]
)
}
func updateScanningDutyCycle(connectedCount: Int) {
guard let central, central.state == .poweredOn else { return }
// Duty cycle only when the app is active and at least one peer is
// connected; force full-time scanning with few neighbors or very
// recent traffic.
let hasRecentTraffic = recentTraffic.hasTraffic(
within: TransportConfig.bleRecentTrafficForceScanSeconds,
now: Date()
)
let scanPlan = BLEScanDutyPolicy.plan(
dutyEnabled: dutyEnabled,
appIsActive: delegate?.radioIsAppActive() ?? true,
connectedCount: connectedCount,
hasRecentTraffic: hasRecentTraffic
)
switch scanPlan {
case .dutyCycle(let onDuration, let offDuration):
let durationsChanged = dutyOnDuration != onDuration || dutyOffDuration != offDuration
dutyOnDuration = onDuration
dutyOffDuration = offDuration
if scanDutyTimer == nil {
// Start with scanning ON; turn OFF after onDuration.
let t = DispatchSource.makeTimerSource(queue: queue)
if !central.isScanning { startScanning() }
dutyActive = true
t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)
t.setEventHandler { [weak self] in
guard let self, let c = self.central else { return }
if self.dutyActive {
if c.isScanning { c.stopScan() }
self.dutyActive = false
self.queue.asyncAfter(deadline: .now() + self.dutyOffDuration) {
if self.central?.state == .poweredOn { self.startScanning() }
self.dutyActive = true
}
}
}
t.resume()
scanDutyTimer = t
} else if durationsChanged {
scanDutyTimer?.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)
if !central.isScanning { startScanning() }
dutyActive = true
}
case .continuous:
// Cancel duty cycle and ensure scanning is ON for discovery.
scanDutyTimer?.cancel()
scanDutyTimer = nil
if !central.isScanning { startScanning() }
}
}
func stopDutyCycle() {
scanDutyTimer?.cancel()
scanDutyTimer = nil
}
func updateRSSIThreshold(connectedCount: Int) {
scheduler.updateRSSIThreshold(
connectedCount: connectedCount,
connectedOrConnectingLinkCount: linkStateStore.connectedOrConnectingPeripheralCount,
now: Date()
)
}
// MARK: - Discovery & connection budget
func handleDiscovery(
_ peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi: NSNumber
) {
guard delegate?.radioIsPanicSuspended() == false, let central else { return }
let peripheralID = peripheral.identifier.uuidString
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "")
let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true
let candidate = BLEConnectionCandidate(
peripheral: peripheral,
peripheralID: peripheralID,
rssi: rssi.intValue,
name: String(advertisedName),
isConnectable: isConnectable,
discoveredAt: Date()
)
if isConnectable {
recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: candidate.discoveredAt)
}
let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init)
switch scheduler.handleDiscovery(
candidate,
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
existingState: existingState,
peripheralState: peripheral.state.connectionSchedulerState,
now: candidate.discoveredAt
) {
case .ignore, .queued:
return
case .scheduleRetry(let delay):
queue.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.tryConnectFromQueue()
}
return
case .cancelStaleConnection:
central.cancelPeripheralConnection(peripheral)
return
case .connectNow:
beginCentralConnection(candidate, using: central, logPrefix: "📱 Connect")
}
}
func tryConnectFromQueue() {
guard delegate?.radioIsPanicSuspended() == false,
let central,
central.state == .poweredOn else { return }
let decision = scheduler.nextCandidate(
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
isAlreadyConnectingOrConnected: { [linkStateStore] peripheralID in
let state = linkStateStore.state(forPeripheralID: peripheralID)
return state?.isConnected == true || state?.isConnecting == true
},
now: Date()
)
switch decision {
case .none:
return
case .retryAfter(let delay):
queue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() }
case .connect(let candidate):
beginCentralConnection(candidate, using: central, logPrefix: "⏩ Queue connect")
}
}
private func beginCentralConnection(
_ candidate: BLEConnectionCandidate<CBPeripheral>,
using central: CBCentralManager,
logPrefix: String
) {
guard delegate?.radioIsPanicSuspended() == false else { return }
let peripheral = candidate.peripheral
let peripheralID = candidate.peripheralID
linkStateStore.beginConnecting(to: peripheral, at: Date())
peripheral.delegate = peripheralDelegate
let options: [String: Any] = [
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
CBConnectPeripheralOptionNotifyOnNotificationKey: true
]
central.connect(peripheral, options: options)
scheduler.recordConnectionAttempt(at: Date())
SecureLogger.debug("\(logPrefix): \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session)
queue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in
guard let self,
let state = self.linkStateStore.state(forPeripheralID: peripheralID),
state.isConnecting && !state.isConnected else { return }
guard peripheral.state != .connected else {
SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(candidate.name)", category: .session)
return
}
if self.delegate?.radioIsAppActive() == false {
// Backgrounded: leave the connect pending. iOS never expires
// it the controller completes it whenever the peer comes
// back into range, waking the app (state restoration
// relaunches us if we were terminated). Foreground return
// cancels stale pendings via cancelStalePendingConnects().
SecureLogger.info("🌙 Connect timeout deferred while backgrounded, left pending for wake-on-proximity: \(candidate.name)", category: .session)
return
}
SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session)
central.cancelPeripheralConnection(peripheral)
self.delegate?.radioTearDownPeripheralLink(peripheralID)
self.scheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
self.tryConnectFromQueue()
}
}
// MARK: - Scheduler bookkeeping (called from the transport's delegates)
var candidateCount: Int { scheduler.candidateCount }
func recordConnectionSuccess(peripheralID: String) {
scheduler.recordConnectionSuccess(peripheralID: peripheralID)
}
func recordConnectionFailure(peripheralID: String) {
scheduler.recordConnectionFailure(peripheralID: peripheralID)
}
func recordDisconnectError(peripheralID: String, at date: Date) {
scheduler.recordDisconnectError(peripheralID: peripheralID, at: date)
}
func recordRecentPeripheral(_ peripheral: CBPeripheral, peripheralID: String, at date: Date) {
recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: date)
}
func pruneConnectionTimeouts(before cutoff: Date) {
scheduler.pruneConnectionTimeouts(before: cutoff)
}
/// Panic wipe: drop the candidate queue, backoff state, and RSSI
/// adaptation with the identity they served.
func reset() {
scheduler.reset()
}
#if os(iOS)
// MARK: - Background wake-on-proximity
/// Backgrounding hands the freed connection budget to iOS as pending
/// connects against recently seen peers: the controller completes one
/// whenever its peer comes into range, waking (or relaunching) the app.
/// A couple of central slots stay reserved for connects driven by live
/// background discovery except on the disconnect re-arm path, which
/// may consume the slot the disconnect itself just freed (a dense mesh
/// with 4+ remaining links would otherwise compute a zero budget and
/// never re-arm the lost peer).
func armPendingBackgroundConnects(
slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
) {
queue.async { [weak self] in
guard let self,
self.delegate?.radioIsPanicSuspended() == false,
let central = self.central,
central.state == .poweredOn else { return }
let budget = TransportConfig.bleMaxCentralLinks
- slotReserve
- self.linkStateStore.connectedOrConnectingPeripheralCount
let now = Date()
let targets = self.recentPeripheralCache.reconnectTargets(now: now, limit: budget) { peripheralID in
let state = self.linkStateStore.state(forPeripheralID: peripheralID)
return state?.isConnected == true || state?.isConnecting == true
}
guard !targets.isEmpty else { return }
for target in targets {
// lastConnectionAttempt stays nil: an indefinite pending
// connect has no attempt clock, and nil marks it always-stale
// so cancelStalePendingConnects() reclaims it on foreground
// even after a quick backgroundforeground bounce.
self.linkStateStore.setPeripheralState(
BLEPeripheralLinkState(
peripheral: target.peripheral,
characteristic: nil,
isConnecting: true,
isConnected: false,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
),
for: target.peripheralID
)
target.peripheral.delegate = self.peripheralDelegate
central.connect(target.peripheral, options: [
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
CBConnectPeripheralOptionNotifyOnNotificationKey: true
])
}
SecureLogger.info("🌙 Armed \(targets.count) pending background connect(s) for wake-on-proximity", category: .session)
}
}
/// Foreground restores normal connection management: pending connects
/// older than the connect timeout (including ones rebuilt by state
/// restoration after a relaunch) are cancelled so live scanning and the
/// scheduler take over. Anything still nearby is rediscovered within
/// seconds by the allow-duplicates foreground scan.
func cancelStalePendingConnects() {
queue.async { [weak self] in
guard let self, let central = self.central else { return }
let now = Date()
var cancelled = 0
for state in self.linkStateStore.peripheralStates where state.isConnecting && !state.isConnected {
let age = state.lastConnectionAttempt.map { now.timeIntervalSince($0) } ?? .infinity
guard age > TransportConfig.bleConnectTimeoutSeconds else { continue }
let peripheralID = state.peripheral.identifier.uuidString
central.cancelPeripheralConnection(state.peripheral)
self.delegate?.radioTearDownPeripheralLink(peripheralID)
cancelled += 1
}
if cancelled > 0 {
SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session)
self.tryConnectFromQueue()
}
}
}
#endif
}
// MARK: - Connection scheduling helpers
private extension BLEExistingConnectionState {
init(_ state: BLEPeripheralLinkState) {
self.init(
isConnecting: state.isConnecting,
isConnected: state.isConnected,
lastConnectionAttempt: state.lastConnectionAttempt
)
}
}
private extension CBPeripheralState {
var connectionSchedulerState: BLEPeripheralConnectionState {
switch self {
case .connected:
return .connected
case .connecting:
return .connecting
case .disconnected, .disconnecting:
return .disconnected
@unknown default:
return .disconnected
}
}
}

View File

@ -77,6 +77,26 @@ struct BLEReceivePipeline {
}
}
/// Lock-backed traffic-level signal: the receive pipeline records packets,
/// and the radio layer (maintenance and scan-duty adaptation on bleQueue)
/// reads the level without crossing onto a transport queue.
final class BLERecentTrafficMonitor: @unchecked Sendable {
private let lock = NSLock()
private var tracker = BLERecentTrafficTracker()
func recordPacket(at now: Date) {
lock.withLock { tracker.recordPacket(at: now) }
}
func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool {
lock.withLock { tracker.hasTraffic(within: seconds, now: now) }
}
func removeAll() {
lock.withLock { tracker.removeAll() }
}
}
struct BLERecentTrafficTracker: Equatable {
private var packetTimestamps: [Date] = []

View File

@ -21,24 +21,53 @@ enum BLERedundantLinkPolicy {
/// A link mid-service-rediscovery (didModifyServices cleared it)
/// must never be kept over a writable duplicate.
let hasCharacteristic: Bool
/// When didConnect last fired for this link in this process. Nil
/// for restored links, whose connect predates the relaunch.
let lastConnectedAt: Date?
init(uuid: String, peerID: PeerID?, isConnected: Bool, hasCharacteristic: Bool) {
init(
uuid: String,
peerID: PeerID?,
isConnected: Bool,
hasCharacteristic: Bool,
lastConnectedAt: Date? = nil
) {
self.uuid = uuid
self.peerID = peerID
self.isConnected = isConnected
self.hasCharacteristic = hasCharacteristic
self.lastConnectedAt = lastConnectedAt
}
}
/// The link to keep when a peer has several connected bound peripheral
/// links, or nil when there is nothing to consolidate. Prefers the
/// ingress link of the verified direct announce that triggered the check
/// (the strongest liveness proof available), falling back to the peer's
/// most recently bound link but only among writable links while any
/// exist: keeping a characteristic-less link and cancelling the writable
/// links, or nil when there is nothing to consolidate.
///
/// Prefers the most recently CONNECTED candidate. Duplicates arise when
/// the peer reappears under a fresh BLE address (privacy address
/// rotation) while an older connection typically state-restored
/// lives on: only the newest connection sits on the address the peer
/// still advertises. Cancelling that one instead just gets it
/// rediscovered and reconnected, a retirereconnect oscillation at the
/// retirement cooldown (field-observed July 31); the older-address link
/// cannot return once cancelled, so consolidation converges immediately.
/// Physical connect recency is also a signal an announce replay cannot
/// nominate, unlike the previous ingress-link preference announce
/// anchors (ingress, then most recently bound) now only break ties and
/// serve links with no connect timestamp at all. Link "health" signals
/// like RSSI are deliberately not inputs: they are transient and the
/// stale-address link often reads stronger; connect recency is the only
/// signal that tracks address currency.
///
/// The survivor must be writable while any writable candidate exists:
/// keeping a characteristic-less link and cancelling the writable
/// duplicate would strand outbound traffic on the central link until
/// rediscovery finishes. When neither anchor is a viable candidate,
/// consolidation waits for a later announce rather than guessing.
/// rediscovery finishes. But when the physically NEWEST connection is
/// the one that is not writable yet (service discovery still running),
/// consolidation defers entirely selecting an older writable link
/// would cancel the freshly advertised connection and recreate the
/// oscillation. When no candidate is identifiable, consolidation waits
/// for a later announce rather than guessing.
static func keptPeripheralUUID(
ingressPeripheralUUID: String?,
mostRecentlyBoundUUID: String?,
@ -51,6 +80,42 @@ enum BLERedundantLinkPolicy {
let writable = bound.filter(\.hasCharacteristic)
let candidates = writable.isEmpty ? bound : writable
// The newest connection is still mid-service-discovery while a
// writable (typically restored, stale-address) duplicate exists:
// defer to a later announce instead of keeping the older link and
// cancelling the one connection on the currently advertised address.
if !writable.isEmpty,
let newestBoundDate = bound.compactMap(\.lastConnectedAt).max(),
!writable.contains(where: { $0.lastConnectedAt == newestBoundDate }) {
return nil
}
if let newestDate = candidates.compactMap(\.lastConnectedAt).max() {
let newest = candidates.filter { $0.lastConnectedAt == newestDate }
if newest.count == 1 {
return newest[0].uuid
}
return anchoredChoice(
among: newest,
ingressPeripheralUUID: ingressPeripheralUUID,
mostRecentlyBoundUUID: mostRecentlyBoundUUID
) ?? newest.map(\.uuid).min()
}
return anchoredChoice(
among: candidates,
ingressPeripheralUUID: ingressPeripheralUUID,
mostRecentlyBoundUUID: mostRecentlyBoundUUID
)
}
/// The pre-timestamp anchors: the verified announce's ingress link,
/// then the peer's most recently bound link.
private static func anchoredChoice(
among candidates: [PeripheralLink],
ingressPeripheralUUID: String?,
mostRecentlyBoundUUID: String?
) -> String? {
if let ingressPeripheralUUID, candidates.contains(where: { $0.uuid == ingressPeripheralUUID }) {
return ingressPeripheralUUID
}

View File

@ -0,0 +1,433 @@
//
// BLEService+LinkLayerCentralRole.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import CoreBluetooth
import Foundation
// The bleQueue half of the link layer: CoreBluetooth delegate callbacks do
// physical bookkeeping (link-state store, buffers, radio policy) and report
// everything else to the engine through the link-event port
// (BLELinkEvent / emitLinkEvent). See docs/BLE-ARCHITECTURE-V3.md.
// MARK: - CBCentralManagerDelegate
extension BLEService: CBCentralManagerDelegate {
#if os(iOS)
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
guard !isPanicSuspended else {
central.stopScan()
restoredPeripherals.forEach {
central.cancelPeripheralConnection($0)
}
return
}
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool
SecureLogger.info(
"♻️ Central restore: peripherals=\(restoredPeripherals.count) services=\(restoredServices.count) allowDuplicates=\(String(describing: allowDuplicates))",
category: .session
)
for peripheral in restoredPeripherals {
let identifier = peripheral.identifier.uuidString
peripheral.delegate = self
let existing = linkStateStore.state(forPeripheralID: identifier)
let assembler = existing?.assembler ?? NotificationStreamAssembler()
let characteristic = existing?.characteristic
let wasConnecting = existing?.isConnecting ?? false
let wasConnected = existing?.isConnected ?? false
let restoredState = BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: characteristic,
isConnecting: wasConnecting || peripheral.state == .connecting,
isConnected: wasConnected || peripheral.state == .connected,
lastConnectionAttempt: existing?.lastConnectionAttempt,
assembler: assembler
)
linkStateStore.setPeripheralState(restoredState, for: identifier)
// Restored peripherals are the freshest wake-on-proximity
// candidates we have after a relaunch without this the cache
// starts empty and backgrounding right after a restore arms
// nothing. Service rediscovery for restored-connected links waits
// for poweredOn: CoreBluetooth drops commands issued during
// restoration (API MISUSE warnings).
radio.recordRecentPeripheral(peripheral, peripheralID: identifier, at: Date())
}
// Via the sampler (not a direct capture): it refreshes the cached
// background budget on main first, so the restore log shows the real
// wake window instead of the init sentinel.
logBluetoothStatus("central-restore")
if central.state == .poweredOn {
radio.startScanning()
}
}
#endif
func centralManagerDidUpdateState(_ central: CBCentralManager) {
emitTransportEvent(.bluetoothStateUpdated(central.state))
switch central.state {
case .poweredOn:
guard !isPanicSuspended else {
central.stopScan()
return
}
// Links restored as connected have no characteristic in the new
// process; without rediscovery they sit connected-but-unusable
// until the peer disconnects. Runs here (not willRestoreState)
// because commands issued before poweredOn are dropped.
for state in linkStateStore.peripheralStates where state.isConnected
&& state.characteristic == nil
&& state.peripheral.state == .connected {
SecureLogger.info("♻️ Rediscovering services on restored link: \(state.peripheral.identifier.uuidString.prefix(8))", category: .session)
state.peripheral.discoverServices([BLEService.serviceUUID])
}
// Start scanning - use allow duplicates for faster discovery when active
radio.startScanning()
case .poweredOff:
// CoreBluetooth has already transitioned out of poweredOn. Do
// not issue stop/cancel commands now; they are rejected as API
// misuse. Retire our link state locally instead.
SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
let peripheralIDs = linkStateStore.peripheralStates.map { $0.peripheral.identifier.uuidString }
for peripheralID in peripheralIDs {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
linkStateStore.clearPeripherals()
emitLinkEvent(.allPeripheralLinksEnded(peripheralIDs: peripheralIDs, retireProofsAndNotify: true))
case .unauthorized:
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
linkStateStore.clearPeripherals()
emitLinkEvent(.allPeripheralLinksEnded(peripheralIDs: [], retireProofsAndNotify: false))
case .unsupported:
// Device doesn't support BLE
SecureLogger.error("❌ Bluetooth LE not supported on this device", category: .session)
case .resetting:
// Bluetooth stack is resetting - will get another state update when done
SecureLogger.info("🔄 Bluetooth stack resetting...", category: .session)
case .unknown:
// Initial state before we know the actual state
SecureLogger.debug("❓ Bluetooth state unknown (initializing)", category: .session)
@unknown default:
SecureLogger.warning("⚠️ Unknown Bluetooth state: \(central.state.rawValue)", category: .session)
}
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
radio.handleDiscovery(peripheral, advertisementData: advertisementData, rssi: RSSI)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
guard !isPanicSuspended else {
central.cancelPeripheralConnection(peripheral)
return
}
let peripheralID = peripheral.identifier.uuidString
#if os(iOS)
// A connect completing while backgrounded is the wake-on-proximity
// path doing its job worth an info line for field verification.
if !isAppActive {
SecureLogger.info("🌙 Background wake: connected to \(peripheral.name ?? peripheralID) while backgrounded", category: .session)
}
#endif
// Update state to connected
linkStateStore.markConnected(peripheral)
// Reset backoff state on success
radio.recordConnectionSuccess(peripheralID: peripheralID)
SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session)
// Discover services
peripheral.discoverServices([BLEService.serviceUUID])
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
let peripheralID = peripheral.identifier.uuidString
SecureLogger.debug("📱 Disconnect: \(peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session)
// If disconnect carried an error (often timeout), apply short backoff to avoid thrash
if error != nil {
radio.recordDisconnectError(peripheralID: peripheralID, at: Date())
}
// Retain the handle: a dropped link is the best wake-on-proximity
// candidate if the app backgrounds before the peer returns.
radio.recordRecentPeripheral(peripheral, peripheralID: peripheralID, at: Date())
#if os(iOS)
// Link lost while backgrounded (peer walked away): re-arm a pending
// connect during this wake window so the peer's return wakes us again.
// Delayed past the disconnect-settle window to avoid reconnect thrash
// at range edge.
if !isAppActive {
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleDisconnectDiscoveryIgnoreSeconds) { [weak self] in
guard let self, !self.isAppActive else { return }
// Reserve 0: use the slot this disconnect freed even in a
// dense mesh, so the lost peer can wake us when it returns.
self.radio.armPendingBackgroundConnects(slotReserve: 0)
}
}
#endif
// Physical teardown now; identity retirement and peer-disconnect
// bookkeeping ride the link-event port. The scan restart and
// connect-slot refill below stay on bleQueue they respond to
// the physical drop regardless of remaining logical links.
discardPeripheralLinkPhysical(peripheralID)
emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: true))
// Restart scanning with allow duplicates for faster rediscovery
if centralManager?.state == .poweredOn {
// Stop and restart scanning to ensure we get fresh discovery events
centralManager?.stopScan()
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in
self?.radio.startScanning()
}
}
// Attempt to fill freed slot from queue
bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() }
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
let peripheralID = peripheral.identifier.uuidString
// Clean up the references: physical now, identity via the port.
discardPeripheralLinkPhysical(peripheralID)
emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: false))
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
radio.recordConnectionFailure(peripheralID: peripheralID)
// Try next candidate
bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() }
}
}
// MARK: - CBPeripheralDelegate
extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard !isPanicSuspended else { return }
if let error = error {
SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
// Retry service discovery after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
guard peripheral.state == .connected else { return }
peripheral.discoverServices([BLEService.serviceUUID])
}
return
}
guard let services = peripheral.services else {
SecureLogger.warning("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: .session)
return
}
guard let service = services.first(where: { $0.uuid == BLEService.serviceUUID }) else {
// Not a BitChat peer - disconnect
centralManager?.cancelPeripheralConnection(peripheral)
return
}
// Discovering BLE characteristics
peripheral.discoverCharacteristics([BLEService.characteristicUUID], for: service)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
guard !isPanicSuspended else { return }
if let error = error {
SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
return
}
guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else {
SecureLogger.warning("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: .session)
return
}
// Found characteristic
// Log characteristic properties for debugging
var properties: [String] = []
if characteristic.properties.contains(.read) { properties.append("read") }
if characteristic.properties.contains(.write) { properties.append("write") }
if characteristic.properties.contains(.writeWithoutResponse) { properties.append("writeWithoutResponse") }
if characteristic.properties.contains(.notify) { properties.append("notify") }
if characteristic.properties.contains(.indicate) { properties.append("indicate") }
// Characteristic properties: \(properties.joined(separator: ", "))
// Verify characteristic supports reliable writes
if !characteristic.properties.contains(.write) {
SecureLogger.warning("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: .session)
}
// Store characteristic in our consolidated structure
let peripheralID = peripheral.identifier.uuidString
linkStateStore.updateCharacteristic(characteristic, forPeripheralID: peripheralID)
// Subscribe for notifications
if characteristic.properties.contains(.notify) {
peripheral.setNotifyValue(true, for: characteristic)
SecureLogger.debug("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: .session)
// Send announce after subscription is confirmed (force send for new connection)
engineScheduler.schedule(after: TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true)
// Try flushing any spooled directed packets now that we have a link
self?.flushDirectedSpool()
}
} else {
SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session)
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard !isPanicSuspended else { return }
if let error = error {
SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
return
}
guard let data = characteristic.value, !data.isEmpty else {
SecureLogger.warning("⚠️ No data in notification", category: .session)
return
}
bufferNotificationChunk(data, from: peripheral)
}
private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) {
let peripheralUUID = peripheral.identifier.uuidString
var state = linkStateStore.state(forPeripheralID: peripheralUUID) ?? BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: nil,
isConnecting: false,
isConnected: peripheral.state == .connected,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
)
var assembler = state.assembler
let result = assembler.append(chunk)
state.assembler = assembler
linkStateStore.setPeripheralState(state, for: peripheralUUID)
for byte in result.droppedPrefixes {
SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session)
}
if result.reset {
SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session)
}
// Attribution spoof rejection, announce binding, ingress
// recording is engine work now (the engine owns the bindings).
// Frames hop up in decode order; the engine's serial slot ordering
// gives the same same-batch spoof protection the old bleQueue-side
// batch-local binding enforced: an announce that binds this link is
// attributed before every frame that rode behind it.
for frame in result.frames {
guard let packet = BinaryProtocol.decode(frame) else {
let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
continue
}
emitLinkEvent(.frameDecoded(
packet,
link: .peripheral(peripheralUUID),
linkDescription: "Peripheral \(peripheralUUID.prefix(8))"
))
}
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
if let error = error {
SecureLogger.error("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: .session)
// Don't retry - just log the error
} else {
SecureLogger.debug("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
}
}
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
guard !isPanicSuspended else { return }
// Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") {
SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session)
}
drainPendingWrites(for: peripheral)
}
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
guard !isPanicSuspended else { return }
SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
let shouldRediscover = BLEService.shouldRediscoverBitChatService(
invalidatedServiceUUIDs: invalidatedServices.map(\.uuid),
cachedServiceUUIDs: peripheral.services?.map(\.uuid)
)
guard shouldRediscover else { return }
let peripheralID = peripheral.identifier.uuidString
linkStateStore.updatePeripheral(peripheralID) {
$0.characteristic = nil
$0.assembler = NotificationStreamAssembler()
}
SecureLogger.debug("🔄 BitChat service changed for \(peripheral.name ?? peripheral.identifier.uuidString), rediscovering", category: .session)
peripheral.discoverServices([BLEService.serviceUUID])
}
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
guard !isPanicSuspended else { return }
if let error = error {
SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
} else {
SecureLogger.debug("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: .session)
// If notifications are now on, send an announce to ensure this peer knows about us
if characteristic.isNotifying {
// Sending announce after subscription
self.sendAnnounce(forceSend: true)
}
}
}
}
extension BLEService {
static func shouldRediscoverBitChatService(
invalidatedServiceUUIDs: [CBUUID],
cachedServiceUUIDs: [CBUUID]?
) -> Bool {
invalidatedServiceUUIDs.contains(serviceUUID) || cachedServiceUUIDs?.contains(serviceUUID) != true
}
}

View File

@ -0,0 +1,320 @@
//
// BLEService+LinkLayerPeripheralRole.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import CoreBluetooth
import Foundation
// The bleQueue half of the link layer: CoreBluetooth delegate callbacks do
// physical bookkeeping (link-state store, buffers, radio policy) and report
// everything else to the engine through the link-event port
// (BLELinkEvent / emitLinkEvent). See docs/BLE-ARCHITECTURE-V3.md.
// MARK: - CBPeripheralManagerDelegate
extension BLEService: CBPeripheralManagerDelegate {
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session)
switch peripheral.state {
case .poweredOn:
guard !isPanicSuspended else {
peripheral.stopAdvertising()
peripheral.removeAllServices()
characteristic = nil
return
}
// Remove all services first to ensure clean state
peripheral.removeAllServices()
// Create characteristic
characteristic = CBMutableCharacteristic(
type: BLEService.characteristicUUID,
properties: [.notify, .write, .writeWithoutResponse, .read],
value: nil,
permissions: [.readable, .writeable]
)
// Create service
let service = CBMutableService(type: BLEService.serviceUUID, primary: true)
service.characteristics = [characteristic!]
// Add service (advertising will start in didAdd delegate)
SecureLogger.debug("🔧 Adding BLE service...", category: .session)
peripheral.add(service)
case .poweredOff:
// Bluetooth was turned off - clean up peripheral state
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
// Clear subscribed centrals (they are now invalid)
let centralIDs = linkStateStore.subscribedCentrals.map { $0.identifier.uuidString }
pendingNotifications.removeAll()
pendingWriteBuffers.removeAll()
linkStateStore.clearCentrals()
subscriptionAnnounceLimiter.removeAll()
characteristic = nil
emitLinkEvent(.allCentralLinksEnded(centralUUIDs: centralIDs, retireProofsAndNotify: true))
case .unauthorized:
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
linkStateStore.clearCentrals()
subscriptionAnnounceLimiter.removeAll()
characteristic = nil
emitLinkEvent(.allCentralLinksEnded(centralUUIDs: [], retireProofsAndNotify: false))
case .unsupported:
// Device doesn't support BLE peripheral role
SecureLogger.error("❌ Bluetooth LE peripheral role not supported", category: .session)
case .resetting:
// Bluetooth stack is resetting
SecureLogger.info("🔄 Bluetooth peripheral stack resetting...", category: .session)
case .unknown:
SecureLogger.debug("❓ Peripheral Bluetooth state unknown (initializing)", category: .session)
@unknown default:
SecureLogger.warning("⚠️ Unknown peripheral Bluetooth state: \(peripheral.state.rawValue)", category: .session)
}
}
#if os(iOS)
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
guard !isPanicSuspended else {
peripheral.stopAdvertising()
peripheral.removeAllServices()
characteristic = nil
return
}
let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
SecureLogger.info(
"♻️ Peripheral restore: services=\(restoredServices.count) advertisingDataKeys=\(Array(restoredAdvertisement.keys))",
category: .session
)
// Attempt to recover characteristic from restored services
if characteristic == nil {
if let service = restoredServices.first(where: { $0.uuid == BLEService.serviceUUID }),
let restoredCharacteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) as? CBMutableCharacteristic {
characteristic = restoredCharacteristic
}
}
// Via the sampler for a fresh background budget (see central-restore).
logBluetoothStatus("peripheral-restore")
if peripheral.state == .poweredOn && !peripheral.isAdvertising {
peripheral.startAdvertising(BLERadioController.advertisementData())
}
}
#endif
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
guard !isPanicSuspended else {
peripheral.stopAdvertising()
return
}
if let error = error {
SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session)
return
}
SecureLogger.debug("✅ Service added successfully, starting advertising", category: .session)
// Start advertising after service is confirmed added
let adData = BLERadioController.advertisementData()
peripheral.startAdvertising(adData)
SecureLogger.debug("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.id.prefix(8))…)", category: .session)
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
guard !isPanicSuspended else { return }
let centralUUID = central.identifier.uuidString
SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))", category: .session)
linkStateStore.addSubscribedCentral(central)
// BCH-01-004: Rate-limit subscription-triggered announces to prevent enumeration attacks
let now = Date()
switch subscriptionAnnounceLimiter.decision(for: centralUUID, now: now) {
case .allowed:
break
case let .rateLimited(backoffSeconds, attemptCount, suppressAnnounce):
SecureLogger.warning("🛡️ BCH-01-004: Rate-limited announce for central \(centralUUID.prefix(8))... (backoff: \(Int(backoffSeconds))s, attempts: \(attemptCount))", category: .security)
if suppressAnnounce {
SecureLogger.warning("🚨 BCH-01-004: Possible enumeration attack from central \(centralUUID.prefix(8))... - suppressing announce", category: .security)
return
}
// Still flush directed packets for legitimate mesh operation
engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
self?.flushDirectedSpool()
}
return
}
// Send announce to the newly subscribed central after a small delay
engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true)
// Flush any spooled directed packets now that we have a central subscribed
self?.flushDirectedSpool()
}
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
let centralID = central.identifier.uuidString
SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))", category: .session)
// bleQueue: physical retirement now.
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
linkStateStore.removeSubscribedCentral(central)
// Ensure we're still advertising for other devices to find us
if !isPanicSuspended, peripheral.isAdvertising == false {
SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
peripheral.startAdvertising(BLERadioController.advertisementData())
}
// Identity retirement and peer-disconnect bookkeeping ride the
// link-event port.
emitLinkEvent(.centralLinkEnded(centralUUID: centralID))
}
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
guard !isPanicSuspended else { return }
drainPendingNotifications(logPrefix: "✅ Sent")
}
func logBackpressureSampled(_ message: @autoclosure () -> String) {
notificationBackpressureLogCount += 1
if notificationBackpressureLogCount == 1 ||
notificationBackpressureLogCount.isMultiple(of: TransportConfig.bleBackpressureLogInterval) {
SecureLogger.debug("\(message()) [backpressure event #\(notificationBackpressureLogCount)]", category: .session)
}
}
func drainPendingNotifications(logPrefix: String) {
bleQueue.async { [weak self] in
guard let self = self,
let characteristic = self.characteristic,
!self.pendingNotifications.isEmpty else { return }
let pending = self.pendingNotifications.takeAll()
let sentCount = self.sendPendingNotifications(pending, characteristic: characteristic)
if sentCount > 0 {
self.logBackpressureSampled("\(logPrefix) \(sentCount) pending notifications from retry queue (\(self.pendingNotifications.count) still pending)")
}
}
}
private func sendPendingNotifications(_ pending: [BLEPendingNotification<CBCentral>], characteristic: CBMutableCharacteristic) -> Int {
var sentCount = 0
for (index, notification) in pending.enumerated() {
let success = peripheralManager?.updateValue(
notification.data,
for: characteristic,
onSubscribedCentrals: notification.targets
) ?? false
guard success else {
let remaining = Array(pending.dropFirst(index))
pendingNotifications.prepend(remaining)
logBackpressureSampled("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items")
break
}
sentCount += 1
}
return sentCount
}
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
// Suppress logs for single write requests to reduce noise
if requests.count > 1 {
SecureLogger.debug("📥 Received \(requests.count) write requests from central", category: .session)
}
// IMPORTANT: Respond immediately to prevent timeouts!
// We must respond within a few milliseconds or the central will timeout
for request in requests {
peripheral.respond(to: request, withResult: .success)
}
guard !isPanicSuspended else { return }
// Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets.
// Combine per-central request values by offset before decoding.
// Process directly on our message queue to match transport context
let grouped = Dictionary(grouping: requests, by: { $0.central.identifier.uuidString })
for (centralUUID, group) in grouped {
// Sort by offset ascending
let sorted = group.sorted { $0.offset < $1.offset }
let hasMultiple = sorted.count > 1 || (sorted.first?.offset ?? 0) > 0
let chunks = sorted.compactMap { request -> BLEInboundWriteChunk? in
guard let data = request.value, !data.isEmpty else { return nil }
return BLEInboundWriteChunk(offset: request.offset, data: data)
}
let result = pendingWriteBuffers.append(
chunks: chunks,
for: centralUUID,
capBytes: TransportConfig.blePendingWriteBufferCapBytes
)
switch result {
case let .decoded(packet, metadata):
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
processDecodedCentralWrite(packet, centralUUID: centralUUID, central: sorted[0].central)
case let .waiting(metadata):
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)
case let .oversized(metadata):
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(metadata.accumulatedBytes) bytes) for central \(centralUUID.prefix(8))", category: .session)
logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)
}
}
}
private func logAccumulatedCentralWrite(_ metadata: BLEInboundWriteAppendMetadata, centralUUID: String) {
guard let packetType = metadata.packetType,
packetType != MessageType.announce.rawValue else { return }
SecureLogger.debug(
"📥 Accumulated write from central \(centralUUID.prefix(8))…: size=\(metadata.accumulatedBytes) (+\(metadata.appendedBytes)) bytes (type=\(packetType)), offsets=\(metadata.offsets)",
category: .session
)
}
private func logFailedSingleWriteIfNeeded(hasMultiple: Bool, sortedRequests: [CBATTRequest]) {
guard !hasMultiple, let raw = sortedRequests.first?.value else { return }
let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session)
}
private func processDecodedCentralWrite(_ packet: BitchatPacket, centralUUID: String, central: CBCentral) {
// bleQueue: physical bookkeeping only. A writer is a live central
// whether or not it subscribed; track it so directed replies and
// the fanout planner can reach it.
linkStateStore.addSubscribedCentral(central)
// Attribution is engine work (the engine owns the bindings).
emitLinkEvent(.frameDecoded(
packet,
link: .central(centralUUID),
linkDescription: "Central \(centralUUID.prefix(8))"
))
}
}

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,8 @@ final class BoardManager: ObservableObject {
@Published private(set) var posts: [BoardPostPacket] = []
private let transport: Transport
/// Board broadcast rides the mesh only; absent on other transports.
private var boardTransport: MeshBoardBroadcasting? { transport as? MeshBoardBroadcasting }
/// Publishes a bridged kind-1 note (expiring with the board post via
/// NIP-40) and returns its Nostr event id, or nil when bridging failed or
/// was skipped.
@ -122,7 +124,7 @@ final class BoardManager: ObservableObject {
flags: flags,
signature: signature
)
transport.sendBoardPayload(BoardWire.post(post).encode())
boardTransport?.sendBoardPayload(BoardWire.post(post).encode())
// Nostr bridge: geohash posts also go out as kind-1 location notes so
// online users see them. Remember the event id for merged deletes.
@ -148,7 +150,7 @@ final class BoardManager: ObservableObject {
deletedAt: deletedAt,
signature: signature
)
transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
boardTransport?.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
// Merged delete: also retract the bridged Nostr copy when we still
// know its event id.

View File

@ -90,6 +90,9 @@ protocol CommandContextProvider: AnyObject {
final class CommandProcessor {
weak var contextProvider: CommandContextProvider?
weak var meshService: Transport?
/// Mesh-only command surfaces, absent when the transport lacks them.
private var meshDiagnostics: MeshDiagnosing? { meshService as? MeshDiagnosing }
private var meshArchive: MeshPublicArchiving? { meshService as? MeshPublicArchiving }
private let identityManager: SecureIdentityStateManagerProtocol
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
@ -371,7 +374,7 @@ final class CommandProcessor {
}
// Scrub their carried public messages now, while the peerID is
// resolvable, so they can't resurface as archived echoes.
meshService?.purgeArchivedPublicMessages(from: peerID)
meshArchive?.purgeArchivedPublicMessages(from: peerID)
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
}
// Mesh lookup failed; try geohash (Nostr) participant by display name
@ -474,7 +477,7 @@ final class CommandProcessor {
// meshPingTimeoutSeconds later, and reading the selected chat at
// callback time would misroute the result after a chat switch.
let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline
meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
meshDiagnostics?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
let provider = currentProvider
guard let result else {
provider?.addCommandOutput("no reply from \(nickname)", to: destination)
@ -496,7 +499,7 @@ final class CommandProcessor {
}
guard let mesh = meshService,
let intermediates = mesh.computeMeshPath(to: target.peerID) else {
let intermediates = meshDiagnostics?.computeMeshPath(to: target.peerID) else {
return .success(message: "no known path to \(target.nickname)")
}
// Graph-derived from gossiped neighbor claims, not route-recorded

View File

@ -0,0 +1,152 @@
import BitFoundation
import CoreBluetooth
import Foundation
/// Optional transport capabilities, discovered with `as?` instead of casting
/// to a concrete transport class. `Transport` stays the contract every
/// transport genuinely implements; a capability protocol here is the
/// contract for one mesh-only feature surface, so app wiring depends on the
/// feature it needs rather than on `BLEService` itself.
/// Radio-state reporting for transports backed by a local radio.
protocol BluetoothStateReporting: AnyObject {
func getCurrentBluetoothState() -> CBManagerState
}
/// Panic-mode lifecycle for transports that own durable identity state.
/// A transport implementing this owns its own restart sequencing:
/// `completePanicReset` decides whether services come back, so generic
/// `startServices()` calls after a panic belong only to transports that
/// don't implement it.
protocol PanicResettingTransport: AnyObject {
/// Quiesces the radio and drains in-flight work ahead of a panic wipe.
func suspendForPanicReset()
/// Finishes a panic wipe, optionally restarting services.
func completePanicReset(restartServices: Bool)
/// Rotates the transport identity as part of a panic reset.
func resetIdentityForPanic(currentNickname: String, restartServices: Bool)
}
/// File and private-media transfer over a mesh transport, including the
/// capability-proof policy that gates encrypted private media.
protocol MeshFileTransferring: AnyObject {
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
)
/// Automatic whole-file retry is admitted only while this exact Noise
/// generation authenticates bit 9. It must never queue across a session
/// replacement or enter the signed raw legacy path.
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
)
func cancelTransfer(_ transferId: String)
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
/// The exact current Noise generation that authenticated both encrypted
/// private media (bit 8) and durable receipts/retry (bit 9).
func authenticatedPrivateMediaReceiptSessionGeneration(to peerID: PeerID) -> UUID?
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
)
}
/// Live voice / push-to-talk: one encoded `VoiceBurstPacket`,
/// fire-and-forget inside the Noise session (private) or as a signed
/// ephemeral broadcast (public). Frames are only useful now the
/// transport drops them (never queues) without an established session.
protocol MeshVoiceStreaming: AnyObject {
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID)
func sendVoiceFrameBroadcast(_ burstContent: Data)
}
/// Courier store-and-forward: seal a message to the recipient's static
/// key and hand it to connected couriers for physical delivery while the
/// recipient is offline. Returns false when the transport cannot courier.
protocol MeshCourierTransporting: AnyObject {
@discardableResult
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
}
/// Private groups: creator-signed state travels 1:1 over Noise sessions;
/// group messages flood like public broadcasts.
protocol MeshGroupMessaging: AnyObject {
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID)
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
func broadcastGroupMessage(_ envelope: Data)
}
/// Bulletin board: broadcast a pre-signed board payload (post or
/// tombstone) so it spreads over relay and gossip sync.
protocol MeshBoardBroadcasting: AnyObject {
func sendBoardPayload(_ payload: Data)
}
/// Mesh diagnostics (/ping, /trace, topology map).
protocol MeshDiagnosing: AnyObject {
/// Sends a directed ping probe; the completion fires exactly once on
/// the main actor with the measured result, or nil on timeout.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void)
/// Estimated intermediate hops toward `peerID` from gossiped topology
/// ([] = direct link, nil = no known path).
func computeMeshPath(to peerID: PeerID) -> [PeerID]?
/// Current mesh graph for the topology map.
func currentMeshTopology() -> MeshTopologySnapshot?
}
/// QR verification and transitive vouching over the Noise session.
protocol MeshVerifying: AnyObject {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
/// Sends an encoded vouch-attestation batch inside the Noise session.
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
}
/// Store-and-forward archive: the public messages this device is carrying
/// for gossip sync, decoded for display as "heard here earlier" echoes.
protocol MeshPublicArchiving: AnyObject {
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void)
/// Drops any carried public messages from a (newly blocked) sender so
/// they can't resurface as archived echoes on a later launch.
func purgeArchivedPublicMessages(from peerID: PeerID)
/// Erases the whole carried public-message archive, on disk included.
func purgeAllArchivedPublicMessages()
}
/// Internet-gateway and geohash-bridge wiring surface (BLE mesh today).
/// Everything the gateway/bridge/courier services need from the mesh
/// transport, so their bootstrap wiring never touches the concrete class.
protocol MeshBridgingTransport: AnyObject {
// Runtime-advertised capability bits
func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool)
func setLocalBridgeGeohash(_ cell: String?)
func advertisedBridgeGeohash() -> String?
// Peers currently advertising bridging roles
func reachableGatewayPeers() -> [PeerID]
func reachableBridgePeers() -> [PeerID]
// Gateway carrier packets (mesh <-> Nostr uplink/downlink)
@discardableResult
func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool
func broadcastNostrCarrier(_ payload: Data)
/// Sink for received carrier packets (set once by app wiring; called on
/// the main actor after transport-level checks).
var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)? { get set }
// Bridge courier drops (sealed envelopes carried across the bridge)
func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope?
@discardableResult
func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool
@discardableResult
func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool
func myNoiseStaticPublicKey() -> Data
func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)]
/// Fired (off-main) when a signature-verified announce is processed.
var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)? { get set }
}

View File

@ -104,12 +104,10 @@ final class LRUDeduplicationCache<Value> {
enum ContentNormalizer {
/// Regex to simplify HTTP URLs by stripping query strings and fragments
private static let simplifyHTTPURL: NSRegularExpression = {
try! NSRegularExpression(
pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?",
options: [.caseInsensitive]
)
}()
private static let simplifyHTTPURL = SafeRegex.compile(
"https?://[^\\s?#]+(?:[?#][^\\s]*)?",
options: [.caseInsensitive]
)
/// Normalizes content for deduplication comparison.
/// - Parameters:

View File

@ -39,37 +39,23 @@ final class MessageFormattingEngine {
/// Precompiled regex patterns for message content parsing
enum Patterns {
static let hashtag: NSRegularExpression = {
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
}()
static let hashtag = SafeRegex.compile("#([a-zA-Z0-9_]+)")
static let mention: NSRegularExpression = {
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
}()
static let mention = SafeRegex.compile("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)")
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let cashu = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b")
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let bolt11 = SafeRegex.compile("(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b")
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
static let lnurl = SafeRegex.compile("(?i)\\blnurl1[a-z0-9]{20,}\\b")
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let lightningScheme = SafeRegex.compile("(?i)\\blightning:[^\\s]+")
static let linkDetector: NSDataDetector? = {
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
}()
static let quickCashuPresence: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let quickCashuPresence = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b")
}
// MARK: - Match Types
@ -124,11 +110,12 @@ final class MessageFormattingEngine {
)
// Format content
let myNickname = context.nickname.normalizedNickname
let contentResult = formatContent(
message.content,
baseColor: baseColor,
isSelf: isSelf,
isMentioned: message.mentions?.contains(context.nickname) ?? false
isMentioned: message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false
)
result.append(contentResult)

View File

@ -281,6 +281,7 @@ final class MessageRouter {
guard remainingSlots > 0 else { return }
for transport in transports {
guard let courierTransport = transport as? MeshCourierTransporting else { continue }
let couriers = eligibleCouriers(
on: transport,
recipientKey: recipientKey,
@ -288,7 +289,7 @@ final class MessageRouter {
limit: remainingSlots
)
guard !couriers.isEmpty else { continue }
if transport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) {
if courierTransport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) {
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))", category: .session)
recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey))
onMessageCarried?(messageID, peerID)
@ -304,6 +305,7 @@ final class MessageRouter {
/// `maxCouriersPerMessage` distinct couriers or expires.
func courierBecameAvailable(_ peerID: PeerID) {
for transport in transports {
guard let courierTransport = transport as? MeshCourierTransporting else { continue }
guard transport.isPeerConnected(peerID),
let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }),
let courierKey = snapshot.noisePublicKey,
@ -319,7 +321,7 @@ final class MessageRouter {
guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage,
!message.depositedCourierKeys.contains(courierKey),
currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue }
if transport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) {
if courierTransport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) {
SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))", category: .session)
recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey])
onMessageCarried?(message.messageID, recipient)

View File

@ -1089,6 +1089,10 @@ final class NoiseEncryptionService {
func _test_initiateAutomaticRekey(for peerID: PeerID) throws {
try initiateAutomaticRekey(for: peerID)
}
func _test_fireSuppressedInitiationRecovery(for peerID: PeerID) {
sessionManager._test_fireSuppressedInitiationRecovery(for: peerID)
}
#endif
deinit {

View File

@ -203,14 +203,12 @@ final class PrivateChatManager: ObservableObject {
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
for message in messages(for: peerID) {
if message.sender == nickname {
if let status = message.deliveryStatus {
switch status {
case .read, .delivered:
externalReceipts.insert(message.id)
sentReadReceipts.insert(message.id)
case .failed, .partiallyDelivered, .sending, .sent, .carried:
break
}
switch message.deliveryStatus {
case .read, .delivered:
externalReceipts.insert(message.id)
sentReadReceipts.insert(message.id)
case .notSentYet, .failed, .partiallyDelivered, .sending, .sent, .carried:
break
}
}
}

View File

@ -203,99 +203,14 @@ protocol Transport: AnyObject {
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
func sendBroadcastAnnounce()
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
)
/// Automatic whole-file retry is admitted only while this exact Noise
/// generation authenticates bit 9. It must never queue across a session
/// replacement or enter the signed raw legacy path.
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
)
func cancelTransfer(_ transferId: String)
// Live voice / push-to-talk (mesh transports only): one encoded
// `VoiceBurstPacket`, fire-and-forget inside the Noise session. Frames are
// only useful now transports drop them (never queue) when no
// established session exists.
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID)
// Public-mesh counterpart: signed ephemeral broadcast, never synced.
func sendVoiceFrameBroadcast(_ burstContent: Data)
// Courier store-and-forward (mesh transports only): seal a message to the
// recipient's static key and hand it to connected couriers for physical
// delivery while the recipient is offline. Returns false when the
// transport cannot courier (no connected courier, or unsupported).
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
// Private groups (mesh transports only): creator-signed state travels
// 1:1 over Noise sessions; group messages flood like public broadcasts.
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID)
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
func broadcastGroupMessage(_ envelope: Data)
// Bulletin board (mesh transports only): broadcast a pre-signed board
// payload (post or tombstone) so it spreads over relay and gossip sync.
func sendBoardPayload(_ payload: Data)
// Mesh diagnostics (optional for transports). Defaults are inert so
// queue-backed transports (e.g. NostrTransport) stay untouched.
/// Sends a directed ping probe; the completion fires exactly once on the
/// main actor with the measured result, or nil on timeout/unsupported.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void)
/// Estimated intermediate hops toward `peerID` from gossiped topology
/// ([] = direct link, nil = no known path).
func computeMeshPath(to peerID: PeerID) -> [PeerID]?
/// Current mesh graph for the topology map; nil when unsupported.
func currentMeshTopology() -> MeshTopologySnapshot?
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
// Vouching / transitive verification (optional for transports)
/// Capabilities the peer advertised in its last verified announce;
/// empty for peers that predate the capabilities TLV.
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
/// The exact current Noise generation that authenticated both encrypted
/// private media (bit 8) and durable receipts/retry (bit 9).
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID?
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
)
/// Sends an encoded vouch-attestation batch inside the Noise session.
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
/// Appends a peer-authenticated observer. Unlike
/// `installNoiseSessionCallbacks` this never touches the (single-slot)
/// handshake-required callback, so secondary features can observe
/// session establishment without disturbing the primary registration.
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void)
// Pending file management (BCH-01-002: files held in memory until user accepts)
func acceptPendingFile(id: String) -> URL?
func declinePendingFile(id: String)
// Store-and-forward archive (mesh transports only): the public messages
// this device is carrying for gossip sync, decoded for display as
// "heard here earlier" timeline echoes.
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void)
/// Drops any carried public messages from a (newly blocked) sender so
/// they can't resurface as archived echoes on a later launch.
func purgeArchivedPublicMessages(from peerID: PeerID)
/// Erases the whole carried public-message archive, on disk included, so
/// clearing the mesh timeline deletes that history rather than hiding it.
func purgeAllArchivedPublicMessages()
}
/// A carried public mesh message from the store-and-forward window, decoded
@ -341,72 +256,12 @@ extension Transport {
onHandshakeRequired: @escaping (PeerID) -> Void
) {}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {}
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
func broadcastGroupMessage(_ envelope: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
nil
}
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
let policy = privateMediaSendPolicy(to: peerID)
Task { @MainActor in
completion(policy == .awaitingCapabilityProof ? .blockedDowngrade : policy)
}
}
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
func sendBoardPayload(_ payload: Data) {}
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {}
func sendVoiceFrameBroadcast(_ burstContent: Data) {}
// Mesh diagnostics are mesh-transport-only; other transports report
// "no reply"/"no path" rather than pretending to measure anything.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
Task { @MainActor in completion(nil) }
}
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
guard !allowLegacyFallback else { return }
sendFilePrivate(packet, to: peerID, transferId: transferId)
}
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
) {}
func cancelTransfer(_ transferId: String) {}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
sendMessage(content, mentions: mentions)
}
func acceptPendingFile(id: String) -> URL? { nil }
func declinePendingFile(id: String) {}
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
Task { @MainActor in completion([]) }
}
func purgeArchivedPublicMessages(from peerID: PeerID) {}
func purgeAllArchivedPublicMessages() {}
}
protocol TransportPeerEventsDelegate: AnyObject {
@ -450,3 +305,14 @@ extension BitchatDelegate {
}
extension BLEService: Transport {}
extension BLEService: MeshFileTransferring {}
extension BLEService: MeshVoiceStreaming {}
extension BLEService: MeshCourierTransporting {}
extension BLEService: MeshGroupMessaging {}
extension BLEService: MeshBoardBroadcasting {}
extension BLEService: MeshDiagnosing {}
extension BLEService: MeshVerifying {}
extension BLEService: MeshPublicArchiving {}
extension BLEService: BluetoothStateReporting {}
extension BLEService: PanicResettingTransport {}
extension BLEService: MeshBridgingTransport {}

View File

@ -236,8 +236,11 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
/// Get peer ID for nickname
func getPeerID(for nickname: String) -> PeerID? {
// Normalize both sides: the query may come from typed content and
// stored names may predate NFC-at-ingest (e.g. persisted favorites).
let target = nickname.normalizedNickname
for peer in peers {
if peer.displayName == nickname || peer.nickname == nickname {
if peer.displayName.normalizedNickname == target || peer.nickname.normalizedNickname == target {
return peer.peerID
}
}
@ -279,7 +282,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// Purge while the fingerprintpeerID mapping is still known: the
// archived-echo seed filter can't resolve offline strangers, so
// scrub their carried messages now rather than at relaunch.
meshService.purgeArchivedPublicMessages(from: peerID)
(meshService as? MeshPublicArchiving)?.purgeArchivedPublicMessages(from: peerID)
}
updatePeers()
return fingerprint

View File

@ -39,9 +39,10 @@ struct InputValidator {
return trimmed
}
/// Validates nickname
/// Validates nickname and returns it in canonical (NFC) form so
/// visually identical names always compare equal.
static func validateNickname(_ nickname: String) -> String? {
return validateUserString(nickname, maxLength: Limits.maxNicknameLength)
return validateUserString(nickname, maxLength: Limits.maxNicknameLength)?.normalizedNickname
}
// MARK: - Protocol Field Validation

View File

@ -0,0 +1,36 @@
//
// SafeRegex.swift
// bitchat
//
// Non-trapping construction for the app's compiled-in regex patterns.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
enum SafeRegex {
/// Compiles a bundled pattern. On failure it logs and returns a regex
/// that can never match, so a bad pattern degrades that one feature
/// instead of crashing at startup.
static func compile(_ pattern: String, options: NSRegularExpression.Options = []) -> NSRegularExpression {
do {
return try NSRegularExpression(pattern: pattern, options: options)
} catch {
SecureLogger.error("Regex pattern failed to compile, matching disabled: \(pattern) (\(error))", category: .session)
return neverMatching
}
}
/// `(?!)` an empty negative lookahead always compiles and can never match.
private static let neverMatching: NSRegularExpression = {
if let regex = try? NSRegularExpression(pattern: "(?!)", options: []) {
return regex
}
// Unreachable: "(?!)" is a valid ICU pattern. The inherited plain
// initializer (empty pattern) is the least-bad non-trapping fallback
// if ICU itself were ever broken.
return NSRegularExpression()
}()
}

View File

@ -9,6 +9,14 @@
import Foundation
extension String {
/// Canonical form for nickname storage and comparison (Unicode NFC).
/// "café" typed with a combining accent and "café" typed precomposed
/// must resolve to the same user wherever nicknames are stored or
/// matched (mentions, DM resolution, autocomplete, geo presence).
var normalizedNickname: String {
precomposedStringWithCanonicalMapping
}
/// Split a nickname into base and a '#abcd' suffix if present
func splitSuffix() -> (String, String) {
let name = self.replacingOccurrences(of: "@", with: "")

View File

@ -31,6 +31,10 @@ protocol ChatComposerContext: AnyObject {
/// The transport's own nickname (excluded from autocomplete candidates).
var meshNickname: String { get }
func meshPeerNicknames() -> [PeerID: String]
/// True when this mesh nickname belongs to a blocked peer.
func isMeshNicknameBlocked(_ nickname: String) -> Bool
/// True when this geohash pubkey is blocked for location chats.
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
// MARK: Geohash identity (shared with the other contexts)
var geoNicknames: [String: String] { get }
@ -40,8 +44,8 @@ protocol ChatComposerContext: AnyObject {
extension ChatViewModel: ChatComposerContext {
// `autocompleteSuggestions`, `autocompleteRange`, `showAutocomplete`,
// `selectedAutocompleteIndex`, `nickname`, `myPeerID`, `activeChannel`,
// `geoNicknames`, `meshPeerNicknames()`, and
// `deriveNostrIdentity(forGeohash:)` are shared requirements with the
// `geoNicknames`, `meshPeerNicknames()`, `isNostrBlocked(pubkeyHexLowercased:)`,
// and `deriveNostrIdentity(forGeohash:)` are shared requirements with the
// other contexts or satisfied by existing `ChatViewModel` members. The
// members below flatten nested service accesses into intent-named calls.
@ -60,6 +64,13 @@ extension ChatViewModel: ChatComposerContext {
var meshNickname: String {
meshService.myNickname
}
func isMeshNicknameBlocked(_ nickname: String) -> Bool {
for (peerID, nick) in meshService.getPeerNicknames() where nick == nickname {
if isPeerBlocked(peerID) { return true }
}
return false
}
}
@MainActor
@ -136,11 +147,14 @@ private extension ChatComposerCoordinator {
switch context.activeChannel {
case .mesh:
let values = context.meshPeerNicknames().values
return Array(values.filter { $0 != context.meshNickname })
return Array(values.filter { nick in
nick != context.meshNickname && !context.isMeshNicknameBlocked(nick)
})
case .location(let channel):
var tokens = Set<String>()
for (pubkey, nick) in context.geoNicknames {
guard !context.isNostrBlocked(pubkeyHexLowercased: pubkey) else { continue }
tokens.insert("\(nick)#\(pubkey.suffix(4))")
}
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {

View File

@ -105,16 +105,19 @@ extension ChatViewModel: ChatGroupContext {
identityManager.isBlocked(fingerprint: fingerprint)
}
/// Group state rides the mesh's Noise sessions only.
private var groupTransport: MeshGroupMessaging? { meshService as? MeshGroupMessaging }
func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) {
meshService.sendGroupInvite(payload, to: peerID)
groupTransport?.sendGroupInvite(payload, to: peerID)
}
func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) {
meshService.sendGroupKeyUpdate(payload, to: peerID)
groupTransport?.sendGroupKeyUpdate(payload, to: peerID)
}
func broadcastGroupMessagePayload(_ payload: Data) {
meshService.broadcastGroupMessage(payload)
groupTransport?.broadcastGroupMessage(payload)
}
// MARK: CommandContextProvider group commands (parsed by CommandProcessor)

View File

@ -106,8 +106,8 @@ extension ChatViewModel: ChatLifecycleContext {
}
func refreshBluetoothState() {
if let bleService = meshService as? BLEService {
updateBluetoothState(bleService.getCurrentBluetoothState())
if let radio = meshService as? BluetoothStateReporting {
updateBluetoothState(radio.getCurrentBluetoothState())
}
}
@ -360,9 +360,9 @@ private extension ChatLifecycleCoordinator {
}
}
func deliveryStatusRank(_ status: DeliveryStatus?) -> Int {
guard let status else { return 0 }
func deliveryStatusRank(_ status: DeliveryStatus) -> Int {
switch status {
case .notSentYet: return 0
case .failed: return 1
case .sending: return 2
case .sent: return 3

View File

@ -353,7 +353,11 @@ final class ChatLiveVoiceCoordinator {
// Eviction skips voice_live_* names, so partials still streaming in
// are safe no matter which caller triggers enforcement.
fileStore.enforceQuota(reservingBytes: TransportConfig.pttMaxBurstBytes)
fileManager.createFile(atPath: fileURL.path, contents: nil)
fileManager.createFile(
atPath: fileURL.path,
contents: nil,
attributes: BLEIncomingFileStore.mediaProtectionAttributes
)
guard let handle = try? FileHandle(forWritingTo: fileURL) else {
SecureLogger.error("PTT: cannot open capture file for burst \(burstID.hexEncodedString())", category: .session)
try? fileManager.removeItem(at: fileURL)

View File

@ -151,14 +151,19 @@ extension ChatViewModel: ChatMediaTransferContext {
// other contexts or satisfied by existing `ChatViewModel` members. The
// members below flatten mesh service accesses.
/// File transfer rides the mesh only. Without that capability the
/// policy degrades to the safe floor (blocked), matching the old
/// inert protocol defaults.
private var fileTransport: MeshFileTransferring? { meshService as? MeshFileTransferring }
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
meshService.privateMediaSendPolicy(to: peerID)
fileTransport?.privateMediaSendPolicy(to: peerID) ?? .blockedDowngrade
}
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
meshService.authenticatedPrivateMediaReceiptSessionGeneration(
fileTransport?.authenticatedPrivateMediaReceiptSessionGeneration(
to: peerID
)
}
@ -167,7 +172,11 @@ extension ChatViewModel: ChatMediaTransferContext {
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion)
guard let fileTransport else {
Task { @MainActor in completion(.blockedDowngrade) }
return
}
fileTransport.resolvePrivateMediaSendPolicy(to: peerID, completion: completion)
}
func requestLegacyPrivateMediaConsent(
@ -197,7 +206,7 @@ extension ChatViewModel: ChatMediaTransferContext {
transferId: String,
allowLegacyFallback: Bool
) {
meshService.sendFilePrivate(
fileTransport?.sendFilePrivate(
packet,
to: peerID,
transferId: transferId,
@ -210,7 +219,7 @@ extension ChatViewModel: ChatMediaTransferContext {
to peerID: PeerID,
transferId: String
) {
meshService.sendFilePrivateReceiptRetry(
fileTransport?.sendFilePrivateReceiptRetry(
packet,
to: peerID,
transferId: transferId
@ -218,11 +227,11 @@ extension ChatViewModel: ChatMediaTransferContext {
}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
meshService.sendFileBroadcast(packet, transferId: transferId)
fileTransport?.sendFileBroadcast(packet, transferId: transferId)
}
func cancelTransfer(_ transferId: String) {
meshService.cancelTransfer(transferId)
fileTransport?.cancelTransfer(transferId)
}
func removeUntombstonedMediaMessage(withID messageID: String) {
@ -1890,7 +1899,7 @@ private extension ChatMediaTransferCoordinator {
try FileManager.default.createDirectory(
at: filesDirectory,
withIntermediateDirectories: true,
attributes: nil
attributes: BLEIncomingFileStore.mediaProtectionAttributes
)
return filesDirectory
}

View File

@ -41,7 +41,9 @@ final class ChatMessageFormatter {
}()
let isDark = colorScheme == .dark
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant) {
let isVerifiedSender = !isSelf && isVerifiedSender(of: message)
let cacheVariant = theme.formatCacheVariant + (isVerifiedSender ? "-vf" : "")
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: cacheVariant) {
return cachedText
}
@ -66,6 +68,9 @@ final class ChatMessageFormatter {
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
if isVerifiedSender {
appendVerifiedSeal(to: &result, baseColor: baseColor, design: design)
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))
let content = message.content
@ -183,7 +188,8 @@ final class ChatMessageFormatter {
allMatches.sort { $0.range.location < $1.range.location }
var lastEnd = content.startIndex
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
let myNickname = viewModel.nickname.normalizedNickname
let isMentioned = message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false
for (range, type) in allMatches {
guard let swiftRange = Range(range, in: content) else { continue }
@ -335,7 +341,7 @@ final class ChatMessageFormatter {
result.append(timestamp.mergingAttributes(timestampStyle))
}
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant)
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: cacheVariant)
return result
}
@ -356,6 +362,7 @@ final class ChatMessageFormatter {
let isDark = colorScheme == .dark
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
let isVerifiedSender = !isSelf && isVerifiedSender(of: message)
if message.sender == "system" {
var style = AttributeContainer()
@ -381,6 +388,9 @@ final class ChatMessageFormatter {
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
if isVerifiedSender {
appendVerifiedSeal(to: &result, baseColor: baseColor, design: design)
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))
return result
}
@ -427,6 +437,29 @@ final class ChatMessageFormatter {
}
private extension ChatMessageFormatter {
/// Whether the message sender has a fingerprint the user has verified.
/// Used for the in-chat seal next to `<@name>` so verification is visible
/// without opening the fingerprint sheet (#1439).
func isVerifiedSender(of message: BitchatMessage) -> Bool {
guard let peerID = message.senderPeerID,
let fingerprint = viewModel.getFingerprint(for: peerID) else {
return false
}
return viewModel.peerIdentityStore.isVerified(fingerprint)
}
func appendVerifiedSeal(
to result: inout AttributedString,
baseColor: Color,
design: Font.Design
) {
var sealStyle = AttributeContainer()
// Match the peer-list verified seal: filled checkmark in the sender tint.
sealStyle.foregroundColor = baseColor
sealStyle.font = .bitchatSystem(size: 11, weight: .semibold, design: design)
result.append(AttributedString("").mergingAttributes(sealStyle))
}
func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
if let spid = message.senderPeerID {
if spid.isGeoChat || spid.isGeoDM {

View File

@ -501,6 +501,9 @@ final class ChatPeerIdentityCoordinator {
@MainActor
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
// Queries arrive from typed commands and message content, so bring
// them to the same canonical (NFC) form nicknames are stored in.
let nickname = nickname.normalizedNickname
switch context.activeChannel {
case .location:
if nickname.contains("#"),

View File

@ -506,14 +506,15 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
}
func checkForMentions(_ message: BitchatMessage) {
var myTokens: Set<String> = [context.nickname]
let myNickname = context.nickname.normalizedNickname
var myTokens: Set<String> = [myNickname]
let meshPeers = context.meshPeerNicknames()
let collisions = meshPeers.values.filter { $0.hasPrefix(context.nickname + "#") }
let collisions = meshPeers.values.filter { $0.normalizedNickname.hasPrefix(myNickname + "#") }
if !collisions.isEmpty {
let suffix = "#" + String(context.myPeerID.id.prefix(4))
myTokens = [context.nickname + suffix]
myTokens = [myNickname + suffix]
}
let isMentioned = message.mentions?.contains(where: myTokens.contains) ?? false
let isMentioned = message.mentions?.contains { myTokens.contains($0.normalizedNickname) } ?? false
if isMentioned && message.sender != context.nickname {
SecureLogger.info("🔔 Mention from \(message.sender)", category: .session)

View File

@ -129,12 +129,15 @@ extension ChatViewModel: ChatVerificationContext {
messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases)
}
/// QR verification rides the mesh's Noise sessions only.
private var verifyTransport: MeshVerifying? { meshService as? MeshVerifying }
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
verifyTransport?.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
verifyTransport?.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
}
func postLocalNotification(title: String, body: String, identifier: String) {

View File

@ -176,10 +176,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
var networkActivationAllowed: Bool { !panicRecoveryBlocked }
@Published var nickname: String = "" {
didSet {
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
let trimmed = nickname.trimmedOrNilIfEmpty ?? ""
if trimmed != nickname {
nickname = trimmed
// Canonicalize whenever nickname is set: trim whitespace
// (whitespace-only becomes "") and apply Unicode NFC so accented
// names match regardless of how they were typed.
let cleaned = (nickname.trimmedOrNilIfEmpty ?? "").normalizedNickname
if cleaned != nickname {
nickname = cleaned
return
}
// Update mesh service nickname if it's initialized
@ -1069,7 +1071,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
}
func purgeArchivedPublicMessages() {
meshService.purgeAllArchivedPublicMessages()
(meshService as? MeshPublicArchiving)?.purgeAllArchivedPublicMessages()
}
/// Queues a system message for the next geohash channel visit. (Tiny
@ -1564,8 +1566,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// Quiesce the mesh before clearing stores. Identity replacement below
// deliberately stays stopped until media deletion and marker commit.
if let bleService = meshService as? BLEService {
bleService.suspendForPanicReset()
if let panicTransport = meshService as? PanicResettingTransport {
panicTransport.suspendForPanicReset()
} else {
meshService.emergencyDisconnectAll()
}
@ -1700,8 +1702,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// Replace the BLE identity while keeping the radio stopped. It may
// reopen only after the durable panic transaction commits.
if let bleService = meshService as? BLEService {
bleService.resetIdentityForPanic(
if let panicTransport = meshService as? PanicResettingTransport {
panicTransport.resetIdentityForPanic(
currentNickname: nickname,
restartServices: false
)
@ -1746,18 +1748,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
guard panicCompleted else { return false }
if let bleService = meshService as? BLEService {
if let panicTransport = meshService as? PanicResettingTransport {
// Startup recovery reopens admission but leaves actual service
// start to the bootstrapper immediately after this method.
bleService.completePanicReset(
panicTransport.completePanicReset(
restartServices: restartServices
)
}
if restartServices {
// All persistent state and media are gone. Bring each service back
// only now, under the new identity.
if !(meshService is BLEService) {
// only now, under the new identity a panic-resetting transport
// owns its own restart sequencing above.
if !(meshService is PanicResettingTransport) {
meshService.startServices()
}

View File

@ -195,9 +195,8 @@ private extension ChatViewModelBootstrapper {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in
guard let viewModel,
let bleService = viewModel.meshService as? BLEService else { return }
let state = bleService.getCurrentBluetoothState()
viewModel.updateBluetoothState(state)
let radio = viewModel.meshService as? BluetoothStateReporting else { return }
viewModel.updateBluetoothState(radio.getCurrentBluetoothState())
}
viewModel.nostrRelayManager = NostrRelayManager.shared
@ -219,8 +218,9 @@ private extension ChatViewModelBootstrapper {
/// right after transport start, so give it a beat before asking.
private func loadArchivedEchoes() {
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiArchivedEchoLoadDelaySeconds) { [weak viewModel] in
guard let viewModel else { return }
viewModel.meshService.collectArchivedPublicMessages { [weak viewModel] allArchived in
guard let viewModel,
let archive = viewModel.meshService as? MeshPublicArchiving else { return }
archive.collectArchivedPublicMessages { [weak viewModel] allArchived in
guard let viewModel else { return }
// A previous /clear dismissed everything heard up to its
// watermark; only newer archive entries come back. Blocking a
@ -331,7 +331,7 @@ private extension ChatViewModelBootstrapper {
func configureGateway() {
// Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests)
// has no carrier packets to bridge.
guard let bleService = viewModel.meshService as? BLEService else { return }
guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return }
let gateway = GatewayService.shared
gateway.publishToRelays = { event, geohash in
@ -410,7 +410,7 @@ private extension ChatViewModelBootstrapper {
/// transport, the relay manager, location, and the public timeline. Same
/// closure-injection style as `configureGateway`.
func configureBridge() {
guard let bleService = viewModel.meshService as? BLEService else { return }
guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return }
let bridge = BridgeService.shared
let idBridge = viewModel.idBridge
@ -545,7 +545,7 @@ private extension ChatViewModelBootstrapper {
/// manager, the mesh transport's sealing/opening primitives, the courier
/// store, and the message router's deposit path.
func configureBridgeCourier() {
guard let bleService = viewModel.meshService as? BLEService else { return }
guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return }
let courier = BridgeCourierService.shared
courier.bridgeEnabled = { BridgeService.shared.isEnabled }

View File

@ -90,7 +90,7 @@ extension ChatViewModel: ChatVouchContext {
}
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
meshService.sendVouchAttestations(payload, to: peerID)
(meshService as? MeshVerifying)?.sendVouchAttestations(payload, to: peerID)
}
func notifyPeerTrustChanged() {

View File

@ -97,14 +97,17 @@ extension ChatViewModel {
/// `sendVoiceNote(at:)`, which live receivers absorb into the live bubble.
@MainActor
func makeVoiceCaptureSession() -> VoiceCaptureSession {
// Live voice rides the mesh only; frames are useful now or never,
// so a transport without the capability just drops them.
let voiceTransport = meshService as? MeshVoiceStreaming
switch liveVoiceTarget() {
case .peer(let peerID):
return PTTLiveVoiceSession(sendPacket: { [meshService] packet in
meshService.sendVoiceFrame(packet, to: peerID)
return PTTLiveVoiceSession(sendPacket: { packet in
voiceTransport?.sendVoiceFrame(packet, to: peerID)
})
case .publicMesh:
return PTTLiveVoiceSession(sendPacket: { [meshService] packet in
meshService.sendVoiceFrameBroadcast(packet)
return PTTLiveVoiceSession(sendPacket: { packet in
voiceTransport?.sendVoiceFrameBroadcast(packet)
})
case nil:
SecureLogger.info("PTT: hold uses classic voice note (liveVoiceEnabled=\(PTTSettings.liveVoiceEnabled), dmSelected=\(selectedPrivateChatPeer != nil))", category: .session)

View File

@ -15,6 +15,8 @@ extension DeliveryStatus {
/// the glyphs alone are unexplained 10pt icons.
var bitchatDescription: String {
switch self {
case .notSentYet:
return String(localized: "content.delivery.not_sent_yet", defaultValue: "Not sent yet", comment: "Delivery status description for a message that has not entered any send pipeline")
case .sending:
return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent")
case .sent:
@ -72,6 +74,13 @@ struct DeliveryStatusView: View {
@ViewBuilder
private var statusGlyph: some View {
switch status {
case .notSentYet:
// Normally hidden by callers; shown as a hollow dotted circle if
// it ever surfaces so the state is visible rather than invisible.
Image(systemName: "circle.dotted")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6))
case .sending:
Image(systemName: "circle")
.font(.bitchatSystem(size: 10))
@ -125,6 +134,7 @@ struct DeliveryStatusView: View {
#Preview {
let statuses: [DeliveryStatus] = [
.notSentYet,
.sending,
.sent,
.carried,

View File

@ -23,7 +23,7 @@ struct TextMessageView: View {
/// SAME instance would otherwise compare "unchanged" and this row's body
/// would be skipped even though the parent list re-rendered. Snapshotting
/// the enum makes the change visible to SwiftUI's structural diff.
private let deliveryStatus: DeliveryStatus?
private let deliveryStatus: DeliveryStatus
@State private var expandedMessageIDs: Set<String> = []
@State private var showDeliveryDetail = false
@ -68,11 +68,11 @@ struct TextMessageView: View {
// .help() tooltips only exist on macOS, so iOS users get the
// explanation as a caption under the row instead.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
deliveryStatus != .notSentYet {
Button {
showDeliveryDetail.toggle()
} label: {
DeliveryStatusView(status: status)
DeliveryStatusView(status: deliveryStatus)
.padding(.leading, 4)
.contentShape(Rectangle())
}
@ -86,15 +86,15 @@ struct TextMessageView: View {
// Failure reasons stay visible without a tap; other statuses
// reveal on demand.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
if case .failed = status {
Text(verbatim: status.bitchatDescription)
deliveryStatus != .notSentYet {
if case .failed = deliveryStatus {
Text(verbatim: deliveryStatus.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(Color.red.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 2)
} else if showDeliveryDetail {
Text(verbatim: status.bitchatDescription)
Text(verbatim: deliveryStatus.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)

View File

@ -92,6 +92,9 @@ struct ContentView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var sharedContentImportModel: SharedContentImportModel
@EnvironmentObject private var peerListModel: PeerListModel
@EnvironmentObject private var publicChatModel: PublicChatModel
@EnvironmentObject private var privateInboxModel: PrivateInboxModel
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
@State private var messageText = ""
@ -182,7 +185,13 @@ struct ContentView: View {
!hasRootModalPresentation else {
return
}
appChromeModel.showBluetoothAlert = false
// SwiftUI can invoke this setter inside a view update (the
// alert dismisses when a scenePhase change re-evaluates the
// `get`); publishing synchronously there is undefined
// behavior, so defer the write one hop.
Task { @MainActor in
appChromeModel.showBluetoothAlert = false
}
}
)
}
@ -205,7 +214,11 @@ struct ContentView: View {
!hasRootModalPresentationBesidesVoiceAlert else {
return
}
voiceRecordingVM.showAlert = false
// Same deferral as the Bluetooth alert above: the setter can
// run inside a view update when the sheet state changes.
Task { @MainActor in
voiceRecordingVM.showAlert = false
}
}
)
}
@ -287,6 +300,17 @@ struct ContentView: View {
showImagePicker: $showImagePicker,
imagePickerSourceType: $imagePickerSourceType
)
// Sheets + NavigationStack can drop inherited EnvironmentObjects on
// some iOS versions (#1558). Re-inject every model the sheet tree
// reads so ContentPeopleListView / MessageListView never crash.
.environmentObject(appChromeModel)
.environmentObject(privateConversationModel)
.environmentObject(verificationModel)
.environmentObject(conversationUIModel)
.environmentObject(locationChannelsModel)
.environmentObject(peerListModel)
.environmentObject(publicChatModel)
.environmentObject(privateInboxModel)
#else
ContentPeopleSheetView(
showSidebar: $showSidebar,
@ -304,6 +328,14 @@ struct ContentView: View {
onSendMessage: sendMessage,
showMacImagePicker: $showMacImagePicker
)
.environmentObject(appChromeModel)
.environmentObject(privateConversationModel)
.environmentObject(verificationModel)
.environmentObject(conversationUIModel)
.environmentObject(locationChannelsModel)
.environmentObject(peerListModel)
.environmentObject(publicChatModel)
.environmentObject(privateInboxModel)
#endif
}
.sheet(isPresented: $appChromeModel.isAppInfoPresented) {

View File

@ -20,7 +20,7 @@ struct MediaMessageView: View {
/// is a reference type mutated in place, and SwiftUI compares reference
/// fields by identity, so without the snapshot a status-only change
/// (send progress, delivered read) would not re-render this row.
private let deliveryStatus: DeliveryStatus?
private let deliveryStatus: DeliveryStatus
@State private var showDeliveryDetail = false
@Binding var imagePreviewURL: URL?
@ -57,11 +57,11 @@ struct MediaMessageView: View {
// .help() tooltips only exist on macOS, so iOS users get the
// explanation as a caption under the row instead.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
deliveryStatus != .notSentYet {
Button {
showDeliveryDetail.toggle()
} label: {
DeliveryStatusView(status: status)
DeliveryStatusView(status: deliveryStatus)
.padding(.leading, 4)
.contentShape(Rectangle())
}
@ -75,14 +75,14 @@ struct MediaMessageView: View {
// Failure reasons stay visible without a tap; other statuses
// reveal on demand.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
if case .failed = status {
Text(verbatim: status.bitchatDescription)
deliveryStatus != .notSentYet {
if case .failed = deliveryStatus {
Text(verbatim: deliveryStatus.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(Color.red.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
} else if showDeliveryDetail {
Text(verbatim: status.bitchatDescription)
Text(verbatim: deliveryStatus.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
@ -132,26 +132,24 @@ struct MediaMessageView: View {
}
}
private func mediaSendState(for deliveryStatus: DeliveryStatus?, isFromMe: Bool) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
private func mediaSendState(for deliveryStatus: DeliveryStatus, isFromMe: Bool) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
// A received message is never in a send state: BitchatMessage defaults
// private messages to .sending, so an incoming message's status must
// not drive the reveal mask or disable the reveal tap.
guard isFromMe else { return (false, nil, false) }
var isSending = false
var progress: Double?
if let status = deliveryStatus {
switch status {
case .sending:
switch deliveryStatus {
case .sending:
isSending = true
progress = 0
case .partiallyDelivered(let reached, let total):
if total > 0 {
isSending = true
progress = 0
case .partiallyDelivered(let reached, let total):
if total > 0 {
isSending = true
progress = Double(reached) / Double(total)
}
case .sent, .carried, .read, .delivered, .failed:
break
progress = Double(reached) / Double(total)
}
case .notSentYet, .sent, .carried, .read, .delivered, .failed:
break
}
let canCancel = isSending && conversationUIModel.isSentByCurrentUser(message)
let clamped = progress.map { max(0, min(1, $0)) }

View File

@ -430,7 +430,7 @@ private extension MessageListView {
guard message.isPrivate,
conversationUIModel.isSentByCurrentUser(message),
conversationUIModel.mediaAttachment(for: message) == nil,
case .some(.failed) = message.deliveryStatus
case .failed = message.deliveryStatus
else { return false }
return true
}

View File

@ -13,6 +13,58 @@ import BitFoundation
struct BLEServiceCoreTests {
/// Records ping completions (delivered on the main actor) so the
/// injected-clock test can assert from its own thread.
private final class MeshPingResultCollector: @unchecked Sendable {
private let lock = NSLock()
private var recorded: [MeshPingResult?] = []
var results: [MeshPingResult?] { lock.withLock { recorded } }
func record(_ result: MeshPingResult?) {
lock.withLock { recorded.append(result) }
}
}
/// The ping deadline asserted on an injected clock: the real 10s
/// product constant, no wall-clock in the loop. This is the pattern
/// for every engine deadline the timeout must not fire early, must
/// fire exactly once at the deadline, and must stay consumed after.
@Test
func meshPingTimesOutOnTheInjectedClockExactlyOnce() async throws {
let scheduler = BLEEngineManualScheduler()
let ble = makeService(engineScheduler: scheduler)
let peer = PeerID(str: "aabbccdd00112233")
ble._test_seedConnectedPeer(peer, nickname: "Alice")
let collector = MeshPingResultCollector()
ble.sendMeshPing(to: peer) { result in
collector.record(result)
}
// The probe registers and its deadline schedules on the engine;
// fence that submission before touching the clock.
await ble._test_drainNoiseMessagePipeline()
#expect(scheduler.pendingCount == 1)
// A hair before the deadline nothing may fire.
scheduler.advance(by: TransportConfig.meshPingTimeoutSeconds - 0.01)
await ble._test_drainNoiseMessagePipeline()
#expect(collector.results.isEmpty)
// Crossing the deadline expires the probe: nil, exactly once, on
// the main actor.
scheduler.advance(by: 0.02)
let completed = await TestHelpers.waitUntil(
{ collector.results.count == 1 },
timeout: TestConstants.longTimeout
)
#expect(completed)
#expect(collector.results == [nil])
// The deadline is consumed more time cannot re-fire it.
scheduler.advance(by: TransportConfig.meshPingTimeoutSeconds * 2)
await ble._test_drainNoiseMessagePipeline()
#expect(collector.results.count == 1)
}
@Test
func duplicatePacket_isDeduped() async throws {
let ble = makeService()
@ -39,7 +91,7 @@ struct BLEServiceCoreTests {
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
let receivedDuplicate = await TestHelpers.waitUntil(
{ delegate.publicMessagesSnapshot().count > 1 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!receivedDuplicate)
@ -117,7 +169,7 @@ struct BLEServiceCoreTests {
let unsignedRelayed = await TestHelpers.waitUntil(
{ outbound.count(ofType: .leave) > 0 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!unsignedRelayed)
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
@ -133,7 +185,7 @@ struct BLEServiceCoreTests {
let badSignatureRelayed = await TestHelpers.waitUntil(
{ outbound.count(ofType: .leave) > 0 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!badSignatureRelayed)
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
@ -502,26 +554,19 @@ struct BLEServiceCoreTests {
)
let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
#expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink))
let rebindGate = VerifiedDirectRebindGate()
ble._test_afterVerifiedDirectRebindEnqueued = rebindGate.pause
defer {
rebindGate.release()
ble._test_afterVerifiedDirectRebindEnqueued = nil
}
ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false)
let announcePaused = await TestHelpers.waitUntil(
{ rebindGate.hasPaused },
// The rebind, its Noise-proof retirement, and the ordinary
// reconnect preparation are one engine slot: no observer can see
// the new binding while the victim's stale sending keys are still
// available. Once the binding is visible, the keys must already be
// gone.
let rebound = await TestHelpers.waitUntil(
{ ble._test_centralBinding(attackerLink) == victimPeerID },
timeout: TestConstants.longTimeout
)
try #require(announcePaused)
// Rebind and ordinary reconnect preparation are one bleQueue
// critical section. Once the binding is visible, stale sending keys
// must already be unavailable.
#expect(ble._test_centralBinding(attackerLink) == victimPeerID)
try #require(rebound)
#expect(!ble.canDeliverSecurely(to: victimPeerID))
rebindGate.release()
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = { outbound.record($0) }
@ -908,6 +953,17 @@ struct BLEServiceCoreTests {
// old generation the remote may no longer be able to read.
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
// The capability-proof watchdog armed at the original authentication
// is still live and can genuinely reach its real 5s deadline here on
// a stalled CI runner. Fire it deterministically: its drain must
// respect the deferred-until-convergence state instead of encrypting
// the parked queues under the restored keys (the exact silent loss
// the defer path exists to prevent). The retry below then still
// finds the queues parked.
ble._test_forcePrivateMediaProofTimeout(for: alicePeerID)
await ble._test_drainNoiseMessagePipeline()
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
// Release the mandatory convergence retry: it retires the restored
// session and starts a fresh XX exchange with the live peer.
recoveryGate.release()
@ -1209,7 +1265,7 @@ struct BLEServiceCoreTests {
let didObservePanicClosure = await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let didObserveClosure = panicIngressObserver.waitUntilClosed(
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
gate.release()
continuation.resume(returning: didObserveClosure)
@ -1340,7 +1396,7 @@ struct BLEServiceCoreTests {
// rotated sender IDs never bought a sixth response.
let exceededBudget = await TestHelpers.waitUntil(
{ outbound.count(ofType: .pong) > budget },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!exceededBudget)
#expect(outbound.count(ofType: .pong) == budget)
@ -1406,35 +1462,6 @@ private final class SessionReconcileCounter: @unchecked Sendable {
}
}
private final class VerifiedDirectRebindGate: @unchecked Sendable {
private let condition = NSCondition()
private var paused = false
private var released = false
var hasPaused: Bool {
condition.lock()
defer { condition.unlock() }
return paused
}
func pause() {
condition.lock()
paused = true
condition.broadcast()
while !released {
condition.wait()
}
condition.unlock()
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
}
private final class ReceivePacketHandoffGate: @unchecked Sendable {
private let condition = NSCondition()
private var paused = false
@ -1499,7 +1526,8 @@ private final class PanicIngressObserver: @unchecked Sendable {
private func makeService(
noiseResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
engineScheduler: BLEEngineScheduling = BLEEngineDispatchScheduler()
) -> BLEService {
let keychain = MockKeychain()
let identityManager = MockIdentityManager(keychain)
@ -1509,7 +1537,8 @@ private func makeService(
idBridge: idBridge,
identityManager: identityManager,
initializeBluetoothManagers: false,
noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout
noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout,
engineScheduler: engineScheduler
)
}

View File

@ -52,9 +52,19 @@ private final class MockChatComposerContext: ChatComposerContext {
var activeChannel: ChannelID = .mesh
var meshNickname = "me"
var meshNicknamesByPeerID: [PeerID: String] = [:]
var blockedMeshNicknames: Set<String> = []
var blockedNostrPubkeys: Set<String> = []
func meshPeerNicknames() -> [PeerID: String] { meshNicknamesByPeerID }
func isMeshNicknameBlocked(_ nickname: String) -> Bool {
blockedMeshNicknames.contains(nickname)
}
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased())
}
// Geohash identity
var geoNicknames: [String: String] = [:]
static let dummyIdentity = NostrIdentity(
@ -120,6 +130,34 @@ struct ChatComposerCoordinatorContextTests {
#expect(context.queriedPeerCandidates == [["carol#dddd"]])
}
@Test @MainActor
func updateAutocomplete_excludesBlockedMeshAndGeohashPeers() {
let context = MockChatComposerContext()
let coordinator = ChatComposerCoordinator(context: context)
context.meshNicknamesByPeerID = [
PeerID(str: "1111111111111111"): "alice",
PeerID(str: "2222222222222222"): "eve",
PeerID(str: "3333333333333333"): "me"
]
context.blockedMeshNicknames = ["eve"]
context.queryResult = (["@alice"], NSRange(location: 0, length: 3))
coordinator.updateAutocomplete(for: "@a", cursorPosition: 2)
#expect(context.queriedPeerCandidates == [["alice"]])
let geoContext = MockChatComposerContext()
let geoCoordinator = ChatComposerCoordinator(context: geoContext)
geoContext.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruydq"))
geoContext.geoNicknames = [
"aaaabbbbccccdddd": "carol",
"bbbbccccddddeeee": "blocked"
]
geoContext.blockedNostrPubkeys = ["bbbbccccddddeeee"]
geoCoordinator.updateAutocomplete(for: "@", cursorPosition: 1)
#expect(geoContext.queriedPeerCandidates == [["carol#dddd"]])
}
@Test @MainActor
func completeNickname_appliesSuggestionResetsStateAndReturnsCursor() {
let context = MockChatComposerContext()

View File

@ -147,6 +147,10 @@ struct ChatViewModelDeliveryStatusTests {
#expect(Conversation.shouldSkipStatusUpdate(current: .sent, new: .sending))
// ...but a retry after a real failure stays visible.
#expect(!Conversation.shouldSkipStatusUpdate(current: .failed(reason: "no route"), new: .sending))
// .notSentYet is the pre-transport initial state: leaving it is always
// allowed, returning to it never is.
#expect(!Conversation.shouldSkipStatusUpdate(current: .notSentYet, new: .sending))
#expect(Conversation.shouldSkipStatusUpdate(current: .sent, new: .notSentYet))
}
@Test @MainActor
@ -729,9 +733,10 @@ struct ChatViewModelDeliveryStatusTests {
@Test @MainActor
func statusRank_orderingIsCorrect() async {
// This tests the implicit ordering used in refreshVisibleMessages
// failed < sending < sent < carried < partiallyDelivered < delivered < read
// notSentYet < failed < sending < sent < carried < partiallyDelivered < delivered < read
let statuses: [DeliveryStatus] = [
.notSentYet,
.failed(reason: "test"),
.sending,
.sent,
@ -745,13 +750,14 @@ struct ChatViewModelDeliveryStatusTests {
// This is more of a documentation test to ensure the ranking logic is understood
for (index, status) in statuses.enumerated() {
switch status {
case .failed: #expect(index == 0)
case .sending: #expect(index == 1)
case .sent: #expect(index == 2)
case .carried: #expect(index == 3)
case .partiallyDelivered: #expect(index == 4)
case .delivered: #expect(index == 5)
case .read: #expect(index == 6)
case .notSentYet: #expect(index == 0)
case .failed: #expect(index == 1)
case .sending: #expect(index == 2)
case .sent: #expect(index == 3)
case .carried: #expect(index == 4)
case .partiallyDelivered: #expect(index == 5)
case .delivered: #expect(index == 6)
case .read: #expect(index == 7)
}
}
}

View File

@ -43,14 +43,14 @@ struct ChatViewModelRefactoringTests {
transport.simulateConnect(peerID, nickname: "alice")
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil },
timeout: TestConstants.shortTimeout)
timeout: TestConstants.settleTimeout)
#expect(didResolve)
// Action: User types /msg command
viewModel.sendMessage("/msg @alice Hello Private World")
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 },
timeout: TestConstants.shortTimeout)
timeout: TestConstants.settleTimeout)
#expect(didSend)
// Assert:
@ -74,7 +74,7 @@ struct ChatViewModelRefactoringTests {
transport.simulateConnect(peerID, nickname: "troll")
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil },
timeout: TestConstants.shortTimeout)
timeout: TestConstants.settleTimeout)
#expect(didResolve)
// Action
@ -83,7 +83,7 @@ struct ChatViewModelRefactoringTests {
// Assert
// Verify identity manager was called to block "fingerprint_123"
let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") },
timeout: TestConstants.shortTimeout)
timeout: TestConstants.settleTimeout)
#expect(didBlock)
}
@ -114,7 +114,7 @@ struct ChatViewModelRefactoringTests {
// Wait for async processing with proper timeout
let found = await TestHelpers.waitUntil(
{ viewModel.privateChats[senderID]?.first?.content == "Secret" },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
// Assert
@ -140,7 +140,7 @@ struct ChatViewModelRefactoringTests {
{
viewModel.publicMessages(for: .mesh).contains(where: { $0.content == "Public Hi" })
},
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
// Assert

View File

@ -321,7 +321,7 @@ struct ChatViewModelCommandTests {
transport.simulateConnect(peerID, nickname: "Alice")
let resolved = await TestHelpers.waitUntil({
viewModel.getPeerIDForNickname("Alice") == peerID
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.negativeWaitWindow)
#expect(resolved)
viewModel.handleCommand("/msg Alice")
@ -422,7 +422,7 @@ struct ChatViewModelServiceLifecycleTests {
transport.sentReadReceipts.contains {
$0.peerID == peerID && $0.receipt.originalMessageID == "read-1"
}
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.negativeWaitWindow)
#expect(sentReadReceipt)
#expect(!viewModel.unreadPrivateMessages.contains(peerID))
@ -506,7 +506,7 @@ struct ChatViewModelReceivingTests {
let found = await TestHelpers.waitUntil({
viewModel.publicMessages(for: .mesh).contains { $0.content == "Public hello from Bob" }
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(found)
}
@ -535,11 +535,11 @@ struct ChatViewModelNoisePayloadTests {
let stored = await TestHelpers.waitUntil({
viewModel.privateChats[peerID]?.contains(where: { $0.id == "pm-noise-1" && $0.content == "Secret hello" }) == true
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.settleTimeout)
let acked = await TestHelpers.waitUntil({
transport.sentDeliveryAcks.contains { $0.messageID == "pm-noise-1" && $0.peerID == peerID }
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(stored)
#expect(acked)
@ -579,7 +579,7 @@ struct ChatViewModelNoisePayloadTests {
return name == "Bob"
}
return false
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(delivered)
}
@ -617,7 +617,7 @@ struct ChatViewModelNoisePayloadTests {
return true
}
return false
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.settleTimeout)
let conversationStoreUpdated = await TestHelpers.waitUntil({
let messages = viewModel.conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
@ -626,7 +626,7 @@ struct ChatViewModelNoisePayloadTests {
return true
}
return false
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(privateChatUpdated)
#expect(conversationStoreUpdated)
@ -730,7 +730,7 @@ struct ChatViewModelVerificationTests {
let bound = await TestHelpers.waitUntil({
viewModel.unifiedPeerService.peers.contains { $0.peerID == peerID }
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(bound)
let qr = VerificationService.VerificationQR(
@ -982,7 +982,7 @@ struct ChatViewModelPeerTests {
let cleaned = await TestHelpers.waitUntil({
!viewModel.unreadPrivateMessages.contains(stalePeer)
}, timeout: TestConstants.defaultTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(cleaned)
}

View File

@ -142,7 +142,7 @@ struct CourierEndToEndTests {
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
@ -151,7 +151,7 @@ struct CourierEndToEndTests {
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(carried)
@ -161,7 +161,7 @@ struct CourierEndToEndTests {
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(announced)
let announcePacket = try #require(bobOut.first(ofType: .announce))
@ -169,7 +169,7 @@ struct CourierEndToEndTests {
let handedOver = await TestHelpers.waitUntil(
{ carolOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(handedOver)
// With CoreBluetooth disabled there is no physical link for the send
@ -183,7 +183,7 @@ struct CourierEndToEndTests {
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
let received = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(received)
@ -229,7 +229,7 @@ struct CourierEndToEndTests {
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
@ -237,7 +237,7 @@ struct CourierEndToEndTests {
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(carried)
@ -245,7 +245,7 @@ struct CourierEndToEndTests {
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(announced)
let announcePacket = try #require(bobOut.first(ofType: .announce))
@ -253,7 +253,7 @@ struct CourierEndToEndTests {
let handedOver = await TestHelpers.waitUntil(
{ carolOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(handedOver)
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
@ -265,7 +265,7 @@ struct CourierEndToEndTests {
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
let delivered = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!delivered)
}
@ -293,7 +293,7 @@ struct CourierEndToEndTests {
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
@ -301,7 +301,7 @@ struct CourierEndToEndTests {
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(carried)
@ -310,7 +310,7 @@ struct CourierEndToEndTests {
let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) > 0 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!leakedOnUnverifiedAnnounce)
#expect(!carol.courierStore.isEmpty)
@ -318,7 +318,7 @@ struct CourierEndToEndTests {
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(announced)
let verifiedAnnounce = try #require(bobOut.first(ofType: .announce))
@ -326,7 +326,7 @@ struct CourierEndToEndTests {
let handedOver = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) == 1 },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(handedOver)
#expect(!carol.courierStore.isEmpty)
@ -355,7 +355,7 @@ struct CourierEndToEndTests {
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
@ -363,14 +363,14 @@ struct CourierEndToEndTests {
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(carried)
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(announced)
let directAnnounce = try #require(bobOut.first(ofType: .announce))
@ -385,7 +385,7 @@ struct CourierEndToEndTests {
let remoteHandover = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) == 1 },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(remoteHandover)
#expect(!carol.courierStore.isEmpty)
@ -398,7 +398,7 @@ struct CourierEndToEndTests {
bob.sendBroadcastAnnounce()
let reannounced = await TestHelpers.waitUntil(
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(reannounced)
let freshAnnounce = try #require(
@ -410,7 +410,7 @@ struct CourierEndToEndTests {
let refloodedInCooldown = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) > 1 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!refloodedInCooldown)
#expect(!carol.courierStore.isEmpty)
@ -424,7 +424,7 @@ struct CourierEndToEndTests {
bob.sendBroadcastAnnounce()
let announcedAgain = await TestHelpers.waitUntil(
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(announcedAgain)
let directAgain = try #require(
@ -434,7 +434,7 @@ struct CourierEndToEndTests {
let handedOverWithoutLinkProof = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) > 1 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!handedOverWithoutLinkProof)
#expect(!carol.courierStore.isEmpty)
@ -457,7 +457,7 @@ struct CourierEndToEndTests {
let queuedPacket = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!queuedPacket)
}
@ -494,7 +494,7 @@ struct CourierEndToEndTests {
carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
let stored = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!stored)
}
@ -532,7 +532,7 @@ struct CourierEndToEndTests {
carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
let stored = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!stored)
}
@ -575,7 +575,7 @@ struct CourierEndToEndTests {
carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false)
let stored = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!stored)
}
@ -602,14 +602,14 @@ struct CourierEndToEndTests {
let delivered = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(delivered)
// Give a duplicate delivery a chance to surface, then confirm the
// second copy never reached the delegate.
let duplicated = await TestHelpers.waitUntil(
{ bobDelegate.snapshot().count > 1 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!duplicated)
#expect(bobDelegate.snapshot().count == 1)
@ -629,7 +629,7 @@ struct CourierEndToEndTests {
let initiated = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseHandshake) > 0 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!initiated)
@ -639,7 +639,7 @@ struct CourierEndToEndTests {
ble.sendDeliveryAck(for: "msg-2", to: present)
let initiatedForPresent = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseHandshake) > 0 },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(initiatedForPresent)
}
@ -669,7 +669,7 @@ struct CourierEndToEndTests {
/// Minimal transport stub for exercising MessageRouter's courier deposit
/// logic without BLE plumbing.
private final class CourierCaptureTransport: Transport {
private final class CourierCaptureTransport: Transport, MeshCourierTransporting {
weak var delegate: BitchatDelegate?
weak var eventDelegate: TransportEventDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate?

View File

@ -87,7 +87,7 @@ struct PrekeyEndToEndTests {
peer.sendBroadcastAnnounce()
let published = await TestHelpers.waitUntil(
{ tap.first(ofType: .announce) != nil && tap.first(ofType: .prekeyBundle) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(published)
return (
@ -124,7 +124,7 @@ struct PrekeyEndToEndTests {
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(cached)
@ -138,7 +138,7 @@ struct PrekeyEndToEndTests {
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
@ -149,7 +149,7 @@ struct PrekeyEndToEndTests {
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(carried)
@ -158,7 +158,7 @@ struct PrekeyEndToEndTests {
bob.sendBroadcastAnnounce()
let reannounced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(reannounced)
let handoverTrigger = try #require(bobOut.first(ofType: .announce))
@ -166,7 +166,7 @@ struct PrekeyEndToEndTests {
let handedOver = await TestHelpers.waitUntil(
{ carolOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(handedOver)
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
@ -178,7 +178,7 @@ struct PrekeyEndToEndTests {
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
let received = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(received)
@ -207,7 +207,7 @@ struct PrekeyEndToEndTests {
bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID)
let redelivered = await TestHelpers.waitUntil(
{ bobDelegate.snapshot().count == 2 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!redelivered)
#expect(bobDelegate.snapshot().count == 1)
@ -235,7 +235,7 @@ struct PrekeyEndToEndTests {
))
let deposited = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
@ -248,7 +248,7 @@ struct PrekeyEndToEndTests {
bob._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, preseedPeer: false)
let received = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(received)
let delivered = try #require(bobDelegate.snapshot().first)
@ -272,7 +272,7 @@ struct PrekeyEndToEndTests {
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!cached)
}
@ -310,7 +310,7 @@ struct PrekeyEndToEndTests {
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!cached)
}
@ -328,7 +328,7 @@ struct PrekeyEndToEndTests {
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.defaultTimeout
timeout: TestConstants.settleTimeout
)
#expect(cached)
// The verified bundle now participates in Alice's sync rounds.
@ -364,7 +364,7 @@ struct PrekeyEndToEndTests {
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!cached)
#expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
@ -396,7 +396,7 @@ struct PrekeyEndToEndTests {
let cached = await TestHelpers.waitUntil(
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
timeout: TestConstants.shortTimeout
timeout: TestConstants.negativeWaitWindow
)
#expect(!cached)
#expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))

View File

@ -204,7 +204,8 @@ struct PrivateMediaEndToEndTests {
alice.sendFilePrivate(
file,
to: bob.myPeerID,
transferId: deniedID
transferId: deniedID,
allowLegacyFallback: false
)
let denied = await TestHelpers.waitUntil(
{ cancellations.contains(deniedID) },
@ -254,7 +255,7 @@ struct PrivateMediaEndToEndTests {
// Consent is invocation-scoped, not a sticky peer preference.
let retryID = "legacy-retry-without-consent-\(UUID().uuidString)"
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: retryID)
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: retryID, allowLegacyFallback: false)
let retryDenied = await TestHelpers.waitUntil(
{ cancellations.contains(retryID) },
timeout: TestConstants.longTimeout
@ -998,7 +999,7 @@ struct PrivateMediaEndToEndTests {
let encryptedID = "encrypted-over-256-\(UUID().uuidString)"
let legacyID = "legacy-over-256-\(UUID().uuidString)"
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: encryptedID)
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: encryptedID, allowLegacyFallback: false)
alice.sendFilePrivate(
file,
to: oldCarol.myPeerID,
@ -1318,7 +1319,7 @@ struct PrivateMediaEndToEndTests {
mimeType: mimeType,
content: content
)
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: "wire-\(UUID().uuidString)")
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: "wire-\(UUID().uuidString)", allowLegacyFallback: false)
let fragmented = await TestHelpers.waitUntil(
{ tap.hasCompleteFragmentTrain },

View File

@ -50,7 +50,7 @@ struct PublicChatE2ETests {
var bobReceivedMessage = false
var charlieReceivedMessage = false
await confirmation("Both recieve message", expectedCount: 2) { receiveMessage in
await confirmation("Both receive message", expectedCount: 2) { receiveMessage in
bob.messageDeliveryHandler = { message in
if message.content == TestConstants.testMessage1 {
if !bobReceivedMessage {

View File

@ -37,7 +37,7 @@ struct GossipSyncManagerTests {
}
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.shortTimeout)
try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.settleTimeout)
}
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
@ -394,7 +394,7 @@ struct GossipSyncManagerTests {
)
manager.handleRequestSync(from: peer, request: request)
try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.shortTimeout)
try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.settleTimeout)
// Barrier: flush the sync queue so a late third packet would be visible.
manager._performMaintenanceSynchronously(now: Date())
let sentPackets = delegate.packets
@ -477,7 +477,7 @@ struct GossipSyncManagerTests {
manager.handleRequestSync(from: peer, request: request)
manager.handleRequestSync(from: peer, request: request)
try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.shortTimeout)
try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.settleTimeout)
// Barrier: both requests have been processed once this returns.
manager._performMaintenanceSynchronously(now: Date())
#expect(delegate.packets.count == 1)
@ -498,7 +498,7 @@ struct GossipSyncManagerTests {
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
let packet = try #require(delegate.packets.first)
let request = try #require(RequestSyncPacket.decode(from: packet.payload))
let types = try #require(request.types)
@ -553,7 +553,7 @@ struct GossipSyncManagerTests {
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
manager.handleRequestSync(from: peer, request: request)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
let sentPackets = delegate.packets
#expect(sentPackets.count == 1)
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
@ -615,7 +615,7 @@ struct GossipSyncManagerTests {
)
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
// Barrier: flush the sync queue so a late second packet would be visible.
manager._performMaintenanceSynchronously(now: Date())
let sentPackets = delegate.packets
@ -641,7 +641,7 @@ struct GossipSyncManagerTests {
let stalledID = try #require(Data(hexString: "0102030405060708"))
manager.requestMissingFragments(fragmentIDs: [stalledID])
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
let sent = try #require(delegate.packets.first)
#expect(sent.type == MessageType.requestSync.rawValue)
#expect(sent.ttl == 0)
@ -697,7 +697,7 @@ struct GossipSyncManagerTests {
// And a .prekeyBundle sync request is answered with the stored packet.
let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .prekeyBundle)
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
let served = try #require(delegate.packets.first)
#expect(served.type == MessageType.prekeyBundle.rawValue)
#expect(served.isRSR)
@ -774,7 +774,7 @@ struct GossipSyncManagerTests {
)
let restored = await TestHelpers.waitUntil(
{ second._messageCount(for: PeerID(hexData: senderID)) == 1 },
timeout: TestConstants.shortTimeout
timeout: TestConstants.settleTimeout
)
#expect(restored)
}
@ -844,7 +844,7 @@ struct GossipSyncManagerTests {
!FileManager.default.fileExists(atPath: fileURL.path)
&& manager._messageCount(for: PeerID(hexData: senderID)) == 0
},
timeout: TestConstants.shortTimeout
timeout: TestConstants.settleTimeout
)
#expect(erased)
}

View File

@ -0,0 +1,49 @@
import Foundation
@testable import bitchat
/// Manually advanced engine scheduler: deferred work runs when the test
/// advances the clock past its deadline, on the real engine queue (deferred
/// bodies touch engine-confined state), and `advance` returns only after
/// the released work has finished so assertions that follow observe its
/// engine-side effects without polling.
final class BLEEngineManualScheduler: BLEEngineScheduling, @unchecked Sendable {
private let lock = NSLock()
private var engineQueue: DispatchQueue?
private var now: TimeInterval = 0
private var pending: [(deadline: TimeInterval, work: DispatchWorkItem)] = []
func activate(engineQueue: DispatchQueue) {
lock.withLock { self.engineQueue = engineQueue }
}
func schedule(after delay: TimeInterval, execute work: DispatchWorkItem) {
lock.withLock { pending.append((now + delay, work)) }
}
var pendingCount: Int {
lock.withLock { pending.count }
}
/// Advances the clock, releasing due work in deadline order.
/// Cancellation keeps its production semantics: dispatch skips a
/// cancelled `DispatchWorkItem` at execution.
func advance(by interval: TimeInterval) {
let (due, queue): ([DispatchWorkItem], DispatchQueue?) = lock.withLock {
now += interval
let cutoff = now
let released = pending
.filter { $0.deadline <= cutoff }
.sorted { $0.deadline < $1.deadline }
.map(\.work)
pending.removeAll { $0.deadline <= cutoff }
return (released, engineQueue)
}
guard let queue else { return }
for work in due {
queue.async(execute: work)
}
// Fence: released work (and anything it enqueued) has run before
// the test's next assertion.
queue.sync {}
}
}

View File

@ -14,7 +14,10 @@ import BitFoundation
/// Mock Transport implementation for testing ChatViewModel in isolation.
/// Records all method calls and allows test code to verify interactions.
final class MockTransport: Transport, PrivateMediaDeletionPersisting {
final class MockTransport: Transport, PrivateMediaDeletionPersisting,
MeshFileTransferring, MeshVerifying, MeshCourierTransporting,
MeshDiagnosing, MeshPublicArchiving, MeshVoiceStreaming,
MeshGroupMessaging, MeshBoardBroadcasting {
// MARK: - Protocol Properties
@ -205,11 +208,6 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
sentBroadcastFiles.append((packet, transferId))
}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
sentPrivateFiles.append((packet, peerID, transferId))
sentPrivateFileLegacyAllowances.append(false)
}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
@ -242,6 +240,15 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
cancelledTransfers.append(transferId)
}
private(set) var sentFileReceiptRetries: [(BitchatFilePacket, PeerID, String)] = []
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
) {
sentFileReceiptRetries.append((packet, peerID, transferId))
}
@MainActor
func persistDeletedPrivateMedia(
messageIDs: [String],
@ -317,6 +324,50 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
meshTopologySnapshot
}
// MARK: - Remaining mesh capabilities (recording stubs)
private(set) var sentVouchAttestations: [(Data, PeerID)] = []
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
sentVouchAttestations.append((payload, peerID))
}
var archivedPublicMessages: [ArchivedPublicMessage] = []
private(set) var purgedAllArchived = false
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
let archived = archivedPublicMessages
Task { @MainActor in completion(archived) }
}
func purgeAllArchivedPublicMessages() {
purgedAllArchived = true
}
private(set) var sentVoiceFrames: [(Data, PeerID)] = []
private(set) var sentVoiceBroadcasts: [Data] = []
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {
sentVoiceFrames.append((burstContent, peerID))
}
func sendVoiceFrameBroadcast(_ burstContent: Data) {
sentVoiceBroadcasts.append(burstContent)
}
private(set) var sentGroupInvites: [(Data, PeerID)] = []
private(set) var sentGroupKeyUpdates: [(Data, PeerID)] = []
private(set) var broadcastGroupMessages: [Data] = []
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {
sentGroupInvites.append((statePayload, peerID))
}
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {
sentGroupKeyUpdates.append((statePayload, peerID))
}
func broadcastGroupMessage(_ envelope: Data) {
broadcastGroupMessages.append(envelope)
}
private(set) var sentBoardPayloads: [Data] = []
func sendBoardPayload(_ payload: Data) {
sentBoardPayloads.append(payload)
}
// MARK: - Test Helpers
/// Clears all recorded method calls for fresh assertions

View File

@ -392,7 +392,7 @@ final class NearbyNotesCounterTests: XCTestCase {
}
private func waitUntil(
timeout: TimeInterval = 1.0,
timeout: TimeInterval = TestConstants.settleTimeout,
condition: @escaping @MainActor () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)

View File

@ -0,0 +1,53 @@
//
// NicknameNormalizationTests.swift
// bitchatTests
//
// Nicknames must compare equal regardless of how the user's keyboard
// produced them: "café" as precomposed U+00E9 and as "e" + combining
// U+0301 are canonically equivalent but bytewise different, which broke
// mention matching, DM resolution, and autocomplete (#214). Storage and
// comparison both canonicalize to NFC via String.normalizedNickname.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
@testable import bitchat
struct NicknameNormalizationTests {
/// "café" with a combining acute accent (NFD form)
private let decomposed = "cafe\u{0301}"
/// "café" with precomposed é (NFC form)
private let precomposed = "caf\u{00E9}"
@Test
func canonicallyEquivalentFormsNormalizeIdentically() {
// Sanity: the raw forms really are different strings byte-wise
#expect(decomposed.unicodeScalars.count != precomposed.unicodeScalars.count)
// and normalization unifies them.
#expect(decomposed.normalizedNickname == precomposed.normalizedNickname)
#expect(decomposed.normalizedNickname == precomposed)
}
@Test
func asciiNicknamesPassThroughUnchanged() {
#expect("alice_42".normalizedNickname == "alice_42")
#expect("".normalizedNickname == "")
}
@Test
func validateNicknameReturnsCanonicalForm() {
#expect(InputValidator.validateNickname(decomposed) == precomposed)
#expect(InputValidator.validateNickname(" \(decomposed) ") == precomposed)
// Validation behavior is otherwise unchanged.
#expect(InputValidator.validateNickname(" ") == nil)
}
@Test
func collisionSuffixSplittingSurvivesNormalization() {
let (base, suffix) = (decomposed.normalizedNickname + "#ab12").splitSuffix()
#expect(base == precomposed)
#expect(suffix == "#ab12")
}
}

View File

@ -723,9 +723,9 @@ struct NoiseCoverageTests {
// A failed startup requirement must not strand a late thread in
// the blocking test double after the test has returned.
oldSession.resumeDecrypt()
_ = decryptResult.wait(timeout: 5)
_ = decryptResult.wait(timeout: TestConstants.settleTimeout)
if let promotionResultForCleanup {
_ = promotionResultForCleanup.wait(timeout: 5)
_ = promotionResultForCleanup.wait(timeout: TestConstants.settleTimeout)
}
}
@ -751,15 +751,19 @@ struct NoiseCoverageTests {
promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote"
promotionThread.qualityOfService = .userInitiated
promotionThread.start()
try #require(promotionStarted.wait(timeout: .now() + 5) == .success)
try #require(promotionStarted.wait(timeout: .now() + TestConstants.settleTimeout) == .success)
#expect(
// test-timing-ok: a NEGATIVE wait it asserts the promotion has
// NOT completed yet, so a long deadline would only make the suite
// slow while still passing. A starved runner can only make this
// more likely to hold, never less.
promotionResult.wait(timeout: 0.05) == nil,
"Promotion must wait for the exact decrypting-session lease"
)
oldSession.resumeDecrypt()
let decrypted = try #require(decryptResult.wait(timeout: 5)).get()
_ = try #require(promotionResult.wait(timeout: 5)).get()
let decrypted = try #require(decryptResult.wait(timeout: TestConstants.settleTimeout)).get()
_ = try #require(promotionResult.wait(timeout: TestConstants.settleTimeout)).get()
#expect(decrypted.plaintext == Data("old session".utf8))
#expect(decrypted.sessionGeneration == oldGeneration)

View File

@ -580,8 +580,16 @@ final class GeoRelayDirectoryTests: XCTestCase {
/// constrained CI runners (2-core, serialized testing) can starve the
/// detached utility-priority fetch task for seconds before it runs, and
/// a successful wait returns as soon as the condition becomes true.
/// Default deliberately far larger than the work being awaited.
///
/// The directory performs its fetch in a `Task.detached(priority: .utility)`,
/// and utility priority competes with every other suite on a CI runner. At
/// ten seconds the retry-scheduling test timed out at exactly 10.06s with
/// the retry never scheduled which reads like a missing retry rather than
/// a starved background task. Returning as soon as the condition holds means
/// a longer deadline only extends the genuine-failure case.
private func waitUntil(
timeout: TimeInterval = 10.0,
timeout: TimeInterval = TestConstants.settleTimeout,
condition: @escaping @MainActor () async -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)

View File

@ -188,7 +188,7 @@ struct PTTBurstPlayerTests {
_ condition: () -> Bool,
sourceLocation: SourceLocation = #_sourceLocation
) async {
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout))
while !condition(), ContinuousClock.now < deadline {
await Task.yield()
try? await Task.sleep(nanoseconds: 1_000_000)

View File

@ -85,24 +85,16 @@ struct ProtocolContractTests {
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)
// Mesh-only features are capability protocols now, not inert
// defaults: a core-only transport simply doesn't have them.
#expect(!(probe as AnyObject is MeshFileTransferring))
#expect(!(probe as AnyObject is MeshDiagnosing))
#expect(probe.peerCapabilities(peerID).isEmpty)
// Secure delivery defaults to prompt delivery (itself defaulting to
// reachability) for transports without a forgeable link layer.
#expect(probe.canDeliverSecurely(to: peerID) == false)

View File

@ -75,6 +75,70 @@ final class BitchatFilePacketTests: XCTestCase {
XCTAssertEqual(decoded.content, content)
}
/// The TLV tag list is a floor, not a ceiling: a decoder that bails on the
/// first tag it does not know makes the format unextendable, because a field
/// the sender considered optional costs the receiver the whole file. This
/// decoder skips them (`case nil: continue`) and that has to stay true it
/// is load-bearing for any peer, version or third-party client that adds a
/// field we have not seen. `PrivateMediaMessageIdentity` exists precisely
/// because the Android decoder does *not* do this, so the asymmetry is real
/// and worth pinning on the side that gets it right.
func testDecodeSkipsUnknownTLVTypesInsteadOfDroppingTheFile() throws {
let content = Data((0..<64).map { UInt8($0) })
let unknownValue = Data("some-message-id".utf8)
var data = Data()
// fileName
data.append(0x01)
data.append(contentsOf: [0x00, 0x09])
data.append(Data("photo.jpg".utf8))
// fileSize
data.append(0x02)
data.append(contentsOf: [0x00, 0x04])
data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)])
// mimeType
data.append(0x03)
data.append(contentsOf: [0x00, 0x0A])
data.append(Data("image/jpeg".utf8))
// An unknown tag, where an encoder appending content last would put it
data.append(0x05)
data.append(contentsOf: [0x00, UInt8(unknownValue.count)])
data.append(unknownValue)
// content
data.append(0x04)
data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)])
data.append(content)
let decoded = try XCTUnwrap(BitchatFilePacket.decode(data))
XCTAssertEqual(decoded.fileName, "photo.jpg")
XCTAssertEqual(decoded.mimeType, "image/jpeg")
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
XCTAssertEqual(decoded.content, content)
}
/// Same contract for an extension that trails the content, which a decoder
/// stopping at the first unknown tag would also lose.
func testDecodeSkipsAnUnknownTLVTrailingTheContent() throws {
let content = Data(repeating: 0x7F, count: 16)
var data = Data()
data.append(0x01)
data.append(contentsOf: [0x00, 0x08])
data.append(Data("note.m4a".utf8))
data.append(0x04)
data.append(contentsOf: [0x00, 0x00, 0x00, UInt8(content.count)])
data.append(content)
data.append(0x7F)
data.append(contentsOf: [0x00, 0x04])
data.append(Data([0x11, 0x11, 0x11, 0x11]))
let decoded = try XCTUnwrap(BitchatFilePacket.decode(data))
XCTAssertEqual(decoded.fileName, "note.m4a")
XCTAssertNil(decoded.mimeType)
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
XCTAssertEqual(decoded.content, content)
}
func testPrivateMediaMessageIdentityConvergesAcrossPeerIDAliases() throws {
let senderKey = Data(repeating: 0x11, count: 32)
let recipientKey = Data(repeating: 0x22, count: 32)

View File

@ -68,6 +68,21 @@ struct BLEAnnounceThrottleTests {
#expect(accepted.value == 1)
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
}
@Test
func resetForgetsThrottleDebtSoARotationAnnounceIsNeverSwallowed() {
let throttle = BLEAnnounceThrottle(
normalMinimumInterval: 1,
forcedMinimumInterval: 1
)
let now = Date()
#expect(throttle.shouldSend(force: true, now: now))
// A panic inside the forced window would be throttled...
#expect(!throttle.shouldSend(force: true, now: now.addingTimeInterval(0.2)))
// ...so the rotation resets the debt and announces immediately.
throttle.reset()
#expect(throttle.shouldSend(force: true, now: now.addingTimeInterval(0.3)))
}
}
private final class LockedCounter: @unchecked Sendable {

View File

@ -222,7 +222,7 @@ struct BLEFileTransferHandlerTests {
#expect(message?.isPrivate == false)
#expect(message?.senderPeerID == remotePeerID)
#expect(message?.timestamp == Date(timeIntervalSince1970: 900))
#expect(message?.deliveryStatus == nil)
#expect(message?.deliveryStatus == .notSentYet)
}
@Test

View File

@ -0,0 +1,75 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
struct BLELinkAuthStateTests {
private let peerID = PeerID(str: "1122334455667788")
private let link = BLEIngressLinkID.peripheral("periph-a")
@Test
func authenticationBindsToTheExactLinkAndOwner() {
var auth = BLELinkAuthState()
auth.markAuthenticated(link, owner: peerID)
#expect(auth.isAuthenticated(link, for: peerID))
#expect(!auth.isAuthenticated(link, for: PeerID(str: "8899aabbccddeeff")))
#expect(!auth.isAuthenticated(.peripheral("periph-b"), for: peerID))
auth.retireLink(link)
#expect(!auth.isAuthenticated(link, for: peerID))
}
@Test
func retireLinksOwnedByPeerReturnsAndRetiresThemAll() {
var auth = BLELinkAuthState()
auth.markAuthenticated(.peripheral("periph-a"), owner: peerID)
auth.markAuthenticated(.central("central-a"), owner: peerID)
auth.markAuthenticated(.central("central-b"), owner: PeerID(str: "8899aabbccddeeff"))
let departed = Set(auth.retireLinks(ownedBy: peerID))
#expect(departed == [.peripheral("periph-a"), .central("central-a")])
#expect(auth.links(ownedBy: peerID).isEmpty)
#expect(auth.isAuthenticated(.central("central-b"), for: PeerID(str: "8899aabbccddeeff")))
}
@Test
func rebindCooldownPermitsOncePerWindowAndAgesOut() {
var auth = BLELinkAuthState()
let start = Date(timeIntervalSince1970: 1_000)
let first = auth.permitRebind(linkUUID: "periph-a", now: start, cooldown: 30)
#expect(first)
let withinWindow = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(10), cooldown: 30)
#expect(!withinWindow)
// A different link has its own allowance.
let otherLink = auth.permitRebind(linkUUID: "periph-b", now: start.addingTimeInterval(10), cooldown: 30)
#expect(otherLink)
// The window ages out.
let afterWindow = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(31), cooldown: 30)
#expect(afterWindow)
}
@Test
func containmentCooldownsSurviveASessionReset() {
var auth = BLELinkAuthState()
let start = Date(timeIntervalSince1970: 2_000)
auth.markAuthenticated(link, owner: peerID)
let rebindBefore = auth.permitRebind(linkUUID: "periph-a", now: start, cooldown: 30)
let retirementBefore = auth.permitRedundantRetirement(peerID: peerID, now: start, cooldown: 30)
#expect(rebindBefore)
#expect(retirementBefore)
// Panic/emergency resets wipe proofs and epochs but a stable
// CoreBluetooth UUID must not earn a fresh rebind or retirement
// allowance just because the session state around it was wiped.
auth.removeAll()
#expect(!auth.isAuthenticated(link, for: peerID))
let rebindAfterReset = auth.permitRebind(linkUUID: "periph-a", now: start.addingTimeInterval(5), cooldown: 30)
let retirementAfterReset = auth.permitRedundantRetirement(peerID: peerID, now: start.addingTimeInterval(5), cooldown: 30)
#expect(!rebindAfterReset)
#expect(!retirementAfterReset)
}
}

View File

@ -0,0 +1,111 @@
import BitFoundation
import Testing
@testable import bitchat
struct BLELinkBindingsTests {
private let peerID = PeerID(str: "1122334455667788")
private let otherPeerID = PeerID(str: "8899aabbccddeeff")
@Test
func centralBindingExposesBoundPeerAndLinks() {
var bindings = BLELinkBindings()
bindings.bindCentral("central-a", to: peerID)
#expect(bindings.peer(forCentralUUID: "central-a") == peerID)
#expect(bindings.hasCentral(boundTo: peerID))
#expect(bindings.boundPeer(for: .central("central-a")) == peerID)
#expect(bindings.links(to: peerID) == [.central("central-a")])
}
@Test
func linksReturnsAllBindingsForPeerAcrossRoles() {
var bindings = BLELinkBindings()
bindings.bindCentral("central-a", to: peerID)
bindings.bindCentral("central-b", to: peerID)
bindings.bindCentral("central-c", to: otherPeerID)
bindings.bindPeripheral("periph-a", to: peerID)
#expect(bindings.links(to: peerID) == [.central("central-a"), .central("central-b"), .peripheral("periph-a")])
}
@Test
func clearCentralsReturnsPreviouslyBoundPeerIDsAndClearsLookups() {
var bindings = BLELinkBindings()
bindings.bindCentral("central-a", to: peerID)
bindings.bindCentral("central-b", to: otherPeerID)
let removedPeerIDs = Set(bindings.clearCentrals())
#expect(removedPeerIDs == Set([peerID, otherPeerID]))
#expect(bindings.peer(forCentralUUID: "central-a") == nil)
#expect(bindings.links(to: peerID).isEmpty)
}
@Test
func rotationRebindDropsTheRetiredIdentitysReverseMapping() {
var bindings = BLELinkBindings()
bindings.bindPeripheral("periph-a", to: peerID)
#expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-a")
// The link's owner rotates: the old identity must no longer claim
// this link as its preferred peripheral.
bindings.bindPeripheral("periph-a", to: otherPeerID)
#expect(bindings.preferredPeripheralUUID(for: peerID) == nil)
#expect(bindings.preferredPeripheralUUID(for: otherPeerID) == "periph-a")
#expect(bindings.peer(forPeripheralID: "periph-a") == otherPeerID)
}
@Test
func removingThePreferredLinkRepairsOntoTheChosenSurvivor() {
var bindings = BLELinkBindings()
bindings.bindPeripheral("periph-a", to: peerID)
bindings.bindPeripheral("periph-b", to: peerID)
// periph-b bound last: it is the preferred link.
#expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-b")
let removed = bindings.peripheralRemoved("periph-b") { remaining in
#expect(remaining == ["periph-a"])
return remaining.first
}
#expect(removed == peerID)
#expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-a")
#expect(bindings.links(to: peerID) == [.peripheral("periph-a")])
}
@Test
func removingADuplicateLinkDoesNotStrandThePreferredOne() {
var bindings = BLELinkBindings()
bindings.bindPeripheral("periph-a", to: peerID)
bindings.bindPeripheral("periph-b", to: peerID)
// Removing the non-preferred duplicate must leave the reverse map
// untouched (no repair callback consulted for a non-preferred link).
let removed = bindings.peripheralRemoved("periph-a") { _ in
Issue.record("survivor choice must not run for a non-preferred link")
return nil
}
#expect(removed == peerID)
#expect(bindings.preferredPeripheralUUID(for: peerID) == "periph-b")
}
@Test
func removingTheLastLinkClearsThePreferredMapping() {
var bindings = BLELinkBindings()
bindings.bindPeripheral("periph-a", to: peerID)
let removed = bindings.peripheralRemoved("periph-a") { remaining in
#expect(remaining.isEmpty)
return nil
}
#expect(removed == peerID)
#expect(bindings.preferredPeripheralUUID(for: peerID) == nil)
#expect(bindings.links(to: peerID).isEmpty)
}
}

View File

@ -1,46 +0,0 @@
import BitFoundation
import Testing
@testable import bitchat
struct BLELinkStateStoreTests {
@Test
func centralBindingExposesDirectLinkStateAndLinks() {
let store = BLELinkStateStore()
let peerID = PeerID(str: "1122334455667788")
store.bindCentral("central-a", to: peerID)
#expect(store.peerID(forCentralUUID: "central-a") == peerID)
#expect(store.directLinkState(for: peerID) == BLEDirectLinkState(hasPeripheral: false, hasCentral: true))
#expect(store.links(to: peerID) == [.central("central-a")])
}
@Test
func linksReturnsAllCentralBindingsForPeer() {
let store = BLELinkStateStore()
let peerID = PeerID(str: "1122334455667788")
let otherPeerID = PeerID(str: "8899aabbccddeeff")
store.bindCentral("central-a", to: peerID)
store.bindCentral("central-b", to: peerID)
store.bindCentral("central-c", to: otherPeerID)
#expect(store.links(to: peerID) == [.central("central-a"), .central("central-b")])
}
@Test
func clearCentralsReturnsPreviouslyBoundPeerIDsAndClearsLookups() {
let store = BLELinkStateStore()
let firstPeerID = PeerID(str: "1122334455667788")
let secondPeerID = PeerID(str: "8899aabbccddeeff")
store.bindCentral("central-a", to: firstPeerID)
store.bindCentral("central-b", to: secondPeerID)
let removedPeerIDs = Set(store.clearCentrals())
#expect(removedPeerIDs == Set([firstPeerID, secondPeerID]))
#expect(store.peerID(forCentralUUID: "central-a") == nil)
#expect(store.links(to: firstPeerID).isEmpty)
}
}

View File

@ -0,0 +1,84 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
struct BLEMeshPingTrackerTests {
private func makeProbe(peerID: PeerID) -> BLEMeshPingProbe {
BLEMeshPingProbe(
peerID: peerID,
sentAt: Date(timeIntervalSince1970: 1_000),
lifecycleGeneration: 1,
completion: { _ in },
timeout: DispatchWorkItem {}
)
}
@Test func resolveReturnsProbeOnlyForTheProbedPeer() {
var tracker = BLEMeshPingTracker()
let nonce = Data([1, 2, 3, 4, 5, 6, 7, 8])
let probed = PeerID(str: "aaaaaaaaaaaaaaaa")
tracker.register(makeProbe(peerID: probed), nonce: nonce)
// A pong claiming the right nonce from the wrong peer must not
// consume the probe.
let wrongPeer = tracker.resolve(nonce: nonce, from: PeerID(str: "bbbbbbbbbbbbbbbb"))
#expect(wrongPeer == nil)
let rightPeer = tracker.resolve(nonce: nonce, from: probed)
#expect(rightPeer != nil)
// Consumed exactly once.
let secondResolve = tracker.resolve(nonce: nonce, from: probed)
#expect(secondResolve == nil)
}
@Test func expireConsumesTheProbeSoResolveCannotFireTwice() {
var tracker = BLEMeshPingTracker()
let nonce = Data([9, 9, 9, 9, 9, 9, 9, 9])
let probed = PeerID(str: "aaaaaaaaaaaaaaaa")
tracker.register(makeProbe(peerID: probed), nonce: nonce)
let firstExpire = tracker.expire(nonce: nonce)
#expect(firstExpire != nil)
let secondExpire = tracker.expire(nonce: nonce)
#expect(secondExpire == nil)
let resolveAfterExpire = tracker.resolve(nonce: nonce, from: probed)
#expect(resolveAfterExpire == nil)
}
@Test func inboundBudgetIsPerLinkAndBounded() {
var tracker = BLEMeshPingTracker()
let now = Date(timeIntervalSince1970: 2_000)
let linkA = PeerID(str: "aaaaaaaaaaaaaaaa")
let linkB = PeerID(str: "bbbbbbbbbbbbbbbb")
var allowedOnA = 0
for _ in 0..<(TransportConfig.meshPingInboundMaxPerLink + 5) {
if tracker.shouldRespond(toLink: linkA, now: now) { allowedOnA += 1 }
}
#expect(allowedOnA == TransportConfig.meshPingInboundMaxPerLink)
// One saturated link must not consume another link's budget.
let allowedOnB = tracker.shouldRespond(toLink: linkB, now: now)
#expect(allowedOnB)
}
@Test func resetDropsProbesRestoresBudgetAndHandsBackTimeouts() {
var tracker = BLEMeshPingTracker()
let now = Date(timeIntervalSince1970: 3_000)
let link = PeerID(str: "aaaaaaaaaaaaaaaa")
let nonce = Data([4, 4, 4, 4, 4, 4, 4, 4])
tracker.register(makeProbe(peerID: link), nonce: nonce)
for _ in 0..<TransportConfig.meshPingInboundMaxPerLink {
_ = tracker.shouldRespond(toLink: link, now: now)
}
let saturated = tracker.shouldRespond(toLink: link, now: now)
#expect(!saturated)
let timeouts = tracker.reset()
#expect(timeouts.count == 1)
let resolveAfterReset = tracker.resolve(nonce: nonce, from: link)
#expect(resolveAfterReset == nil)
let allowedAfterReset = tracker.shouldRespond(toLink: link, now: now)
#expect(allowedAfterReset)
}
}

View File

@ -260,6 +260,48 @@ struct BLEOutboundFragmentTransferSchedulerTests {
}
}
@Test
func blockedDuplicateAtFrontOfQueueDoesNotStarveALaterUnrelatedPendingTransfer() {
// Bug: reservePendingStarts spent the slot budget on a pending
// request the moment it was dequeued, before checking whether that
// request would actually be admitted. A resend of still-active
// content sitting at the front of the queue therefore consumed a
// slot even though it was deferred back to the queue rather than
// started -- starving an unrelated, genuinely startable transfer
// right behind it until some other transfer happened to complete.
var scheduler = BLEOutboundFragmentTransferScheduler()
let t1 = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t1", payload: "file-a")
let t2 = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t2", payload: "file-b")
let dupT1 = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t1", payload: "file-a")
let unrelated = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "t3", payload: "file-c")
_ = scheduler.submit(t1, maxConcurrentTransfers: 2)
_ = scheduler.submit(t2, maxConcurrentTransfers: 2)
#expect(scheduler.activeCount == 2)
// Both slots are full, so a resend of "t1" (still active) and an
// unrelated transfer both land in the pending queue, in that order.
_ = scheduler.submit(dupT1, maxConcurrentTransfers: 2)
_ = scheduler.submit(unrelated, maxConcurrentTransfers: 2)
#expect(scheduler.pendingCount == 2)
// "t2" finishes; "t1" stays active, so the queued "t1" resend at the
// front of the queue is still blocked when we reserve pending starts.
let didActivate = scheduler.activateReservedTransfer(id: "t2", totalFragments: 1, workItems: [])
#expect(didActivate)
#expect(scheduler.markFragmentSent(transferId: "t2") == .complete(sentFragments: 1, totalFragments: 1))
let starts = scheduler.reservePendingStarts(maxConcurrentTransfers: 2)
let startedTransferIds: [String] = starts.compactMap {
if case let .start(_, reservedTransferId) = $0 { return reservedTransferId }
return nil
}
#expect(startedTransferIds == ["t3"], "the unrelated pending transfer must start in the same pass despite the blocked front item")
#expect(scheduler.activeCount == 2, "t1 (still running) and the newly-started t3")
#expect(scheduler.pendingCount == 1, "only the blocked t1 resend remains queued")
}
@Test
func removeAllReturnsActiveWorkItemsAndDropsPendingTransfers() {
var scheduler = BLEOutboundFragmentTransferScheduler()

View File

@ -0,0 +1,192 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
struct BLEPrivateMediaSessionStoreTests {
private let peer = PeerID(str: "aaaaaaaaaaaaaaaa")
private let fingerprint = "ABCDEF0123456789"
@Test func sameGenerationReconciliationDoesNotRearmProofMachinery() {
let store = BLEPrivateMediaSessionStore()
let generation = UUID()
let fresh = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: generation
)
#expect(fresh != nil)
// A quarantine-restore of the same generation is a reconciliation,
// not a new session: no new watchdog, no waiter churn.
let again = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: generation
)
#expect(again == nil)
// The original watchdog identity survives.
#expect(store.proofTimeoutTarget(for: peer)?.nonce == fresh?.watchdogNonce)
}
@Test func freshGenerationRejectsFingerprintMismatchedWaiters() {
let store = BLEPrivateMediaSessionStore()
var completed = 0
_ = store.registerPolicyResolution(
for: peer, fingerprint: fingerprint, requestID: UUID(),
completion: { _ in completed += 1 }
)
// The replacement session authenticates a DIFFERENT identity: the
// old waiters must come back for rejection instead of riding along.
let fresh = store.beginAuthenticatedGeneration(
for: peer, fingerprint: "FEDCBA9876543210", generation: UUID()
)
#expect(fresh?.rejected.count == 1)
#expect(!store.hasPendingPolicyResolution(for: peer))
}
@Test func applyPeerStateRequiresTheCurrentGenerationAndReleasesWaiters() {
let store = BLEPrivateMediaSessionStore()
let generation = UUID()
_ = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: generation
)
_ = store.registerPolicyResolution(
for: peer, fingerprint: fingerprint, requestID: UUID(),
completion: { _ in }
)
// A proof bound to a superseded generation must not classify the
// replacement session.
let stale = store.applyAuthenticatedPeerState(
for: peer, fingerprint: fingerprint, generation: UUID(),
capabilities: [.privateMedia]
)
#expect(stale == nil)
let released = store.applyAuthenticatedPeerState(
for: peer, fingerprint: fingerprint, generation: generation,
capabilities: [.privateMedia]
)
#expect(released?.count == 1)
// Proof landed: the watchdog is retired, so nothing can time out.
#expect(store.proofTimeoutTarget(for: peer) == nil)
}
@Test func expiryRequiresTheLiveDeadlineIdentityAndPinsTheMarker() {
let store = BLEPrivateMediaSessionStore()
let generation = UUID()
let fresh = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: generation
)
let nonce = fresh?.watchdogNonce ?? UUID()
// A stale nonce (superseded deadline) must not expire anything.
let stale = store.expireProofDeadline(
for: peer, fingerprint: fingerprint,
sessionGeneration: generation, nonce: UUID()
)
#expect(!stale.expired)
let expired = store.expireProofDeadline(
for: peer, fingerprint: fingerprint,
sessionGeneration: generation, nonce: nonce
)
#expect(expired.expired)
#expect(!expired.deferredOutbound)
// The timeout marker now classifies this generation as unproven.
#expect(store.policyInputs(for: peer).timedOut != nil)
}
@Test func expiryReportsTheConvergenceDeferralSoDrainsStayParked() {
let store = BLEPrivateMediaSessionStore()
let generation = UUID()
let fresh = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: generation
)
store.setOutboundDeferredUntilConvergence(peer)
let expired = store.expireProofDeadline(
for: peer, fingerprint: fingerprint,
sessionGeneration: generation, nonce: fresh?.watchdogNonce ?? UUID()
)
#expect(expired.expired)
#expect(expired.deferredOutbound)
store.clearOutboundDeferredUntilConvergence(peer)
// Deferral is per-peer state, not per-deadline: once convergence
// clears it, a later expiry may drain.
_ = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: UUID()
)
let next = store.proofTimeoutTarget(for: peer)
let afterClear = store.expireProofDeadline(
for: peer, fingerprint: fingerprint,
sessionGeneration: next?.generation ?? nil, nonce: next?.nonce ?? UUID()
)
#expect(afterClear.expired)
#expect(!afterClear.deferredOutbound)
}
@Test func policyWaitersReuseTheLiveWatchdogDeadline() {
let store = BLEPrivateMediaSessionStore()
let generation = UUID()
let fresh = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: generation
)
// First waiter piggybacks on the watchdog's deadline (case-insensitive
// fingerprint match): no second timeout gets scheduled.
let first = store.registerPolicyResolution(
for: peer, fingerprint: fingerprint.lowercased(), requestID: UUID(),
completion: { _ in }
)
#expect(first.registered)
#expect(!first.shouldSchedule)
#expect(first.nonce == fresh?.watchdogNonce)
// Later waiters join the existing set.
let second = store.registerPolicyResolution(
for: peer, fingerprint: fingerprint, requestID: UUID(),
completion: { _ in }
)
#expect(second.registered)
#expect(!second.shouldSchedule)
}
@Test func clearSessionRebasesWaitersOntoANilGenerationDeadline() {
let store = BLEPrivateMediaSessionStore()
let generation = UUID()
_ = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: generation
)
_ = store.registerPolicyResolution(
for: peer, fingerprint: fingerprint, requestID: UUID(),
completion: { _ in }
)
store.setOutboundDeferredUntilConvergence(peer)
let rearm = store.clearSession(for: peer)
// Waiters survive the clear but their deadline is rebased so the
// old generation's timeout can no longer claim them.
#expect(rearm != nil)
#expect(store.currentGeneration(for: peer) == nil)
#expect(store.hasPendingPolicyResolution(for: peer))
let target = store.proofTimeoutTarget(for: peer)
#expect(target?.generation == nil)
#expect(target?.nonce == rearm?.nonce)
}
@Test func peerStateSendsAreOncePerGenerationPerKind() {
let store = BLEPrivateMediaSessionStore()
_ = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: UUID()
)
#expect(store.markPeerStateSend(for: peer, echo: false))
#expect(!store.markPeerStateSend(for: peer, echo: false))
#expect(store.markPeerStateSend(for: peer, echo: true))
#expect(!store.markPeerStateSend(for: peer, echo: true))
// A fresh generation resets both slots.
_ = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: UUID()
)
#expect(store.markPeerStateSend(for: peer, echo: false))
}
}

View File

@ -0,0 +1,85 @@
import Foundation
import Testing
/// Pins the BLE transport's sync-edge order so it stays structural instead
/// of decaying back into per-site discipline:
///
/// main / test threads sync engine sync bleQueue
/// sync noise / identity queues
///
/// and never the reverse. `onEngine` is the single place allowed to
/// sync-enter the engine it carries the debug trap that catches a
/// bleQueue caller before it can pair with `readLinkState` into an ABBA
/// deadlock (the class of the July 9 field freeze). A raw
/// `messageQueue.sync` bypasses that trap, and a `DispatchQueue.main.sync`
/// from transport code completes a cycle with the main actor's sync reads.
///
/// Waive a line with `queue-contract-ok:` plus a reason.
struct BLEQueueContractTests {
static let waiver = "queue-contract-ok:"
private static let bleRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // Services
.deletingLastPathComponent() // bitchatTests
.deletingLastPathComponent() // repo root
.appendingPathComponent("bitchat/Services/BLE")
private struct Line {
let file: String
let number: Int
let text: String
let waived: Bool
}
private static func bleLines() throws -> [Line] {
let enumerator = FileManager.default.enumerator(
at: bleRoot,
includingPropertiesForKeys: nil
)
var out: [Line] = []
while let url = enumerator?.nextObject() as? URL {
guard url.pathExtension == "swift" else { continue }
let name = url.lastPathComponent
let texts = try String(contentsOf: url, encoding: .utf8)
.components(separatedBy: .newlines)
for (index, text) in texts.enumerated() {
var waived = text.contains(waiver)
var back = index - 1
while !waived, back >= 0 {
let previous = texts[back].trimmingCharacters(in: .whitespaces)
guard previous.hasPrefix("//") else { break }
waived = previous.contains(waiver)
back -= 1
}
out.append(Line(file: name, number: index + 1, text: text, waived: waived))
}
}
return out
}
private func offenders(matching pattern: String) throws -> [String] {
try Self.bleLines()
.filter { !$0.waived && $0.text.contains(pattern) }
.map { "\($0.file):\($0.number): \($0.text.trimmingCharacters(in: .whitespaces))" }
}
@Test func onlyOnEngineSyncEntersTheEngine() throws {
let hits = try offenders(matching: "messageQueue.sync")
#expect(hits.isEmpty, "Raw messageQueue.sync bypasses onEngine's bleQueue trap; route through onEngine (or waive with a reason): \(hits)")
}
@Test func transportCodeNeverSyncDispatchesToMain() throws {
let hits = try offenders(matching: "DispatchQueue.main.sync")
#expect(hits.isEmpty, "A main.sync from transport code can complete an ABBA cycle with the main actor's sync reads: \(hits)")
}
@Test func theCollectionsQueueStaysDeleted() throws {
let hits = try offenders(matching: "collectionsQueue")
#expect(hits.isEmpty, "Engine state has exactly one serial domain; do not reintroduce a side queue: \(hits)")
}
@Test func deferredEngineWorkGoesThroughTheScheduler() throws {
let hits = try offenders(matching: "messageQueue.asyncAfter")
#expect(hits.isEmpty, "Engine delays must use BLEEngineScheduling so tests can drive protocol deadlines with a manual clock: \(hits)")
}
}

View File

@ -7,8 +7,8 @@ struct BLERedundantLinkPolicyTests {
private let peer = PeerID(str: "1122334455667788")
private let otherPeer = PeerID(str: "8877665544332211")
private func link(_ uuid: String, _ peerID: PeerID?, connected: Bool = true, writable: Bool = true) -> BLERedundantLinkPolicy.PeripheralLink {
BLERedundantLinkPolicy.PeripheralLink(uuid: uuid, peerID: peerID, isConnected: connected, hasCharacteristic: writable)
private func link(_ uuid: String, _ peerID: PeerID?, connected: Bool = true, writable: Bool = true, connectedAt: Date? = nil) -> BLERedundantLinkPolicy.PeripheralLink {
BLERedundantLinkPolicy.PeripheralLink(uuid: uuid, peerID: peerID, isConnected: connected, hasCharacteristic: writable, lastConnectedAt: connectedAt)
}
@Test
@ -131,4 +131,144 @@ struct BLERedundantLinkPolicyTests {
)
#expect(Set(retiring) == Set(["p-stale-1", "p-stale-2"]))
}
// MARK: Connect-recency preference (the July 31 retirereconnect fix)
@Test
func newestConnectionWinsOverIngressAndBindingAnchors() {
// Field oscillation: the restored old-address link (no connect
// timestamp) carried the announce ingress AND the binding, so it
// kept winning and the cancelled fresh-address link kept getting
// rediscovered and reconnected. Physical connect recency must beat
// both announce anchors.
let now = Date()
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: "p-restored",
mostRecentlyBoundUUID: "p-restored",
links: [
link("p-restored", peer),
link("p-fresh", peer, connectedAt: now)
],
peerID: peer
)
#expect(kept == "p-fresh")
}
@Test
func amongTimestampedLinksTheNewestWins() {
let now = Date()
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: "p-older",
mostRecentlyBoundUUID: "p-older",
links: [
link("p-older", peer, connectedAt: now.addingTimeInterval(-30)),
link("p-newer", peer, connectedAt: now)
],
peerID: peer
)
#expect(kept == "p-newer")
}
@Test
func newestLinkMidDiscoveryDefersInsteadOfKeepingOlderWritable() {
// The fresh connection hasn't finished service discovery, so it is
// not writable yet. Keeping the older writable (restored) link now
// would cancel the one connection on the currently advertised
// address and recreate the oscillation defer to a later announce.
let now = Date()
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: "p-writable",
mostRecentlyBoundUUID: "p-writable",
links: [
link("p-writable", peer, connectedAt: now.addingTimeInterval(-30)),
link("p-fresh-bare", peer, writable: false, connectedAt: now)
],
peerID: peer
)
#expect(kept == nil)
}
@Test
func restoredWritableAnchorAlsoDefersToFreshUnwritableLink() {
// Same discovery window as above, but the writable duplicate is a
// restored link with no connect timestamp at all the exact field
// topology. It must not win just because the fresh link is bare.
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: "p-restored",
mostRecentlyBoundUUID: "p-restored",
links: [
link("p-restored", peer),
link("p-fresh-bare", peer, writable: false, connectedAt: Date())
],
peerID: peer
)
#expect(kept == nil)
}
@Test
func coNewestWritableLinkStillWinsOverBareTwin() {
// Two links share the newest timestamp and one is writable: no
// discovery window to wait out the writable co-newest survives.
let now = Date()
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: nil,
mostRecentlyBoundUUID: nil,
links: [
link("p-bare", peer, writable: false, connectedAt: now),
link("p-writable", peer, connectedAt: now)
],
peerID: peer
)
#expect(kept == "p-writable")
}
@Test
func allUnwritableDuplicatesConsolidateByConnectRecency() {
// No writable link exists at all: nothing can be stranded, so the
// newest connection consolidates immediately.
let now = Date()
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: "p-older",
mostRecentlyBoundUUID: "p-older",
links: [
link("p-older", peer, writable: false, connectedAt: now.addingTimeInterval(-30)),
link("p-newer", peer, writable: false, connectedAt: now)
],
peerID: peer
)
#expect(kept == "p-newer")
}
@Test
func allRestoredLinksFallBackToAnnounceAnchors() {
// No connect timestamps at all (every link restored): the legacy
// ingress-then-binding preference still decides.
let kept = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: "p-ingress",
mostRecentlyBoundUUID: "p-bound",
links: [link("p-ingress", peer), link("p-bound", peer)],
peerID: peer
)
#expect(kept == "p-ingress")
}
@Test
func timestampTiesBreakByAnchorsThenDeterministically() {
let now = Date()
let anchored = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: "p-b",
mostRecentlyBoundUUID: nil,
links: [link("p-a", peer, connectedAt: now), link("p-b", peer, connectedAt: now)],
peerID: peer
)
#expect(anchored == "p-b")
let unanchored = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: nil,
mostRecentlyBoundUUID: nil,
links: [link("p-b", peer, connectedAt: now), link("p-a", peer, connectedAt: now)],
peerID: peer
)
#expect(unanchored == "p-a")
}
}

View File

@ -15,7 +15,7 @@ final class FavoritesPersistenceServiceTests: XCTestCase {
service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1alice", peerNickname: "Alice")
wait(for: [expectation], timeout: 1.0)
wait(for: [expectation], timeout: TestConstants.settleTimeout)
XCTAssertTrue(service.isFavorite(peerKey))
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Alice")
XCTAssertNotNil(keychain.load(key: storageKey, service: serviceKey))

View File

@ -227,7 +227,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
}
private func waitUntil(
timeout: TimeInterval = 1.0,
timeout: TimeInterval = TestConstants.settleTimeout,
condition: @escaping @MainActor () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)

View File

@ -355,7 +355,7 @@ final class LocationStateManagerTests: XCTestCase {
}
private func waitUntil(
timeout: TimeInterval = 1.0,
timeout: TimeInterval = TestConstants.settleTimeout,
condition: @escaping @MainActor () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)

View File

@ -114,4 +114,83 @@ struct MediaRetentionTests {
func defaultRetentionIsSevenDays() {
#expect(BLEIncomingFileStore.defaultMediaRetention == 7 * 24 * 60 * 60)
}
#if os(iOS)
/// Media was the one persistence layer that never stated a protection
/// class at its write site, so payloads inherited the container
/// default. Saves must survive the added write option,
/// and on device the class must read back. The simulator's filesystem
/// does not model data protection (the attribute reads back nil there),
/// so the readback assertion is device-only.
@Test
func savedMediaSurvivesExplicitProtectionClass() throws {
let root = makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEIncomingFileStore(baseDirectory: root)
let payload = Data([0xFF, 0xD8, 0xFF, 0xD9])
let saved = try #require(store.save(
data: payload,
preferredName: "note.m4a",
subdirectory: "voicenotes/incoming",
fallbackExtension: "m4a",
defaultPrefix: "voice"
))
#expect(try Data(contentsOf: saved) == payload)
#if !targetEnvironment(simulator)
let protection = try FileManager.default.attributesOfItem(
atPath: saved.path
)[.protectionKey] as? FileProtectionType
#expect(protection == .completeUntilFirstUserAuthentication)
#endif
}
/// Files written before payloads carried an explicit class are stamped
/// by the launch-time migration that follows the retention sweep: the
/// directory plus each resident file, without error. In-flight live
/// captures are left alone, exactly as the sweep leaves them: the
/// coordinator may still be writing to one through an open FileHandle,
/// and new captures receive the class at creation. Readback is device-only for the same
/// reason as above.
@Test
func migrationStampsPreexistingMediaAndSkipsLiveCaptures() throws {
let root = makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEIncomingFileStore(baseDirectory: root)
let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming")
let legacy = try write(
"received.m4a",
in: incoming,
modified: Date(timeIntervalSinceNow: -60)
)
_ = try write(
"\(BLEIncomingFileStore.liveCapturePrefix)00112233445566ff_dm.aac",
in: incoming,
modified: Date(timeIntervalSinceNow: -60)
)
// Exactly the directory itself plus the legacy file; strict equality
// is what proves the live capture was not stamped.
#expect(store.migrateFileProtectionIfNeeded() == 2)
#expect(FileManager.default.fileExists(atPath: legacy.path))
#if !targetEnvironment(simulator)
let protection = try FileManager.default.attributesOfItem(
atPath: legacy.path
)[.protectionKey] as? FileProtectionType
#expect(protection == .completeUntilFirstUserAuthentication)
#endif
}
/// A store with no media on disk has nothing to stamp.
@Test
func migrationWithNoMediaIsANoOp() {
let root = makeRoot()
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEIncomingFileStore(baseDirectory: root)
#expect(store.migrateFileProtectionIfNeeded() == 0)
}
#endif
}

Some files were not shown because too many files have changed in this diff Show More