mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:25:20 +00:00
Improve BLE connection stability and message reliability (#318)
* Remove sequence numbers from protocol - Remove sequenceNumber field from BitchatPacket struct - Update BinaryProtocol to not encode/decode sequence numbers (header size reduced from 17 to 13 bytes) - Replace sequence-based duplicate detection with content-based using packet ID hash - Update packet ID generation to use SHA256(senderID + timestamp + type + payload prefix) - Remove all sequence tracking variables and methods - Simplify duplicate detection to rely on timestamp and content hashing * Fix connection stability issues - Increase peer availability check interval from 5 to 15 seconds - Fix availability logic to not mark connected peers as unavailable - Add BLE connection keepalive timer (20s) to prevent iOS timeouts - Fix missing delivery ACKs by passing peripheral context through Noise decryption - Reduce identity announce frequency from 2 to 10 seconds minimum - Remove unnecessary identity announces on connection - Debounce identity cache keychain saves (2 second delay) - Add message retry notification handler in ChatViewModel - Fix version negotiation redundancy by checking existing negotiations - Keep Noise sessions for already-connected peers * Fix build errors in message retry handler - Fix reference to 'displayedMessages' - should be 'messages' - Fix sendMessage call signature to use individual parameters instead of message object - Both iOS and macOS builds now succeed * Fix remaining connection stability issues - Fix duplicate identity announces with content-based deduplication - Simplify peripheral mapping with cleaner temp ID to peer ID transitions - Improve graceful leave detection across peer ID rotations - Track previousPeerID from announcements to maintain state - Add time-based cleanup for gracefully left peers * Fix connection stability issues - Add special duplicate detection for identity announces - Simplify peripheral mapping with dedicated structure - Improve graceful leave detection with peer ID rotation handling - Track graceful leave timestamps for cleanup - Transfer states properly during peer ID rotation * Improve BLE connection stability and message reliability - Increase peer availability timeout from 5s to 15s to prevent flapping - Add BLE keepalive timer with 30s interval to maintain connections - Fix missing delivery ACKs by passing peripheral context through decryption - Reduce identity announce frequency from 2s to 10s minimum interval - Add keychain save debouncing with 2s delay to prevent excessive writes - Implement message retry system for failed deliveries to favorites - Fix version negotiation redundancy by checking existing state - Add special duplicate detection for identity announcements - Implement graceful leave detection with peer ID rotation support - Simplify peripheral mapping to reduce complexity - Fix switch statement structure issues causing build errors These changes significantly improve connection stability, eliminate peer availability flapping, and ensure reliable message delivery. --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -27,6 +27,11 @@ class SecureIdentityStateManager {
|
||||
// Thread safety
|
||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||
|
||||
// Debouncing for keychain saves
|
||||
private var saveTimer: Timer?
|
||||
private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds
|
||||
private var pendingSave = false
|
||||
|
||||
// Encryption key
|
||||
private let encryptionKey: SymmetricKey
|
||||
|
||||
@@ -72,16 +77,48 @@ class SecureIdentityStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Force save any pending changes
|
||||
forceSave()
|
||||
}
|
||||
|
||||
func saveIdentityCache() {
|
||||
// Mark that we need to save
|
||||
pendingSave = true
|
||||
|
||||
// Cancel any existing timer
|
||||
saveTimer?.invalidate()
|
||||
|
||||
// Schedule a new save after the debounce interval
|
||||
saveTimer = Timer.scheduledTimer(withTimeInterval: saveDebounceInterval, repeats: false) { [weak self] _ in
|
||||
self?.performSave()
|
||||
}
|
||||
}
|
||||
|
||||
private func performSave() {
|
||||
guard pendingSave else { return }
|
||||
pendingSave = false
|
||||
|
||||
do {
|
||||
let data = try JSONEncoder().encode(cache)
|
||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||
_ = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
|
||||
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
|
||||
if saved {
|
||||
SecureLogger.log("Identity cache saved to keychain", category: SecureLogger.security, level: .debug)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.logError(error, context: "Failed to save identity cache", category: SecureLogger.security)
|
||||
}
|
||||
}
|
||||
|
||||
// Force immediate save (for app termination)
|
||||
func forceSave() {
|
||||
saveTimer?.invalidate()
|
||||
if pendingSave {
|
||||
performSave()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Identity Resolution
|
||||
|
||||
func resolveIdentity(peerID: String, claimedNickname: String) -> IdentityHint {
|
||||
|
||||
@@ -19,12 +19,11 @@ extension Data {
|
||||
}
|
||||
|
||||
// Binary Protocol Format:
|
||||
// Header (Fixed 17 bytes):
|
||||
// Header (Fixed 13 bytes):
|
||||
// - Version: 1 byte
|
||||
// - Type: 1 byte
|
||||
// - TTL: 1 byte
|
||||
// - Timestamp: 8 bytes (UInt64)
|
||||
// - SequenceNumber: 4 bytes (UInt32)
|
||||
// - Flags: 1 byte (bit 0: hasRecipient, bit 1: hasSignature)
|
||||
// - PayloadLength: 2 bytes (UInt16)
|
||||
//
|
||||
@@ -35,7 +34,7 @@ extension Data {
|
||||
// - Signature: 64 bytes (if hasSignature flag set)
|
||||
|
||||
struct BinaryProtocol {
|
||||
static let headerSize = 17
|
||||
static let headerSize = 13
|
||||
static let senderIDSize = 8
|
||||
static let recipientIDSize = 8
|
||||
static let signatureSize = 64
|
||||
@@ -78,11 +77,6 @@ struct BinaryProtocol {
|
||||
data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF))
|
||||
}
|
||||
|
||||
// Sequence number (4 bytes, big-endian)
|
||||
for i in (0..<4).reversed() {
|
||||
data.append(UInt8((packet.sequenceNumber >> (i * 8)) & 0xFF))
|
||||
}
|
||||
|
||||
// Flags
|
||||
var flags: UInt8 = 0
|
||||
if packet.recipientID != nil {
|
||||
@@ -171,13 +165,6 @@ struct BinaryProtocol {
|
||||
}
|
||||
offset += 8
|
||||
|
||||
// Sequence number
|
||||
let sequenceData = unpaddedData[offset..<offset+4]
|
||||
let sequenceNumber = sequenceData.reduce(0) { result, byte in
|
||||
(result << 8) | UInt32(byte)
|
||||
}
|
||||
offset += 4
|
||||
|
||||
// Flags
|
||||
let flags = unpaddedData[offset]; offset += 1
|
||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||
@@ -253,8 +240,7 @@ struct BinaryProtocol {
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: signature,
|
||||
ttl: ttl,
|
||||
sequenceNumber: sequenceNumber
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +139,8 @@ struct BitchatPacket: Codable {
|
||||
let payload: Data
|
||||
let signature: Data?
|
||||
var ttl: UInt8
|
||||
let sequenceNumber: UInt32 // New field for duplicate detection
|
||||
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, sequenceNumber: UInt32 = 0) {
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
||||
self.version = 1
|
||||
self.type = type
|
||||
self.senderID = senderID
|
||||
@@ -150,11 +149,10 @@ struct BitchatPacket: Codable {
|
||||
self.payload = payload
|
||||
self.signature = signature
|
||||
self.ttl = ttl
|
||||
self.sequenceNumber = sequenceNumber
|
||||
}
|
||||
|
||||
// Convenience initializer for new binary format
|
||||
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data, sequenceNumber: UInt32 = 0) {
|
||||
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
|
||||
self.version = 1
|
||||
self.type = type
|
||||
// Convert hex string peer ID to binary data (8 bytes)
|
||||
@@ -173,7 +171,6 @@ struct BitchatPacket: Codable {
|
||||
self.payload = payload
|
||||
self.signature = nil
|
||||
self.ttl = ttl
|
||||
self.sequenceNumber = sequenceNumber
|
||||
}
|
||||
|
||||
var data: Data? {
|
||||
|
||||
@@ -334,6 +334,10 @@ class BluetoothMeshService: NSObject {
|
||||
private let processedMessagesLock = NSLock()
|
||||
private let maxProcessedMessages = 10000
|
||||
|
||||
// Special handling for identity announces to prevent duplicates
|
||||
private var recentIdentityAnnounces = [String: Date]() // peerID -> last seen time
|
||||
private let identityAnnounceDuplicateWindow: TimeInterval = 30.0 // 30 second window
|
||||
|
||||
private let maxTTL: UInt8 = 7 // Maximum hops for long-distance delivery
|
||||
private var announcedToPeers = Set<String>() // Track which peers we've announced to
|
||||
private var announcedPeers = Set<String>() // Track peers who have already been announced
|
||||
@@ -344,6 +348,8 @@ class BluetoothMeshService: NSObject {
|
||||
private let networkEmptyResetDelay: TimeInterval = 60 // 1 minute before resetting notification flag
|
||||
private var intentionalDisconnects = Set<String>() // Track peripherals we're disconnecting intentionally
|
||||
private var gracefullyLeftPeers = Set<String>() // Track peers that sent leave messages
|
||||
private var gracefulLeaveTimestamps: [String: Date] = [:] // Track when peers left
|
||||
private let gracefulLeaveExpirationTime: TimeInterval = 300.0 // 5 minutes
|
||||
private var peerLastSeenTimestamps = LRUCache<String, Date>(maxSize: 100) // Bounded cache for peer timestamps
|
||||
private var cleanupTimer: Timer? // Timer to clean up stale peers
|
||||
|
||||
@@ -385,7 +391,7 @@ class BluetoothMeshService: NSObject {
|
||||
|
||||
// Track when we last sent identity announcements to prevent flooding
|
||||
private var lastIdentityAnnounceTimes: [String: Date] = [:]
|
||||
private let identityAnnounceMinInterval: TimeInterval = 2.0 // Minimum 2 seconds between announcements per peer
|
||||
private let identityAnnounceMinInterval: TimeInterval = 10.0 // Minimum 10 seconds between announcements per peer
|
||||
|
||||
// Track handshake attempts to handle timeouts
|
||||
private var handshakeAttemptTimes: [String: Date] = [:]
|
||||
@@ -409,6 +415,8 @@ class BluetoothMeshService: NSObject {
|
||||
private let maxAckRetries = 3
|
||||
private var ackTimer: Timer?
|
||||
private var advertisingTimer: Timer? // Timer for interval-based advertising
|
||||
private var connectionKeepAliveTimer: Timer? // Timer to send keepalive pings
|
||||
private let keepAliveInterval: TimeInterval = 20.0 // Send keepalive every 20 seconds
|
||||
|
||||
// Timing randomization for privacy (now with exponential distribution)
|
||||
private let minMessageDelay: TimeInterval = 0.01 // 10ms minimum for faster sync
|
||||
@@ -446,6 +454,72 @@ class BluetoothMeshService: NSObject {
|
||||
private let maxConnectionAttempts = 3
|
||||
private let baseBackoffInterval: TimeInterval = 1.0
|
||||
|
||||
// Simplified peripheral mapping system
|
||||
private struct PeripheralMapping {
|
||||
let peripheral: CBPeripheral
|
||||
var peerID: String? // nil until we receive announce
|
||||
var rssi: NSNumber?
|
||||
var lastActivity: Date
|
||||
|
||||
var isIdentified: Bool { peerID != nil }
|
||||
}
|
||||
private var peripheralMappings: [String: PeripheralMapping] = [:] // peripheralID -> mapping
|
||||
|
||||
// Helper methods for peripheral mapping
|
||||
private func registerPeripheral(_ peripheral: CBPeripheral) {
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
peripheralMappings[peripheralID] = PeripheralMapping(
|
||||
peripheral: peripheral,
|
||||
peerID: nil,
|
||||
rssi: nil,
|
||||
lastActivity: Date()
|
||||
)
|
||||
}
|
||||
|
||||
private func updatePeripheralMapping(peripheralID: String, peerID: String) {
|
||||
guard var mapping = peripheralMappings[peripheralID] else { return }
|
||||
|
||||
// Remove old temp mapping if it exists
|
||||
let tempID = peripheralID
|
||||
if connectedPeripherals[tempID] != nil {
|
||||
connectedPeripherals.removeValue(forKey: tempID)
|
||||
}
|
||||
|
||||
mapping.peerID = peerID
|
||||
mapping.lastActivity = Date()
|
||||
peripheralMappings[peripheralID] = mapping
|
||||
|
||||
// Update legacy mappings
|
||||
peerIDByPeripheralID[peripheralID] = peerID
|
||||
connectedPeripherals[peerID] = mapping.peripheral
|
||||
|
||||
// Transfer RSSI
|
||||
if let rssi = mapping.rssi {
|
||||
peerRSSI[peerID] = rssi
|
||||
}
|
||||
}
|
||||
|
||||
private func updatePeripheralRSSI(peripheralID: String, rssi: NSNumber) {
|
||||
guard var mapping = peripheralMappings[peripheralID] else { return }
|
||||
mapping.rssi = rssi
|
||||
mapping.lastActivity = Date()
|
||||
peripheralMappings[peripheralID] = mapping
|
||||
|
||||
// Update peer RSSI if we have the peer ID
|
||||
if let peerID = mapping.peerID {
|
||||
peerRSSI[peerID] = rssi
|
||||
}
|
||||
}
|
||||
|
||||
private func findPeripheralForPeerID(_ peerID: String) -> CBPeripheral? {
|
||||
for (_, mapping) in peripheralMappings {
|
||||
if mapping.peerID == peerID {
|
||||
return mapping.peripheral
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Probabilistic flooding
|
||||
private var relayProbability: Double = 1.0 // Start at 100%, decrease with peer count
|
||||
private let minRelayProbability: Double = 0.4 // Minimum 40% relay chance - ensures coverage
|
||||
@@ -465,15 +539,6 @@ class BluetoothMeshService: NSObject {
|
||||
return max(activePeers.count, connectedPeripherals.count)
|
||||
}
|
||||
|
||||
// Sequence number tracking for duplicate detection
|
||||
private var currentSequenceNumber: UInt32 = 0
|
||||
private var peerSequenceNumbers: [String: UInt32] = [:] // Track last seen sequence per peer
|
||||
|
||||
private func getNextSequenceNumber() -> UInt32 {
|
||||
currentSequenceNumber += 1
|
||||
return currentSequenceNumber
|
||||
}
|
||||
|
||||
// Adaptive parameters based on network size
|
||||
private var adaptiveTTL: UInt8 {
|
||||
// Keep TTL high enough for messages to travel far
|
||||
@@ -625,6 +690,14 @@ class BluetoothMeshService: NSObject {
|
||||
oldPeerID = existingPeerID
|
||||
SecureLogger.log("Peer ID rotation detected: \(existingPeerID) -> \(newPeerID) for fingerprint \(fingerprint)", category: SecureLogger.security, level: .info)
|
||||
|
||||
// Transfer gracefullyLeftPeers state from old to new peer ID
|
||||
if self.gracefullyLeftPeers.contains(existingPeerID) {
|
||||
self.gracefullyLeftPeers.remove(existingPeerID)
|
||||
self.gracefullyLeftPeers.insert(newPeerID)
|
||||
SecureLogger.log("Transferred gracefullyLeft state from \(existingPeerID) to \(newPeerID)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
|
||||
self.peerIDToFingerprint.removeValue(forKey: existingPeerID)
|
||||
|
||||
// Transfer nickname if known
|
||||
@@ -912,8 +985,8 @@ class BluetoothMeshService: NSObject {
|
||||
self?.checkAckTimeouts()
|
||||
}
|
||||
|
||||
// Start peer availability checking timer (every 5 seconds)
|
||||
availabilityCheckTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
||||
// Start peer availability checking timer (every 15 seconds)
|
||||
availabilityCheckTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: true) { [weak self] _ in
|
||||
self?.checkPeerAvailability()
|
||||
}
|
||||
|
||||
@@ -935,6 +1008,11 @@ class BluetoothMeshService: NSObject {
|
||||
// Start memory cleanup timer (every minute)
|
||||
startMemoryCleanupTimer()
|
||||
|
||||
// Start connection keep-alive timer to prevent iOS BLE timeouts
|
||||
connectionKeepAliveTimer = Timer.scheduledTimer(withTimeInterval: keepAliveInterval, repeats: true) { [weak self] _ in
|
||||
self?.sendKeepAlivePings()
|
||||
}
|
||||
|
||||
// Log handshake states periodically for debugging and clean up stale states
|
||||
#if DEBUG
|
||||
Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
|
||||
@@ -1005,6 +1083,8 @@ class BluetoothMeshService: NSObject {
|
||||
cleanupTimer?.invalidate()
|
||||
rotationTimer?.invalidate()
|
||||
memoryCleanupTimer?.invalidate()
|
||||
connectionKeepAliveTimer?.invalidate()
|
||||
availabilityCheckTimer?.invalidate()
|
||||
}
|
||||
|
||||
@objc private func appWillTerminate() {
|
||||
@@ -1092,9 +1172,7 @@ class BluetoothMeshService: NSObject {
|
||||
type: MessageType.announce.rawValue,
|
||||
ttl: 3, // Increase TTL so announce reaches all peers
|
||||
senderID: myPeerID,
|
||||
payload: Data(vm.nickname.utf8),
|
||||
sequenceNumber: getNextSequenceNumber()
|
||||
)
|
||||
payload: Data(vm.nickname.utf8) )
|
||||
|
||||
|
||||
// Single send with smart collision avoidance
|
||||
@@ -1102,8 +1180,8 @@ class BluetoothMeshService: NSObject {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) { [weak self] in
|
||||
self?.broadcastPacket(announcePacket)
|
||||
|
||||
// Also send Noise identity announcement
|
||||
self?.sendNoiseIdentityAnnounce()
|
||||
// Don't automatically send identity announcement on startup
|
||||
// Let it happen naturally when peers connect
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1225,9 +1303,7 @@ class BluetoothMeshService: NSObject {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), // milliseconds
|
||||
payload: messageData,
|
||||
signature: nil,
|
||||
ttl: self.adaptiveTTL,
|
||||
sequenceNumber: self.getNextSequenceNumber()
|
||||
)
|
||||
ttl: self.adaptiveTTL )
|
||||
|
||||
// Track this message to prevent duplicate sends
|
||||
let msgID = "\(packet.timestamp)-\(self.myPeerID)-\(packet.payload.prefix(32).hashValue)"
|
||||
@@ -1354,9 +1430,7 @@ class BluetoothMeshService: NSObject {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encryptedPayload,
|
||||
signature: nil,
|
||||
ttl: 3,
|
||||
sequenceNumber: self.getNextSequenceNumber()
|
||||
)
|
||||
ttl: 3 )
|
||||
|
||||
self.broadcastPacket(outerPacket)
|
||||
} catch {
|
||||
@@ -1380,8 +1454,7 @@ class BluetoothMeshService: NSObject {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encryptedPayload,
|
||||
signature: nil, // ACKs don't need signatures
|
||||
ttl: 3, // Limited TTL for ACKs
|
||||
sequenceNumber: self.getNextSequenceNumber()
|
||||
ttl: 3 // Limited TTL for ACKs
|
||||
)
|
||||
|
||||
// Send immediately without delay (ACKs should be fast)
|
||||
@@ -1440,9 +1513,7 @@ class BluetoothMeshService: NSObject {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: receiptData,
|
||||
signature: nil,
|
||||
ttl: 3,
|
||||
sequenceNumber: self.getNextSequenceNumber()
|
||||
)
|
||||
ttl: 3 )
|
||||
|
||||
// Encrypt the entire inner packet
|
||||
if let innerData = innerPacket.toBinaryData() {
|
||||
@@ -1456,9 +1527,7 @@ class BluetoothMeshService: NSObject {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encryptedInnerData,
|
||||
signature: nil,
|
||||
ttl: 3,
|
||||
sequenceNumber: self.getNextSequenceNumber()
|
||||
)
|
||||
ttl: 3 )
|
||||
|
||||
SecureLogger.log("Sending encrypted read receipt for message \(receipt.originalMessageID) to \(recipientID)", category: SecureLogger.noise, level: .info)
|
||||
self.broadcastPacket(outerPacket)
|
||||
@@ -1511,9 +1580,7 @@ class BluetoothMeshService: NSObject {
|
||||
type: MessageType.announce.rawValue,
|
||||
ttl: 3, // Allow relay for better reach
|
||||
senderID: myPeerID,
|
||||
payload: Data(vm.nickname.utf8),
|
||||
sequenceNumber: getNextSequenceNumber()
|
||||
)
|
||||
payload: Data(vm.nickname.utf8) )
|
||||
|
||||
if let data = packet.toBinaryData() {
|
||||
// Try both broadcast and targeted send
|
||||
@@ -1539,9 +1606,7 @@ class BluetoothMeshService: NSObject {
|
||||
type: MessageType.leave.rawValue,
|
||||
ttl: 1, // Don't relay leave messages
|
||||
senderID: myPeerID,
|
||||
payload: Data(vm.nickname.utf8),
|
||||
sequenceNumber: getNextSequenceNumber()
|
||||
)
|
||||
payload: Data(vm.nickname.utf8) )
|
||||
|
||||
broadcastPacket(packet)
|
||||
}
|
||||
@@ -1730,6 +1795,18 @@ class BluetoothMeshService: NSObject {
|
||||
let staleThreshold: TimeInterval = 180.0 // 3 minutes - increased for better stability
|
||||
let now = Date()
|
||||
|
||||
// Clean up expired gracefully left peers
|
||||
let expiredGracefulPeers = gracefulLeaveTimestamps.filter { (_, timestamp) in
|
||||
now.timeIntervalSince(timestamp) > gracefulLeaveExpirationTime
|
||||
}.map { $0.key }
|
||||
|
||||
for peerID in expiredGracefulPeers {
|
||||
gracefullyLeftPeers.remove(peerID)
|
||||
gracefulLeaveTimestamps.removeValue(forKey: peerID)
|
||||
SecureLogger.log("Cleaned up expired gracefullyLeft entry for \(peerID)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
|
||||
let peersToRemove = collectionsQueue.sync(flags: .barrier) {
|
||||
let toRemove = activePeers.filter { peerID in
|
||||
if let lastSeen = peerLastSeenTimestamps.get(peerID) {
|
||||
@@ -2213,7 +2290,7 @@ class BluetoothMeshService: NSObject {
|
||||
|
||||
if senderID != self.myPeerID && isRateLimited(peerID: senderID, messageType: packet.type) {
|
||||
if !isHighPriority {
|
||||
SecureLogger.log("RATE_LIMITED: Dropped \(messageTypeName) from \(senderID) (seq#\(packet.sequenceNumber))",
|
||||
SecureLogger.log("RATE_LIMITED: Dropped \(messageTypeName) from \(senderID)",
|
||||
category: SecureLogger.security, level: .warning)
|
||||
return
|
||||
} else {
|
||||
@@ -2227,10 +2304,10 @@ class BluetoothMeshService: NSObject {
|
||||
recordMessage(from: senderID, messageType: packet.type)
|
||||
}
|
||||
|
||||
// Sequence-based duplicate detection for more reliability
|
||||
let messageID = "\(senderID)-\(packet.sequenceNumber)"
|
||||
// Content-based duplicate detection using packet ID
|
||||
let messageID = generatePacketID(for: packet)
|
||||
|
||||
// Check if we've seen this exact message before (sequence + sender)
|
||||
// Check if we've seen this exact message before
|
||||
processedMessagesLock.lock()
|
||||
if let existingState = processedMessages[messageID] {
|
||||
// Update the state
|
||||
@@ -2239,22 +2316,13 @@ class BluetoothMeshService: NSObject {
|
||||
processedMessages[messageID] = updatedState
|
||||
processedMessagesLock.unlock()
|
||||
|
||||
SecureLogger.log("Dropped duplicate message seq#\(packet.sequenceNumber) from \(senderID) (seen \(updatedState.seenCount) times)", category: SecureLogger.security, level: .debug)
|
||||
SecureLogger.log("Dropped duplicate message from \(senderID) (seen \(updatedState.seenCount) times)", category: SecureLogger.security, level: .debug)
|
||||
// Cancel any pending relay for this message
|
||||
cancelPendingRelay(messageID: messageID)
|
||||
return
|
||||
}
|
||||
processedMessagesLock.unlock()
|
||||
|
||||
// Check if this is an old message (sequence number rollback)
|
||||
if let lastSeenSeq = peerSequenceNumbers[senderID] {
|
||||
// Allow some out-of-order tolerance (up to 100 messages)
|
||||
if packet.sequenceNumber < lastSeenSeq && (lastSeenSeq - packet.sequenceNumber) > 100 {
|
||||
SecureLogger.log("Dropped old message seq#\(packet.sequenceNumber) from \(senderID), last seen: \(lastSeenSeq)", category: SecureLogger.security, level: .debug)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Use bloom filter for efficient duplicate detection
|
||||
if messageBloomFilter.contains(messageID) {
|
||||
// Double check with exact set (bloom filter can have false positives)
|
||||
@@ -2288,8 +2356,6 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
processedMessagesLock.unlock()
|
||||
|
||||
// Update last seen sequence number for this peer
|
||||
peerSequenceNumbers[senderID] = max(packet.sequenceNumber, peerSequenceNumbers[senderID] ?? 0)
|
||||
|
||||
// Log statistics periodically
|
||||
if messageBloomFilter.insertCount % 100 == 0 {
|
||||
@@ -2629,39 +2695,25 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
|
||||
if let peripheral = peripheralToUpdate {
|
||||
SecureLogger.log("handleReceivedPacket: Updating peripheral mapping for announce from \(senderID)", category: SecureLogger.session, level: .debug)
|
||||
SecureLogger.log("handleReceivedPacket: Current connectedPeripherals: \(self.connectedPeripherals.keys.joined(separator: ", "))", category: SecureLogger.session, level: .debug)
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
SecureLogger.log("Updating peripheral \(peripheralID) mapping to peer ID \(senderID)",
|
||||
category: SecureLogger.session, level: .info)
|
||||
|
||||
// Update simplified mapping
|
||||
updatePeripheralMapping(peripheralID: peripheralID, peerID: senderID)
|
||||
|
||||
// Find and remove any temp ID mapping for this peripheral
|
||||
var tempIDToRemove: String? = nil
|
||||
for (id, per) in self.connectedPeripherals {
|
||||
if per == peripheral && id != senderID {
|
||||
SecureLogger.log("handleReceivedPacket: Found temp ID \(id) for peripheral that announced as \(senderID)", category: SecureLogger.session, level: .debug)
|
||||
if per == peripheral && id != senderID && id == peripheralID {
|
||||
tempIDToRemove = id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if let tempID = tempIDToRemove {
|
||||
SecureLogger.log("handleReceivedPacket: Transferring from temp ID \(tempID) to real peer ID \(senderID)", category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Remove temp mapping
|
||||
self.connectedPeripherals.removeValue(forKey: tempID)
|
||||
// Add real peer ID mapping
|
||||
self.connectedPeripherals[senderID] = peripheral
|
||||
// Update peripheral ID to peer ID mapping
|
||||
self.peerIDByPeripheralID[peripheral.identifier.uuidString] = senderID
|
||||
|
||||
// Transfer RSSI from temp ID to real peer ID
|
||||
if let tempRSSI = self.peripheralRSSI[tempID] {
|
||||
self.peerRSSI[senderID] = tempRSSI
|
||||
self.peripheralRSSI.removeValue(forKey: tempID)
|
||||
}
|
||||
// Also check if RSSI is stored by peripheral UUID
|
||||
let peripheralUUID = peripheral.identifier.uuidString
|
||||
if let peripheralStoredRSSI = self.peripheralRSSI[peripheralUUID] {
|
||||
self.peerRSSI[senderID] = peripheralStoredRSSI
|
||||
}
|
||||
// RSSI transfer is handled by updatePeripheralMapping
|
||||
self.peripheralRSSI.removeValue(forKey: tempID)
|
||||
|
||||
// IMPORTANT: Remove old peer ID from activePeers to prevent duplicates
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
@@ -2782,7 +2834,6 @@ class BluetoothMeshService: NSObject {
|
||||
// Just update the peer list
|
||||
self.notifyPeerListUpdate()
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
// Relay announce if TTL > 0
|
||||
@@ -2794,7 +2845,6 @@ class BluetoothMeshService: NSObject {
|
||||
let delay = Double.random(in: 0.1...0.3)
|
||||
self.scheduleRelay(relayPacket, messageID: messageID, delay: delay)
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
case .leave:
|
||||
@@ -2809,6 +2859,7 @@ class BluetoothMeshService: NSObject {
|
||||
if wasRemoved {
|
||||
// Mark as gracefully left to prevent duplicate disconnect message
|
||||
self.gracefullyLeftPeers.insert(senderID)
|
||||
self.gracefulLeaveTimestamps[senderID] = Date()
|
||||
|
||||
SecureLogger.log("📴 Peer left network: \(senderID) (\(nickname)) - marked as gracefully left", category: SecureLogger.session, level: .info)
|
||||
}
|
||||
@@ -2973,6 +3024,20 @@ class BluetoothMeshService: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
// Special duplicate detection for identity announces
|
||||
processedMessagesLock.lock()
|
||||
if let lastSeenTime = recentIdentityAnnounces[senderID] {
|
||||
let timeSince = Date().timeIntervalSince(lastSeenTime)
|
||||
if timeSince < identityAnnounceDuplicateWindow {
|
||||
processedMessagesLock.unlock()
|
||||
SecureLogger.log("Dropped duplicate identity announce from \(senderID) (last seen \(timeSince)s ago)",
|
||||
category: SecureLogger.security, level: .debug)
|
||||
return
|
||||
}
|
||||
}
|
||||
recentIdentityAnnounces[senderID] = Date()
|
||||
processedMessagesLock.unlock()
|
||||
|
||||
if senderID != myPeerID && !isPeerIDOurs(senderID) {
|
||||
// Create defensive copy and validate
|
||||
let payloadCopy = Data(packet.payload)
|
||||
@@ -3024,6 +3089,27 @@ class BluetoothMeshService: NSObject {
|
||||
|
||||
SecureLogger.log("Creating identity binding for \(announcement.peerID) -> \(fingerprint)", category: SecureLogger.security, level: .info)
|
||||
|
||||
// Handle peer ID rotation if previousPeerID is provided
|
||||
if let previousID = announcement.previousPeerID, previousID != announcement.peerID {
|
||||
SecureLogger.log("Peer announced rotation from \(previousID) to \(announcement.peerID)",
|
||||
category: SecureLogger.security, level: .info)
|
||||
|
||||
// Transfer states from previous ID
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
// Transfer gracefullyLeft state
|
||||
if self.gracefullyLeftPeers.contains(previousID) {
|
||||
self.gracefullyLeftPeers.remove(previousID)
|
||||
self.gracefullyLeftPeers.insert(announcement.peerID)
|
||||
}
|
||||
|
||||
// Clean up the old peer ID from active peers
|
||||
if self.activePeers.contains(previousID) {
|
||||
self.activePeers.remove(previousID)
|
||||
// Don't add new ID yet - let normal flow handle it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update our mappings
|
||||
updatePeerBinding(announcement.peerID, fingerprint: fingerprint, binding: binding)
|
||||
|
||||
@@ -3189,7 +3275,7 @@ class BluetoothMeshService: NSObject {
|
||||
let senderID = packet.senderID.hexEncodedString()
|
||||
if !isPeerIDOurs(senderID) {
|
||||
_ = packet.recipientID?.hexEncodedString()
|
||||
handleNoiseEncryptedMessage(from: senderID, encryptedData: packet.payload, originalPacket: packet)
|
||||
handleNoiseEncryptedMessage(from: senderID, encryptedData: packet.payload, originalPacket: packet, peripheral: peripheral)
|
||||
}
|
||||
|
||||
case .versionHello:
|
||||
@@ -3297,9 +3383,7 @@ class BluetoothMeshService: NSObject {
|
||||
timestamp: packet.timestamp, // Use original timestamp
|
||||
payload: fragmentPayload,
|
||||
signature: nil, // Fragments don't need signatures
|
||||
ttl: packet.ttl,
|
||||
sequenceNumber: getNextSequenceNumber()
|
||||
)
|
||||
ttl: packet.ttl )
|
||||
|
||||
// Send fragments with linear delay
|
||||
let totalDelay = Double(index) * delayBetweenFragments
|
||||
@@ -3465,7 +3549,7 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // End of BluetoothMeshService class
|
||||
|
||||
extension BluetoothMeshService: CBCentralManagerDelegate {
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
@@ -3608,19 +3692,19 @@ extension BluetoothMeshService: CBCentralManagerDelegate {
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
let tempID = peripheral.identifier.uuidString
|
||||
// Peripheral connected
|
||||
|
||||
// Current mapping state for debugging
|
||||
SecureLogger.log("Current peerIDByPeripheralID mappings:",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
|
||||
peripheral.delegate = self
|
||||
peripheral.discoverServices([BluetoothMeshService.serviceUUID])
|
||||
|
||||
// Store peripheral by its system ID temporarily until we get the real peer ID
|
||||
connectedPeripherals[tempID] = peripheral
|
||||
SecureLogger.log("didConnect: Stored peripheral with temp ID \(tempID)", category: SecureLogger.session, level: .debug)
|
||||
// Register peripheral in simplified mapping system
|
||||
registerPeripheral(peripheral)
|
||||
|
||||
// Store peripheral temporarily until we get the real peer ID
|
||||
connectedPeripherals[peripheralID] = peripheral
|
||||
|
||||
SecureLogger.log("Connected to peripheral \(peripheralID) - awaiting peer ID",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Update connection state to connected (but not authenticated yet)
|
||||
// We don't know the real peer ID yet, so we can't update the state
|
||||
@@ -3659,16 +3743,20 @@ extension BluetoothMeshService: CBCentralManagerDelegate {
|
||||
SecureLogger.log("Peripheral disconnected normally: \(peripheralID)", category: SecureLogger.session, level: .info)
|
||||
}
|
||||
|
||||
// Find the real peer ID for this peripheral
|
||||
// Find the real peer ID using simplified mapping
|
||||
var realPeerID: String? = nil
|
||||
|
||||
// First check our peripheral ID to peer ID mapping
|
||||
if let peerID = peerIDByPeripheralID[peripheralID] {
|
||||
if let mapping = peripheralMappings[peripheralID], let peerID = mapping.peerID {
|
||||
realPeerID = peerID
|
||||
SecureLogger.log("Found peer ID \(peerID) from peerIDByPeripheralID for peripheral \(peripheralID)",
|
||||
SecureLogger.log("Found peer ID \(peerID) for peripheral \(peripheralID)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
} else if let peerID = peerIDByPeripheralID[peripheralID] {
|
||||
// Fallback to legacy mapping
|
||||
realPeerID = peerID
|
||||
SecureLogger.log("Found peer ID \(peerID) from legacy mapping for peripheral \(peripheralID)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
} else {
|
||||
SecureLogger.log("No mapping in peerIDByPeripheralID for peripheral \(peripheralID), checking connectedPeripherals",
|
||||
SecureLogger.log("No peer ID mapping found for peripheral \(peripheralID)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Fallback: check if we have a direct mapping from peripheral to peer ID
|
||||
@@ -3738,6 +3826,7 @@ extension BluetoothMeshService: CBCentralManagerDelegate {
|
||||
}
|
||||
|
||||
// Clean up peripheral tracking
|
||||
peripheralMappings.removeValue(forKey: peripheralID)
|
||||
peerIDByPeripheralID.removeValue(forKey: peripheralID)
|
||||
lastActivityByPeripheralID.removeValue(forKey: peripheralID)
|
||||
|
||||
@@ -3781,6 +3870,7 @@ extension BluetoothMeshService: CBCentralManagerDelegate {
|
||||
} else {
|
||||
// Peer gracefully left, just clean up the tracking
|
||||
self.gracefullyLeftPeers.remove(peerID)
|
||||
self.gracefulLeaveTimestamps.removeValue(forKey: peerID)
|
||||
SecureLogger.log("Cleaning up gracefullyLeftPeers for \(peerID)", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
}
|
||||
@@ -3874,9 +3964,7 @@ extension BluetoothMeshService: CBPeripheralDelegate {
|
||||
type: MessageType.announce.rawValue,
|
||||
ttl: 3,
|
||||
senderID: self.myPeerID,
|
||||
payload: Data(vm.nickname.utf8),
|
||||
sequenceNumber: self.getNextSequenceNumber()
|
||||
)
|
||||
payload: Data(vm.nickname.utf8) )
|
||||
self.broadcastPacket(announcePacket)
|
||||
}
|
||||
|
||||
@@ -3891,9 +3979,7 @@ extension BluetoothMeshService: CBPeripheralDelegate {
|
||||
type: MessageType.announce.rawValue,
|
||||
ttl: 3,
|
||||
senderID: self.myPeerID,
|
||||
payload: Data(vm.nickname.utf8),
|
||||
sequenceNumber: self.getNextSequenceNumber()
|
||||
)
|
||||
payload: Data(vm.nickname.utf8) )
|
||||
if let data = announcePacket.toBinaryData() {
|
||||
self.writeToPeripheral(data, peripheral: peripheral, characteristic: characteristic, peerID: nil)
|
||||
}
|
||||
@@ -3981,37 +4067,30 @@ extension BluetoothMeshService: CBPeripheralDelegate {
|
||||
// Valid RSSI received, reset retry count
|
||||
rssiRetryCount.removeValue(forKey: peripheralID)
|
||||
|
||||
// Store RSSI by peripheral ID for fallback lookup
|
||||
// Update RSSI in simplified mapping
|
||||
updatePeripheralRSSI(peripheralID: peripheralID, rssi: RSSI)
|
||||
|
||||
// Also store in legacy mapping for compatibility
|
||||
peripheralRSSI[peripheralID] = RSSI
|
||||
|
||||
// Find the peer ID for this peripheral
|
||||
if let peerID = connectedPeripherals.first(where: { $0.value == peripheral })?.key {
|
||||
// Handle both temp IDs and real peer IDs
|
||||
// If we have a peer ID, update peer RSSI (handled in updatePeripheralRSSI)
|
||||
if let mapping = peripheralMappings[peripheralID], mapping.isIdentified {
|
||||
// Force UI update when we have a real peer ID
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
if peerID.count != 16 {
|
||||
// It's a temp ID, store RSSI temporarily
|
||||
self.peripheralRSSI[peerID] = RSSI
|
||||
// Keep trying to read RSSI until we get real peer ID
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak peripheral] in
|
||||
guard let peripheral = peripheral, peripheral.state == .connected else { return }
|
||||
peripheral.readRSSI()
|
||||
}
|
||||
} else {
|
||||
// It's a real peer ID, store it
|
||||
self.peerRSSI[peerID] = RSSI
|
||||
// Force UI update when we have a real peer ID
|
||||
self.notifyPeerListUpdate()
|
||||
}
|
||||
self?.notifyPeerListUpdate()
|
||||
}
|
||||
|
||||
// Periodically update RSSI
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak peripheral] in
|
||||
} else {
|
||||
// Keep trying to read RSSI until we get real peer ID
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak peripheral] in
|
||||
guard let peripheral = peripheral, peripheral.state == .connected else { return }
|
||||
peripheral.readRSSI()
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
// Periodically update RSSI
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak peripheral] in
|
||||
guard let peripheral = peripheral, peripheral.state == .connected else { return }
|
||||
peripheral.readRSSI()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4112,8 +4191,9 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
if !subscribedCentrals.contains(central) {
|
||||
subscribedCentrals.append(central)
|
||||
|
||||
// Send Noise identity announcement to newly connected central
|
||||
sendNoiseIdentityAnnounce()
|
||||
// Only send identity announcement if we haven't recently
|
||||
// This reduces spam when multiple centrals connect quickly
|
||||
// sendNoiseIdentityAnnounce() will check rate limits internally
|
||||
|
||||
// Update peer list to show we're connected (even without peer ID yet)
|
||||
self.notifyPeerListUpdate()
|
||||
@@ -4581,6 +4661,12 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
// Clean global timestamps
|
||||
totalMessageTimestamps.removeAll { $0 < cutoff }
|
||||
|
||||
// Clean up old identity announce tracking
|
||||
processedMessagesLock.lock()
|
||||
let identityCutoff = Date().addingTimeInterval(-identityAnnounceDuplicateWindow * 2)
|
||||
recentIdentityAnnounces = recentIdentityAnnounces.filter { $0.value > identityCutoff }
|
||||
processedMessagesLock.unlock()
|
||||
|
||||
// Clean per-peer chat message timestamps
|
||||
for (peerID, timestamps) in messageRateLimiter {
|
||||
let filtered = timestamps.filter { $0 >= cutoff }
|
||||
@@ -4753,6 +4839,26 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
initiateNoiseHandshake(with: peerID)
|
||||
}
|
||||
|
||||
// Send keep-alive pings to all connected peers to prevent iOS BLE timeouts
|
||||
private func sendKeepAlivePings() {
|
||||
let connectedPeers = collectionsQueue.sync {
|
||||
return Array(activePeers).filter { peerID in
|
||||
// Only send keepalive to authenticated peers
|
||||
return peerConnectionStates[peerID] == .authenticated
|
||||
}
|
||||
}
|
||||
|
||||
for peerID in connectedPeers {
|
||||
// Don't spam if we recently heard from them
|
||||
if let lastHeard = lastHeardFromPeer[peerID],
|
||||
Date().timeIntervalSince(lastHeard) < keepAliveInterval / 2 {
|
||||
continue // Skip if we heard from them in the last 10 seconds
|
||||
}
|
||||
|
||||
validateNoiseSession(with: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate an existing Noise session by sending an encrypted ping
|
||||
private func validateNoiseSession(with peerID: String) {
|
||||
let encryptionQueue = getEncryptionQueue(for: peerID)
|
||||
@@ -4775,9 +4881,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
signature: nil,
|
||||
ttl: 1,
|
||||
sequenceNumber: self.getNextSequenceNumber()
|
||||
)
|
||||
ttl: 1 )
|
||||
|
||||
self.broadcastPacket(packet)
|
||||
|
||||
@@ -4896,8 +5000,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: handshakeData,
|
||||
signature: nil,
|
||||
ttl: 6, // Increased TTL for better delivery on startup
|
||||
sequenceNumber: getNextSequenceNumber()
|
||||
ttl: 6 // Increased TTL for better delivery on startup
|
||||
)
|
||||
|
||||
// Track packet for ACK
|
||||
@@ -4970,8 +5073,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: response,
|
||||
signature: nil,
|
||||
ttl: 6, // Increased TTL for better delivery on startup
|
||||
sequenceNumber: getNextSequenceNumber()
|
||||
ttl: 6 // Increased TTL for better delivery on startup
|
||||
)
|
||||
|
||||
// Track packet for ACK
|
||||
@@ -5043,7 +5145,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleNoiseEncryptedMessage(from peerID: String, encryptedData: Data, originalPacket: BitchatPacket) {
|
||||
private func handleNoiseEncryptedMessage(from peerID: String, encryptedData: Data, originalPacket: BitchatPacket, peripheral: CBPeripheral? = nil) {
|
||||
// Use noiseService directly
|
||||
|
||||
// For Noise encrypted messages, we need to decrypt first to check the inner packet
|
||||
@@ -5137,7 +5239,8 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
// Process the decrypted inner packet
|
||||
// The packet will be handled according to its recipient ID
|
||||
// If it's for us, it won't be relayed
|
||||
handleReceivedPacket(innerPacket, from: peerID)
|
||||
// Pass the peripheral context for proper ACK routing
|
||||
handleReceivedPacket(innerPacket, from: peerID, peripheral: peripheral)
|
||||
} else {
|
||||
SecureLogger.log("Failed to parse inner packet from decrypted data", category: SecureLogger.encryption, level: .warning)
|
||||
}
|
||||
@@ -5200,6 +5303,17 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
// Update last connection time
|
||||
lastConnectionTime[peerID] = Date()
|
||||
|
||||
// Check if we've already negotiated version with this peer
|
||||
if let existingVersion = negotiatedVersions[peerID] {
|
||||
SecureLogger.log("Already negotiated version \(existingVersion) with \(peerID), skipping re-negotiation",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
// If we have a session, validate it
|
||||
if noiseService.hasEstablishedSession(with: peerID) {
|
||||
validateNoiseSession(with: peerID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Try JSON first if it looks like JSON
|
||||
let hello: VersionHello?
|
||||
if let firstByte = dataCopy.first, firstByte == 0x7B { // '{' character
|
||||
@@ -5344,9 +5458,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
type: MessageType.versionHello.rawValue,
|
||||
ttl: 1, // Version negotiation is direct, no relay
|
||||
senderID: myPeerID,
|
||||
payload: helloData,
|
||||
sequenceNumber: getNextSequenceNumber()
|
||||
)
|
||||
payload: helloData )
|
||||
|
||||
// Mark that we initiated version negotiation
|
||||
// We don't know the peer ID yet from peripheral, so we'll track it when we get the response
|
||||
@@ -5373,8 +5485,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: ackData,
|
||||
signature: nil,
|
||||
ttl: 1, // Direct response, no relay
|
||||
sequenceNumber: getNextSequenceNumber()
|
||||
ttl: 1 // Direct response, no relay
|
||||
)
|
||||
|
||||
broadcastPacket(packet)
|
||||
@@ -5522,8 +5633,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: ack.toBinaryData(),
|
||||
signature: nil,
|
||||
ttl: 3, // ACKs don't need to travel far
|
||||
sequenceNumber: getNextSequenceNumber()
|
||||
ttl: 3 // ACKs don't need to travel far
|
||||
)
|
||||
|
||||
broadcastPacket(ackPacket)
|
||||
@@ -5549,8 +5659,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: nack.toBinaryData(),
|
||||
signature: nil,
|
||||
ttl: 3, // NACKs don't need to travel far
|
||||
sequenceNumber: getNextSequenceNumber()
|
||||
ttl: 3 // NACKs don't need to travel far
|
||||
)
|
||||
|
||||
broadcastPacket(nackPacket)
|
||||
@@ -5564,9 +5673,10 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
// Create a deterministic ID using SHA256 of immutable fields
|
||||
var data = Data()
|
||||
data.append(packet.senderID)
|
||||
data.append(contentsOf: withUnsafeBytes(of: packet.sequenceNumber) { Array($0) })
|
||||
data.append(contentsOf: withUnsafeBytes(of: packet.timestamp) { Array($0) })
|
||||
data.append(packet.type)
|
||||
// Add first 32 bytes of payload for uniqueness
|
||||
data.append(packet.payload.prefix(32))
|
||||
|
||||
let hash = SHA256.hash(data: data)
|
||||
let hashData = Data(hash)
|
||||
@@ -5653,7 +5763,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
retries: pending.retries + 1)
|
||||
|
||||
// Resend the packet
|
||||
SecureLogger.log("Resending packet seq#\(pending.packet.sequenceNumber) due to ACK timeout (retry \(pending.retries + 1))",
|
||||
SecureLogger.log("Resending packet due to ACK timeout (retry \(pending.retries + 1))",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
DispatchQueue.main.async {
|
||||
self.broadcastPacket(pending.packet)
|
||||
@@ -5722,9 +5832,9 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
let hasConnection = connectionState == .connected || connectionState == .authenticating || connectionState == .authenticated
|
||||
|
||||
// Peer is available if:
|
||||
// 1. We have an active connection AND heard from them recently, OR
|
||||
// 1. We have an active connection (regardless of last heard time), OR
|
||||
// 2. We're authenticated and heard from them within timeout period
|
||||
let isAvailable = (hasConnection && timeSinceLastHeard < 60.0) ||
|
||||
let isAvailable = hasConnection ||
|
||||
(connectionState == .authenticated && timeSinceLastHeard < peerAvailabilityTimeout)
|
||||
|
||||
if wasAvailable != isAvailable {
|
||||
@@ -5834,9 +5944,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: announcementData,
|
||||
signature: nil,
|
||||
ttl: adaptiveTTL,
|
||||
sequenceNumber: getNextSequenceNumber()
|
||||
)
|
||||
ttl: adaptiveTTL )
|
||||
|
||||
if let targetPeer = specificPeerID {
|
||||
SecureLogger.log("Sending targeted identity announce to \(targetPeer)",
|
||||
@@ -5958,8 +6066,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: messageData,
|
||||
signature: nil,
|
||||
ttl: self.adaptiveTTL, // Inner packet needs valid TTL for processing after decryption
|
||||
sequenceNumber: getNextSequenceNumber()
|
||||
ttl: self.adaptiveTTL // Inner packet needs valid TTL for processing after decryption
|
||||
)
|
||||
|
||||
guard let innerData = innerPacket.toBinaryData() else { return }
|
||||
@@ -5981,9 +6088,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encryptedData,
|
||||
signature: nil,
|
||||
ttl: adaptiveTTL,
|
||||
sequenceNumber: getNextSequenceNumber()
|
||||
)
|
||||
ttl: adaptiveTTL )
|
||||
|
||||
SecureLogger.log("Broadcasting encrypted private message \(msgID) to \(recipientPeerID)", category: SecureLogger.session, level: .info)
|
||||
|
||||
|
||||
@@ -103,6 +103,14 @@ class ChatViewModel: ObservableObject {
|
||||
.sink { [weak self] (messageID, status) in
|
||||
self?.updateMessageDeliveryStatus(messageID, status: status)
|
||||
}
|
||||
|
||||
// Listen for retry notifications
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleRetryMessage),
|
||||
name: Notification.Name("bitchat.retryMessage"),
|
||||
object: nil
|
||||
)
|
||||
|
||||
// When app becomes active, send read receipts for visible messages
|
||||
#if os(macOS)
|
||||
@@ -549,6 +557,30 @@ class ChatViewModel: ObservableObject {
|
||||
selectedPrivateChatFingerprint = nil
|
||||
}
|
||||
|
||||
@objc private func handleRetryMessage(_ notification: Notification) {
|
||||
guard let messageID = notification.userInfo?["messageID"] as? String else { return }
|
||||
|
||||
// Find the message to retry
|
||||
if let message = messages.first(where: { $0.id == messageID }) {
|
||||
SecureLogger.log("Retrying message \(messageID) to \(message.recipientNickname ?? "unknown")",
|
||||
category: SecureLogger.session, level: .info)
|
||||
|
||||
// Resend the message through mesh service
|
||||
if message.isPrivate,
|
||||
let peerID = getPeerIDForNickname(message.recipientNickname ?? "") {
|
||||
// Update status to sending
|
||||
updateMessageDeliveryStatus(messageID, status: .sending)
|
||||
|
||||
// Resend via mesh service
|
||||
meshService.sendMessage(message.content,
|
||||
mentions: message.mentions ?? [],
|
||||
to: peerID,
|
||||
messageID: messageID,
|
||||
timestamp: message.timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func appDidBecomeActive() {
|
||||
// When app becomes active, send read receipts for visible private chat
|
||||
if let peerID = selectedPrivateChatPeer {
|
||||
|
||||
@@ -23,17 +23,17 @@ struct AppInfoView: View {
|
||||
|
||||
enum Features {
|
||||
static let title = "FEATURES"
|
||||
static let offlineComm = ("wifi.slash", "offline communication", "works without internet using Bluetooth mesh networking")
|
||||
static let offlineComm = ("wifi.slash", "offline communication", "works without internet using Bluetooth low energy")
|
||||
static let encryption = ("lock.shield", "end-to-end encryption", "private messages encrypted with noise protocol")
|
||||
static let extendedRange = ("antenna.radiowaves.left.and.right", "extended range", "messages relay through peers, increasing the distance")
|
||||
static let favorites = ("star.fill", "favorites", "store-and-forward messages for favorite people")
|
||||
static let extendedRange = ("antenna.radiowaves.left.and.right", "extended range", "messages relay through peers, going the distance")
|
||||
static let favorites = ("star.fill", "favorites", "get notified when your favorite people join")
|
||||
static let mentions = ("at", "mentions", "use @nickname to notify specific people")
|
||||
}
|
||||
|
||||
enum Privacy {
|
||||
static let title = "PRIVACY"
|
||||
static let noTracking = ("eye.slash", "no tracking", "no servers, accounts, or data collection")
|
||||
static let ephemeral = ("shuffle", "ephemeral identity", "new peer ID generated each session")
|
||||
static let ephemeral = ("shuffle", "ephemeral identity", "new peer ID generated regularly")
|
||||
static let panic = ("hand.raised.fill", "panic mode", "triple-tap logo to instantly clear all data")
|
||||
}
|
||||
|
||||
@@ -44,8 +44,7 @@ struct AppInfoView: View {
|
||||
"• swipe left for sidebar",
|
||||
"• tap a peer to start a private chat",
|
||||
"• use @nickname to mention someone",
|
||||
"• triple-tap \"bitchat\" for panic mode",
|
||||
"• triple-tap chat messages to clear current chat"
|
||||
"• triple-tap chat to clear"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user