Compare commits

..
Author SHA1 Message Date
jack 6550a64676 project: add TransportConfig.swift to Share Extension target to fix build 2025-08-25 21:17:57 +02:00
jack 7e9d573f6d refactor(config): centralize remaining magic numbers and finalize log hygiene
Add comprehensive TransportConfig constants for UI, Nostr, and BLE; adopt across ChatViewModel, ContentView, BLEService, ShareViewController, and BitchatApp to remove scattered literals. Standardize Nostr lookbacks/limits, UI delays/animations, and BLE announce/duty-cycle/candidate caps. Preserve behavior while making tuning explicit and safe.

Highlights:\n- UI: animations, scroll throttle, long-message thresholds, batch stagger, color hue tuning, rate-limit buckets, read-receipt debounce, startup delays, share accept/dismiss windows, migration cutoff.\n- Nostr: short display length (8), conv-key prefix length (16), DM lookback (24h), geohash sample lookback/limits; consistent use throughout ChatViewModel.\n- BLE: dynamic RSSI defaults, announce intervals/base+jitter, duty cycles (dense/sparse), fragment/ingress lifetimes, expected write timings/spacing, recent packet window (30s/100), peer inactivity timeout; unified candidate caps (100).\n- Share: use constant dismiss delay; App: use constant share accept window.\n\nRisk/impact: behavior-equivalent with centralized knobs; easier to tune without code edits.
2025-08-25 21:13:17 +02:00
jack ed3583f2ff chore: centralize more BLE/Nostr delays; tighten NostrRelayManager logs to concise summaries; adopt config for location/geohash/relays 2025-08-25 20:57:52 +02:00
jack 2251afa360 chore: centralize compression, dedup, verification QR, relay backoff, georelay fetch constants; adopt across modules 2025-08-25 20:51:12 +02:00
jack c39df29b25 chore: centralize location + geohash constants (filters, lookback, relay count) and adopt in LocationChannelManager/ChatViewModel 2025-08-25 20:17:09 +02:00
jack 43e159df0e chore: centralize more constants in TransportConfig (BLE thresholds, Nostr read-ack, UI caps) and adopt in BLEService/ChatViewModel/NostrTransport 2025-08-25 20:04:37 +02:00
jack adab49154b project: remove duplicate TransportConfig.swift entries from Sources build phases 2025-08-25 19:59:08 +02:00
jack 62d259cd9c chore(BLE): trim raw hex dump on central decode failure to length+prefix 2025-08-25 19:56:27 +02:00
jack 131db9c569 chore(NostrTransport): factor recipient npub resolution into helper; reduce duplication 2025-08-25 19:50:45 +02:00
jack f5a2a1e29a chore(BLE): move connect/duty/announce constants to TransportConfig and reference them 2025-08-25 18:59:47 +02:00
jack 3a8cf4d37f project: fix TransportConfig reference path; remove recovered reference; hook correct fileRef in iOS/macOS sources 2025-08-25 18:58:19 +02:00
jack b243b930c4 chore: remove unnecessary UserDefaults.synchronize() calls (extension/app/VM) 2025-08-25 18:54:42 +02:00
jack c803f862d5 project: add TransportConfig.swift and PeerDisplayNameResolver.swift to iOS/macOS targets (no xcodegen) 2025-08-25 18:52:54 +02:00
jack 58c516075c docs(plans): add refactor plan; chore(config): introduce TransportConfig and use in BLEService/ChatViewModel/PrivateChatManager; feat: add PeerDisplayNameResolver and apply in BLEService; chore: make TransportPeerSnapshot Equatable/Hashable; perf: simplify read-receipt persistence (no synchronize) 2025-08-25 18:48:02 +02:00
15 changed files with 211 additions and 725 deletions
+9 -6
View File
@@ -328,6 +328,7 @@
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */, A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
C3D98EB3E1B455E321F519F4 /* bitchatTests */, C3D98EB3E1B455E321F519F4 /* bitchatTests */,
9F37F9F2C353B58AC809E93B /* Products */, 9F37F9F2C353B58AC809E93B /* Products */,
048A4BE52E5CCC5C00162C4A /* Recovered References */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -749,6 +750,7 @@
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */, A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */, AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */, 9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */, 049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */,
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */, 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */,
@@ -808,6 +810,7 @@
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */, A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */, ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */, AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */, 049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */,
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */, F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */,
@@ -1004,7 +1007,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.3.3; MARKETING_VERSION = 1.3.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -1035,7 +1038,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.3; MARKETING_VERSION = 1.3.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1090,7 +1093,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.3; MARKETING_VERSION = 1.3.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1122,7 +1125,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.3.3; MARKETING_VERSION = 1.3.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -1211,7 +1214,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.3.3; MARKETING_VERSION = 1.3.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -1304,7 +1307,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.3.3; MARKETING_VERSION = 1.3.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
-2
View File
@@ -106,8 +106,6 @@ enum HandshakeState {
struct CryptographicIdentity: Codable { struct CryptographicIdentity: Codable {
let fingerprint: String // SHA256 of public key let fingerprint: String // SHA256 of public key
let publicKey: Data // Noise static public key let publicKey: Data // Noise static public key
// Optional Ed25519 signing public key (used to authenticate public messages)
var signingPublicKey: Data? = nil
let firstSeen: Date let firstSeen: Date
let lastHandshake: Date? let lastHandshake: Date?
} }
@@ -230,91 +230,6 @@ class SecureIdentityStateManager {
} }
} }
// MARK: - Cryptographic Identities
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
/// - Parameters:
/// - fingerprint: SHA-256 hex of the Noise static public key
/// - noisePublicKey: Noise static public key data
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
queue.async(flags: .barrier) {
let now = Date()
if var existing = self.cryptographicIdentities[fingerprint] {
// Update keys if changed
if existing.publicKey != noisePublicKey {
existing = CryptographicIdentity(
fingerprint: fingerprint,
publicKey: noisePublicKey,
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = existing
} else {
// Update signing key and lastHandshake
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
let updated = CryptographicIdentity(
fingerprint: existing.fingerprint,
publicKey: existing.publicKey,
signingPublicKey: existing.signingPublicKey,
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = updated
}
// Persist updated state (already assigned in branches above)
} else {
// New entry
let entry = CryptographicIdentity(
fingerprint: fingerprint,
publicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
firstSeen: now,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = entry
}
// Optionally persist claimed nickname into social identity
if let claimed = claimedNickname {
var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: claimed,
trustLevel: .unknown,
isFavorite: false,
isBlocked: false,
notes: nil
)
// Update claimed nickname if changed
if identity.claimedNickname != claimed {
identity.claimedNickname = claimed
self.cache.socialIdentities[fingerprint] = identity
} else if self.cache.socialIdentities[fingerprint] == nil {
self.cache.socialIdentities[fingerprint] = identity
}
}
self.saveIdentityCache()
}
}
/// Retrieve cryptographic identity by fingerprint
func getCryptographicIdentity(for fingerprint: String) -> CryptographicIdentity? {
queue.sync { cryptographicIdentities[fingerprint] }
}
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
queue.sync {
// Defensive: ensure hex and correct length
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
}
}
func getAllSocialIdentities() -> [SocialIdentity] { func getAllSocialIdentities() -> [SocialIdentity] {
queue.sync { queue.sync {
return Array(cache.socialIdentities.values) return Array(cache.socialIdentities.values)
+1 -9
View File
@@ -8,7 +8,6 @@ struct BitchatPeer: Identifiable, Equatable {
let nickname: String let nickname: String
let lastSeen: Date let lastSeen: Date
let isConnected: Bool let isConnected: Bool
let isReachable: Bool
// Favorite-related properties // Favorite-related properties
var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship? var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?
@@ -19,7 +18,6 @@ struct BitchatPeer: Identifiable, Equatable {
// Connection state // Connection state
enum ConnectionState { enum ConnectionState {
case bluetoothConnected case bluetoothConnected
case meshReachable // Seen via mesh recently, not directly connected
case nostrAvailable // Mutual favorite, reachable via Nostr case nostrAvailable // Mutual favorite, reachable via Nostr
case offline // Not connected via any transport case offline // Not connected via any transport
} }
@@ -27,8 +25,6 @@ struct BitchatPeer: Identifiable, Equatable {
var connectionState: ConnectionState { var connectionState: ConnectionState {
if isConnected { if isConnected {
return .bluetoothConnected return .bluetoothConnected
} else if isReachable {
return .meshReachable
} else if favoriteStatus?.isMutual == true { } else if favoriteStatus?.isMutual == true {
// Mutual favorites can communicate via Nostr when offline // Mutual favorites can communicate via Nostr when offline
return .nostrAvailable return .nostrAvailable
@@ -58,8 +54,6 @@ struct BitchatPeer: Identifiable, Equatable {
switch connectionState { switch connectionState {
case .bluetoothConnected: case .bluetoothConnected:
return "📻" // Radio icon for mesh connection return "📻" // Radio icon for mesh connection
case .meshReachable:
return "📡" // Antenna for mesh reachable
case .nostrAvailable: case .nostrAvailable:
return "🌐" // Purple globe for Nostr return "🌐" // Purple globe for Nostr
case .offline: case .offline:
@@ -77,15 +71,13 @@ struct BitchatPeer: Identifiable, Equatable {
noisePublicKey: Data, noisePublicKey: Data,
nickname: String, nickname: String,
lastSeen: Date = Date(), lastSeen: Date = Date(),
isConnected: Bool = false, isConnected: Bool = false
isReachable: Bool = false
) { ) {
self.id = id self.id = id
self.noisePublicKey = noisePublicKey self.noisePublicKey = noisePublicKey
self.nickname = nickname self.nickname = nickname
self.lastSeen = lastSeen self.lastSeen = lastSeen
self.isConnected = isConnected self.isConnected = isConnected
self.isReachable = isReachable
// Load favorite status - will be set later by the manager // Load favorite status - will be set later by the manager
self.favoriteStatus = nil self.favoriteStatus = nil
+77 -384
View File
@@ -102,12 +102,6 @@ final class BLEService: NSObject {
// Queue for messages pending handshake completion // Queue for messages pending handshake completion
private var pendingMessagesAfterHandshake: [String: [(content: String, messageID: String)]] = [:] private var pendingMessagesAfterHandshake: [String: [(content: String, messageID: String)]] = [:]
// Noise typed payloads (ACKs, read receipts, etc.) pending handshake
private var pendingNoisePayloadsAfterHandshake: [String: [Data]] = [:]
// Keep a tiny buffer of the last few unique announces we've seen (by sender)
private var recentAnnounceBySender: [String: BitchatPacket] = [:]
private var recentAnnounceOrder: [String] = []
private let recentAnnounceBufferCap = 3
// Queue for notifications that failed due to full queue // Queue for notifications that failed due to full queue
private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = [] private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = []
@@ -128,13 +122,6 @@ final class BLEService: NSObject {
// Backpressure-aware write queue per peripheral // Backpressure-aware write queue per peripheral
private var pendingPeripheralWrites: [String: [Data]] = [:] private var pendingPeripheralWrites: [String: [Data]] = [:]
// Debounce duplicate disconnect notifies
private var recentDisconnectNotifies: [String: Date] = [:]
// Store-and-forward for directed messages when we have no links
// Keyed by recipient short peerID -> messageID -> (packet, enqueuedAt)
private var pendingDirectedRelays: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]
// Debounce for 'reconnected' logs
private var lastReconnectLogAt: [String: Date] = [:]
// MARK: - Maintenance Timer // MARK: - Maintenance Timer
@@ -320,11 +307,6 @@ final class BLEService: NSObject {
// Send any messages that were queued during handshake // Send any messages that were queued during handshake
self?.messageQueue.async { [weak self] in self?.messageQueue.async { [weak self] in
self?.sendPendingMessagesAfterHandshake(for: peerID) self?.sendPendingMessagesAfterHandshake(for: peerID)
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
}
// Proactive presence nudge: announce immediately after handshake
self?.messageQueue.async { [weak self] in
self?.sendAnnounce(forceSend: true)
} }
} }
@@ -520,27 +502,6 @@ final class BLEService: NSObject {
return collectionsQueue.sync { peers[shortID]?.isConnected ?? false } return collectionsQueue.sync { peers[shortID]?.isConnected ?? false }
} }
func isPeerReachable(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys
let shortID: String = {
if peerID.count == 64, let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key)
}
return peerID
}()
return collectionsQueue.sync {
// Must be mesh-attached: at least one live direct link to the mesh
let meshAttached = peers.values.contains { $0.isConnected }
guard let info = peers[shortID] else { return false }
if info.isConnected { return true }
guard meshAttached else { return false }
// Apply reachability retention window
let isVerified = info.isVerifiedNickname
let retention: TimeInterval = isVerified ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
return Date().timeIntervalSince(info.lastSeen) <= retention
}
}
func peerNickname(peerID: String) -> String? { func peerNickname(peerID: String) -> String? {
collectionsQueue.sync { collectionsQueue.sync {
guard let peer = peers[peerID], peer.isConnected else { return nil } guard let peer = peers[peerID], peer.isConnected else { return nil }
@@ -580,15 +541,21 @@ final class BLEService: NSObject {
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
// Create typed payload: [type byte] + [message ID] // Send encrypted read receipt
var payload = Data([NoisePayloadType.readReceipt.rawValue]) guard noiseService.hasSession(with: peerID) else {
payload.append(contentsOf: receipt.originalMessageID.utf8) SecureLogger.log("Cannot send read receipt - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning)
return
}
if noiseService.hasEstablishedSession(with: peerID) {
SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
// Create read receipt payload: [type byte] + [message ID]
var receiptPayload = Data([NoisePayloadType.readReceipt.rawValue])
receiptPayload.append(contentsOf: receipt.originalMessageID.utf8)
do { do {
let encrypted = try noiseService.encrypt(payload, for: peerID) let encrypted = try noiseService.encrypt(receiptPayload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: Data(hexString: myPeerID) ?? Data(),
@@ -598,24 +565,20 @@ final class BLEService: NSObject {
signature: nil, signature: nil,
ttl: messageTTL ttl: messageTTL
) )
// If already on messageQueue, call directly
if DispatchQueue.getSpecific(key: messageQueueKey) != nil { if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(packet) broadcastPacket(packet)
} else { } else {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) } messageQueue.async { [weak self] in
self?.broadcastPacket(packet)
} }
}
// Read receipt sent
} catch { } catch {
SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error) SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error)
} }
} else {
// Queue for after handshake and initiate if needed
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
}
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
SecureLogger.log("🕒 Queued READ receipt for \(peerID) until handshake completes",
category: SecureLogger.session, level: .debug)
}
} }
func sendBroadcastAnnounce() { func sendBroadcastAnnounce() {
@@ -734,26 +697,19 @@ final class BLEService: NSObject {
self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID) self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID)
} else { } else {
// Public broadcast // Public broadcast
// Create packet with explicit fields so we can sign it // Public message - logged at relay point for mesh debugging
let basePacket = BitchatPacket( let packet = BitchatPacket(
type: MessageType.message.rawValue, type: MessageType.message.rawValue,
senderID: Data(hexString: self.myPeerID) ?? Data(), ttl: self.messageTTL,
recipientID: nil, senderID: self.myPeerID,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: Data(content.utf8)
payload: Data(content.utf8),
signature: nil,
ttl: self.messageTTL
) )
guard let signedPacket = self.noiseService.signPacket(basePacket) else {
SecureLogger.log("❌ Failed to sign public message", category: SecureLogger.security, level: .error)
return
}
// Pre-mark our own broadcast as processed to avoid handling relayed self copy // Pre-mark our own broadcast as processed to avoid handling relayed self copy
let senderHex = signedPacket.senderID.hexEncodedString() let senderHex = packet.senderID.hexEncodedString()
let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)" let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)"
self.messageDeduplicator.markProcessed(dedupID) self.messageDeduplicator.markProcessed(dedupID)
// Call synchronously since we're already on background queue // Call synchronously since we're already on background queue
self.broadcastPacket(signedPacket) self.broadcastPacket(packet)
} }
} }
} }
@@ -1081,23 +1037,15 @@ final class BLEService: NSObject {
} }
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically // For broadcast (no directed peer) and non-fragment, choose a subset deterministically
// Special-case announces: do NOT subset to maximize reach for presence
var selectedPeripheralIDs = Set(allowedPeripheralIDs) var selectedPeripheralIDs = Set(allowedPeripheralIDs)
var selectedCentralIDs = Set(allowedCentralIDs) var selectedCentralIDs = Set(allowedCentralIDs)
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue && packet.type != MessageType.announce.rawValue { if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue {
let kp = subsetSizeForFanout(allowedPeripheralIDs.count) let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
let kc = subsetSizeForFanout(allowedCentralIDs.count) let kc = subsetSizeForFanout(allowedCentralIDs.count)
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID) selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID) selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID)
} }
// If directed and we currently have no links to forward on, spool for a short window
if let only = directedOnlyPeer,
selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty,
(packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {
spoolDirectedPacket(packet, recipientPeerID: only)
}
// Writes to selected connected peripherals // Writes to selected connected peripherals
for s in states where s.isConnected { for s in states where s.isConnected {
let pid = s.peripheral.identifier.uuidString let pid = s.peripheral.identifier.uuidString
@@ -1115,57 +1063,6 @@ final class BLEService: NSObject {
} }
} }
// MARK: - Directed store-and-forward
private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) {
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
var byMsg = self.pendingDirectedRelays[recipientPeerID] ?? [:]
if byMsg[msgID] == nil {
byMsg[msgID] = (packet: packet, enqueuedAt: Date())
self.pendingDirectedRelays[recipientPeerID] = byMsg
SecureLogger.log("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))", category: SecureLogger.session, level: .debug)
}
}
}
private func flushDirectedSpool() {
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
let toSend: [(String, BitchatPacket)] = collectionsQueue.sync(flags: .barrier) {
var out: [(String, BitchatPacket)] = []
let now = Date()
for (recipient, dict) in pendingDirectedRelays {
for (_, entry) in dict {
if now.timeIntervalSince(entry.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds {
out.append((recipient, entry.packet))
}
}
// Clear recipient bucket; items will be re-spooled if still no links
pendingDirectedRelays.removeValue(forKey: recipient)
}
return out
}
guard !toSend.isEmpty else { return }
for (_, packet) in toSend {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
}
}
private func rebroadcastRecentAnnounces() {
// Snapshot sender order to preserve ordering and avoid holding locks while sending
let packets: [BitchatPacket] = collectionsQueue.sync {
recentAnnounceOrder.compactMap { recentAnnounceBySender[$0] }
}
guard !packets.isEmpty else { return }
for (idx, pkt) in packets.enumerated() {
// Stagger slightly to avoid bursts
let delayMs = idx * 20
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
self?.broadcastPacket(pkt)
}
}
}
private func sendData(_ data: Data, to peripheral: CBPeripheral) { private func sendData(_ data: Data, to peripheral: CBPeripheral) {
// Fire-and-forget: Simple send without complex fallback logic // Fire-and-forget: Simple send without complex fallback logic
guard peripheral.state == .connected else { return } guard peripheral.state == .connected else { return }
@@ -1230,8 +1127,7 @@ final class BLEService: NSObject {
ttl: packet.ttl ttl: packet.ttl
) )
// Pace fragments with small jitter to avoid bursts // Pace fragments with small jitter to avoid bursts
let perFragMs = (directedOnlyPeer != nil || packet.recipientID != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs let delayMs = index * TransportConfig.bleFragmentSpacingMs // ~6ms spacing per fragment
let delayMs = index * perFragMs
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
self?.broadcastPacket(fragmentPacket) self?.broadcastPacket(fragmentPacket)
} }
@@ -1326,16 +1222,12 @@ final class BLEService: NSObject {
SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)", SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
} }
// In sparse graphs (<=2 neighbors), keep the pending relay to ensure bridging. // Cancel any pending relay for this message (arrived via another neighbor)
// In denser graphs, cancel the pending relay to reduce redundant floods.
let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }
if connectedCount > 2 {
collectionsQueue.async(flags: .barrier) { [weak self] in collectionsQueue.async(flags: .barrier) { [weak self] in
if let task = self?.scheduledRelays.removeValue(forKey: messageID) { if let task = self?.scheduledRelays.removeValue(forKey: messageID) {
task.cancel() task.cancel()
} }
} }
}
return // Duplicate ignored return // Duplicate ignored
} }
@@ -1392,7 +1284,6 @@ final class BLEService: NSObject {
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil), isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil),
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue, isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
isAnnounce: packet.type == MessageType.announce.rawValue,
degree: degree, degree: degree,
highDegreeThreshold: highDegreeThreshold highDegreeThreshold: highDegreeThreshold
) )
@@ -1443,14 +1334,11 @@ final class BLEService: NSObject {
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
// Check if we have an actual BLE connection to this peer // Check if we have an actual BLE connection to this peer
let peripheralUUID = peerToPeripheralUUID[peerID] let peripheralUUID = peerToPeripheralUUID[peerID]
let hasPeripheralConnection = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true _ = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true // hasPeripheralConnection
// Check if this peer is subscribed to us as a central // Check if this peer is subscribed to us as a central
// Note: We can't identify which specific central is which peer without additional mapping // Note: We can't identify which specific central is which peer without additional mapping
let hasCentralSubscription = centralToPeerID.values.contains(peerID) _ = !subscribedCentrals.isEmpty // hasCentralSubscription
// Direct announces arrive with full TTL (no prior hop)
let isDirectAnnounce = (packet.ttl == messageTTL)
// Check if we already have this peer (might be reconnecting) // Check if we already have this peer (might be reconnecting)
let existingPeer = peers[peerID] let existingPeer = peers[peerID]
@@ -1488,7 +1376,7 @@ final class BLEService: NSObject {
peers[peerID] = PeerInfo( peers[peerID] = PeerInfo(
id: existing.id, id: existing.id,
nickname: announcement.nickname, nickname: announcement.nickname,
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, isConnected: true,
noisePublicKey: announcement.noisePublicKey, noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey, signingPublicKey: announcement.signingPublicKey,
isVerifiedNickname: true, isVerifiedNickname: true,
@@ -1499,7 +1387,7 @@ final class BLEService: NSObject {
peers[peerID] = PeerInfo( peers[peerID] = PeerInfo(
id: peerID, id: peerID,
nickname: announcement.nickname, nickname: announcement.nickname,
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, isConnected: true,
noisePublicKey: announcement.noisePublicKey, noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey, signingPublicKey: announcement.signingPublicKey,
isVerifiedNickname: true, isVerifiedNickname: true,
@@ -1507,51 +1395,15 @@ final class BLEService: NSObject {
) )
} }
// Log connection status only for direct connectivity changes; debounce to reduce spam // Log connection status
if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {
let now = Date()
if existingPeer == nil { if existingPeer == nil {
SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .debug) SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .debug)
} else if wasDisconnected { } else if wasDisconnected {
// Debounce 'reconnected' logs within short window
if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds {
// Skip duplicate log
} else {
SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .debug) SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .debug)
lastReconnectLogAt[peerID] = now
}
} else if existingPeer?.nickname != announcement.nickname { } else if existingPeer?.nickname != announcement.nickname {
SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .debug) SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .debug)
} }
} }
}
// Persist cryptographic identity and signing key for robust offline verification
do {
// Derive fingerprint from Noise public key
let hash = SHA256.hash(data: announcement.noisePublicKey)
let fingerprint = hash.map { String(format: "%02x", $0) }.joined()
SecureIdentityStateManager.shared.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
claimedNickname: announcement.nickname
)
}
// Record this announce for lightweight rebroadcast buffer (exclude self)
if peerID != myPeerID {
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self.recentAnnounceBySender[peerID] = packet
if !self.recentAnnounceOrder.contains(peerID) { self.recentAnnounceOrder.append(peerID) }
// Trim to cap, oldest first
while self.recentAnnounceOrder.count > self.recentAnnounceBufferCap {
let victim = self.recentAnnounceOrder.removeFirst()
self.recentAnnounceBySender.removeValue(forKey: victim)
}
}
}
// Notify UI on main thread // Notify UI on main thread
notifyUI { [weak self] in notifyUI { [weak self] in
@@ -1560,8 +1412,8 @@ final class BLEService: NSObject {
// Get current peer list (after addition) // Get current peer list (after addition)
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
// Only notify of connection for new or reconnected peers when it is a direct announce // Only notify of connection for new or reconnected peers
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) { if isNewPeer || isReconnectedPeer {
self.delegate?.didConnectToPeer(peerID) self.delegate?.didConnectToPeer(peerID)
} }
@@ -1581,14 +1433,6 @@ final class BLEService: NSObject {
// Force send to ensure the peer receives our announce // Force send to ensure the peer receives our announce
sendAnnounce(forceSend: true) sendAnnounce(forceSend: true)
} }
// Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop
if isNewPeer {
let delay = Double.random(in: 0.3...0.6)
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.sendAnnounce(forceSend: true)
}
}
} }
// Mention parsing moved to ChatViewModel // Mention parsing moved to ChatViewModel
@@ -1597,40 +1441,8 @@ final class BLEService: NSObject {
// Ignore self-origin public messages that may be seen again via relay // Ignore self-origin public messages that may be seen again via relay
if peerID == myPeerID { return } if peerID == myPeerID { return }
var accepted = false // Enforce: only accept public messages from verified peers we know
var senderNickname: String = "" guard let info = peers[peerID], info.isVerifiedNickname else {
if let info = peers[peerID], info.isVerifiedNickname {
// Known verified peer path
accepted = true
senderNickname = info.nickname
// Handle nickname collisions
let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname)
if hasCollision {
senderNickname += "#" + String(peerID.prefix(4))
}
} else {
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
// Find candidate identities by peerID prefix (16 hex)
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(peerID)
for candidate in candidates {
if let signingKey = candidate.signingPublicKey,
noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) {
accepted = true
// Prefer persisted social petname or claimed nickname
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: candidate.fingerprint) {
senderNickname = social.localPetname ?? social.claimedNickname
} else {
senderNickname = "anon" + String(peerID.prefix(4))
}
break
}
}
}
}
guard accepted else {
SecureLogger.log("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))", category: SecureLogger.security, level: .warning) SecureLogger.log("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))", category: SecureLogger.security, level: .warning)
return return
} }
@@ -1639,16 +1451,16 @@ final class BLEService: NSObject {
SecureLogger.log("❌ Failed to decode message payload as UTF-8", category: SecureLogger.session, level: .error) SecureLogger.log("❌ Failed to decode message payload as UTF-8", category: SecureLogger.session, level: .error)
return return
} }
// Determine if we have a direct link to the sender
let hasDirectLink: Bool = collectionsQueue.sync { // Resolve display nickname; if collisions exist, append short peerID suffix
let perUUID = peerToPeripheralUUID[peerID] var senderNickname = info.nickname
let perConnected = perUUID != nil && peripherals[perUUID!]?.isConnected == true // Treat a collision if another connected peer shares the nickname OR our own nickname matches
let hasCentral = centralToPeerID.values.contains(peerID) let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname)
return perConnected || hasCentral if hasCollision {
senderNickname += "#" + String(peerID.prefix(4))
} }
let pathTag = hasDirectLink ? "direct" : "mesh" SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug)
SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in notifyUI { [weak self] in
@@ -1847,13 +1659,18 @@ final class BLEService: NSObject {
} }
func sendDeliveryAck(for messageID: String, to peerID: String) { func sendDeliveryAck(for messageID: String, to peerID: String) {
// Create typed payload: [type byte] + [message ID] // Send encrypted delivery ACK
var payload = Data([NoisePayloadType.delivered.rawValue]) guard noiseService.hasSession(with: peerID) else {
payload.append(contentsOf: messageID.utf8) SecureLogger.log("Cannot send ACK - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning)
return
}
// Create ACK payload: [type byte] + [message ID]
var ackPayload = Data([NoisePayloadType.delivered.rawValue])
ackPayload.append(contentsOf: messageID.utf8)
if noiseService.hasEstablishedSession(with: peerID) {
do { do {
let encrypted = try noiseService.encrypt(payload, for: peerID) let encrypted = try noiseService.encrypt(ackPayload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: Data(hexString: myPeerID) ?? Data(),
@@ -1864,48 +1681,10 @@ final class BLEService: NSObject {
ttl: messageTTL ttl: messageTTL
) )
broadcastPacket(packet) broadcastPacket(packet)
// Delivery ACK sent
} catch { } catch {
SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error) SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error)
} }
} else {
// Queue for after handshake and initiate if needed
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
}
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
SecureLogger.log("🕒 Queued DELIVERED ack for \(peerID) until handshake completes",
category: SecureLogger.session, level: .debug)
}
}
private func sendPendingNoisePayloadsAfterHandshake(for peerID: String) {
let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in
let list = pendingNoisePayloadsAfterHandshake[peerID] ?? []
pendingNoisePayloadsAfterHandshake.removeValue(forKey: peerID)
return list
}
guard !payloads.isEmpty else { return }
SecureLogger.log("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake",
category: SecureLogger.session, level: .debug)
for payload in payloads {
do {
let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted,
signature: nil,
ttl: messageTTL
)
broadcastPacket(packet)
} catch {
SecureLogger.log("❌ Failed to send pending noise payload to \(peerID): \(error)",
category: SecureLogger.noise, level: .error)
}
}
} }
private func updatePeerLastSeen(_ peerID: String) { private func updatePeerLastSeen(_ peerID: String) {
@@ -1918,18 +1697,6 @@ final class BLEService: NSObject {
} }
} }
// Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window
private func notifyPeerDisconnectedDebounced(_ peerID: String) {
let now = Date()
let last = recentDisconnectNotifies[peerID]
if last == nil || now.timeIntervalSince(last!) >= TransportConfig.bleDisconnectNotifyDebounceSeconds {
delegate?.didDisconnectFromPeer(peerID)
recentDisconnectNotifies[peerID] = now
} else {
// Suppressed duplicate disconnect notification
}
}
// NEW: Publish peer snapshots to subscribers and notify Transport delegates // NEW: Publish peer snapshots to subscribers and notify Transport delegates
private func publishFullPeerData() { private func publishFullPeerData() {
let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync { let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
@@ -1982,16 +1749,6 @@ final class BLEService: NSObject {
if elapsed >= target { sendAnnounce(forceSend: true) } if elapsed >= target { sendAnnounce(forceSend: true) }
} }
// Activity-driven quick-announce: if we've seen any packet in last 5s and it has
// been >=10s since the last announce, send a presence nudge.
let recentSeen = collectionsQueue.sync { () -> Bool in
let cutoff = now.addingTimeInterval(-5.0)
return recentPacketTimestamps.contains(where: { $0 >= cutoff })
}
if recentSeen && elapsed >= 10.0 {
sendAnnounce(forceSend: true)
}
// If we have no peers, ensure we're scanning and advertising // If we have no peers, ensure we're scanning and advertising
if peers.isEmpty { if peers.isEmpty {
// Ensure we're advertising as peripheral // Ensure we're advertising as peripheral
@@ -2004,19 +1761,16 @@ final class BLEService: NSObject {
updateScanningDutyCycle(connectedCount: connectedCount) updateScanningDutyCycle(connectedCount: connectedCount)
updateRSSIThreshold(connectedCount: connectedCount) updateRSSIThreshold(connectedCount: connectedCount)
// Check peer connectivity every cycle for snappier UI updates // Every 20 seconds (2 cycles): Check peer connectivity
if maintenanceCounter % 2 == 0 {
checkPeerConnectivity() checkPeerConnectivity()
}
// Every 30 seconds (3 cycles): Cleanup // Every 30 seconds (3 cycles): Cleanup
if maintenanceCounter % 3 == 0 { if maintenanceCounter % 3 == 0 {
performCleanup() performCleanup()
} }
// Attempt to flush any spooled directed messages periodically (~every 5 seconds)
if maintenanceCounter % 2 == 1 {
flushDirectedSpool()
}
// No rotating alias: nothing to refresh // No rotating alias: nothing to refresh
// Reset counter to prevent overflow (every 60 seconds) // Reset counter to prevent overflow (every 60 seconds)
@@ -2029,39 +1783,28 @@ final class BLEService: NSObject {
let now = Date() let now = Date()
var disconnectedPeers: [String] = [] var disconnectedPeers: [String] = []
var removedOfflineCount = 0
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
for (peerID, peer) in peers { for (peerID, peer) in peers {
let age = now.timeIntervalSince(peer.lastSeen) if peer.isConnected && now.timeIntervalSince(peer.lastSeen) > TransportConfig.blePeerInactivityTimeoutSeconds {
let retention: TimeInterval = peer.isVerifiedNickname ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
// Check if we still have an active BLE connection to this peer // Check if we still have an active BLE connection to this peer
let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil && let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil &&
peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true
let hasCentralConnection = centralToPeerID.values.contains(peerID) let hasCentralConnection = centralToPeerID.values.contains(peerID)
// If direct link is gone, mark as not connected (retain entry for reachability) // Only remove if we don't have an active BLE connection
if !hasPeripheralConnection && !hasCentralConnection { if !hasPeripheralConnection && !hasCentralConnection {
var updated = peer // Remove the peer completely (they'll be re-added when they reconnect)
updated.isConnected = false SecureLogger.log("⏱️ Peer timed out (no packets for 20s): \(peerID) (\(peer.nickname))",
peers[peerID] = updated
disconnectedPeers.append(peerID)
}
}
// Cleanup: remove peers that are not connected and past reachability retention
if !peer.isConnected {
if age > retention {
SecureLogger.log("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
peers.removeValue(forKey: peerID) peers.removeValue(forKey: peerID)
removedOfflineCount += 1 disconnectedPeers.append(peerID)
} }
} }
} }
} }
// Update UI if there were direct disconnections or offline removals // Update UI if any peers were disconnected
if !disconnectedPeers.isEmpty || removedOfflineCount > 0 { if !disconnectedPeers.isEmpty {
notifyUI { [weak self] in notifyUI { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -2071,8 +1814,6 @@ final class BLEService: NSObject {
for peerID in disconnectedPeers { for peerID in disconnectedPeers {
self.delegate?.didDisconnectFromPeer(peerID) self.delegate?.didDisconnectFromPeer(peerID)
} }
// Publish snapshots so UnifiedPeerService updates connection/reachability icons
self.publishFullPeerData()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
@@ -2116,15 +1857,6 @@ final class BLEService: NSObject {
if !self.ingressByMessageID.isEmpty { if !self.ingressByMessageID.isEmpty {
self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff } self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff }
} }
// Clean expired directed spooled items
if !self.pendingDirectedRelays.isEmpty {
var cleaned: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]
for (recipient, dict) in self.pendingDirectedRelays {
let pruned = dict.filter { now.timeIntervalSince($0.value.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds }
if !pruned.isEmpty { cleaned[recipient] = pruned }
}
self.pendingDirectedRelays = cleaned
}
} }
} }
@@ -2136,13 +1868,7 @@ final class BLEService: NSObject {
#else #else
let active = true let active = true
#endif #endif
// Force full-time scanning if we have very few neighbors or very recent traffic let shouldDuty = dutyEnabled && active && connectedCount > 0
let hasRecentTraffic: Bool = collectionsQueue.sync {
let cutoff = Date().addingTimeInterval(-TransportConfig.bleRecentTrafficForceScanSeconds)
return recentPacketTimestamps.contains(where: { $0 >= cutoff })
}
let forceScanOn = (connectedCount <= 2) || hasRecentTraffic
let shouldDuty = dutyEnabled && active && connectedCount > 0 && !forceScanOn
if shouldDuty { if shouldDuty {
if scanDutyTimer == nil { if scanDutyTimer == nil {
// Start timer to toggle scanning on/off // Start timer to toggle scanning on/off
@@ -2412,11 +2138,6 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
SecureLogger.log("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", SecureLogger.log("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
// If disconnect carried an error (often timeout), apply short backoff to avoid thrash
if error != nil {
recentConnectTimeouts[peripheralID] = Date()
}
// Clean up references // Clean up references
peripherals.removeValue(forKey: peripheralID) peripherals.removeValue(forKey: peripheralID)
@@ -2424,12 +2145,9 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
if let peerID = peerID { if let peerID = peerID {
peerToPeripheralUUID.removeValue(forKey: peerID) peerToPeripheralUUID.removeValue(forKey: peerID)
// Do not remove peer; mark as not connected but retain for reachability // Remove peer completely (they'll be re-added when they reconnect and announce)
collectionsQueue.sync(flags: .barrier) { _ = collectionsQueue.sync(flags: .barrier) {
if var info = peers[peerID] { peers.removeValue(forKey: peerID)
info.isConnected = false
peers[peerID] = info
}
} }
} }
@@ -2444,7 +2162,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
// Attempt to fill freed slot from queue // Attempt to fill freed slot from queue
bleQueue.async { [weak self] in self?.tryConnectFromQueue() } bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
// Notify delegate about disconnection on main thread (direct link dropped) // Notify delegate about disconnection on main thread
notifyUI { [weak self] in notifyUI { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -2452,7 +2170,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
if let peerID = peerID { if let peerID = peerID {
self.notifyPeerDisconnectedDebounced(peerID) self.delegate?.didDisconnectFromPeer(peerID)
} }
self.publishFullPeerData() self.publishFullPeerData()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
@@ -2504,18 +2222,6 @@ extension BLEService {
guard candidate.isConnectable else { return } guard candidate.isConnectable else { return }
let peripheral = candidate.peripheral let peripheral = candidate.peripheral
let peripheralID = peripheral.identifier.uuidString let peripheralID = peripheral.identifier.uuidString
// Weak-link cooldown: if we recently timed out and RSSI is very weak, delay retries
if let lastTO = recentConnectTimeouts[peripheralID] {
let elapsed = Date().timeIntervalSince(lastTO)
if elapsed < TransportConfig.bleWeakLinkCooldownSeconds && candidate.rssi <= TransportConfig.bleWeakLinkRSSICutoff {
// Requeue the candidate and try again later
connectionCandidates.append(candidate)
let remaining = TransportConfig.bleWeakLinkCooldownSeconds - elapsed
let delay = min(max(2.0, remaining), 15.0)
bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() }
return
}
}
if peripherals[peripheralID]?.isConnected == true || peripherals[peripheralID]?.isConnecting == true { if peripherals[peripheralID]?.isConnected == true || peripherals[peripheralID]?.isConnecting == true {
// Already in progress; skip // Already in progress; skip
bleQueue.async { [weak self] in self?.tryConnectFromQueue() } bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
@@ -2649,10 +2355,6 @@ extension BLEService: CBPeripheralDelegate {
// Send announce after subscription is confirmed (force send for new connection) // Send announce after subscription is confirmed (force send for new connection)
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true) self?.sendAnnounce(forceSend: true)
// Try flushing any spooled directed packets now that we have a link
self?.flushDirectedSpool()
// Rebroadcast a couple of recent announces to seed the new link
self?.rebroadcastRecentAnnounces()
} }
} else { } else {
SecureLogger.log("⚠️ Characteristic does not support notifications", category: SecureLogger.session, level: .warning) SecureLogger.log("⚠️ Characteristic does not support notifications", category: SecureLogger.session, level: .warning)
@@ -2815,10 +2517,6 @@ extension BLEService: CBPeripheralManagerDelegate {
// Send announce to the newly subscribed central after a small delay to avoid overwhelming // Send announce to the newly subscribed central after a small delay to avoid overwhelming
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true) self?.sendAnnounce(forceSend: true)
// Flush any spooled directed packets now that we have a central subscribed
self?.flushDirectedSpool()
// Rebroadcast a couple of recent announces to seed the new link
self?.rebroadcastRecentAnnounces()
} }
} }
@@ -2835,12 +2533,9 @@ extension BLEService: CBPeripheralManagerDelegate {
// Find and disconnect the peer associated with this central // Find and disconnect the peer associated with this central
let centralUUID = central.identifier.uuidString let centralUUID = central.identifier.uuidString
if let peerID = centralToPeerID[centralUUID] { if let peerID = centralToPeerID[centralUUID] {
// Mark peer as not connected; retain for reachability // Remove peer completely (they'll be re-added when they reconnect)
collectionsQueue.sync(flags: .barrier) { _ = collectionsQueue.sync(flags: .barrier) {
if var info = peers[peerID] { peers.removeValue(forKey: peerID)
info.isConnected = false
peers[peerID] = info
}
} }
// Clean up mappings // Clean up mappings
@@ -2853,9 +2548,7 @@ extension BLEService: CBPeripheralManagerDelegate {
// Get current peer list (after removal) // Get current peer list (after removal)
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
self.notifyPeerDisconnectedDebounced(peerID) self.delegate?.didDisconnectFromPeer(peerID)
// Publish snapshots so UnifiedPeerService can refresh icons promptly
self.publishFullPeerData()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
+9 -11
View File
@@ -37,11 +37,11 @@ final class MessageRouter {
} }
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) { func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
let reachableMesh = mesh.isPeerReachable(peerID) let hasMesh = mesh.isPeerConnected(peerID)
if reachableMesh { let hasEstablished = mesh.getNoiseService().hasEstablishedSession(with: peerID)
SecureLogger.log("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", if hasMesh && hasEstablished {
SecureLogger.log("Routing PM via mesh to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
// BLEService will initiate a handshake if needed and queue the message
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) { } else if canSendViaNostr(peerID: peerID) {
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
@@ -57,9 +57,9 @@ final class MessageRouter {
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
// Prefer mesh for reachable peers; BLE will queue if handshake is needed // Prefer mesh only if a Noise session is established; else use Nostr to avoid handshakeRequired spam
if mesh.isPeerReachable(peerID) { if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) {
SecureLogger.log("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", SecureLogger.log("Routing READ ack via mesh to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
mesh.sendReadReceipt(receipt, to: peerID) mesh.sendReadReceipt(receipt, to: peerID)
} else { } else {
@@ -70,9 +70,7 @@ final class MessageRouter {
} }
func sendDeliveryAck(_ messageID: String, to peerID: String) { func sendDeliveryAck(_ messageID: String, to peerID: String) {
if mesh.isPeerReachable(peerID) { if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) {
SecureLogger.log("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
mesh.sendDeliveryAck(for: messageID, to: peerID) mesh.sendDeliveryAck(for: messageID, to: peerID)
} else { } else {
nostr.sendDeliveryAck(for: messageID, to: peerID) nostr.sendDeliveryAck(for: messageID, to: peerID)
@@ -103,7 +101,7 @@ final class MessageRouter {
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
// Prefer mesh if connected; else try Nostr if mapping exists // Prefer mesh if connected; else try Nostr if mapping exists
for (content, nickname, messageID) in queued { for (content, nickname, messageID) in queued {
if mesh.isPeerReachable(peerID) { if mesh.isPeerConnected(peerID) {
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
-1
View File
@@ -31,7 +31,6 @@ final class NostrTransport: Transport {
func emergencyDisconnectAll() { /* no-op */ } func emergencyDisconnectAll() { /* no-op */ }
func isPeerConnected(_ peerID: String) -> Bool { false } func isPeerConnected(_ peerID: String) -> Bool { false }
func isPeerReachable(_ peerID: String) -> Bool { false }
func peerNickname(peerID: String) -> String? { nil } func peerNickname(peerID: String) -> String? { nil }
func getPeerNicknames() -> [String : String] { [:] } func getPeerNicknames() -> [String : String] { [:] }
+4 -13
View File
@@ -15,7 +15,6 @@ struct RelayController {
isDirectedEncrypted: Bool, isDirectedEncrypted: Bool,
isDirectedFragment: Bool, isDirectedFragment: Bool,
isHandshake: Bool, isHandshake: Bool,
isAnnounce: Bool,
degree: Int, degree: Int,
highDegreeThreshold: Int) -> RelayDecision { highDegreeThreshold: Int) -> RelayDecision {
// Suppress obvious non-relays // Suppress obvious non-relays
@@ -26,8 +25,7 @@ struct RelayController {
// Always relay with no TTL cap for these types // Always relay with no TTL cap for these types
let newTTL = (ttl &- 1) let newTTL = (ttl &- 1)
// Slight jitter to desynchronize without adding too much latency // Slight jitter to desynchronize without adding too much latency
// Tighter for faster multi-hop handshakes and directed DMs let delayRange: ClosedRange<Int> = isHandshake ? 20...60 : 40...120
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
let delayMs = Int.random(in: delayRange) let delayMs = Int.random(in: delayRange)
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs) return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
} }
@@ -44,22 +42,15 @@ struct RelayController {
let prob = baseProb let prob = baseProb
let shouldRelay = Double.random(in: 0...1) <= prob let shouldRelay = Double.random(in: 0...1) <= prob
// TTL clamping for broadcast // TTL clamping in dense graphs (only for broadcast)
// - Dense graphs: keep very low to avoid floods let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5
// - Sparse graphs: allow slightly longer reach for multi-hop discovery
// - Announces in sparse graphs get a bit more headroom
let ttlCap: UInt8 = {
if degree >= highDegreeThreshold { return 3 }
return isAnnounce ? 7 : 6
}()
let clamped = max(1, min(ttl, ttlCap)) let clamped = max(1, min(ttl, ttlCap))
let newTTL = clamped &- 1 let newTTL = clamped &- 1
// Wider jitter window to allow duplicate suppression to win more often // Wider jitter window to allow duplicate suppression to win more often
// For sparse graphs (<=2), relay quickly to avoid cancellation races
let delayMs: Int let delayMs: Int
switch degree { switch degree {
case 0...2: delayMs = Int.random(in: 10...40) case 0...2: delayMs = Int.random(in: 40...100)
case 3...5: delayMs = Int.random(in: 60...150) case 3...5: delayMs = Int.random(in: 60...150)
case 6...9: delayMs = Int.random(in: 80...180) case 6...9: delayMs = Int.random(in: 80...180)
default: delayMs = Int.random(in: 100...220) default: delayMs = Int.random(in: 100...220)
-1
View File
@@ -29,7 +29,6 @@ protocol Transport: AnyObject {
// Connectivity and peers // Connectivity and peers
func isPeerConnected(_ peerID: String) -> Bool func isPeerConnected(_ peerID: String) -> Bool
func isPeerReachable(_ peerID: String) -> Bool
func peerNickname(peerID: String) -> String? func peerNickname(peerID: String) -> String?
func getPeerNicknames() -> [String: String] func getPeerNicknames() -> [String: String]
+11 -32
View File
@@ -62,7 +62,7 @@ enum TransportConfig {
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60 static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
// BLE maintenance & thresholds // BLE maintenance & thresholds
static let bleMaintenanceInterval: TimeInterval = 5.0 static let bleMaintenanceInterval: TimeInterval = 10.0
static let bleMaintenanceLeewaySeconds: Int = 1 static let bleMaintenanceLeewaySeconds: Int = 1
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60 static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60
static let bleRecentTimeoutWindowSeconds: TimeInterval = 60 static let bleRecentTimeoutWindowSeconds: TimeInterval = 60
@@ -71,32 +71,23 @@ enum TransportConfig {
static let bleRSSIIsolatedRelaxed: Int = -92 static let bleRSSIIsolatedRelaxed: Int = -92
static let bleRSSIConnectedThreshold: Int = -85 static let bleRSSIConnectedThreshold: Int = -85
static let bleRSSIHighTimeoutThreshold: Int = -80 static let bleRSSIHighTimeoutThreshold: Int = -80
// How long without seeing traffic before we sanity-check the direct link static let blePeerInactivityTimeoutSeconds: TimeInterval = 20.0
// Lowered to make connectedreachable icon changes react faster when walking out of range
static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0
// How long to retain a peer as "reachable" (not directly connected) since lastSeen
static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0 // 21s for verified/favorites
static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0 // 21s for unknown/unverified
static let bleFragmentLifetimeSeconds: TimeInterval = 30.0 static let bleFragmentLifetimeSeconds: TimeInterval = 30.0
static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0 static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0
static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0 static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0
static let bleRecentPacketWindowSeconds: TimeInterval = 30.0 static let bleRecentPacketWindowSeconds: TimeInterval = 30.0
static let bleRecentPacketWindowMaxCount: Int = 100 static let bleRecentPacketWindowMaxCount: Int = 100
// Keep scanning fully ON when we saw traffic very recently
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05 static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
static let bleExpectedWritePerFragmentMs: Int = 8 static let bleExpectedWritePerFragmentMs: Int = 8
static let bleExpectedWriteMaxMs: Int = 2000 static let bleExpectedWriteMaxMs: Int = 2000
// Faster fragment pacing; use slightly tighter spacing for directed trains static let bleFragmentSpacingMs: Int = 6
static let bleFragmentSpacingMs: Int = 5 static let bleAnnounceIntervalSeconds: TimeInterval = 10.0
static let bleFragmentSpacingDirectedMs: Int = 4
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
static let bleDutyOnDurationDense: TimeInterval = 3.0 static let bleDutyOnDurationDense: TimeInterval = 3.0
static let bleDutyOffDurationDense: TimeInterval = 15.0 static let bleDutyOffDurationDense: TimeInterval = 15.0
static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 30.0 static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 90.0
static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 15.0 static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 45.0
static let bleConnectedAnnounceJitterDense: TimeInterval = 8.0 static let bleConnectedAnnounceJitterDense: TimeInterval = 20.0
static let bleConnectedAnnounceJitterSparse: TimeInterval = 4.0 static let bleConnectedAnnounceJitterSparse: TimeInterval = 7.5
// Location // Location
static let locationDistanceFilterMeters: Double = 1000 static let locationDistanceFilterMeters: Double = 1000
@@ -135,24 +126,12 @@ enum TransportConfig {
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24 static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
// BLE operational delays // BLE operational delays
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6 static let bleInitialAnnounceDelaySeconds: TimeInterval = 2.0
static let bleConnectTimeoutSeconds: TimeInterval = 8.0 static let bleConnectTimeoutSeconds: TimeInterval = 8.0
static let bleRestartScanDelaySeconds: TimeInterval = 0.1 static let bleRestartScanDelaySeconds: TimeInterval = 0.1
static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.05 static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.1
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4 static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15 static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.2
// Store-and-forward for directed packets at relays
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
// Log/UI debounce windows
// Shorter debounce so UI reacts faster while still suppressing duplicate callbacks
static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9
static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0
// Weak-link cooldown after connection timeouts
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
static let bleWeakLinkRSSICutoff: Int = -90
// Content hashing / formatting // Content hashing / formatting
static let contentKeyPrefixLength: Int = 256 static let contentKeyPrefixLength: Int = 256
+17 -35
View File
@@ -68,28 +68,24 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
private func updatePeers() { private func updatePeers() {
let meshPeers = meshService.currentPeerSnapshots() let meshPeers = meshService.currentPeerSnapshots()
// If we have no direct links at all, peers should not be marked reachable
// "Reachable" means mesh-attached via at least one live link.
let hasAnyConnected = meshPeers.contains { $0.isConnected }
let favorites = favoritesService.favorites let favorites = favoritesService.favorites
var enrichedPeers: [BitchatPeer] = [] var enrichedPeers: [BitchatPeer] = []
var connected: Set<String> = [] var connected: Set<String> = []
var addedPeerIDs: Set<String> = [] var addedPeerIDs: Set<String> = []
// Phase 1: Add all mesh peers (connected and reachable) // Phase 1: Add all connected mesh peers
for peerInfo in meshPeers { for peerInfo in meshPeers where peerInfo.isConnected {
let peerID = peerInfo.id let peerID = peerInfo.id
guard peerID != meshService.myPeerID else { continue } // Never add self guard peerID != meshService.myPeerID else { continue } // Never add self
let peer = buildPeerFromMesh( let peer = buildPeerFromMesh(
peerInfo: peerInfo, peerInfo: peerInfo,
favorites: favorites, favorites: favorites
meshAttached: hasAnyConnected
) )
enrichedPeers.append(peer) enrichedPeers.append(peer)
if peer.isConnected { connected.insert(peerID) } connected.insert(peerID)
addedPeerIDs.insert(peerID) addedPeerIDs.insert(peerID)
// Update fingerprint cache // Update fingerprint cache
@@ -121,12 +117,14 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// Phase 3: Sort peers // Phase 3: Sort peers
enrichedPeers.sort { lhs, rhs in enrichedPeers.sort { lhs, rhs in
// Connectivity rank: connected > reachable > others // Connected first
func rank(_ p: BitchatPeer) -> Int { p.isConnected ? 2 : (p.isReachable ? 1 : 0) } if lhs.isConnected != rhs.isConnected {
let lr = rank(lhs), rr = rank(rhs) return lhs.isConnected
if lr != rr { return lr > rr } }
// Then favorites inside same rank // Then favorites
if lhs.isFavorite != rhs.isFavorite { return lhs.isFavorite } if lhs.isFavorite != rhs.isFavorite {
return lhs.isFavorite
}
// Finally alphabetical // Finally alphabetical
return lhs.displayName < rhs.displayName return lhs.displayName < rhs.displayName
} }
@@ -147,11 +145,8 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
} }
} }
// Phase 5: Filter out offline non-mutual peers and update published properties // Phase 5: Update published properties
let filtered = enrichedPeers.filter { p in self.peers = enrichedPeers
p.isConnected || p.isReachable || p.isMutualFavorite
}
self.peers = filtered
self.connectedPeerIDs = connected self.connectedPeerIDs = connected
self.favorites = favoritesList self.favorites = favoritesList
self.mutualFavorites = mutualsList self.mutualFavorites = mutualsList
@@ -167,26 +162,14 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
private func buildPeerFromMesh( private func buildPeerFromMesh(
peerInfo: TransportPeerSnapshot, peerInfo: TransportPeerSnapshot,
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship], favorites: [Data: FavoritesPersistenceService.FavoriteRelationship]
meshAttached: Bool
) -> BitchatPeer { ) -> BitchatPeer {
// Determine reachability based on lastSeen and identity trust
let now = Date()
let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()
let isVerified = fingerprint.map { SecureIdentityStateManager.shared.isVerified(fingerprint: $0) } ?? false
let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false
let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
// A peer is reachable if we recently saw them AND we are attached to the mesh
let withinRetention = now.timeIntervalSince(peerInfo.lastSeen) <= retention
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
var peer = BitchatPeer( var peer = BitchatPeer(
id: peerInfo.id, id: peerInfo.id,
noisePublicKey: peerInfo.noisePublicKey ?? Data(), noisePublicKey: peerInfo.noisePublicKey ?? Data(),
nickname: peerInfo.nickname, nickname: peerInfo.nickname,
lastSeen: peerInfo.lastSeen, lastSeen: peerInfo.lastSeen,
isConnected: peerInfo.isConnected, isConnected: true
isReachable: isReachable
) )
// Check for favorite status // Check for favorite status
@@ -233,8 +216,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
noisePublicKey: favorite.peerNoisePublicKey, noisePublicKey: favorite.peerNoisePublicKey,
nickname: favorite.peerNickname, nickname: favorite.peerNickname,
lastSeen: favorite.lastUpdated, lastSeen: favorite.lastUpdated,
isConnected: false, isConnected: false
isReachable: false
) )
peer.favoriteStatus = favorite peer.favoriteStatus = favorite
+10 -14
View File
@@ -935,13 +935,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if unreadPrivateMessages.contains(noiseKeyHex) { if unreadPrivateMessages.contains(noiseKeyHex) {
return true return true
} }
// Also check for geohash (Nostr) DM conv key if this peer has a known Nostr pubkey
if let nostrHex = peer.nostrPublicKey {
let convKey = "nostr_" + String(nostrHex.prefix(TransportConfig.nostrConvKeyPrefixLength))
if unreadPrivateMessages.contains(convKey) {
return true
}
}
} }
// Get the peer's nickname to check for temporary Nostr peer IDs // Get the peer's nickname to check for temporary Nostr peer IDs
@@ -1881,7 +1874,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Determine routing method and recipient nickname // Determine routing method and recipient nickname
guard let noiseKey = Data(hexString: peerID) else { return } guard let noiseKey = Data(hexString: peerID) else { return }
let isConnected = meshService.isPeerConnected(peerID) let isConnected = meshService.isPeerConnected(peerID)
let isReachable = meshService.isPeerReachable(peerID)
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
let isMutualFavorite = favoriteStatus?.isMutual ?? false let isMutualFavorite = favoriteStatus?.isMutual ?? false
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
@@ -1921,8 +1913,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Trigger UI update for sent message // Trigger UI update for sent message
objectWillChange.send() objectWillChange.send()
// Send via appropriate transport (BLE if connected/reachable, else Nostr when possible) // Send via appropriate transport (BLE if connected, else Nostr when possible)
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) { if isConnected || (isMutualFavorite && hasNostrKey) {
messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID) messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID)
// Optimistically mark as sent for both transports; delivery/read will update subsequently // Optimistically mark as sent for both transports; delivery/read will update subsequently
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
@@ -2550,12 +2542,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
} }
// If this originated over Nostr, skip (handled by Nostr code paths) // If we know the original transport, use it for the read receipt
if originalTransport == "nostr" { if originalTransport == "nostr" {
return // Skip read receipts for Nostr messages - unnecessary complexity
} // The radical simplification plan says to accept occasional loss
// Use router to decide (mesh if reachable, else Nostr if available) } else if meshService.peerNickname(peerID: actualPeerID) != nil {
// Use mesh for connected peers (default behavior)
messageRouter.sendReadReceipt(receipt, to: actualPeerID) messageRouter.sendReadReceipt(receipt, to: actualPeerID)
} else {
// Skip read receipts for offline peers - fire and forget principle
}
} }
@MainActor @MainActor
+21 -49
View File
@@ -1036,8 +1036,9 @@ struct ContentView: View {
case .mesh: case .mesh:
let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
guard peer.id != viewModel.meshService.myPeerID else { return } guard peer.id != viewModel.meshService.myPeerID else { return }
if peer.isConnected { counts.mesh += 1; counts.others += 1 } let isMeshConnected = peer.isConnected
else if peer.isReachable { counts.others += 1 } if isMeshConnected { counts.mesh += 1; counts.others += 1 }
else if peer.isMutualFavorite { counts.others += 1 }
} }
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary
@@ -1180,11 +1181,20 @@ struct ContentView: View {
@ViewBuilder @ViewBuilder
private func privateHeaderContent(for privatePeerID: String) -> some View { private func privateHeaderContent(for privatePeerID: String) -> some View {
// Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only. // Prefer short (mesh) ID when mesh-connected (radio). Only use full Noise key when not connected (globe).
let headerPeerID: String = { let headerPeerID: String = {
if privatePeerID.count == 64 { if privatePeerID.count == 16 {
// Map stable Noise key to short ID if we know it (even if not directly connected) let isMeshConnected = viewModel.meshService.isPeerConnected(privatePeerID) || viewModel.connectedPeers.contains(privatePeerID)
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short } if !isMeshConnected, let stable = viewModel.getNoiseKeyForShortID(privatePeerID) {
return stable
}
} else if privatePeerID.count == 64 {
// If we have a full Noise key and a corresponding short ID is currently mesh-connected, prefer short ID
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
if viewModel.meshService.isPeerConnected(short) || viewModel.connectedPeers.contains(short) {
return short
}
}
} }
return privatePeerID return privatePeerID
}() }()
@@ -1199,29 +1209,10 @@ struct ContentView: View {
return "#\(ch.geohash)/@\(disp)" return "#\(ch.geohash)/@\(disp)"
} }
} }
// Try mesh/unified peer display return peer?.displayName ??
if let name = peer?.displayName { return name } viewModel.meshService.peerNickname(peerID: headerPeerID) ??
// Try direct mesh nickname (connected-only) FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ??
if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name } "Unknown"
// Try favorite nickname by stable Noise key
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
!fav.peerNickname.isEmpty { return fav.peerNickname }
// Fallback: resolve from persisted social identity via fingerprint mapping
if headerPeerID.count == 16 {
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let id = candidates.first,
let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname }
}
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
let fp = keyData.sha256Fingerprint()
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname }
}
}
return "Unknown"
}() }()
let isNostrAvailable: Bool = { let isNostrAvailable: Bool = {
guard let connectionState = peer?.connectionState else { guard let connectionState = peer?.connectionState else {
@@ -1256,12 +1247,6 @@ struct ContentView: View {
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundColor(textColor) .foregroundColor(textColor)
.accessibilityLabel("Connected via mesh") .accessibilityLabel("Connected via mesh")
case .meshReachable:
// point.3 filled icon for reachable via mesh (not directly connected)
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.system(size: 14))
.foregroundColor(textColor)
.accessibilityLabel("Reachable via mesh")
case .nostrAvailable: case .nostrAvailable:
// Purple globe for Nostr // Purple globe for Nostr
Image(systemName: "globe") Image(systemName: "globe")
@@ -1272,12 +1257,6 @@ struct ContentView: View {
// Should not happen for PM header, but handle gracefully // Should not happen for PM header, but handle gracefully
EmptyView() EmptyView()
} }
} else if viewModel.meshService.isPeerReachable(headerPeerID) {
// Fallback: reachable via mesh but not in current peer list
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.system(size: 14))
.foregroundColor(textColor)
.accessibilityLabel("Reachable via mesh")
} else if isNostrAvailable { } else if isNostrAvailable {
// Fallback to Nostr if peer not in list but is mutual favorite // Fallback to Nostr if peer not in list but is mutual favorite
Image(systemName: "globe") Image(systemName: "globe")
@@ -1296,14 +1275,7 @@ struct ContentView: View {
.font(.system(size: 16, weight: .medium, design: .monospaced)) .font(.system(size: 16, weight: .medium, design: .monospaced))
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs) .foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
if !privatePeerID.hasPrefix("nostr_") { if !privatePeerID.hasPrefix("nostr_") {
// Use short peer ID if available for encryption status (sessions keyed by short ID) let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID)
let statusPeerID: String = {
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
return short
}
return headerPeerID
}()
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
if let icon = encryptionStatus.icon { if let icon = encryptionStatus.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 14)) .font(.system(size: 14))
+4 -22
View File
@@ -41,27 +41,9 @@ struct FingerprintView: View {
.padding() .padding()
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
// Prefer short mesh ID for session/encryption status // Peer info
let statusPeerID: String = { let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "Unknown"
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short } let encryptionStatus = viewModel.getEncryptionStatus(for: peerID)
return peerID
}()
// Resolve a friendly name
let peerNickname: String = {
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
if peerID.count == 64, let data = Data(hexString: peerID) {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
let fp = data.sha256Fingerprint()
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname }
}
}
return "Unknown"
}()
// Accurate encryption state based on short ID session
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
HStack { HStack {
if let icon = encryptionStatus.icon { if let icon = encryptionStatus.icon {
@@ -92,7 +74,7 @@ struct FingerprintView: View {
.font(.system(size: 12, weight: .bold, design: .monospaced)) .font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) { if let fingerprint = viewModel.getFingerprint(for: peerID) {
Text(formatFingerprint(fingerprint)) Text(formatFingerprint(fingerprint))
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
-13
View File
@@ -52,11 +52,6 @@ struct MeshPeerList: View {
Image(systemName: "antenna.radiowaves.left.and.right") Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 10)) .font(.system(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
} else if peer.isReachable {
// Mesh-reachable (relayed): point.3 icon
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.system(size: 10))
.foregroundColor(baseColor)
} else if peer.isMutualFavorite { } else if peer.isMutualFavorite {
// Mutual favorite reachable via Nostr: globe icon (purple) // Mutual favorite reachable via Nostr: globe icon (purple)
Image(systemName: "globe") Image(systemName: "globe")
@@ -115,14 +110,6 @@ struct MeshPeerList: View {
Spacer() Spacer()
// Unread message indicator for this peer
if !isMe, item.hasUnread {
Image(systemName: "envelope.fill")
.font(.system(size: 10))
.foregroundColor(.orange)
.help("New messages")
}
if !isMe { if !isMe {
Button(action: { onToggleFavorite(peer.id) }) { Button(action: { onToggleFavorite(peer.id) }) {
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star") Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")