mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:05:20 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06791ce218 | ||
|
|
95af23a8f6 | ||
|
|
b919b3ff0a | ||
|
|
ae2b247834 | ||
|
|
c6ed3cd665 | ||
|
|
08eceab7cd | ||
|
|
11950a7fe4 | ||
|
|
86bdb1af27 | ||
|
|
744e87f924 | ||
|
|
0d1450df4d | ||
|
|
89175e2065 | ||
|
|
bc383cb02f | ||
|
|
1913719662 | ||
|
|
fc690fc0ac | ||
|
|
35e4ad8914 | ||
|
|
f251c5333c | ||
|
|
92a0204368 | ||
|
|
acfce858ec | ||
|
|
dc9b9e996d |
@@ -328,7 +328,6 @@
|
|||||||
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
|
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
|
||||||
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
|
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
|
||||||
9F37F9F2C353B58AC809E93B /* Products */,
|
9F37F9F2C353B58AC809E93B /* Products */,
|
||||||
048A4BE52E5CCC5C00162C4A /* Recovered References */,
|
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -743,14 +742,13 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
||||||
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
|
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
|
||||||
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
|
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
|
||||||
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
|
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
|
||||||
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 */,
|
||||||
@@ -803,14 +801,13 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
||||||
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
|
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
|
||||||
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
|
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
|
||||||
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
|
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
|
||||||
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 */,
|
||||||
@@ -1007,7 +1004,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.2;
|
MARKETING_VERSION = 1.3.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -1038,7 +1035,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.2;
|
MARKETING_VERSION = 1.3.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -1093,7 +1090,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.2;
|
MARKETING_VERSION = 1.3.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -1125,7 +1122,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.3.1;
|
MARKETING_VERSION = 1.3.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -1214,7 +1211,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.3.1;
|
MARKETING_VERSION = 1.3.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -1307,7 +1304,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.2;
|
MARKETING_VERSION = 1.3.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ 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,6 +230,91 @@ 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)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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?
|
||||||
@@ -18,6 +19,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -25,6 +27,8 @@ 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
|
||||||
@@ -54,6 +58,8 @@ 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:
|
||||||
@@ -71,13 +77,15 @@ 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
|
||||||
|
|||||||
+423
-116
@@ -102,6 +102,12 @@ 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]?)] = []
|
||||||
@@ -122,6 +128,13 @@ 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
|
||||||
|
|
||||||
@@ -307,6 +320,11 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,6 +520,27 @@ 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 }
|
||||||
@@ -541,43 +580,41 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||||
// Send encrypted read receipt
|
// Create typed payload: [type byte] + [message ID]
|
||||||
guard noiseService.hasSession(with: peerID) else {
|
var payload = Data([NoisePayloadType.readReceipt.rawValue])
|
||||||
SecureLogger.log("Cannot send read receipt - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning)
|
payload.append(contentsOf: receipt.originalMessageID.utf8)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)",
|
if noiseService.hasEstablishedSession(with: peerID) {
|
||||||
category: SecureLogger.session, level: .debug)
|
SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
// Create read receipt payload: [type byte] + [message ID]
|
do {
|
||||||
var receiptPayload = Data([NoisePayloadType.readReceipt.rawValue])
|
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
||||||
receiptPayload.append(contentsOf: receipt.originalMessageID.utf8)
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
do {
|
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||||
let encrypted = try noiseService.encrypt(receiptPayload, for: peerID)
|
recipientID: Data(hexString: peerID),
|
||||||
let packet = BitchatPacket(
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
payload: encrypted,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
signature: nil,
|
||||||
recipientID: Data(hexString: peerID),
|
ttl: messageTTL
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
)
|
||||||
payload: encrypted,
|
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||||
signature: nil,
|
broadcastPacket(packet)
|
||||||
ttl: messageTTL
|
} else {
|
||||||
)
|
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
|
||||||
|
|
||||||
// If already on messageQueue, call directly
|
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
|
||||||
broadcastPacket(packet)
|
|
||||||
} else {
|
|
||||||
messageQueue.async { [weak self] in
|
|
||||||
self?.broadcastPacket(packet)
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
// Read receipt sent
|
// Queue for after handshake and initiate if needed
|
||||||
} catch {
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,19 +734,26 @@ final class BLEService: NSObject {
|
|||||||
self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID)
|
self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID)
|
||||||
} else {
|
} else {
|
||||||
// Public broadcast
|
// Public broadcast
|
||||||
// Public message - logged at relay point for mesh debugging
|
// Create packet with explicit fields so we can sign it
|
||||||
let packet = BitchatPacket(
|
let basePacket = BitchatPacket(
|
||||||
type: MessageType.message.rawValue,
|
type: MessageType.message.rawValue,
|
||||||
ttl: self.messageTTL,
|
senderID: Data(hexString: self.myPeerID) ?? Data(),
|
||||||
senderID: self.myPeerID,
|
recipientID: nil,
|
||||||
payload: Data(content.utf8)
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
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 = packet.senderID.hexEncodedString()
|
let senderHex = signedPacket.senderID.hexEncodedString()
|
||||||
let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)"
|
let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.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(packet)
|
self.broadcastPacket(signedPacket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1037,15 +1081,23 @@ 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 {
|
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue && packet.type != MessageType.announce.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
|
||||||
@@ -1063,6 +1115,57 @@ 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 }
|
||||||
@@ -1127,7 +1230,8 @@ 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 delayMs = index * TransportConfig.bleFragmentSpacingMs // ~6ms spacing per fragment
|
let perFragMs = (directedOnlyPeer != nil || packet.recipientID != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@@ -1222,10 +1326,14 @@ final class BLEService: NSObject {
|
|||||||
SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)",
|
SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
}
|
}
|
||||||
// Cancel any pending relay for this message (arrived via another neighbor)
|
// In sparse graphs (<=2 neighbors), keep the pending relay to ensure bridging.
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
// In denser graphs, cancel the pending relay to reduce redundant floods.
|
||||||
if let task = self?.scheduledRelays.removeValue(forKey: messageID) {
|
let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }
|
||||||
task.cancel()
|
if connectedCount > 2 {
|
||||||
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
|
if let task = self?.scheduledRelays.removeValue(forKey: messageID) {
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return // Duplicate ignored
|
return // Duplicate ignored
|
||||||
@@ -1284,6 +1392,7 @@ 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
|
||||||
)
|
)
|
||||||
@@ -1334,11 +1443,14 @@ 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]
|
||||||
_ = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true // hasPeripheralConnection
|
let hasPeripheralConnection = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true
|
||||||
|
|
||||||
// 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
|
||||||
_ = !subscribedCentrals.isEmpty // hasCentralSubscription
|
let hasCentralSubscription = centralToPeerID.values.contains(peerID)
|
||||||
|
|
||||||
|
// 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]
|
||||||
@@ -1376,7 +1488,7 @@ final class BLEService: NSObject {
|
|||||||
peers[peerID] = PeerInfo(
|
peers[peerID] = PeerInfo(
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
nickname: announcement.nickname,
|
nickname: announcement.nickname,
|
||||||
isConnected: true,
|
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||||
noisePublicKey: announcement.noisePublicKey,
|
noisePublicKey: announcement.noisePublicKey,
|
||||||
signingPublicKey: announcement.signingPublicKey,
|
signingPublicKey: announcement.signingPublicKey,
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
@@ -1387,7 +1499,7 @@ final class BLEService: NSObject {
|
|||||||
peers[peerID] = PeerInfo(
|
peers[peerID] = PeerInfo(
|
||||||
id: peerID,
|
id: peerID,
|
||||||
nickname: announcement.nickname,
|
nickname: announcement.nickname,
|
||||||
isConnected: true,
|
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||||
noisePublicKey: announcement.noisePublicKey,
|
noisePublicKey: announcement.noisePublicKey,
|
||||||
signingPublicKey: announcement.signingPublicKey,
|
signingPublicKey: announcement.signingPublicKey,
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
@@ -1395,13 +1507,49 @@ final class BLEService: NSObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log connection status
|
// Log connection status only for direct connectivity changes; debounce to reduce spam
|
||||||
if existingPeer == nil {
|
if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {
|
||||||
SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .debug)
|
let now = Date()
|
||||||
} else if wasDisconnected {
|
if existingPeer == nil {
|
||||||
SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .debug)
|
SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .debug)
|
||||||
} else if existingPeer?.nickname != announcement.nickname {
|
} else if wasDisconnected {
|
||||||
SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .debug)
|
// 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)
|
||||||
|
lastReconnectLogAt[peerID] = now
|
||||||
|
}
|
||||||
|
} else if existingPeer?.nickname != announcement.nickname {
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1412,8 +1560,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
|
// Only notify of connection for new or reconnected peers when it is a direct announce
|
||||||
if isNewPeer || isReconnectedPeer {
|
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
|
||||||
self.delegate?.didConnectToPeer(peerID)
|
self.delegate?.didConnectToPeer(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1433,6 +1581,14 @@ 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
|
||||||
@@ -1441,8 +1597,40 @@ 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 }
|
||||||
|
|
||||||
// Enforce: only accept public messages from verified peers we know
|
var accepted = false
|
||||||
guard let info = peers[peerID], info.isVerifiedNickname else {
|
var senderNickname: String = ""
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -1451,16 +1639,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
|
||||||
// Resolve display nickname; if collisions exist, append short peerID suffix
|
let hasDirectLink: Bool = collectionsQueue.sync {
|
||||||
var senderNickname = info.nickname
|
let perUUID = peerToPeripheralUUID[peerID]
|
||||||
// Treat a collision if another connected peer shares the nickname OR our own nickname matches
|
let perConnected = perUUID != nil && peripherals[perUUID!]?.isConnected == true
|
||||||
let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname)
|
let hasCentral = centralToPeerID.values.contains(peerID)
|
||||||
if hasCollision {
|
return perConnected || hasCentral
|
||||||
senderNickname += "#" + String(peerID.prefix(4))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug)
|
let pathTag = hasDirectLink ? "direct" : "mesh"
|
||||||
|
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
|
||||||
@@ -1659,31 +1847,64 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||||
// Send encrypted delivery ACK
|
// Create typed payload: [type byte] + [message ID]
|
||||||
guard noiseService.hasSession(with: peerID) else {
|
var payload = Data([NoisePayloadType.delivered.rawValue])
|
||||||
SecureLogger.log("Cannot send ACK - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning)
|
payload.append(contentsOf: messageID.utf8)
|
||||||
return
|
|
||||||
|
if noiseService.hasEstablishedSession(with: peerID) {
|
||||||
|
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 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)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create ACK payload: [type byte] + [message ID]
|
private func sendPendingNoisePayloadsAfterHandshake(for peerID: String) {
|
||||||
var ackPayload = Data([NoisePayloadType.delivered.rawValue])
|
let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in
|
||||||
ackPayload.append(contentsOf: messageID.utf8)
|
let list = pendingNoisePayloadsAfterHandshake[peerID] ?? []
|
||||||
|
pendingNoisePayloadsAfterHandshake.removeValue(forKey: peerID)
|
||||||
do {
|
return list
|
||||||
let encrypted = try noiseService.encrypt(ackPayload, for: peerID)
|
}
|
||||||
let packet = BitchatPacket(
|
guard !payloads.isEmpty else { return }
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
SecureLogger.log("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake",
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
category: SecureLogger.session, level: .debug)
|
||||||
recipientID: Data(hexString: peerID),
|
for payload in payloads {
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
do {
|
||||||
payload: encrypted,
|
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
||||||
signature: nil,
|
let packet = BitchatPacket(
|
||||||
ttl: messageTTL
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
)
|
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||||
broadcastPacket(packet)
|
recipientID: Data(hexString: peerID),
|
||||||
// Delivery ACK sent
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
} catch {
|
payload: encrypted,
|
||||||
SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error)
|
signature: nil,
|
||||||
|
ttl: messageTTL
|
||||||
|
)
|
||||||
|
broadcastPacket(packet)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.log("❌ Failed to send pending noise payload to \(peerID): \(error)",
|
||||||
|
category: SecureLogger.noise, level: .error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1697,6 +1918,18 @@ 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 {
|
||||||
@@ -1749,6 +1982,16 @@ 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
|
||||||
@@ -1761,16 +2004,19 @@ final class BLEService: NSObject {
|
|||||||
updateScanningDutyCycle(connectedCount: connectedCount)
|
updateScanningDutyCycle(connectedCount: connectedCount)
|
||||||
updateRSSIThreshold(connectedCount: connectedCount)
|
updateRSSIThreshold(connectedCount: connectedCount)
|
||||||
|
|
||||||
// Every 20 seconds (2 cycles): Check peer connectivity
|
// Check peer connectivity every cycle for snappier UI updates
|
||||||
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)
|
||||||
@@ -1783,28 +2029,39 @@ 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 {
|
||||||
if peer.isConnected && now.timeIntervalSince(peer.lastSeen) > TransportConfig.blePeerInactivityTimeoutSeconds {
|
let age = now.timeIntervalSince(peer.lastSeen)
|
||||||
|
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)
|
||||||
|
|
||||||
// Only remove if we don't have an active BLE connection
|
// If direct link is gone, mark as not connected (retain entry for reachability)
|
||||||
if !hasPeripheralConnection && !hasCentralConnection {
|
if !hasPeripheralConnection && !hasCentralConnection {
|
||||||
// Remove the peer completely (they'll be re-added when they reconnect)
|
var updated = peer
|
||||||
SecureLogger.log("⏱️ Peer timed out (no packets for 20s): \(peerID) (\(peer.nickname))",
|
updated.isConnected = false
|
||||||
category: SecureLogger.session, level: .debug)
|
peers[peerID] = updated
|
||||||
peers.removeValue(forKey: peerID)
|
|
||||||
disconnectedPeers.append(peerID)
|
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)
|
||||||
|
peers.removeValue(forKey: peerID)
|
||||||
|
removedOfflineCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update UI if any peers were disconnected
|
// Update UI if there were direct disconnections or offline removals
|
||||||
if !disconnectedPeers.isEmpty {
|
if !disconnectedPeers.isEmpty || removedOfflineCount > 0 {
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
@@ -1814,6 +2071,8 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1857,6 +2116,15 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1868,7 +2136,13 @@ final class BLEService: NSObject {
|
|||||||
#else
|
#else
|
||||||
let active = true
|
let active = true
|
||||||
#endif
|
#endif
|
||||||
let shouldDuty = dutyEnabled && active && connectedCount > 0
|
// Force full-time scanning if we have very few neighbors or very recent traffic
|
||||||
|
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
|
||||||
@@ -2138,6 +2412,11 @@ 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)
|
||||||
|
|
||||||
@@ -2145,9 +2424,12 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
|||||||
if let peerID = peerID {
|
if let peerID = peerID {
|
||||||
peerToPeripheralUUID.removeValue(forKey: peerID)
|
peerToPeripheralUUID.removeValue(forKey: peerID)
|
||||||
|
|
||||||
// Remove peer completely (they'll be re-added when they reconnect and announce)
|
// Do not remove peer; mark as not connected but retain for reachability
|
||||||
_ = collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
peers.removeValue(forKey: peerID)
|
if var info = peers[peerID] {
|
||||||
|
info.isConnected = false
|
||||||
|
peers[peerID] = info
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2162,7 +2444,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
|
// Notify delegate about disconnection on main thread (direct link dropped)
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
@@ -2170,7 +2452,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.delegate?.didDisconnectFromPeer(peerID)
|
self.notifyPeerDisconnectedDebounced(peerID)
|
||||||
}
|
}
|
||||||
self.publishFullPeerData()
|
self.publishFullPeerData()
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
@@ -2222,6 +2504,18 @@ 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() }
|
||||||
@@ -2355,6 +2649,10 @@ 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)
|
||||||
@@ -2517,6 +2815,10 @@ 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2533,9 +2835,12 @@ 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] {
|
||||||
// Remove peer completely (they'll be re-added when they reconnect)
|
// Mark peer as not connected; retain for reachability
|
||||||
_ = collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
peers.removeValue(forKey: peerID)
|
if var info = peers[peerID] {
|
||||||
|
info.isConnected = false
|
||||||
|
peers[peerID] = info
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up mappings
|
// Clean up mappings
|
||||||
@@ -2548,7 +2853,9 @@ 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.delegate?.didDisconnectFromPeer(peerID)
|
self.notifyPeerDisconnectedDebounced(peerID)
|
||||||
|
// Publish snapshots so UnifiedPeerService can refresh icons promptly
|
||||||
|
self.publishFullPeerData()
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 hasMesh = mesh.isPeerConnected(peerID)
|
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||||
let hasEstablished = mesh.getNoiseService().hasEstablishedSession(with: peerID)
|
if reachableMesh {
|
||||||
if hasMesh && hasEstablished {
|
SecureLogger.log("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
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 only if a Noise session is established; else use Nostr to avoid handshakeRequired spam
|
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||||
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
SecureLogger.log("Routing READ ack via mesh to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…",
|
SecureLogger.log("Routing READ ack via mesh (reachable) 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,7 +70,9 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
||||||
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) {
|
if mesh.isPeerReachable(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)
|
||||||
@@ -101,7 +103,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.isPeerConnected(peerID) {
|
if mesh.isPeerReachable(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)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ 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] { [:] }
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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
|
||||||
@@ -25,7 +26,8 @@ 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
|
||||||
let delayRange: ClosedRange<Int> = isHandshake ? 20...60 : 40...120
|
// Tighter for faster multi-hop handshakes and directed DMs
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@@ -42,15 +44,22 @@ 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 in dense graphs (only for broadcast)
|
// TTL clamping for broadcast
|
||||||
let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5
|
// - Dense graphs: keep very low to avoid floods
|
||||||
|
// - 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: 40...100)
|
case 0...2: delayMs = Int.random(in: 10...40)
|
||||||
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)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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]
|
||||||
|
|
||||||
|
|||||||
@@ -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 = 10.0
|
static let bleMaintenanceInterval: TimeInterval = 5.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,23 +71,32 @@ 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
|
||||||
static let blePeerInactivityTimeoutSeconds: TimeInterval = 20.0
|
// How long without seeing traffic before we sanity-check the direct link
|
||||||
|
// Lowered to make connected→reachable 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
|
||||||
static let bleFragmentSpacingMs: Int = 6
|
// Faster fragment pacing; use slightly tighter spacing for directed trains
|
||||||
static let bleAnnounceIntervalSeconds: TimeInterval = 10.0
|
static let bleFragmentSpacingMs: Int = 5
|
||||||
|
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 = 90.0
|
static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 30.0
|
||||||
static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 45.0
|
static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 15.0
|
||||||
static let bleConnectedAnnounceJitterDense: TimeInterval = 20.0
|
static let bleConnectedAnnounceJitterDense: TimeInterval = 8.0
|
||||||
static let bleConnectedAnnounceJitterSparse: TimeInterval = 7.5
|
static let bleConnectedAnnounceJitterSparse: TimeInterval = 4.0
|
||||||
|
|
||||||
// Location
|
// Location
|
||||||
static let locationDistanceFilterMeters: Double = 1000
|
static let locationDistanceFilterMeters: Double = 1000
|
||||||
@@ -126,12 +135,24 @@ 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 = 2.0
|
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
||||||
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.1
|
static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.05
|
||||||
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
|
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
|
||||||
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.2
|
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|||||||
@@ -68,24 +68,28 @@ 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 connected mesh peers
|
// Phase 1: Add all mesh peers (connected and reachable)
|
||||||
for peerInfo in meshPeers where peerInfo.isConnected {
|
for peerInfo in meshPeers {
|
||||||
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)
|
||||||
connected.insert(peerID)
|
if peer.isConnected { connected.insert(peerID) }
|
||||||
addedPeerIDs.insert(peerID)
|
addedPeerIDs.insert(peerID)
|
||||||
|
|
||||||
// Update fingerprint cache
|
// Update fingerprint cache
|
||||||
@@ -117,14 +121,12 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// Phase 3: Sort peers
|
// Phase 3: Sort peers
|
||||||
enrichedPeers.sort { lhs, rhs in
|
enrichedPeers.sort { lhs, rhs in
|
||||||
// Connected first
|
// Connectivity rank: connected > reachable > others
|
||||||
if lhs.isConnected != rhs.isConnected {
|
func rank(_ p: BitchatPeer) -> Int { p.isConnected ? 2 : (p.isReachable ? 1 : 0) }
|
||||||
return lhs.isConnected
|
let lr = rank(lhs), rr = rank(rhs)
|
||||||
}
|
if lr != rr { return lr > rr }
|
||||||
// Then favorites
|
// Then favorites inside same rank
|
||||||
if lhs.isFavorite != rhs.isFavorite {
|
if lhs.isFavorite != rhs.isFavorite { return lhs.isFavorite }
|
||||||
return lhs.isFavorite
|
|
||||||
}
|
|
||||||
// Finally alphabetical
|
// Finally alphabetical
|
||||||
return lhs.displayName < rhs.displayName
|
return lhs.displayName < rhs.displayName
|
||||||
}
|
}
|
||||||
@@ -145,8 +147,11 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 5: Update published properties
|
// Phase 5: Filter out offline non-mutual peers and update published properties
|
||||||
self.peers = enrichedPeers
|
let filtered = enrichedPeers.filter { p in
|
||||||
|
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
|
||||||
@@ -162,14 +167,26 @@ 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: true
|
isConnected: peerInfo.isConnected,
|
||||||
|
isReachable: isReachable
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check for favorite status
|
// Check for favorite status
|
||||||
@@ -216,7 +233,8 @@ 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
|
||||||
|
|||||||
@@ -935,6 +935,13 @@ 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
|
||||||
@@ -1874,6 +1881,7 @@ 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
|
||||||
@@ -1913,8 +1921,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, else Nostr when possible)
|
// Send via appropriate transport (BLE if connected/reachable, else Nostr when possible)
|
||||||
if isConnected || (isMutualFavorite && hasNostrKey) {
|
if isConnected || isReachable || (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 }) {
|
||||||
@@ -2542,16 +2550,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we know the original transport, use it for the read receipt
|
// If this originated over Nostr, skip (handled by Nostr code paths)
|
||||||
if originalTransport == "nostr" {
|
if originalTransport == "nostr" {
|
||||||
// Skip read receipts for Nostr messages - unnecessary complexity
|
return
|
||||||
// The radical simplification plan says to accept occasional loss
|
|
||||||
} else if meshService.peerNickname(peerID: actualPeerID) != nil {
|
|
||||||
// Use mesh for connected peers (default behavior)
|
|
||||||
messageRouter.sendReadReceipt(receipt, to: actualPeerID)
|
|
||||||
} else {
|
|
||||||
// Skip read receipts for offline peers - fire and forget principle
|
|
||||||
}
|
}
|
||||||
|
// Use router to decide (mesh if reachable, else Nostr if available)
|
||||||
|
messageRouter.sendReadReceipt(receipt, to: actualPeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -1036,9 +1036,8 @@ 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 }
|
||||||
let isMeshConnected = peer.isConnected
|
if peer.isConnected { counts.mesh += 1; counts.others += 1 }
|
||||||
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
|
else if peer.isReachable { 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
|
||||||
@@ -1181,20 +1180,11 @@ 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 when mesh-connected (radio). Only use full Noise key when not connected (globe).
|
// Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only.
|
||||||
let headerPeerID: String = {
|
let headerPeerID: String = {
|
||||||
if privatePeerID.count == 16 {
|
if privatePeerID.count == 64 {
|
||||||
let isMeshConnected = viewModel.meshService.isPeerConnected(privatePeerID) || viewModel.connectedPeers.contains(privatePeerID)
|
// Map stable Noise key to short ID if we know it (even if not directly connected)
|
||||||
if !isMeshConnected, let stable = viewModel.getNoiseKeyForShortID(privatePeerID) {
|
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short }
|
||||||
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
|
||||||
}()
|
}()
|
||||||
@@ -1209,10 +1199,29 @@ struct ContentView: View {
|
|||||||
return "#\(ch.geohash)/@\(disp)"
|
return "#\(ch.geohash)/@\(disp)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return peer?.displayName ??
|
// Try mesh/unified peer display
|
||||||
viewModel.meshService.peerNickname(peerID: headerPeerID) ??
|
if let name = peer?.displayName { return name }
|
||||||
FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ??
|
// Try direct mesh nickname (connected-only)
|
||||||
"Unknown"
|
if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name }
|
||||||
|
// 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 {
|
||||||
@@ -1247,6 +1256,12 @@ 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")
|
||||||
@@ -1257,6 +1272,12 @@ 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")
|
||||||
@@ -1275,7 +1296,14 @@ 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_") {
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID)
|
// Use short peer ID if available for encryption status (sessions keyed by short ID)
|
||||||
|
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))
|
||||||
|
|||||||
@@ -41,9 +41,27 @@ struct FingerprintView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
// Peer info
|
// Prefer short mesh ID for session/encryption status
|
||||||
let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "Unknown"
|
let statusPeerID: String = {
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: peerID)
|
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
|
||||||
|
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 {
|
||||||
@@ -74,7 +92,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: peerID) {
|
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
||||||
Text(formatFingerprint(fingerprint))
|
Text(formatFingerprint(fingerprint))
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ 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")
|
||||||
@@ -110,6 +115,14 @@ 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")
|
||||||
|
|||||||
Reference in New Issue
Block a user