mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
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:
@@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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"
|
||||
|
||||
+473
-177
@@ -21,9 +21,13 @@ final class BLEService: NSObject {
|
||||
#endif
|
||||
static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
|
||||
|
||||
private let maxFragmentSize = 469 // 512 MTU - headers
|
||||
// Default per-fragment chunk size when link limits are unknown
|
||||
private let defaultFragmentSize = 469 // ~512 MTU minus protocol overhead
|
||||
private let maxMessageLength = 10_000
|
||||
private let messageTTL: UInt8 = 7
|
||||
// Flood/battery controls
|
||||
private let maxInFlightAssemblies = 128 // cap concurrent fragment assemblies
|
||||
private let highDegreeThreshold = 6 // for adaptive TTL/probabilistic relays
|
||||
|
||||
// MARK: - Core State (5 Essential Collections)
|
||||
|
||||
@@ -82,7 +86,7 @@ final class BLEService: NSObject {
|
||||
// MARK: - Identity
|
||||
|
||||
var myPeerID: String = ""
|
||||
var myNickname: String = "Anonymous"
|
||||
var myNickname: String = "anon"
|
||||
private let noiseService = NoiseEncryptionService()
|
||||
|
||||
// MARK: - Advertising Privacy
|
||||
@@ -94,6 +98,7 @@ final class BLEService: NSObject {
|
||||
private let collectionsQueue = DispatchQueue(label: "mesh.collections", attributes: .concurrent)
|
||||
private let messageQueueKey = DispatchSpecificKey<Void>()
|
||||
private let bleQueue = DispatchQueue(label: "mesh.bluetooth", qos: .userInitiated)
|
||||
private let bleQueueKey = DispatchSpecificKey<Void>()
|
||||
|
||||
// Queue for messages pending handshake completion
|
||||
private var pendingMessagesAfterHandshake: [String: [(content: String, messageID: String)]] = [:]
|
||||
@@ -103,12 +108,55 @@ 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>()
|
||||
var peerSnapshotPublisher: AnyPublisher<[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
|
||||
sendEncrypted(packet, data: data, pad: padForBLE)
|
||||
return
|
||||
}
|
||||
sendGenericBroadcast(packet, data: data, pad: padForBLE)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
var centralMaxLen: Int?
|
||||
do {
|
||||
let (centrals, mapping) = snapshotSubscribedCentrals()
|
||||
if let central = centrals.first(where: { mapping[$0.identifier.uuidString] == recipientPeerID }) {
|
||||
centralMaxLen = central.maximumUpdateValueLength
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Do NOT broadcast encrypted messages to all centrals
|
||||
// Encrypted messages must only go to the intended recipient
|
||||
}
|
||||
}
|
||||
|
||||
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 !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
|
||||
}
|
||||
|
||||
// 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
|
||||
// Writes to connected peripherals
|
||||
for s in states where s.isConnected {
|
||||
if let ch = s.characteristic {
|
||||
s.peripheral.writeValue(data, for: ch, type: .withoutResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
} 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
|
||||
// Notify all subscribed centrals
|
||||
if let ch = characteristic {
|
||||
_ = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,13 +919,29 @@ final class BLEService: NSObject {
|
||||
|
||||
// MARK: - Fragmentation (Required for messages > BLE MTU)
|
||||
|
||||
private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool) {
|
||||
private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: String? = nil) {
|
||||
guard let fullData = packet.toBinaryData(padding: pad) else { return }
|
||||
// Fragment the unpadded frame; each fragment will be encoded independently
|
||||
|
||||
let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
|
||||
let fragments = stride(from: 0, to: fullData.count, by: maxFragmentSize).map { offset in
|
||||
Data(fullData[offset..<min(offset + maxFragmentSize, fullData.count)])
|
||||
let chunk = maxChunk ?? defaultFragmentSize
|
||||
let safeChunk = max(64, chunk)
|
||||
let fragments = stride(from: 0, to: fullData.count, by: safeChunk).map { offset in
|
||||
Data(fullData[offset..<min(offset + safeChunk, fullData.count)])
|
||||
}
|
||||
// Lightweight pacing to reduce floods and allow BLE buffers to drain
|
||||
// Also briefly pause scanning during long fragment trains to save battery
|
||||
let totalFragments = fragments.count
|
||||
if totalFragments > 4 {
|
||||
bleQueue.async { [weak self] in
|
||||
guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return }
|
||||
if c.isScanning { c.stopScan() }
|
||||
// Resume scanning after we expect last fragment to be sent
|
||||
let expectedMs = min(2000, totalFragments * 8) // ~8ms per fragment
|
||||
self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in
|
||||
self?.startScanning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (index, fragment) in fragments.enumerated() {
|
||||
@@ -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,9 +1799,45 @@ 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
|
||||
}
|
||||
|
||||
@@ -1622,14 +1847,14 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user