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
+18
View File
@@ -23,6 +23,12 @@
047502AD2E55E8360083520F /* BinaryProtocolPaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502AB2E55E8360083520F /* BinaryProtocolPaddingTests.swift */; }; 047502AD2E55E8360083520F /* BinaryProtocolPaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502AB2E55E8360083520F /* BinaryProtocolPaddingTests.swift */; };
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502AE2E55E8450083520F /* InputValidatorTests.swift */; }; 047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502AE2E55E8450083520F /* InputValidatorTests.swift */; };
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502AE2E55E8450083520F /* InputValidatorTests.swift */; }; 047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502AE2E55E8450083520F /* InputValidatorTests.swift */; };
047502B42E55FED60083520F /* MeshPeerList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B32E55FED60083520F /* MeshPeerList.swift */; };
047502B52E55FED60083520F /* GeohashPeopleList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B22E55FED60083520F /* GeohashPeopleList.swift */; };
047502B62E55FED60083520F /* MeshPeerList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B32E55FED60083520F /* MeshPeerList.swift */; };
047502B72E55FED60083520F /* GeohashPeopleList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B22E55FED60083520F /* GeohashPeopleList.swift */; };
047502B92E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
047502BA2E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; }; 049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; };
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; }; 049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; };
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; }; 049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; };
@@ -180,6 +186,9 @@
047502912E547ACC0083520F /* LocationChannelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChannelsTests.swift; sourceTree = "<group>"; }; 047502912E547ACC0083520F /* LocationChannelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChannelsTests.swift; sourceTree = "<group>"; };
047502AB2E55E8360083520F /* BinaryProtocolPaddingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolPaddingTests.swift; sourceTree = "<group>"; }; 047502AB2E55E8360083520F /* BinaryProtocolPaddingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolPaddingTests.swift; sourceTree = "<group>"; };
047502AE2E55E8450083520F /* InputValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputValidatorTests.swift; sourceTree = "<group>"; }; 047502AE2E55E8450083520F /* InputValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputValidatorTests.swift; sourceTree = "<group>"; };
047502B22E55FED60083520F /* GeohashPeopleList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashPeopleList.swift; sourceTree = "<group>"; };
047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; };
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; }; 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; };
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; }; 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; }; 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; };
@@ -412,6 +421,8 @@
A55126E93155456CAA8D6656 /* Views */ = { A55126E93155456CAA8D6656 /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
047502B32E55FED60083520F /* MeshPeerList.swift */,
0475028E2E5417660083520F /* LocationChannelsSheet.swift */, 0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */, 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */,
A08E03AA0C63E97C91749AEC /* ContentView.swift */, A08E03AA0C63E97C91749AEC /* ContentView.swift */,
@@ -483,6 +494,7 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = { D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
047502B82E560F690083520F /* RelayController.swift */,
0475028B2E54171C0083520F /* LocationChannelManager.swift */, 0475028B2E54171C0083520F /* LocationChannelManager.swift */,
049BD3B02E51F319001A566B /* MessageRouter.swift */, 049BD3B02E51F319001A566B /* MessageRouter.swift */,
049BD3B12E51F319001A566B /* NostrTransport.swift */, 049BD3B12E51F319001A566B /* NostrTransport.swift */,
@@ -703,6 +715,7 @@
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */, 049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */,
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */, 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */,
047502B92E560F690083520F /* RelayController.swift in Sources */,
6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */, 6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */,
84E3F9B64FB7FB4A140BD0A8 /* BitchatPeer.swift in Sources */, 84E3F9B64FB7FB4A140BD0A8 /* BitchatPeer.swift in Sources */,
923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */, 923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */,
@@ -710,6 +723,8 @@
B0CA7796B2B2AC2B33F84548 /* CompressionUtil.swift in Sources */, B0CA7796B2B2AC2B33F84548 /* CompressionUtil.swift in Sources */,
92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */, 92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */,
5C93B4FDD0C448C3EDDBF8AE /* FavoritesPersistenceService.swift in Sources */, 5C93B4FDD0C448C3EDDBF8AE /* FavoritesPersistenceService.swift in Sources */,
047502B42E55FED60083520F /* MeshPeerList.swift in Sources */,
047502B52E55FED60083520F /* GeohashPeopleList.swift in Sources */,
6C63FA98D59854C15C57B3D6 /* FingerprintView.swift in Sources */, 6C63FA98D59854C15C57B3D6 /* FingerprintView.swift in Sources */,
38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */, 38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */,
7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */, 7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */,
@@ -753,6 +768,7 @@
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */, 049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */,
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */, F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */,
047502BA2E560F690083520F /* RelayController.swift in Sources */,
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */, 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */,
84D13329AB7EE1D65A37438A /* BitchatPeer.swift in Sources */, 84D13329AB7EE1D65A37438A /* BitchatPeer.swift in Sources */,
6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */, 6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */,
@@ -760,6 +776,8 @@
7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */, 7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */,
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */, 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */,
ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */, ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */,
047502B62E55FED60083520F /* MeshPeerList.swift in Sources */,
047502B72E55FED60083520F /* GeohashPeopleList.swift in Sources */,
132DF1E24B4E9C7DCDAD4376 /* FingerprintView.swift in Sources */, 132DF1E24B4E9C7DCDAD4376 /* FingerprintView.swift in Sources */,
B909706CD38FC56C0C8EB7BF /* IdentityModels.swift in Sources */, B909706CD38FC56C0C8EB7BF /* IdentityModels.swift in Sources */,
EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */, EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */,
+1 -1
View File
@@ -25,7 +25,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
switch self { switch self {
case .street: return "Street" case .street: return "Street"
case .block: return "Block" case .block: return "Block"
case .neighborhood: return "'hood" case .neighborhood: return "Neighborhood"
case .city: return "City" case .city: return "City"
case .region: return "Region" case .region: return "Region"
case .country: return "Country" case .country: return "Country"
+479 -183
View File
@@ -21,9 +21,13 @@ final class BLEService: NSObject {
#endif #endif
static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") 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 maxMessageLength = 10_000
private let messageTTL: UInt8 = 7 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) // MARK: - Core State (5 Essential Collections)
@@ -82,7 +86,7 @@ final class BLEService: NSObject {
// MARK: - Identity // MARK: - Identity
var myPeerID: String = "" var myPeerID: String = ""
var myNickname: String = "Anonymous" var myNickname: String = "anon"
private let noiseService = NoiseEncryptionService() private let noiseService = NoiseEncryptionService()
// MARK: - Advertising Privacy // MARK: - Advertising Privacy
@@ -94,6 +98,7 @@ final class BLEService: NSObject {
private let collectionsQueue = DispatchQueue(label: "mesh.collections", attributes: .concurrent) private let collectionsQueue = DispatchQueue(label: "mesh.collections", attributes: .concurrent)
private let messageQueueKey = DispatchSpecificKey<Void>() private let messageQueueKey = DispatchSpecificKey<Void>()
private let bleQueue = DispatchQueue(label: "mesh.bluetooth", qos: .userInitiated) private let bleQueue = DispatchQueue(label: "mesh.bluetooth", qos: .userInitiated)
private let bleQueueKey = DispatchSpecificKey<Void>()
// Queue for messages pending handshake completion // Queue for messages pending handshake completion
private var pendingMessagesAfterHandshake: [String: [(content: String, messageID: String)]] = [:] 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 // Accumulate long write chunks per central until a full frame decodes
private var pendingWriteBuffers: [String: Data] = [:] 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 // MARK: - Maintenance Timer
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
private var maintenanceCounter = 0 // Track maintenance cycles 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) // MARK: - Peer snapshots publisher (non-UI convenience)
private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>() private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>()
@@ -201,6 +249,9 @@ final class BLEService: NSObject {
) )
#endif #endif
// Tag BLE queue for re-entrancy detection
bleQueue.setSpecific(key: bleQueueKey, value: ())
// Initialize BLE on background queue to prevent main thread blocking // Initialize BLE on background queue to prevent main thread blocking
// This prevents app freezes during BLE operations // This prevents app freezes during BLE operations
centralManager = CBCentralManager(delegate: self, queue: bleQueue) centralManager = CBCentralManager(delegate: self, queue: bleQueue)
@@ -229,6 +280,8 @@ final class BLEService: NSObject {
deinit { deinit {
maintenanceTimer?.cancel() maintenanceTimer?.cancel()
scanDutyTimer?.cancel()
scanDutyTimer = nil
centralManager?.stopScan() centralManager?.stopScan()
peripheralManager?.stopAdvertising() peripheralManager?.stopAdvertising()
#if os(iOS) #if os(iOS)
@@ -337,6 +390,8 @@ final class BLEService: NSObject {
// Stop timer // Stop timer
maintenanceTimer?.cancel() maintenanceTimer?.cancel()
maintenanceTimer = nil maintenanceTimer = nil
scanDutyTimer?.cancel()
scanDutyTimer = nil
centralManager?.stopScan() centralManager?.stopScan()
peripheralManager?.stopAdvertising() peripheralManager?.stopAdvertising()
@@ -722,168 +777,130 @@ final class BLEService: NSObject {
// MARK: - Packet Broadcasting // MARK: - Packet Broadcasting
private func broadcastPacket(_ packet: BitchatPacket) { private func broadcastPacket(_ packet: BitchatPacket) {
// Balanced privacy: pad sensitive payloads (Noise handshake/encrypted), // Encode once using a small per-type padding policy, then delegate by type
// keep public/announce/leave unpadded to reduce airtime. let padForBLE = padPolicy(for: packet.type)
let padForBLE: Bool = {
if let t = MessageType(rawValue: packet.type) {
switch t {
case .noiseEncrypted, .noiseHandshake:
return true
default:
return false
}
}
return false
}()
guard let data = packet.toBinaryData(padding: padForBLE) else { guard let data = packet.toBinaryData(padding: padForBLE) else {
SecureLogger.log("❌ Failed to convert packet to binary data", category: SecureLogger.session, level: .error) SecureLogger.log("❌ Failed to convert packet to binary data", category: SecureLogger.session, level: .error)
return return
} }
// Only log broadcasts for non-announce packets
// Log encrypted and relayed packets for debugging
if packet.type == MessageType.noiseEncrypted.rawValue { if packet.type == MessageType.noiseEncrypted.rawValue {
SecureLogger.log("📡 Encrypted packet to \(packet.recipientID?.hexEncodedString() ?? "unknown")", sendEncrypted(packet, data: data, pad: padForBLE)
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)
return return
} }
sendGenericBroadcast(packet, data: data, pad: padForBLE)
// For private encrypted messages (not handshakes), send to specific peer only }
// Handshakes need broader delivery to establish encryption
if packet.type == MessageType.noiseEncrypted.rawValue, // MARK: - Broadcast helpers (single responsibility)
let recipientID = packet.recipientID { private func padPolicy(for type: UInt8) -> Bool {
let recipientPeerID = recipientID.hexEncodedString() switch MessageType(rawValue: type) {
var sentEncrypted = false case .noiseEncrypted, .noiseHandshake:
return true
// Check routing availability (only log if there's an issue) default:
let hasPeripheral = peerToPeripheralUUID[recipientPeerID] != nil return false
let hasCentral = centralToPeerID.values.contains(recipientPeerID) }
}
// Try to send directly to the specific peer as peripheral first
if let peripheralUUID = peerToPeripheralUUID[recipientPeerID], private func sendEncrypted(_ packet: BitchatPacket, data: Data, pad: Bool) {
let state = peripherals[peripheralUUID], guard let recipientID = packet.recipientID else { return }
state.isConnected, let recipientPeerID = recipientID.hexEncodedString()
let characteristic = state.characteristic { var sentEncrypted = false
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
// Successfully routed via peripheral // Per-link limits for the specific peer
sentEncrypted = true 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) var centralMaxLen: Int?
if let characteristic = characteristic { do {
// Find the specific central for this peer let (centrals, mapping) = snapshotSubscribedCentrals()
for central in subscribedCentrals { if let central = centrals.first(where: { mapping[$0.identifier.uuidString] == recipientPeerID }) {
if centralToPeerID[central.identifier.uuidString] == recipientPeerID { centralMaxLen = central.maximumUpdateValueLength
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
} }
}
if !sentEncrypted { if let pm = peripheralMaxLen, data.count > pm {
// Log detailed routing failure for debugging let overhead = 13 + 8 + 8 + 13
SecureLogger.log("⚠️ Failed to route encrypted message to \(recipientPeerID) - peripheral=\(hasPeripheral) central=\(hasCentral)", let chunk = max(64, pm - overhead)
category: SecureLogger.session, level: .warning) sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID)
}
return return
} }
if let cm = centralMaxLen, data.count > cm {
// For broadcast messages, use the original simple routing let overhead = 13 + 8 + 8 + 13
// This ensures announces can be sent before peer ID mappings are established let chunk = max(64, cm - overhead)
var sentToPeripherals = 0 sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID)
var sentToCentrals = 0 return
// 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
}
} }
// 2. Also send via notifications to subscribed centrals // Direct write via peripheral link
// This ensures all connected peers receive the message regardless of their connection role if let peripheralUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }),
// Broadcast message types that should go to all peers let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }),
// Include handshakes since they need to reach peers to establish encryption state.isConnected,
let isBroadcastType = packet.type == MessageType.announce.rawValue || let characteristic = state.characteristic {
packet.type == MessageType.message.rawValue || state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
packet.type == MessageType.leave.rawValue || sentEncrypted = true
packet.type == MessageType.noiseHandshake.rawValue }
if isBroadcastType, let characteristic = characteristic, !subscribedCentrals.isEmpty {
// If value exceeds minimum allowed by connected centrals, handle per constraints // Notify via central link (dual-role)
let minAllowed = subscribedCentrals.map { $0.maximumUpdateValueLength }.min() ?? 20 if let characteristic = characteristic, !sentEncrypted {
// Minimum BitChat frame = 13 (header) + 8 (senderID) = 21 bytes let (centrals, mapping) = snapshotSubscribedCentrals()
if minAllowed < 21 { for central in centrals where mapping[central.identifier.uuidString] == recipientPeerID {
// Cannot deliver any BitChat frame via notifications on this link; skip notify let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [central]) ?? false
SecureLogger.log("⚠️ Skipping notify: central max update length (\(minAllowed)) < 21", category: SecureLogger.session, level: .debug) if success { sentEncrypted = true; break }
} else if data.count > minAllowed { collectionsQueue.async(flags: .barrier) { [weak self] in
// Fragment via protocol (preserve chosen padding for BLE) guard let self = self else { return }
sendFragmentedPacket(packet, pad: padForBLE) if self.pendingNotifications.count < 20 {
return self.pendingNotifications.append((data: data, centrals: [central]))
} SecureLogger.log("📋 Queued encrypted packet for retry (notification queue full)", category: SecureLogger.session, level: .debug)
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)
}
} }
} else {
SecureLogger.log("⚠️ Notification queue full for packet type \(packet.type)",
category: SecureLogger.session, level: .warning)
} }
} }
} }
let totalSent = sentToPeripherals + sentToCentrals if !sentEncrypted {
if totalSent == 0 { // Flood as last resort with recipient set; link aware
// No peers to send to - this is normal when isolated sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: recipientPeerID)
} else { }
// Broadcast sent }
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) // 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 } guard let fullData = packet.toBinaryData(padding: pad) else { return }
// Fragment the unpadded frame; each fragment will be encoded independently // Fragment the unpadded frame; each fragment will be encoded independently
let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) }) 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 let chunk = maxChunk ?? defaultFragmentSize
Data(fullData[offset..<min(offset + maxFragmentSize, fullData.count)]) 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() { for (index, fragment) in fragments.enumerated() {
var payload = Data() var payload = Data()
payload.append(fragmentID) payload.append(fragmentID)
@@ -919,18 +952,26 @@ final class BLEService: NSObject {
payload.append(packet.type) payload.append(packet.type)
payload.append(fragment) 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( let fragmentPacket = BitchatPacket(
type: MessageType.fragment.rawValue, type: MessageType.fragment.rawValue,
senderID: packet.senderID, senderID: packet.senderID,
recipientID: packet.recipientID, recipientID: fragmentRecipient,
timestamp: packet.timestamp, timestamp: packet.timestamp,
payload: payload, payload: payload,
signature: nil, signature: nil,
ttl: packet.ttl ttl: packet.ttl
) )
// Pace fragments with small jitter to avoid bursts
// Send immediately (should be on messageQueue already) let delayMs = index * 6 // ~6ms spacing per fragment
broadcastPacket(fragmentPacket) messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
self?.broadcastPacket(fragmentPacket)
}
} }
} }
@@ -961,6 +1002,14 @@ final class BLEService: NSObject {
// Store fragment // Store fragment
let key = "\(senderHex):\(fragmentID)" let key = "\(senderHex):\(fragmentID)"
if incomingFragments[key] == nil { 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] = [:] incomingFragments[key] = [:]
fragmentMetadata[key] = (originalType, total, Date()) fragmentMetadata[key] = (originalType, total, Date())
} }
@@ -1014,12 +1063,31 @@ final class BLEService: NSObject {
SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)", SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)",
category: SecureLogger.session, level: .debug) 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 return // Duplicate ignored
} }
// Update peer info without verbose logging - update the peer we received from, not the original sender // Update peer info without verbose logging - update the peer we received from, not the original sender
updatePeerLastSeen(peerID) 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 // Process by type
switch MessageType(rawValue: packet.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 // Relay if TTL > 1 and we're not the original sender
// Do this asynchronously to avoid blocking and potential loops // Relay decision and scheduling (extracted via RelayController)
// BUT: Don't relay private encrypted messages (they have a specific recipient) do {
let shouldRelay = packet.ttl > 1 && let degree = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }
senderID != myPeerID && let decision = RelayController.decide(
packet.type != MessageType.noiseEncrypted.rawValue ttl: packet.ttl,
senderIsSelf: senderID == myPeerID,
if shouldRelay { isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
messageQueue.async { [weak self] in 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 var relayPacket = packet
relayPacket.ttl -= 1 relayPacket.ttl = decision.newTTL
// Relaying packet self.broadcastPacket(relayPacket)
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() { private func performMaintenance() {
maintenanceCounter += 1 maintenanceCounter += 1
// Always: Send keep-alive announce (every 10 seconds) // Adaptive announce: reduce frequency when we have connected peers
sendAnnounce(forceSend: true) 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 we have no peers, ensure we're scanning and advertising
if peers.isEmpty { 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 // Every 20 seconds (2 cycles): Check peer connectivity
if maintenanceCounter % 2 == 0 { if maintenanceCounter % 2 == 0 {
checkPeerConnectivity() checkPeerConnectivity()
@@ -1567,6 +1666,96 @@ final class BLEService: NSObject {
// Clean old connection timeout backoff entries (> 2 minutes) // Clean old connection timeout backoff entries (> 2 minutes)
let timeoutCutoff = now.addingTimeInterval(-120) let timeoutCutoff = now.addingTimeInterval(-120)
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= timeoutCutoff } 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) // Skip if peripheral is not connectable (per advertisement data)
guard isConnectable else { return } guard isConnectable else { return }
// Skip if signal too weak - prevents connection attempts at extreme range // Skip immediate connect if signal too weak for current conditions; enqueue instead
guard rssiValue > -90 else { if rssiValue <= dynamicRSSIThreshold {
// Too far away, don't attempt connection 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 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 // Check if we already have this peripheral
if let state = peripherals[peripheralID] { if let state = peripherals[peripheralID] {
if state.isConnected || state.isConnecting { if state.isConnected || state.isConnecting {
return // Already connected or connecting return // Already connected or connecting
} }
// Add backoff for reconnection attempts // Add backoff for reconnection attempts
if let lastAttempt = state.lastConnectionAttempt { if let lastAttempt = state.lastConnectionAttempt {
let timeSinceLastAttempt = Date().timeIntervalSince(lastAttempt) let timeSinceLastAttempt = Date().timeIntervalSince(lastAttempt)
if timeSinceLastAttempt < 2.0 { if timeSinceLastAttempt < 2.0 {
return // Wait at least 2 seconds between connection attempts return // Wait at least 2 seconds between connection attempts
}
} }
} }
}
// Backoff if this peripheral recently timed out connection within the last 15 seconds // Backoff if this peripheral recently timed out connection within the last 15 seconds
if let lastTimeout = recentConnectTimeouts[peripheralID], Date().timeIntervalSince(lastTimeout) < 15 { if let lastTimeout = recentConnectTimeouts[peripheralID], Date().timeIntervalSince(lastTimeout) < 15 {
@@ -1669,6 +1894,7 @@ extension BLEService: CBCentralManagerDelegate {
CBConnectPeripheralOptionNotifyOnNotificationKey: true CBConnectPeripheralOptionNotifyOnNotificationKey: true
] ]
central.connect(peripheral, options: options) central.connect(peripheral, options: options)
lastGlobalConnectAttempt = Date()
// Set a timeout for the connection attempt (slightly longer for reliability) // Set a timeout for the connection attempt (slightly longer for reliability)
// Use BLE queue to mutate BLE-related state consistently // Use BLE queue to mutate BLE-related state consistently
@@ -1680,13 +1906,16 @@ extension BLEService: CBCentralManagerDelegate {
// Connection timed out - cancel it // Connection timed out - cancel it
SecureLogger.log("⏱️ Timeout: \(advertisedName)", SecureLogger.log("⏱️ Timeout: \(advertisedName)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
central.cancelPeripheralConnection(peripheral) central.cancelPeripheralConnection(peripheral)
self.peripherals[peripheralID] = nil self.peripherals[peripheralID] = nil
self.recentConnectTimeouts[peripheralID] = Date() 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 let peripheralID = peripheral.identifier.uuidString
// Update state to connected // 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) SecureLogger.log("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: SecureLogger.session, level: .debug)
// Discover services // Discover services
@@ -1741,6 +1974,8 @@ extension BLEService: CBCentralManagerDelegate {
self?.startScanning() self?.startScanning()
} }
} }
// Attempt to fill freed slot from queue
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
// Notify delegate about disconnection on main thread // Notify delegate about disconnection on main thread
notifyUI { [weak self] in notifyUI { [weak self] in
@@ -1764,6 +1999,67 @@ extension BLEService: CBCentralManagerDelegate {
peripherals.removeValue(forKey: peripheralID) peripherals.removeValue(forKey: peripheralID)
SecureLogger.log("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: SecureLogger.session, level: .error) 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? { private func retrieveData(forKey key: String) -> Data? {
// Base query // Base query
var base: [String: Any] = [ let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key, kSecAttrAccount as String: key,
kSecAttrService as String: service, kSecAttrService as String: service,
@@ -158,7 +158,7 @@ class KeychainManager {
private func delete(forKey key: String) -> Bool { private func delete(forKey key: String) -> Bool {
// Base delete query // Base delete query
var base: [String: Any] = [ let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key, kSecAttrAccount as String: key,
kSecAttrService as String: service 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)
}
}
+52 -212
View File
@@ -632,216 +632,51 @@ struct ContentView: View {
// People section // People section
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
#if os(iOS) #if os(iOS)
switch locationManager.selectedChannel { if case .location = locationManager.selectedChannel {
case .location: GeohashPeopleList(viewModel: viewModel,
if viewModel.geohashPeople.isEmpty { textColor: textColor,
Text("nobody around...") secondaryTextColor: secondaryTextColor,
.font(.system(size: 14, design: .monospaced)) onTapPerson: {
.foregroundColor(secondaryTextColor) withAnimation(.easeInOut(duration: 0.2)) {
.padding(.horizontal) showSidebar = false
.padding(.top, 12) sidebarDragOffset = 0
} else { }
// Show 'you' at top and bold })
let myHex: String? = { } else {
if case .location(let ch) = locationManager.selectedChannel, MeshPeerList(viewModel: viewModel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { textColor: textColor,
return id.publicKeyHex.lowercased() secondaryTextColor: secondaryTextColor,
} onTapPeer: { peerID in
return nil viewModel.startPrivateChat(with: peerID)
}() withAnimation(.easeInOut(duration: 0.2)) {
let ordered = viewModel.geohashPeople.sorted { a, b in showSidebar = false
if let me = myHex { sidebarDragOffset = 0
if a.id == me && b.id != me { return true } }
if b.id == me && a.id != me { return false } },
} onToggleFavorite: { peerID in
return a.lastSeen > b.lastSeen viewModel.toggleFavorite(peerID: peerID)
} },
ForEach(ordered) { person in onShowFingerprint: { peerID in
HStack(spacing: 4) { viewModel.showFingerprint(for: peerID)
// Unread indicator matches mesh behavior })
let convKey = "nostr_" + String(person.id.prefix(16))
if viewModel.unreadPrivateMessages.contains(convKey) {
Image(systemName: "envelope.fill")
.font(.system(size: 12))
.foregroundColor(Color.orange)
.accessibilityLabel("Unread message from \(person.displayName)")
} else {
Image(systemName: "person.fill")
.font(.system(size: 10))
.foregroundColor(textColor)
}
Text(person.displayName + (person.id == myHex ? " (you)" : ""))
.font(.system(size: 14, design: .monospaced))
.fontWeight(person.id == myHex ? .bold : .regular)
.foregroundColor(textColor)
Spacer()
}
.padding(.horizontal)
.padding(.vertical, 4)
.contentShape(Rectangle())
.onTapGesture {
// Open DM with this participant (not for self)
if person.id != myHex {
viewModel.startGeohashDM(withPubkeyHex: person.id)
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
}
}
}
}
}
default:
// Mesh peers list (original)
if viewModel.allPeers.isEmpty {
Text("nobody around...")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal)
.padding(.top, 12)
} else {
// Extract peer data for display
let peerNicknames = viewModel.meshService.getPeerNicknames()
// Show all peers (connected and favorites)
// Pre-compute peer data outside ForEach to reduce overhead
let peerData = viewModel.allPeers.map { peer in
// Get current myPeerID for each peer to avoid stale values
let currentMyPeerID = viewModel.meshService.myPeerID
return PeerDisplayData(
id: peer.id,
displayName: peer.id == currentMyPeerID ? viewModel.nickname : peer.nickname,
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
isMe: peer.id == currentMyPeerID,
hasUnreadMessages: viewModel.hasUnreadMessages(for: peer.id),
encryptionStatus: viewModel.getEncryptionStatus(for: peer.id),
connectionState: peer.connectionState,
isMutualFavorite: peer.favoriteStatus?.isMutual ?? false
)
}.sorted { (peer1: PeerDisplayData, peer2: PeerDisplayData) in
// Sort: favorites first, then alphabetically by nickname
if peer1.isFavorite != peer2.isFavorite {
return peer1.isFavorite
}
return peer1.displayName < peer2.displayName
}
ForEach(peerData) { peer in
HStack(spacing: 4) {
// Signal strength indicator or unread message icon
if peer.isMe {
Image(systemName: "person.fill")
.font(.system(size: 10))
.foregroundColor(textColor)
.accessibilityLabel("You")
} else if peer.hasUnreadMessages {
Image(systemName: "envelope.fill")
.font(.system(size: 12))
.foregroundColor(Color.orange)
.accessibilityLabel("Unread message from \(peer.displayName)")
} else {
// Connection state indicator
switch peer.connectionState {
case .bluetoothConnected:
// Radio icon for mesh connection
Image(systemName: "dot.radiowaves.left.and.right")
.font(.system(size: 10))
.foregroundColor(textColor)
.accessibilityLabel("Connected via mesh")
case .nostrAvailable:
// Purple globe for mutual favorites reachable via Nostr
Image(systemName: "globe")
.font(.system(size: 10))
.foregroundColor(.purple)
.accessibilityLabel("Available via Nostr")
case .offline:
if peer.isFavorite {
// Crescent moon for non-mutual favorites
Image(systemName: "moon.fill")
.font(.system(size: 10))
.foregroundColor(Color.secondary.opacity(0.5))
.accessibilityLabel("Favorite - Offline")
} else {
// Offline indicator for non-favorites (shouldn't happen since we only show favorites when offline)
Image(systemName: "circle")
.font(.system(size: 8))
.foregroundColor(Color.secondary.opacity(0.3))
.accessibilityLabel("Offline")
}
}
}
// Peer name
if peer.isMe {
HStack {
Text(peer.displayName + " (you)")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor)
Spacer()
}
} else {
// Render nickname with light-gray '#abcd' suffix if present
let parts = splitNameSuffix(peer.displayName)
HStack(spacing: 0) {
Text(parts.base)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(peer.isFavorite || peerNicknames[peer.id] != nil ? textColor : secondaryTextColor)
if !parts.suffix.isEmpty {
Text(parts.suffix)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(Color.secondary.opacity(0.6))
}
}
// Encryption status icon (after peer name)
if let icon = peer.encryptionStatus.icon {
Image(systemName: icon)
.font(.system(size: 10))
.foregroundColor(peer.encryptionStatus == .noiseVerified ? textColor :
peer.encryptionStatus == .noiseSecured ? textColor :
peer.encryptionStatus == .noiseHandshaking ? Color.orange :
Color.red)
.accessibilityLabel("Encryption: \(peer.encryptionStatus == .noiseVerified ? "verified" : peer.encryptionStatus == .noiseSecured ? "secured" : peer.encryptionStatus == .noiseHandshaking ? "establishing" : "none")")
}
Spacer()
// Favorite star
Button(action: {
viewModel.toggleFavorite(peerID: peer.id)
}) {
Image(systemName: peer.isFavorite ? "star.fill" : "star")
.font(.system(size: 12))
.foregroundColor(peer.isFavorite ? Color.yellow : secondaryTextColor)
}
.buttonStyle(.plain)
.accessibilityLabel(peer.isFavorite ? "Remove \(peer.displayName) from favorites" : "Add \(peer.displayName) to favorites")
}
}
.padding(.horizontal)
.padding(.vertical, 4)
.contentShape(Rectangle())
.onTapGesture {
if !peer.isMe {
// Allow tapping on any peer (connected or offline favorite)
viewModel.startPrivateChat(with: peer.id)
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
}
}
}
.onTapGesture(count: 2) {
if !peer.isMe {
// Show fingerprint on double tap
viewModel.showFingerprint(for: peer.id)
}
}
}
}
// Close switch
} }
#else
MeshPeerList(viewModel: viewModel,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPeer: { peerID in
viewModel.startPrivateChat(with: peerID)
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
}
},
onToggleFavorite: { peerID in
viewModel.toggleFavorite(peerID: peerID)
},
onShowFingerprint: { peerID in
viewModel.showFingerprint(for: peerID)
})
#endif #endif
} }
} }
@@ -948,7 +783,9 @@ struct ContentView: View {
if isMeshConnected { counts.mesh += 1; counts.others += 1 } if isMeshConnected { counts.mesh += 1; counts.others += 1 }
else if peer.isMutualFavorite { counts.others += 1 } else if peer.isMutualFavorite { counts.others += 1 }
} }
let color: Color = counts.mesh > 0 ? Color.blue : Color.secondary // Darker, more neutral blue (less purple hue)
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary
return (counts.others, color) return (counts.others, color)
} }
} }
@@ -1019,7 +856,9 @@ struct ContentView: View {
else if peer.isMutualFavorite { counts.others += 1 } else if peer.isMutualFavorite { counts.others += 1 }
} }
let otherPeersCount = peerCounts.others let otherPeersCount = peerCounts.others
let countColor: Color = (peerCounts.mesh > 0) ? Color.blue : Color.secondary // Darker, more neutral blue (less purple hue)
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let countColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary
#endif #endif
// Location channels button '#' // Location channels button '#'
@@ -1037,7 +876,8 @@ struct ContentView: View {
let badgeColor: Color = { let badgeColor: Color = {
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: case .mesh:
return Color.blue // Darker, more neutral blue (less purple hue)
return Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
case .location: case .location:
// Standard green to avoid overly bright appearance in light mode // Standard green to avoid overly bright appearance in light mode
return (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) return (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
+62
View File
@@ -0,0 +1,62 @@
import SwiftUI
#if os(iOS)
struct GeohashPeopleList: View {
@ObservedObject var viewModel: ChatViewModel
let textColor: Color
let secondaryTextColor: Color
let onTapPerson: () -> Void
var body: some View {
Group {
if viewModel.geohashPeople.isEmpty {
Text("nobody around...")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal)
.padding(.top, 12)
} else {
let myHex: String? = {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
return id.publicKeyHex.lowercased()
}
return nil
}()
let ordered = viewModel.geohashPeople.sorted { a, b in
if let me = myHex {
if a.id == me && b.id != me { return true }
if b.id == me && a.id != me { return false }
}
return a.lastSeen > b.lastSeen
}
ForEach(ordered) { person in
HStack(spacing: 4) {
let convKey = "nostr_" + String(person.id.prefix(16))
if viewModel.unreadPrivateMessages.contains(convKey) {
Image(systemName: "envelope.fill").font(.system(size: 12)).foregroundColor(.orange)
} else {
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(textColor)
}
Text(person.displayName + (person.id == myHex ? " (you)" : ""))
.font(.system(size: 14, design: .monospaced))
.fontWeight(person.id == myHex ? .bold : .regular)
.foregroundColor(textColor)
Spacer()
}
.padding(.horizontal)
.padding(.vertical, 4)
.contentShape(Rectangle())
.onTapGesture {
if person.id != myHex {
viewModel.startGeohashDM(withPubkeyHex: person.id)
onTapPerson()
}
}
}
}
}
}
}
#endif
+94
View File
@@ -0,0 +1,94 @@
import SwiftUI
struct MeshPeerList: View {
@ObservedObject var viewModel: ChatViewModel
let textColor: Color
let secondaryTextColor: Color
let onTapPeer: (String) -> Void
let onToggleFavorite: (String) -> Void
let onShowFingerprint: (String) -> Void
var body: some View {
Group {
if viewModel.allPeers.isEmpty {
Text("nobody around...")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal)
.padding(.top, 12)
} else {
let peerNicknames = viewModel.meshService.getPeerNicknames()
let myPeerID = viewModel.meshService.myPeerID
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
let isMe = peer.id == myPeerID
let hasUnread = viewModel.hasUnreadMessages(for: peer.id)
let enc = viewModel.getEncryptionStatus(for: peer.id)
return (peer, isMe, hasUnread, enc)
}
let peers = mapped.sorted { lhs, rhs in
let lFav = lhs.peer.favoriteStatus?.isFavorite ?? false
let rFav = rhs.peer.favoriteStatus?.isFavorite ?? false
if lFav != rFav { return lFav }
let lhsName = lhs.isMe ? viewModel.nickname : lhs.peer.nickname
let rhsName = rhs.isMe ? viewModel.nickname : rhs.peer.nickname
return lhsName < rhsName
}
ForEach(0..<peers.count, id: \.self) { idx in
let item = peers[idx]
let peer = item.peer
let isMe = item.isMe
let hasUnread = item.hasUnread
HStack(spacing: 4) {
if isMe {
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(textColor)
} else if hasUnread {
Image(systemName: "envelope.fill").font(.system(size: 12)).foregroundColor(.orange)
} else {
switch peer.connectionState {
case .bluetoothConnected:
Image(systemName: "dot.radiowaves.left.and.right").font(.system(size: 10)).foregroundColor(textColor)
case .nostrAvailable:
Image(systemName: "globe").font(.system(size: 10)).foregroundColor(.purple)
case .offline:
if peer.favoriteStatus?.isFavorite ?? false {
Image(systemName: "moon.fill").font(.system(size: 10)).foregroundColor(.gray)
} else {
Image(systemName: "person").font(.system(size: 10)).foregroundColor(.gray)
}
}
}
let displayName = isMe ? viewModel.nickname : peer.nickname
Text(displayName)
.font(.system(size: 14, design: .monospaced))
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) || peerNicknames[peer.id] != nil ? textColor : secondaryTextColor)
if let icon = item.enc.icon, !isMe {
Image(systemName: icon)
.font(.system(size: 10))
.foregroundColor(item.enc == .noiseVerified || item.enc == .noiseSecured ? textColor : (item.enc == .noiseHandshaking ? .orange : .red))
}
Spacer()
if !isMe {
Button(action: { onToggleFavorite(peer.id) }) {
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
.font(.system(size: 12))
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.vertical, 4)
.padding(.top, idx == 0 ? 6 : 0)
.contentShape(Rectangle())
.onTapGesture { if !isMe { onTapPeer(peer.id) } }
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.id) } }
}
}
}
}
}