mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:25:20 +00:00
Fix live DM handoff across transport aliases
This commit is contained in:
@@ -110,6 +110,10 @@ final class MessageOutboxStore {
|
|||||||
/// Delivery/read acknowledgments received before a deferred cold-load
|
/// Delivery/read acknowledgments received before a deferred cold-load
|
||||||
/// reveals the durable queue. Applied to every merge before persistence.
|
/// reveals the durable queue. Applied to every merge before persistence.
|
||||||
private var pendingRemovalMessageIDs = Set<String>()
|
private var pendingRemovalMessageIDs = Set<String>()
|
||||||
|
/// 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<String>] = [:]
|
||||||
private var recoveryHandler: (@MainActor (Snapshot) -> Void)?
|
private var recoveryHandler: (@MainActor (Snapshot) -> Void)?
|
||||||
/// Recovery loaded durable state that MessageRouter has not merged yet.
|
/// Recovery loaded durable state that MessageRouter has not merged yet.
|
||||||
/// While true, router saves must union with `cachedSnapshot` instead of
|
/// While true, router saves must union with `cachedSnapshot` instead of
|
||||||
@@ -195,7 +199,7 @@ final class MessageOutboxStore {
|
|||||||
? (pendingSnapshot ?? [:])
|
? (pendingSnapshot ?? [:])
|
||||||
: Self.merge(durable, pendingSnapshot ?? [:]))
|
: Self.merge(durable, pendingSnapshot ?? [:]))
|
||||||
diskState = .loaded
|
diskState = .loaded
|
||||||
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
|
if pendingSnapshot != nil || hasPendingRemovalsLocked {
|
||||||
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
||||||
pendingSnapshot = nil
|
pendingSnapshot = nil
|
||||||
pendingSnapshotIsAuthoritative = false
|
pendingSnapshotIsAuthoritative = false
|
||||||
@@ -212,7 +216,7 @@ final class MessageOutboxStore {
|
|||||||
case .missing:
|
case .missing:
|
||||||
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
|
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
|
||||||
diskState = .loaded
|
diskState = .loaded
|
||||||
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
|
if pendingSnapshot != nil || hasPendingRemovalsLocked {
|
||||||
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
||||||
pendingSnapshot = nil
|
pendingSnapshot = nil
|
||||||
pendingSnapshotIsAuthoritative = false
|
pendingSnapshotIsAuthoritative = false
|
||||||
@@ -235,7 +239,7 @@ final class MessageOutboxStore {
|
|||||||
diskState = .loaded
|
diskState = .loaded
|
||||||
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
|
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
|
||||||
SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session)
|
SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session)
|
||||||
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
|
if pendingSnapshot != nil || hasPendingRemovalsLocked {
|
||||||
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
||||||
pendingSnapshot = nil
|
pendingSnapshot = nil
|
||||||
pendingSnapshotIsAuthoritative = false
|
pendingSnapshotIsAuthoritative = false
|
||||||
@@ -442,6 +446,25 @@ final class MessageOutboxStore {
|
|||||||
lock.unlock()
|
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<PeerID>) {
|
||||||
|
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
|
/// Retries a deferred protected-data load. The returned snapshot includes
|
||||||
/// both durable messages and any messages queued during the locked wake.
|
/// both durable messages and any messages queued during the locked wake.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -478,7 +501,7 @@ final class MessageOutboxStore {
|
|||||||
: (pendingSnapshotIsAuthoritative ? known : Self.merge(durable, known)))
|
: (pendingSnapshotIsAuthoritative ? known : Self.merge(durable, known)))
|
||||||
cachedSnapshot = merged
|
cachedSnapshot = merged
|
||||||
diskState = .loaded
|
diskState = .loaded
|
||||||
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
|
if (pendingSnapshot == nil && !hasPendingRemovalsLocked) ||
|
||||||
persistSnapshotAndClearRemovalsLocked(merged) {
|
persistSnapshotAndClearRemovalsLocked(merged) {
|
||||||
pendingSnapshot = nil
|
pendingSnapshot = nil
|
||||||
pendingSnapshotIsAuthoritative = false
|
pendingSnapshotIsAuthoritative = false
|
||||||
@@ -507,7 +530,7 @@ final class MessageOutboxStore {
|
|||||||
: known)
|
: known)
|
||||||
cachedSnapshot = merged
|
cachedSnapshot = merged
|
||||||
diskState = .loaded
|
diskState = .loaded
|
||||||
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
|
if (pendingSnapshot == nil && !hasPendingRemovalsLocked) ||
|
||||||
persistSnapshotAndClearRemovalsLocked(merged) {
|
persistSnapshotAndClearRemovalsLocked(merged) {
|
||||||
pendingSnapshot = nil
|
pendingSnapshot = nil
|
||||||
pendingSnapshotIsAuthoritative = false
|
pendingSnapshotIsAuthoritative = false
|
||||||
@@ -539,7 +562,7 @@ final class MessageOutboxStore {
|
|||||||
: known)
|
: known)
|
||||||
cachedSnapshot = merged
|
cachedSnapshot = merged
|
||||||
diskState = .loaded
|
diskState = .loaded
|
||||||
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
|
if (pendingSnapshot == nil && !hasPendingRemovalsLocked) ||
|
||||||
persistSnapshotAndClearRemovalsLocked(merged) {
|
persistSnapshotAndClearRemovalsLocked(merged) {
|
||||||
pendingSnapshot = nil
|
pendingSnapshot = nil
|
||||||
pendingSnapshotIsAuthoritative = false
|
pendingSnapshotIsAuthoritative = false
|
||||||
@@ -578,6 +601,7 @@ final class MessageOutboxStore {
|
|||||||
pendingSnapshot = nil
|
pendingSnapshot = nil
|
||||||
pendingSnapshotIsAuthoritative = false
|
pendingSnapshotIsAuthoritative = false
|
||||||
pendingRemovalMessageIDs.removeAll()
|
pendingRemovalMessageIDs.removeAll()
|
||||||
|
pendingScopedRemovalMessageIDs.removeAll()
|
||||||
recoveryDeliveryPending = false
|
recoveryDeliveryPending = false
|
||||||
unseenRecoveryPendingPersistence = false
|
unseenRecoveryPendingPersistence = false
|
||||||
unseenRecoveredSnapshot = [:]
|
unseenRecoveredSnapshot = [:]
|
||||||
@@ -742,12 +766,21 @@ final class MessageOutboxStore {
|
|||||||
private func persistSnapshotAndClearRemovalsLocked(_ snapshot: Snapshot) -> Bool {
|
private func persistSnapshotAndClearRemovalsLocked(_ snapshot: Snapshot) -> Bool {
|
||||||
guard persistSnapshotLocked(snapshot) else { return false }
|
guard persistSnapshotLocked(snapshot) else { return false }
|
||||||
pendingRemovalMessageIDs.removeAll()
|
pendingRemovalMessageIDs.removeAll()
|
||||||
|
pendingScopedRemovalMessageIDs.removeAll()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Must be called with `lock` held.
|
/// Must be called with `lock` held.
|
||||||
private func applyingPendingRemovalsLocked(_ snapshot: Snapshot) -> Snapshot {
|
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<String>, from snapshot: Snapshot) -> Snapshot {
|
private static func removing(_ messageIDs: Set<String>, from snapshot: Snapshot) -> Snapshot {
|
||||||
@@ -760,9 +793,28 @@ final class MessageOutboxStore {
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func removing(
|
||||||
|
_ messageIDsByPeer: [PeerID: Set<String>],
|
||||||
|
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 {
|
private static func excludingKnownMessages(from durable: Snapshot, known: Snapshot) -> Snapshot {
|
||||||
let knownIDs = Set(known.values.flatMap { $0.map(\.messageID) })
|
var unseen: Snapshot = [:]
|
||||||
return removing(knownIDs, from: durable)
|
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 {
|
private static func merge(_ durable: Snapshot, _ pending: Snapshot) -> Snapshot {
|
||||||
|
|||||||
@@ -375,6 +375,32 @@ final class MessageRouter {
|
|||||||
persistOutbox()
|
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) {
|
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
|
||||||
var message = message
|
var message = message
|
||||||
var queue = outbox[peerID] ?? []
|
var queue = outbox[peerID] ?? []
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ protocol ChatPrivateConversationContext: AnyObject {
|
|||||||
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool
|
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 sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
|
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
|
||||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||||
@@ -166,6 +171,11 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
|||||||
messageRouter.sendReadReceipt(receipt, to: peerID)
|
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) {
|
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
}
|
}
|
||||||
@@ -250,6 +260,60 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
return true
|
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) {
|
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
|
||||||
guard !content.isEmpty else { return }
|
guard !content.isEmpty else { return }
|
||||||
|
|
||||||
@@ -464,6 +528,8 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let conversationPeerID = consolidateAccountConversationAliases(for: convKey)
|
||||||
|
|
||||||
if context.privateChatsContainMessage(withID: messageId) { return }
|
if context.privateChatsContainMessage(withID: messageId) { return }
|
||||||
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
@@ -474,18 +540,18 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
isRelay: false,
|
isRelay: false,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: context.nickname,
|
recipientNickname: context.nickname,
|
||||||
senderPeerID: convKey,
|
senderPeerID: conversationPeerID,
|
||||||
deliveryStatus: .delivered(to: context.nickname, at: Date())
|
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 wasReadBefore = context.sentReadReceipts.contains(messageId)
|
||||||
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
||||||
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
|
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
|
||||||
if shouldMarkUnread {
|
if shouldMarkUnread {
|
||||||
context.markPrivateChatUnread(convKey)
|
context.markPrivateChatUnread(conversationPeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isViewing {
|
if isViewing {
|
||||||
@@ -493,7 +559,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !isViewing && shouldMarkUnread {
|
if !isViewing && shouldMarkUnread {
|
||||||
context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: convKey)
|
context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: conversationPeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
context.notifyUIChanged()
|
context.notifyUIChanged()
|
||||||
@@ -502,17 +568,32 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
||||||
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
||||||
|
|
||||||
if context.privateChat(convKey, containsMessageWithID: messageID) {
|
let aliases = accountConversationAliases(for: convKey)
|
||||||
context.setPrivateDeliveryStatus(
|
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()),
|
.delivered(to: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
|
||||||
forMessageID: messageID,
|
forMessageID: messageID,
|
||||||
peerID: convKey
|
peerID: conversationPeerID
|
||||||
)
|
)
|
||||||
context.notifyUIChanged()
|
if didChange {
|
||||||
|
context.notifyUIChanged()
|
||||||
|
}
|
||||||
SecureLogger.info(
|
SecureLogger.info(
|
||||||
"GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…",
|
"GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
|
} else if clearedRetainedMessage {
|
||||||
|
SecureLogger.debug(
|
||||||
|
"GeoDM: recv DELIVERED for cleared mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// A stale ack for a message this device no longer tracks (dropped
|
// A stale ack for a message this device no longer tracks (dropped
|
||||||
// outbox entry, cleared chat, or a peer re-acking after losing our
|
// 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) {
|
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
||||||
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
||||||
|
|
||||||
if context.privateChat(convKey, containsMessageWithID: messageID) {
|
let aliases = accountConversationAliases(for: convKey)
|
||||||
context.setPrivateDeliveryStatus(
|
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()),
|
.read(by: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
|
||||||
forMessageID: messageID,
|
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)
|
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 {
|
} else {
|
||||||
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
|||||||
private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = []
|
private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = []
|
||||||
private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = []
|
private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = []
|
||||||
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
|
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
|
||||||
|
var queuedMessageIDsByPeerID: [PeerID: Set<String>] = [:]
|
||||||
|
private(set) var deliveryAckAttempts: [(messageID: String, peerIDs: [PeerID])] = []
|
||||||
|
private(set) var deliveredMessageIDs: [String] = []
|
||||||
|
|
||||||
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
routedPrivateMessages.append((content, peerID, messageID))
|
routedPrivateMessages.append((content, peerID, messageID))
|
||||||
@@ -187,6 +190,22 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
|||||||
return routeReadReceiptResult
|
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) {
|
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
meshReadReceipts.append((receipt.originalMessageID, peerID))
|
meshReadReceipts.append((receipt.originalMessageID, peerID))
|
||||||
}
|
}
|
||||||
@@ -370,6 +389,66 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
|||||||
convKey: convKey
|
convKey: convKey
|
||||||
)
|
)
|
||||||
#expect(context.notifyUIChangedCount == 2)
|
#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
|
@Test @MainActor
|
||||||
@@ -412,6 +491,180 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
|||||||
#expect(context.privateChats[convKey]?.count == 1)
|
#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
|
@Test @MainActor
|
||||||
func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async {
|
func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async {
|
||||||
let context = MockChatPrivateConversationContext()
|
let context = MockChatPrivateConversationContext()
|
||||||
|
|||||||
@@ -207,6 +207,42 @@ struct MessageOutboxStoreTests {
|
|||||||
#expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty)
|
#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() {
|
@Test func wipeRemovesFileAndKey() {
|
||||||
let fileURL = makeTempURL()
|
let fileURL = makeTempURL()
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
|
|||||||
@@ -582,6 +582,79 @@ struct MessageRouterTests {
|
|||||||
#expect(transport2.sentPrivateMessages.isEmpty)
|
#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
|
@Test @MainActor
|
||||||
func protectedDataRecoveryMergesDurableAndLockedWakeMessagesIntoRouter() async {
|
func protectedDataRecoveryMergesDurableAndLockedWakeMessagesIntoRouter() async {
|
||||||
let fileURL = FileManager.default.temporaryDirectory
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
|||||||
Reference in New Issue
Block a user