Files
bitchat/bitchat/Services/BLE/BLEService.swift
T
jackandGitHub 820c933958 Harden PTT audio and the 1.7.1 release (#1423)
Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage.

Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives.
2026-07-10 14:04:09 +02:00

5786 lines
263 KiB
Swift

import BitLogger
import BitFoundation
import Foundation
import CoreBluetooth
import Combine
#if os(iOS)
import UIKit
#endif
/// BLEService — Bluetooth Mesh Transport
/// - Emits events exclusively via `BitchatDelegate` for UI.
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
final class BLEService: NSObject {
// MARK: - Constants
#if DEBUG
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5A") // testnet
#else
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet
#endif
static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
private static let centralRestorationID = "chat.bitchat.ble.central"
private static let peripheralRestorationID = "chat.bitchat.ble.peripheral"
// Default per-fragment chunk size when link limits are unknown
private let defaultFragmentSize = TransportConfig.bleDefaultFragmentSize
private let bleMaxMTU = 512
private let maxMessageLength = InputValidator.Limits.maxMessageLength
private let messageTTL: UInt8 = TransportConfig.messageTTLDefault
// Flood/battery controls
private let maxInFlightAssemblies = TransportConfig.bleMaxInFlightAssemblies // cap concurrent fragment assemblies
private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays
// MARK: - Core State (5 Essential Collections)
// 1. Consolidated BLE link tracking for both central and peripheral roles.
private var linkStateStore = BLELinkStateStore()
// A peer ID can retain an established Noise session after its physical
// link disappears. Courier handover therefore needs the stronger fact
// that the session was established *on this current ingress link*, not
// merely that some session exists for the claimed ID. bleQueue-owned.
private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:]
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
// store): entries older than the cooldown are pruned on insert.
private var lastLinkRebindAt: [String: Date] = [:]
// Redundant-link retirement cooldown per peer (bleQueue-owned): bounds
// how often a replayed announce could flip which duplicate link survives.
private var lastRedundantLinkRetirementAt: [PeerID: Date] = [:]
// BCH-01-004: Rate-limiting for subscription-triggered announces.
private var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter()
// 3. Peer Information (single source of truth)
private var peerRegistry = BLEPeerRegistry()
// 4. Efficient Message Deduplication
private let messageDeduplicator = MessageDeduplicator()
// Courier store-and-forward: envelopes this device carries for offline
// third parties, and the trust gate for accepting deposits. The policy
// maps (depositor key, announce-verified?) to a quota tier, or nil to
// reject. Injectable for tests; main-actor policy because favorites live
// on the main actor.
var courierStore: CourierStore = .shared
// Bulletin-board posts this device carries; injectable for tests.
var boardStore: BoardStore = .shared
var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in
if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite }
return isVerifiedPeer ? .verified : nil
}
// Local-only store-and-forward counters; nil in unit tests.
var sfMetrics: StoreAndForwardMetrics?
// Verified one-time prekey bundles gossiped by other peers, used to seal
// courier mail forward-secretly. Injectable for tests.
var prekeyBundleStore: PrekeyBundleStore = .shared
// Throttle for re-broadcasting our own (unchanged) bundle; guarded by
// collectionsQueue barriers.
private var lastPrekeyBundleSentAt: Date?
// Prekey bundles that arrived before their owner's verified announce bound
// a signing key. The receive queue is concurrent, so a bundle can race
// ahead of the announce it depends on; we retain the latest such bundle per
// owner (bounded) and re-attempt attribution when the announce lands.
// Guarded by collectionsQueue barriers.
private var pendingPrekeyBundles: [PeerID: BitchatPacket] = [:]
private static let pendingPrekeyBundleCap = 64
// Gateway mode: sink for received nostrCarrier packets (set by app
// wiring, called on the main actor after transport-level checks) and the
// runtime-toggled capability bits ORed into `PeerCapabilities.localSupported`
// for every announce. `directedToUs` distinguishes an uplink deposit
// addressed to this device from a downlink broadcast.
var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)?
/// Fired (off-main) when a signature-verified announce is processed —
/// the bridge courier watch refreshes its tag set on new arrivals.
var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)?
private var runtimeCapabilities: PeerCapabilities = [] // collectionsQueue
private var localBridgeGeohash: String? // collectionsQueue
#if DEBUG
// Test-only tap on the outbound pipeline so multi-node tests can ferry
// packets between in-process service instances.
var _test_onOutboundPacket: ((BitchatPacket) -> Void)?
#endif
private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker()
// Route health for originated source routes; guarded by collectionsQueue.
private var sourceRouteFailures = BLESourceRouteFailureCache()
// Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the
// inbound ping budget — keyed by the ingress link (the directly connected
// peer that delivered the packet), since the unsigned claimed sender is
// spoofable — so a directed unencrypted probe cannot be turned into an
// amplification primitive. Both are owned by collectionsQueue barriers
// like the other mutable collections.
private struct PendingMeshPing {
let peerID: PeerID
let sentAt: Date
let completion: @MainActor (MeshPingResult?) -> Void
let timeout: DispatchWorkItem
}
private var pendingMeshPings: [Data: PendingMeshPing] = [:]
private var meshPingResponseLimiter = SyncResponseRateLimiter(
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
window: TransportConfig.meshPingInboundWindowSeconds
)
// 5. Fragment Reassembly (necessary for messages > MTU)
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
private let incomingFileStore = BLEIncomingFileStore()
// Simple announce throttling
private var announceThrottle = BLEAnnounceThrottle()
// Application state tracking (thread-safe)
#if os(iOS)
private var isAppActive: Bool = true // Assume active initially
/// Last `UIApplication.shared.backgroundTimeRemaining` sampled on the
/// main thread, cached so bleQueue status logs can read it without ever
/// dispatching to main (see `captureBluetoothStatus` for the invariant).
private let backgroundTimeLock = NSLock()
private var _cachedBackgroundTimeRemaining: TimeInterval = .greatestFiniteMagnitude
private var cachedBackgroundTimeRemaining: TimeInterval {
backgroundTimeLock.lock(); defer { backgroundTimeLock.unlock() }
return _cachedBackgroundTimeRemaining
}
#endif
// MARK: - Core BLE Objects
private var centralManager: CBCentralManager?
private var peripheralManager: CBPeripheralManager?
private var characteristic: CBMutableCharacteristic?
// MARK: - Identity
private var noiseService: NoiseEncryptionService
private let identityManager: SecureIdentityStateManagerProtocol
private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge
/// Binary form of `myPeerID`; same contract — mutated only inside a
/// `messageQueue` barrier via `refreshPeerIdentity()`.
private var myPeerIDData: Data = Data()
// MARK: - Advertising Privacy
// No Local Name by default for maximum privacy. No rotating alias.
// MARK: - Queues
private let messageQueue = DispatchQueue(label: "mesh.message", attributes: .concurrent)
private let collectionsQueue = DispatchQueue(label: "mesh.collections", attributes: .concurrent)
private let messageQueueKey = DispatchSpecificKey<Void>()
private let bleQueue = DispatchQueue(label: "mesh.bluetooth", qos: .userInitiated)
private let bleQueueKey = DispatchSpecificKey<Void>()
// Noise messages and typed payloads pending handshake completion.
private var pendingNoiseSessionQueues = BLENoiseSessionQueues()
// Queue for notifications that failed due to full queue
private var pendingNotifications = BLEOutboundNotificationBuffer<CBCentral>()
// Backpressure logging fires per fragment during media transfers
// (hundreds of lines per image); sampled via this counter, which is
// only touched inside collectionsQueue barriers (no sync needed).
var notificationBackpressureLogCount = 0
// Accumulate long write chunks per central until a full frame decodes
private var pendingWriteBuffers = BLEInboundWriteBuffer()
// Relay jitter scheduling to reduce redundant floods
private var scheduledRelays = BLEScheduledRelayStore()
// Track short-lived traffic bursts to adapt announces/scanning under load
private var recentTrafficTracker = BLERecentTrafficTracker()
// Ingress link tracking for duplicate and last-hop suppression
private var ingressLinks = BLEIngressLinkRegistry()
// Inner message IDs of recently opened courier envelopes. Redundant
// copies of one message ride different envelopes (each seal uses a fresh
// ephemeral key, and bridge drops multiply across relays/couriers), so
// envelope-level dedup can't catch them; dedup on the inner ID before
// delivery so a duplicate costs one decrypt instead of a delivery + ack
// + handshake each. Owned by collectionsQueue barriers.
private var openedCourierMessageIDs = BoundedIDSet(capacity: TransportConfig.courierOpenedMessageIDCap)
private let logRateLimiter = BLELogRateLimiter(defaultMinimumInterval: 5)
private var pendingPeripheralWrites = BLEOutboundWriteBuffer()
// Debounce duplicate disconnect notifies
private var disconnectNotifyDebouncer = BLEPeerEventDebouncer()
// Store-and-forward for directed messages when we have no links
private var pendingDirectedRelays = BLEDirectedRelaySpool()
// Debounce for 'reconnected' logs
private var reconnectLogDebouncer = BLEPeerEventDebouncer()
// Announce-packet orchestration (queue hops stay in the environment closures)
private lazy var announceHandler = BLEAnnounceHandler(environment: makeAnnounceHandlerEnvironment())
// Public-message orchestration (queue hops stay in the environment closures)
private lazy var publicMessageHandler = BLEPublicMessageHandler(environment: makePublicMessageHandlerEnvironment())
// Noise handshake/encrypted orchestration (queue hops and crypto stay in the environment closures)
private lazy var noisePacketHandler = BLENoisePacketHandler(environment: makeNoisePacketHandlerEnvironment())
// Fragment-assembly orchestration (queue hops stay in the environment closures)
private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment())
// File-transfer orchestration (queue hops stay in the environment closures)
private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment())
// MARK: - Gossip Sync
private var gossipSyncManager: GossipSyncManager?
private let requestSyncManager = RequestSyncManager()
// MARK: - Maintenance Timer
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
private var maintenanceCounter = 0 // Track maintenance cycles
private var lastMaintenanceAt = Date.distantPast // bleQueue-confined; drives background-wake catch-up passes
/// Whether real CoreBluetooth managers were initialized. When false (unit
/// tests), periodic mesh background work is not started — the maintenance
/// timer and the gossip-sync timers only drain BLE writes/notifications,
/// re-announce, and sign/broadcast sync packets, all meaningless without
/// Bluetooth. Leaving them running in the test process is pure background
/// churn that aggravates flaky exit hangs.
private var meshBackgroundEnabled = false
// MARK: - Connection budget & scheduling (central role)
private var connectionScheduler = BLEConnectionScheduler<CBPeripheral>()
// Recently seen peripherals retained for background wake-on-proximity
// connects (bleQueue-confined, like the link state store)
private let recentPeripheralCache = BLERecentPeripheralCache<CBPeripheral>()
// MARK: - 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
// Debounced publish to coalesce rapid changes
private var peerPublishCoalescer = BLEPeerPublishCoalescer()
private func requestPeerDataPublish() {
switch peerPublishCoalescer.requestPublish(now: Date()) {
case .publishNow:
publishFullPeerData()
case .schedule(let delay):
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self = self else { return }
self.peerPublishCoalescer.scheduledPublishFired(now: Date())
self.publishFullPeerData()
}
case .skip:
break
}
}
// MARK: - Initialization
init(
keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol,
initializeBluetoothManagers: Bool = true
) {
self.keychain = keychain
self.idBridge = idBridge
noiseService = NoiseEncryptionService(keychain: keychain)
self.identityManager = identityManager
super.init()
configureNoiseServiceCallbacks(for: noiseService)
refreshPeerIdentity()
// Set queue key for identification
messageQueue.setSpecific(key: messageQueueKey, value: ())
// Set up application state tracking (iOS only)
#if os(iOS)
// Check initial state on main thread. The background-budget cache is
// seeded here too: a background-restore launch captures Bluetooth
// status before any lifecycle notification fires, and the init-time
// sentinel would log a meaningless bgRemaining=∞ for exactly the
// wake window that matters.
if Thread.isMainThread {
isAppActive = UIApplication.shared.applicationState == .active
refreshCachedBackgroundTimeRemaining()
} else {
DispatchQueue.main.sync {
isAppActive = UIApplication.shared.applicationState == .active
refreshCachedBackgroundTimeRemaining()
}
}
// Observe application state changes
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
#endif
// Tag BLE queue for re-entrancy detection
bleQueue.setSpecific(key: bleQueueKey, value: ())
// Link state is owned exclusively by bleQueue; debug builds trap
// any access from another queue (cross-queue reads use readLinkState).
linkStateStore.assumeOwnership(of: bleQueue)
if initializeBluetoothManagers {
// Initialize BLE on background queue to prevent main thread blocking.
#if os(iOS)
let centralOptions: [String: Any] = [
CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID
]
centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions)
let peripheralOptions: [String: Any] = [
CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID
]
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions)
#else
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
#endif
}
// Single maintenance timer for all periodic tasks (dispatch-based for
// determinism). Only run it when real Bluetooth managers exist.
meshBackgroundEnabled = initializeBluetoothManagers
startMaintenanceTimer()
// Publish initial empty state
requestPeerDataPublish()
// Initialize gossip sync manager
restartGossipManager()
}
private func restartGossipManager() {
// Stop existing
gossipSyncManager?.stop()
let config = GossipSyncManager.Config(
seenCapacity: TransportConfig.syncSeenCapacity,
gcsMaxBytes: TransportConfig.syncGCSMaxBytes,
gcsTargetFpr: TransportConfig.syncGCSTargetFpr,
maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds,
publicMessageMaxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds,
maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds,
stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds,
stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds,
fragmentCapacity: TransportConfig.syncFragmentCapacity,
fileTransferCapacity: TransportConfig.syncFileTransferCapacity,
fragmentSyncIntervalSeconds: TransportConfig.syncFragmentIntervalSeconds,
fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds,
messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds,
responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses,
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds,
prekeyBundleCapacity: TransportConfig.syncPrekeyBundleCapacity,
prekeyBundleSyncIntervalSeconds: TransportConfig.syncPrekeyBundleIntervalSeconds,
prekeyBundleMaxAgeSeconds: TransportConfig.syncPrekeyBundleMaxAgeSeconds
)
// Only real Bluetooth sessions archive to disk; unit tests stay hermetic.
let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive)
manager.delegate = self
// Board posts sync from the board store (their retention owner) so
// deleted/expired posts drop out of rounds immediately. Real sessions
// only, matching the archive: unit tests stay hermetic.
if meshBackgroundEnabled {
manager.boardPacketsProvider = { [weak self] in
self?.boardStore.syncCandidates() ?? []
}
}
// Only start the periodic sync timers when real Bluetooth exists. In unit
// tests there is no mesh to sync with, and the periodic sign/broadcast
// churn just keeps the process busy and aggravates flaky exit hangs.
if meshBackgroundEnabled {
manager.start()
}
gossipSyncManager = manager
}
// No advertising policy to set; we never include Local Name in adverts.
deinit {
maintenanceTimer?.cancel()
scanDutyTimer?.cancel()
scanDutyTimer = nil
centralManager?.stopScan()
peripheralManager?.stopAdvertising()
#if os(iOS)
NotificationCenter.default.removeObserver(self)
#endif
}
func resetIdentityForPanic(currentNickname: String) {
messageQueue.sync(flags: .barrier) {
pendingNoiseSessionQueues.removeAll()
}
let cancelledTransfers = collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.removeAll()
pendingNotifications.removeAll()
let transfers = outboundFragmentTransfers.removeAll()
fragmentAssemblyBuffer.removeAll()
pendingDirectedRelays.removeAll()
ingressLinks.removeAll()
recentTrafficTracker.removeAll()
scheduledRelays.cancelAll()
// Let the post-panic identity publish its fresh bundle promptly.
lastPrekeyBundleSentAt = nil
return transfers
}
for entry in cancelledTransfers {
entry.workItems.forEach { $0.cancel() }
TransferProgressManager.shared.cancel(id: entry.id)
}
bleQueue.sync {
pendingWriteBuffers.removeAll()
connectionScheduler.reset()
}
disconnectNotifyDebouncer.removeAll()
// The crypto-service replacement and the derived identity swap must be
// one atomic unit with respect to messageQueue senders: a queued send
// must never observe the new Noise service alongside the old peer ID
// (it would sign with the new identity while carrying the old sender).
// refreshPeerIdentity() executes inline here via its re-entrancy check.
messageQueue.sync(flags: .barrier) {
noiseService.clearEphemeralStateForPanic()
noiseService.clearPersistentIdentity()
let newNoise = NoiseEncryptionService(keychain: keychain)
noiseService = newNoise
configureNoiseServiceCallbacks(for: newNoise)
refreshPeerIdentity()
}
restartGossipManager()
setNickname(currentNickname)
messageDeduplicator.reset()
messageQueue.async(flags: .barrier) { [weak self] in
self?.selfBroadcastTracker.removeAll()
}
requestPeerDataPublish()
startServices()
}
// Ensure this runs on message queue to avoid main thread blocking
func sendMessage(_ content: String, mentions: [String] = [], to recipientID: PeerID? = nil, messageID: String? = nil, timestamp: Date? = nil) {
// Call directly if already on messageQueue, otherwise dispatch
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
messageQueue.async { [weak self] in
self?.sendMessage(content, mentions: mentions, to: recipientID, messageID: messageID, timestamp: timestamp)
}
return
}
guard content.count <= maxMessageLength else {
SecureLogger.error("Message too long: \(content.count) chars", category: .session)
return
}
if let recipientID {
sendPrivateMessage(content, to: recipientID, messageID: messageID ?? UUID().uuidString)
return
}
// Public broadcast
// Create packet with explicit fields so we can sign it
let sendDate = timestamp ?? Date()
let sendTimestampMs = UInt64(sendDate.timeIntervalSince1970 * 1000)
let basePacket = BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: sendTimestampMs,
payload: Data(content.utf8),
signature: nil,
ttl: messageTTL
)
guard let signedPacket = noiseService.signPacket(basePacket) else {
SecureLogger.error("❌ Failed to sign public message", category: .security)
return
}
// Pre-mark our own broadcast as processed to avoid handling relayed self copy
let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
messageDeduplicator.markProcessed(dedupID)
if let messageID {
selfBroadcastTracker.record(messageID: messageID, packet: signedPacket, sentAt: sendDate)
}
// Call synchronously since we're already on background queue
broadcastPacket(signedPacket)
// Track our own broadcast for sync
gossipSyncManager?.onPublicPacketSeen(signedPacket)
}
// MARK: - Transport Protocol Conformance
// MARK: Delegates
weak var delegate: BitchatDelegate?
weak var eventDelegate: TransportEventDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate?
func currentPeerSnapshots() -> [TransportPeerSnapshot] {
collectionsQueue.sync {
peerRegistry.transportSnapshots(selfNickname: myNickname)
}
}
// MARK: Identity
/// Derived from the Noise identity fingerprint; rotated only via
/// `refreshPeerIdentity()` (e.g. panic reset), which performs the swap
/// inside a `messageQueue` barrier so concurrent queue work never sees a
/// half-updated identity. Externally read-only — no out-of-band mutation
/// may bypass that derivation.
private(set) var myPeerID = PeerID(str: "")
/// Externally read-only; mutate via `setNickname(_:)`, which also
/// broadcasts the change to peers.
private(set) var myNickname: String = "anon"
/// Sole mutator for `myNickname`: updates the stored value and force-sends
/// an announce so peers learn the new name.
func setNickname(_ nickname: String) {
self.myNickname = nickname
// Send announce to notify peers of nickname change (force send)
sendAnnounce(forceSend: true)
}
// MARK: Lifecycle
/// Creates and starts the periodic maintenance timer if it is not already
/// running. Idempotent so it can be called from both `init` and
/// `startServices()` — the latter matters after a panic reset, where
/// `stopServices()` cancels and nils the timer.
private func startMaintenanceTimer() {
guard meshBackgroundEnabled, maintenanceTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
repeating: TransportConfig.bleMaintenanceInterval,
leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))
timer.setEventHandler { [weak self] in
self?.performMaintenance()
}
timer.resume()
maintenanceTimer = timer
}
func startServices() {
// Restart the maintenance timer if a prior stopServices() cancelled it
// (e.g. the panic flow), otherwise periodic announces, peer reconciliation
// and cache cleanup would never resume until app restart.
startMaintenanceTimer()
// Start BLE services if not already running
if centralManager?.state == .poweredOn {
centralManager?.scanForPeripherals(
withServices: [BLEService.serviceUUID],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
// Send initial announce after services are ready
// Use longer delay to avoid conflicts with other announces
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true)
}
}
func stopServices() {
// Send leave message synchronously to ensure delivery
var leavePacket = BitchatPacket(
type: MessageType.leave.rawValue,
senderID: myPeerIDData,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(),
signature: nil,
ttl: messageTTL
)
if let signed = noiseService.signPacket(leavePacket) {
leavePacket = signed
}
// Send immediately to all connected peers (synchronized access to BLE state)
if let data = leavePacket.toBinaryData(padding: false) {
let leavePriority = BLEOutboundPacketPolicy.priority(for: leavePacket, data: data)
// Snapshot BLE state under bleQueue to avoid races with delegate callbacks
let (peripheralStates, centralsCount, char) = bleQueue.sync {
(linkStateStore.peripheralStates, linkStateStore.subscribedCentralCount, characteristic)
}
// Send to peripherals we're connected to as central
for state in peripheralStates where state.isConnected {
if let characteristic = state.characteristic {
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic, priority: leavePriority)
}
}
// Send to centrals subscribed to us as peripheral
if centralsCount > 0, let ch = char {
peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: nil)
}
}
// Give leave message a moment to send (cooperative delay allows BLE callbacks to fire)
let deadline = Date().addingTimeInterval(TransportConfig.bleThreadSleepWriteShortDelaySeconds)
while Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
}
// Clear pending notifications
collectionsQueue.sync(flags: .barrier) {
pendingNotifications.removeAll()
}
// Stop timer
maintenanceTimer?.cancel()
maintenanceTimer = nil
scanDutyTimer?.cancel()
scanDutyTimer = nil
centralManager?.stopScan()
peripheralManager?.stopAdvertising()
// Disconnect all peripherals (synchronized access)
let peripheralsToDisconnect = bleQueue.sync { linkStateStore.peripheralStates }
for state in peripheralsToDisconnect {
centralManager?.cancelPeripheralConnection(state.peripheral)
}
}
func emergencyDisconnectAll() {
stopServices()
// Clear all sessions and peers
let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) {
let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) }
peerRegistry.removeAll()
fragmentAssemblyBuffer.removeAll()
sourceRouteFailures = BLESourceRouteFailureCache()
// Also clear pending message queues to avoid stale state across sessions
pendingNoiseSessionQueues.removeAll()
pendingDirectedRelays.removeAll()
return entries
}
for entry in cancelledTransfers {
entry.items.forEach { $0.cancel() }
TransferProgressManager.shared.cancel(id: entry.id)
}
// Clear processed messages
messageDeduplicator.reset()
// Clear peripheral references (synchronized access to avoid races with BLE callbacks)
bleQueue.sync {
linkStateStore.clearAll()
noiseAuthenticatedLinkOwners.removeAll()
connectionScheduler.reset()
subscriptionAnnounceLimiter.removeAll()
}
meshTopology.reset()
}
// MARK: Connectivity and peers
func isPeerConnected(_ peerID: PeerID) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys
return collectionsQueue.sync { peerRegistry.isConnected(peerID) }
}
func isPeerReachable(_ peerID: PeerID) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys
return collectionsQueue.sync {
peerRegistry.isReachable(peerID, now: Date())
}
}
func canDeliverSecurely(to peerID: PeerID) -> Bool {
// A live link binding alone is forgeable: the rotation heal rebinds a
// link on a signature-verified "direct" announce, but directness rides
// on the unsigned TTL, so a replayed announce can bind an absent
// peer's ID to the replayer's link. An established Noise session
// proves the other end of the link holds the peer's private key.
//
// Sessions are keyed by the short wire ID, so normalize like
// isPeerConnected does — a send keyed by the full 64-hex Noise key
// must not misread an established session as insecure.
noiseService.hasEstablishedSession(with: peerID.toShort())
}
func peerNickname(peerID: PeerID) -> String? {
collectionsQueue.sync {
peerRegistry.nickname(for: peerID, connectedOnly: true)
}
}
/// Capabilities the peer advertised in its last verified announce.
/// Empty for peers that predate the capabilities TLV.
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities {
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
}
/// Enables or disables a runtime-advertised capability bit (e.g. the
/// internet-gateway toggle) and re-announces so peers learn promptly.
/// Build-time bits stay in `PeerCapabilities.localSupported`.
func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool) {
let changed: Bool = collectionsQueue.sync(flags: .barrier) {
let before = runtimeCapabilities
if enabled {
runtimeCapabilities.insert(capability)
} else {
runtimeCapabilities.remove(capability)
}
return runtimeCapabilities != before
}
guard changed else { return }
sendAnnounce(forceSend: true)
}
/// Reachable peers currently advertising the `.gateway` capability.
func reachableGatewayPeers() -> [PeerID] {
let now = Date()
return collectionsQueue.sync {
peerRegistry.peers(advertising: .gateway)
.filter { peerRegistry.isReachable($0, now: now) }
}
}
/// Reachable peers currently advertising the `.bridge` capability.
func reachableBridgePeers() -> [PeerID] {
let now = Date()
return collectionsQueue.sync {
peerRegistry.peers(advertising: .bridge)
.filter { peerRegistry.isReachable($0, now: now) }
}
}
/// A rendezvous cell advertised by a bridge-capable peer's announce.
func advertisedBridgeGeohash() -> String? {
collectionsQueue.sync { peerRegistry.advertisedBridgeGeohash() }
}
/// The rendezvous cell this device advertises in its own announces while
/// bridging with the gateway toggle on. Set from the main actor; the
/// value rides the next (forced) announce.
func setLocalBridgeGeohash(_ cell: String?) {
let changed: Bool = collectionsQueue.sync(flags: .barrier) {
guard localBridgeGeohash != cell else { return false }
localBridgeGeohash = cell
return true
}
guard changed else { return }
sendAnnounce(forceSend: true)
}
func getPeerNicknames() -> [PeerID: String] {
return collectionsQueue.sync {
peerRegistry.displayNicknames(selfNickname: myNickname)
}
}
// MARK: Protocol utilities
func getFingerprint(for peerID: PeerID) -> String? {
return collectionsQueue.sync {
peerRegistry.fingerprint(for: peerID)
}
}
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState {
if noiseService.hasEstablishedSession(with: peerID) {
return .established
} else if noiseService.hasSession(with: peerID) {
return .handshaking
} else {
return .none
}
}
func triggerHandshake(with peerID: PeerID) {
// Callers are on the main actor; the handshake broadcast sync-waits
// on bleQueue for link state, so hop off main first.
messageQueue.async { [weak self] in
self?.initiateNoiseHandshake(with: peerID)
}
}
// MARK: Noise identity/session access (narrow Transport wrappers)
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? {
noiseService.getPeerPublicKeyData(peerID)
}
func noiseIdentityFingerprint() -> String {
noiseService.getIdentityFingerprint()
}
func noiseStaticPublicKeyData() -> Data {
noiseService.getStaticPublicKeyData()
}
func noiseSigningPublicKeyData() -> Data {
noiseService.getSigningPublicKeyData()
}
func noiseSignData(_ data: Data) -> Data? {
noiseService.signData(data)
}
func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool {
noiseService.verifySignature(signature, for: data, publicKey: publicKey)
}
func installNoiseSessionCallbacks(
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
onHandshakeRequired: @escaping (PeerID) -> Void
) {
// `onPeerAuthenticated` is additive (the encryption service keeps an
// array of handlers); `onHandshakeRequired` is a single slot.
noiseService.onPeerAuthenticated = onPeerAuthenticated
noiseService.onHandshakeRequired = onHandshakeRequired
}
func getCurrentBluetoothState() -> CBManagerState {
return centralManager?.state ?? .unknown
}
// MARK: Messaging
func cancelTransfer(_ transferId: String) {
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
switch self.outboundFragmentTransfers.cancelTransfer(transferId) {
case let .active(id, workItems):
workItems.forEach { $0.cancel() }
TransferProgressManager.shared.cancel(id: transferId)
SecureLogger.debug("🛑 Cancelled transfer \(id.prefix(8))…", category: .session)
self.messageQueue.async { [weak self] in
self?.startNextPendingTransferIfNeeded()
}
case let .pending(id):
TransferProgressManager.shared.cancel(id: transferId)
SecureLogger.debug("🛑 Removed pending transfer \(id.prefix(8))… before start", category: .session)
case .missing:
break
}
}
}
// Transport protocol conformance helper: simplified public message send
func sendMessage(_ content: String, mentions: [String]) {
// Delegate to the full API with default routing
sendMessage(content, mentions: mentions, to: nil, messageID: nil, timestamp: nil)
}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
sendMessage(content, mentions: mentions, to: nil, messageID: messageID, timestamp: timestamp)
}
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
sendPrivateMessage(content, to: peerID, messageID: messageID)
}
func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) {
messageQueue.async { [weak self] in
guard let self = self else { return }
guard let payload = filePacket.encode() else {
SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session)
return
}
var packet = BitchatPacket(
type: MessageType.fileTransfer.rawValue,
senderID: self.myPeerIDData,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: self.messageTTL,
version: 2
)
if let signed = self.noiseService.signPacket(packet) {
packet = signed
} else {
SecureLogger.error("❌ Failed to sign file broadcast packet", category: .security)
return
}
let senderHex = packet.senderID.hexEncodedString()
let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)"
self.messageDeduplicator.markProcessed(dedupID)
SecureLogger.debug("📁 Broadcasting file transfer payload bytes=\(payload.count)", category: .session)
self.broadcastPacket(packet, transferId: transferId)
self.gossipSyncManager?.onPublicPacketSeen(packet)
}
}
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
messageQueue.async { [weak self] in
guard let self = self else { return }
guard let payload = filePacket.encode() else {
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
return
}
// Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility
// This ensures 64-hex Noise keys are converted to the canonical routing format
let targetID = peerID.toShort()
guard let recipientData = Data(hexString: targetID.id) else {
SecureLogger.error("❌ Invalid recipient peer ID for file transfer: \(peerID.id.prefix(8))…", category: .session)
return
}
var packet = BitchatPacket(
type: MessageType.fileTransfer.rawValue,
senderID: self.myPeerIDData,
recipientID: recipientData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: self.messageTTL,
version: 2
)
if let signed = self.noiseService.signPacket(packet) {
packet = signed
}
SecureLogger.debug("📁 Sending private file transfer to \(peerID.id.prefix(8))… bytes=\(payload.count)", category: .session)
self.broadcastPacket(packet, transferId: transferId)
}
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
// Hop like sendMessage: callers are often on the main actor, and the
// send path sync-waits on bleQueue for link state — the main thread
// must never block on bleQueue (see captureBluetoothStatus).
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
messageQueue.async { [weak self] in
self?.sendReadReceipt(receipt, to: peerID)
}
return
}
let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID)
if noiseService.hasEstablishedSession(with: peerID) {
SecureLogger.debug("📤 Sending READ receipt id=\(receipt.originalMessageID.prefix(8))… to \(peerID.id.prefix(8))…", category: .session)
do {
broadcastPacket(try makeEncryptedNoisePacket(payload, to: peerID))
} catch {
SecureLogger.error("Failed to send read receipt: \(error)")
}
} else {
// Queue for after handshake; initiate only while the peer is
// around to answer (see sendDeliveryAck — absent senders must
// not turn queued acks into handshake floods).
collectionsQueue.sync(flags: .barrier) {
pendingNoiseSessionQueues.appendTypedPayload(payload, for: peerID)
}
if !noiseService.hasSession(with: peerID), isPeerReachable(peerID) {
initiateNoiseHandshake(with: peerID)
}
SecureLogger.debug("🕒 Queued READ receipt for \(peerID.id.prefix(8))… until handshake completes", category: .session)
}
}
private func acceptedIngressContext(
for packet: BitchatPacket,
claimedSenderID: PeerID,
boundPeerID: PeerID?,
linkDescription: String
) -> BLEIngressPacketContext? {
switch BLEIngressPacketGuard.evaluate(
packet: packet,
claimedSenderID: claimedSenderID,
boundPeerID: boundPeerID,
localPeerID: myPeerID,
directAnnounceTTL: messageTTL,
isValidSyncResponse: { [requestSyncManager] peerID in
requestSyncManager.isValidResponse(from: peerID, isRSR: true)
}
) {
case .success(let context):
if packet.isRSR {
logValidRSR(from: context.validationPeerID)
}
return context
case .failure(.selfLoopback):
logSelfLoopback(packetType: packet.type, linkDescription: linkDescription)
return nil
case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)):
SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! \(linkDescription) claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(boundPeerID.id.prefix(8))…", category: .security)
return nil
case .failure(.invalidRSR(let peerID)):
SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security)
return nil
case .failure(.timestampSkew(let peerID, let skewMs, let maxSkewMs)):
SecureLogger.warning("Packet timestamp skewed by \(skewMs)ms (max \(maxSkewMs)ms) from \(peerID.id.prefix(8))…", category: .security)
return nil
}
}
private func isAcceptedIngressPayload(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
switch BLEIngressPacketGuard.validatePayload(
packet,
from: peerID,
isValidSyncResponse: { [requestSyncManager] peerID in
requestSyncManager.isValidResponse(from: peerID, isRSR: true)
}
) {
case .success:
if packet.isRSR {
logValidRSR(from: peerID)
}
return true
case .failure(.invalidRSR(let peerID)):
SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security)
return false
case .failure(.timestampSkew(let peerID, let skewMs, let maxSkewMs)):
SecureLogger.warning("Packet timestamp skewed by \(skewMs)ms (max \(maxSkewMs)ms) from \(peerID.id.prefix(8))…", category: .security)
return false
case .failure(.selfLoopback), .failure(.directSenderMismatch):
return false
}
}
private func logValidRSR(from peerID: PeerID) {
guard logRateLimiter.shouldLog(key: "valid-rsr:\(peerID.id)") else { return }
SecureLogger.debug("Valid RSR packet from \(peerID.id.prefix(8))… - skipping timestamp check", category: .security)
}
private func logSelfLoopback(packetType: UInt8, linkDescription: String) {
guard logRateLimiter.shouldLog(
key: "self-loopback:\(packetType)",
minimumInterval: 30
) else { return }
SecureLogger.debug("↩️ Dropping BLE self-loopback packet type \(packetType) from \(linkDescription)", category: .session)
}
private func recordIngressIfNew(_ packet: BitchatPacket, link: BLEIngressLinkID, peerID: PeerID) -> Bool {
return collectionsQueue.sync(flags: .barrier) {
ingressLinks.recordIfNew(
packet,
link: link,
peerID: peerID,
lifetime: TransportConfig.bleIngressRecordLifetimeSeconds
)
}
}
// MARK: - Packet Broadcasting
private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) {
// Apply route if recipient exists (centralized route application)
let packetToSend: BitchatPacket
if let recipientPeerID = PeerID(hexData: packet.recipientID) {
packetToSend = applyRouteIfAvailable(packet, to: recipientPeerID)
} else {
packetToSend = packet
}
#if DEBUG
_test_onOutboundPacket?(packetToSend)
#endif
// Encode once using a small per-type padding policy, then delegate by type
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
if packetToSend.type == MessageType.fileTransfer.rawValue {
sendFragmentedPacket(packetToSend, pad: padForBLE, maxChunk: nil, directedOnlyPeer: nil, transferId: transferId)
return
}
guard let data = packetToSend.toBinaryData(padding: padForBLE) else {
SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
return
}
if packetToSend.type == MessageType.noiseEncrypted.rawValue {
sendEncrypted(packetToSend, data: data, pad: padForBLE)
return
}
sendGenericBroadcast(packetToSend, data: data, pad: padForBLE)
}
private func sendEncrypted(_ packet: BitchatPacket, data: Data, pad: Bool) {
guard let recipientPeerID = PeerID(hexData: packet.recipientID) else { return }
var sentEncrypted = false
let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data)
// Per-link limits for the specific peer
let directPeripheralState = snapshotDirectPeripheralState(for: recipientPeerID)
let recipientCentral = snapshotSubscribedCentrals().central(for: recipientPeerID)
if let peripheralMaxLen = directPeripheralState?.peripheral.maximumWriteValueLength(for: .withoutResponse),
data.count > peripheralMaxLen {
let chunk = BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: peripheralMaxLen)
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID)
return
}
if let centralMaxLen = recipientCentral?.maximumUpdateValueLength,
data.count > centralMaxLen {
let chunk = BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: centralMaxLen)
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID)
return
}
// Direct write via peripheral link
if let state = directPeripheralState,
state.isConnected,
let characteristic = state.characteristic {
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic, priority: outboundPriority)
sentEncrypted = true
}
// Notify via central link (dual-role)
if let characteristic = characteristic, !sentEncrypted, let recipientCentral {
let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [recipientCentral]) ?? false
if success {
sentEncrypted = true
} else {
enqueuePendingNotification(data: data, centrals: [recipientCentral], context: "encrypted")
}
}
if !sentEncrypted {
// Flood as last resort with recipient set; link aware
sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: recipientPeerID)
}
}
private func sendGenericBroadcast(_ packet: BitchatPacket, data: Data, pad: Bool) {
sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: nil)
}
private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) {
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
let result = self.pendingNotifications.enqueue(
data: data,
targets: centrals,
capCount: TransportConfig.blePendingNotificationsCapCount
)
if case let .enqueued(count) = result {
self.logBackpressureSampled("📋 Queued \(context) packet for retry (pending=\(count))")
return
}
if attempt >= TransportConfig.bleNotificationRetryMaxAttempts {
SecureLogger.error("❌ Dropping \(context) packet after exhausting retry window (pending=\(self.pendingNotifications.count))", category: .session)
return
}
let backoff = TransportConfig.bleNotificationRetryDelayMs * max(1, attempt + 1)
let deadline = DispatchTime.now() + .milliseconds(backoff)
self.messageQueue.asyncAfter(deadline: deadline) { [weak self] in
self?.enqueuePendingNotification(data: data, centrals: centrals, context: context, attempt: attempt + 1)
}
}
}
/// Synchronously admits a notification to the link-specific retry queue.
/// Destructive courier handoff uses this result as its commit point, so a
/// full process-local queue must be reported as rejection, not success.
private func enqueuePendingNotificationIfAccepted(
data: Data,
centrals: [CBCentral],
context: String
) -> Bool {
let result = collectionsQueue.sync(flags: .barrier) {
pendingNotifications.enqueue(
data: data,
targets: centrals,
capCount: TransportConfig.blePendingNotificationsCapCount
)
}
switch result {
case let .enqueued(count):
SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(count))", category: .session)
return true
case let .full(count):
SecureLogger.warning("⚠️ Rejecting \(context) packet: notification queue full (pending=\(count))", category: .session)
return false
}
}
/// Serializes the final authenticated-link check with CoreBluetooth's
/// notification admission on `bleQueue`, closing the rebind/disconnect
/// race between fanout planning and the actual handoff.
private func notifyOrEnqueueIfAccepted(
data: Data,
centrals: [CBCentral],
characteristic: CBMutableCharacteristic,
context: String,
requiredAuthenticatedPeer: PeerID?
) -> Bool {
let accept = { [self] in
let eligible: [CBCentral]
if let peerID = requiredAuthenticatedPeer {
eligible = centrals.filter { central in
let link = BLEIngressLinkID.central(central.identifier.uuidString)
return noiseAuthenticatedLinkOwners[link] == peerID
&& linkStateStore.peerID(forCentralUUID: central.identifier.uuidString) == peerID
}
} else {
eligible = centrals
}
guard !eligible.isEmpty else { return false }
if peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: eligible) == true {
return true
}
return enqueuePendingNotificationIfAccepted(
data: data,
centrals: eligible,
context: context
)
}
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
return accept()
}
return bleQueue.sync(execute: accept)
}
/// Returns true only when the packet was accepted by at least one current
/// physical link (including its link-specific backpressure queue). A
/// process-local directed spool is deliberately not success: callers
/// that own a durable upstream copy must keep it retryable.
@discardableResult
private func sendOnAllLinks(
packet: BitchatPacket,
data: Data,
pad: Bool,
directedOnlyPeer: PeerID?,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
) -> Bool {
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
var excludedPeerLinks = links(to: ingressRecord?.peerID)
if requireNoiseAuthenticatedPeerLink {
guard let directedOnlyPeer else { return false }
let boundLinks = links(to: directedOnlyPeer)
let authenticatedLinks = currentNoiseAuthenticatedLinks(to: directedOnlyPeer)
guard !authenticatedLinks.isEmpty else { return false }
excludedPeerLinks.formUnion(boundLinks.subtracting(authenticatedLinks))
}
let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data)
let states = snapshotPeripheralStates()
// A link without a discovered characteristic cannot be written to
// (the write loop below skips it); offering it to the planner only
// wastes fanout slots — and a peer's single collapsed copy would be
// silently dropped if its bound link is still mid-rediscovery.
let connectedStates = states.filter { $0.isConnected && $0.characteristic != nil }
let centralSnapshot = snapshotSubscribedCentrals()
let subscribedCentrals = characteristic == nil ? [] : centralSnapshot.centrals
let connectedPeripheralIDs = connectedStates.map { $0.peripheral.identifier.uuidString }
let centralIDs = subscribedCentrals.map { $0.identifier.uuidString }
let peripheralPeerBindings = Dictionary(uniqueKeysWithValues: connectedStates.compactMap { state in
state.peerID.map { (state.peripheral.identifier.uuidString, $0) }
})
let plan = BLEOutboundLinkPlanner.plan(
packet: packet,
dataCount: data.count,
peripheralIDs: connectedPeripheralIDs,
peripheralWriteLimits: connectedStates.map { $0.peripheral.maximumWriteValueLength(for: .withoutResponse) },
centralIDs: centralIDs,
centralNotifyLimits: subscribedCentrals.map { $0.maximumUpdateValueLength },
ingressRecord: ingressRecord,
excludedLinks: excludedPeerLinks,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralSnapshot.peerIDsByCentralUUID,
// Perf note: this is a third bleQueue hop per send; if send-path
// profiling ever flags it, fold it into snapshotPeripheralStates
// as a combined snapshot.
preferredPeripheralPerPeer: readLinkState { $0.preferredPeripheralBindings },
directAnnounceTTL: messageTTL,
directedOnlyPeer: directedOnlyPeer,
requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink
)
if let chunk = plan.fragmentChunkSize {
guard !plan.selectedLinks.peripheralIDs.isEmpty || !plan.selectedLinks.centralIDs.isEmpty else {
return false
}
return sendFragmentedPacket(
packet,
pad: pad,
maxChunk: chunk,
directedOnlyPeer: directedOnlyPeer,
requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink,
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
)
}
// If directed and we currently have no links to forward on, spool for a short window
if let only = plan.directedPeerHint,
plan.shouldSpoolDirectedPacket {
spoolDirectedPacket(packet, recipientPeerID: only)
}
var acceptedByPhysicalLink = false
// Writes to selected connected peripherals
for s in connectedStates {
let pid = s.peripheral.identifier.uuidString
guard plan.selectedLinks.peripheralIDs.contains(pid) else { continue }
if let ch = s.characteristic {
if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink {
acceptedByPhysicalLink = writeOrEnqueueIfAccepted(
data,
to: s.peripheral,
characteristic: ch,
priority: outboundPriority,
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil
) || acceptedByPhysicalLink
} else {
writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority)
}
}
}
// Notify selected subscribed centrals
if let ch = characteristic {
let targets = subscribedCentrals.filter { plan.selectedLinks.centralIDs.contains($0.identifier.uuidString) }
if !targets.isEmpty {
if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink {
acceptedByPhysicalLink = notifyOrEnqueueIfAccepted(
data: data,
centrals: targets,
characteristic: ch,
context: "directed",
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil
) || acceptedByPhysicalLink
} else {
let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false
if !success {
// Notification queue full - queue for retry to prevent silent packet loss
// This is critical for fragment delivery reliability
let context = packet.type == MessageType.fragment.rawValue ? "fragment" : "broadcast"
enqueuePendingNotification(data: data, centrals: targets, context: context)
}
}
}
}
if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink { return acceptedByPhysicalLink }
return !plan.selectedLinks.peripheralIDs.isEmpty || !plan.selectedLinks.centralIDs.isEmpty
}
// Directed send helper (unicast to a specific peerID) without altering packet contents
@discardableResult
private func sendPacketDirected(
_ packet: BitchatPacket,
to peerID: PeerID,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
) -> Bool {
#if DEBUG
_test_onOutboundPacket?(packet)
#endif
guard let data = packet.toBinaryData(padding: false) else { return false }
return sendOnAllLinks(
packet: packet,
data: data,
pad: false,
directedOnlyPeer: peerID,
requireDirectPeerLink: requireDirectPeerLink,
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
)
}
// MARK: - Directed store-and-forward
private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: PeerID) {
let msgID = BLEOutboundPacketPolicy.messageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
if self.pendingDirectedRelays.enqueue(
packet: packet,
recipient: recipientPeerID,
messageID: msgID,
enqueuedAt: Date()
) {
SecureLogger.debug("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))…", category: .session)
}
}
}
private func flushDirectedSpool() {
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
let toSend = collectionsQueue.sync(flags: .barrier) {
pendingDirectedRelays.drainUnexpired(
now: Date(),
window: TransportConfig.bleDirectedSpoolWindowSeconds
)
}
guard !toSend.isEmpty else { return }
for entry in toSend {
messageQueue.async { [weak self] in self?.broadcastPacket(entry.packet) }
}
}
private func signedSenderDisplayName(for packet: BitchatPacket, from peerID: PeerID) -> String? {
guard let signature = packet.signature,
let packetData = packet.toBinaryDataForSigning() else {
return nil
}
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
for candidate in candidates {
guard let signingKey = candidate.signingPublicKey,
noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) else {
continue
}
if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) {
return social.localPetname ?? social.claimedNickname
}
return BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
}
return nil
}
// MARK: - Archived public messages ("heard here earlier")
func purgeArchivedPublicMessages(from peerID: PeerID) {
gossipSyncManager?.removePublicMessages(from: peerID)
}
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
guard let sync = gossipSyncManager else {
Task { @MainActor in completion([]) }
return
}
sync.collectPublicMessagePackets { [weak self] packets in
guard let self = self else {
Task { @MainActor in completion([]) }
return
}
// Signature verification and registry lookups run on messageQueue
// like the live receive path.
self.messageQueue.async {
let decoded = packets
.compactMap { self.decodeArchivedPublicMessage($0) }
.sorted { $0.timestamp < $1.timestamp }
Task { @MainActor in completion(decoded) }
}
}
}
private func decodeArchivedPublicMessage(_ packet: BitchatPacket) -> ArchivedPublicMessage? {
guard packet.type == MessageType.message.rawValue,
let content = String(data: packet.payload, encoding: .utf8)?.trimmedOrNilIfEmpty
else { return nil }
let senderPeerID = PeerID(hexData: packet.senderID)
let peers = collectionsQueue.sync { peerRegistry.snapshotByID }
// Archived senders are usually long gone, so the signature-derived
// identity is the best shot at a name; a live registry entry is
// next; anonymous fallback matches the live path.
let nickname = signedSenderDisplayName(for: packet, from: senderPeerID)
?? BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: senderPeerID,
localPeerID: myPeerID,
localNickname: myNickname,
peers: peers,
allowConnectedUnverified: false
)
?? BLEPeerSenderDisplayName.anonymousNickname(for: senderPeerID)
return ArchivedPublicMessage(
packetIdHex: PacketIdUtil.computeId(packet).hexEncodedString(),
senderPeerID: senderPeerID,
senderNickname: nickname,
content: content,
timestamp: Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
)
}
private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
fileTransferHandler.handle(packet, from: peerID)
}
/// Builds the file-transfer handler environment. All queue hops stay here
/// so `BLEFileTransferHandler` remains queue-agnostic and synchronously
/// testable.
private func makeFileTransferHandlerEnvironment() -> BLEFileTransferHandlerEnvironment {
BLEFileTransferHandlerEnvironment(
localPeerID: { [weak self] in
self?.myPeerID ?? PeerID(str: "")
},
localNickname: { [weak self] in
self?.myNickname ?? ""
},
peersSnapshot: { [weak self] in
guard let self = self else { return [:] }
return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
},
verifyPacketSignature: { [weak self] packet, signingPublicKey in
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
},
signedSenderDisplayName: { [weak self] packet, peerID in
self?.signedSenderDisplayName(for: packet, from: peerID)
},
trackPacketSeen: { [weak self] packet in
self?.gossipSyncManager?.onPublicPacketSeen(packet)
},
enforceStorageQuota: { [weak self] reservingBytes in
self?.incomingFileStore.enforceQuota(reservingBytes: reservingBytes)
},
saveIncomingFile: { [weak self] data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
self?.incomingFileStore.save(
data: data,
preferredName: preferredName,
subdirectory: subdirectory,
fallbackExtension: fallbackExtension,
defaultPrefix: defaultPrefix
)
},
updatePeerLastSeen: { [weak self] peerID in
self?.updatePeerLastSeen(peerID)
},
deliverMessage: { [weak self] message in
// Single main-actor hop delivering `.messageReceived`.
self?.emitTransportEvent(.messageReceived(message))
}
)
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session)
// Include Nostr public key in the notification
var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]"
var includesNostrIdentity = false
// Add our Nostr public key if available
if let myNostrIdentity = try? idBridge.getCurrentNostrIdentity() {
content += ":" + myNostrIdentity.npub
includesNostrIdentity = true
SecureLogger.debug("📝 Favorite notification includes Nostr npub=\(myNostrIdentity.npub.prefix(16))…", category: .session)
}
SecureLogger.debug("📤 Sending favorite notification to \(peerID.id.prefix(8))… isFavorite=\(isFavorite) includesNostrIdentity=\(includesNostrIdentity)", category: .session)
sendPrivateMessage(content, to: peerID, messageID: UUID().uuidString)
}
func sendBroadcastAnnounce() {
sendAnnounce()
}
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
// Hop like sendMessage: callers are often on the main actor, and the
// send path sync-waits on bleQueue for link state — the main thread
// must never block on bleQueue (see captureBluetoothStatus).
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
messageQueue.async { [weak self] in
self?.sendDeliveryAck(for: messageID, to: peerID)
}
return
}
let payload = BLENoisePayloadFactory.delivered(messageID: messageID)
if noiseService.hasEstablishedSession(with: peerID) {
do {
broadcastPacket(try makeEncryptedNoisePacket(payload, to: peerID))
} catch {
SecureLogger.error("Failed to send delivery ACK: \(error)")
}
} else {
// Queue for after handshake; initiate only while the peer is
// around to answer — couriered/bridged mail routinely arrives
// from absent (or rotated) identities, and every duplicate copy
// initiating a handshake broadcast turns one undeliverable ack
// into a mesh-wide flood. The queued ack flushes whenever a
// session eventually establishes.
collectionsQueue.sync(flags: .barrier) {
pendingNoiseSessionQueues.appendTypedPayload(payload, for: peerID)
}
if !noiseService.hasSession(with: peerID), isPeerReachable(peerID) {
initiateNoiseHandshake(with: peerID)
}
SecureLogger.debug("🕒 Queued DELIVERED ack for \(peerID.id.prefix(8))… until handshake completes", category: .session)
}
}
private func handleLeave(_: BitchatPacket, from peerID: PeerID) {
_ = collectionsQueue.sync(flags: .barrier) {
// Remove the peer when they leave
peerRegistry.remove(peerID)
}
// Remove any stored announcement for sync purposes
gossipSyncManager?.removeAnnouncementForPeer(peerID)
// Send on main thread
notifyUI { [weak self] in
guard let self = self else { return }
// Get current peer list (after removal)
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
self.deliverTransportEvent(.peerDisconnected(peerID))
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
}
}
private func sendAnnounce(forceSend: Bool = false) {
// Throttle announces to prevent flooding
if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
return
}
// Reduced logging - only log errors, not every announce
// Create announce payload with both noise and signing public keys
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
let (connectedPeerIDs, advertisedCapabilities, advertisedBridgeCell): ([Data], PeerCapabilities, String?) = collectionsQueue.sync {
(
peerRegistry.connectedRoutingData,
PeerCapabilities.localSupported.union(runtimeCapabilities),
runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil
)
}
let announcement = AnnouncementPacket(
nickname: myNickname,
noisePublicKey: noisePub,
signingPublicKey: signingPub,
directNeighbors: connectedPeerIDs,
capabilities: advertisedCapabilities,
bridgeGeohash: advertisedBridgeCell
)
guard let payload = announcement.encode() else {
SecureLogger.error("❌ Failed to encode announce packet", category: .session)
return
}
// Create packet with signature using the noise private key
let packet = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: myPeerIDData,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil, // Will be set by signPacket below
ttl: messageTTL
)
// Sign the packet using the noise private key
guard let signedPacket = noiseService.signPacket(packet) else {
SecureLogger.error("❌ Failed to sign announce packet", category: .security)
return
}
// Call directly if on messageQueue, otherwise dispatch
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(signedPacket)
} else {
messageQueue.async { [weak self] in
self?.broadcastPacket(signedPacket)
}
}
// Ensure our own announce is included in sync state
gossipSyncManager?.onPublicPacketSeen(signedPacket)
// Keep our prekey bundle riding alongside presence (throttled; the
// send is a no-op when the bundle was refreshed recently).
sendPrekeyBundle()
}
// MARK: QR Verification over Noise
// MARK: Private Groups
/// Sends creator-signed group state (invite) 1:1 over the Noise session,
/// queueing behind a handshake when none is established yet.
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {
sendNoisePayload(NoisePayload(type: .groupInvite, data: statePayload).encode(), to: peerID)
}
/// Sends creator-signed group state (key rotation / roster update) 1:1
/// over the Noise session.
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {
sendNoisePayload(NoisePayload(type: .groupKeyUpdate, data: statePayload).encode(), to: peerID)
}
/// Broadcasts a sealed group message (MessageType 0x25) like a public
/// message: fire-and-flood with gossip-sync backfill. The outer packet is
/// intentionally unsigned — receivers authenticate the sender's Ed25519
/// signature inside the ciphertext, which still verifies for backfilled
/// copies long after the sender's announce has expired.
func broadcastGroupMessage(_ envelope: Data) {
guard !envelope.isEmpty else { return }
messageQueue.async { [weak self] in
guard let self else { return }
let packet = BitchatPacket(
type: MessageType.groupMessage.rawValue,
senderID: Data(hexString: self.myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: envelope,
signature: nil,
ttl: self.messageTTL
)
// Pre-mark our own broadcast as processed to avoid handling a
// relayed self copy.
let dedupID = BLESelfBroadcastTracker.dedupID(for: packet)
self.messageDeduplicator.markProcessed(dedupID)
self.broadcastPacket(packet)
// Track our own broadcast for gossip sync
self.gossipSyncManager?.onPublicPacketSeen(packet)
}
}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA)
sendNoisePayload(payload, to: peerID)
}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return }
sendNoisePayload(payload, to: peerID)
}
// MARK: Vouching over Noise
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
sendNoisePayload(NoisePayload(type: .vouch, data: payload).encode(), to: peerID)
}
// MARK: Live Voice (PTT)
/// Sends one live voice-burst packet inside the Noise session. Unlike
/// `sendNoisePayload` this never queues behind a handshake: live audio is
/// only useful now, so without an established session frames are dropped.
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {
messageQueue.async { [weak self] in
guard let self else { return }
guard self.noiseService.hasEstablishedSession(with: peerID) else {
SecureLogger.debug("PTT: dropping voice frame — no established session with \(peerID.id.prefix(8))…", category: .session)
return
}
do {
let typedPayload = NoisePayload(type: .voiceFrame, data: burstContent).encode()
self.broadcastPacket(try self.makeEncryptedNoisePacket(typedPayload, to: peerID))
} catch {
SecureLogger.error("Failed to send voice frame: \(error)", category: .session)
}
}
}
/// Broadcasts one live voice-burst packet to the public mesh, signed like
/// a public message so receivers can authenticate the talker. Ephemeral:
/// never tracked for gossip sync (stale audio is worthless to replay).
func sendVoiceFrameBroadcast(_ burstContent: Data) {
guard !burstContent.isEmpty else { return }
messageQueue.async { [weak self] in
guard let self else { return }
let packet = BitchatPacket(
type: MessageType.voiceFrame.rawValue,
senderID: self.myPeerIDData,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: burstContent,
signature: nil,
ttl: self.messageTTL
)
guard let signedPacket = self.noiseService.signPacket(packet) else {
SecureLogger.error("❌ Failed to sign voice frame", category: .security)
return
}
// Pre-mark our own broadcast as processed to avoid handling a
// relayed self copy.
let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
self.messageDeduplicator.markProcessed(dedupID)
self.broadcastPacket(signedPacket)
}
}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
// Appends to the encryption service's handler array, so this never
// displaces the callbacks installed by installNoiseSessionCallbacks.
noiseService.addOnPeerAuthenticatedHandler(handler)
}
}
// MARK: - GossipSyncManager Delegate
extension BLEService: GossipSyncManager.Delegate {
func sendPacket(_ packet: BitchatPacket) {
broadcastPacket(packet)
}
func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
sendPacketDirected(packet, to: peerID)
}
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
return noiseService.signPacket(packet) ?? packet
}
func getConnectedPeers() -> [PeerID] {
return collectionsQueue.sync {
peerRegistry.connectedPeerIDs
}
}
}
// MARK: - CBCentralManagerDelegate
extension BLEService: CBCentralManagerDelegate {
#if os(iOS)
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
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 peerID = existing?.peerID
let wasConnecting = existing?.isConnecting ?? false
let wasConnected = existing?.isConnected ?? false
let restoredState = BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: characteristic,
peerID: peerID,
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).
recentPeripheralCache.record(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 {
startScanning()
}
}
#endif
func centralManagerDidUpdateState(_ central: CBCentralManager) {
emitTransportEvent(.bluetoothStateUpdated(central.state))
switch central.state {
case .poweredOn:
// 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
startScanning()
case .poweredOff:
// Bluetooth was turned off - stop scanning and clean up connection state
SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
central.stopScan()
// Mark all peripheral connections as disconnected (they are now invalid)
let peripheralStates = linkStateStore.peripheralStates
let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID)
for state in peripheralStates {
central.cancelPeripheralConnection(state.peripheral)
}
_ = linkStateStore.clearPeripherals()
// Notify UI of disconnections
for peerID in peerIDs {
notifyUI { [weak self] in
self?.notifyPeerDisconnectedDebounced(peerID)
}
}
case .unauthorized:
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
central.stopScan()
_ = linkStateStore.clearPeripherals()
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)
}
}
private func startScanning() {
guard let central = centralManager,
central.state == .poweredOn,
!central.isScanning else { return }
// Use allow duplicates = true for faster discovery in foreground
// This gives us discovery events immediately instead of coalesced
#if os(iOS)
let allowDuplicates = isAppActive // Use our tracked state (thread-safe)
#else
let allowDuplicates = true // macOS doesn't have background restrictions
#endif
central.scanForPeripherals(
withServices: [BLEService.serviceUUID],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates]
)
// Started BLE scanning
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
let peripheralID = peripheral.identifier.uuidString
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…")
let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true
let rssiValue = RSSI.intValue
let candidate = BLEConnectionCandidate(
peripheral: peripheral,
peripheralID: peripheralID,
rssi: rssiValue,
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 connectionScheduler.handleDiscovery(
candidate,
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
existingState: existingState,
peripheralState: peripheral.state.connectionSchedulerState,
now: candidate.discoveredAt
) {
case .ignore, .queued:
return
case .scheduleRetry(let delay):
bleQueue.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 centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
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
connectionScheduler.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
// Find the peer ID if we have it
let peerID = linkStateStore.peerID(forPeripheralID: peripheralID)
SecureLogger.debug("📱 Disconnect: \(peerID?.id ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session)
// If disconnect carried an error (often timeout), apply short backoff to avoid thrash
if error != nil {
connectionScheduler.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.
recentPeripheralCache.record(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.armPendingBackgroundConnects(slotReserve: 0)
}
}
#endif
// Clean up references and peer mappings
collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
_ = linkStateStore.removePeripheral(peripheralID)
// A duplicate link can drop while the peer stays live on another
// (the dual-role central link, or a second bound link after a
// restore): peer-disconnect bookkeeping only runs once the peer's
// last live link is gone. removePeripheral just repaired the reverse
// map onto a connected survivor, so directLinkState is accurate
// here. The scan restart and connect-slot refill below stay
// unguarded — they respond to the physical drop regardless of
// remaining logical links.
let remainingLinks = peerID.map { linkStateStore.directLinkState(for: $0) }
let peerStillLinked = (remainingLinks?.hasPeripheral ?? false) || (remainingLinks?.hasCentral ?? false)
if let peerID, !peerStillLinked {
// Do not remove peer; mark as not connected but retain for reachability
collectionsQueue.sync(flags: .barrier) {
peerRegistry.markDisconnected(peerID)
}
refreshLocalTopology()
}
// 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?.startScanning()
}
}
// Attempt to fill freed slot from queue
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
// Notify delegate about disconnection on main thread (direct link dropped)
notifyUI { [weak self] in
guard let self = self else { return }
// Get current peer list (after removal)
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
if let peerID, !peerStillLinked {
self.notifyPeerDisconnectedDebounced(peerID)
}
self.requestPeerDataPublish()
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
}
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
let peripheralID = peripheral.identifier.uuidString
// Clean up the references
collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
_ = linkStateStore.removePeripheral(peripheralID)
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
connectionScheduler.recordConnectionFailure(peripheralID: peripheralID)
// Try next candidate
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
}
}
// 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
}
}
}
extension BLEService {
private func tryConnectFromQueue() {
guard let central = centralManager, central.state == .poweredOn else { return }
let decision = connectionScheduler.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):
bleQueue.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
) {
let peripheral = candidate.peripheral
let peripheralID = candidate.peripheralID
linkStateStore.beginConnecting(to: peripheral, at: Date())
peripheral.delegate = self
let options: [String: Any] = [
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
CBConnectPeripheralOptionNotifyOnNotificationKey: true
]
central.connect(peripheral, options: options)
connectionScheduler.recordConnectionAttempt(at: Date())
SecureLogger.debug("\(logPrefix): \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session)
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in
guard let self = 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 os(iOS)
if !self.isAppActive {
// 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
}
#endif
SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session)
central.cancelPeripheralConnection(peripheral)
self.collectionsQueue.sync(flags: .barrier) {
self.pendingPeripheralWrites.discardAll(for: peripheralID)
}
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
_ = self.linkStateStore.removePeripheral(peripheralID)
self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
self.tryConnectFromQueue()
}
}
}
private extension BLEService {
static func shouldRediscoverBitChatService(
invalidatedServiceUUIDs: [CBUUID],
cachedServiceUUIDs: [CBUUID]?
) -> Bool {
invalidatedServiceUUIDs.contains(serviceUUID) || cachedServiceUUIDs?.contains(serviceUUID) != true
}
}
#if DEBUG
// Test-only helper to inject packets into the receive pipeline
extension BLEService {
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
if preseedPeer {
// Ensure the synthetic peer is known and marked verified for public-message tests
let normalizedID = PeerID(hexData: packet.senderID)
collectionsQueue.sync(flags: .barrier) {
if var existing = peerRegistry.info(for: normalizedID) {
existing.isConnected = true
existing.isVerifiedNickname = true
if let signingPublicKey { existing.signingPublicKey = signingPublicKey }
existing.lastSeen = Date()
peerRegistry.upsert(existing)
} else {
peerRegistry.upsert(BLEPeerInfo(
peerID: normalizedID,
nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
isConnected: true,
noisePublicKey: packet.senderID,
signingPublicKey: signingPublicKey,
isVerifiedNickname: true,
lastSeen: Date()
))
}
}
}
handleReceivedPacket(packet, from: fromPeerID)
}
/// Waits until fragment ingress already submitted by a test has finished
/// reassembly/reinjection and any resulting transport event has crossed
/// the MainActor delivery hop. This is a deterministic pipeline fence,
/// avoiding wall-clock sleeps that become flaky under a parallel suite.
func _test_drainFragmentPipeline() async {
await withCheckedContinuation { continuation in
messageQueue.async(flags: .barrier) {
// Reassembled packets are reinjected synchronously on
// `messageQueue`; their UI delivery task is therefore already
// enqueued before this later MainActor marker.
Task { @MainActor in
continuation.resume()
}
}
}
}
func _test_hasGossipPrekeyBundle(for peerID: PeerID) -> Bool {
gossipSyncManager?._hasPrekeyBundle(for: peerID) ?? false
}
func _test_acceptsIngress(packet: BitchatPacket, boundPeerID: PeerID?) -> Bool {
let claimedSenderID = PeerID(hexData: packet.senderID)
guard case .success = BLEIngressLinkRegistry.packetContext(
for: packet,
claimedSenderID: claimedSenderID,
boundPeerID: boundPeerID,
localPeerID: myPeerID,
directAnnounceTTL: messageTTL
) else {
return false
}
return true
}
func _test_recordIngressIfNew(packet: BitchatPacket, linkID: String) -> Bool {
recordIngressIfNew(packet, link: .central(linkID), peerID: PeerID(hexData: packet.senderID))
}
func _test_bindCentral(_ centralUUID: String, to peerID: PeerID) {
bleQueue.sync { linkStateStore.bindCentral(centralUUID, to: peerID) }
}
func _test_centralBinding(_ centralUUID: String) -> PeerID? {
bleQueue.sync { linkStateStore.peerID(forCentralUUID: centralUUID) }
}
func _test_markNoiseAuthenticatedCentral(_ centralUUID: String, to peerID: PeerID) {
bleQueue.sync {
guard linkStateStore.peerID(forCentralUUID: centralUUID) == peerID else { return }
noiseAuthenticatedLinkOwners[.central(centralUUID)] = peerID
}
}
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
collectionsQueue.sync(flags: .barrier) {
peerRegistry.upsert(BLEPeerInfo(
peerID: peerID,
nickname: nickname,
isConnected: true,
noisePublicKey: nil,
signingPublicKey: nil,
isVerifiedNickname: true,
lastSeen: Date()
))
}
}
/// Handshake plumbing for tests that need a real established Noise
/// session (e.g. canDeliverSecurely) without Bluetooth in the loop.
func _test_noiseInitiateHandshake(with peerID: PeerID) throws -> Data {
try noiseService.initiateHandshake(with: peerID)
}
func _test_noiseProcessHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
try noiseService.processHandshakeMessage(from: peerID, message: message)
}
static func _test_shouldRediscoverBitChatService(
invalidatedServiceUUIDs: [CBUUID],
cachedServiceUUIDs: [CBUUID]?
) -> Bool {
shouldRediscoverBitChatService(
invalidatedServiceUUIDs: invalidatedServiceUUIDs,
cachedServiceUUIDs: cachedServiceUUIDs
)
}
}
#endif
// MARK: - CBPeripheralDelegate
extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
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?) {
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)
messageQueue.asyncAfter(deadline: .now() + 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?) {
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,
peerID: 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)
}
// Codex review identified TOCTOU in this patch.
// Enforce per-link sender binding immediately within the same notification batch.
// NOTE: `processNotificationPacket` may bind the stored peer ID when an announce
// is processed, but `state` above is a snapshot. Track a local binding that we update as soon as
// we see a binding-eligible announce so subsequent frames can't spoof a different sender.
var boundPeerID: PeerID? = state.peerID
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
}
let claimedSenderID = PeerID(hexData: packet.senderID)
let context = acceptedIngressContext(
for: packet,
claimedSenderID: claimedSenderID,
boundPeerID: boundPeerID,
linkDescription: "Peripheral \(peripheralUUID.prefix(8))…"
)
guard let context else { continue }
// If this is a direct-link announce, bind immediately for the remainder of this batch.
if boundPeerID == nil,
packet.type == MessageType.announce.rawValue,
packet.ttl == messageTTL {
boundPeerID = claimedSenderID
state.peerID = claimedSenderID
linkStateStore.bindPeripheral(peripheralUUID, to: claimedSenderID)
}
if !recordIngressIfNew(packet, link: .peripheral(peripheralUUID), peerID: context.receivedFromPeerID) {
continue
}
processNotificationPacket(
packet,
from: peripheral,
peripheralUUID: peripheralUUID,
receivedFrom: context.receivedFromPeerID
)
}
}
private func processNotificationPacket(_ packet: BitchatPacket, from _: CBPeripheral, peripheralUUID: String, receivedFrom peerID: PeerID) {
let senderID = PeerID(hexData: packet.senderID)
if packet.type != MessageType.announce.rawValue {
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID.id.prefix(8))…", category: .session)
}
if packet.type == MessageType.announce.rawValue,
packet.ttl == messageTTL {
// Only bind an unbound link here: this runs before signature
// verification, so a bound link must not be re-bound by a raw
// announce (spoofable). Rotation rebinds happen after the announce
// verifies (rebindLinkAfterVerifiedDirectAnnounce).
let boundPeerID = linkStateStore.peerID(forPeripheralID: peripheralUUID)
if boundPeerID == nil || boundPeerID == senderID {
linkStateStore.bindPeripheral(peripheralUUID, to: senderID)
refreshLocalTopology()
}
}
handleReceivedPacket(packet, from: peerID)
}
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) {
// 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]) {
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?) {
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)
}
}
}
}
// MARK: - CBPeripheralManagerDelegate
extension BLEService: CBPeripheralManagerDelegate {
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session)
switch peripheral.state {
case .poweredOn:
// 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)
peripheral.stopAdvertising()
// Clear subscribed centrals (they are now invalid)
let centralPeerIDs = linkStateStore.clearCentrals()
subscriptionAnnounceLimiter.removeAll()
characteristic = nil
// Notify UI of disconnections
for peerID in centralPeerIDs {
notifyUI { [weak self] in
self?.notifyPeerDisconnectedDebounced(peerID)
}
}
case .unauthorized:
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
peripheral.stopAdvertising()
_ = linkStateStore.clearCentrals()
subscriptionAnnounceLimiter.removeAll()
characteristic = nil
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]) {
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(buildAdvertisementData())
}
}
#endif
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
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 = buildAdvertisementData()
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) {
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
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
self?.flushDirectedSpool()
}
return
}
// Send announce to the newly subscribed central after a small delay
messageQueue.asyncAfter(deadline: .now() + 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)
collectionsQueue.sync(flags: .barrier) {
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID))
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
// Ensure we're still advertising for other devices to find us
if peripheral.isAdvertising == false {
SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
peripheral.startAdvertising(buildAdvertisementData())
}
// Find and disconnect the peer associated with this central
if let peerID = removedPeerID {
// The remote side retiring a redundant duplicate connection
// arrives here as an unsubscribe while the peer stays live on
// its other links; only the peer's last link disconnecting
// counts. If every link truly dropped, the surviving-link
// callbacks (didDisconnectPeripheral, or this one again) run
// the bookkeeping.
guard linkStateStore.links(to: peerID).isEmpty else { return }
// Mark peer as not connected; retain for reachability
collectionsQueue.sync(flags: .barrier) {
peerRegistry.markDisconnected(peerID)
}
refreshLocalTopology()
// Update UI immediately
notifyUI { [weak self] in
guard let self = self else { return }
// Get current peer list (after removal)
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
self.notifyPeerDisconnectedDebounced(peerID)
// Publish snapshots so UnifiedPeerService can refresh icons promptly
self.requestPeerDataPublish()
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
}
}
}
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
drainPendingNotifications(logPrefix: "✅ Sent")
}
private func logBackpressureSampled(_ message: @autoclosure () -> String) {
notificationBackpressureLogCount += 1
if notificationBackpressureLogCount == 1 ||
notificationBackpressureLogCount.isMultiple(of: TransportConfig.bleBackpressureLogInterval) {
SecureLogger.debug("\(message()) [backpressure event #\(notificationBackpressureLogCount)]", category: .session)
}
}
private func drainPendingNotifications(logPrefix: String) {
collectionsQueue.async(flags: .barrier) { [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)
}
// 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) {
let claimedSenderID = PeerID(hexData: packet.senderID)
let context = acceptedIngressContext(
for: packet,
claimedSenderID: claimedSenderID,
boundPeerID: linkStateStore.peerID(forCentralUUID: centralUUID),
linkDescription: "Central \(centralUUID.prefix(8))…"
)
guard let context else { return }
if packet.type != MessageType.announce.rawValue {
SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(claimedSenderID.id.prefix(8))…", category: .session)
}
linkStateStore.addSubscribedCentral(central)
if packet.type == MessageType.announce.rawValue,
packet.ttl == messageTTL {
// Same rule as the peripheral path: raw announces only bind
// unbound links; rotation rebinds require a verified announce.
let boundPeerID = linkStateStore.peerID(forCentralUUID: centralUUID)
if boundPeerID == nil || boundPeerID == claimedSenderID {
linkStateStore.bindCentral(centralUUID, to: claimedSenderID)
refreshLocalTopology()
}
}
guard recordIngressIfNew(packet, link: .central(centralUUID), peerID: context.receivedFromPeerID) else {
return
}
handleReceivedPacket(packet, from: context.receivedFromPeerID)
}
}
// MARK: - Advertising Builders & Alias Rotation
extension BLEService {
private func buildAdvertisementData() -> [String: Any] {
let data: [String: Any] = [
CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID]
]
// No Local Name for privacy
return data
}
// No alias rotation or advertising restarts required.
}
// MARK: - Private Helpers
extension BLEService {
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
private func notifyUI(_ block: @escaping @MainActor () -> Void) {
// Always hop onto the MainActor so calls to @MainActor delegates are safe
Task { @MainActor in
block()
}
}
private func emitTransportEvent(_ event: TransportEvent) {
notifyUI { [weak self] in
self?.deliverTransportEvent(event)
}
}
@MainActor
private func deliverTransportEvent(_ event: TransportEvent) {
if let eventDelegate {
eventDelegate.didReceiveTransportEvent(event)
} else {
delegate?.receiveTransportEvent(event)
}
}
private func logBluetoothStatus(_ context: String) {
scheduleBluetoothStatusSample(after: 0, context: context)
}
private func scheduleBluetoothStatusSample(after delay: TimeInterval, context: String) {
#if os(iOS)
// Sample the main-actor background budget first (async hop, never a
// sync wait), then log from bleQueue off the cache — bleQueue must
// never block on main (see captureBluetoothStatus).
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self else { return }
self.refreshCachedBackgroundTimeRemaining()
self.bleQueue.async { self.captureBluetoothStatus(context: context) }
}
#else
bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.captureBluetoothStatus(context: context)
}
#endif
}
#if os(iOS)
/// Main thread only (reads main-actor UIApplication state).
private func refreshCachedBackgroundTimeRemaining() {
dispatchPrecondition(condition: .onQueue(.main))
let seconds = UIApplication.shared.backgroundTimeRemaining
backgroundTimeLock.lock()
_cachedBackgroundTimeRemaining = seconds
backgroundTimeLock.unlock()
}
#endif
private func captureBluetoothStatus(context: String) {
assert(DispatchQueue.getSpecific(key: bleQueueKey) != nil, "captureBluetoothStatus must run on bleQueue")
let centralState = centralManager?.state ?? .unknown
let isScanning = centralManager?.isScanning ?? false
let peripheralState = peripheralManager?.state ?? .unknown
let isAdvertising = peripheralManager?.isAdvertising ?? false
let peerSummary = collectionsQueue.sync {
(
connected: peerRegistry.connectedCount,
known: peerRegistry.count,
candidates: connectionScheduler.candidateCount
)
}
#if os(iOS)
// INVARIANT: bleQueue must NEVER sync-dispatch to the main thread.
// The main actor sync-waits on bleQueue along the send paths
// (readLinkState), so a main.sync here completes an ABBA deadlock —
// field-verified as a permanent freeze when a courier-drop storm put
// an ack send (main → bleQueue.sync) up against a status capture
// (bleQueue → main.sync). backgroundTimeRemaining is main-actor
// state, so it is sampled on main and cached.
let backgroundSeconds = cachedBackgroundTimeRemaining
let backgroundDescriptor: String
if backgroundSeconds == .greatestFiniteMagnitude {
backgroundDescriptor = " bgRemaining=∞"
} else {
backgroundDescriptor = String(format: " bgRemaining=%.1fs", backgroundSeconds)
}
let appPhase = isAppActive ? "foreground" : "background"
#else
let backgroundDescriptor = ""
let appPhase = "foreground"
#endif
SecureLogger.info(
"📊 BLE status [\(context)]: phase=\(appPhase) central=\(centralState) scanning=\(isScanning) peripheral=\(peripheralState) advertising=\(isAdvertising) connected=\(peerSummary.connected) known=\(peerSummary.known) candidates=\(peerSummary.candidates)\(backgroundDescriptor)",
category: .session
)
}
private func routingData(for peerID: PeerID) -> Data? {
peerID.toShort().routingData
}
private func refreshLocalTopology() {
let neighbors: [Data] = collectionsQueue.sync {
peerRegistry.connectedRoutingData
}
meshTopology.updateNeighbors(for: myPeerIDData, neighbors: neighbors)
}
private func computeRoute(to peerID: PeerID) -> [Data]? {
// Version-gated: every hop and the recipient must have been observed
// speaking v2, since a v1-only node drops v2 frames on decode.
meshTopology.computeRoute(
from: myPeerIDData,
to: routingData(for: peerID),
maxHops: TransportConfig.bleSourceRouteMaxIntermediateHops,
requiringVersion: 2
)
}
private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket {
let now = Date()
let route = BLESourceRouteOriginationPolicy.route(
for: packet,
to: recipient,
localPeerIDData: myPeerIDData,
isRecipientConnected: { self.isPeerConnected($0) },
shouldAttemptRoute: { peer in
self.collectionsQueue.sync(flags: .barrier) {
self.sourceRouteFailures.shouldAttemptRoute(to: peer, now: now)
}
},
computeRoute: { self.computeRoute(to: $0) }
)
guard let route else { return packet }
// Create new packet with route applied and version upgraded to 2
let routedPacket = BitchatPacket(
type: packet.type,
senderID: packet.senderID,
recipientID: packet.recipientID,
timestamp: packet.timestamp,
payload: packet.payload,
signature: nil, // Will be re-signed below
ttl: packet.ttl,
version: 2,
route: route
)
// Re-sign the packet since route and version changed
guard let signedPacket = noiseService.signPacket(routedPacket) else {
SecureLogger.error("❌ Failed to re-sign packet with route", category: .security)
return packet // Return original packet if signing fails
}
collectionsQueue.sync(flags: .barrier) {
sourceRouteFailures.noteRoutedSend(to: recipient, now: now)
}
return signedPacket
}
private func routingPeer(from data: Data) -> PeerID? {
PeerID(routingData: data)
}
// MARK: - Mesh Diagnostics (/ping, /trace, topology map)
/// Sends a directed unencrypted ping probe (8-byte nonce + origin TTL).
/// The completion fires exactly once on the main actor: with RTT/hops
/// when the matching pong returns, or nil after the timeout window.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
messageQueue.async { [weak self] in
guard let self,
let recipientData = peerID.toShort().routingData,
let payload = MeshPingPayload(
nonce: Data((0..<MeshPingPayload.nonceLength).map { _ in UInt8.random(in: .min ... .max) }),
originTTL: self.messageTTL
) else {
Task { @MainActor in completion(nil) }
return
}
let nonce = payload.nonce
let packet = BitchatPacket(
type: MessageType.ping.rawValue,
senderID: self.myPeerIDData,
recipientID: recipientData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload.encode(),
signature: nil,
ttl: self.messageTTL
)
let timeout = DispatchWorkItem { [weak self] in
guard let self else { return }
let expired = self.collectionsQueue.sync(flags: .barrier) {
self.pendingMeshPings.removeValue(forKey: nonce)
}
guard let expired else { return }
Task { @MainActor in expired.completion(nil) }
}
self.collectionsQueue.sync(flags: .barrier) {
self.pendingMeshPings[nonce] = PendingMeshPing(
peerID: PeerID(hexData: recipientData),
sentAt: Date(),
completion: completion,
timeout: timeout
)
}
self.messageQueue.asyncAfter(
deadline: .now() + TransportConfig.meshPingTimeoutSeconds,
execute: timeout
)
self.broadcastPacket(packet)
}
}
/// Answers a ping addressed to us with a pong echoing its nonce; pings
/// addressed elsewhere are left to the generic directed-relay path.
///
/// `linkPeerID` is the directly connected peer that delivered the packet
/// (the ingress link), NOT the packet's claimed sender: pings are
/// unsigned, so `packet.senderID` is attacker-controlled, and keying the
/// response budget on it would let one connected peer rotate forged
/// sender IDs to emit unbounded pongs. The budget is per physical link;
/// the pong still goes to the claimed sender (that's the protocol).
private func handleMeshPing(_ packet: BitchatPacket, fromLink linkPeerID: PeerID) {
guard packet.recipientID == myPeerIDData else { return }
guard let ping = MeshPingPayload.decode(packet.payload) else {
SecureLogger.debug("⚠️ Malformed ping via \(linkPeerID.id.prefix(8))…", category: .session)
return
}
let allowed = collectionsQueue.sync(flags: .barrier) {
meshPingResponseLimiter.shouldRespond(to: linkPeerID, now: Date())
}
guard allowed else {
if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") {
SecureLogger.warning("🚫 Rate-limiting pings via link \(linkPeerID.id.prefix(8))…", category: .security)
}
return
}
guard let pong = MeshPingPayload(nonce: ping.nonce, originTTL: messageTTL) else { return }
let reply = BitchatPacket(
type: MessageType.pong.rawValue,
senderID: myPeerIDData,
recipientID: packet.senderID,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: pong.encode(),
signature: nil,
ttl: messageTTL
)
broadcastPacket(reply)
}
/// Resolves a pong against its outstanding probe. The unguessable echoed
/// nonce plus the sender check bind the reply to the probed peer; hops
/// come from the pong's TTL decrements on the return path.
private func handleMeshPong(_ packet: BitchatPacket, from peerID: PeerID) {
guard packet.recipientID == myPeerIDData else { return }
guard let pong = MeshPingPayload.decode(packet.payload) else { return }
let pending = collectionsQueue.sync(flags: .barrier) { () -> PendingMeshPing? in
guard pendingMeshPings[pong.nonce]?.peerID == peerID else { return nil }
return pendingMeshPings.removeValue(forKey: pong.nonce)
}
guard let pending else { return }
pending.timeout.cancel()
let rttMs = Int((Date().timeIntervalSince(pending.sentAt) * 1000).rounded())
let result = MeshPingResult(
rttMs: max(0, rttMs),
hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
)
Task { @MainActor in pending.completion(result) }
}
/// Estimated intermediate hops toward `peerID`, BFS over gossiped
/// bidirectionally-confirmed neighbor claims ([] = direct, nil = none).
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
refreshLocalTopology()
if let route = computeRoute(to: peerID) {
return route.compactMap { PeerID(routingData: $0) }
}
// Confirmed claims can lag a brand-new link (the peer's next announce
// hasn't arrived yet); a live direct connection is still a known path.
return isPeerConnected(peerID) ? [] : nil
}
/// Mesh graph for the topology map. Edges are advisory: announces cap
/// neighbor lists at 10, so an edge claimed by either endpoint counts.
func currentMeshTopology() -> MeshTopologySnapshot? {
refreshLocalTopology()
let claims = meshTopology.adjacencySnapshot()
var nodes = Set<PeerID>()
var edges = Set<MeshTopologyEdge>()
for (source, neighbors) in claims {
guard let sourcePeer = PeerID(routingData: source) else { continue }
nodes.insert(sourcePeer)
for neighborData in neighbors {
guard let neighborPeer = PeerID(routingData: neighborData),
neighborPeer != sourcePeer else { continue }
nodes.insert(neighborPeer)
edges.insert(MeshTopologyEdge(sourcePeer, neighborPeer))
}
}
nodes.insert(myPeerID)
return MeshTopologySnapshot(
localPeerID: myPeerID,
nodes: nodes.sorted(),
edges: edges.sorted { ($0.a, $0.b) < ($1.a, $1.b) }
)
}
private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool {
let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData)
let plan = BLERouteForwardingPolicy.plan(
for: packet,
localPeerID: myPeerID,
localRoutingData: myRoutingData,
routingPeer: routingPeer(from:),
isPeerConnected: isPeerConnected(_:)
)
if let forwardPacket = plan.forwardPacket, let nextHop = plan.nextHop {
sendPacketDirected(forwardPacket, to: nextHop)
}
return plan.shouldSuppressFloodRelay
}
/// Safely fetch the current direct-link state for a peer using the BLE queue.
private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) {
let state = readLinkState { $0.directLinkState(for: peerID) }
return (state.hasPeripheral, state.hasCentral)
}
private func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
readLinkState { $0.links(to: peerID) }
}
private func boundPeerID(for link: BLEIngressLinkID, in store: BLELinkStateStore) -> PeerID? {
switch link {
case .peripheral(let peripheralUUID):
store.peerID(forPeripheralID: peripheralUUID)
case .central(let centralUUID):
store.peerID(forCentralUUID: centralUUID)
}
}
/// Marks the exact physical ingress link that completed a fresh Noise
/// handshake. An old session keyed only by peer ID is insufficient: a
/// replayed announce can rebind an attacker's link to that ID.
private func markNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) {
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return }
readLinkState { store in
guard boundPeerID(for: link, in: store) == peerID else { return }
noiseAuthenticatedLinkOwners[link] = peerID
}
}
private func isNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) -> Bool {
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return false }
return readLinkState { store in
noiseAuthenticatedLinkOwners[link] == peerID && boundPeerID(for: link, in: store) == peerID
}
}
private func hasCurrentNoiseAuthenticatedLink(to peerID: PeerID) -> Bool {
!currentNoiseAuthenticatedLinks(to: peerID).isEmpty
}
private func currentNoiseAuthenticatedLinks(to peerID: PeerID) -> Set<BLEIngressLinkID> {
readLinkState { store in
Set(noiseAuthenticatedLinkOwners.compactMap { link, owner in
owner == peerID && boundPeerID(for: link, in: store) == peerID ? link : nil
})
}
}
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))…")
self?.messageQueue.async { [weak self] in
self?.sendPendingMessagesAfterHandshake(for: peerID)
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
}
self?.messageQueue.async { [weak self] in
self?.sendAnnounce(forceSend: true)
}
}
}
/// Swaps `myPeerID`/`myPeerIDData` to match the current Noise identity.
/// The swap runs as a `messageQueue` barrier so in-flight work items that
/// read the identity (e.g. `sendMessage` building packets) complete
/// against the old value and everything after sees the new one atomically.
/// Callers (init, panic reset on the main thread) are never on
/// `messageQueue`; the re-entrancy check keeps any future on-queue caller
/// from deadlocking.
private func refreshPeerIdentity() {
let swap = {
let fingerprint = self.noiseService.getIdentityFingerprint()
self.myPeerID = PeerID(str: fingerprint.prefix(16))
self.myPeerIDData = Data(hexString: self.myPeerID.id) ?? Data()
self.meshTopology.reset()
}
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
swap()
} else {
messageQueue.sync(flags: .barrier, execute: swap)
}
}
private func sendNoisePayload(_ typedPayload: Data, to peerID: PeerID) {
// Hop like sendMessage: the Transport-facing wrappers (verify/vouch/
// group payloads) call this from the main actor, and the send path
// sync-waits on bleQueue for link state.
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
messageQueue.async { [weak self] in
self?.sendNoisePayload(typedPayload, to: peerID)
}
return
}
guard noiseService.hasSession(with: peerID) else {
// No session yet - queue the payload SYNCHRONOUSLY before initiating handshake
// to prevent race where fast handshake completion drains empty queue
collectionsQueue.sync(flags: .barrier) {
self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID)
SecureLogger.debug("📥 Queued noise payload for \(peerID.id.prefix(8))… pending handshake", category: .session)
}
initiateNoiseHandshake(with: peerID)
return
}
do {
broadcastPacket(try makeEncryptedNoisePacket(typedPayload, to: peerID))
} catch {
SecureLogger.error("Failed to send verification payload: \(error)")
}
}
private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket {
let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
return BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted,
signature: nil,
ttl: messageTTL
)
}
// MARK: Courier Store-and-Forward
/// Seal `content` for the recipient and hand the envelope to the given
/// couriers for physical delivery. When a verified one-time prekey bundle
/// is cached for the recipient, sealing targets one of its prekeys
/// (forward secret, envelope v2); otherwise it falls back to their static
/// key (one-way Noise X, v1) exactly as before. Returns false when no
/// courier is connected, the payload cannot be built, or sealing fails;
/// link writes are queued asynchronously after the envelope is ready.
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
let connected = couriers.filter { isPeerConnected($0) }
guard !connected.isEmpty,
let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
return false
}
let payload: Data
do {
let now = Date()
let sealed: Data
let prekeyID: UInt32?
if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
prekeyID = prekey.id
} else {
sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
prekeyID = nil
}
let envelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: recipientNoiseKey,
epochDay: CourierEnvelope.epochDay(for: now)
),
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
ciphertext: sealed,
copies: TransportConfig.courierInitialCopies,
prekeyID: prekeyID
)
guard let encoded = envelope.encode() else { return false }
payload = encoded
} catch {
SecureLogger.error("Failed to seal courier envelope: \(error)", category: .encryption)
return false
}
messageQueue.async { [weak self] in
guard let self else { return }
for courier in connected {
SecureLogger.debug("📦 Depositing courier envelope with \(courier.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
self.sendPacketDirected(self.makeCourierPacket(payload, to: courier), to: courier)
}
}
return true
}
// MARK: Courier over the bridge
/// Seals `content` into a courier envelope for relay parking (a bridge
/// courier drop). Same sealing rules as `sendCourierMessage` — prekey
/// (v2) when a verified bundle is cached, static Noise X (v1) otherwise —
/// but carry-only: a relay copy never sprays.
func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope? {
guard let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
return nil
}
do {
let now = Date()
let sealed: Data
let prekeyID: UInt32?
if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
prekeyID = prekey.id
} else {
sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
prekeyID = nil
}
return CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: recipientNoiseKey,
epochDay: CourierEnvelope.epochDay(for: now)
),
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
ciphertext: sealed,
copies: 1,
prekeyID: prekeyID
)
} catch {
SecureLogger.error("Failed to seal bridge courier envelope: \(error)", category: .encryption)
return nil
}
}
/// Opens a courier envelope that arrived as a bridge drop (relay fetch,
/// not a directed mesh packet). Returns false when the rotating tag does
/// not match our static key — a drop for someone else, or a stale tag.
/// The inner Noise X seal authenticates the sender; there is no packet
/// signature to check on this path.
@discardableResult
func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool {
guard !envelope.isExpired else { return false }
let myKey = noiseService.getStaticPublicKeyData()
guard CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) else {
return false
}
return openCourierEnvelope(envelope)
}
/// Hands a bridge-fetched envelope directly to the matching local peer
/// as a directed courier packet. Delivery-only by design: the recipient's
/// tag matched, so this never lands in a stranger's carry quota.
/// Returns true only if a current Noise-authenticated physical link
/// accepted the packet; a stale peer-level session, reachability record,
/// replay-rebound link, or process-local spool is not delivery.
@discardableResult
func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool {
guard hasCurrentNoiseAuthenticatedLink(to: peerID) else { return false }
guard let payload = envelope.encode() else { return false }
let packet = makeCourierPacket(payload, to: peerID)
let send = { [weak self] in
self?.sendPacketDirected(
packet,
to: peerID,
requireDirectPeerLink: true,
requireNoiseAuthenticatedPeerLink: true
) ?? false
}
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
return send()
}
return messageQueue.sync(execute: send)
}
/// Our own Noise static public key (for computing our courier tags).
func myNoiseStaticPublicKey() -> Data {
noiseService.getStaticPublicKeyData()
}
/// Verified reachable peers with known Noise keys — the set a bridge
/// gateway watches courier drops for.
func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)] {
let now = Date()
return collectionsQueue.sync {
peerRegistry.snapshotByID.values.compactMap { info in
guard info.isVerifiedNickname,
let key = info.noisePublicKey,
peerRegistry.isReachable(info.peerID, now: now) else { return nil }
return (info.peerID, key)
}
}
}
/// The prekey to seal a courier message with, or nil to fall back to
/// static sealing. The real signal is a verified, unexpired bundle with a
/// spare prekey; the advertised `.prekeys` capability only acts as a veto
/// for peers we currently see on the mesh (a cached bundle can outlive a
/// peer's downgrade to a build that no longer holds the privates).
/// Re-deposits of the same message reuse its assigned prekey, so one
/// message consumes exactly one prekey ID regardless of courier count.
private func assignRecipientPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? {
let shortID = PeerID(publicKey: recipientNoiseKey)
let knownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil }
if knownOnMesh, !peerCapabilities(shortID).contains(.prekeys) {
return nil
}
return prekeyBundleStore.assignPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey)
}
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
let packet = BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
// Signed so a courier can authenticate the depositor before carrying
// mail under their quota. Handover to the recipient doesn't need the
// packet signature — the inner Noise X seal authenticates the sender.
return noiseService.signPacket(packet) ?? packet
}
/// Handles both courier roles for an incoming envelope addressed to us:
/// recipient (the rotating tag matches our static key → open and deliver)
/// or courier (a trusted peer is depositing mail for someone else → store).
private func handleCourierEnvelope(_ packet: BitchatPacket, from peerID: PeerID) {
// Directed packets only; envelopes addressed elsewhere ride the
// generic relay path untouched.
guard packet.recipientID == myPeerIDData else { return }
guard let envelope = CourierEnvelope.decode(packet.payload), !envelope.isExpired else { return }
let myKey = noiseService.getStaticPublicKeyData()
if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) {
openCourierEnvelope(envelope)
} else {
acceptCourierDeposit(envelope, from: peerID, packet: packet)
}
}
@discardableResult
private func openCourierEnvelope(_ envelope: CourierEnvelope) -> Bool {
do {
let typedPayload: Data
let senderStaticKey: Data
if let prekeyID = envelope.prekeyID {
// Envelope v2: sealed to one of our one-time prekeys. Opening
// consumes the prekey (48h redelivery grace), which shrinks our
// published bundle under a strictly newer generatedAt. Re-gossip
// so peers replace their cached copy and stop assigning the
// consumed ID before the grace lapses; force the broadcast when
// the batch also topped back up (low-water), otherwise let the
// rebroadcast throttle coalesce bursts.
let opened = try noiseService.openPrekeyPayload(envelope.ciphertext, prekeyID: prekeyID)
(typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey)
if opened.consumedPrekey {
let replenished = noiseService.replenishPrekeysIfNeeded()
sendPrekeyBundle(force: replenished)
}
} else {
(typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
}
guard let typeRaw = typedPayload.first,
let payloadType = NoisePayloadType(rawValue: typeRaw),
payloadType == .privateMessage else {
SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session)
return true // decrypted but deterministically unsupported
}
let payload = Data(typedPayload.dropFirst())
guard let innerMessageID = PrivateMessagePacket.decode(from: payload)?.messageID else {
SecureLogger.warning("⚠️ Courier envelope carried undecodable private message", category: .session)
return true // decrypted but deterministically malformed
}
// Redundant copies of one message arrive as distinct envelopes
// (fresh seal each: mesh couriers, bridge drops across relays),
// so dedup here on the inner message ID — before delivery, ack,
// and handshake work. A duplicate costs only the decrypt above
// and at most one ack ever goes out per message ID.
let firstOpen = collectionsQueue.sync(flags: .barrier) {
openedCourierMessageIDs.insert(innerMessageID)
}
guard firstOpen else {
SecureLogger.debug("📦 Dropping duplicate courier envelope for message \(innerMessageID.prefix(8))…", category: .session)
return true
}
// Couriered mail arrives while the sender is absent, so the UI's
// block check can't resolve their fingerprint from a live session.
// Gate here, where the full static key is in hand.
guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else {
SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security)
return true
}
// A present sender resolves to their live mesh thread via the
// derived short ID. An absent sender — the usual courier case —
// uses the full noise-key ID so the message lands on the stable
// favorite conversation instead of an unresolvable short-ID
// thread labeled "Unknown".
let shortID = PeerID(publicKey: senderStaticKey)
let isKnownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil }
let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey)
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session)
sfMetrics?.record(.courierOpened)
notifyUI { [weak self] in
self?.deliverTransportEvent(.noisePayloadReceived(
peerID: senderPeerID,
type: payloadType,
payload: payload,
timestamp: Date()
))
}
return true
} catch {
// Tag collision or stale key: not addressed to us after all.
SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption)
return false
}
}
private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID, packet: BitchatPacket) {
// A deposit must come from its depositor over the direct link: the
// claimed sender has to be the ingress peer, and the packet signature
// has to verify against that peer's announced signing key. Otherwise
// an untrusted sender could route an envelope through any trusted
// neighbor and have us carry it under the neighbor's quota.
guard PeerID(hexData: packet.senderID) == peerID else {
SecureLogger.debug("📦 Courier deposit rejected: relayed envelope claims sender \(PeerID(hexData: packet.senderID).id.prefix(8))… but arrived from \(peerID.id.prefix(8))…", category: .security)
return
}
let depositorInfo = collectionsQueue.sync { peerRegistry.info(for: peerID) }
guard let depositorKey = depositorInfo?.noisePublicKey else {
SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session)
return
}
guard let signingKey = depositorInfo?.signingPublicKey,
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (missing/invalid signature)", category: .security)
return
}
let isVerifiedPeer = depositorInfo?.isVerifiedNickname ?? false
let store = courierStore
let policy = courierDepositPolicy
let metrics = sfMetrics
Task { @MainActor in
guard let tier = policy(depositorKey, isVerifiedPeer) else {
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session)
return
}
if store.deposit(envelope, from: depositorKey, tier: tier) {
SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))… (\(tier.rawValue))", category: .session)
metrics?.record(.courierAccepted)
}
}
}
/// Hand over any carried envelopes addressed to a peer we just heard from.
private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) {
let metrics = sfMetrics
let accepted = courierStore.handoverEnvelopes(for: noiseKey) { [weak self] envelope in
guard let self,
let payload = envelope.encode(),
self.sendPacketDirected(
self.makeCourierPacket(payload, to: peerID),
to: peerID,
requireDirectPeerLink: true,
requireNoiseAuthenticatedPeerLink: true
) else {
return false
}
metrics?.record(.courierHandedOver)
return true
}
if accepted > 0 {
SecureLogger.debug("📦 Handed over \(accepted) courier envelope(s) to \(peerID.id.prefix(8))…", category: .session)
}
}
/// Speculative handover toward a recipient heard only via a relayed
/// announce: the envelope floods the mesh as a directed packet (relays
/// treat it like a directed DM). Non-destructive — the carried copy stays
/// until a direct handover or expiry, throttled per envelope so repeated
/// announces don't re-flood.
private func deliverCourierMailRemotely(to peerID: PeerID, noiseKey: Data) {
let envelopes = courierStore.envelopesForRemoteHandover(
recipientNoiseKey: noiseKey,
cooldown: TransportConfig.courierRemoteHandoverCooldownSeconds
)
guard !envelopes.isEmpty else { return }
SecureLogger.debug("📦 Remote handover: flooding \(envelopes.count) envelope(s) toward \(peerID.id.prefix(8))…", category: .session)
for envelope in envelopes {
guard let payload = envelope.encode() else { continue }
broadcastPacket(makeCourierPacket(payload, to: peerID))
sfMetrics?.record(.courierRemoteHandover)
}
}
/// Spray-and-wait: split copy budgets with another courier we just
/// encountered, so carried mail diffuses through a moving crowd instead
/// of riding a single carrier. Only favorites and verified peers qualify,
/// mirroring the deposit policy they would apply to us.
private func sprayCourierMail(to peerID: PeerID, noiseKey: Data, isVerifiedPeer: Bool) {
let store = courierStore
let metrics = sfMetrics
let sendSpray: () -> Void = { [weak self] in
guard let self else { return }
let accepted = store.transferSprayCopies(to: noiseKey) { envelope in
guard let payload = envelope.encode(),
self.sendPacketDirected(
self.makeCourierPacket(payload, to: peerID),
to: peerID,
requireDirectPeerLink: true,
requireNoiseAuthenticatedPeerLink: true
) else {
return false
}
metrics?.record(.courierSprayed)
return true
}
if accepted > 0 {
SecureLogger.debug("📦 Sprayed \(accepted) envelope copy(ies) to courier \(peerID.id.prefix(8))…", category: .session)
}
}
let policy = courierDepositPolicy
Task { @MainActor in
// Same trust gate as deposits: don't hand mail to a peer who
// would reject it from us.
guard policy(noiseKey, isVerifiedPeer) != nil else { return }
sendSpray()
}
}
// MARK: One-Time Prekey Bundles
/// Broadcasts our signed prekey bundle and tracks it for gossip sync.
/// Unforced sends (piggybacked on announces) are throttled — gossip does
/// the spreading, the broadcast just keeps our own gossip entry fresh.
/// Forced sends (bundle changed after consumption) go immediately.
private func sendPrekeyBundle(force: Bool = false) {
let now = Date()
let shouldSend: Bool = collectionsQueue.sync(flags: .barrier) {
if !force,
let last = lastPrekeyBundleSentAt,
now.timeIntervalSince(last) < TransportConfig.prekeyBundleRebroadcastSeconds {
return false
}
lastPrekeyBundleSentAt = now
return true
}
guard shouldSend else { return }
guard let bundle = noiseService.currentPrekeyBundle(),
let payload = bundle.encode() else {
SecureLogger.error("❌ Failed to build prekey bundle", category: .security)
return
}
let packet = BitchatPacket(
type: MessageType.prekeyBundle.rawValue,
senderID: myPeerIDData,
recipientID: nil,
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
guard let signedPacket = noiseService.signPacket(packet) else {
SecureLogger.error("❌ Failed to sign prekey bundle packet", category: .security)
return
}
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(signedPacket)
} else {
messageQueue.async { [weak self] in
self?.broadcastPacket(signedPacket)
}
}
gossipSyncManager?.onPublicPacketSeen(signedPacket)
}
/// Ingests a gossiped prekey bundle. Attribution is layered: the outer
/// packet must originate from the bundle owner (fabricated sender IDs, used
/// to multiply cache/gossip entries, are rejected), and BOTH the inner
/// bundle signature and the outer packet signature must verify against the
/// owner's announce-bound signing key. Verifying the outer packet — whose
/// signed bytes cover senderID and timestamp — stops a valid bundle from
/// being replayed under a fresh timestamp or spoofed sender to pass
/// freshness or poison attribution. Only after that does the packet enter
/// our own gossip store, so we never help spread a bundle we couldn't
/// attribute.
private func handlePrekeyBundle(_ packet: BitchatPacket, from peerID: PeerID) {
guard let bundle = PrekeyBundle.decode(packet.payload) else {
SecureLogger.debug("🔑 Ignoring malformed prekey bundle from \(peerID.id.prefix(8))…", category: .security)
return
}
// Our own bundle is tracked at send time; a copy echoing back adds nothing.
guard bundle.noiseStaticPublicKey != noiseService.getStaticPublicKeyData() else { return }
let owner = PeerID(publicKey: bundle.noiseStaticPublicKey)
// The owner's genuine bundle (direct or relayed) always carries the
// owner's senderID + outer signature; gossip resends preserve both. A
// packet whose senderID isn't the owner can't be authenticated here.
guard PeerID(hexData: packet.senderID) == owner else {
SecureLogger.debug("🔑 Ignoring prekey bundle whose sender ≠ owner \(owner.id.prefix(8))…", category: .security)
return
}
// Look up the announce-bound signing key and stash-if-unbound in ONE
// barrier: the receive queue is concurrent, so this bundle can race
// ahead of the announce that binds the key. Reading the live registry
// and stashing atomically closes the check-then-act gap against
// handleAnnounce's drain (see drainPendingPrekeyBundles).
let signingKey: Data? = collectionsQueue.sync(flags: .barrier) {
if let info = peerRegistry.info(for: owner),
info.noisePublicKey == bundle.noiseStaticPublicKey,
let key = info.signingPublicKey {
return key
}
// Offline-verified identities are stable across this race.
for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(owner)
where candidate.publicKey == bundle.noiseStaticPublicKey {
if let key = candidate.signingPublicKey { return key }
}
// No binding yet: retain the latest bundle per owner, bounded, and
// retry once the verified announce lands.
if pendingPrekeyBundles[owner] != nil
|| pendingPrekeyBundles.count < Self.pendingPrekeyBundleCap {
pendingPrekeyBundles[owner] = packet
}
return nil
}
guard let signingKey else {
SecureLogger.debug("🔑 Deferring prekey bundle without a bound signing key (owner \(owner.id.prefix(8))…)", category: .security)
return
}
ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
}
/// Verify a bundle's inner + outer signatures against the owner's bound
/// signing key and, on success, cache it and let it enter our gossip store.
private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) {
guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey),
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
SecureLogger.debug("🔑 Ignoring prekey bundle without verifiable signature (owner \(owner.id.prefix(8))…)", category: .security)
return
}
if prekeyBundleStore.ingest(bundle) {
SecureLogger.debug("🔑 Cached prekey bundle for \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .security)
}
gossipSyncManager?.onPublicPacketSeen(packet)
}
/// Re-attempt any prekey bundle that arrived before this owner's announce
/// bound a signing key. Called from handleAnnounce after a verified
/// announce, in a barrier ordered after the registry write, so a bundle
/// stashed before the write is always observed here.
private func drainPendingPrekeyBundles(for owner: PeerID) {
let pending: BitchatPacket? = collectionsQueue.sync(flags: .barrier) {
pendingPrekeyBundles.removeValue(forKey: owner)
}
guard let packet = pending,
let bundle = PrekeyBundle.decode(packet.payload),
let signingKey = announceBoundSigningKey(forNoiseKey: bundle.noiseStaticPublicKey) else { return }
ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
}
/// Ed25519 signing key bound to a Noise static key by a verified
/// announce: from the live registry when the owner is on the mesh, else
/// from identities persisted for offline verification.
private func announceBoundSigningKey(forNoiseKey noiseKey: Data) -> Data? {
let shortID = PeerID(publicKey: noiseKey)
if let info = collectionsQueue.sync(execute: { peerRegistry.info(for: shortID) }),
info.noisePublicKey == noiseKey,
let signingKey = info.signingPublicKey {
return signingKey
}
for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(shortID)
where candidate.publicKey == noiseKey {
if let signingKey = candidate.signingPublicKey {
return signingKey
}
}
return nil
}
// MARK: Gateway carrier (nostrCarrier)
/// Sign and send an encoded `toGateway` carrier payload directed at a
/// gateway peer. The packet is signed so the gateway can key its uplink
/// quotas to an authenticated depositor; the carried Nostr event has its
/// own Schnorr signature for content authenticity. Returns false when
/// the gateway is not reachable or signing fails.
func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool {
guard isPeerReachable(gatewayPeer) else { return false }
let packet = BitchatPacket(
type: MessageType.nostrCarrier.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: gatewayPeer.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
guard let signed = noiseService.signPacket(packet) else { return false }
messageQueue.async { [weak self] in
// broadcastPacket applies a known route when one exists and
// otherwise floods the directed packet like a DM, so a gateway
// that is reachable but multi-hop still gets the deposit.
self?.broadcastPacket(signed)
}
return true
}
/// Broadcast an encoded `fromGateway` carrier payload on the mesh with
/// the default TTL. Unsigned at the packet layer — receivers verify the
/// carried event's own Schnorr signature.
func broadcastNostrCarrier(_ payload: Data) {
let packet = BitchatPacket(
type: MessageType.nostrCarrier.rawValue,
senderID: myPeerIDData,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
messageQueue.async { [weak self] in
self?.broadcastPacket(packet)
}
}
/// Transport-level handling for a received nostrCarrier packet; policy
/// (verification of the carried event, quotas, loop prevention) lives in
/// `GatewayService` behind `onNostrCarrierPacket`.
private func handleNostrCarrier(_ packet: BitchatPacket, from _: PeerID) {
let senderID = PeerID(hexData: packet.senderID)
let directedToUs: Bool
if let recipientID = packet.recipientID {
// Carriers addressed elsewhere ride the generic relay path untouched.
guard recipientID == myPeerIDData else { return }
// Uplink deposit: quotas are keyed by the depositor, so the
// packet signature must verify against the sender's announced
// signing key. Unlike courier deposits the depositor may be
// multi-hop away, so ingress-link identity is not required.
let signingKey = collectionsQueue.sync { peerRegistry.info(for: senderID)?.signingPublicKey }
guard let signingKey,
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
SecureLogger.debug("🌐 nostrCarrier uplink from \(senderID.id.prefix(8))… rejected (missing/invalid packet signature)", category: .security)
return
}
directedToUs = true
} else {
directedToUs = false
}
let payload = packet.payload
notifyUI { [weak self] in
self?.onNostrCarrierPacket?(payload, senderID, directedToUs)
}
}
// MARK: Link capability snapshots (thread-safe via bleQueue)
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
return body(linkStateStore)
} else {
return bleQueue.sync { body(linkStateStore) }
}
}
private func snapshotDirectPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
readLinkState { $0.directPeripheralState(for: peerID) }
}
private func snapshotPeripheralStates() -> [BLEPeripheralLinkState] {
readLinkState(\.peripheralStates)
}
private func snapshotSubscribedCentrals() -> BLESubscribedCentralSnapshot {
readLinkState(\.subscribedCentralSnapshot)
}
// MARK: Helpers: IDs, selection, and write backpressure
private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic, priority: BLEOutboundWritePriority) {
// BLE operations run on bleQueue; keep queue affinity
bleQueue.async { [weak self] in
guard let self = self else { return }
let uuid = peripheral.identifier.uuidString
if peripheral.canSendWriteWithoutResponse {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
} else {
self.collectionsQueue.async(flags: .barrier) {
let result = self.pendingPeripheralWrites.enqueue(
data: data,
for: uuid,
priority: priority,
capBytes: TransportConfig.blePendingWriteBufferCapBytes
)
switch result {
case .oversized(let bytes):
SecureLogger.warning("⚠️ Dropping oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session)
case let .enqueued(trimmedBytes, remainingBytes) where trimmedBytes > 0:
SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(trimmedBytes)B to \(remainingBytes)B", category: .session)
case .enqueued:
break
}
}
}
}
}
/// Writes immediately or synchronously admits the packet to this
/// peripheral's bounded retry queue. Unlike `writeOrEnqueue`, the return
/// value distinguishes a retained queue item from one rejected or trimmed
/// immediately, which lets durable courier state commit truthfully.
private func writeOrEnqueueIfAccepted(
_ data: Data,
to peripheral: CBPeripheral,
characteristic: CBCharacteristic,
priority: BLEOutboundWritePriority,
requiredAuthenticatedPeer: PeerID?
) -> Bool {
let accept = { [self] in
let uuid = peripheral.identifier.uuidString
guard let state = linkStateStore.state(forPeripheralID: uuid),
state.isConnected,
state.characteristic?.uuid == characteristic.uuid else {
return false
}
if let peerID = requiredAuthenticatedPeer {
let link = BLEIngressLinkID.peripheral(uuid)
guard state.peerID == peerID,
noiseAuthenticatedLinkOwners[link] == peerID else {
return false
}
}
if peripheral.canSendWriteWithoutResponse {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
return true
}
let attempt = collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.enqueueReportingAcceptance(
data: data,
for: uuid,
priority: priority,
capBytes: TransportConfig.blePendingWriteBufferCapBytes
)
}
switch attempt.result {
case .oversized(let bytes):
SecureLogger.warning("⚠️ Rejecting oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session)
case let .enqueued(trimmedBytes, remainingBytes) where trimmedBytes > 0:
SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(trimmedBytes)B to \(remainingBytes)B", category: .session)
case .enqueued:
break
}
return attempt.accepted
}
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
return accept()
}
return bleQueue.sync(execute: accept)
}
private func drainPendingWrites(for peripheral: CBPeripheral) {
let uuid = peripheral.identifier.uuidString
bleQueue.async { [weak self] in
guard let self = self else { return }
guard let state = self.linkStateStore.state(forPeripheralID: uuid), let ch = state.characteristic else { return }
// Atomically take all pending items from the queue to avoid race conditions
// where new items could be enqueued between read and update
let itemsToSend: [BLEPendingWrite] = self.collectionsQueue.sync(flags: .barrier) {
self.pendingPeripheralWrites.takeAll(for: uuid)
}
guard !itemsToSend.isEmpty else { return }
// Send as many as possible
var sent = 0
for item in itemsToSend {
if peripheral.canSendWriteWithoutResponse {
peripheral.writeValue(item.data, for: ch, type: .withoutResponse)
sent += 1
} else {
break
}
}
// Re-enqueue any items that couldn't be sent (maintaining order)
let unsent = Array(itemsToSend.dropFirst(sent))
if !unsent.isEmpty {
self.collectionsQueue.async(flags: .barrier) {
self.pendingPeripheralWrites.prepend(unsent, for: uuid)
}
}
}
}
/// Periodically try to drain pending notifications as a backup mechanism
private func drainPendingNotificationsIfPossible() {
drainPendingNotifications(logPrefix: "🔄 Periodic drain: sent")
}
/// Periodically try to drain pending writes for all connected peripherals
private func drainAllPendingWrites() {
let uuids = collectionsQueue.sync { pendingPeripheralWrites.peripheralIDs }
for uuid in uuids {
guard let state = linkStateStore.state(forPeripheralID: uuid), state.isConnected else { continue }
drainPendingWrites(for: state.peripheral)
}
}
// MARK: Application State Handlers (iOS)
#if os(iOS)
@objc private func appDidBecomeActive() {
isAppActive = true
refreshCachedBackgroundTimeRemaining()
// Restart scanning with allow duplicates when app becomes active
if centralManager?.state == .poweredOn {
centralManager?.stopScan()
startScanning()
}
cancelStalePendingConnects()
logBluetoothStatus("became-active")
scheduleBluetoothStatusSample(after: 5.0, context: "active-5s")
// No Local Name; nothing to refresh for advertising policy
}
@objc private func appDidEnterBackground() {
isAppActive = false
refreshCachedBackgroundTimeRemaining()
// Restart scanning without allow duplicates in background
if centralManager?.state == .poweredOn {
centralManager?.stopScan()
startScanning()
}
armPendingBackgroundConnects()
// Backgrounding may precede a kill; flush the public-history archive
// outside its 30s maintenance cadence.
gossipSyncManager?.persistNow()
logBluetoothStatus("entered-background")
scheduleBluetoothStatusSample(after: 15.0, context: "background-15s")
// No Local Name; nothing to refresh for advertising policy
}
/// Issue indefinite `connect()` requests to recently seen peripherals on
/// backgrounding. Pending connects live in the Bluetooth controller's
/// allowlist — no scanning and no app CPU — and complete whenever a 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).
private func armPendingBackgroundConnects(
slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
) {
bleQueue.async { [weak self] in
guard let self, let central = self.centralManager, 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 background→foreground bounce.
self.linkStateStore.setPeripheralState(
BLEPeripheralLinkState(
peripheral: target.peripheral,
characteristic: nil,
peerID: nil,
isConnecting: true,
isConnected: false,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
),
for: target.peripheralID
)
target.peripheral.delegate = self
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.
private func cancelStalePendingConnects() {
bleQueue.async { [weak self] in
guard let self, let central = self.centralManager 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.collectionsQueue.sync(flags: .barrier) {
self.pendingPeripheralWrites.discardAll(for: peripheralID)
}
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
_ = self.linkStateStore.removePeripheral(peripheralID)
cancelled += 1
}
if cancelled > 0 {
SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session)
self.tryConnectFromQueue()
}
}
}
#endif
// MARK: Private Message Handling
private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) {
// Hop like sendMessage: the Transport-facing wrappers call this from
// the main actor (router sends, favorite notifications), and the send
// path sync-waits on bleQueue for link state.
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
messageQueue.async { [weak self] in
self?.sendPrivateMessage(content, to: recipientID, messageID: messageID)
}
return
}
// Sessions and wire recipient IDs are keyed by the short 16-hex form;
// callers may pass the full 64-hex noise key (mirrors sendFilePrivate).
let recipientID = recipientID.toShort()
SecureLogger.debug("📨 Sending PM to \(recipientID.id.prefix(8))… id=\(messageID.prefix(8))… chars=\(content.count) bytes=\(content.utf8.count)", category: .session)
// Check if we have an established Noise session
if noiseService.hasEstablishedSession(with: recipientID) {
// Encrypt and send
do {
guard let messagePayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
SecureLogger.error("Failed to encode private message with TLV")
return
}
broadcastPacket(try makeEncryptedNoisePacket(messagePayload, to: recipientID))
// Notify delegate that message was sent
notifyUI { [weak self] in
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: messageID, status: .sent))
}
} catch {
SecureLogger.error("Failed to encrypt message: \(error)")
}
} else {
// Queue message for sending after handshake completes
SecureLogger.debug("🤝 No session with \(recipientID.id.prefix(8))…, initiating handshake and queueing message", category: .session)
// Queue the message (especially important for favorite notifications)
collectionsQueue.sync(flags: .barrier) {
pendingNoiseSessionQueues.appendPrivateMessage(content: content, messageID: messageID, for: recipientID)
}
initiateNoiseHandshake(with: recipientID)
// Notify delegate that message is pending
notifyUI { [weak self] in
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: messageID, status: .sending))
}
}
}
private func initiateNoiseHandshake(with peerID: PeerID) {
// Use NoiseEncryptionService for handshake
guard !noiseService.hasSession(with: peerID) else { return }
do {
let handshakeData = try noiseService.initiateHandshake(with: peerID)
// Send handshake init
let packet = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: handshakeData,
signature: nil,
ttl: messageTTL
)
broadcastPacket(packet)
} catch {
SecureLogger.error("Failed to initiate handshake: \(error)")
}
}
private func sendPendingMessagesAfterHandshake(for peerID: PeerID) {
// Atomically take all pending messages to process (prevents concurrent modification)
let pendingMessages = collectionsQueue.sync(flags: .barrier) { () -> [BLEPendingPrivateMessage] in
pendingNoiseSessionQueues.takePrivateMessages(for: peerID)
}
guard !pendingMessages.isEmpty else { return }
SecureLogger.debug("📤 Sending \(pendingMessages.count) pending messages after handshake to \(peerID.id.prefix(8))…", category: .session)
// Track failed messages for re-queuing
var failedMessages: [BLEPendingPrivateMessage] = []
// Send each pending message directly (we know session is established)
for message in pendingMessages {
do {
// Use the same TLV format as normal sends to keep receiver decoding consistent
guard let messagePayload = BLENoisePayloadFactory.privateMessage(content: message.content, messageID: message.messageID) else {
SecureLogger.error("Failed to encode pending private message TLV")
failedMessages.append(message)
continue
}
// We're already on messageQueue from the callback
broadcastPacket(try makeEncryptedNoisePacket(messagePayload, to: peerID))
// Notify delegate that message was sent
notifyUI { [weak self] in
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .sent))
}
SecureLogger.debug("✅ Sent pending message id=\(message.messageID.prefix(8))… to \(peerID.id.prefix(8))… after handshake", category: .session)
} catch {
SecureLogger.error("Failed to send pending message after handshake: \(error)")
failedMessages.append(message)
// Notify delegate of failure
notifyUI { [weak self] in
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer"))))
}
}
}
// Re-queue any failed messages for retry on next handshake
if !failedMessages.isEmpty {
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
// Prepend failed messages to maintain order
self.pendingNoiseSessionQueues.prependPrivateMessages(failedMessages, for: peerID)
SecureLogger.warning("⚠️ Re-queued \(failedMessages.count) failed messages for \(peerID.id.prefix(8))…", category: .session)
}
}
}
// MARK: Fragmentation (Required for messages > BLE MTU)
@discardableResult
private func sendFragmentedPacket(
_ packet: BitchatPacket,
pad: Bool,
maxChunk: Int? = nil,
directedOnlyPeer: PeerID? = nil,
transferId: String? = nil,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
) -> Bool {
let request = BLEOutboundFragmentTransferRequest(
packet: packet,
pad: pad,
maxChunk: maxChunk,
directedPeer: directedOnlyPeer,
transferId: transferId,
requireDirectPeerLink: requireDirectPeerLink,
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
)
let result = collectionsQueue.sync(flags: .barrier) {
outboundFragmentTransfers.submit(request, maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers)
}
return handleFragmentTransferSubmitResult(result)
}
@discardableResult
private func handleFragmentTransferSubmitResult(_ result: BLEOutboundFragmentTransferScheduler.SubmitResult) -> Bool {
switch result {
case let .start(request, reservedTransferId):
return startFragmentedPacket(request, reservedTransferId: reservedTransferId)
case let .queued(_, transferId, _):
if let transferId {
SecureLogger.debug("🚦 Queued media transfer \(transferId.prefix(8))… waiting for slot", category: .session)
} else {
SecureLogger.debug("🚦 Queued fragment transfer waiting for slot", category: .session)
}
return false
case let .rejectedStrict(_, transferId):
SecureLogger.debug(
"🚫 Strict directed fragment transfer \(transferId?.prefix(8) ?? "?")… rejected while scheduler busy",
category: .session
)
return false
case let .droppedDuplicate(_, activeTransferId):
SecureLogger.debug(
"🔁 Skipping duplicate outbound transfer — same content already in flight as \(activeTransferId?.prefix(8) ?? "?")…",
category: .session
)
return false
}
}
@discardableResult
private func startFragmentedPacket(
_ request: BLEOutboundFragmentTransferRequest,
reservedTransferId: String?
) -> Bool {
let releaseReservedSlot: (String) -> Void = { [weak self] id in
guard let self = self else { return }
TransferProgressManager.shared.cancel(id: id)
self.collectionsQueue.async(flags: .barrier) { [weak self] in
_ = self?.outboundFragmentTransfers.releaseReservation(id)
}
self.messageQueue.async { [weak self] in
self?.startNextPendingTransferIfNeeded()
}
}
guard let plan = BLEOutboundFragmentPlanner.makePlan(
for: request,
defaultChunkSize: defaultFragmentSize,
bleMaxMTU: bleMaxMTU
) else {
if let id = reservedTransferId {
releaseReservedSlot(id)
}
return false
}
// Lightweight pacing to reduce floods and allow BLE buffers to drain
// Also briefly pause scanning during long fragment trains to save battery
if plan.shouldPauseScanning {
bleQueue.async { [weak self] in
guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return }
if c.isScanning { c.stopScan() }
let totalFragments = plan.totalFragments
let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, totalFragments * TransportConfig.bleExpectedWritePerFragmentMs)
self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in
self?.startScanning()
}
}
}
let transferIdentifier: String? = {
guard let id = reservedTransferId else { return nil }
collectionsQueue.sync(flags: .barrier) {
_ = self.outboundFragmentTransfers.activateReservedTransfer(id: id, totalFragments: plan.totalFragments, workItems: [])
}
TransferProgressManager.shared.start(id: id, totalFragments: plan.totalFragments)
return id
}()
let sendFragment: (BitchatPacket) -> Bool = { [weak self] fragmentPacket in
guard let self else { return false }
if request.requireDirectPeerLink, let directedPeer = request.directedPeer {
return self.sendPacketDirected(
fragmentPacket,
to: directedPeer,
requireDirectPeerLink: true,
requireNoiseAuthenticatedPeerLink: request.requireNoiseAuthenticatedPeerLink
)
}
self.broadcastPacket(fragmentPacket)
return true
}
// Strict courier handoff is transactional at the fragment-admission
// boundary: every fragment must enter the intended authenticated
// link or its bounded retry queue before the durable owner may commit.
// A partial train is harmlessly abandoned and the envelope stays
// retryable with a fresh fragment ID on the next encounter.
if request.requireDirectPeerLink {
let admitted = BLEStrictFragmentAdmission.admitAll(plan.fragmentPackets) { fragmentPacket in
guard sendFragment(fragmentPacket) else { return false }
if let transferId = transferIdentifier {
markFragmentSent(transferId: transferId)
}
return true
}
guard admitted else {
if let id = reservedTransferId {
releaseReservedSlot(id)
}
return false
}
return true
}
var scheduledItems: [(item: DispatchWorkItem, index: Int)] = []
for (index, fragmentPacket) in plan.fragmentPackets.enumerated() {
let workItem = DispatchWorkItem { [weak self] in
guard let self = self else { return }
if let transferId = transferIdentifier {
let isActive = self.collectionsQueue.sync { self.outboundFragmentTransfers.isActive(transferId) }
guard isActive else { return }
}
if fragmentPacket.recipientID == nil || fragmentPacket.recipientID?.allSatisfy({ $0 == 0xFF }) == true {
self.gossipSyncManager?.onPublicPacketSeen(fragmentPacket)
}
_ = sendFragment(fragmentPacket)
if let transferId = transferIdentifier {
self.markFragmentSent(transferId: transferId)
}
}
scheduledItems.append((item: workItem, index: index))
}
if let transferId = transferIdentifier {
let workItems = scheduledItems.map { $0.item }
collectionsQueue.async(flags: .barrier) { [weak self] in
_ = self?.outboundFragmentTransfers.updateWorkItems(workItems, for: transferId)
}
}
for (workItem, index) in scheduledItems {
let delayMs = index * plan.spacingMs
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem)
}
return true
}
// MARK: - Fragmentation (Required for messages > BLE MTU)
private func markFragmentSent(transferId: String) {
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
switch self.outboundFragmentTransfers.markFragmentSent(transferId: transferId) {
case .progress, .complete:
TransferProgressManager.shared.recordFragmentSent(id: transferId)
case .missing:
return
}
if !self.outboundFragmentTransfers.isActive(transferId) {
self.messageQueue.async { [weak self] in
self?.startNextPendingTransferIfNeeded()
}
}
}
}
private func startNextPendingTransferIfNeeded() {
let results = collectionsQueue.sync(flags: .barrier) {
outboundFragmentTransfers.reservePendingStarts(maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers)
}
for result in results {
messageQueue.async { [weak self] in
self?.handleFragmentTransferSubmitResult(result)
}
}
}
private func handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
fragmentHandler.handle(packet, from: peerID)
} else {
messageQueue.async(flags: .barrier) { [weak self] in
self?.fragmentHandler.handle(packet, from: peerID)
}
}
}
/// Builds the fragment handler environment. All queue hops stay here so
/// `BLEFragmentHandler` remains queue-agnostic and synchronously testable.
private func makeFragmentHandlerEnvironment() -> BLEFragmentHandlerEnvironment {
BLEFragmentHandlerEnvironment(
localPeerID: { [weak self] in
self?.myPeerID ?? PeerID(str: "")
},
trackPacketSeen: { [weak self] packet in
self?.gossipSyncManager?.onPublicPacketSeen(packet)
},
appendFragment: { [weak self] header in
guard let self = self else {
return .stored(header: header, started: false)
}
return self.collectionsQueue.sync(flags: .barrier) {
self.fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: self.maxInFlightAssemblies)
}
},
isAcceptedIngressPayload: { [weak self] packet, innerSender in
self?.isAcceptedIngressPayload(packet, from: innerSender) ?? false
},
processReassembledPacket: { [weak self] packet, peerID in
self?.handleReceivedPacket(packet, from: peerID)
}
)
}
// MARK: Packet Reception
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
// Call directly if already on messageQueue, otherwise dispatch
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
messageQueue.async { [weak self] in
self?.handleReceivedPacket(packet, from: peerID)
}
return
}
let context = BLEReceivePipeline.context(for: packet, localPeerID: myPeerID)
let senderID = context.senderID
let messageID = context.messageID
// Only log non-announce packets to reduce noise
if context.logsHandlingDetails {
// Log packet details for debugging
SecureLogger.debug("📦 Handling packet type \(packet.type) from \(senderID.id.prefix(8))…, messageID: \(messageID.prefix(24))…", category: .session)
}
if dropDuplicatePacketIfNeeded(context: context, messageID: messageID) { return }
// Update peer info without verbose logging - update the peer we received from, not the original sender
updatePeerLastSeen(peerID)
// Track recent traffic timestamps for adaptive behavior; the same
// barrier hop confirms route health for the packet's originator.
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self.recentTrafficTracker.recordPacket(at: Date())
self.sourceRouteFailures.noteInboundActivity(from: senderID)
}
// Per-peer protocol version: originated source routes only use hops
// observed speaking v2 (a v1-only node cannot decode v2 frames).
if packet.version >= 2 {
meshTopology.recordObservedVersion(packet.version, for: packet.senderID)
if peerID != senderID {
meshTopology.recordObservedVersion(packet.version, for: routingData(for: peerID))
}
}
#if os(iOS)
// The maintenance timer is suspended with the app, so a packet arriving
// while backgrounded means the radio woke us — use the wake window to
// run the announce/flush/drain pass the timer would have run.
if !isAppActive {
bleQueue.async { [weak self] in self?.performBackgroundWakeMaintenanceIfStale() }
}
#endif
// Process by type
switch context.messageType {
case .announce:
handleAnnounce(packet, from: senderID)
case .message:
handleMessage(packet, from: senderID)
case .requestSync:
handleRequestSync(packet, from: senderID)
case .noiseHandshake:
handleNoiseHandshake(packet, from: senderID)
case .noiseEncrypted:
handleNoiseEncrypted(packet, from: senderID)
case .fragment:
handleFragment(packet, from: senderID)
case .fileTransfer:
// Broadcast files that fail sender authentication must not spread
// to downstream (possibly older, ungated) nodes; skip the relay
// step below, like invalid board posts and voice frames.
guard handleFileTransfer(packet, from: senderID) else { return }
case .courierEnvelope:
handleCourierEnvelope(packet, from: peerID)
case .groupMessage:
handleGroupMessage(packet, from: senderID)
case .prekeyBundle:
handlePrekeyBundle(packet, from: senderID)
case .boardPost:
// Invalid or deleted posts must not spread; skip the relay step.
guard handleBoardPost(packet, from: senderID) else { return }
case .nostrCarrier:
handleNostrCarrier(packet, from: peerID)
case .voiceFrame:
// Rejected frames (unsigned/stale/spoofed) must not spread; skip
// the relay step below, like invalid board posts.
guard handleVoiceFrame(packet, from: senderID) else { return }
case .ping:
// Rate limiting must key on the ingress link (`peerID`), not the
// packet-claimed sender: pings are unsigned, so `senderID` is
// attacker-controlled and rotating it would reset the budget.
handleMeshPing(packet, fromLink: peerID)
case .pong:
handleMeshPong(packet, from: senderID)
case .leave:
handleLeave(packet, from: senderID)
case .none:
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
}
if forwardAlongRouteIfNeeded(packet) {
return
}
scheduleRelayIfNeeded(packet, senderID: senderID, messageID: messageID)
}
private func dropDuplicatePacketIfNeeded(context: BLEReceivedPacketContext, messageID: String) -> Bool {
guard context.shouldDeduplicate, messageDeduplicator.isDuplicate(messageID) else {
return false
}
if context.logsHandlingDetails {
SecureLogger.debug("⚠️ Duplicate packet ignored: \(messageID.prefix(24))…", category: .session)
}
let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount }
if BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: connectedCount) {
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.scheduledRelays.cancel(messageID: messageID)
}
}
return true
}
private func scheduleRelayIfNeeded(_ packet: BitchatPacket, senderID: PeerID, messageID: String) {
let degree = collectionsQueue.sync { peerRegistry.connectedCount }
let decision = BLEReceivePipeline.relayDecision(
for: packet,
senderID: senderID,
localPeerID: myPeerID,
degree: degree,
highDegreeThreshold: highDegreeThreshold
)
guard decision.shouldRelay else { return }
let work = DispatchWorkItem { [weak self] in
guard let self = self else { return }
self.collectionsQueue.async(flags: .barrier) { [weak self] in
self?.scheduledRelays.remove(messageID: messageID)
}
var relayPacket = packet
relayPacket.ttl = decision.newTTL
self.broadcastPacket(relayPacket)
}
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.scheduledRelays.schedule(work, messageID: messageID)
}
messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work)
}
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
let result = announceHandler.handle(packet, from: peerID)
// A verified announce is the moment a signing key becomes bound to this
// owner's noise key: retry any prekey bundle that raced ahead of it.
if let result, result.isVerified {
drainPendingPrekeyBundles(for: result.peerID)
}
// A verified direct announce proves the sender owns the link it came
// in on: heal any stale binding left by a peer-ID rotation, and
// consolidate duplicate same-role connections onto that link.
if let result, result.isVerified, result.isDirectAnnounce {
rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID)
retireRedundantPeripheralLinks(packet, to: result.peerID)
}
// Bridge courier watch: a verified announce may add a peer whose
// relay-parked drops we should start watching for.
if let result, result.isVerified {
onVerifiedPeerAnnounce?(result.peerID)
}
// Courier work: an announce is the moment we learn a peer's Noise
// static key, so check whether we're carrying mail addressed to them
// (or spray-able mail they could carry). Verified announces only.
guard !courierStore.isEmpty,
let result,
result.isVerified else { return }
let noiseKey = result.announcement.noisePublicKey
let authenticatedIngress = result.isDirectAnnounce
&& canDeliverSecurely(to: result.peerID)
&& isNoiseAuthenticatedIngressLink(for: packet, peerID: result.peerID)
if authenticatedIngress {
// The session was established on this still-bound ingress link.
// A peer-level Noise session alone is not enough: it can outlive
// its physical link while a replay rebinds an attacker's link to
// the victim's ID.
deliverCourierMail(to: result.peerID, noiseKey: noiseKey)
sprayCourierMail(to: result.peerID, noiseKey: noiseKey, isVerifiedPeer: true)
} else {
// Relayed announce, or a direct-looking announce that has not yet
// proved link ownership with Noise: push a speculative copy while
// retaining the durable carried original.
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
if result.isDirectAnnounce,
!hasCurrentNoiseAuthenticatedLink(to: result.peerID) {
if noiseService.hasEstablishedSession(with: result.peerID) {
// A session with no surviving authenticated link is stale;
// force the current link to prove possession again.
noiseService.clearSession(for: result.peerID)
}
if !noiseService.hasSession(with: result.peerID) {
initiateNoiseHandshake(with: result.peerID)
}
}
}
}
/// When a peer relaunches it rotates its ephemeral peer ID, but an
/// already-open BLE connection keeps its old peripheral/central→peerID
/// binding. Until that binding heals, the rotated peer shows up twice in
/// the peer list and its directed traffic on this link is dropped as
/// spoofed. A signature-verified direct announce proves the claimed
/// sender owns the link it arrived on, so rebind the link to the new ID
/// and retire the old identity.
private func rebindLinkAfterVerifiedDirectAnnounce(_ packet: BitchatPacket, to peerID: PeerID) {
guard let link = (collectionsQueue.sync { ingressLinks.link(for: packet) }) else { return }
bleQueue.async { [weak self] in
guard let self else { return }
let linkUUID: String
let previousPeerID: PeerID?
switch link {
case .peripheral(let peripheralUUID):
linkUUID = peripheralUUID
previousPeerID = self.linkStateStore.peerID(forPeripheralID: peripheralUUID)
case .central(let centralUUID):
linkUUID = centralUUID
previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID)
}
guard let previousPeerID, previousPeerID != peerID else { return }
// The signature does not authenticate directness (TTL is excluded
// from signing because relays mutate it), so a "verified direct"
// announce can be a replay of another peer's fresh announce with
// its TTL restored. Contain what a forged rebind could do:
// never steal an identity another live link already owns, and
// allow at most one rebind per link per cooldown window so two
// identities can't fight over a link in a replay flip-flop.
guard self.linkStateStore.links(to: peerID).isEmpty else {
SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: identity already owns another live link", category: .security)
return
}
let now = Date()
self.lastLinkRebindAt = self.lastLinkRebindAt.filter {
now.timeIntervalSince($0.value) < TransportConfig.bleLinkRebindCooldownSeconds
}
guard self.lastLinkRebindAt[linkUUID] == nil else {
SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: rebind cooldown active for this link", category: .security)
return
}
self.lastLinkRebindAt[linkUUID] = now
// A Noise proof belongs to the old physical binding. Never carry
// it across an announce-driven rebind, whose direct TTL is
// replayable; the new owner must complete a fresh handshake.
self.noiseAuthenticatedLinkOwners.removeValue(forKey: link)
switch link {
case .peripheral(let peripheralUUID):
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
case .central(let centralUUID):
self.linkStateStore.bindCentral(centralUUID, to: peerID)
}
SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session)
self.refreshLocalTopology()
// The announce that triggered this rebind was upserted as
// disconnected: the registry ran while the link still belonged
// to the previous ID (the ambiguous state BLEAnnounceHandler
// denies the connected shortcut). The rebind has now
// containment-checked the claim and the identity owns a live
// link, so promote it — otherwise a healed rotation leaves a
// live link that reads as disconnected until the next announce.
self.messageQueue.async { [weak self] in
self?.promoteReboundPeerToConnected(peerID)
}
// Any other peripheral links still bound to the rotated-away ID
// are stale duplicates of the same physical device (its restored
// connections outlived the relaunch that rotated the ID): cancel
// them now instead of leaving ghost links that spray duplicate
// traffic until the inactivity timeout.
self.cancelBoundPeripheralLinks(to: previousPeerID, keeping: linkUUID)
// Retire the rotated-away ID only once its last link is gone; a
// remaining stale link heals the same way or ages out.
guard self.linkStateStore.links(to: previousPeerID).isEmpty else { return }
self.messageQueue.async { [weak self] in
self?.retireRotatedPeer(previousPeerID)
}
}
}
/// After a restore relaunch the same phone can reappear under a fresh
/// peripheral UUID while its restored connection lives on, leaving
/// several live central-role connections to one peer that each carry
/// every packet (field-verified: every voice frame arrived 2-3x). A
/// verified direct announce is the consolidation point: keep the link it
/// proves live (or the peer's most recently bound one) and cancel the
/// rest. Only same-role duplicates are touched — one connection per role
/// is the normal dual-role topology — and only connections we own as
/// central: the peer's central subscriptions on our peripheral manager
/// are its connections to cancel, and it runs this same policy.
///
/// Directness is forgeable (TTL is unsigned), so a replayed announce
/// could nominate the replayer's link as the survivor. Containment
/// mirrors the rotation rebind: only links already BOUND to the peer are
/// retired (announce-evidenced, never pre-announce links), at most one
/// retirement per peer per cooldown window, and the peer keeps a live
/// link either way.
private func retireRedundantPeripheralLinks(_ packet: BitchatPacket, to peerID: PeerID) {
let ingressLink = collectionsQueue.sync { ingressLinks.link(for: packet) }
bleQueue.async { [weak self] in
guard let self else { return }
let now = Date()
self.lastRedundantLinkRetirementAt = self.lastRedundantLinkRetirementAt.filter {
now.timeIntervalSince($0.value) < TransportConfig.bleLinkRebindCooldownSeconds
}
guard self.lastRedundantLinkRetirementAt[peerID] == nil else { return }
var ingressPeripheralUUID: String?
if case .peripheral(let uuid) = ingressLink {
ingressPeripheralUUID = uuid
}
guard let keptUUID = BLERedundantLinkPolicy.keptPeripheralUUID(
ingressPeripheralUUID: ingressPeripheralUUID,
mostRecentlyBoundUUID: self.linkStateStore.preferredPeripheralBindings[peerID],
links: self.peripheralLinkPolicySnapshot(),
peerID: peerID
) else { return }
self.lastRedundantLinkRetirementAt[peerID] = now
// The survivor becomes the peer's reverse-mapped link so directed
// sends follow the consolidation.
self.linkStateStore.bindPeripheral(keptUUID, to: peerID)
self.cancelBoundPeripheralLinks(to: peerID, keeping: keptUUID)
self.refreshLocalTopology()
}
}
/// Cancels our central-role connections whose link is bound to `peerID`,
/// except `keptUUID`. bleQueue only. Each entry is removed from the link
/// store BEFORE cancelling so didDisconnectPeripheral sees no peer
/// binding and skips its peer-disconnect bookkeeping — the peer is still
/// live (on the kept link, or under its rotated identity).
private func cancelBoundPeripheralLinks(to peerID: PeerID, keeping keptUUID: String?) {
let retiring = BLERedundantLinkPolicy.peripheralUUIDsToRetire(
links: peripheralLinkPolicySnapshot(),
peerID: peerID,
keeping: keptUUID ?? ""
)
for uuid in retiring {
guard let state = linkStateStore.state(forPeripheralID: uuid) else { continue }
collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.discardAll(for: uuid)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid))
_ = linkStateStore.removePeripheral(uuid)
SecureLogger.info(
"🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")",
category: .session
)
centralManager?.cancelPeripheralConnection(state.peripheral)
}
}
/// bleQueue only (reads the link store).
private func peripheralLinkPolicySnapshot() -> [BLERedundantLinkPolicy.PeripheralLink] {
linkStateStore.peripheralStates.map {
BLERedundantLinkPolicy.PeripheralLink(
uuid: $0.peripheral.identifier.uuidString,
peerID: $0.peerID,
isConnected: $0.isConnected,
hasCharacteristic: $0.characteristic != nil
)
}
}
/// After a successful verified rebind the new identity owns a live link,
/// but its announce was stored disconnected (the link was still bound to
/// the rotated-away ID when the registry upsert ran). Flip it to
/// connected and republish so routing and the peer list see the healed
/// link. The `.peerConnected` UI event already fired from the announce
/// path (new/reconnected + direct), so only list state needs refreshing.
private func promoteReboundPeerToConnected(_ peerID: PeerID) {
let promoted = collectionsQueue.sync(flags: .barrier) {
peerRegistry.markConnected(peerID)
}
guard promoted else { return }
refreshLocalTopology()
publishFullPeerData()
notifyUI { [weak self] in
guard let self else { return }
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
}
}
/// Rotation is an implicit leave of the old identity: drop it immediately
/// instead of letting a ghost duplicate linger for the reachability
/// retention window.
private func retireRotatedPeer(_ peerID: PeerID) {
let removed = collectionsQueue.sync(flags: .barrier) {
peerRegistry.remove(peerID) != nil
}
guard removed else { return }
gossipSyncManager?.removeAnnouncementForPeer(peerID)
refreshLocalTopology()
notifyUI { [weak self] in
guard let self else { return }
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
self.deliverTransportEvent(.peerDisconnected(peerID))
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
}
}
/// Builds the announce handler environment. All queue hops stay here so
/// `BLEAnnounceHandler` remains queue-agnostic and synchronously testable.
private func makeAnnounceHandlerEnvironment() -> BLEAnnounceHandlerEnvironment {
BLEAnnounceHandlerEnvironment(
localPeerID: { [weak self] in
self?.myPeerID ?? PeerID(str: "")
},
messageTTL: messageTTL,
now: { Date() },
existingNoisePublicKey: { [weak self] peerID in
guard let self = self else { return nil }
return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
},
verifySignature: { [weak self] packet, signingPublicKey in
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
},
linkState: { [weak self] peerID in
self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false)
},
linkBoundToOtherPeer: { [weak self] packet, peerID in
// Reads the CURRENT binding — i.e. the state before
// rebindLinkAfterVerifiedDirectAnnounce (which runs after the
// handler) may steal the link and promote the new owner to
// connected. See the caller in BLEAnnounceHandler for why the
// residual forged-presence window this leaves is accepted.
guard let self else { return false }
guard let link = (self.collectionsQueue.sync { self.ingressLinks.link(for: packet) }) else { return false }
let boundPeerID: PeerID? = self.readLinkState { store in
switch link {
case .peripheral(let peripheralUUID):
return store.peerID(forPeripheralID: peripheralUUID)
case .central(let centralUUID):
return store.peerID(forCentralUUID: centralUUID)
}
}
guard let boundPeerID else { return false }
return boundPeerID != peerID
},
withRegistryBarrier: { [weak self] body in
self?.collectionsQueue.sync(flags: .barrier) { body() }
},
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
// Called from inside withRegistryBarrier; access registry directly.
self?.peerRegistry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now,
capabilities: announcement.capabilities ?? [],
bridgeGeohash: announcement.bridgeGeohash
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
},
shouldEmitReconnectLog: { [weak self] peerID, now in
// Called from inside withRegistryBarrier; access debouncer directly.
self?.reconnectLogDebouncer.shouldEmit(
peerID: peerID,
now: now,
minimumInterval: TransportConfig.bleReconnectLogDebounceSeconds
) ?? false
},
updateTopology: { [weak self] peerID, neighbors in
self?.meshTopology.updateNeighbors(for: peerID.routingData, neighbors: neighbors)
},
persistIdentity: { [weak self] announcement in
self?.identityManager.upsertCryptographicIdentity(
fingerprint: announcement.noisePublicKey.sha256Fingerprint(),
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
claimedNickname: announcement.nickname
)
},
dedupContains: { [weak self] id in
self?.messageDeduplicator.contains(id) ?? true
},
dedupMarkProcessed: { [weak self] id in
self?.messageDeduplicator.markProcessed(id)
},
deliverAnnounceUIEvents: { [weak self] peerID, notifyPeerConnected, scheduleInitialSync in
// Single main-actor hop so event order is guaranteed:
// .peerConnected → initial sync scheduling → .peerListUpdated.
self?.notifyUI { [weak self] in
guard let self = self else { return }
if notifyPeerConnected {
self.deliverTransportEvent(.peerConnected(peerID))
}
if scheduleInitialSync {
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
}
// Get current peer list (after addition)
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
self.requestPeerDataPublish()
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
}
},
trackPacketSeen: { [weak self] packet in
self?.gossipSyncManager?.onPublicPacketSeen(packet)
},
sendAnnounceBack: { [weak self] in
self?.sendAnnounce(forceSend: true)
},
scheduleAfterglow: { [weak self] delay in
self?.messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.sendAnnounce(forceSend: true)
}
}
)
}
// MARK: - Board (geohash bulletin board)
/// Validates and stores an incoming board post or tombstone. Returns
/// whether the packet is worth relaying onward.
private func handleBoardPost(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
guard let wire = BoardWire.decode(from: packet.payload) else {
SecureLogger.warning("⚠️ Malformed board packet from \(peerID.id.prefix(8))…", category: .session)
return false
}
// Posts are self-authenticating: the payload embeds the author's
// Ed25519 key and signature, so verification does not depend on the
// author still being around to announce.
guard wire.verifySignature() else {
if logRateLimiter.shouldLog(key: "board-sig:\(peerID.id)") {
SecureLogger.warning("🚫 Dropping board packet with invalid signature from \(peerID.id.prefix(8))…", category: .security)
}
return false
}
switch boardStore.ingest(wire, packet: packet) {
case .accepted, .duplicate:
return true
case .rejected:
return false
}
}
/// Broadcasts a pre-signed board payload (post or tombstone) built by the
/// board manager, and ingests it locally so it shows up on our own board
/// and joins gossip sync immediately.
func sendBoardPayload(_ payload: Data) {
guard let wire = BoardWire.decode(from: payload), wire.verifySignature() else {
SecureLogger.error("❌ Refusing to send invalid board payload", category: .session)
return
}
messageQueue.async { [weak self] in
guard let self = self else { return }
let basePacket = BitchatPacket(
type: MessageType.boardPost.rawValue,
senderID: Data(hexString: self.myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: self.messageTTL
)
guard let signedPacket = self.noiseService.signPacket(basePacket) else {
SecureLogger.error("❌ Failed to sign board packet", category: .security)
return
}
// Pre-mark our own broadcast as processed to avoid handling a relayed self copy
let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
self.messageDeduplicator.markProcessed(dedupID)
self.boardStore.ingest(wire, packet: signedPacket)
self.broadcastPacket(signedPacket)
}
}
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) {
// REQUEST_SYNC is link-local by design (always sent with ttl 0): a
// nonzero TTL means a crafted or relayed request, and answering one
// would let a single small packet fan a full store replay out of
// every node it reaches.
guard packet.ttl == 0 else {
if logRateLimiter.shouldLog(key: "sync-ttl:\(peerID.id)") {
SecureLogger.warning("🚫 Dropping REQUEST_SYNC with nonzero TTL from \(peerID.id.prefix(8))…", category: .security)
}
return
}
// A response can replay the entire gossip store, so require proof the
// requester owns the claimed sender ID: the request must verify
// against the signing key from that peer's announce.
let signingKey = collectionsQueue.sync { peerRegistry.info(for: peerID)?.signingPublicKey }
guard let signingKey, noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
if logRateLimiter.shouldLog(key: "sync-sig:\(peerID.id)") {
SecureLogger.warning("🚫 Dropping REQUEST_SYNC without verifiable signature from \(peerID.id.prefix(8))…", category: .security)
}
return
}
guard let req = RequestSyncPacket.decode(from: packet.payload) else {
SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .session)
return
}
gossipSyncManager?.handleRequestSync(from: peerID, request: req)
}
// Mention parsing moved to ChatViewModel
private func handleMessage(_ packet: BitchatPacket, from peerID: PeerID) {
publicMessageHandler.handle(packet, from: peerID)
}
/// Builds the public-message handler environment. All queue hops stay here
/// so `BLEPublicMessageHandler` remains queue-agnostic and synchronously
/// testable.
private func makePublicMessageHandlerEnvironment() -> BLEPublicMessageHandlerEnvironment {
BLEPublicMessageHandlerEnvironment(
localPeerID: { [weak self] in
self?.myPeerID ?? PeerID(str: "")
},
localNickname: { [weak self] in
self?.myNickname ?? ""
},
now: { Date() },
peersSnapshot: { [weak self] in
guard let self = self else { return [:] }
return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
},
verifyPacketSignature: { [weak self] packet, signingPublicKey in
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
},
signedSenderDisplayName: { [weak self] packet, peerID in
self?.signedSenderDisplayName(for: packet, from: peerID)
},
trackPacketSeen: { [weak self] packet in
self?.gossipSyncManager?.onPublicPacketSeen(packet)
},
linkState: { [weak self] peerID in
self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false)
},
takeSelfBroadcastMessageID: { [weak self] packet in
// Caller is on messageQueue, where the tracker is owned.
self?.selfBroadcastTracker.takeMessageID(for: packet)
},
deliverPublicMessage: { [weak self] peerID, nickname, content, timestamp, messageID in
// Single main-actor hop delivering `.publicMessageReceived`.
self?.notifyUI { [weak self] in
self?.deliverTransportEvent(
.publicMessageReceived(
peerID: peerID,
nickname: nickname,
content: content,
timestamp: timestamp,
messageID: messageID
)
)
}
}
)
}
/// Group broadcasts are opaque ciphertext to this layer: track them for
/// gossip backfill and hand the payload to the UI layer, where the group
/// coordinator decrypts and authenticates against the roster. Non-members
/// still relay (generic broadcast relay path) but never decode.
private func handleGroupMessage(_ packet: BitchatPacket, from _: PeerID) {
let isBroadcastRecipient: Bool = {
guard let recipient = packet.recipientID else { return true }
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
}()
guard isBroadcastRecipient, !packet.payload.isEmpty else { return }
gossipSyncManager?.onPublicPacketSeen(packet)
let payload = packet.payload
let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
notifyUI { [weak self] in
self?.deliverTransportEvent(.groupMessageReceived(payload: payload, timestamp: timestamp))
}
}
/// Inbound public live-voice packet: broadcast-only, freshness-gated, and
/// signature-verified against the claimed sender's announce (mirrors the
/// public-message identity gate — `senderID` is attacker-controlled, so a
/// valid packet signature is required before any audio reaches the UI).
/// Returns whether the packet was accepted; rejected packets must not be
/// relayed either, or spoofed 0x29 floods would still amplify.
private func handleVoiceFrame(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
guard peerID != myPeerID else { return false }
guard BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID) else { return false }
guard !BLEPacketFreshnessPolicy.isStale(
timestampMilliseconds: packet.timestamp,
now: Date(),
maxAgeSeconds: TransportConfig.pttPublicFrameMaxAgeSeconds
) else { return false }
let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID }
let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey
let verifiedViaRegistry = registrySigningKey.map { noiseService.verifyPacketSignature(packet, publicKey: $0) } ?? false
let signedDisplayName = verifiedViaRegistry ? nil : signedSenderDisplayName(for: packet, from: peerID)
guard verifiedViaRegistry || signedDisplayName != nil else {
SecureLogger.warning("🚫 Dropping voice frame with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", category: .security)
return false
}
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: myPeerID,
localNickname: myNickname,
peers: peersSnapshot,
allowConnectedUnverified: false
) ?? signedDisplayName else {
return false
}
let payload = packet.payload
let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
notifyUI { [weak self] in
self?.deliverTransportEvent(.publicVoiceFrameReceived(
peerID: peerID,
nickname: senderNickname,
payload: payload,
timestamp: timestamp
))
}
return true
}
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
noisePacketHandler.handleHandshake(packet, from: peerID)
if !wasEstablished, noiseService.hasEstablishedSession(with: peerID) {
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
}
}
private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
noisePacketHandler.handleEncrypted(packet, from: peerID)
}
/// Builds the Noise packet handler environment. All queue hops and
/// `noiseService` crypto calls stay here so `BLENoisePacketHandler`
/// remains queue-agnostic and synchronously testable.
private func makeNoisePacketHandlerEnvironment() -> BLENoisePacketHandlerEnvironment {
BLENoisePacketHandlerEnvironment(
localPeerID: { [weak self] in
self?.myPeerID ?? PeerID(str: "")
},
localPeerIDData: { [weak self] in
self?.myPeerIDData ?? Data()
},
messageTTL: messageTTL,
now: { Date() },
processHandshakeMessage: { [weak self] peerID, message in
try self?.noiseService.processHandshakeMessage(from: peerID, message: message)
},
hasNoiseSession: { [weak self] peerID in
self?.noiseService.hasSession(with: peerID) ?? false
},
initiateHandshake: { [weak self] peerID in
self?.initiateNoiseHandshake(with: peerID)
},
broadcastPacket: { [weak self] packet in
self?.broadcastPacket(packet)
},
updatePeerLastSeen: { [weak self] peerID in
self?.updatePeerLastSeen(peerID)
},
decrypt: { [weak self] payload, peerID in
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
return try self.noiseService.decrypt(payload, from: peerID)
},
clearSession: { [weak self] peerID in
self?.noiseService.clearSession(for: peerID)
},
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
// Single main-actor hop delivering `.noisePayloadReceived`.
self?.notifyUI { [weak self] in
self?.deliverTransportEvent(.noisePayloadReceived(
peerID: peerID,
type: type,
payload: payload,
timestamp: timestamp
))
}
}
)
}
// MARK: Helper Functions
private func sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) {
let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in
pendingNoiseSessionQueues.takeTypedPayloads(for: peerID)
}
guard !payloads.isEmpty else { return }
SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session)
for payload in payloads {
do {
broadcastPacket(try makeEncryptedNoisePacket(payload, to: peerID))
} catch {
SecureLogger.error("❌ Failed to send pending noise payload to \(peerID.id.prefix(8))…: \(error)")
}
}
}
private func updatePeerLastSeen(_ peerID: PeerID) {
// Use async to avoid deadlock - we don't need immediate consistency for last seen updates
collectionsQueue.async(flags: .barrier) {
self.peerRegistry.updateLastSeen(peerID, at: Date())
}
}
// Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window
@MainActor
private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) {
if disconnectNotifyDebouncer.shouldEmit(
peerID: peerID,
now: Date(),
minimumInterval: TransportConfig.bleDisconnectNotifyDebounceSeconds
) {
deliverTransportEvent(.peerDisconnected(peerID))
}
}
// NEW: Publish peer snapshots to subscribers and notify Transport delegates
private func publishFullPeerData() {
let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
peerRegistry.transportSnapshots(selfNickname: myNickname)
}
// Notify UI on MainActor via delegate
Task { @MainActor [weak self] in
self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers)
}
}
// MARK: Consolidated Maintenance
private func performMaintenance() {
maintenanceCounter += 1
lastMaintenanceAt = Date()
let now = Date()
let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount }
let elapsed = announceThrottle.elapsed(since: now)
let recentSeen = collectionsQueue.sync { () -> Bool in
recentTrafficTracker.hasTraffic(within: 5.0, now: now)
}
let hasNoPeers = collectionsQueue.sync { peerRegistry.isEmpty }
let plan = BLEMaintenancePolicy.plan(
cycle: maintenanceCounter,
connectedCount: connectedCount,
peerRegistryIsEmpty: hasNoPeers,
elapsedSinceLastAnnounce: elapsed,
hasRecentTraffic: recentSeen
)
if plan.shouldSendAnnounce {
sendAnnounce(forceSend: true)
}
if plan.shouldEnsureAdvertising {
// Ensure we're advertising as peripheral
if let pm = peripheralManager, pm.state == .poweredOn && !pm.isAdvertising {
pm.startAdvertising(buildAdvertisementData())
}
}
// Update scanning duty-cycle based on connectivity
updateScanningDutyCycle(connectedCount: connectedCount)
updateRSSIThreshold(connectedCount: connectedCount)
// Drain the connection candidate queue. Weak-RSSI discoveries are
// enqueued rather than connected immediately, and the event-driven
// drains (disconnect/failure/timeout) never fire when we're idle —
// without this, an isolated node surrounded only by weak (distant)
// peers would queue them all and never connect to anyone.
tryConnectFromQueue()
// Check peer connectivity every cycle for snappier UI updates
checkPeerConnectivity()
// Every 30 seconds (3 cycles): Cleanup
if plan.shouldRunCleanup {
performCleanup()
}
// Attempt to flush any spooled directed messages periodically (~every 5 seconds)
if plan.shouldFlushDirectedSpool {
flushDirectedSpool()
}
// Periodically attempt to drain pending notifications and writes as backup
// in case callbacks are missed or delayed (every maintenance cycle = 5 seconds)
drainPendingNotificationsIfPossible()
drainAllPendingWrites()
// No rotating alias: nothing to refresh
// Reset counter to prevent overflow (every 60 seconds)
if plan.shouldResetCounter {
maintenanceCounter = 0
}
}
#if os(iOS)
/// Catch-up maintenance for background wake windows (bleQueue-confined).
/// Rate-limited to the normal maintenance cadence so a burst of inbound
/// packets during one wake still runs at most one extra pass.
private func performBackgroundWakeMaintenanceIfStale() {
guard meshBackgroundEnabled,
!isAppActive,
Date().timeIntervalSince(lastMaintenanceAt) >= TransportConfig.bleMaintenanceInterval else { return }
performMaintenance()
}
#endif
private func checkPeerConnectivity() {
let now = Date()
let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { peerRegistry.peerIDs }
var cachedLinkStates: [PeerID: BLEPeerLinkPresence] = [:]
for peerID in peerIDsForLinkState {
let state = linkState(for: peerID)
cachedLinkStates[peerID] = BLEPeerLinkPresence(
hasPeripheral: state.hasPeripheral,
hasCentral: state.hasCentral
)
}
let changes = collectionsQueue.sync(flags: .barrier) {
peerRegistry.reconcileConnectivity(now: now, linkStates: cachedLinkStates)
}
for removedPeer in changes.removedPeers {
SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(removedPeer.peerID.id.prefix(8))… (\(removedPeer.nickname))", category: .session)
gossipSyncManager?.removeAnnouncementForPeer(removedPeer.peerID)
}
// Update UI if there were direct disconnections or offline removals
if !changes.disconnectedPeerIDs.isEmpty || !changes.removedPeers.isEmpty {
notifyUI { [weak self] in
guard let self else { return }
// Get current peer list (after removal)
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
for peerID in changes.disconnectedPeerIDs {
self.deliverTransportEvent(.peerDisconnected(peerID))
}
// Publish snapshots so UnifiedPeerService updates connection/reachability icons
self.requestPeerDataPublish()
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
}
}
// Refresh local topology to keep our own entry fresh and sync any changes
refreshLocalTopology()
// Prune stale topology nodes (using safe retention window)
meshTopology.prune(olderThan: 60.0)
}
private func performCleanup() {
let now = Date()
// Clean old processed messages efficiently
messageDeduplicator.cleanup()
// Clean old fragments (> configured seconds old), then ask peers for
// the specific fragment streams whose reassembly has stalled instead
// of waiting for the next periodic GCS fragment round.
let stalledFragmentIDs = collectionsQueue.sync(flags: .barrier) { () -> [Data] in
let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds)
fragmentAssemblyBuffer.removeExpired(before: cutoff)
sourceRouteFailures.prune(now: now)
return fragmentAssemblyBuffer.stalledBroadcastFragmentIDs(
stalledAfter: TransportConfig.bleFragmentResyncStallSeconds,
retryAfter: TransportConfig.bleFragmentResyncRetrySeconds,
now: now
)
}
if !stalledFragmentIDs.isEmpty {
gossipSyncManager?.requestMissingFragments(fragmentIDs: stalledFragmentIDs)
}
// Clean old connection timeout backoff entries (> window)
let timeoutCutoff = now.addingTimeInterval(-TransportConfig.bleConnectTimeoutBackoffWindowSeconds)
connectionScheduler.pruneConnectionTimeouts(before: timeoutCutoff)
// Clean up stale scheduled relays that somehow persisted (> 2s)
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
// Nothing to compare times to; just cap the size defensively
self.scheduledRelays.removeAllIfOverCapacity(512)
}
// Clean ingress link records older than configured seconds
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
let cutoff = now.addingTimeInterval(-TransportConfig.bleIngressRecordLifetimeSeconds)
if !self.ingressLinks.isEmpty {
self.ingressLinks.prune(before: cutoff)
}
// Clean expired directed spooled items
self.pendingDirectedRelays.pruneExpired(
now: now,
window: TransportConfig.bleDirectedSpoolWindowSeconds
)
}
messageQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
guard !self.selfBroadcastTracker.isEmpty else { return }
let cutoff = now.addingTimeInterval(-TransportConfig.messageDedupMaxAgeSeconds)
self.selfBroadcastTracker.prune(before: cutoff)
}
}
private func updateScanningDutyCycle(connectedCount: Int) {
guard let central = centralManager, central.state == .poweredOn else { return }
// Duty cycle only when app is active and at least one peer connected
#if os(iOS)
let active = isAppActive
#else
let active = true
#endif
// Force full-time scanning if we have very few neighbors or very recent traffic
let hasRecentTraffic: Bool = collectionsQueue.sync {
recentTrafficTracker.hasTraffic(
within: TransportConfig.bleRecentTrafficForceScanSeconds,
now: Date()
)
}
let scanPlan = BLEScanDutyPolicy.plan(
dutyEnabled: dutyEnabled,
appIsActive: active,
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 timer to toggle scanning on/off
let t = DispatchSource.makeTimerSource(queue: bleQueue)
// Start with scanning ON; we'll turn OFF after onDuration
if !central.isScanning { startScanning() }
dutyActive = true
t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)
t.setEventHandler { [weak self] in
guard let self = self, let c = self.centralManager else { return }
if self.dutyActive {
// Turn OFF scanning for offDuration
if c.isScanning { c.stopScan() }
self.dutyActive = false
// Schedule turning back ON after offDuration
self.bleQueue.asyncAfter(deadline: .now() + self.dutyOffDuration) {
if self.centralManager?.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() }
}
}
private func updateRSSIThreshold(connectedCount: Int) {
connectionScheduler.updateRSSIThreshold(
connectedCount: connectedCount,
connectedOrConnectingLinkCount: linkStateStore.connectedOrConnectingPeripheralCount,
now: Date()
)
}
}