diff --git a/bitchat/Services/Courier/MessageOutboxStore.swift b/bitchat/Services/Courier/MessageOutboxStore.swift index 5543a8d0..9bb07be9 100644 --- a/bitchat/Services/Courier/MessageOutboxStore.swift +++ b/bitchat/Services/Courier/MessageOutboxStore.swift @@ -110,6 +110,10 @@ final class MessageOutboxStore { /// Delivery/read acknowledgments received before a deferred cold-load /// reveals the durable queue. Applied to every merge before persistence. private var pendingRemovalMessageIDs = Set() + /// Peer-scoped acknowledgments received before a deferred cold-load + /// reveals the durable queue. Unlike the legacy global tombstones above, + /// these must not remove a colliding message ID queued for another peer. + private var pendingScopedRemovalMessageIDs: [PeerID: Set] = [:] private var recoveryHandler: (@MainActor (Snapshot) -> Void)? /// Recovery loaded durable state that MessageRouter has not merged yet. /// While true, router saves must union with `cachedSnapshot` instead of @@ -195,7 +199,7 @@ final class MessageOutboxStore { ? (pendingSnapshot ?? [:]) : Self.merge(durable, pendingSnapshot ?? [:])) diskState = .loaded - if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty { + if pendingSnapshot != nil || hasPendingRemovalsLocked { if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) { pendingSnapshot = nil pendingSnapshotIsAuthoritative = false @@ -212,7 +216,7 @@ final class MessageOutboxStore { case .missing: cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:]) diskState = .loaded - if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty { + if pendingSnapshot != nil || hasPendingRemovalsLocked { if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) { pendingSnapshot = nil pendingSnapshotIsAuthoritative = false @@ -235,7 +239,7 @@ final class MessageOutboxStore { diskState = .loaded cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:]) SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session) - if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty { + if pendingSnapshot != nil || hasPendingRemovalsLocked { if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) { pendingSnapshot = nil pendingSnapshotIsAuthoritative = false @@ -442,6 +446,25 @@ final class MessageOutboxStore { lock.unlock() } + /// Records an ack for only the supplied peer aliases. This preserves + /// another recipient's queued entry when message IDs happen to collide, + /// including while the durable snapshot is hidden by protected data. + func recordRemoval(messageID: String, for peerIDs: Set) { + guard !peerIDs.isEmpty else { return } + + lock.lock() + for peerID in peerIDs { + pendingScopedRemovalMessageIDs[peerID, default: []].insert(messageID) + } + cachedSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: cachedSnapshot) + unseenRecoveredSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: unseenRecoveredSnapshot) + recoveryRouterSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: recoveryRouterSnapshot) + if let pendingSnapshot { + self.pendingSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: pendingSnapshot) + } + lock.unlock() + } + /// Retries a deferred protected-data load. The returned snapshot includes /// both durable messages and any messages queued during the locked wake. @discardableResult @@ -478,7 +501,7 @@ final class MessageOutboxStore { : (pendingSnapshotIsAuthoritative ? known : Self.merge(durable, known))) cachedSnapshot = merged diskState = .loaded - if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) || + if (pendingSnapshot == nil && !hasPendingRemovalsLocked) || persistSnapshotAndClearRemovalsLocked(merged) { pendingSnapshot = nil pendingSnapshotIsAuthoritative = false @@ -507,7 +530,7 @@ final class MessageOutboxStore { : known) cachedSnapshot = merged diskState = .loaded - if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) || + if (pendingSnapshot == nil && !hasPendingRemovalsLocked) || persistSnapshotAndClearRemovalsLocked(merged) { pendingSnapshot = nil pendingSnapshotIsAuthoritative = false @@ -539,7 +562,7 @@ final class MessageOutboxStore { : known) cachedSnapshot = merged diskState = .loaded - if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) || + if (pendingSnapshot == nil && !hasPendingRemovalsLocked) || persistSnapshotAndClearRemovalsLocked(merged) { pendingSnapshot = nil pendingSnapshotIsAuthoritative = false @@ -578,6 +601,7 @@ final class MessageOutboxStore { pendingSnapshot = nil pendingSnapshotIsAuthoritative = false pendingRemovalMessageIDs.removeAll() + pendingScopedRemovalMessageIDs.removeAll() recoveryDeliveryPending = false unseenRecoveryPendingPersistence = false unseenRecoveredSnapshot = [:] @@ -742,12 +766,21 @@ final class MessageOutboxStore { private func persistSnapshotAndClearRemovalsLocked(_ snapshot: Snapshot) -> Bool { guard persistSnapshotLocked(snapshot) else { return false } pendingRemovalMessageIDs.removeAll() + pendingScopedRemovalMessageIDs.removeAll() return true } /// Must be called with `lock` held. private func applyingPendingRemovalsLocked(_ snapshot: Snapshot) -> Snapshot { - Self.removing(pendingRemovalMessageIDs, from: snapshot) + Self.removing( + pendingScopedRemovalMessageIDs, + from: Self.removing(pendingRemovalMessageIDs, from: snapshot) + ) + } + + /// Must be read with `lock` held. + private var hasPendingRemovalsLocked: Bool { + !pendingRemovalMessageIDs.isEmpty || !pendingScopedRemovalMessageIDs.isEmpty } private static func removing(_ messageIDs: Set, from snapshot: Snapshot) -> Snapshot { @@ -760,9 +793,28 @@ final class MessageOutboxStore { return filtered } + private static func removing( + _ messageIDsByPeer: [PeerID: Set], + from snapshot: Snapshot + ) -> Snapshot { + guard !messageIDsByPeer.isEmpty else { return snapshot } + var filtered = snapshot + for (peerID, messageIDs) in messageIDsByPeer { + guard !messageIDs.isEmpty, let queue = filtered[peerID] else { continue } + let remaining = queue.filter { !messageIDs.contains($0.messageID) } + filtered[peerID] = remaining.isEmpty ? nil : remaining + } + return filtered + } + private static func excludingKnownMessages(from durable: Snapshot, known: Snapshot) -> Snapshot { - let knownIDs = Set(known.values.flatMap { $0.map(\.messageID) }) - return removing(knownIDs, from: durable) + var unseen: Snapshot = [:] + for (peerID, durableQueue) in durable { + let knownIDs = Set(known[peerID]?.map(\.messageID) ?? []) + let remaining = durableQueue.filter { !knownIDs.contains($0.messageID) } + if !remaining.isEmpty { unseen[peerID] = remaining } + } + return unseen } private static func merge(_ durable: Snapshot, _ pending: Snapshot) -> Snapshot { diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index fbc6d694..0f4b5499 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -375,6 +375,32 @@ final class MessageRouter { persistOutbox() } + /// A delivery or read ack authenticated to one account confirms receipt + /// only for that account's transport aliases. A colliding message ID + /// queued for another peer must remain retained. + @discardableResult + func markDelivered(_ messageID: String, for peerAliases: [PeerID]) -> Bool { + let peerIDs = Set(peerAliases) + guard !peerIDs.isEmpty else { return false } + + var cleared = false + for peerID in peerIDs { + guard let queue = outbox[peerID] else { continue } + let filtered = queue.filter { $0.messageID != messageID } + guard filtered.count != queue.count else { continue } + outbox[peerID] = filtered.isEmpty ? nil : filtered + cleared = true + } + // Preserve the scoped ack even when protected data hides the durable + // queue during a cold launch. + outboxStore?.recordRemoval(messageID: messageID, for: peerIDs) + if cleared { + metrics?.record(.outboxDelivered) + } + persistOutbox() + return cleared + } + private func enqueue(_ message: QueuedMessage, for peerID: PeerID) { var message = message var queue = outbox[peerID] ?? [] diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index 0bfca931..e011c763 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -85,6 +85,11 @@ protocol ChatPrivateConversationContext: AnyObject { func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) @discardableResult func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool + /// Confirms an authenticated delivery/read acknowledgement so the router + /// stops retaining the original private message for resend. The peer + /// aliases scope the removal to the authenticated sender. + @discardableResult + func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) @@ -166,6 +171,11 @@ extension ChatViewModel: ChatPrivateConversationContext { messageRouter.sendReadReceipt(receipt, to: peerID) } + @discardableResult + func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool { + messageRouter.markDelivered(messageID, for: peerIDs) + } + func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) } @@ -250,6 +260,60 @@ final class ChatPrivateConversationCoordinator { return true } + /// Account DMs can arrive under the authenticated peer's full Noise key + /// while an existing mesh conversation is keyed by its derived short ID. + /// These are the only aliases we may safely join: the short ID is derived + /// directly from the authenticated key, rather than guessed from a + /// nickname or found by scanning unrelated chats. + private func accountConversationAliases(for peerID: PeerID) -> [PeerID] { + guard peerID.noiseKey != nil else { return [peerID] } + let shortPeerID = peerID.toShort() + return shortPeerID == peerID ? [peerID] : [peerID, shortPeerID] + } + + /// Keeps a connected account DM on its short routing ID and an offline DM + /// on its stable Noise-key ID, folding the other authenticated alias into + /// it and handing an open sheet across without closing it. + private func consolidateAccountConversationAliases(for peerID: PeerID) -> PeerID { + let aliases = accountConversationAliases(for: peerID) + guard aliases.count > 1 else { return peerID } + + let shortPeerID = peerID.toShort() + let targetPeerID = context.isPeerConnected(shortPeerID) ? shortPeerID : peerID + let sourcePeerIDs = aliases.filter { $0 != targetPeerID } + + for sourcePeerID in sourcePeerIDs where !context.privateMessages(for: sourcePeerID).isEmpty { + context.migratePrivateChat(from: sourcePeerID, to: targetPeerID) + // ConversationStore deliberately preserves message values during a + // generic migration, including its destination-wins rule for + // duplicate IDs. Rewrite the resulting canonical copies so later + // read-receipt scans compare against the new routing key without + // replacing a newer destination value with the source snapshot. + let canonicalMessages = context.privateMessages(for: targetPeerID) + for message in canonicalMessages where message.senderPeerID == sourcePeerID { + context.upsertPrivateMessage( + BitchatMessage( + id: message.id, + sender: message.sender, + content: message.content, + timestamp: message.timestamp, + isRelay: message.isRelay, + originalSender: message.originalSender, + isPrivate: message.isPrivate, + recipientNickname: message.recipientNickname, + senderPeerID: targetPeerID, + mentions: message.mentions, + deliveryStatus: message.deliveryStatus, + isBridged: message.isBridged + ), + in: targetPeerID + ) + } + } + context.handOffSelectedPrivateChat(from: sourcePeerIDs, to: targetPeerID) + return targetPeerID + } + func sendPrivateMessage(_ content: String, to peerID: PeerID) { guard !content.isEmpty else { return } @@ -464,6 +528,8 @@ final class ChatPrivateConversationCoordinator { return } + let conversationPeerID = consolidateAccountConversationAliases(for: convKey) + if context.privateChatsContainMessage(withID: messageId) { return } let message = BitchatMessage( @@ -474,18 +540,18 @@ final class ChatPrivateConversationCoordinator { isRelay: false, isPrivate: true, recipientNickname: context.nickname, - senderPeerID: convKey, + senderPeerID: conversationPeerID, deliveryStatus: .delivered(to: context.nickname, at: Date()) ) - context.appendPrivateMessage(message, to: convKey) + context.appendPrivateMessage(message, to: conversationPeerID) - let isViewing = context.selectedPrivateChatPeer == convKey + let isViewing = context.selectedPrivateChatPeer == conversationPeerID let wasReadBefore = context.sentReadReceipts.contains(messageId) let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage if shouldMarkUnread { - context.markPrivateChatUnread(convKey) + context.markPrivateChatUnread(conversationPeerID) } if isViewing { @@ -493,7 +559,7 @@ final class ChatPrivateConversationCoordinator { } if !isViewing && shouldMarkUnread { - context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: convKey) + context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: conversationPeerID) } context.notifyUIChanged() @@ -502,17 +568,32 @@ final class ChatPrivateConversationCoordinator { func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { guard let messageID = String(data: payload.data, encoding: .utf8) else { return } - if context.privateChat(convKey, containsMessageWithID: messageID) { - context.setPrivateDeliveryStatus( + let aliases = accountConversationAliases(for: convKey) + let clearedRetainedMessage = convKey.noiseKey != nil + ? context.markMessageDelivered(messageID, for: aliases) + : false + let hasConversationMessage = aliases.contains { + context.privateChat($0, containsMessageWithID: messageID) + } + if hasConversationMessage { + let conversationPeerID = consolidateAccountConversationAliases(for: convKey) + let didChange = context.setPrivateDeliveryStatus( .delivered(to: context.displayNameForNostrPubkey(senderPubkey), at: Date()), forMessageID: messageID, - peerID: convKey + peerID: conversationPeerID ) - context.notifyUIChanged() + if didChange { + context.notifyUIChanged() + } SecureLogger.info( "GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session ) + } else if clearedRetainedMessage { + SecureLogger.debug( + "GeoDM: recv DELIVERED for cleared mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", + category: .session + ) } else { // A stale ack for a message this device no longer tracks (dropped // outbox entry, cleared chat, or a peer re-acking after losing our @@ -524,14 +605,29 @@ final class ChatPrivateConversationCoordinator { func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { guard let messageID = String(data: payload.data, encoding: .utf8) else { return } - if context.privateChat(convKey, containsMessageWithID: messageID) { - context.setPrivateDeliveryStatus( + let aliases = accountConversationAliases(for: convKey) + let clearedRetainedMessage = convKey.noiseKey != nil + ? context.markMessageDelivered(messageID, for: aliases) + : false + let hasConversationMessage = aliases.contains { + context.privateChat($0, containsMessageWithID: messageID) + } + if hasConversationMessage { + let conversationPeerID = consolidateAccountConversationAliases(for: convKey) + let didChange = context.setPrivateDeliveryStatus( .read(by: context.displayNameForNostrPubkey(senderPubkey), at: Date()), forMessageID: messageID, - peerID: convKey + peerID: conversationPeerID ) - context.notifyUIChanged() + if didChange { + context.notifyUIChanged() + } SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session) + } else if clearedRetainedMessage { + SecureLogger.debug( + "GeoDM: recv READ for cleared mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", + category: .session + ) } else { SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) } diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index 5fad9fd0..54cb341e 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -176,6 +176,9 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = [] private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = [] private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] + var queuedMessageIDsByPeerID: [PeerID: Set] = [:] + private(set) var deliveryAckAttempts: [(messageID: String, peerIDs: [PeerID])] = [] + private(set) var deliveredMessageIDs: [String] = [] func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { routedPrivateMessages.append((content, peerID, messageID)) @@ -187,6 +190,22 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC return routeReadReceiptResult } + @discardableResult + func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool { + deliveryAckAttempts.append((messageID, peerIDs)) + var cleared = false + for peerID in Set(peerIDs) { + guard var queued = queuedMessageIDsByPeerID[peerID], + queued.remove(messageID) != nil else { continue } + queuedMessageIDsByPeerID[peerID] = queued.isEmpty ? nil : queued + cleared = true + } + if cleared { + deliveredMessageIDs.append(messageID) + } + return cleared + } + func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { meshReadReceipts.append((receipt.originalMessageID, peerID)) } @@ -370,6 +389,66 @@ struct ChatPrivateConversationCoordinatorContextTests { convKey: convKey ) #expect(context.notifyUIChangedCount == 2) + #expect(context.deliveryAckAttempts.isEmpty) + #expect(context.deliveredMessageIDs.isEmpty) + } + + @Test @MainActor + func accountDMAcks_findShortIDMessageAndHandConversationToStable() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xD5, count: 32) + let stablePeerID = PeerID(hexData: noiseKey) + let shortPeerID = stablePeerID.toShort() + let senderPubkey = "feedface00112233" + context.displayNamesByPubkey[senderPubkey] = "alice" + context.selectedPrivateChatPeer = shortPeerID + context.privateChats[shortPeerID] = [ + makeIncomingMessage(id: "mine-short-1", sender: "me"), + makeIncomingMessage(id: "mine-short-2", sender: "me") + ] + context.queuedMessageIDsByPeerID[shortPeerID] = ["mine-short-1", "mine-short-2"] + + coordinator.handleDelivered( + NoisePayload(type: .delivered, data: Data("mine-short-1".utf8)), + senderPubkey: senderPubkey, + convKey: stablePeerID + ) + coordinator.handleReadReceipt( + NoisePayload(type: .readReceipt, data: Data("mine-short-2".utf8)), + senderPubkey: senderPubkey, + convKey: stablePeerID + ) + + #expect(context.privateChats[shortPeerID] == nil) + #expect(isDelivered(context.privateChats[stablePeerID]?.first?.deliveryStatus, to: "alice")) + #expect(isRead(context.privateChats[stablePeerID]?.last?.deliveryStatus, by: "alice")) + #expect(context.selectedPrivateChatPeer == stablePeerID) + #expect(context.deliveredMessageIDs == ["mine-short-1", "mine-short-2"]) + #expect(context.notifyUIChangedCount == 2) + } + + @Test @MainActor + func accountDMAck_doesNotTouchAnUnrelatedConversationWithTheSameMessageID() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let stablePeerID = PeerID(hexData: Data(repeating: 0xC1, count: 32)) + let unrelatedPeerID = PeerID(str: "1111222233334444") + context.privateChats[unrelatedPeerID] = [ + makeIncomingMessage(id: "collision", sender: "me") + ] + context.queuedMessageIDsByPeerID[unrelatedPeerID] = ["collision"] + + coordinator.handleDelivered( + NoisePayload(type: .delivered, data: Data("collision".utf8)), + senderPubkey: "feedface00112233", + convKey: stablePeerID + ) + + #expect(isDelivered(context.privateChats[unrelatedPeerID]?.first?.deliveryStatus, to: "me")) + #expect(context.deliveredMessageIDs.isEmpty) + #expect(context.queuedMessageIDsByPeerID[unrelatedPeerID] == ["collision"]) + #expect(context.notifyUIChangedCount == 0) } @Test @MainActor @@ -412,6 +491,180 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.privateChats[convKey]?.count == 1) } + @Test @MainActor + func accountDM_handsOpenShortIDConversationToStableWhenOffline() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xA7, count: 32) + let stablePeerID = PeerID(hexData: noiseKey) + let shortPeerID = stablePeerID.toShort() + let senderPubkey = "feedface00112233" + let now = Date() + context.displayNamesByPubkey[senderPubkey] = "bob" + context.selectedPrivateChatPeer = shortPeerID + context.privateChats[shortPeerID] = [ + makeIncomingMessage( + id: "short-history", + sender: "me", + timestamp: now.addingTimeInterval(-30), + senderPeerID: context.myPeerID + ), + makeIncomingMessage( + id: "short-inbound", + sender: "bob", + timestamp: now.addingTimeInterval(-25), + senderPeerID: shortPeerID + ) + ] + context.privateChats[stablePeerID] = [ + makeIncomingMessage( + id: "stable-history", + sender: "bob", + timestamp: now.addingTimeInterval(-20), + senderPeerID: stablePeerID + ) + ] + let payloadData = PrivateMessagePacket(messageID: "account-live-1", content: "live reply").encode()! + + coordinator.handlePrivateMessage( + NoisePayload(type: .privateMessage, data: payloadData), + senderPubkey: senderPubkey, + convKey: stablePeerID, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: now + ) + + #expect(context.privateChats[shortPeerID] == nil) + #expect(context.privateChats[stablePeerID]?.map(\.id) == [ + "short-history", + "short-inbound", + "stable-history", + "account-live-1" + ]) + #expect(context.privateChats[stablePeerID]?[1].senderPeerID == stablePeerID) + #expect(context.privateChats[stablePeerID]?.last?.senderPeerID == stablePeerID) + #expect(context.migratedChats.contains(where: { $0.from == shortPeerID && $0.to == stablePeerID })) + #expect(context.selectedPrivateChatPeer == stablePeerID) + #expect(context.geoReadReceipts.map(\.messageID) == ["account-live-1"]) + #expect(context.unreadPrivateMessages.isEmpty) + #expect(context.notifyUIChangedCount == 1) + } + + @Test @MainActor + func accountDM_aliasMergePreservesTheCanonicalDestinationCopy() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xA8, count: 32) + let stablePeerID = PeerID(hexData: noiseKey) + let shortPeerID = stablePeerID.toShort() + let senderPubkey = "feedface00112233" + let olderSource = makeIncomingMessage( + id: "duplicate-history", + sender: "bob", + content: "older source copy", + senderPeerID: shortPeerID + ) + olderSource.deliveryStatus = .delivered(to: "me", at: Date(timeIntervalSince1970: 10)) + let newerDestination = makeIncomingMessage( + id: "duplicate-history", + sender: "bob", + content: "newer destination copy", + senderPeerID: shortPeerID + ) + newerDestination.deliveryStatus = .read(by: "me", at: Date(timeIntervalSince1970: 20)) + context.privateChats[shortPeerID] = [olderSource] + context.privateChats[stablePeerID] = [newerDestination] + context.displayNamesByPubkey[senderPubkey] = "bob" + let payloadData = PrivateMessagePacket(messageID: "after-merge", content: "new reply").encode()! + + coordinator.handlePrivateMessage( + NoisePayload(type: .privateMessage, data: payloadData), + senderPubkey: senderPubkey, + convKey: stablePeerID, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: Date() + ) + + let merged = context.privateChats[stablePeerID]?.first + #expect(context.privateChats[shortPeerID] == nil) + #expect(merged?.content == "newer destination copy") + #expect(isRead(merged?.deliveryStatus, by: "me")) + #expect(merged?.senderPeerID == stablePeerID) + } + + @Test @MainActor + func accountDM_keepsTheConnectedShortIDConversationCanonical() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xB8, count: 32) + let stablePeerID = PeerID(hexData: noiseKey) + let shortPeerID = stablePeerID.toShort() + let senderPubkey = "feedface00112233" + context.connectedPeers = [shortPeerID] + context.selectedPrivateChatPeer = stablePeerID + context.displayNamesByPubkey[senderPubkey] = "bob" + context.privateChats[stablePeerID] = [ + makeIncomingMessage(id: "stable-history", senderPeerID: stablePeerID) + ] + let payloadData = PrivateMessagePacket(messageID: "connected-live-1", content: "still nearby").encode()! + + coordinator.handlePrivateMessage( + NoisePayload(type: .privateMessage, data: payloadData), + senderPubkey: senderPubkey, + convKey: stablePeerID, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: Date() + ) + + #expect(context.privateChats[stablePeerID] == nil) + #expect(context.privateChats[shortPeerID]?.map(\.id) == ["stable-history", "connected-live-1"]) + #expect(context.privateChats[shortPeerID]?.first?.senderPeerID == shortPeerID) + #expect(context.privateChats[shortPeerID]?.last?.senderPeerID == shortPeerID) + #expect(context.selectedPrivateChatPeer == shortPeerID) + #expect(context.geoReadReceipts.map(\.messageID) == ["connected-live-1"]) + } + + @Test @MainActor + func accountDM_duplicateDowngradeAckStillClearsTheRetainedOutbox() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let stablePeerID = PeerID(hexData: Data(repeating: 0xE2, count: 32)) + let message = makeIncomingMessage(id: "already-read", sender: "me") + message.deliveryStatus = .read(by: "bob", at: Date()) + context.privateChats[stablePeerID] = [message] + context.queuedMessageIDsByPeerID[stablePeerID] = ["already-read"] + + coordinator.handleDelivered( + NoisePayload(type: .delivered, data: Data("already-read".utf8)), + senderPubkey: "feedface00112233", + convKey: stablePeerID + ) + + #expect(isRead(context.privateChats[stablePeerID]?.first?.deliveryStatus, by: "bob")) + #expect(context.deliveredMessageIDs == ["already-read"]) + #expect(context.notifyUIChangedCount == 0) + } + + @Test @MainActor + func accountDMAck_clearsRetainedMessageAfterConversationWasRemoved() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let stablePeerID = PeerID(hexData: Data(repeating: 0xE3, count: 32)) + let shortPeerID = stablePeerID.toShort() + context.queuedMessageIDsByPeerID[shortPeerID] = ["cleared-bubble"] + + coordinator.handleDelivered( + NoisePayload(type: .delivered, data: Data("cleared-bubble".utf8)), + senderPubkey: "feedface00112233", + convKey: stablePeerID + ) + + #expect(context.privateChats.isEmpty) + #expect(context.queuedMessageIDsByPeerID[shortPeerID] == nil) + #expect(context.deliveredMessageIDs == ["cleared-bubble"]) + #expect(context.notifyUIChangedCount == 0) + } + @Test @MainActor func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async { let context = MockChatPrivateConversationContext() diff --git a/bitchatTests/Services/MessageOutboxStoreTests.swift b/bitchatTests/Services/MessageOutboxStoreTests.swift index c54b9f99..c8059827 100644 --- a/bitchatTests/Services/MessageOutboxStoreTests.swift +++ b/bitchatTests/Services/MessageOutboxStoreTests.swift @@ -207,6 +207,42 @@ struct MessageOutboxStoreTests { #expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty) } + @Test func deferredScopedRemovalTombstoneFiltersOnlySelectedPeer() { + let fileURL = makeTempURL() + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let acknowledgedPeer = PeerID(str: "0000000000000001") + let otherPeer = PeerID(str: "0000000000000002") + MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([ + acknowledgedPeer: [makeMessage("shared-id", content: "for acknowledged peer")], + otherPeer: [makeMessage("shared-id", content: "for other peer")] + ]) + + var protectedDataUnavailable = true + let restored = MessageOutboxStore( + keychain: keychain, + fileURL: fileURL, + readData: { url in + if protectedDataUnavailable { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } + return try Data(contentsOf: url) + } + ) + #expect(restored.load().isEmpty) + restored.recordRemoval(messageID: "shared-id", for: [acknowledgedPeer]) + restored.save([:]) + + protectedDataUnavailable = false + let recovered = restored.retryDeferredLoad() + #expect(recovered?[acknowledgedPeer] == nil) + #expect(recovered?[otherPeer]?.map(\.messageID) == ["shared-id"]) + + let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() + #expect(relaunched[acknowledgedPeer] == nil) + #expect(relaunched[otherPeer]?.map(\.messageID) == ["shared-id"]) + } + @Test func wipeRemovesFileAndKey() { let fileURL = makeTempURL() let keychain = MockKeychain() diff --git a/bitchatTests/Services/MessageRouterTests.swift b/bitchatTests/Services/MessageRouterTests.swift index 6c9d4ea1..a45b0bcd 100644 --- a/bitchatTests/Services/MessageRouterTests.swift +++ b/bitchatTests/Services/MessageRouterTests.swift @@ -582,6 +582,79 @@ struct MessageRouterTests { #expect(transport2.sentPrivateMessages.isEmpty) } + @Test @MainActor + func scopedDeliveryAckClearsOnlySelectedPeerWhenMessageIDsCollide() async { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("router-outbox-scoped-ack-\(UUID().uuidString).sealed") + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let acknowledgedPeer = PeerID(str: "00000000000000d1") + let otherPeer = PeerID(str: "00000000000000d2") + let transport = MockTransport() + let router = MessageRouter( + transports: [transport], + outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) + ) + router.sendPrivate("For acknowledged peer", to: acknowledgedPeer, recipientNickname: "One", messageID: "shared-id") + router.sendPrivate("For other peer", to: otherPeer, recipientNickname: "Two", messageID: "shared-id") + + #expect(router.markDelivered("shared-id", for: [acknowledgedPeer])) + transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer]) + router.flushOutbox(for: acknowledgedPeer) + router.flushOutbox(for: otherPeer) + + #expect(transport.sentPrivateMessages.map(\.peerID) == [otherPeer]) + let persisted = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() + #expect(persisted[acknowledgedPeer] == nil) + #expect(persisted[otherPeer]?.map(\.messageID) == ["shared-id"]) + } + + @Test @MainActor + func scopedAckWhileColdLoadIsLockedPreventsOnlyTargetPeerResurrection() async { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("router-locked-scoped-ack-\(UUID().uuidString).sealed") + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let acknowledgedPeer = PeerID(str: "00000000000000d3") + let otherPeer = PeerID(str: "00000000000000d4") + let durable = MessageOutboxStore.QueuedMessage( + content: "Queued before reboot", + nickname: "Peer", + messageID: "shared-locked-id", + timestamp: Date() + ) + MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([ + acknowledgedPeer: [durable], + otherPeer: [durable] + ]) + + var protectedDataUnavailable = true + let restoredStore = MessageOutboxStore( + keychain: keychain, + fileURL: fileURL, + readData: { url in + if protectedDataUnavailable { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } + return try Data(contentsOf: url) + } + ) + let transport = MockTransport() + transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer]) + let router = MessageRouter(transports: [transport], outboxStore: restoredStore) + + #expect(!router.markDelivered("shared-locked-id", for: [acknowledgedPeer])) + protectedDataUnavailable = false + restoredStore.retryDeferredLoad() + await Task.yield() + await Task.yield() + + #expect(transport.sentPrivateMessages.map(\.peerID) == [otherPeer]) + let persisted = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() + #expect(persisted[acknowledgedPeer] == nil) + #expect(persisted[otherPeer]?.map(\.messageID) == ["shared-locked-id"]) + } + @Test @MainActor func protectedDataRecoveryMergesDurableAndLockedWakeMessagesIntoRouter() async { let fileURL = FileManager.default.temporaryDirectory