Merge pull request #34 from nelsoncampoos/fix/message-duplication-sync-improvements

Fix message duplication and improve sync performance in mesh network
This commit is contained in:
jack
2025-07-08 18:26:10 +02:00
committed by GitHub
3 changed files with 268 additions and 132 deletions
+49 -38
View File
@@ -101,8 +101,8 @@ class BluetoothMeshService: NSObject {
private var advertisingTimer: Timer? // Timer for interval-based advertising private var advertisingTimer: Timer? // Timer for interval-based advertising
// Timing randomization for privacy // Timing randomization for privacy
private let minMessageDelay: TimeInterval = 0.05 // 50ms minimum private let minMessageDelay: TimeInterval = 0.01 // 10ms minimum for faster sync
private let maxMessageDelay: TimeInterval = 0.5 // 500ms maximum private let maxMessageDelay: TimeInterval = 0.1 // 100ms maximum for faster sync
// Fragment handling // Fragment handling
private var incomingFragments: [String: [Int: Data]] = [:] // fragmentID -> [index: data] private var incomingFragments: [String: [Int: Data]] = [:] // fragmentID -> [index: data]
@@ -372,7 +372,7 @@ class BluetoothMeshService: NSObject {
} }
// Send initial announces after services are ready // 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() self?.sendBroadcastAnnounce()
} }
@@ -401,7 +401,7 @@ class BluetoothMeshService: NSObject {
} }
// Send multiple times for reliability with jittered delays // 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() let jitteredDelay = baseDelay + self.randomDelay()
DispatchQueue.main.asyncAfter(deadline: .now() + jitteredDelay) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + jitteredDelay) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -495,10 +495,9 @@ class BluetoothMeshService: NSObject {
self.characteristic = characteristic self.characteristic = characteristic
} }
func sendMessage(_ content: String, mentions: [String] = [], channel: String? = nil, to recipientID: String? = nil) { func sendMessage(_ content: String, mentions: [String] = [], channel: String? = nil, to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) {
// Defensive check for empty content // Defensive check for empty content
guard !content.isEmpty else { return } guard !content.isEmpty else { return }
messageQueue.async { [weak self] in messageQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -506,9 +505,10 @@ class BluetoothMeshService: NSObject {
let senderNick = nickname?.nickname ?? self.myPeerID let senderNick = nickname?.nickname ?? self.myPeerID
let message = BitchatMessage( let message = BitchatMessage(
id: messageID,
sender: senderNick, sender: senderNick,
content: content, content: content,
timestamp: Date(), timestamp: timestamp ?? Date(),
isRelay: false, isRelay: false,
originalSender: nil, originalSender: nil,
isPrivate: false, isPrivate: false,
@@ -818,7 +818,7 @@ class BluetoothMeshService: NSObject {
} }
} }
func sendEncryptedChannelMessage(_ content: String, mentions: [String], channel: String, channelKey: SymmetricKey) { func sendEncryptedChannelMessage(_ content: String, mentions: [String], channel: String, channelKey: SymmetricKey, messageID: String? = nil, timestamp: Date? = nil) {
messageQueue.async { [weak self] in messageQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -839,9 +839,10 @@ class BluetoothMeshService: NSObject {
// Create message with encrypted content // Create message with encrypted content
let message = BitchatMessage( let message = BitchatMessage(
id: messageID,
sender: senderNick, sender: senderNick,
content: "", // Empty placeholder since actual content is encrypted content: "", // Empty placeholder since actual content is encrypted
timestamp: Date(), timestamp: timestamp ?? Date(),
isRelay: false, isRelay: false,
originalSender: nil, originalSender: nil,
isPrivate: false, isPrivate: false,
@@ -1221,7 +1222,7 @@ class BluetoothMeshService: NSObject {
// Send cached messages with slight delay between each // Send cached messages with slight delay between each
for (index, storedMessage) in messagesToSend.enumerated() { 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 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak peripheral] in
guard let peripheral = peripheral, guard let peripheral = peripheral,
@@ -1275,8 +1276,10 @@ class BluetoothMeshService: NSObject {
private func broadcastPacket(_ packet: BitchatPacket) { private func broadcastPacket(_ packet: BitchatPacket) {
guard let data = packet.toBinaryData() else { guard let data = packet.toBinaryData() else {
// print("[ERROR] Failed to convert packet to binary data") // print("[ERROR] Failed to convert packet to binary data")
// Add to retry queue if this is a message packet // Add to retry queue if this is a message packet AND it's our own message
if packet.type == MessageType.message.rawValue, if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8),
senderID == self.myPeerID,
packet.type == MessageType.message.rawValue,
let message = BitchatMessage.fromBinaryPayload(packet.payload) { let message = BitchatMessage.fromBinaryPayload(packet.payload) {
MessageRetryService.shared.addMessageForRetry( MessageRetryService.shared.addMessageForRetry(
content: message.content, content: message.content,
@@ -1284,7 +1287,10 @@ class BluetoothMeshService: NSObject {
channel: message.channel, channel: message.channel,
isPrivate: message.isPrivate, isPrivate: message.isPrivate,
recipientPeerID: nil, recipientPeerID: nil,
recipientNickname: message.recipientNickname recipientNickname: message.recipientNickname,
channelKey: nil,
originalMessageID: message.id,
originalTimestamp: message.timestamp
) )
} }
return return
@@ -1331,29 +1337,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 sentToPeripherals == 0 && sentToCentrals == 0 {
if packet.type == MessageType.message.rawValue, // Check if this packet originated from us
let message = BitchatMessage.fromBinaryPayload(packet.payload) { if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8),
// For encrypted channel messages, we need to preserve the channel key senderID == self.myPeerID {
var channelKeyData: Data? = nil // This is our own message that failed to send
if let channel = message.channel, message.isEncrypted { if packet.type == MessageType.message.rawValue,
// This is an encrypted channel message let message = BitchatMessage.fromBinaryPayload(packet.payload) {
if let viewModel = delegate as? ChatViewModel, // For encrypted channel messages, we need to preserve the channel key
let channelKey = viewModel.channelKeys[channel] { var channelKeyData: Data? = nil
channelKeyData = channelKey.withUnsafeBytes { Data($0) } if let channel = message.channel, message.isEncrypted {
// This is an encrypted channel message
if let viewModel = delegate as? ChatViewModel,
let channelKey = viewModel.channelKeys[channel] {
channelKeyData = channelKey.withUnsafeBytes { Data($0) }
}
} }
}
MessageRetryService.shared.addMessageForRetry( MessageRetryService.shared.addMessageForRetry(
content: message.content, content: message.content,
mentions: message.mentions, mentions: message.mentions,
channel: message.channel, channel: message.channel,
isPrivate: message.isPrivate, isPrivate: message.isPrivate,
recipientPeerID: nil, recipientPeerID: nil,
recipientNickname: message.recipientNickname, recipientNickname: message.recipientNickname,
channelKey: channelKeyData channelKey: channelKeyData,
) originalMessageID: message.id,
originalTimestamp: message.timestamp
)
}
} }
} }
} }
@@ -1759,11 +1772,9 @@ class BluetoothMeshService: NSObject {
// Send announce with our nickname immediately // Send announce with our nickname immediately
self.sendAnnouncementToPeer(senderID) self.sendAnnouncementToPeer(senderID)
// Delay sending cached messages to ensure connection is fully established // Send cached messages immediately for faster sync
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in // Check if this peer has cached messages (especially for favorites)
// Check if this peer has cached messages (especially for favorites) self.sendCachedMessages(to: senderID)
self?.sendCachedMessages(to: senderID)
}
} }
} }
+141 -85
View File
@@ -12,6 +12,8 @@ import CryptoKit
struct RetryableMessage { struct RetryableMessage {
let id: String let id: String
let originalMessageID: String?
let originalTimestamp: Date?
let content: String let content: String
let mentions: [String]? let mentions: [String]?
let channel: String? let channel: String?
@@ -29,7 +31,7 @@ class MessageRetryService {
private var retryQueue: [RetryableMessage] = [] private var retryQueue: [RetryableMessage] = []
private var retryTimer: Timer? 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 private let maxQueueSize = 50
weak var meshService: BluetoothMeshService? weak var meshService: BluetoothMeshService?
@@ -55,15 +57,34 @@ class MessageRetryService {
isPrivate: Bool = false, isPrivate: Bool = false,
recipientPeerID: String? = nil, recipientPeerID: String? = nil,
recipientNickname: String? = nil, recipientNickname: String? = nil,
channelKey: Data? = nil channelKey: Data? = nil,
originalMessageID: String? = nil,
originalTimestamp: Date? = nil
) { ) {
// Don't queue empty or whitespace-only messages
guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return
}
// Don't queue if we're at capacity // Don't queue if we're at capacity
guard retryQueue.count < maxQueueSize else { guard retryQueue.count < maxQueueSize else {
return return
} }
// Check if this message is already in the queue
if let messageID = originalMessageID {
let alreadyQueued = retryQueue.contains { msg in
msg.originalMessageID == messageID
}
if alreadyQueued {
return // Don't add duplicate
}
}
let retryMessage = RetryableMessage( let retryMessage = RetryableMessage(
id: UUID().uuidString, id: UUID().uuidString,
originalMessageID: originalMessageID,
originalTimestamp: originalTimestamp,
content: content, content: content,
mentions: mentions, mentions: mentions,
channel: channel, channel: channel,
@@ -76,10 +97,17 @@ class MessageRetryService {
) )
retryQueue.append(retryMessage) retryQueue.append(retryMessage)
// Sort the queue by original timestamp to maintain message order
retryQueue.sort { (msg1, msg2) in
let time1 = msg1.originalTimestamp ?? Date.distantPast
let time2 = msg2.originalTimestamp ?? Date.distantPast
return time1 < time2
}
} }
private func processRetryQueue() { private func processRetryQueue() {
guard let meshService = meshService else { return } guard meshService != nil else { return }
let now = Date() let now = Date()
var messagesToRetry: [RetryableMessage] = [] var messagesToRetry: [RetryableMessage] = []
@@ -95,95 +123,123 @@ class MessageRetryService {
retryQueue = updatedQueue retryQueue = updatedQueue
for message in messagesToRetry { // Sort messages by original timestamp to maintain order
messagesToRetry.sort { (msg1, msg2) in
let time1 = msg1.originalTimestamp ?? Date.distantPast
let time2 = msg2.originalTimestamp ?? Date.distantPast
return time1 < time2
}
// Send messages with delay to maintain order
for (index, message) in messagesToRetry.enumerated() {
// Check if we should still retry // Check if we should still retry
if message.retryCount >= message.maxRetries { if message.retryCount >= message.maxRetries {
continue continue
} }
// Check connectivity before retrying // Add delay between messages to ensure proper ordering
let viewModel = meshService.delegate as? ChatViewModel let delay = Double(index) * 0.05 // 50ms between messages
let connectedPeers = viewModel?.connectedPeers ?? []
if message.isPrivate { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
// For private messages, check if recipient is connected guard let self = self,
if let recipientID = message.recipientPeerID, let meshService = self.meshService else { return }
connectedPeers.contains(recipientID) {
// Retry private message // Check connectivity before retrying
meshService.sendPrivateMessage( let viewModel = meshService.delegate as? ChatViewModel
message.content, let connectedPeers = viewModel?.connectedPeers ?? []
to: recipientID,
recipientNickname: message.recipientNickname ?? "unknown" if message.isPrivate {
) // For private messages, check if recipient is connected
if let recipientID = message.recipientPeerID,
connectedPeers.contains(recipientID) {
// Retry private message
meshService.sendPrivateMessage(
message.content,
to: recipientID,
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,
channel: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: message.recipientPeerID,
recipientNickname: message.recipientNickname,
channelKey: message.channelKey,
retryCount: message.retryCount + 1,
nextRetryTime: Date().addingTimeInterval(self.retryInterval * Double(message.retryCount + 2))
)
self.retryQueue.append(updatedMessage)
}
} else if let channel = message.channel, let channelKeyData = message.channelKey {
// For channel messages, check if we have peers in the channel
if !connectedPeers.isEmpty {
// Recreate SymmetricKey from data
let channelKey = SymmetricKey(data: channelKeyData)
meshService.sendEncryptedChannelMessage(
message.content,
mentions: message.mentions ?? [],
channel: channel,
channelKey: channelKey,
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,
channel: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: message.recipientPeerID,
recipientNickname: message.recipientNickname,
channelKey: message.channelKey,
retryCount: message.retryCount + 1,
nextRetryTime: Date().addingTimeInterval(self.retryInterval * Double(message.retryCount + 2))
)
self.retryQueue.append(updatedMessage)
}
} else { } else {
// Recipient not connected, keep in queue with updated retry time // Regular message
var updatedMessage = message if !connectedPeers.isEmpty {
updatedMessage = RetryableMessage( meshService.sendMessage(
id: message.id, message.content,
content: message.content, mentions: message.mentions ?? [],
mentions: message.mentions, channel: message.channel,
channel: message.channel, to: nil,
isPrivate: message.isPrivate, messageID: message.originalMessageID,
recipientPeerID: message.recipientPeerID, timestamp: message.originalTimestamp
recipientNickname: message.recipientNickname, )
channelKey: message.channelKey, } else {
retryCount: message.retryCount + 1, // No peers connected, keep in queue
nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2)) var updatedMessage = message
) updatedMessage = RetryableMessage(
retryQueue.append(updatedMessage) id: message.id,
} originalMessageID: message.originalMessageID,
} else if let channel = message.channel, let channelKeyData = message.channelKey { originalTimestamp: message.originalTimestamp,
// For channel messages, check if we have peers in the channel content: message.content,
if !connectedPeers.isEmpty { mentions: message.mentions,
// Recreate SymmetricKey from data channel: message.channel,
let channelKey = SymmetricKey(data: channelKeyData) isPrivate: message.isPrivate,
meshService.sendEncryptedChannelMessage( recipientPeerID: message.recipientPeerID,
message.content, recipientNickname: message.recipientNickname,
mentions: message.mentions ?? [], channelKey: message.channelKey,
channel: channel, retryCount: message.retryCount + 1,
channelKey: channelKey nextRetryTime: Date().addingTimeInterval(self.retryInterval * Double(message.retryCount + 2))
) )
} else { self.retryQueue.append(updatedMessage)
// No peers connected, keep in queue }
var updatedMessage = message
updatedMessage = RetryableMessage(
id: message.id,
content: message.content,
mentions: message.mentions,
channel: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: message.recipientPeerID,
recipientNickname: message.recipientNickname,
channelKey: message.channelKey,
retryCount: message.retryCount + 1,
nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2))
)
retryQueue.append(updatedMessage)
}
} else {
// Regular message
if !connectedPeers.isEmpty {
meshService.sendMessage(
message.content,
mentions: message.mentions ?? [],
channel: message.channel
)
} else {
// No peers connected, keep in queue
var updatedMessage = message
updatedMessage = RetryableMessage(
id: message.id,
content: message.content,
mentions: message.mentions,
channel: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: message.recipientPeerID,
recipientNickname: message.recipientNickname,
channelKey: message.channelKey,
retryCount: message.retryCount + 1,
nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2))
)
retryQueue.append(updatedMessage)
} }
} }
} }
+78 -9
View File
@@ -2750,8 +2750,42 @@ extension ChatViewModel: BitchatDelegate {
if channelMessages[channel] == nil { if channelMessages[channel] == nil {
channelMessages[channel] = [] channelMessages[channel] = []
} }
channelMessages[channel]?.append(finalMessage)
channelMessages[channel]?.sort { $0.timestamp < $1.timestamp } // Check if this is our own message being echoed back
if finalMessage.sender != nickname && finalMessage.sender != "system" {
// Skip empty or whitespace-only messages
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
channelMessages[channel]?.append(finalMessage)
channelMessages[channel]?.sort { $0.timestamp < $1.timestamp }
}
} else if finalMessage.sender != "system" {
// Our own message - check if we already have it (by ID and content)
let messageExists = channelMessages[channel]?.contains { existingMsg in
// Check by ID first
if existingMsg.id == finalMessage.id {
return true
}
// Check by content and sender with time window (within 1 second)
if existingMsg.content == finalMessage.content &&
existingMsg.sender == finalMessage.sender {
let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(finalMessage.timestamp))
return timeDiff < 1.0
}
return false
} ?? false
if !messageExists {
// This is a message we sent from another device or it's missing locally
// Skip empty or whitespace-only messages
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
channelMessages[channel]?.append(finalMessage)
channelMessages[channel]?.sort { $0.timestamp < $1.timestamp }
}
}
} else {
// System message - always add
channelMessages[channel]?.append(finalMessage)
channelMessages[channel]?.sort { $0.timestamp < $1.timestamp }
}
// Save message if channel has retention enabled // Save message if channel has retention enabled
if retentionEnabledChannels.contains(channel) { if retentionEnabledChannels.contains(channel) {
@@ -2767,8 +2801,8 @@ extension ChatViewModel: BitchatDelegate {
} else { } else {
} }
// Update unread count if not currently viewing this channel // Update unread count if not currently viewing this channel and it's not our own message
if currentChannel != channel { if currentChannel != channel && finalMessage.sender != nickname {
unreadChannelMessages[channel] = (unreadChannelMessages[channel] ?? 0) + 1 unreadChannelMessages[channel] = (unreadChannelMessages[channel] ?? 0) + 1
} }
} else { } else {
@@ -2782,9 +2816,10 @@ extension ChatViewModel: BitchatDelegate {
(message.content.contains("🫂") || message.content.contains("🐟") || (message.content.contains("🫂") || message.content.contains("🐟") ||
message.content.contains("took a screenshot")) message.content.contains("took a screenshot"))
let finalMessage: BitchatMessage
if isActionMessage { if isActionMessage {
// Convert to system message // Convert to system message
let systemMessage = BitchatMessage( finalMessage = BitchatMessage(
sender: "system", sender: "system",
content: String(message.content.dropFirst(2).dropLast(2)), // Remove * * wrapper content: String(message.content.dropFirst(2).dropLast(2)), // Remove * * wrapper
timestamp: message.timestamp, timestamp: message.timestamp,
@@ -2796,12 +2831,46 @@ extension ChatViewModel: BitchatDelegate {
mentions: message.mentions, mentions: message.mentions,
channel: message.channel channel: message.channel
) )
messages.append(systemMessage)
} else { } else {
messages.append(message) finalMessage = message
}
// Check if this is our own message being echoed back
if finalMessage.sender != nickname && finalMessage.sender != "system" {
// Skip empty or whitespace-only messages
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
messages.append(finalMessage)
// Sort messages by timestamp to ensure proper ordering
messages.sort { $0.timestamp < $1.timestamp }
}
} else if finalMessage.sender != "system" {
// 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 == finalMessage.id {
return true
}
// Check by content and sender with time window (within 1 second)
if existingMsg.content == finalMessage.content &&
existingMsg.sender == finalMessage.sender {
let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(finalMessage.timestamp))
return timeDiff < 1.0
}
return false
}
if !messageExists {
// This is a message we sent from another device or it's missing locally
// Skip empty or whitespace-only messages
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
messages.append(finalMessage)
messages.sort { $0.timestamp < $1.timestamp }
}
}
} else {
// System message - always add
messages.append(finalMessage)
messages.sort { $0.timestamp < $1.timestamp }
} }
// Sort messages by timestamp to ensure proper ordering
messages.sort { $0.timestamp < $1.timestamp }
} }
// Check if we're mentioned // Check if we're mentioned