From 602657fec2c5ba060b95741d49bcb4ea8dd14b94 Mon Sep 17 00:00:00 2001 From: Nelson Campos Date: Mon, 7 Jul 2025 22:32:47 -0300 Subject: [PATCH] Enhance message handling and synchronization in BluetoothMeshService - Introduce thread safety for recentlySentMessages using NSLock to prevent race conditions. - Adjust message delay parameters for faster synchronization: reduce min/max message delays and adjust initial announce timing. - Update sendMessage and sendEncryptedRoomMessage methods to accept optional messageID and timestamp parameters for better tracking. - Improve message deduplication logic in ChatViewModel to prevent duplicates from being added when echoing back sent messages. - Modify MessageRetryService to include original message ID and timestamp for better retry handling. --- bitchat/Services/BluetoothMeshService.swift | 117 +++++++++++++------- bitchat/Services/MessageRetryService.swift | 28 ++++- bitchat/ViewModels/ChatViewModel.swift | 61 ++++++++-- 3 files changed, 151 insertions(+), 55 deletions(-) diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index dc9bacc1..9d0c5b53 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -74,6 +74,7 @@ class BluetoothMeshService: NSObject { private var cachedMessagesSentToPeer: Set = [] // Track which peers have already received cached messages private var receivedMessageTimestamps: [String: Date] = [:] // Track timestamps of received messages for debugging private var recentlySentMessages: Set = [] // Short-term cache to prevent any duplicate sends + private let recentlySentMessagesLock = NSLock() // Thread safety for recentlySentMessages private var lastMessageFromPeer: [String: Date] = [:] // Track last message time from each peer for connection prioritization // Battery and range optimizations @@ -100,8 +101,8 @@ class BluetoothMeshService: NSObject { private var advertisingTimer: Timer? // Timer for interval-based advertising // Timing randomization for privacy - private let minMessageDelay: TimeInterval = 0.05 // 50ms minimum - private let maxMessageDelay: TimeInterval = 0.5 // 500ms maximum + private let minMessageDelay: TimeInterval = 0.01 // 10ms minimum for faster sync + private let maxMessageDelay: TimeInterval = 0.1 // 100ms maximum for faster sync // Fragment handling private var incomingFragments: [String: [Int: Data]] = [:] // fragmentID -> [index: data] @@ -366,7 +367,7 @@ class BluetoothMeshService: NSObject { } // Send initial announces after services are ready - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in self?.sendBroadcastAnnounce() } @@ -395,7 +396,7 @@ class BluetoothMeshService: NSObject { } // Send multiple times for reliability with jittered delays - for baseDelay in [0.5, 1.0, 2.0] { + for baseDelay in [0.2, 0.5, 1.0] { let jitteredDelay = baseDelay + self.randomDelay() DispatchQueue.main.asyncAfter(deadline: .now() + jitteredDelay) { [weak self] in guard let self = self else { return } @@ -489,7 +490,7 @@ class BluetoothMeshService: NSObject { self.characteristic = characteristic } - func sendMessage(_ content: String, mentions: [String] = [], room: String? = nil, to recipientID: String? = nil) { + func sendMessage(_ content: String, mentions: [String] = [], room: String? = nil, to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) { messageQueue.async { [weak self] in guard let self = self else { return } @@ -497,9 +498,10 @@ class BluetoothMeshService: NSObject { let senderNick = nickname?.nickname ?? self.myPeerID let message = BitchatMessage( + id: messageID, sender: senderNick, content: content, - timestamp: Date(), + timestamp: timestamp ?? Date(), isRelay: false, originalSender: nil, isPrivate: false, @@ -532,12 +534,21 @@ class BluetoothMeshService: NSObject { // Track this message to prevent duplicate sends let msgID = "\(packet.timestamp)-\(self.myPeerID)-\(packet.payload.prefix(32).hashValue)" - if !self.recentlySentMessages.contains(msgID) { + + self.recentlySentMessagesLock.lock() + let shouldSend = !self.recentlySentMessages.contains(msgID) + if shouldSend { self.recentlySentMessages.insert(msgID) - + } + self.recentlySentMessagesLock.unlock() + + if shouldSend { // Clean up old entries after 10 seconds self.messageQueue.asyncAfter(deadline: .now() + 10.0) { [weak self] in - self?.recentlySentMessages.remove(msgID) + guard let self = self else { return } + self.recentlySentMessagesLock.lock() + self.recentlySentMessages.remove(msgID) + self.recentlySentMessagesLock.unlock() } // Add random delay before initial send @@ -630,12 +641,21 @@ class BluetoothMeshService: NSObject { // Track to prevent duplicate sends let msgID = "\(packet.timestamp)-\(self.myPeerID)-\(packet.payload.prefix(32).hashValue)" - if !self.recentlySentMessages.contains(msgID) { + + self.recentlySentMessagesLock.lock() + let shouldSend = !self.recentlySentMessages.contains(msgID) + if shouldSend { self.recentlySentMessages.insert(msgID) - + } + self.recentlySentMessagesLock.unlock() + + if shouldSend { // Clean up after 10 seconds self.messageQueue.asyncAfter(deadline: .now() + 10.0) { [weak self] in - self?.recentlySentMessages.remove(msgID) + guard let self = self else { return } + self.recentlySentMessagesLock.lock() + self.recentlySentMessages.remove(msgID) + self.recentlySentMessagesLock.unlock() } // Message tracking is now done in ChatViewModel to ensure consistent message IDs @@ -784,7 +804,7 @@ class BluetoothMeshService: NSObject { } } - func sendEncryptedRoomMessage(_ content: String, mentions: [String], room: String, roomKey: SymmetricKey) { + func sendEncryptedRoomMessage(_ content: String, mentions: [String], room: String, roomKey: SymmetricKey, messageID: String? = nil, timestamp: Date? = nil) { messageQueue.async { [weak self] in guard let self = self else { return } @@ -802,9 +822,10 @@ class BluetoothMeshService: NSObject { // Create message with encrypted content let message = BitchatMessage( + id: messageID, sender: senderNick, content: "", // Empty placeholder since actual content is encrypted - timestamp: Date(), + timestamp: timestamp ?? Date(), isRelay: false, originalSender: nil, isPrivate: false, @@ -1184,7 +1205,7 @@ class BluetoothMeshService: NSObject { // Send cached messages with slight delay between each for (index, storedMessage) in messagesToSend.enumerated() { - let delay = Double(index) * 0.1 // 100ms between messages + let delay = Double(index) * 0.02 // 20ms between messages for faster sync DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak peripheral] in guard let peripheral = peripheral, @@ -1238,8 +1259,10 @@ class BluetoothMeshService: NSObject { private func broadcastPacket(_ packet: BitchatPacket) { guard let data = packet.toBinaryData() else { // print("[ERROR] Failed to convert packet to binary data") - // Add to retry queue if this is a message packet - if packet.type == MessageType.message.rawValue, + // Add to retry queue if this is a message packet AND it's our own message + if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8), + senderID == self.myPeerID, + packet.type == MessageType.message.rawValue, let message = BitchatMessage.fromBinaryPayload(packet.payload) { MessageRetryService.shared.addMessageForRetry( content: message.content, @@ -1247,7 +1270,10 @@ class BluetoothMeshService: NSObject { room: message.room, isPrivate: message.isPrivate, recipientPeerID: nil, - recipientNickname: message.recipientNickname + recipientNickname: message.recipientNickname, + roomKey: nil, + originalMessageID: message.id, + originalTimestamp: message.timestamp ) } return @@ -1294,29 +1320,36 @@ class BluetoothMeshService: NSObject { } } - // If no peers received the message, add to retry queue + // If no peers received the message, add to retry queue ONLY if it's our own message if sentToPeripherals == 0 && sentToCentrals == 0 { - if packet.type == MessageType.message.rawValue, - let message = BitchatMessage.fromBinaryPayload(packet.payload) { - // For encrypted room messages, we need to preserve the room key - var roomKeyData: Data? = nil - if let room = message.room, message.isEncrypted { - // This is an encrypted room message - if let viewModel = delegate as? ChatViewModel, - let roomKey = viewModel.roomKeys[room] { - roomKeyData = roomKey.withUnsafeBytes { Data($0) } + // Check if this packet originated from us + if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8), + senderID == self.myPeerID { + // This is our own message that failed to send + if packet.type == MessageType.message.rawValue, + let message = BitchatMessage.fromBinaryPayload(packet.payload) { + // For encrypted room messages, we need to preserve the room key + var roomKeyData: Data? = nil + if let room = message.room, message.isEncrypted { + // This is an encrypted room message + if let viewModel = delegate as? ChatViewModel, + let roomKey = viewModel.roomKeys[room] { + roomKeyData = roomKey.withUnsafeBytes { Data($0) } + } } + + MessageRetryService.shared.addMessageForRetry( + content: message.content, + mentions: message.mentions, + room: message.room, + isPrivate: message.isPrivate, + recipientPeerID: nil, + recipientNickname: message.recipientNickname, + roomKey: roomKeyData, + originalMessageID: message.id, + originalTimestamp: message.timestamp + ) } - - MessageRetryService.shared.addMessageForRetry( - content: message.content, - mentions: message.mentions, - room: message.room, - isPrivate: message.isPrivate, - recipientPeerID: nil, - recipientNickname: message.recipientNickname, - roomKey: roomKeyData - ) } } } @@ -1722,11 +1755,9 @@ class BluetoothMeshService: NSObject { // Send announce with our nickname immediately self.sendAnnouncementToPeer(senderID) - // Delay sending cached messages to ensure connection is fully established - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - // Check if this peer has cached messages (especially for favorites) - self?.sendCachedMessages(to: senderID) - } + // Send cached messages immediately for faster sync + // Check if this peer has cached messages (especially for favorites) + self.sendCachedMessages(to: senderID) } } diff --git a/bitchat/Services/MessageRetryService.swift b/bitchat/Services/MessageRetryService.swift index 0c2fdea8..48a7d4c8 100644 --- a/bitchat/Services/MessageRetryService.swift +++ b/bitchat/Services/MessageRetryService.swift @@ -12,6 +12,8 @@ import CryptoKit struct RetryableMessage { let id: String + let originalMessageID: String? + let originalTimestamp: Date? let content: String let mentions: [String]? let room: String? @@ -29,7 +31,7 @@ class MessageRetryService { private var retryQueue: [RetryableMessage] = [] private var retryTimer: Timer? - private let retryInterval: TimeInterval = 5.0 // Retry every 5 seconds + private let retryInterval: TimeInterval = 2.0 // Retry every 2 seconds for faster sync private let maxQueueSize = 50 weak var meshService: BluetoothMeshService? @@ -55,7 +57,9 @@ class MessageRetryService { isPrivate: Bool = false, recipientPeerID: String? = nil, recipientNickname: String? = nil, - roomKey: Data? = nil + roomKey: Data? = nil, + originalMessageID: String? = nil, + originalTimestamp: Date? = nil ) { // Don't queue if we're at capacity guard retryQueue.count < maxQueueSize else { @@ -64,6 +68,8 @@ class MessageRetryService { let retryMessage = RetryableMessage( id: UUID().uuidString, + originalMessageID: originalMessageID, + originalTimestamp: originalTimestamp, content: content, mentions: mentions, room: room, @@ -113,13 +119,16 @@ class MessageRetryService { meshService.sendPrivateMessage( message.content, to: recipientID, - recipientNickname: message.recipientNickname ?? "unknown" + recipientNickname: message.recipientNickname ?? "unknown", + messageID: message.originalMessageID ) } else { // Recipient not connected, keep in queue with updated retry time var updatedMessage = message updatedMessage = RetryableMessage( id: message.id, + originalMessageID: message.originalMessageID, + originalTimestamp: message.originalTimestamp, content: message.content, mentions: message.mentions, room: message.room, @@ -141,13 +150,17 @@ class MessageRetryService { message.content, mentions: message.mentions ?? [], room: room, - roomKey: roomKey + roomKey: roomKey, + messageID: message.originalMessageID, + timestamp: message.originalTimestamp ) } else { // No peers connected, keep in queue var updatedMessage = message updatedMessage = RetryableMessage( id: message.id, + originalMessageID: message.originalMessageID, + originalTimestamp: message.originalTimestamp, content: message.content, mentions: message.mentions, room: message.room, @@ -166,13 +179,18 @@ class MessageRetryService { meshService.sendMessage( message.content, mentions: message.mentions ?? [], - room: message.room + room: message.room, + to: nil, + messageID: message.originalMessageID, + timestamp: message.originalTimestamp ) } else { // No peers connected, keep in queue var updatedMessage = message updatedMessage = RetryableMessage( id: message.id, + originalMessageID: message.originalMessageID, + originalTimestamp: message.originalTimestamp, content: message.content, mentions: message.mentions, room: message.room, diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 57dafea8..346c76fe 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -2118,8 +2118,32 @@ extension ChatViewModel: BitchatDelegate { if roomMessages[room] == nil { roomMessages[room] = [] } - roomMessages[room]?.append(messageToAdd) - roomMessages[room]?.sort { $0.timestamp < $1.timestamp } + + // Check if this is our own message being echoed back + if messageToAdd.sender != nickname { + roomMessages[room]?.append(messageToAdd) + roomMessages[room]?.sort { $0.timestamp < $1.timestamp } + } else { + // Our own message - check if we already have it (by ID and content) + let messageExists = roomMessages[room]?.contains { existingMsg in + // Check by ID first + if existingMsg.id == messageToAdd.id { + return true + } + // Check by content and sender with time window (within 1 second) + if existingMsg.content == messageToAdd.content && + existingMsg.sender == messageToAdd.sender { + let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(messageToAdd.timestamp)) + return timeDiff < 1.0 + } + return false + } ?? false + if !messageExists { + // This is a message we sent from another device or it's missing locally + roomMessages[room]?.append(messageToAdd) + roomMessages[room]?.sort { $0.timestamp < $1.timestamp } + } + } // Save message if room has retention enabled if retentionEnabledRooms.contains(room) { @@ -2135,8 +2159,8 @@ extension ChatViewModel: BitchatDelegate { } else { } - // Update unread count if not currently viewing this room - if currentRoom != room { + // Update unread count if not currently viewing this room and it's not our own message + if currentRoom != room && messageToAdd.sender != nickname { unreadRoomMessages[room] = (unreadRoomMessages[room] ?? 0) + 1 } } else { @@ -2144,9 +2168,32 @@ extension ChatViewModel: BitchatDelegate { } } else { // Regular public message (main chat) - messages.append(message) - // Sort messages by timestamp to ensure proper ordering - messages.sort { $0.timestamp < $1.timestamp } + // Check if this is our own message being echoed back + if message.sender != nickname { + messages.append(message) + // Sort messages by timestamp to ensure proper ordering + messages.sort { $0.timestamp < $1.timestamp } + } else { + // Our own message - check if we already have it (by ID and content) + let messageExists = messages.contains { existingMsg in + // Check by ID first + if existingMsg.id == message.id { + return true + } + // Check by content and sender with time window (within 1 second) + if existingMsg.content == message.content && + existingMsg.sender == message.sender { + let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(message.timestamp)) + return timeDiff < 1.0 + } + return false + } + if !messageExists { + // This is a message we sent from another device or it's missing locally + messages.append(message) + messages.sort { $0.timestamp < $1.timestamp } + } + } } // Check if we're mentioned