From 496972dcc9339c67f13522b3aa2407cdadab4df2 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:27:49 +0200 Subject: [PATCH] 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 --- bitchat.xcodeproj/project.pbxproj | 18 + bitchat/Protocols/LocationChannel.swift | 2 +- bitchat/Services/BLEService.swift | 662 +++++++++++++++++------- bitchat/Services/KeychainManager.swift | 4 +- bitchat/Services/RelayController.swift | 47 ++ bitchat/Views/ContentView.swift | 264 ++-------- bitchat/Views/GeohashPeopleList.swift | 62 +++ bitchat/Views/MeshPeerList.swift | 94 ++++ 8 files changed, 755 insertions(+), 398 deletions(-) create mode 100644 bitchat/Services/RelayController.swift create mode 100644 bitchat/Views/GeohashPeopleList.swift create mode 100644 bitchat/Views/MeshPeerList.swift diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index a67dfbc5..6b8c8738 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -23,6 +23,12 @@ 047502AD2E55E8360083520F /* BinaryProtocolPaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502AB2E55E8360083520F /* BinaryProtocolPaddingTests.swift */; }; 047502B02E55E8450083520F /* 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 */; }; 049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.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 = ""; }; 047502AB2E55E8360083520F /* BinaryProtocolPaddingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolPaddingTests.swift; sourceTree = ""; }; 047502AE2E55E8450083520F /* InputValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputValidatorTests.swift; sourceTree = ""; }; + 047502B22E55FED60083520F /* GeohashPeopleList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashPeopleList.swift; sourceTree = ""; }; + 047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = ""; }; + 047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = ""; }; 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = ""; }; 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = ""; }; 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = ""; }; @@ -412,6 +421,8 @@ A55126E93155456CAA8D6656 /* Views */ = { isa = PBXGroup; children = ( + 047502B22E55FED60083520F /* GeohashPeopleList.swift */, + 047502B32E55FED60083520F /* MeshPeerList.swift */, 0475028E2E5417660083520F /* LocationChannelsSheet.swift */, 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */, A08E03AA0C63E97C91749AEC /* ContentView.swift */, @@ -483,6 +494,7 @@ D98A3186D7E4C72E35BDF7FE /* Services */ = { isa = PBXGroup; children = ( + 047502B82E560F690083520F /* RelayController.swift */, 0475028B2E54171C0083520F /* LocationChannelManager.swift */, 049BD3B02E51F319001A566B /* MessageRouter.swift */, 049BD3B12E51F319001A566B /* NostrTransport.swift */, @@ -703,6 +715,7 @@ 049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */, 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */, + 047502B92E560F690083520F /* RelayController.swift in Sources */, 6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */, 84E3F9B64FB7FB4A140BD0A8 /* BitchatPeer.swift in Sources */, 923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */, @@ -710,6 +723,8 @@ B0CA7796B2B2AC2B33F84548 /* CompressionUtil.swift in Sources */, 92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */, 5C93B4FDD0C448C3EDDBF8AE /* FavoritesPersistenceService.swift in Sources */, + 047502B42E55FED60083520F /* MeshPeerList.swift in Sources */, + 047502B52E55FED60083520F /* GeohashPeopleList.swift in Sources */, 6C63FA98D59854C15C57B3D6 /* FingerprintView.swift in Sources */, 38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */, 7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */, @@ -753,6 +768,7 @@ 049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */, F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */, + 047502BA2E560F690083520F /* RelayController.swift in Sources */, 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */, 84D13329AB7EE1D65A37438A /* BitchatPeer.swift in Sources */, 6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */, @@ -760,6 +776,8 @@ 7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */, 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */, ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */, + 047502B62E55FED60083520F /* MeshPeerList.swift in Sources */, + 047502B72E55FED60083520F /* GeohashPeopleList.swift in Sources */, 132DF1E24B4E9C7DCDAD4376 /* FingerprintView.swift in Sources */, B909706CD38FC56C0C8EB7BF /* IdentityModels.swift in Sources */, EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */, diff --git a/bitchat/Protocols/LocationChannel.swift b/bitchat/Protocols/LocationChannel.swift index 33b2e718..b5e9d4c8 100644 --- a/bitchat/Protocols/LocationChannel.swift +++ b/bitchat/Protocols/LocationChannel.swift @@ -25,7 +25,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable { switch self { case .street: return "Street" case .block: return "Block" - case .neighborhood: return "'hood" + case .neighborhood: return "Neighborhood" case .city: return "City" case .region: return "Region" case .country: return "Country" diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index bbb89a8d..f18dab52 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -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() private let bleQueue = DispatchQueue(label: "mesh.bluetooth", qos: .userInitiated) + private let bleQueueKey = DispatchSpecificKey() // 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.. 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) } } diff --git a/bitchat/Services/KeychainManager.swift b/bitchat/Services/KeychainManager.swift index 3335c1c6..974b74dc 100644 --- a/bitchat/Services/KeychainManager.swift +++ b/bitchat/Services/KeychainManager.swift @@ -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 diff --git a/bitchat/Services/RelayController.swift b/bitchat/Services/RelayController.swift new file mode 100644 index 00000000..2fedd366 --- /dev/null +++ b/bitchat/Services/RelayController.swift @@ -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) + } +} + diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index ac78fd8b..d4950403 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -632,216 +632,51 @@ struct ContentView: View { // People section VStack(alignment: .leading, spacing: 4) { #if os(iOS) - switch locationManager.selectedChannel { - case .location: - if viewModel.geohashPeople.isEmpty { - Text("nobody around...") - .font(.system(size: 14, design: .monospaced)) - .foregroundColor(secondaryTextColor) - .padding(.horizontal) - .padding(.top, 12) - } else { - // Show 'you' at top and bold - let myHex: String? = { - if case .location(let ch) = locationManager.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) { - // 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 + if case .location = locationManager.selectedChannel { + GeohashPeopleList(viewModel: viewModel, + textColor: textColor, + secondaryTextColor: secondaryTextColor, + onTapPerson: { + withAnimation(.easeInOut(duration: 0.2)) { + showSidebar = false + sidebarDragOffset = 0 + } + }) + } 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) + }) } + #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 } } @@ -948,7 +783,9 @@ struct ContentView: View { if isMeshConnected { counts.mesh += 1; 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) } } @@ -1019,7 +856,9 @@ struct ContentView: View { else if peer.isMutualFavorite { counts.others += 1 } } 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 // Location channels button '#' @@ -1037,7 +876,8 @@ struct ContentView: View { let badgeColor: Color = { switch locationManager.selectedChannel { 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: // Standard green to avoid overly bright appearance in light mode return (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift new file mode 100644 index 00000000..980d1dd0 --- /dev/null +++ b/bitchat/Views/GeohashPeopleList.swift @@ -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 + diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift new file mode 100644 index 00000000..1d53e961 --- /dev/null +++ b/bitchat/Views/MeshPeerList.swift @@ -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..