Mesh robustness optimizations (#463)

* Mesh robustness: link-aware fragmentation, relay jitter, connection budget, adaptive scanning; fix BLE queue deadlock

- Link-aware fragmentation using per-link write/notify limits
- Directed fragments for one-to-one; exclude from relays
- Add relay jitter with cancel-on-duplicate to reduce floods
- Cap central links and rate-limit connect attempts with queue
- Foreground duty-cycled scanning when connected; continuous when isolated
- Fix deadlock by avoiding sync on BLE queue when already on it
- Silence Keychain var->let warnings

* Mesh: add relay jitter; dynamic RSSI threshold; candidate scoring/backoff; fix misplaced lines in BLEService extension

* macOS UI: restore mesh peer list when geohash feature is iOS-only by moving mesh list outside iOS switch (ContentView)

* ContentView: add macOS mesh peer list under #else to fix missing People section on macOS

* Refactor People section: extract MeshPeerList and GeohashPeopleList into separate views to reduce SwiftUI type-checking complexity; integrate in ContentView

* BLE: prevent re-fragmentation loops; UI: darker mesh blue + first-row spacing in peer list

* Mesh flood/battery: probabilistic relays, adaptive TTL caps, fragment pacing + scan pause, assembly cap; enable relayed DMs with direct-first then flood fallback.

* Refactor BLEService broadcast: split pad policy, encrypted/direct-first handler, and link-aware sender; simplify broadcastPacket to delegate by type.

* Extract relay decision into RelayController with RelayDecision; simplify handleReceivedPacket to use it.

* Move RelayController and RelayDecision into their own file under Services; keep BLEService leaner.

* UI: adjust mesh blue to a darker, less purple tone; apply consistently for count and #mesh badge.

* Project: include RelayController.swift in target and sync project file changes.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-08-20 16:27:49 +02:00
committed by GitHub
co-authored by jack
parent 3074fa0fcb
commit 496972dcc9
8 changed files with 755 additions and 398 deletions
+479 -183
View File
@@ -21,9 +21,13 @@ final class BLEService: NSObject {
#endif
static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
private let maxFragmentSize = 469 // 512 MTU - headers
// Default per-fragment chunk size when link limits are unknown
private let defaultFragmentSize = 469 // ~512 MTU minus protocol overhead
private let maxMessageLength = 10_000
private let messageTTL: UInt8 = 7
// Flood/battery controls
private let maxInFlightAssemblies = 128 // cap concurrent fragment assemblies
private let highDegreeThreshold = 6 // for adaptive TTL/probabilistic relays
// MARK: - Core State (5 Essential Collections)
@@ -82,7 +86,7 @@ final class BLEService: NSObject {
// MARK: - Identity
var myPeerID: String = ""
var myNickname: String = "Anonymous"
var myNickname: String = "anon"
private let noiseService = NoiseEncryptionService()
// MARK: - Advertising Privacy
@@ -94,6 +98,7 @@ final class BLEService: NSObject {
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>()
// Queue for messages pending handshake completion
private var pendingMessagesAfterHandshake: [String: [(content: String, messageID: String)]] = [:]
@@ -103,11 +108,54 @@ final class BLEService: NSObject {
// Accumulate long write chunks per central until a full frame decodes
private var pendingWriteBuffers: [String: Data] = [:]
// Relay jitter scheduling to reduce redundant floods
private var scheduledRelays: [String: DispatchWorkItem] = [:]
// Track short-lived traffic bursts to adapt announces/scanning under load
private var recentPacketTimestamps: [Date] = []
// MARK: - Maintenance Timer
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
private var maintenanceCounter = 0 // Track maintenance cycles
// MARK: - Connection budget & scheduling (central role)
private let maxCentralLinks = 6
private let connectRateLimitInterval: TimeInterval = 0.5
private var lastGlobalConnectAttempt: Date = .distantPast
private struct ConnectionCandidate {
let peripheral: CBPeripheral
let rssi: Int
let name: String
let isConnectable: Bool
let discoveredAt: Date
}
private var connectionCandidates: [ConnectionCandidate] = []
private var failureCounts: [String: Int] = [:] // Peripheral UUID -> failures
private var lastIsolatedAt: Date? = nil
private var dynamicRSSIThreshold: Int = -90
// MARK: - Adaptive scanning duty-cycle
private var scanDutyTimer: DispatchSourceTimer?
private var dutyEnabled: Bool = true
private var dutyOnDuration: TimeInterval = 5
private var dutyOffDuration: TimeInterval = 10
private var dutyActive: Bool = false
// MARK: - Link capability snapshots (thread-safe via bleQueue)
private func snapshotPeripheralStates() -> [PeripheralState] {
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
return Array(peripherals.values)
} else {
return bleQueue.sync { Array(peripherals.values) }
}
}
private func snapshotSubscribedCentrals() -> ([CBCentral], [String: String]) {
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
return (self.subscribedCentrals, self.centralToPeerID)
} else {
return bleQueue.sync { (self.subscribedCentrals, self.centralToPeerID) }
}
}
// MARK: - Peer snapshots publisher (non-UI convenience)
private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>()
@@ -201,6 +249,9 @@ final class BLEService: NSObject {
)
#endif
// Tag BLE queue for re-entrancy detection
bleQueue.setSpecific(key: bleQueueKey, value: ())
// Initialize BLE on background queue to prevent main thread blocking
// This prevents app freezes during BLE operations
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
@@ -229,6 +280,8 @@ final class BLEService: NSObject {
deinit {
maintenanceTimer?.cancel()
scanDutyTimer?.cancel()
scanDutyTimer = nil
centralManager?.stopScan()
peripheralManager?.stopAdvertising()
#if os(iOS)
@@ -337,6 +390,8 @@ final class BLEService: NSObject {
// Stop timer
maintenanceTimer?.cancel()
maintenanceTimer = nil
scanDutyTimer?.cancel()
scanDutyTimer = nil
centralManager?.stopScan()
peripheralManager?.stopAdvertising()
@@ -722,168 +777,130 @@ final class BLEService: NSObject {
// MARK: - Packet Broadcasting
private func broadcastPacket(_ packet: BitchatPacket) {
// Balanced privacy: pad sensitive payloads (Noise handshake/encrypted),
// keep public/announce/leave unpadded to reduce airtime.
let padForBLE: Bool = {
if let t = MessageType(rawValue: packet.type) {
switch t {
case .noiseEncrypted, .noiseHandshake:
return true
default:
return false
}
}
return false
}()
// Encode once using a small per-type padding policy, then delegate by type
let padForBLE = padPolicy(for: packet.type)
guard let data = packet.toBinaryData(padding: padForBLE) else {
SecureLogger.log("❌ Failed to convert packet to binary data", category: SecureLogger.session, level: .error)
return
}
// Only log broadcasts for non-announce packets
// Log encrypted and relayed packets for debugging
if packet.type == MessageType.noiseEncrypted.rawValue {
SecureLogger.log("📡 Encrypted packet to \(packet.recipientID?.hexEncodedString() ?? "unknown")",
category: SecureLogger.session, level: .debug)
} else if packet.ttl < messageTTL {
// Relayed packet
}
// Check if application-level fragmentation needed for large messages
// (CoreBluetooth only handles ATT-level fragmentation for single writes)
if data.count > 512 && packet.type != MessageType.fragment.rawValue {
sendFragmentedPacket(packet, pad: padForBLE)
sendEncrypted(packet, data: data, pad: padForBLE)
return
}
// For private encrypted messages (not handshakes), send to specific peer only
// Handshakes need broader delivery to establish encryption
if packet.type == MessageType.noiseEncrypted.rawValue,
let recipientID = packet.recipientID {
let recipientPeerID = recipientID.hexEncodedString()
var sentEncrypted = false
// Check routing availability (only log if there's an issue)
let hasPeripheral = peerToPeripheralUUID[recipientPeerID] != nil
let hasCentral = centralToPeerID.values.contains(recipientPeerID)
// Try to send directly to the specific peer as peripheral first
if let peripheralUUID = peerToPeripheralUUID[recipientPeerID],
let state = peripherals[peripheralUUID],
state.isConnected,
let characteristic = state.characteristic {
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
// Successfully routed via peripheral
sentEncrypted = true
sendGenericBroadcast(packet, data: data, pad: padForBLE)
}
// MARK: - Broadcast helpers (single responsibility)
private func padPolicy(for type: UInt8) -> Bool {
switch MessageType(rawValue: type) {
case .noiseEncrypted, .noiseHandshake:
return true
default:
return false
}
}
private func sendEncrypted(_ packet: BitchatPacket, data: Data, pad: Bool) {
guard let recipientID = packet.recipientID else { return }
let recipientPeerID = recipientID.hexEncodedString()
var sentEncrypted = false
// Per-link limits for the specific peer
var peripheralMaxLen: Int?
if let perUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }) {
if let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[perUUID] : bleQueue.sync(execute: { peripherals[perUUID] }) {
peripheralMaxLen = state.peripheral.maximumWriteValueLength(for: .withoutResponse)
}
// Also try notification if peer is connected as central (dual-role support)
if let characteristic = characteristic {
// Find the specific central for this peer
for central in subscribedCentrals {
if centralToPeerID[central.identifier.uuidString] == recipientPeerID {
let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [central]) ?? false
if success {
// Successfully routed via central notification
sentEncrypted = true
} else {
// Queue for retry when notification queue has space
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
// Limit queue size to prevent memory issues
if self.pendingNotifications.count < 20 {
self.pendingNotifications.append((data: data, centrals: [central]))
SecureLogger.log("📋 Queued encrypted packet for retry (notification queue full)",
category: SecureLogger.session, level: .debug)
} else {
SecureLogger.log("⚠️ Pending notification queue full, dropping packet",
category: SecureLogger.session, level: .warning)
}
}
}
}
}
// Do NOT broadcast encrypted messages to all centrals
// Encrypted messages must only go to the intended recipient
}
var centralMaxLen: Int?
do {
let (centrals, mapping) = snapshotSubscribedCentrals()
if let central = centrals.first(where: { mapping[$0.identifier.uuidString] == recipientPeerID }) {
centralMaxLen = central.maximumUpdateValueLength
}
if !sentEncrypted {
// Log detailed routing failure for debugging
SecureLogger.log("⚠️ Failed to route encrypted message to \(recipientPeerID) - peripheral=\(hasPeripheral) central=\(hasCentral)",
category: SecureLogger.session, level: .warning)
}
}
if let pm = peripheralMaxLen, data.count > pm {
let overhead = 13 + 8 + 8 + 13
let chunk = max(64, pm - overhead)
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID)
return
}
// For broadcast messages, use the original simple routing
// This ensures announces can be sent before peer ID mappings are established
var sentToPeripherals = 0
var sentToCentrals = 0
// 1. First try sending as central via writes to connected peripherals
// This is the preferred path when we have direct peripheral connections
for state in peripherals.values where state.isConnected {
if let characteristic = state.characteristic {
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
sentToPeripherals += 1
}
if let cm = centralMaxLen, data.count > cm {
let overhead = 13 + 8 + 8 + 13
let chunk = max(64, cm - overhead)
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID)
return
}
// 2. Also send via notifications to subscribed centrals
// This ensures all connected peers receive the message regardless of their connection role
// Broadcast message types that should go to all peers
// Include handshakes since they need to reach peers to establish encryption
let isBroadcastType = packet.type == MessageType.announce.rawValue ||
packet.type == MessageType.message.rawValue ||
packet.type == MessageType.leave.rawValue ||
packet.type == MessageType.noiseHandshake.rawValue
if isBroadcastType, let characteristic = characteristic, !subscribedCentrals.isEmpty {
// If value exceeds minimum allowed by connected centrals, handle per constraints
let minAllowed = subscribedCentrals.map { $0.maximumUpdateValueLength }.min() ?? 20
// Minimum BitChat frame = 13 (header) + 8 (senderID) = 21 bytes
if minAllowed < 21 {
// Cannot deliver any BitChat frame via notifications on this link; skip notify
SecureLogger.log("⚠️ Skipping notify: central max update length (\(minAllowed)) < 21", category: SecureLogger.session, level: .debug)
} else if data.count > minAllowed {
// Fragment via protocol (preserve chosen padding for BLE)
sendFragmentedPacket(packet, pad: padForBLE)
return
}
let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false
if success {
sentToCentrals = subscribedCentrals.count
if packet.type == MessageType.message.rawValue {
// Broadcast message sent
} else if packet.type == MessageType.noiseHandshake.rawValue {
// Handshake broadcast to centrals
}
} else {
// Notification queue full - queue for retry on handshake and announce packets
if packet.type == MessageType.noiseHandshake.rawValue || packet.type == MessageType.announce.rawValue {
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
if self.pendingNotifications.count < 20 {
self.pendingNotifications.append((data: data, centrals: nil))
let kind = packet.type == MessageType.announce.rawValue ? "announce" : "handshake"
SecureLogger.log("📋 Queued \(kind) packet for retry (notification queue full)",
category: SecureLogger.session, level: .debug)
}
// Direct write via peripheral link
if let peripheralUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }),
let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }),
state.isConnected,
let characteristic = state.characteristic {
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
sentEncrypted = true
}
// Notify via central link (dual-role)
if let characteristic = characteristic, !sentEncrypted {
let (centrals, mapping) = snapshotSubscribedCentrals()
for central in centrals where mapping[central.identifier.uuidString] == recipientPeerID {
let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [central]) ?? false
if success { sentEncrypted = true; break }
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
if self.pendingNotifications.count < 20 {
self.pendingNotifications.append((data: data, centrals: [central]))
SecureLogger.log("📋 Queued encrypted packet for retry (notification queue full)", category: SecureLogger.session, level: .debug)
}
} else {
SecureLogger.log("⚠️ Notification queue full for packet type \(packet.type)",
category: SecureLogger.session, level: .warning)
}
}
}
let totalSent = sentToPeripherals + sentToCentrals
if totalSent == 0 {
// No peers to send to - this is normal when isolated
} else {
// Broadcast sent
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 sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: String?) {
let states = snapshotPeripheralStates()
var minCentralWriteLen: Int?
for s in states where s.isConnected {
let m = s.peripheral.maximumWriteValueLength(for: .withoutResponse)
minCentralWriteLen = minCentralWriteLen.map { min($0, m) } ?? m
}
var snapshotCentrals: [CBCentral] = []
if let _ = characteristic {
let (centrals, _) = snapshotSubscribedCentrals()
snapshotCentrals = centrals
}
var minNotifyLen: Int?
if !snapshotCentrals.isEmpty {
minNotifyLen = snapshotCentrals.map { $0.maximumUpdateValueLength }.min()
}
// Avoid re-fragmenting fragment packets
if packet.type != MessageType.fragment.rawValue,
let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min(),
data.count > minLen {
let overhead = 13 + 8 + 8 + 13
let chunk = max(64, minLen - overhead)
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer)
return
}
// Writes to connected peripherals
for s in states where s.isConnected {
if let ch = s.characteristic {
s.peripheral.writeValue(data, for: ch, type: .withoutResponse)
}
}
// Notify all subscribed centrals
if let ch = characteristic {
_ = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: nil)
}
}
@@ -902,15 +919,31 @@ final class BLEService: NSObject {
// MARK: - Fragmentation (Required for messages > BLE MTU)
private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool) {
private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: String? = nil) {
guard let fullData = packet.toBinaryData(padding: pad) else { return }
// Fragment the unpadded frame; each fragment will be encoded independently
let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
let fragments = stride(from: 0, to: fullData.count, by: maxFragmentSize).map { offset in
Data(fullData[offset..<min(offset + maxFragmentSize, fullData.count)])
let chunk = maxChunk ?? defaultFragmentSize
let safeChunk = max(64, chunk)
let fragments = stride(from: 0, to: fullData.count, by: safeChunk).map { offset in
Data(fullData[offset..<min(offset + safeChunk, fullData.count)])
}
// Lightweight pacing to reduce floods and allow BLE buffers to drain
// Also briefly pause scanning during long fragment trains to save battery
let totalFragments = fragments.count
if totalFragments > 4 {
bleQueue.async { [weak self] in
guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return }
if c.isScanning { c.stopScan() }
// Resume scanning after we expect last fragment to be sent
let expectedMs = min(2000, totalFragments * 8) // ~8ms per fragment
self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in
self?.startScanning()
}
}
}
for (index, fragment) in fragments.enumerated() {
var payload = Data()
payload.append(fragmentID)
@@ -919,18 +952,26 @@ final class BLEService: NSObject {
payload.append(packet.type)
payload.append(fragment)
// Choose recipient for the fragment: directed override if provided
let fragmentRecipient: Data? = {
if let only = directedOnlyPeer { return Data(hexString: only) }
return packet.recipientID
}()
let fragmentPacket = BitchatPacket(
type: MessageType.fragment.rawValue,
senderID: packet.senderID,
recipientID: packet.recipientID,
recipientID: fragmentRecipient,
timestamp: packet.timestamp,
payload: payload,
signature: nil,
ttl: packet.ttl
)
// Send immediately (should be on messageQueue already)
broadcastPacket(fragmentPacket)
// Pace fragments with small jitter to avoid bursts
let delayMs = index * 6 // ~6ms spacing per fragment
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
self?.broadcastPacket(fragmentPacket)
}
}
}
@@ -961,6 +1002,14 @@ final class BLEService: NSObject {
// Store fragment
let key = "\(senderHex):\(fragmentID)"
if incomingFragments[key] == nil {
// Cap in-flight assemblies to prevent memory/battery blowups
if incomingFragments.count >= maxInFlightAssemblies {
// Evict the oldest assembly by timestamp
if let oldest = fragmentMetadata.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
incomingFragments.removeValue(forKey: oldest)
fragmentMetadata.removeValue(forKey: oldest)
}
}
incomingFragments[key] = [:]
fragmentMetadata[key] = (originalType, total, Date())
}
@@ -1014,12 +1063,31 @@ final class BLEService: NSObject {
SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)",
category: SecureLogger.session, level: .debug)
}
// Cancel any pending relay for this message (arrived via another neighbor)
collectionsQueue.async(flags: .barrier) { [weak self] in
if let task = self?.scheduledRelays.removeValue(forKey: messageID) {
task.cancel()
}
}
return // Duplicate ignored
}
// 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
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
let now = Date()
self.recentPacketTimestamps.append(now)
// keep last 100 timestamps within 30s window
let cutoff = now.addingTimeInterval(-30)
if self.recentPacketTimestamps.count > 100 {
self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - 100)
}
self.recentPacketTimestamps.removeAll { $0 < cutoff }
}
// Process by type
switch MessageType(rawValue: packet.type) {
@@ -1047,19 +1115,34 @@ final class BLEService: NSObject {
}
// Relay if TTL > 1 and we're not the original sender
// Do this asynchronously to avoid blocking and potential loops
// BUT: Don't relay private encrypted messages (they have a specific recipient)
let shouldRelay = packet.ttl > 1 &&
senderID != myPeerID &&
packet.type != MessageType.noiseEncrypted.rawValue
if shouldRelay {
messageQueue.async { [weak self] in
// Relay decision and scheduling (extracted via RelayController)
do {
let degree = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }
let decision = RelayController.decide(
ttl: packet.ttl,
senderIsSelf: senderID == myPeerID,
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
degree: degree,
highDegreeThreshold: highDegreeThreshold
)
guard decision.shouldRelay else { return }
let work = DispatchWorkItem { [weak self] in
guard let self = self else { return }
// Remove scheduled task before executing
self.collectionsQueue.async(flags: .barrier) { [weak self] in
_ = self?.scheduledRelays.removeValue(forKey: messageID)
}
var relayPacket = packet
relayPacket.ttl -= 1
// Relaying packet
self?.broadcastPacket(relayPacket)
relayPacket.ttl = decision.newTTL
self.broadcastPacket(relayPacket)
}
// Track the scheduled relay so duplicates can cancel it
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.scheduledRelays[messageID] = work
}
messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work)
}
}
@@ -1479,8 +1562,20 @@ final class BLEService: NSObject {
private func performMaintenance() {
maintenanceCounter += 1
// Always: Send keep-alive announce (every 10 seconds)
sendAnnounce(forceSend: true)
// Adaptive announce: reduce frequency when we have connected peers
let now = Date()
let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }
let elapsed = now.timeIntervalSince(lastAnnounceSent)
if connectedCount == 0 {
// Discovery mode: keep frequent announces (~10s)
if elapsed >= 10.0 { sendAnnounce(forceSend: true) }
} else {
// Connected mode: announce less often; much less in dense networks
let base = connectedCount >= 6 ? 90.0 : 45.0
let jitter = connectedCount >= 6 ? 20.0 : 7.5
let target = base + Double.random(in: -jitter...jitter)
if elapsed >= target { sendAnnounce(forceSend: true) }
}
// If we have no peers, ensure we're scanning and advertising
if peers.isEmpty {
@@ -1490,6 +1585,10 @@ final class BLEService: NSObject {
}
}
// Update scanning duty-cycle based on connectivity
updateScanningDutyCycle(connectedCount: connectedCount)
updateRSSIThreshold(connectedCount: connectedCount)
// Every 20 seconds (2 cycles): Check peer connectivity
if maintenanceCounter % 2 == 0 {
checkPeerConnectivity()
@@ -1567,6 +1666,96 @@ final class BLEService: NSObject {
// Clean old connection timeout backoff entries (> 2 minutes)
let timeoutCutoff = now.addingTimeInterval(-120)
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= timeoutCutoff }
// Clean up stale scheduled relays that somehow persisted (> 2s)
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
if !self.scheduledRelays.isEmpty {
// Nothing to compare times to; just cap the size defensively
if self.scheduledRelays.count > 512 {
self.scheduledRelays.removeAll()
}
}
}
}
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
let shouldDuty = dutyEnabled && active && connectedCount > 0
if shouldDuty {
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
// Adjust duty cycle under dense networks to save battery
if connectedCount >= 6 {
dutyOnDuration = 3
dutyOffDuration = 15
} else {
dutyOnDuration = 5
dutyOffDuration = 10
}
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 {
// Cancel duty cycle and ensure scanning is ON for discovery
scanDutyTimer?.cancel()
scanDutyTimer = nil
if !central.isScanning { startScanning() }
}
}
private func updateRSSIThreshold(connectedCount: Int) {
// Adjust RSSI threshold based on connectivity, candidate pressure, and failures
if connectedCount == 0 {
// Isolated: relax floor slowly to hunt for distant nodes
if lastIsolatedAt == nil { lastIsolatedAt = Date() }
let iso = lastIsolatedAt ?? Date()
let elapsed = Date().timeIntervalSince(iso)
if elapsed > 60 {
dynamicRSSIThreshold = -92
} else {
dynamicRSSIThreshold = -90
}
return
}
lastIsolatedAt = nil
// Base threshold when connected
var threshold = -90
// If we're at budget or queue is large, prefer closer peers
let linkCount = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
if linkCount >= maxCentralLinks || connectionCandidates.count > 20 {
threshold = -85
}
// If we have many recent timeouts, raise further
let recentTimeouts = recentConnectTimeouts.filter { Date().timeIntervalSince($0.value) < 60 }.count
if recentTimeouts >= 3 {
threshold = max(threshold, -80)
}
dynamicRSSIThreshold = threshold
}
}
@@ -1610,26 +1799,62 @@ extension BLEService: CBCentralManagerDelegate {
// Skip if peripheral is not connectable (per advertisement data)
guard isConnectable else { return }
// Skip if signal too weak - prevents connection attempts at extreme range
guard rssiValue > -90 else {
// Too far away, don't attempt connection
// Skip immediate connect if signal too weak for current conditions; enqueue instead
if rssiValue <= dynamicRSSIThreshold {
connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date()))
// Keep list tidy
connectionCandidates.sort { (a, b) in
if a.rssi != b.rssi { return a.rssi > b.rssi }
return a.discoveredAt < b.discoveredAt
}
if connectionCandidates.count > 100 { connectionCandidates.removeLast(connectionCandidates.count - 100) }
return
}
// Budget: limit simultaneous central links (connected + connecting)
let currentCentralLinks = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
if currentCentralLinks >= maxCentralLinks {
// Enqueue as candidate; we'll attempt later as slots open
connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date()))
// Keep candidate list tidy: prefer stronger RSSI, then recency; cap list
connectionCandidates.sort { (a, b) in
if a.rssi != b.rssi { return a.rssi > b.rssi }
return a.discoveredAt < b.discoveredAt
}
if connectionCandidates.count > 100 { connectionCandidates.removeLast(connectionCandidates.count - 100) }
return
}
// Rate limit global connect attempts
let sinceLast = Date().timeIntervalSince(lastGlobalConnectAttempt)
if sinceLast < connectRateLimitInterval {
connectionCandidates.append(ConnectionCandidate(peripheral: peripheral, rssi: rssiValue, name: String(advertisedName), isConnectable: isConnectable, discoveredAt: Date()))
connectionCandidates.sort { (a, b) in
if a.rssi != b.rssi { return a.rssi > b.rssi }
return a.discoveredAt < b.discoveredAt
}
// Schedule a deferred attempt after rate-limit interval
let delay = connectRateLimitInterval - sinceLast + 0.05
bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.tryConnectFromQueue()
}
return
}
// Check if we already have this peripheral
if let state = peripherals[peripheralID] {
if state.isConnected || state.isConnecting {
return // Already connected or connecting
}
// Add backoff for reconnection attempts
if let lastAttempt = state.lastConnectionAttempt {
let timeSinceLastAttempt = Date().timeIntervalSince(lastAttempt)
if timeSinceLastAttempt < 2.0 {
return // Wait at least 2 seconds between connection attempts
}
// Add backoff for reconnection attempts
if let lastAttempt = state.lastConnectionAttempt {
let timeSinceLastAttempt = Date().timeIntervalSince(lastAttempt)
if timeSinceLastAttempt < 2.0 {
return // Wait at least 2 seconds between connection attempts
}
}
}
// Backoff if this peripheral recently timed out connection within the last 15 seconds
if let lastTimeout = recentConnectTimeouts[peripheralID], Date().timeIntervalSince(lastTimeout) < 15 {
@@ -1669,6 +1894,7 @@ extension BLEService: CBCentralManagerDelegate {
CBConnectPeripheralOptionNotifyOnNotificationKey: true
]
central.connect(peripheral, options: options)
lastGlobalConnectAttempt = Date()
// Set a timeout for the connection attempt (slightly longer for reliability)
// Use BLE queue to mutate BLE-related state consistently
@@ -1680,13 +1906,16 @@ extension BLEService: CBCentralManagerDelegate {
// Connection timed out - cancel it
SecureLogger.log("⏱️ Timeout: \(advertisedName)",
category: SecureLogger.session, level: .debug)
central.cancelPeripheralConnection(peripheral)
self.peripherals[peripheralID] = nil
self.recentConnectTimeouts[peripheralID] = Date()
central.cancelPeripheralConnection(peripheral)
self.peripherals[peripheralID] = nil
self.recentConnectTimeouts[peripheralID] = Date()
self.failureCounts[peripheralID, default: 0] += 1
// Try next candidate if any
self.tryConnectFromQueue()
}
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
let peripheralID = peripheral.identifier.uuidString
// Update state to connected
@@ -1705,6 +1934,10 @@ extension BLEService: CBCentralManagerDelegate {
)
}
// Reset backoff state on success
failureCounts[peripheralID] = 0
recentConnectTimeouts.removeValue(forKey: peripheralID)
SecureLogger.log("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: SecureLogger.session, level: .debug)
// Discover services
@@ -1741,6 +1974,8 @@ extension BLEService: CBCentralManagerDelegate {
self?.startScanning()
}
}
// Attempt to fill freed slot from queue
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
// Notify delegate about disconnection on main thread
notifyUI { [weak self] in
@@ -1764,6 +1999,67 @@ extension BLEService: CBCentralManagerDelegate {
peripherals.removeValue(forKey: peripheralID)
SecureLogger.log("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: SecureLogger.session, level: .error)
failureCounts[peripheralID, default: 0] += 1
// Try next candidate
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
}
}
// MARK: - Connection scheduling helpers
extension BLEService {
private func tryConnectFromQueue() {
guard let central = centralManager, central.state == .poweredOn else { return }
// Check budget and rate limit
let current = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
guard current < maxCentralLinks else { return }
let delta = Date().timeIntervalSince(lastGlobalConnectAttempt)
guard delta >= connectRateLimitInterval else {
let delay = connectRateLimitInterval - delta + 0.05
bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() }
return
}
// Pull best candidate by composite score
guard !connectionCandidates.isEmpty else { return }
// compute score: connectable> RSSI > recency, with backoff penalty
func score(_ c: ConnectionCandidate) -> Int {
let uuid = c.peripheral.identifier.uuidString
// Penalty if recently timed out (exponential)
let fails = failureCounts[uuid] ?? 0
let penalty = min(20, (1 << min(4, fails))) // 1,2,4,8,16 cap 16-20
let timeoutRecent = recentConnectTimeouts[uuid]
let timeoutBias = (timeoutRecent != nil && Date().timeIntervalSince(timeoutRecent!) < 60) ? 10 : 0
let base = (c.isConnectable ? 1000 : 0) + (c.rssi + 100) * 2
let rec = -Int(Date().timeIntervalSince(c.discoveredAt) * 10)
return base + rec - penalty - timeoutBias
}
connectionCandidates.sort { score($0) > score($1) }
let candidate = connectionCandidates.removeFirst()
guard candidate.isConnectable else { return }
let peripheral = candidate.peripheral
let peripheralID = peripheral.identifier.uuidString
if peripherals[peripheralID]?.isConnected == true || peripherals[peripheralID]?.isConnecting == true {
// Already in progress; skip
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
return
}
// Initiate connection
peripherals[peripheralID] = PeripheralState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: true,
isConnected: false,
lastConnectionAttempt: Date()
)
peripheral.delegate = self
let options: [String: Any] = [
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
CBConnectPeripheralOptionNotifyOnNotificationKey: true
]
central.connect(peripheral, options: options)
lastGlobalConnectAttempt = Date()
SecureLogger.log("⏩ Queue connect: \(candidate.name) [RSSI:\(candidate.rssi)]", category: SecureLogger.session, level: .debug)
}
}
+2 -2
View File
@@ -127,7 +127,7 @@ class KeychainManager {
private func retrieveData(forKey key: String) -> Data? {
// Base query
var base: [String: Any] = [
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrService as String: service,
@@ -158,7 +158,7 @@ class KeychainManager {
private func delete(forKey key: String) -> Bool {
// Base delete query
var base: [String: Any] = [
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrService as String: service
+47
View File
@@ -0,0 +1,47 @@
import Foundation
// RelayDecision encapsulates a single relay scheduling choice.
struct RelayDecision {
let shouldRelay: Bool
let newTTL: UInt8
let delayMs: Int
}
// RelayController centralizes flood control policy for relays.
struct RelayController {
static func decide(ttl: UInt8,
senderIsSelf: Bool,
isEncrypted: Bool,
isDirectedFragment: Bool,
isHandshake: Bool,
degree: Int,
highDegreeThreshold: Int) -> RelayDecision {
// Suppress obvious non-relays
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
// Degree-aware probability to reduce floods in dense graphs
let baseProb: Double
switch degree {
case 0...2: baseProb = 1.0
case 3...4: baseProb = 0.9
case 5...6: baseProb = 0.7
case 7...9: baseProb = 0.55
default: baseProb = 0.45
}
var prob = baseProb
if isHandshake { prob = max(0.3, baseProb - 0.2) }
// Sample a forwarding decision
let shouldRelay = Double.random(in: 0...1) <= prob
// TTL clamping in dense graphs
let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5
let clamped = max(1, min(ttl, ttlCap))
let newTTL = clamped &- 1
// Short jitter to desynchronize rebroadcasts
let delayMs = Int.random(in: 20...80)
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
}
}