mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:25:20 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21ddb79db | ||
|
|
a8d8cca438 | ||
|
|
6419b7d40a | ||
|
|
7f20ba2ede | ||
|
|
0de4dd6ef4 | ||
|
|
9208d43935 |
@@ -426,6 +426,40 @@ final class ConversationStore: ObservableObject {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
guard let ids = conversationIDsByMessageID[messageID] else { return false }
|
guard let ids = conversationIDsByMessageID[messageID] else { return false }
|
||||||
|
return applyDeliveryStatus(status, forMessageID: messageID, among: ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies an authenticated delivery/read receipt only to the supplied
|
||||||
|
/// direct-conversation aliases. A colliding message ID in another peer's
|
||||||
|
/// conversation (or a public timeline) must not inherit the receipt.
|
||||||
|
///
|
||||||
|
/// Stable and ephemeral aliases can temporarily hold the same message
|
||||||
|
/// instance during handoff. The shared helper republishes every targeted
|
||||||
|
/// alias even when the first mutation already changed that instance.
|
||||||
|
@discardableResult
|
||||||
|
func setDeliveryStatus(
|
||||||
|
_ status: DeliveryStatus,
|
||||||
|
forMessageID messageID: String,
|
||||||
|
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||||
|
) -> Bool {
|
||||||
|
guard !peerIDs.isEmpty,
|
||||||
|
let indexedIDs = conversationIDsByMessageID[messageID] else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let allowedIDs = Set(peerIDs.map { ConversationID.directPeer($0) })
|
||||||
|
return applyDeliveryStatus(
|
||||||
|
status,
|
||||||
|
forMessageID: messageID,
|
||||||
|
among: indexedIDs.intersection(allowedIDs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyDeliveryStatus(
|
||||||
|
_ status: DeliveryStatus,
|
||||||
|
forMessageID messageID: String,
|
||||||
|
among ids: Set<ConversationID>
|
||||||
|
) -> Bool {
|
||||||
|
guard !ids.isEmpty else { return false }
|
||||||
var applied = false
|
var applied = false
|
||||||
var skipped: [ConversationID] = []
|
var skipped: [ConversationID] = []
|
||||||
for id in ids {
|
for id in ids {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -106,13 +106,14 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
dedupKey: String?,
|
dedupKey: String?,
|
||||||
operationID: UUID?
|
operationID: UUID?
|
||||||
)] = []
|
)] = []
|
||||||
/// Message IDs already published as drops (sender-side dedup) and drop
|
/// Opaque recipient/message keys already published as drops (sender-side
|
||||||
/// event IDs already handled (multi-relay dedup). Both persist across
|
/// dedup) and drop event IDs already handled (multi-relay dedup). Both
|
||||||
/// relaunches: relays hold drops for the full 24h NIP-40 window and the
|
/// persist across relaunches: relays hold drops for the full 24h NIP-40
|
||||||
/// persisted outbox keeps re-depositing, so in-memory-only dedup meant
|
/// window and the persisted outbox keeps re-depositing, so in-memory-only
|
||||||
/// every relaunch republished the same message as a fresh drop and every
|
/// dedup meant every relaunch republished the same message as a fresh drop
|
||||||
/// gateway relaunch re-delivered the whole backlog (field-verified
|
/// and every gateway relaunch re-delivered the whole backlog
|
||||||
/// amplification storm). Entries age out with the 24h drop window.
|
/// (field-verified amplification storm). Entries age out with the 24h drop
|
||||||
|
/// window.
|
||||||
private var publishedDropKeys: ExpiringIDSet
|
private var publishedDropKeys: ExpiringIDSet
|
||||||
private var seenDropEventIDs: ExpiringIDSet
|
private var seenDropEventIDs: ExpiringIDSet
|
||||||
private var subscriptionOpen = false
|
private var subscriptionOpen = false
|
||||||
@@ -126,7 +127,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
}
|
}
|
||||||
/// Sender operations queued locally or awaiting relay confirmation.
|
/// Sender operations queued locally or awaiting relay confirmation.
|
||||||
/// The per-attempt ID prevents a stale pre-wipe callback from completing
|
/// The per-attempt ID prevents a stale pre-wipe callback from completing
|
||||||
/// a newer attempt for the same message.
|
/// a newer attempt for the same recipient/message pair.
|
||||||
private var activeDropOperations: [String: ActiveDropOperation] = [:]
|
private var activeDropOperations: [String: ActiveDropOperation] = [:]
|
||||||
/// Held-envelope publishes have no sender message ID, but still need an
|
/// Held-envelope publishes have no sender message ID, but still need an
|
||||||
/// in-flight identity: repeated refreshes inside the relay-OK wait window
|
/// in-flight identity: repeated refreshes inside the relay-OK wait window
|
||||||
@@ -214,11 +215,46 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Sender role
|
// MARK: - Sender role
|
||||||
|
|
||||||
|
/// Stable, opaque sender-side dedup key. Recipient scope prevents one
|
||||||
|
/// conversation's colliding message ID from suppressing another's drop,
|
||||||
|
/// while hashing keeps recipient keys out of the persisted snapshot.
|
||||||
|
private static func senderDropKey(
|
||||||
|
messageID: String,
|
||||||
|
recipientNoiseKey: Data
|
||||||
|
) -> String {
|
||||||
|
var material = Data("bitchat-bridge-drop-dedup-v2".utf8)
|
||||||
|
appendLengthPrefixed(Data(messageID.utf8), to: &material)
|
||||||
|
appendLengthPrefixed(recipientNoiseKey, to: &material)
|
||||||
|
return "v2:\(material.sha256Hex())"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func appendLengthPrefixed(_ value: Data, to output: inout Data) {
|
||||||
|
let length = UInt32(value.count)
|
||||||
|
output.append(UInt8((length >> 24) & 0xFF))
|
||||||
|
output.append(UInt8((length >> 16) & 0xFF))
|
||||||
|
output.append(UInt8((length >> 8) & 0xFF))
|
||||||
|
output.append(UInt8(length & 0xFF))
|
||||||
|
output.append(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Previous releases persisted raw message IDs without recipient scope.
|
||||||
|
/// They remain conservative wildcards for their original 24-hour
|
||||||
|
/// lifetime: assigning one to a recipient would be guesswork and could
|
||||||
|
/// republish the original drop. New acceptances persist only v2 keys.
|
||||||
|
private func wasPublished(
|
||||||
|
legacyMessageID: String,
|
||||||
|
dedupKey: String,
|
||||||
|
now date: Date
|
||||||
|
) -> Bool {
|
||||||
|
publishedDropKeys.contains(dedupKey, now: date)
|
||||||
|
|| publishedDropKeys.contains(legacyMessageID, now: date)
|
||||||
|
}
|
||||||
|
|
||||||
/// Parallel-deposit a sealed copy of an outbound private message as a
|
/// Parallel-deposit a sealed copy of an outbound private message as a
|
||||||
/// relay drop. Called by the message router alongside physical courier
|
/// relay drop. Called by the message router alongside physical courier
|
||||||
/// deposits; idempotent per message ID. Completion becomes true only
|
/// deposits; idempotent per recipient/message pair. Completion becomes
|
||||||
/// after a real relay acceptance arrives, which is when the router may
|
/// true only after a real relay acceptance arrives, which is when the
|
||||||
/// show "carried".
|
/// router may show "carried".
|
||||||
func depositDrop(
|
func depositDrop(
|
||||||
content: String,
|
content: String,
|
||||||
messageID: String,
|
messageID: String,
|
||||||
@@ -229,9 +265,18 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
completion(false)
|
completion(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard !publishedDropKeys.contains(messageID, now: now()),
|
let date = now()
|
||||||
activeDropOperations[messageID] == nil,
|
let dedupKey = Self.senderDropKey(
|
||||||
!rejectedDropKeys.contains(messageID, now: now()) else {
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: recipientNoiseKey
|
||||||
|
)
|
||||||
|
guard !wasPublished(
|
||||||
|
legacyMessageID: messageID,
|
||||||
|
dedupKey: dedupKey,
|
||||||
|
now: date
|
||||||
|
),
|
||||||
|
activeDropOperations[dedupKey] == nil,
|
||||||
|
!rejectedDropKeys.contains(dedupKey, now: date) else {
|
||||||
completion(false)
|
completion(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -244,13 +289,13 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
// of the sealing); suppress it in-memory so the retry sweep does not
|
// of the sealing); suppress it in-memory so the retry sweep does not
|
||||||
// churn, but never persist it as a published drop.
|
// churn, but never persist it as a published drop.
|
||||||
guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else {
|
guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else {
|
||||||
rejectedDropKeys.insert(messageID, now: now())
|
rejectedDropKeys.insert(dedupKey, now: date)
|
||||||
completion(false)
|
completion(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let operationID = UUID()
|
let operationID = UUID()
|
||||||
activeDropOperations[messageID] = ActiveDropOperation(id: operationID, completion: completion)
|
activeDropOperations[dedupKey] = ActiveDropOperation(id: operationID, completion: completion)
|
||||||
publishDrop(envelope, messageID: messageID, operationID: operationID)
|
publishDrop(envelope, dedupKey: dedupKey, operationID: operationID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publishes held envelopes (mail we carry for others) as drops,
|
/// Publishes held envelopes (mail we carry for others) as drops,
|
||||||
@@ -272,13 +317,14 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publishes a drop, or queues it when relays are down. `messageID` is the
|
/// Publishes a drop, or queues it when relays are down. `dedupKey` is the
|
||||||
/// sender-side dedup key (nil for held/relayed envelopes we don't track);
|
/// opaque sender-side recipient/message key (nil for held/relayed
|
||||||
/// it rides the pending queue so an evicted or failed drop can release its
|
/// envelopes we don't track); it rides the pending queue so an evicted or
|
||||||
/// in-flight slot. Completion reports actual NIP-01 relay acceptance.
|
/// failed drop can release its in-flight slot. Completion reports actual
|
||||||
|
/// NIP-01 relay acceptance.
|
||||||
private func publishDrop(
|
private func publishDrop(
|
||||||
_ envelope: CourierEnvelope,
|
_ envelope: CourierEnvelope,
|
||||||
messageID: String? = nil,
|
dedupKey: String? = nil,
|
||||||
operationID: UUID? = nil,
|
operationID: UUID? = nil,
|
||||||
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
|
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
@@ -286,7 +332,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
encoded.count <= Limits.maxDropEnvelopeBytes,
|
encoded.count <= Limits.maxDropEnvelopeBytes,
|
||||||
!envelope.isExpired else {
|
!envelope.isExpired else {
|
||||||
finishPublish(
|
finishPublish(
|
||||||
messageID: messageID,
|
dedupKey: dedupKey,
|
||||||
operationID: operationID,
|
operationID: operationID,
|
||||||
succeeded: false,
|
succeeded: false,
|
||||||
untrackedCompletion: untrackedCompletion
|
untrackedCompletion: untrackedCompletion
|
||||||
@@ -297,15 +343,15 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
// Held mail remains in CourierStore and has no sender operation to
|
// Held mail remains in CourierStore and has no sender operation to
|
||||||
// recover after an in-memory queue loss. Leave its cooldown unset
|
// recover after an in-memory queue loss. Leave its cooldown unset
|
||||||
// and let the next connected refresh offer it again.
|
// and let the next connected refresh offer it again.
|
||||||
guard messageID != nil else {
|
guard dedupKey != nil else {
|
||||||
untrackedCompletion?(false)
|
untrackedCompletion?(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pendingDrops.append((envelope, messageID, operationID))
|
pendingDrops.append((envelope, dedupKey, operationID))
|
||||||
while pendingDrops.count > Limits.maxPendingDrops {
|
while pendingDrops.count > Limits.maxPendingDrops {
|
||||||
let evicted = pendingDrops.removeFirst()
|
let evicted = pendingDrops.removeFirst()
|
||||||
finishPublish(
|
finishPublish(
|
||||||
messageID: evicted.dedupKey,
|
dedupKey: evicted.dedupKey,
|
||||||
operationID: evicted.operationID,
|
operationID: evicted.operationID,
|
||||||
succeeded: false
|
succeeded: false
|
||||||
)
|
)
|
||||||
@@ -321,7 +367,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
) else {
|
) else {
|
||||||
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
|
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
|
||||||
finishPublish(
|
finishPublish(
|
||||||
messageID: messageID,
|
dedupKey: dedupKey,
|
||||||
operationID: operationID,
|
operationID: operationID,
|
||||||
succeeded: false,
|
succeeded: false,
|
||||||
untrackedCompletion: untrackedCompletion
|
untrackedCompletion: untrackedCompletion
|
||||||
@@ -331,7 +377,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
guard let publishEvent else {
|
guard let publishEvent else {
|
||||||
SecureLogger.error("📦🌉 Courier drop publisher is not configured", category: .session)
|
SecureLogger.error("📦🌉 Courier drop publisher is not configured", category: .session)
|
||||||
finishPublish(
|
finishPublish(
|
||||||
messageID: messageID,
|
dedupKey: dedupKey,
|
||||||
operationID: operationID,
|
operationID: operationID,
|
||||||
succeeded: false,
|
succeeded: false,
|
||||||
untrackedCompletion: untrackedCompletion
|
untrackedCompletion: untrackedCompletion
|
||||||
@@ -341,7 +387,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
publishEvent(event) { [weak self] succeeded in
|
publishEvent(event) { [weak self] succeeded in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
guard self.finishPublish(
|
guard self.finishPublish(
|
||||||
messageID: messageID,
|
dedupKey: dedupKey,
|
||||||
operationID: operationID,
|
operationID: operationID,
|
||||||
succeeded: succeeded,
|
succeeded: succeeded,
|
||||||
untrackedCompletion: untrackedCompletion
|
untrackedCompletion: untrackedCompletion
|
||||||
@@ -356,23 +402,23 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func finishPublish(
|
private func finishPublish(
|
||||||
messageID: String?,
|
dedupKey: String?,
|
||||||
operationID: UUID?,
|
operationID: UUID?,
|
||||||
succeeded: Bool,
|
succeeded: Bool,
|
||||||
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
|
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
guard let messageID else {
|
guard let dedupKey else {
|
||||||
untrackedCompletion?(succeeded)
|
untrackedCompletion?(succeeded)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Missing/mismatched means this callback was duplicated, invalidated
|
// Missing/mismatched means this callback was duplicated, invalidated
|
||||||
// by panic wipe, or belongs to an older attempt for the same key.
|
// by panic wipe, or belongs to an older attempt for the same key.
|
||||||
guard let operationID,
|
guard let operationID,
|
||||||
let operation = activeDropOperations[messageID],
|
let operation = activeDropOperations[dedupKey],
|
||||||
operation.id == operationID else { return false }
|
operation.id == operationID else { return false }
|
||||||
activeDropOperations.removeValue(forKey: messageID)
|
activeDropOperations.removeValue(forKey: dedupKey)
|
||||||
if succeeded {
|
if succeeded {
|
||||||
publishedDropKeys.insert(messageID, now: now())
|
publishedDropKeys.insert(dedupKey, now: now())
|
||||||
persistDedup()
|
persistDedup()
|
||||||
}
|
}
|
||||||
operation.completion(succeeded)
|
operation.completion(succeeded)
|
||||||
@@ -387,7 +433,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
for item in queued {
|
for item in queued {
|
||||||
publishDrop(
|
publishDrop(
|
||||||
item.envelope,
|
item.envelope,
|
||||||
messageID: item.dedupKey,
|
dedupKey: item.dedupKey,
|
||||||
operationID: item.operationID
|
operationID: item.operationID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,11 +64,11 @@ struct ExpiringIDSet {
|
|||||||
/// fresh drop (fresh throwaway seal, undeduplicatable downstream) and every
|
/// fresh drop (fresh throwaway seal, undeduplicatable downstream) and every
|
||||||
/// gateway relaunch re-delivered the whole backlog. Field-verified: ~20
|
/// gateway relaunch re-delivered the whole backlog. Field-verified: ~20
|
||||||
/// copies of one DM delivered in 40ms fed the storm behind a permanent
|
/// copies of one DM delivered in 40ms fed the storm behind a permanent
|
||||||
/// device freeze. Persisting both sides caps this at one drop per message
|
/// device freeze. Persisting both sides caps this at one drop per
|
||||||
/// ID per 24h regardless of relaunch count.
|
/// recipient/message pair per 24h regardless of relaunch count.
|
||||||
///
|
///
|
||||||
/// Contents are opaque IDs (message UUIDs, relay event IDs) — no plaintext,
|
/// Contents are opaque hashes and relay event IDs — no plaintext or peer
|
||||||
/// no peer identities — so until-first-unlock protection matches
|
/// identities — so until-first-unlock protection matches
|
||||||
/// `NostrProcessedEventStore`, and the file must load during a
|
/// `NostrProcessedEventStore`, and the file must load during a
|
||||||
/// locked-background restoration relaunch. Wiped on panic with the rest of
|
/// locked-background restoration relaunch. Wiped on panic with the rest of
|
||||||
/// the courier state.
|
/// the courier state.
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ struct CourierDirectory {
|
|||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
typealias QueuedMessage = MessageOutboxStore.QueuedMessage
|
typealias QueuedMessage = MessageOutboxStore.QueuedMessage
|
||||||
|
|
||||||
|
private struct PeerMessageKey: Hashable {
|
||||||
|
let peerID: PeerID
|
||||||
|
let messageID: String
|
||||||
|
|
||||||
|
static func == (lhs: PeerMessageKey, rhs: PeerMessageKey) -> Bool {
|
||||||
|
lhs.peerID == rhs.peerID && lhs.messageID == rhs.messageID
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(peerID)
|
||||||
|
hasher.combine(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private let transports: [Transport]
|
private let transports: [Transport]
|
||||||
private let now: () -> Date
|
private let now: () -> Date
|
||||||
private let courierDirectory: CourierDirectory
|
private let courierDirectory: CourierDirectory
|
||||||
@@ -104,15 +118,25 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var bridgeSweepTask: Task<Void, Never>?
|
private var bridgeSweepTask: Task<Void, Never>?
|
||||||
private var bridgeDepositsInFlight = Set<String>()
|
private var bridgeDepositsInFlight = Set<PeerMessageKey>()
|
||||||
|
|
||||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||||
|
/// Peer/message pairs whose latest router-owned transmission used an
|
||||||
|
/// already-established secure session and still await an ack. Peer scope
|
||||||
|
/// is required because message IDs are not globally unique across direct
|
||||||
|
/// conversations. This deliberately excludes messages handed to BLE while
|
||||||
|
/// a handshake is pending: BLE owns those sends and drains its queue after
|
||||||
|
/// authentication, so retrying them here would duplicate every normal
|
||||||
|
/// first-handshake DM.
|
||||||
|
private var secureTransmissions = Set<PeerMessageKey>()
|
||||||
|
|
||||||
// Outbox limits to prevent unbounded memory growth
|
// Outbox limits to prevent unbounded memory growth
|
||||||
private static let maxMessagesPerPeer = 100
|
private static let maxMessagesPerPeer = 100
|
||||||
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
||||||
// Bound resends of messages sent on a weak reachability signal that never
|
// Bound actual sends that never receive an ack, whether they used weak
|
||||||
// get a delivery ack (e.g. peer on an old client that doesn't ack).
|
// reachability or an apparently secure session that keeps being replaced.
|
||||||
|
// Connected pre-handshake sends are transport-owned and do not burn this
|
||||||
|
// cap because BLE queues/drains them itself.
|
||||||
private static let maxSendAttempts = 8
|
private static let maxSendAttempts = 8
|
||||||
// Redundant couriers improve delivery odds; receivers dedup by message ID.
|
// Redundant couriers improve delivery odds; receivers dedup by message ID.
|
||||||
private static let maxCouriersPerMessage = 3
|
private static let maxCouriersPerMessage = 3
|
||||||
@@ -181,15 +205,28 @@ final class MessageRouter {
|
|||||||
// MARK: - Message Sending
|
// MARK: - Message Sending
|
||||||
|
|
||||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
|
let message = QueuedMessage(
|
||||||
|
content: content,
|
||||||
|
nickname: recipientNickname,
|
||||||
|
messageID: messageID,
|
||||||
|
timestamp: now(),
|
||||||
|
sendAttempts: 1
|
||||||
|
)
|
||||||
|
|
||||||
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
|
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
|
||||||
// A live link that can complete an encrypted delivery is a
|
// Even an established Noise session can be stale after the peer
|
||||||
// strong delivery signal; trust it outright.
|
// restarts or replaces its app. Persist before handing the packet
|
||||||
|
// to the transport so a fast ack cannot race ahead of retention,
|
||||||
|
// then keep the copy until a delivery/read ack clears it. A
|
||||||
|
// replacement handshake will retry this same message ID, which
|
||||||
|
// receivers deduplicate.
|
||||||
|
enqueue(message, for: peerID)
|
||||||
|
secureTransmissions.insert(PeerMessageKey(peerID: peerID, messageID: messageID))
|
||||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1)
|
|
||||||
if let transport = connectedTransport(for: peerID) {
|
if let transport = connectedTransport(for: peerID) {
|
||||||
// "Connected" without an established secure session is forgeable:
|
// "Connected" without an established secure session is forgeable:
|
||||||
// link bindings heal on signature-verified "direct" announces, but
|
// link bindings heal on signature-verified "direct" announces, but
|
||||||
@@ -207,8 +244,9 @@ final class MessageRouter {
|
|||||||
// deposit is cleared on ack. Don't "optimize" the courier call
|
// deposit is cleared on ack. Don't "optimize" the courier call
|
||||||
// away.
|
// away.
|
||||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
|
||||||
enqueue(message, for: peerID)
|
enqueue(message, for: peerID)
|
||||||
|
secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID))
|
||||||
|
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
attemptCourierDeposit(messageID: messageID, for: peerID)
|
attemptCourierDeposit(messageID: messageID, for: peerID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -219,8 +257,8 @@ final class MessageRouter {
|
|||||||
// Send now, but retain a copy until a delivery/read ack clears it;
|
// Send now, but retain a copy until a delivery/read ack clears it;
|
||||||
// receivers dedup resends by message ID.
|
// receivers dedup resends by message ID.
|
||||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
|
||||||
enqueue(message, for: peerID)
|
enqueue(message, for: peerID)
|
||||||
|
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
// "Reachable" without prompt delivery means the send only joined
|
// "Reachable" without prompt delivery means the send only joined
|
||||||
// a queue (Nostr with relays down): also hand a sealed copy to
|
// a queue (Nostr with relays down): also hand a sealed copy to
|
||||||
// any connected couriers rather than waiting for internet that
|
// any connected couriers rather than waiting for internet that
|
||||||
@@ -343,11 +381,12 @@ final class MessageRouter {
|
|||||||
for peerID: PeerID,
|
for peerID: PeerID,
|
||||||
recipientKey: Data
|
recipientKey: Data
|
||||||
) {
|
) {
|
||||||
|
let inFlightKey = PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||||
guard let bridgeCourierDeposit,
|
guard let bridgeCourierDeposit,
|
||||||
bridgeDepositsInFlight.insert(message.messageID).inserted else { return }
|
bridgeDepositsInFlight.insert(inFlightKey).inserted else { return }
|
||||||
bridgeCourierDeposit(message.content, message.messageID, recipientKey) { [weak self] succeeded in
|
bridgeCourierDeposit(message.content, message.messageID, recipientKey) { [weak self] succeeded in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.bridgeDepositsInFlight.remove(message.messageID)
|
self.bridgeDepositsInFlight.remove(inFlightKey)
|
||||||
// A direct delivery may have cleared the outbox while the relay
|
// A direct delivery may have cleared the outbox while the relay
|
||||||
// relay confirmation was in flight; do not regress its UI state.
|
// relay confirmation was in flight; do not regress its UI state.
|
||||||
guard succeeded, self.queuedMessage(message.messageID, for: peerID) != nil else { return }
|
guard succeeded, self.queuedMessage(message.messageID, for: peerID) != nil else { return }
|
||||||
@@ -366,15 +405,40 @@ final class MessageRouter {
|
|||||||
|
|
||||||
// MARK: - Outbox Management
|
// MARK: - Outbox Management
|
||||||
|
|
||||||
/// A delivery or read ack confirms receipt; stop retaining the message.
|
/// A locally trusted delivery transition confirms receipt; stop retaining
|
||||||
|
/// every copy of the message. Authenticated remote receipts must use the
|
||||||
|
/// peer-bound overload below instead.
|
||||||
func markDelivered(_ messageID: String) {
|
func markDelivered(_ messageID: String) {
|
||||||
|
clearRetainedMessage(messageID, allowedPeerIDs: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops retaining a message only for the authenticated conversation
|
||||||
|
/// aliases that produced the accepted receipt. A peer that learns another
|
||||||
|
/// conversation's message ID cannot use it to clear that conversation's
|
||||||
|
/// retry state.
|
||||||
|
func markDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
|
||||||
|
guard !peerIDs.isEmpty else { return }
|
||||||
|
_ = markDelivered(messageID, for: Array(peerIDs))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearRetainedMessage(
|
||||||
|
_ messageID: String,
|
||||||
|
allowedPeerIDs: Set<PeerID>?
|
||||||
|
) {
|
||||||
var cleared = false
|
var cleared = false
|
||||||
for (peerID, queue) in outbox {
|
for (peerID, queue) in outbox {
|
||||||
|
if let allowedPeerIDs, !allowedPeerIDs.contains(peerID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
let filtered = queue.filter { $0.messageID != messageID }
|
let filtered = queue.filter { $0.messageID != messageID }
|
||||||
guard filtered.count != queue.count else { continue }
|
guard filtered.count != queue.count else { continue }
|
||||||
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||||
cleared = true
|
cleared = true
|
||||||
}
|
}
|
||||||
|
let matchingSecureTransmissions = secureTransmissions.filter {
|
||||||
|
$0.messageID == messageID
|
||||||
|
}
|
||||||
|
secureTransmissions.subtract(matchingSecureTransmissions)
|
||||||
// The durable snapshot may still be hidden by protected data. Record
|
// The durable snapshot may still be hidden by protected data. Record
|
||||||
// the ack even when this cold-load view cannot find the message, then
|
// the ack even when this cold-load view cannot find the message, then
|
||||||
// persist the current view so the store retains a removal tombstone.
|
// persist the current view so the store retains a removal tombstone.
|
||||||
@@ -385,6 +449,35 @@ 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
|
||||||
|
}
|
||||||
|
for peerID in peerIDs {
|
||||||
|
secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID))
|
||||||
|
}
|
||||||
|
// 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] ?? []
|
||||||
@@ -408,7 +501,34 @@ final class MessageRouter {
|
|||||||
persistOutbox()
|
persistOutbox()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func removeQueuedMessage(_ messageID: String, for peerID: PeerID) -> Bool {
|
||||||
|
guard let queue = outbox[peerID],
|
||||||
|
let index = queue.firstIndex(where: { $0.messageID == messageID }) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var updated = queue
|
||||||
|
updated.remove(at: index)
|
||||||
|
outbox[peerID] = updated.isEmpty ? nil : updated
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func incrementSendAttemptsIfQueued(_ messageID: String, for peerID: PeerID) -> Bool {
|
||||||
|
guard var queue = outbox[peerID],
|
||||||
|
let index = queue.firstIndex(where: { $0.messageID == messageID }) else {
|
||||||
|
// A synchronous delivery/read ack may have cleared the retained
|
||||||
|
// copy while `sendPrivateMessage` was on the stack. Never
|
||||||
|
// resurrect it from the flush snapshot.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
queue[index].sendAttempts += 1
|
||||||
|
outbox[peerID] = queue
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private func dropMessage(_ messageID: String, for peerID: PeerID) {
|
private func dropMessage(_ messageID: String, for peerID: PeerID) {
|
||||||
|
secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID))
|
||||||
metrics?.record(.outboxDropped)
|
metrics?.record(.outboxDropped)
|
||||||
onMessageDropped?(messageID, peerID)
|
onMessageDropped?(messageID, peerID)
|
||||||
}
|
}
|
||||||
@@ -452,6 +572,7 @@ final class MessageRouter {
|
|||||||
/// Panic wipe: forget queued mail on disk and in memory.
|
/// Panic wipe: forget queued mail on disk and in memory.
|
||||||
func wipeOutbox() {
|
func wipeOutbox() {
|
||||||
outbox.removeAll()
|
outbox.removeAll()
|
||||||
|
secureTransmissions.removeAll()
|
||||||
outboxStore?.wipe()
|
outboxStore?.wipe()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,26 +600,160 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retries only messages that the router previously transmitted through
|
||||||
|
/// an already-established secure session and that still await an ack.
|
||||||
|
///
|
||||||
|
/// A peer restart can leave that local session looking usable until the
|
||||||
|
/// replacement handshake arrives; the first ciphertext is then
|
||||||
|
/// undecryptable remotely. Normal pre-handshake sends are intentionally
|
||||||
|
/// absent from `secureTransmissions` because BLE already queues
|
||||||
|
/// and drains them when authentication completes.
|
||||||
|
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) {
|
||||||
|
typealias Candidate = (
|
||||||
|
peerID: PeerID,
|
||||||
|
message: QueuedMessage,
|
||||||
|
aliasOrder: Int,
|
||||||
|
queueOrder: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
var visitedPeerIDs = Set<PeerID>()
|
||||||
|
var retriedMessageIDs = Set<String>()
|
||||||
|
var outboxChanged = false
|
||||||
|
let currentDate = now()
|
||||||
|
var candidates: [Candidate] = []
|
||||||
|
|
||||||
|
for (aliasOrder, peerID) in peerIDAliases.enumerated() {
|
||||||
|
guard visitedPeerIDs.insert(peerID).inserted else { continue }
|
||||||
|
guard let queued = outbox[peerID], !queued.isEmpty,
|
||||||
|
let transport = connectedTransport(for: peerID),
|
||||||
|
transport.canDeliverSecurely(to: peerID) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (queueOrder, message) in queued.enumerated() {
|
||||||
|
let key = PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||||
|
guard secureTransmissions.contains(key) else { continue }
|
||||||
|
candidates.append((
|
||||||
|
peerID: peerID,
|
||||||
|
message: message,
|
||||||
|
aliasOrder: aliasOrder,
|
||||||
|
queueOrder: queueOrder
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversation migration can leave retained messages split across the
|
||||||
|
// ephemeral and stable outbox keys. Merge both queues into one
|
||||||
|
// chronological stream so callback alias order cannot send newer mail
|
||||||
|
// ahead of older mail.
|
||||||
|
candidates.sort { lhs, rhs in
|
||||||
|
if lhs.message.timestamp != rhs.message.timestamp {
|
||||||
|
return lhs.message.timestamp < rhs.message.timestamp
|
||||||
|
}
|
||||||
|
if lhs.aliasOrder != rhs.aliasOrder {
|
||||||
|
return lhs.aliasOrder < rhs.aliasOrder
|
||||||
|
}
|
||||||
|
if lhs.queueOrder != rhs.queueOrder {
|
||||||
|
return lhs.queueOrder < rhs.queueOrder
|
||||||
|
}
|
||||||
|
return lhs.message.messageID < rhs.message.messageID
|
||||||
|
}
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
let peerID = candidate.peerID
|
||||||
|
let message = candidate.message
|
||||||
|
let key = PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||||
|
guard retriedMessageIDs.insert(message.messageID).inserted,
|
||||||
|
secureTransmissions.contains(key),
|
||||||
|
queuedMessage(message.messageID, for: peerID) != nil,
|
||||||
|
let transport = connectedTransport(for: peerID),
|
||||||
|
transport.canDeliverSecurely(to: peerID) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentDate.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
||||||
|
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||||
|
dropMessage(message.messageID, for: peerID)
|
||||||
|
outboxChanged = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) secure attempts",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||||
|
dropMessage(message.messageID, for: peerID)
|
||||||
|
outboxChanged = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.debug(
|
||||||
|
"Auth retry -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
transport.sendPrivateMessage(
|
||||||
|
message.content,
|
||||||
|
to: peerID,
|
||||||
|
recipientNickname: message.nickname,
|
||||||
|
messageID: message.messageID
|
||||||
|
)
|
||||||
|
metrics?.record(.outboxResent)
|
||||||
|
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
if outboxChanged {
|
||||||
|
persistOutbox()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func flushOutbox(for peerID: PeerID) {
|
func flushOutbox(for peerID: PeerID) {
|
||||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||||
|
|
||||||
let now = now()
|
let now = now()
|
||||||
var remaining: [QueuedMessage] = []
|
var outboxChanged = false
|
||||||
|
|
||||||
for message in queued {
|
for message in queued {
|
||||||
|
// A synchronous ack from an earlier send in this flush may have
|
||||||
|
// removed an entry from the live outbox. The snapshot is only an
|
||||||
|
// iteration order; never use it to recreate removed messages.
|
||||||
|
guard queuedMessage(message.messageID, for: peerID) != nil else { continue }
|
||||||
|
|
||||||
// Skip expired messages (TTL exceeded)
|
// Skip expired messages (TTL exceeded)
|
||||||
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
||||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
||||||
dropMessage(message.messageID, for: peerID)
|
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||||
|
dropMessage(message.messageID, for: peerID)
|
||||||
|
outboxChanged = true
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
|
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
|
||||||
// Live link with a secure session: send and stop retaining.
|
// A secure session is meaningful enough to retry, but not
|
||||||
|
// proof that this particular ciphertext reached the peer: the
|
||||||
|
// remote app may have restarted while our old session still
|
||||||
|
// looked established. Retain until an ack, while bounding
|
||||||
|
// actual secure transmissions for peers that never ack.
|
||||||
|
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||||
|
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
||||||
|
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||||
|
dropMessage(message.messageID, for: peerID)
|
||||||
|
outboxChanged = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||||
|
secureTransmissions.insert(
|
||||||
|
PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||||
|
)
|
||||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||||
metrics?.record(.outboxResent)
|
metrics?.record(.outboxResent)
|
||||||
|
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
|
||||||
} else if let transport = connectedTransport(for: peerID) {
|
} else if let transport = connectedTransport(for: peerID) {
|
||||||
// "Connected" without a secure session — possibly a stolen
|
// "Connected" without a secure session — possibly a stolen
|
||||||
// binding from a replayed announce: send (a genuine link
|
// binding from a replayed announce: send (a genuine link
|
||||||
@@ -511,9 +766,11 @@ final class MessageRouter {
|
|||||||
// preserve. Retention stays bounded by the 24h outbox TTL
|
// preserve. Retention stays bounded by the 24h outbox TTL
|
||||||
// and the per-peer FIFO cap.
|
// and the per-peer FIFO cap.
|
||||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected, no secure session) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected, no secure session) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||||
|
secureTransmissions.remove(
|
||||||
|
PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||||
|
)
|
||||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||||
metrics?.record(.outboxResent)
|
metrics?.record(.outboxResent)
|
||||||
remaining.append(message)
|
|
||||||
} else if let transport = reachableTransport(for: peerID) {
|
} else if let transport = reachableTransport(for: peerID) {
|
||||||
// Reachability without a connection is a freshness heuristic,
|
// Reachability without a connection is a freshness heuristic,
|
||||||
// so the send can silently go nowhere: send but keep retaining
|
// so the send can silently go nowhere: send but keep retaining
|
||||||
@@ -521,26 +778,22 @@ final class MessageRouter {
|
|||||||
// that never ack.
|
// that never ack.
|
||||||
guard message.sendAttempts < Self.maxSendAttempts else {
|
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||||
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
||||||
dropMessage(message.messageID, for: peerID)
|
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||||
|
dropMessage(message.messageID, for: peerID)
|
||||||
|
outboxChanged = true
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||||
metrics?.record(.outboxResent)
|
metrics?.record(.outboxResent)
|
||||||
var retained = message
|
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
|
||||||
retained.sendAttempts += 1
|
|
||||||
remaining.append(retained)
|
|
||||||
} else {
|
|
||||||
remaining.append(message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if remaining.isEmpty {
|
if outboxChanged {
|
||||||
outbox.removeValue(forKey: peerID)
|
persistOutbox()
|
||||||
} else {
|
|
||||||
outbox[peerID] = remaining
|
|
||||||
}
|
}
|
||||||
persistOutbox()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushAllOutbox() {
|
func flushAllOutbox() {
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ protocol ChatDeliveryContext: AnyObject {
|
|||||||
/// message is unknown or no copy changed.
|
/// message is unknown or no copy changed.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool
|
||||||
|
/// Applies an authenticated receipt only to the direct conversations
|
||||||
|
/// represented by the supplied peer aliases.
|
||||||
|
@discardableResult
|
||||||
|
func setDeliveryStatus(
|
||||||
|
_ status: DeliveryStatus,
|
||||||
|
forMessageID messageID: String,
|
||||||
|
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||||
|
) -> Bool
|
||||||
/// Current delivery status of the message in whichever conversation holds it.
|
/// Current delivery status of the message in whichever conversation holds it.
|
||||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus?
|
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus?
|
||||||
/// Message IDs across all direct conversations (read-receipt pruning).
|
/// Message IDs across all direct conversations (read-receipt pruning).
|
||||||
@@ -33,6 +41,12 @@ protocol ChatDeliveryContext: AnyObject {
|
|||||||
func notifyUIChanged()
|
func notifyUIChanged()
|
||||||
/// Confirms receipt so the message router stops retaining the message for resend.
|
/// Confirms receipt so the message router stops retaining the message for resend.
|
||||||
func markMessageDelivered(_ messageID: String)
|
func markMessageDelivered(_ messageID: String)
|
||||||
|
/// Peer-bound form for authenticated remote receipts. Only the supplied
|
||||||
|
/// conversation aliases may have retained state terminalized.
|
||||||
|
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>)
|
||||||
|
/// Returns true only when `messageID` is one of our outgoing messages in
|
||||||
|
/// at least one of the authenticated peer's direct-conversation aliases.
|
||||||
|
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ChatViewModel: ChatDeliveryContext {
|
extension ChatViewModel: ChatDeliveryContext {
|
||||||
@@ -41,6 +55,19 @@ extension ChatViewModel: ChatDeliveryContext {
|
|||||||
conversations.setDeliveryStatus(status, forMessageID: messageID)
|
conversations.setDeliveryStatus(status, forMessageID: messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setDeliveryStatus(
|
||||||
|
_ status: DeliveryStatus,
|
||||||
|
forMessageID messageID: String,
|
||||||
|
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||||
|
) -> Bool {
|
||||||
|
conversations.setDeliveryStatus(
|
||||||
|
status,
|
||||||
|
forMessageID: messageID,
|
||||||
|
inDirectPeerAliases: peerIDs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||||
conversations.deliveryStatus(forMessageID: messageID)
|
conversations.deliveryStatus(forMessageID: messageID)
|
||||||
}
|
}
|
||||||
@@ -59,6 +86,24 @@ extension ChatViewModel: ChatDeliveryContext {
|
|||||||
messageID: messageID
|
messageID: messageID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
|
||||||
|
messageRouter.markDelivered(messageID, from: peerIDs)
|
||||||
|
// The caller has already bound this receipt to one of our outgoing
|
||||||
|
// conversations. Release the matching media retry only after that
|
||||||
|
// peer-scoped validation and accepted delivery-status transition.
|
||||||
|
mediaTransferCoordinator.confirmPrivateMediaDelivery(
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
|
||||||
|
peerIDs.contains { peerID in
|
||||||
|
privateMessages(for: peerID).contains { message in
|
||||||
|
message.id == messageID && message.senderPeerID == myPeerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Thin mapper from delivery events (read receipts, transport delivery
|
/// Thin mapper from delivery events (read receipts, transport delivery
|
||||||
@@ -86,9 +131,10 @@ final class ChatDeliveryCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
|
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
|
||||||
updateMessageDeliveryStatus(
|
updateAcknowledgedMessageDeliveryStatus(
|
||||||
receipt.originalMessageID,
|
receipt.originalMessageID,
|
||||||
status: .read(by: receipt.readerNickname, at: receipt.timestamp)
|
status: .read(by: receipt.readerNickname, at: receipt.timestamp),
|
||||||
|
from: [receipt.readerID]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,17 +151,47 @@ final class ChatDeliveryCoordinator {
|
|||||||
@MainActor
|
@MainActor
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
||||||
|
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
switch status {
|
switch status {
|
||||||
case .delivered, .read:
|
case .delivered, .read:
|
||||||
// Confirmed receipt — stop retaining the message for resend.
|
// Terminalize only after the store accepted the transition.
|
||||||
context.markMessageDelivered(messageID)
|
context.markMessageDelivered(messageID)
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
context.notifyUIChanged()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
|
/// Applies an authenticated remote delivery/read receipt only when it
|
||||||
|
/// belongs to one of our outgoing messages in that peer's conversation.
|
||||||
|
/// Retry state is cleared after, never before, the status transition is
|
||||||
|
/// accepted by the store.
|
||||||
|
@MainActor
|
||||||
|
@discardableResult
|
||||||
|
func updateAcknowledgedMessageDeliveryStatus(
|
||||||
|
_ messageID: String,
|
||||||
|
status: DeliveryStatus,
|
||||||
|
from peerIDAliases: Set<PeerID>
|
||||||
|
) -> Bool {
|
||||||
|
switch status {
|
||||||
|
case .delivered, .read:
|
||||||
|
break
|
||||||
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
guard !peerIDAliases.isEmpty,
|
||||||
|
context.isOutgoingPrivateMessage(messageID, toAny: peerIDAliases),
|
||||||
|
context.setDeliveryStatus(
|
||||||
|
status,
|
||||||
|
forMessageID: messageID,
|
||||||
|
inDirectPeerAliases: peerIDAliases
|
||||||
|
) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
context.markMessageDelivered(messageID, from: peerIDAliases)
|
||||||
context.notifyUIChanged()
|
context.notifyUIChanged()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func sendGeohashPrivateMessage(
|
func sendGeohashPrivateMessage(
|
||||||
@@ -172,6 +177,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)
|
||||||
}
|
}
|
||||||
@@ -269,6 +279,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 }
|
||||||
|
|
||||||
@@ -491,6 +555,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(
|
||||||
@@ -501,18 +567,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 {
|
||||||
@@ -520,7 +586,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()
|
||||||
@@ -529,17 +595,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
|
||||||
@@ -551,14 +632,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,10 +62,15 @@ protocol ChatTransportEventContext: AnyObject {
|
|||||||
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
|
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||||
|
|
||||||
// MARK: Delivery status
|
// MARK: Delivery status
|
||||||
/// Applies the status to every known location of the message.
|
/// Applies an authenticated receipt to the message only when it belongs
|
||||||
/// Returns `false` when no message with that ID was updated.
|
/// to the supplied peer conversation aliases. Returns `false` for an
|
||||||
|
/// unknown ID, wrong peer, or rejected status transition.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool
|
func applyAcknowledgedMessageDeliveryStatus(
|
||||||
|
_ messageID: String,
|
||||||
|
status: DeliveryStatus,
|
||||||
|
from peerIDAliases: Set<PeerID>
|
||||||
|
) -> Bool
|
||||||
func deliveryStatus(for messageID: String) -> DeliveryStatus?
|
func deliveryStatus(for messageID: String) -> DeliveryStatus?
|
||||||
|
|
||||||
// MARK: Verification payloads
|
// MARK: Verification payloads
|
||||||
@@ -122,8 +127,16 @@ extension ChatViewModel: ChatTransportEventContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
func applyAcknowledgedMessageDeliveryStatus(
|
||||||
deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: status)
|
_ messageID: String,
|
||||||
|
status: DeliveryStatus,
|
||||||
|
from peerIDAliases: Set<PeerID>
|
||||||
|
) -> Bool {
|
||||||
|
deliveryCoordinator.updateAcknowledgedMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: status,
|
||||||
|
from: peerIDAliases
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
||||||
@@ -446,9 +459,10 @@ private extension ChatTransportEventCoordinator {
|
|||||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||||
|
|
||||||
let name = deliveryStatusName(for: peerID, in: context)
|
let name = deliveryStatusName(for: peerID, in: context)
|
||||||
let didUpdate = context.applyMessageDeliveryStatus(
|
let didUpdate = context.applyAcknowledgedMessageDeliveryStatus(
|
||||||
messageID,
|
messageID,
|
||||||
status: .delivered(to: name, at: Date())
|
status: .delivered(to: name, at: Date()),
|
||||||
|
from: receiptPeerAliases(for: peerID, in: context)
|
||||||
)
|
)
|
||||||
|
|
||||||
if !didUpdate {
|
if !didUpdate {
|
||||||
@@ -463,9 +477,10 @@ private extension ChatTransportEventCoordinator {
|
|||||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||||
|
|
||||||
let name = deliveryStatusName(for: peerID, in: context)
|
let name = deliveryStatusName(for: peerID, in: context)
|
||||||
let didUpdate = context.applyMessageDeliveryStatus(
|
let didUpdate = context.applyAcknowledgedMessageDeliveryStatus(
|
||||||
messageID,
|
messageID,
|
||||||
status: .read(by: name, at: Date())
|
status: .read(by: name, at: Date()),
|
||||||
|
from: receiptPeerAliases(for: peerID, in: context)
|
||||||
)
|
)
|
||||||
|
|
||||||
if !didUpdate {
|
if !didUpdate {
|
||||||
@@ -503,4 +518,21 @@ private extension ChatTransportEventCoordinator {
|
|||||||
func deliveryStatusName(for peerID: PeerID, in context: any ChatTransportEventContext) -> String {
|
func deliveryStatusName(for peerID: PeerID, in context: any ChatTransportEventContext) -> String {
|
||||||
context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID)
|
context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func receiptPeerAliases(
|
||||||
|
for peerID: PeerID,
|
||||||
|
in context: any ChatTransportEventContext
|
||||||
|
) -> Set<PeerID> {
|
||||||
|
var aliases: Set<PeerID> = [peerID]
|
||||||
|
// The active authenticated Noise key is authoritative. A cached
|
||||||
|
// ephemeral→stable mapping can predate an identity replacement, so
|
||||||
|
// use it only when the live session cannot provide its static key.
|
||||||
|
if let keyData = context.noiseSessionPublicKeyData(for: peerID) {
|
||||||
|
aliases.insert(PeerID(hexData: keyData))
|
||||||
|
} else if let stablePeerID = context.cachedStablePeerID(for: peerID) {
|
||||||
|
aliases.insert(stablePeerID)
|
||||||
|
}
|
||||||
|
return aliases
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ protocol ChatVerificationContext: AnyObject {
|
|||||||
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
|
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
|
||||||
func triggerHandshake(with peerID: PeerID)
|
func triggerHandshake(with peerID: PeerID)
|
||||||
func privateMediaPeerDidAuthenticate(_ peerID: PeerID)
|
func privateMediaPeerDidAuthenticate(_ peerID: PeerID)
|
||||||
|
/// Retries only private messages previously transmitted through a secure
|
||||||
|
/// session and still pending an ack. Both ephemeral and stable aliases
|
||||||
|
/// are supplied because either can own the outbox entry.
|
||||||
|
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID])
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
|
|
||||||
@@ -121,6 +125,10 @@ extension ChatViewModel: ChatVerificationContext {
|
|||||||
mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort())
|
mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) {
|
||||||
|
messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases)
|
||||||
|
}
|
||||||
|
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||||
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
||||||
}
|
}
|
||||||
@@ -216,16 +224,37 @@ final class ChatVerificationCoordinator {
|
|||||||
|
|
||||||
self.context.invalidateEncryptionCache(for: peerID)
|
self.context.invalidateEncryptionCache(for: peerID)
|
||||||
|
|
||||||
if self.context.cachedStablePeerID(for: peerID) == nil,
|
var authenticatedStablePeerID: PeerID?
|
||||||
let keyData = self.context.noiseSessionPublicKeyData(for: peerID) {
|
if let keyData = self.context.noiseSessionPublicKeyData(for: peerID) {
|
||||||
let stablePeerID = PeerID(hexData: keyData)
|
let stablePeerID = PeerID(hexData: keyData)
|
||||||
self.context.cacheStablePeerID(stablePeerID, for: peerID)
|
authenticatedStablePeerID = stablePeerID
|
||||||
|
if self.context.cachedStablePeerID(for: peerID) != stablePeerID {
|
||||||
|
// The freshly authenticated Noise key outranks a
|
||||||
|
// stale announce-derived alias.
|
||||||
|
self.context.cacheStablePeerID(stablePeerID, for: peerID)
|
||||||
|
}
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
"🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))…",
|
"🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))…",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A locally established session may have belonged to the
|
||||||
|
// peer's previous app process. The first ciphertext sent
|
||||||
|
// into that stale session is retained by MessageRouter;
|
||||||
|
// retry it now that this newly authenticated/replacement
|
||||||
|
// session can actually decrypt it.
|
||||||
|
var peerIDAliases = [peerID]
|
||||||
|
if let stablePeerID = authenticatedStablePeerID
|
||||||
|
?? self.context.cachedStablePeerID(for: peerID),
|
||||||
|
stablePeerID != peerID {
|
||||||
|
// Conversations can migrate from the ephemeral BLE ID
|
||||||
|
// to the authenticated Noise-key ID. Retry both aliases
|
||||||
|
// because either may own the retained outbox entry.
|
||||||
|
peerIDAliases.append(stablePeerID)
|
||||||
|
}
|
||||||
|
self.context.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases)
|
||||||
|
|
||||||
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
|
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
|
||||||
self.context.sendVerifyChallenge(
|
self.context.sendVerifyChallenge(
|
||||||
to: peerID,
|
to: peerID,
|
||||||
|
|||||||
@@ -6,11 +6,26 @@ import UIKit
|
|||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
struct ContentPeopleSheetModalPresentationState {
|
||||||
|
var isImagePreviewPresented = false
|
||||||
|
var isVerificationSheetPresented = false
|
||||||
|
var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? = nil
|
||||||
|
var isMediaPickerPresented = false
|
||||||
|
|
||||||
|
var hasPresentation: Bool {
|
||||||
|
isImagePreviewPresented
|
||||||
|
|| isVerificationSheetPresented
|
||||||
|
|| legacyPrivateMediaConsentRequest != nil
|
||||||
|
|| isMediaPickerPresented
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ContentPeopleSheetView: View {
|
struct ContentPeopleSheetView: View {
|
||||||
@EnvironmentObject private var appChromeModel: AppChromeModel
|
@EnvironmentObject private var appChromeModel: AppChromeModel
|
||||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||||
@EnvironmentObject private var verificationModel: VerificationModel
|
@EnvironmentObject private var verificationModel: VerificationModel
|
||||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@Binding var showSidebar: Bool
|
@Binding var showSidebar: Bool
|
||||||
@Binding var messageText: String
|
@Binding var messageText: String
|
||||||
@@ -23,6 +38,7 @@ struct ContentPeopleSheetView: View {
|
|||||||
var isTextFieldFocused: FocusState<Bool>.Binding
|
var isTextFieldFocused: FocusState<Bool>.Binding
|
||||||
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
|
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
|
||||||
@Binding var autocompleteDebounceTimer: Timer?
|
@Binding var autocompleteDebounceTimer: Timer?
|
||||||
|
@State private var showVerifySheet = false
|
||||||
@ThemedPalette private var palette
|
@ThemedPalette private var palette
|
||||||
|
|
||||||
let headerHeight: CGFloat
|
let headerHeight: CGFloat
|
||||||
@@ -35,6 +51,40 @@ struct ContentPeopleSheetView: View {
|
|||||||
@Binding var showMacImagePicker: Bool
|
@Binding var showMacImagePicker: Bool
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private var hasModalPresentation: Bool {
|
||||||
|
#if os(iOS)
|
||||||
|
let isMediaPickerPresented = showImagePicker
|
||||||
|
#else
|
||||||
|
let isMediaPickerPresented = showMacImagePicker
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return ContentPeopleSheetModalPresentationState(
|
||||||
|
isImagePreviewPresented: imagePreviewURL != nil,
|
||||||
|
isVerificationSheetPresented: showVerifySheet,
|
||||||
|
legacyPrivateMediaConsentRequest:
|
||||||
|
conversationUIModel.legacyPrivateMediaConsentRequest,
|
||||||
|
isMediaPickerPresented: isMediaPickerPresented
|
||||||
|
).hasPresentation
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bluetoothAlertBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
scenePhase == .active
|
||||||
|
&& appChromeModel.showBluetoothAlert
|
||||||
|
&& !hasModalPresentation
|
||||||
|
},
|
||||||
|
set: { isPresented in
|
||||||
|
guard !isPresented,
|
||||||
|
scenePhase == .active,
|
||||||
|
!hasModalPresentation else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appChromeModel.showBluetoothAlert = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest
|
let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -78,7 +128,8 @@ struct ContentPeopleSheetView: View {
|
|||||||
#endif
|
#endif
|
||||||
} else {
|
} else {
|
||||||
ContentPeopleListView(
|
ContentPeopleListView(
|
||||||
showSidebar: $showSidebar
|
showSidebar: $showSidebar,
|
||||||
|
showVerifySheet: $showVerifySheet
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,6 +233,17 @@ struct ContentPeopleSheetView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
.alert(
|
||||||
|
"content.alert.bluetooth_required.title",
|
||||||
|
isPresented: bluetoothAlertBinding
|
||||||
|
) {
|
||||||
|
Button("content.alert.bluetooth_required.settings") {
|
||||||
|
SystemSettings.bluetooth.open()
|
||||||
|
}
|
||||||
|
Button("common.ok", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(appChromeModel.bluetoothAlertMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,8 +258,7 @@ private struct ContentPeopleListView: View {
|
|||||||
@ThemedPalette private var palette
|
@ThemedPalette private var palette
|
||||||
|
|
||||||
@Binding var showSidebar: Bool
|
@Binding var showSidebar: Bool
|
||||||
|
@Binding var showVerifySheet: Bool
|
||||||
@State private var showVerifySheet = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|||||||
@@ -14,6 +14,61 @@ import AppKit
|
|||||||
#endif
|
#endif
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
|
||||||
|
struct ContentRootModalPresentationState {
|
||||||
|
var isPeopleSheetPresented = false
|
||||||
|
var isAppInfoPresented = false
|
||||||
|
var isFingerprintPresented = false
|
||||||
|
var isLocationChannelsSheetPresented = false
|
||||||
|
var isNoticesSheetPresented = false
|
||||||
|
var isImagePreviewPresented = false
|
||||||
|
var isVerificationSheetPresented = false
|
||||||
|
var isVoiceAlertPresented = false
|
||||||
|
var isScreenshotPrivacyAlertPresented = false
|
||||||
|
var isMediaPickerPresented = false
|
||||||
|
|
||||||
|
var hasPresentation: Bool {
|
||||||
|
isPeopleSheetPresented
|
||||||
|
|| isAppInfoPresented
|
||||||
|
|| isFingerprintPresented
|
||||||
|
|| isLocationChannelsSheetPresented
|
||||||
|
|| isNoticesSheetPresented
|
||||||
|
|| isImagePreviewPresented
|
||||||
|
|| isVerificationSheetPresented
|
||||||
|
|| isVoiceAlertPresented
|
||||||
|
|| isScreenshotPrivacyAlertPresented
|
||||||
|
|| isMediaPickerPresented
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ContentRootModalPresentationState {
|
||||||
|
@MainActor
|
||||||
|
init(
|
||||||
|
appChromeModel: AppChromeModel,
|
||||||
|
isPeopleSheetPresented: Bool = false,
|
||||||
|
isImagePreviewPresented: Bool = false,
|
||||||
|
isVerificationSheetPresented: Bool = false,
|
||||||
|
isVoiceAlertPresented: Bool = false,
|
||||||
|
isMediaPickerPresented: Bool = false
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
isPeopleSheetPresented: isPeopleSheetPresented,
|
||||||
|
isAppInfoPresented: appChromeModel.isAppInfoPresented,
|
||||||
|
isFingerprintPresented:
|
||||||
|
appChromeModel.showingFingerprintFor != nil,
|
||||||
|
isLocationChannelsSheetPresented:
|
||||||
|
appChromeModel.isLocationChannelsSheetPresented,
|
||||||
|
isNoticesSheetPresented:
|
||||||
|
appChromeModel.isNoticesSheetPresented,
|
||||||
|
isImagePreviewPresented: isImagePreviewPresented,
|
||||||
|
isVerificationSheetPresented: isVerificationSheetPresented,
|
||||||
|
isVoiceAlertPresented: isVoiceAlertPresented,
|
||||||
|
isScreenshotPrivacyAlertPresented:
|
||||||
|
appChromeModel.showScreenshotPrivacyWarning,
|
||||||
|
isMediaPickerPresented: isMediaPickerPresented
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// On macOS 14+, disables the default system focus ring on TextFields.
|
/// On macOS 14+, disables the default system focus ring on TextFields.
|
||||||
/// On earlier macOS versions and on iOS this is a no-op.
|
/// On earlier macOS versions and on iOS this is a no-op.
|
||||||
struct FocusEffectDisabledModifier: ViewModifier {
|
struct FocusEffectDisabledModifier: ViewModifier {
|
||||||
@@ -42,6 +97,7 @@ struct ContentView: View {
|
|||||||
@FocusState private var isTextFieldFocused: Bool
|
@FocusState private var isTextFieldFocused: Bool
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@Environment(\.appTheme) private var appTheme
|
@Environment(\.appTheme) private var appTheme
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var showSidebar = false
|
@State private var showSidebar = false
|
||||||
@State private var selectedMessageSender: String?
|
@State private var selectedMessageSender: String?
|
||||||
@State private var selectedMessageSenderID: PeerID?
|
@State private var selectedMessageSenderID: PeerID?
|
||||||
@@ -71,6 +127,45 @@ struct ContentView: View {
|
|||||||
|
|
||||||
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
|
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
|
||||||
|
|
||||||
|
private var isPeopleSheetPresented: Bool {
|
||||||
|
showSidebar || selectedPrivatePeerID != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasRootModalPresentation: Bool {
|
||||||
|
#if os(iOS)
|
||||||
|
let isMediaPickerPresented = showImagePicker
|
||||||
|
#else
|
||||||
|
let isMediaPickerPresented = showMacImagePicker
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return ContentRootModalPresentationState(
|
||||||
|
appChromeModel: appChromeModel,
|
||||||
|
isPeopleSheetPresented: isPeopleSheetPresented,
|
||||||
|
isImagePreviewPresented: imagePreviewURL != nil,
|
||||||
|
isVerificationSheetPresented: showVerifySheet,
|
||||||
|
isVoiceAlertPresented: voiceRecordingVM.showAlert,
|
||||||
|
isMediaPickerPresented: isMediaPickerPresented
|
||||||
|
).hasPresentation
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rootBluetoothAlertBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
scenePhase == .active
|
||||||
|
&& appChromeModel.showBluetoothAlert
|
||||||
|
&& !hasRootModalPresentation
|
||||||
|
},
|
||||||
|
set: { isPresented in
|
||||||
|
guard !isPresented,
|
||||||
|
scenePhase == .active,
|
||||||
|
!hasRootModalPresentation else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appChromeModel.showBluetoothAlert = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
mainContent
|
mainContent
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -107,11 +202,18 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.sheet(
|
.sheet(
|
||||||
isPresented: Binding(
|
isPresented: Binding(
|
||||||
get: { showSidebar || selectedPrivatePeerID != nil },
|
get: { isPeopleSheetPresented },
|
||||||
set: { isPresented in
|
set: { isPresented in
|
||||||
if !isPresented {
|
if !isPresented {
|
||||||
showSidebar = false
|
showSidebar = false
|
||||||
privateConversationModel.endConversation()
|
// Scene/background and Bluetooth-alert presentation
|
||||||
|
// reconciliation are not user requests to leave the
|
||||||
|
// conversation. Keep the selected DM so the sheet
|
||||||
|
// remains live when the app returns from Settings.
|
||||||
|
if scenePhase == .active,
|
||||||
|
!appChromeModel.showBluetoothAlert {
|
||||||
|
privateConversationModel.endConversation()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -222,7 +324,7 @@ struct ContentView: View {
|
|||||||
}, message: {
|
}, message: {
|
||||||
Text(voiceRecordingVM.state.alertMessage)
|
Text(voiceRecordingVM.state.alertMessage)
|
||||||
})
|
})
|
||||||
.alert("content.alert.bluetooth_required.title", isPresented: $appChromeModel.showBluetoothAlert) {
|
.alert("content.alert.bluetooth_required.title", isPresented: rootBluetoothAlertBinding) {
|
||||||
Button("content.alert.bluetooth_required.settings") {
|
Button("content.alert.bluetooth_required.settings") {
|
||||||
SystemSettings.bluetooth.open()
|
SystemSettings.bluetooth.open()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,9 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
|||||||
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 geohashPrivateMessageAccepted = true
|
var geohashPrivateMessageAccepted = true
|
||||||
|
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))
|
||||||
@@ -188,6 +191,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))
|
||||||
}
|
}
|
||||||
@@ -382,6 +401,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
|
||||||
@@ -443,6 +522,180 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
|||||||
#expect(isFailed(context.privateChats[peerID]?.first?.deliveryStatus))
|
#expect(isFailed(context.privateChats[peerID]?.first?.deliveryStatus))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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()
|
||||||
|
|||||||
@@ -133,11 +133,17 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
|||||||
// Delivery status
|
// Delivery status
|
||||||
var applyMessageDeliveryStatusResult = true
|
var applyMessageDeliveryStatusResult = true
|
||||||
var deliveryStatusesByMessageID: [String: DeliveryStatus] = [:]
|
var deliveryStatusesByMessageID: [String: DeliveryStatus] = [:]
|
||||||
private(set) var appliedDeliveryStatuses: [(messageID: String, status: DeliveryStatus)] = []
|
private(set) var appliedDeliveryStatuses: [
|
||||||
|
(messageID: String, status: DeliveryStatus, peerIDAliases: Set<PeerID>)
|
||||||
|
] = []
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
func applyAcknowledgedMessageDeliveryStatus(
|
||||||
appliedDeliveryStatuses.append((messageID, status))
|
_ messageID: String,
|
||||||
|
status: DeliveryStatus,
|
||||||
|
from peerIDAliases: Set<PeerID>
|
||||||
|
) -> Bool {
|
||||||
|
appliedDeliveryStatuses.append((messageID, status, peerIDAliases))
|
||||||
return applyMessageDeliveryStatusResult
|
return applyMessageDeliveryStatusResult
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +423,10 @@ struct ChatTransportEventCoordinatorContextTests {
|
|||||||
let peerID = PeerID(str: "99aabbccddeeff00")
|
let peerID = PeerID(str: "99aabbccddeeff00")
|
||||||
let noiseKey = Data(repeating: 0x44, count: 32)
|
let noiseKey = Data(repeating: 0x44, count: 32)
|
||||||
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
|
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
|
||||||
|
let stablePeerID = PeerID(hexData: noiseKey)
|
||||||
|
let staleStablePeerID = PeerID(hexData: Data(repeating: 0x55, count: 32))
|
||||||
|
context.cacheStablePeerID(staleStablePeerID, for: peerID)
|
||||||
|
context.noiseSessionKeysByPeerID[peerID] = noiseKey
|
||||||
|
|
||||||
// Inbound private message: decoded, handled, and delivery-acked.
|
// Inbound private message: decoded, handled, and delivery-acked.
|
||||||
let packet = PrivateMessagePacket(messageID: "pm-1", content: "hi there")
|
let packet = PrivateMessagePacket(messageID: "pm-1", content: "hi there")
|
||||||
@@ -438,6 +448,8 @@ struct ChatTransportEventCoordinatorContextTests {
|
|||||||
await drainMainActorTasks()
|
await drainMainActorTasks()
|
||||||
#expect(context.appliedDeliveryStatuses.count == 2)
|
#expect(context.appliedDeliveryStatuses.count == 2)
|
||||||
#expect(context.appliedDeliveryStatuses[0].messageID == "m-1")
|
#expect(context.appliedDeliveryStatuses[0].messageID == "m-1")
|
||||||
|
#expect(context.appliedDeliveryStatuses[0].peerIDAliases == [peerID, stablePeerID])
|
||||||
|
#expect(!context.appliedDeliveryStatuses[0].peerIDAliases.contains(staleStablePeerID))
|
||||||
if case .delivered(let to, _) = context.appliedDeliveryStatuses[0].status {
|
if case .delivered(let to, _) = context.appliedDeliveryStatuses[0].status {
|
||||||
#expect(to == "alice")
|
#expect(to == "alice")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
|||||||
private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)?
|
private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)?
|
||||||
private(set) var triggeredHandshakes: [PeerID] = []
|
private(set) var triggeredHandshakes: [PeerID] = []
|
||||||
private(set) var privateMediaAuthenticatedPeers: [PeerID] = []
|
private(set) var privateMediaAuthenticatedPeers: [PeerID] = []
|
||||||
|
private(set) var securePrivateMessageRetryAliases: [[PeerID]] = []
|
||||||
private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
|
|
||||||
@@ -118,6 +119,10 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
|||||||
privateMediaAuthenticatedPeers.append(peerID)
|
privateMediaAuthenticatedPeers.append(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) {
|
||||||
|
securePrivateMessageRetryAliases.append(peerIDAliases)
|
||||||
|
}
|
||||||
|
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||||
sentChallenges.append((peerID, noiseKeyHex, nonceA))
|
sentChallenges.append((peerID, noiseKeyHex, nonceA))
|
||||||
}
|
}
|
||||||
@@ -269,6 +274,10 @@ struct ChatVerificationCoordinatorContextTests {
|
|||||||
let peerID = PeerID(str: "1122334455667788")
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
let noiseKey = Data(repeating: 0x33, count: 32)
|
let noiseKey = Data(repeating: 0x33, count: 32)
|
||||||
context.noiseSessionKeysByPeerID[peerID] = noiseKey
|
context.noiseSessionKeysByPeerID[peerID] = noiseKey
|
||||||
|
context.cacheStablePeerID(
|
||||||
|
PeerID(hexData: Data(repeating: 0x44, count: 32)),
|
||||||
|
for: peerID
|
||||||
|
)
|
||||||
context.verifiedFingerprints = ["fp-verified"]
|
context.verifiedFingerprints = ["fp-verified"]
|
||||||
|
|
||||||
coordinator.setupNoiseCallbacks()
|
coordinator.setupNoiseCallbacks()
|
||||||
@@ -279,9 +288,11 @@ struct ChatVerificationCoordinatorContextTests {
|
|||||||
callbacks?.onPeerAuthenticated(peerID, "fp-verified")
|
callbacks?.onPeerAuthenticated(peerID, "fp-verified")
|
||||||
await waitForMainQueue()
|
await waitForMainQueue()
|
||||||
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
|
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
|
||||||
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
|
let stablePeerID = PeerID(hexData: noiseKey)
|
||||||
|
#expect(context.stablePeerIDCache[peerID] == stablePeerID)
|
||||||
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
|
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
|
||||||
#expect(context.privateMediaAuthenticatedPeers == [peerID])
|
#expect(context.privateMediaAuthenticatedPeers == [peerID])
|
||||||
|
#expect(context.securePrivateMessageRetryAliases == [[peerID, stablePeerID]])
|
||||||
|
|
||||||
// Handshake required -> handshaking status.
|
// Handshake required -> handshaking status.
|
||||||
callbacks?.onHandshakeRequired(peerID)
|
callbacks?.onHandshakeRequired(peerID)
|
||||||
|
|||||||
@@ -298,6 +298,137 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
}())
|
}())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticatedNoiseAckCannotClearAnotherPeersRetryState() async {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let intendedPeer = PeerID(str: "0102030405060708")
|
||||||
|
let otherPeer = PeerID(str: "1112131415161718")
|
||||||
|
let messageID = "noise-peer-bound-ack"
|
||||||
|
|
||||||
|
viewModel.seedPrivateChat(
|
||||||
|
[
|
||||||
|
BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
content: "Keep retrying",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: "Intended",
|
||||||
|
senderPeerID: viewModel.myPeerID,
|
||||||
|
deliveryStatus: .sent
|
||||||
|
)
|
||||||
|
],
|
||||||
|
for: intendedPeer
|
||||||
|
)
|
||||||
|
transport.reachablePeers.insert(intendedPeer)
|
||||||
|
viewModel.messageRouter.sendPrivate(
|
||||||
|
"Keep retrying",
|
||||||
|
to: intendedPeer,
|
||||||
|
recipientNickname: "Intended",
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 1)
|
||||||
|
|
||||||
|
// This models a decrypted Noise receipt: the transport-authenticated
|
||||||
|
// peer is authoritative, not the attacker-controlled message ID.
|
||||||
|
viewModel.didReceiveNoisePayload(
|
||||||
|
from: otherPeer,
|
||||||
|
type: .delivered,
|
||||||
|
payload: Data(messageID.utf8),
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
for _ in 0..<10 { await Task.yield() }
|
||||||
|
|
||||||
|
#expect(isSent(viewModel.conversations.deliveryStatus(forMessageID: messageID)))
|
||||||
|
viewModel.messageRouter.flushOutbox(for: intendedPeer)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
|
||||||
|
viewModel.didReceiveNoisePayload(
|
||||||
|
from: intendedPeer,
|
||||||
|
type: .delivered,
|
||||||
|
payload: Data(messageID.utf8),
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
for _ in 0..<10 { await Task.yield() }
|
||||||
|
|
||||||
|
#expect(isDelivered(viewModel.conversations.deliveryStatus(forMessageID: messageID)))
|
||||||
|
viewModel.messageRouter.flushOutbox(for: intendedPeer)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticatedNoiseAckClearsOnlyIntendedPeersPrivateMediaRetry() async throws {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let intendedPeer = PeerID(str: "0102030405060708")
|
||||||
|
let otherPeer = PeerID(str: "1112131415161718")
|
||||||
|
let fileName = "voice_0011223344556677.m4a"
|
||||||
|
let content = Data("voice".utf8)
|
||||||
|
|
||||||
|
transport.privateMediaPolicies[intendedPeer] = .encrypted
|
||||||
|
transport.privateMediaReceiptSessionGenerations[intendedPeer] = UUID()
|
||||||
|
viewModel.selectedPrivateChatPeer = intendedPeer
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(
|
||||||
|
context: viewModel,
|
||||||
|
prepareVoiceNotePacket: { _ in
|
||||||
|
BitchatFilePacket(
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "audio/mp4",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
},
|
||||||
|
transferIDFactory: { "\($0)-receipt-ack" }
|
||||||
|
)
|
||||||
|
viewModel.mediaTransferCoordinator = coordinator
|
||||||
|
|
||||||
|
let directory = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"scoped-media-ack-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let sourceURL = directory.appendingPathComponent(fileName)
|
||||||
|
try content.write(to: sourceURL)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: sourceURL)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{
|
||||||
|
transport.sentPrivateFiles.count == 1
|
||||||
|
&& coordinator.retainedReconnectRetryCount == 1
|
||||||
|
},
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
let messageID = try #require(
|
||||||
|
viewModel.privateChats[intendedPeer]?.first?.id
|
||||||
|
)
|
||||||
|
let transferID = try #require(
|
||||||
|
transport.sentPrivateFiles.first?.transferID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!viewModel.deliveryCoordinator
|
||||||
|
.updateAcknowledgedMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: .delivered(to: "Other", at: Date()),
|
||||||
|
from: [otherPeer]
|
||||||
|
))
|
||||||
|
#expect(coordinator.retainedReconnectRetryCount == 1)
|
||||||
|
#expect(transport.cancelledTransfers.isEmpty)
|
||||||
|
|
||||||
|
#expect(viewModel.deliveryCoordinator
|
||||||
|
.updateAcknowledgedMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: .delivered(to: "Intended", at: Date()),
|
||||||
|
from: [intendedPeer]
|
||||||
|
))
|
||||||
|
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||||
|
#expect(transport.cancelledTransfers == [transferID])
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func cleanupOldReadReceipts_removesReceiptIDsWithoutMessages() async {
|
func cleanupOldReadReceipts_removesReceiptIDsWithoutMessages() async {
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
@@ -577,12 +708,26 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
|
|||||||
var isStartupPhase = false
|
var isStartupPhase = false
|
||||||
private(set) var notifyUIChangedCount = 0
|
private(set) var notifyUIChangedCount = 0
|
||||||
private(set) var markedDeliveredMessageIDs: [String] = []
|
private(set) var markedDeliveredMessageIDs: [String] = []
|
||||||
|
private(set) var peerBoundDeliveredMessages: [(messageID: String, peerIDs: Set<PeerID>)] = []
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
store.setDeliveryStatus(status, forMessageID: messageID)
|
store.setDeliveryStatus(status, forMessageID: messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setDeliveryStatus(
|
||||||
|
_ status: DeliveryStatus,
|
||||||
|
forMessageID messageID: String,
|
||||||
|
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||||
|
) -> Bool {
|
||||||
|
store.setDeliveryStatus(
|
||||||
|
status,
|
||||||
|
forMessageID: messageID,
|
||||||
|
inDirectPeerAliases: peerIDs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||||
store.deliveryStatus(forMessageID: messageID)
|
store.deliveryStatus(forMessageID: messageID)
|
||||||
}
|
}
|
||||||
@@ -604,6 +749,24 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
|
|||||||
func markMessageDelivered(_ messageID: String) {
|
func markMessageDelivered(_ messageID: String) {
|
||||||
markedDeliveredMessageIDs.append(messageID)
|
markedDeliveredMessageIDs.append(messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
|
||||||
|
peerBoundDeliveredMessages.append((messageID, peerIDs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
|
||||||
|
peerIDs.contains { peerID in
|
||||||
|
contextMessages(for: peerID).contains { message in
|
||||||
|
message.id == messageID && message.senderPeerID == localPeerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let localPeerID = PeerID(str: "aabbccddeeff0011")
|
||||||
|
|
||||||
|
private func contextMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||||
|
store.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -685,7 +848,151 @@ struct ChatDeliveryCoordinatorContextTests {
|
|||||||
|
|
||||||
#expect(isRead(coordinator.deliveryStatus(for: messageID)))
|
#expect(isRead(coordinator.deliveryStatus(for: messageID)))
|
||||||
#expect(context.notifyUIChangedCount == 1)
|
#expect(context.notifyUIChangedCount == 1)
|
||||||
#expect(context.markedDeliveredMessageIDs == [messageID])
|
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
||||||
|
#expect(context.peerBoundDeliveredMessages.count == 1)
|
||||||
|
#expect(context.peerBoundDeliveredMessages[0].messageID == messageID)
|
||||||
|
#expect(context.peerBoundDeliveredMessages[0].peerIDs == [peerID])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticatedReceiptWithCollidingIDUpdatesOnlyAuthenticatedAliases() async {
|
||||||
|
let context = MockChatDeliveryContext()
|
||||||
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
|
let ephemeralPeerID = PeerID(str: "0102030405060708")
|
||||||
|
let stablePeerID = PeerID(hexData: Data(repeating: 0x08, count: 32))
|
||||||
|
let otherPeerID = PeerID(str: "1112131415161718")
|
||||||
|
let messageID = "authenticated-receipt-collision"
|
||||||
|
let mirroredMessage = makePrivateMessage(id: messageID, status: .sent)
|
||||||
|
|
||||||
|
context.store.append(mirroredMessage, to: .directPeer(ephemeralPeerID))
|
||||||
|
context.store.append(mirroredMessage, to: .directPeer(stablePeerID))
|
||||||
|
context.store.append(
|
||||||
|
makePrivateMessage(id: messageID, status: .sent),
|
||||||
|
to: .directPeer(otherPeerID)
|
||||||
|
)
|
||||||
|
context.store.append(makePublicMessage(id: messageID, status: .sent), to: .mesh)
|
||||||
|
|
||||||
|
let aliases: Set<PeerID> = [ephemeralPeerID, stablePeerID]
|
||||||
|
let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: .delivered(to: "Peer", at: Date()),
|
||||||
|
from: aliases
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(didUpdate)
|
||||||
|
#expect(isDelivered(
|
||||||
|
context.store.conversation(for: .directPeer(ephemeralPeerID))
|
||||||
|
.message(withID: messageID)?.deliveryStatus
|
||||||
|
))
|
||||||
|
#expect(isDelivered(
|
||||||
|
context.store.conversation(for: .directPeer(stablePeerID))
|
||||||
|
.message(withID: messageID)?.deliveryStatus
|
||||||
|
))
|
||||||
|
#expect(isSent(
|
||||||
|
context.store.conversation(for: .directPeer(otherPeerID))
|
||||||
|
.message(withID: messageID)?.deliveryStatus
|
||||||
|
))
|
||||||
|
#expect(isSent(
|
||||||
|
context.store.conversation(for: .mesh)
|
||||||
|
.message(withID: messageID)?.deliveryStatus
|
||||||
|
))
|
||||||
|
#expect(context.peerBoundDeliveredMessages.count == 1)
|
||||||
|
#expect(context.peerBoundDeliveredMessages[0].messageID == messageID)
|
||||||
|
#expect(context.peerBoundDeliveredMessages[0].peerIDs == aliases)
|
||||||
|
#expect(context.notifyUIChangedCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticatedReceiptRemainsScopedAfterPeerAliasMigration() async {
|
||||||
|
let context = MockChatDeliveryContext()
|
||||||
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
|
let ephemeralPeerID = PeerID(str: "2122232425262728")
|
||||||
|
let stablePeerID = PeerID(hexData: Data(repeating: 0x28, count: 32))
|
||||||
|
let otherPeerID = PeerID(str: "3132333435363738")
|
||||||
|
let messageID = "authenticated-receipt-after-migration"
|
||||||
|
|
||||||
|
context.store.append(
|
||||||
|
makePrivateMessage(id: messageID, status: .sent),
|
||||||
|
to: .directPeer(ephemeralPeerID)
|
||||||
|
)
|
||||||
|
context.store.append(
|
||||||
|
makePrivateMessage(id: messageID, status: .sent),
|
||||||
|
to: .directPeer(otherPeerID)
|
||||||
|
)
|
||||||
|
context.store.migrateConversation(
|
||||||
|
from: .directPeer(ephemeralPeerID),
|
||||||
|
to: .directPeer(stablePeerID)
|
||||||
|
)
|
||||||
|
|
||||||
|
let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: .read(by: "Peer", at: Date()),
|
||||||
|
from: [ephemeralPeerID, stablePeerID]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(didUpdate)
|
||||||
|
#expect(context.store.conversationsByID[.directPeer(ephemeralPeerID)] == nil)
|
||||||
|
#expect(isRead(
|
||||||
|
context.store.conversation(for: .directPeer(stablePeerID))
|
||||||
|
.message(withID: messageID)?.deliveryStatus
|
||||||
|
))
|
||||||
|
#expect(isSent(
|
||||||
|
context.store.conversation(for: .directPeer(otherPeerID))
|
||||||
|
.message(withID: messageID)?.deliveryStatus
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func receiptFromWrongPeerDoesNotUpdateOrTerminalizeOutgoingMessage() async {
|
||||||
|
let context = MockChatDeliveryContext()
|
||||||
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
|
let intendedPeer = PeerID(str: "0102030405060708")
|
||||||
|
let otherPeer = PeerID(str: "1112131415161718")
|
||||||
|
let messageID = "wrong-peer-receipt"
|
||||||
|
context.store.append(
|
||||||
|
makePrivateMessage(id: messageID, status: .sent),
|
||||||
|
to: .directPeer(intendedPeer)
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator.didReceiveReadReceipt(
|
||||||
|
ReadReceipt(
|
||||||
|
originalMessageID: messageID,
|
||||||
|
readerID: otherPeer,
|
||||||
|
readerNickname: "Other"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(isSent(coordinator.deliveryStatus(for: messageID)))
|
||||||
|
#expect(context.peerBoundDeliveredMessages.isEmpty)
|
||||||
|
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
||||||
|
#expect(context.notifyUIChangedCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func rejectedStaleReceiptDoesNotTerminalizeRetryState() async {
|
||||||
|
let context = MockChatDeliveryContext()
|
||||||
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "0102030405060708")
|
||||||
|
let messageID = "stale-receipt"
|
||||||
|
context.store.append(
|
||||||
|
makePrivateMessage(
|
||||||
|
id: messageID,
|
||||||
|
status: .read(by: "Peer", at: Date())
|
||||||
|
),
|
||||||
|
to: .directPeer(peerID)
|
||||||
|
)
|
||||||
|
|
||||||
|
let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: .delivered(to: "Peer", at: Date()),
|
||||||
|
from: [peerID]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!didUpdate)
|
||||||
|
#expect(isRead(coordinator.deliveryStatus(for: messageID)))
|
||||||
|
#expect(context.peerBoundDeliveredMessages.isEmpty)
|
||||||
|
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
||||||
|
#expect(context.notifyUIChangedCount == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
|
|||||||
@@ -755,6 +755,71 @@ struct ConversationStoreTests {
|
|||||||
#expect(statusChangedIDs.isEmpty)
|
#expect(statusChangedIDs.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("peer-scoped receipt updates only authenticated direct aliases")
|
||||||
|
@MainActor
|
||||||
|
func peerScopedReceiptUpdatesOnlyAuthenticatedDirectAliases() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let ephemeralPeer = PeerID(str: "0102030405060708")
|
||||||
|
let stablePeer = PeerID(hexData: Data(repeating: 0x08, count: 32))
|
||||||
|
let otherPeer = PeerID(str: "1112131415161718")
|
||||||
|
let ephemeral = ConversationID.directPeer(ephemeralPeer)
|
||||||
|
let stable = ConversationID.directPeer(stablePeer)
|
||||||
|
let other = ConversationID.directPeer(otherPeer)
|
||||||
|
let messageID = "scoped-receipt"
|
||||||
|
let mirrored = makeMessage(
|
||||||
|
id: messageID,
|
||||||
|
timestamp: 1,
|
||||||
|
isPrivate: true,
|
||||||
|
deliveryStatus: .sent
|
||||||
|
)
|
||||||
|
store.upsertByID(mirrored, in: ephemeral)
|
||||||
|
store.upsertByID(mirrored, in: stable)
|
||||||
|
store.upsertByID(
|
||||||
|
makeMessage(
|
||||||
|
id: messageID,
|
||||||
|
timestamp: 1,
|
||||||
|
isPrivate: true,
|
||||||
|
deliveryStatus: .sent
|
||||||
|
),
|
||||||
|
in: other
|
||||||
|
)
|
||||||
|
store.upsertByID(
|
||||||
|
makeMessage(id: messageID, timestamp: 1, deliveryStatus: .sent),
|
||||||
|
in: .mesh
|
||||||
|
)
|
||||||
|
|
||||||
|
var cancellables = Set<AnyCancellable>()
|
||||||
|
var publishedIDs: [ConversationID] = []
|
||||||
|
for id in [ephemeral, stable, other, .mesh] {
|
||||||
|
store.conversation(for: id).objectWillChange
|
||||||
|
.sink { publishedIDs.append(id) }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
var statusChangedIDs: [ConversationID] = []
|
||||||
|
store.changes
|
||||||
|
.sink { change in
|
||||||
|
if case .statusChanged(let id, messageID, _) = change,
|
||||||
|
messageID == "scoped-receipt" {
|
||||||
|
statusChangedIDs.append(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
let delivered = DeliveryStatus.delivered(to: "bob", at: Date())
|
||||||
|
#expect(store.setDeliveryStatus(
|
||||||
|
delivered,
|
||||||
|
forMessageID: messageID,
|
||||||
|
inDirectPeerAliases: [ephemeralPeer, stablePeer]
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(Set(publishedIDs) == Set([ephemeral, stable]))
|
||||||
|
#expect(Set(statusChangedIDs) == Set([ephemeral, stable]))
|
||||||
|
#expect(store.conversation(for: ephemeral).message(withID: messageID)?.deliveryStatus == delivered)
|
||||||
|
#expect(store.conversation(for: stable).message(withID: messageID)?.deliveryStatus == delivered)
|
||||||
|
#expect(store.conversation(for: other).message(withID: messageID)?.deliveryStatus == .sent)
|
||||||
|
#expect(store.conversation(for: .mesh).message(withID: messageID)?.deliveryStatus == .sent)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Invariant audit (field observability)
|
// MARK: - Invariant audit (field observability)
|
||||||
|
|
||||||
/// A store exercised through every intent family: public + geohash +
|
/// A store exercised through every intent family: public + geohash +
|
||||||
|
|||||||
@@ -65,11 +65,15 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
|
|||||||
var peerFingerprints: [PeerID: String] = [:]
|
var peerFingerprints: [PeerID: String] = [:]
|
||||||
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
||||||
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
|
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
|
||||||
|
var privateMediaReceiptSessionGenerations: [PeerID: UUID] = [:]
|
||||||
var persistDeletedPrivateMediaResult = true
|
var persistDeletedPrivateMediaResult = true
|
||||||
var deferDeletedPrivateMediaPersistence = false
|
var deferDeletedPrivateMediaPersistence = false
|
||||||
private var pendingDeletedPrivateMediaCompletions: [
|
private var pendingDeletedPrivateMediaCompletions: [
|
||||||
@MainActor (Bool) -> Void
|
@MainActor (Bool) -> Void
|
||||||
] = []
|
] = []
|
||||||
|
/// Optional synchronous hook for send-ordering tests (for example, an ack
|
||||||
|
/// arriving before the router's send call returns).
|
||||||
|
var onSendPrivateMessage: (@MainActor (_ messageID: String) -> Void)?
|
||||||
private let mockKeychain = MockKeychain()
|
private let mockKeychain = MockKeychain()
|
||||||
|
|
||||||
// MARK: - Transport Protocol Implementation
|
// MARK: - Transport Protocol Implementation
|
||||||
@@ -174,6 +178,11 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
|
|||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
sentPrivateMessages.append((content, peerID, recipientNickname, messageID))
|
sentPrivateMessages.append((content, peerID, recipientNickname, messageID))
|
||||||
|
if let onSendPrivateMessage {
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
onSendPrivateMessage(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
@@ -215,6 +224,12 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
|
|||||||
privateMediaPolicies[peerID] ?? .encrypted
|
privateMediaPolicies[peerID] ?? .encrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to peerID: PeerID
|
||||||
|
) -> UUID? {
|
||||||
|
privateMediaReceiptSessionGenerations[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
func resolvePrivateMediaSendPolicy(
|
func resolvePrivateMediaSendPolicy(
|
||||||
to peerID: PeerID,
|
to peerID: PeerID,
|
||||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
|
|||||||
@@ -690,12 +690,29 @@ private final class PerfDeliveryContext: ChatDeliveryContext {
|
|||||||
|
|
||||||
func notifyUIChanged() {}
|
func notifyUIChanged() {}
|
||||||
func markMessageDelivered(_ messageID: String) {}
|
func markMessageDelivered(_ messageID: String) {}
|
||||||
|
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {}
|
||||||
|
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
store.setDeliveryStatus(status, forMessageID: messageID)
|
store.setDeliveryStatus(status, forMessageID: messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setDeliveryStatus(
|
||||||
|
_ status: DeliveryStatus,
|
||||||
|
forMessageID messageID: String,
|
||||||
|
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||||
|
) -> Bool {
|
||||||
|
store.setDeliveryStatus(
|
||||||
|
status,
|
||||||
|
forMessageID: messageID,
|
||||||
|
inDirectPeerAliases: peerIDs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||||
store.deliveryStatus(forMessageID: messageID)
|
store.deliveryStatus(forMessageID: messageID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,134 @@ struct BridgeCourierServiceTests {
|
|||||||
#expect(confirmed.sealRequests.isEmpty)
|
#expect(confirmed.sealRequests.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func sameMessageIDIsScopedByRecipientAcrossRejectedActiveAndPersistedState() {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let rejectedKey = Fixture.randomKey()
|
||||||
|
let firstKey = Fixture.randomKey()
|
||||||
|
let secondKey = Fixture.randomKey()
|
||||||
|
let thirdKey = Fixture.randomKey()
|
||||||
|
let messageID = "recipient-scoped-collision"
|
||||||
|
|
||||||
|
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||||
|
fixture.sealResult = makeEnvelope(
|
||||||
|
recipientKey: rejectedKey,
|
||||||
|
ciphertext: Data(
|
||||||
|
repeating: 7,
|
||||||
|
count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
var rejectedResults: [Bool] = []
|
||||||
|
fixture.service.depositDrop(
|
||||||
|
content: "rejected",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: rejectedKey
|
||||||
|
) { rejectedResults.append($0) }
|
||||||
|
#expect(rejectedResults == [false])
|
||||||
|
|
||||||
|
fixture.sealResult = makeEnvelope(recipientKey: firstKey)
|
||||||
|
fixture.automaticPublishResult = nil
|
||||||
|
var firstResults: [Bool] = []
|
||||||
|
var secondResults: [Bool] = []
|
||||||
|
var duplicateFirstResults: [Bool] = []
|
||||||
|
fixture.service.depositDrop(
|
||||||
|
content: "first",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: firstKey
|
||||||
|
) { firstResults.append($0) }
|
||||||
|
fixture.service.depositDrop(
|
||||||
|
content: "second",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: secondKey
|
||||||
|
) { secondResults.append($0) }
|
||||||
|
fixture.service.depositDrop(
|
||||||
|
content: "first duplicate",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: firstKey
|
||||||
|
) { duplicateFirstResults.append($0) }
|
||||||
|
|
||||||
|
#expect(fixture.publishedEvents.count == 2)
|
||||||
|
#expect(fixture.pendingPublishCompletions.count == 2)
|
||||||
|
#expect(duplicateFirstResults == [false])
|
||||||
|
#expect(firstResults.isEmpty)
|
||||||
|
#expect(secondResults.isEmpty)
|
||||||
|
|
||||||
|
fixture.resolveNextPublish(true)
|
||||||
|
fixture.resolveNextPublish(true)
|
||||||
|
#expect(firstResults == [true])
|
||||||
|
#expect(secondResults == [true])
|
||||||
|
fixture.service.flushDedupSnapshot()
|
||||||
|
|
||||||
|
let relaunched = Fixture(
|
||||||
|
dedupStore: BridgeDropDedupStore(fileURL: fileURL)
|
||||||
|
)
|
||||||
|
relaunched.sealResult = makeEnvelope(recipientKey: thirdKey)
|
||||||
|
var relaunchResults: [Bool] = []
|
||||||
|
relaunched.service.depositDrop(
|
||||||
|
content: "first",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: firstKey
|
||||||
|
) { relaunchResults.append($0) }
|
||||||
|
relaunched.service.depositDrop(
|
||||||
|
content: "second",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: secondKey
|
||||||
|
) { relaunchResults.append($0) }
|
||||||
|
relaunched.service.depositDrop(
|
||||||
|
content: "third",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: thirdKey
|
||||||
|
) { relaunchResults.append($0) }
|
||||||
|
|
||||||
|
#expect(relaunchResults == [false, false, true])
|
||||||
|
#expect(relaunched.sealRequests.count == 1)
|
||||||
|
#expect(relaunched.sealRequests.first?.key == thirdKey)
|
||||||
|
#expect(relaunched.publishedEvents.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func legacyPublishedMessageIDIsWildcardUntilItsOriginalExpiry() {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
var date = Date()
|
||||||
|
let messageID = "legacy-wildcard"
|
||||||
|
let recipientKey = Fixture.randomKey()
|
||||||
|
let store = BridgeDropDedupStore(fileURL: fileURL)
|
||||||
|
store.save(BridgeDropDedupStore.Snapshot(
|
||||||
|
publishedDropKeys: [messageID: date],
|
||||||
|
seenDropEventIDs: [:]
|
||||||
|
))
|
||||||
|
|
||||||
|
let fixture = Fixture(
|
||||||
|
now: { date },
|
||||||
|
dedupStore: BridgeDropDedupStore(fileURL: fileURL)
|
||||||
|
)
|
||||||
|
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
|
||||||
|
var results: [Bool] = []
|
||||||
|
fixture.service.depositDrop(
|
||||||
|
content: "legacy",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: recipientKey
|
||||||
|
) { results.append($0) }
|
||||||
|
#expect(results == [false])
|
||||||
|
#expect(fixture.sealRequests.isEmpty)
|
||||||
|
|
||||||
|
date = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + 1)
|
||||||
|
fixture.service.depositDrop(
|
||||||
|
content: "after expiry",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: recipientKey
|
||||||
|
) { results.append($0) }
|
||||||
|
#expect(results == [false, true])
|
||||||
|
#expect(fixture.publishedEvents.count == 1)
|
||||||
|
fixture.service.flushDedupSnapshot()
|
||||||
|
|
||||||
|
let snapshot = BridgeDropDedupStore(fileURL: fileURL).load()
|
||||||
|
#expect(snapshot.publishedDropKeys[messageID] == nil)
|
||||||
|
#expect(snapshot.publishedDropKeys.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func panicWipeInvalidatesInFlightPublishCompletion() throws {
|
@Test func panicWipeInvalidatesInFlightPublishCompletion() throws {
|
||||||
let fileURL = FileManager.default.temporaryDirectory
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||||
@@ -297,8 +425,10 @@ struct BridgeCourierServiceTests {
|
|||||||
#expect(firstResults == [false])
|
#expect(firstResults == [false])
|
||||||
|
|
||||||
// The evicted first drop is deposit-able again (slot released).
|
// The evicted first drop is deposit-able again (slot released).
|
||||||
|
let sealCountBeforeRetry = fixture.sealRequests.count
|
||||||
fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key)
|
fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key)
|
||||||
#expect(fixture.service.pendingDrops.last?.dedupKey == firstID)
|
#expect(fixture.sealRequests.count == sealCountBeforeRetry + 1)
|
||||||
|
#expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func oversizeDropConsumesSlotInsteadOfChurning() {
|
@Test func oversizeDropConsumesSlotInsteadOfChurning() {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -79,18 +79,231 @@ struct MessageRouterTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func sendPrivate_connectedSendIsNotRetained() async {
|
func peerBoundDeliveryAckCannotClearAnotherPeersRetainedMessage() async {
|
||||||
|
let intendedPeer = PeerID(str: "0000000000000023")
|
||||||
|
let otherPeer = PeerID(str: "0000000000000024")
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.reachablePeers = [intendedPeer, otherPeer]
|
||||||
|
|
||||||
|
let router = MessageRouter(transports: [transport])
|
||||||
|
router.sendPrivate(
|
||||||
|
"Secret",
|
||||||
|
to: intendedPeer,
|
||||||
|
recipientNickname: "Intended",
|
||||||
|
messageID: "peer-bound-ack"
|
||||||
|
)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 1)
|
||||||
|
|
||||||
|
// Even a receipt arriving over another authenticated conversation
|
||||||
|
// must not terminalize the intended peer's retained retry.
|
||||||
|
router.markDelivered("peer-bound-ack", from: [otherPeer])
|
||||||
|
router.flushOutbox(for: intendedPeer)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
|
||||||
|
router.markDelivered("peer-bound-ack", from: [intendedPeer])
|
||||||
|
router.flushOutbox(for: intendedPeer)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func sendPrivate_connectedSecureSendRetainsUntilDeliveryAck() async {
|
||||||
let peerID = PeerID(str: "0000000000000007")
|
let peerID = PeerID(str: "0000000000000007")
|
||||||
let transport = MockTransport()
|
let transport = MockTransport()
|
||||||
transport.connectedPeers.insert(peerID)
|
transport.connectedPeers.insert(peerID)
|
||||||
transport.reachablePeers.insert(peerID)
|
transport.reachablePeers.insert(peerID)
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
|
||||||
let router = MessageRouter(transports: [transport])
|
let router = MessageRouter(transports: [transport])
|
||||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7")
|
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7")
|
||||||
#expect(transport.sentPrivateMessages.count == 1)
|
#expect(transport.sentPrivateMessages.count == 1)
|
||||||
|
|
||||||
router.flushOutbox(for: peerID)
|
// A newly authenticated/replacement session retries the retained
|
||||||
|
// message instead of losing the first ciphertext to a stale session.
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
|
||||||
|
router.markDelivered("m7")
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticationRetry_matchesStableOutboxAliasWithoutDoubleSending() async {
|
||||||
|
let shortPeerID = PeerID(str: "0000000000000019")
|
||||||
|
let stablePeerID = PeerID(hexData: Data(repeating: 0x19, count: 32))
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers.insert(stablePeerID)
|
||||||
|
transport.securePeers = [stablePeerID]
|
||||||
|
|
||||||
|
let router = MessageRouter(transports: [transport])
|
||||||
|
router.sendPrivate("Hello", to: stablePeerID, recipientNickname: "Peer", messageID: "alias-retry")
|
||||||
|
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID, stablePeerID])
|
||||||
|
|
||||||
|
#expect(transport.sentPrivateMessages.map(\.messageID) == ["alias-retry", "alias-retry"])
|
||||||
|
#expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == stablePeerID })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticationRetry_preservesFIFOAcrossSplitAliases() async {
|
||||||
|
let shortPeerID = PeerID(str: "0000000000000022")
|
||||||
|
let stablePeerID = PeerID(hexData: Data(repeating: 0x22, count: 32))
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers = [shortPeerID, stablePeerID]
|
||||||
|
transport.securePeers = [shortPeerID, stablePeerID]
|
||||||
|
let clock = MutableTestClock()
|
||||||
|
let router = MessageRouter(transports: [transport], now: { clock.now })
|
||||||
|
|
||||||
|
// The older message lives under the stable key, even though the auth
|
||||||
|
// callback supplies the ephemeral alias first.
|
||||||
|
router.sendPrivate("Older", to: stablePeerID, recipientNickname: "Peer", messageID: "fifo-old")
|
||||||
|
clock.now = clock.now.addingTimeInterval(1)
|
||||||
|
router.sendPrivate("Newer", to: shortPeerID, recipientNickname: "Peer", messageID: "fifo-new")
|
||||||
|
transport.resetRecordings()
|
||||||
|
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID])
|
||||||
|
|
||||||
|
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fifo-old", "fifo-new"])
|
||||||
|
#expect(transport.sentPrivateMessages.map(\.peerID) == [stablePeerID, shortPeerID])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticationRetry_doesNotDuplicateNormalPendingHandshakeSend() async {
|
||||||
|
let peerID = PeerID(str: "0000000000000020")
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers.insert(peerID)
|
||||||
|
transport.securePeers = []
|
||||||
|
|
||||||
|
let router = MessageRouter(transports: [transport])
|
||||||
|
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "normal-handshake")
|
||||||
#expect(transport.sentPrivateMessages.count == 1)
|
#expect(transport.sentPrivateMessages.count == 1)
|
||||||
|
|
||||||
|
// BLE owns this pending send and drains it after authentication. Once
|
||||||
|
// the session becomes secure, the router's targeted auth retry must
|
||||||
|
// stay silent instead of producing a second copy.
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||||
|
#expect(transport.sentPrivateMessages.count == 1)
|
||||||
|
|
||||||
|
router.markDelivered("normal-handshake")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticationRetry_scopesCollidingMessageIDsByPeer() async {
|
||||||
|
let securePeer = PeerID(str: "0000000000000025")
|
||||||
|
let pendingPeer = PeerID(str: "0000000000000026")
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers = [securePeer, pendingPeer]
|
||||||
|
transport.securePeers = [securePeer]
|
||||||
|
|
||||||
|
let router = MessageRouter(transports: [transport])
|
||||||
|
let promotedID = "collision-promoted"
|
||||||
|
let clearedID = "collision-cleared"
|
||||||
|
|
||||||
|
// Pending B then secure A: an ID-global marker falsely promotes B.
|
||||||
|
router.sendPrivate(
|
||||||
|
"pending promoted",
|
||||||
|
to: pendingPeer,
|
||||||
|
recipientNickname: "Pending",
|
||||||
|
messageID: promotedID
|
||||||
|
)
|
||||||
|
router.sendPrivate(
|
||||||
|
"secure promoted",
|
||||||
|
to: securePeer,
|
||||||
|
recipientNickname: "Secure",
|
||||||
|
messageID: promotedID
|
||||||
|
)
|
||||||
|
|
||||||
|
// Secure A then pending B: an ID-global removal falsely clears A.
|
||||||
|
router.sendPrivate(
|
||||||
|
"secure cleared",
|
||||||
|
to: securePeer,
|
||||||
|
recipientNickname: "Secure",
|
||||||
|
messageID: clearedID
|
||||||
|
)
|
||||||
|
router.sendPrivate(
|
||||||
|
"pending cleared",
|
||||||
|
to: pendingPeer,
|
||||||
|
recipientNickname: "Pending",
|
||||||
|
messageID: clearedID
|
||||||
|
)
|
||||||
|
|
||||||
|
transport.resetRecordings()
|
||||||
|
transport.securePeers = [securePeer, pendingPeer]
|
||||||
|
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [pendingPeer])
|
||||||
|
#expect(transport.sentPrivateMessages.isEmpty)
|
||||||
|
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [securePeer])
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
#expect(Set(transport.sentPrivateMessages.map(\.messageID)) == [promotedID, clearedID])
|
||||||
|
#expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == securePeer })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticationRetry_doesNotDuplicateMessageRequeuedByBLEForHandshake() async {
|
||||||
|
let peerID = PeerID(str: "0000000000000021")
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers.insert(peerID)
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
|
||||||
|
let router = MessageRouter(transports: [transport])
|
||||||
|
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "session-lost")
|
||||||
|
#expect(transport.sentPrivateMessages.count == 1)
|
||||||
|
|
||||||
|
// The session disappears before a normal outbox flush. That send is
|
||||||
|
// now owned by BLE's pending-handshake queue, so it clears the
|
||||||
|
// router's secure-auth retry marker.
|
||||||
|
transport.securePeers = []
|
||||||
|
router.flushOutbox(for: peerID)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
|
||||||
|
router.markDelivered("session-lost")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func sendPrivate_fastDeliveryAckCannotRaceAheadOfRetention() async {
|
||||||
|
let peerID = PeerID(str: "0000000000000017")
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers.insert(peerID)
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
|
||||||
|
let router = MessageRouter(transports: [transport])
|
||||||
|
transport.onSendPrivateMessage = { messageID in
|
||||||
|
router.markDelivered(messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "fast-ack")
|
||||||
|
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"])
|
||||||
|
|
||||||
|
transport.onSendPrivateMessage = nil
|
||||||
|
router.flushOutbox(for: peerID)
|
||||||
|
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func flushOutbox_synchronousAckDoesNotResurrectSnapshotEntry() async {
|
||||||
|
let peerID = PeerID(str: "0000000000000018")
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers.insert(peerID)
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
|
||||||
|
let router = MessageRouter(transports: [transport])
|
||||||
|
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "flush-fast-ack")
|
||||||
|
transport.onSendPrivateMessage = { messageID in
|
||||||
|
router.markDelivered(messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.flushOutbox(for: peerID)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
|
||||||
|
transport.onSendPrivateMessage = nil
|
||||||
|
router.flushOutbox(for: peerID)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -392,10 +605,31 @@ struct MessageRouterTests {
|
|||||||
#expect(transport.sentPrivateMessages.count == 11)
|
#expect(transport.sentPrivateMessages.count == 11)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// With an established secure session the connected fast-path stays
|
|
||||||
/// exactly as before: trusted outright, no retained copy, no courier.
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func sendPrivate_connectedWithSecureSessionIsTrustedOutright() async {
|
func authenticationRetry_capsActualSecureTransmissions() async {
|
||||||
|
let peerID = PeerID(str: "00000000000000ad")
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers.insert(peerID)
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
|
||||||
|
let router = MessageRouter(transports: [transport])
|
||||||
|
var dropped: [String] = []
|
||||||
|
router.onMessageDropped = { messageID, _ in dropped.append(messageID) }
|
||||||
|
|
||||||
|
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "secure-retry")
|
||||||
|
for _ in 0..<10 {
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(dropped == ["secure-retry"])
|
||||||
|
#expect(transport.sentPrivateMessages.count == 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With an established secure session the connected fast-path sends
|
||||||
|
/// immediately and never leaks to couriers, but retains a local encrypted
|
||||||
|
/// outbox copy until the peer confirms receipt.
|
||||||
|
@Test @MainActor
|
||||||
|
func sendPrivate_connectedWithSecureSessionRetainsLocallyWithoutCourier() async {
|
||||||
let peerID = PeerID(str: "00000000000000ab")
|
let peerID = PeerID(str: "00000000000000ab")
|
||||||
let peerKey = Data(repeating: 0xAB, count: 32)
|
let peerKey = Data(repeating: 0xAB, count: 32)
|
||||||
let courier = PeerID(str: "00000000000000cc")
|
let courier = PeerID(str: "00000000000000cc")
|
||||||
@@ -415,7 +649,11 @@ struct MessageRouterTests {
|
|||||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["cs2"])
|
#expect(transport.sentPrivateMessages.map(\.messageID) == ["cs2"])
|
||||||
#expect(transport.sentCourierMessages.isEmpty)
|
#expect(transport.sentCourierMessages.isEmpty)
|
||||||
router.flushOutbox(for: peerID)
|
router.flushOutbox(for: peerID)
|
||||||
#expect(transport.sentPrivateMessages.count == 1)
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
#expect(transport.sentCourierMessages.isEmpty)
|
||||||
|
router.markDelivered("cs2")
|
||||||
|
router.flushOutbox(for: peerID)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -527,6 +765,52 @@ struct MessageRouterTests {
|
|||||||
#expect(carried == ["bridge-ack"])
|
#expect(carried == ["bridge-ack"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func bridgeDepositsScopeCollidingMessageIDsByRecipient() async {
|
||||||
|
let firstRecipient = PeerID(str: "00000000000000b1")
|
||||||
|
let secondRecipient = PeerID(str: "00000000000000b2")
|
||||||
|
let firstKey = Data(repeating: 0xB1, count: 32)
|
||||||
|
let secondKey = Data(repeating: 0xB2, count: 32)
|
||||||
|
let recipientKeys = [
|
||||||
|
firstRecipient: firstKey,
|
||||||
|
secondRecipient: secondKey
|
||||||
|
]
|
||||||
|
let router = MessageRouter(
|
||||||
|
transports: [MockTransport()],
|
||||||
|
courierDirectory: CourierDirectory(
|
||||||
|
noiseKey: { recipientKeys[$0] },
|
||||||
|
isTrustedCourier: { _ in false }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
var requestedKeys: [Data] = []
|
||||||
|
var completions: [@MainActor (Bool) -> Void] = []
|
||||||
|
router.bridgeCourierDeposit = { _, _, recipientKey, completion in
|
||||||
|
requestedKeys.append(recipientKey)
|
||||||
|
completions.append(completion)
|
||||||
|
}
|
||||||
|
var carriedPeers: [PeerID] = []
|
||||||
|
router.onMessageCarried = { _, peerID in carriedPeers.append(peerID) }
|
||||||
|
|
||||||
|
router.sendPrivate(
|
||||||
|
"First",
|
||||||
|
to: firstRecipient,
|
||||||
|
recipientNickname: "First",
|
||||||
|
messageID: "bridge-collision"
|
||||||
|
)
|
||||||
|
router.sendPrivate(
|
||||||
|
"Second",
|
||||||
|
to: secondRecipient,
|
||||||
|
recipientNickname: "Second",
|
||||||
|
messageID: "bridge-collision"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(completions.count == 2)
|
||||||
|
#expect(Set(requestedKeys) == [firstKey, secondKey])
|
||||||
|
|
||||||
|
completions.forEach { $0(true) }
|
||||||
|
#expect(Set(carriedPeers) == [firstRecipient, secondRecipient])
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Outbox persistence
|
// MARK: - Outbox persistence
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -582,6 +866,82 @@ 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)
|
||||||
|
|
||||||
|
router.markDelivered(
|
||||||
|
"shared-locked-id",
|
||||||
|
from: [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
|
||||||
@@ -780,12 +1140,14 @@ struct MessageRouterTests {
|
|||||||
protectedDataUnavailable = false
|
protectedDataUnavailable = false
|
||||||
restoredStore.retryDeferredLoad() // captures unseen durable + known wake
|
restoredStore.retryDeferredLoad() // captures unseen durable + known wake
|
||||||
|
|
||||||
// Secure direct flush removes the wake message before recovery's
|
// A secure direct retry followed by its delivery ack removes the wake
|
||||||
// MainActor merge. It must remain removed, while the unseen durable
|
// message before recovery's MainActor merge. It must remain removed,
|
||||||
// message still arrives through the pending recovery claim.
|
// while the unseen durable message still arrives through the pending
|
||||||
|
// recovery claim.
|
||||||
transport.connectedPeers.insert(peerID)
|
transport.connectedPeers.insert(peerID)
|
||||||
transport.securePeers = [peerID]
|
transport.securePeers = [peerID]
|
||||||
router.flushOutbox(for: peerID)
|
router.flushOutbox(for: peerID)
|
||||||
|
router.markDelivered("recovery-gap-known")
|
||||||
await Task.yield()
|
await Task.yield()
|
||||||
await Task.yield()
|
await Task.yield()
|
||||||
|
|
||||||
@@ -856,7 +1218,8 @@ struct MessageRouterTests {
|
|||||||
restoredStore.retryDeferredLoad() // persists D+W and queues recovery
|
restoredStore.retryDeferredLoad() // persists D+W and queues recovery
|
||||||
transport.connectedPeers.insert(peerID)
|
transport.connectedPeers.insert(peerID)
|
||||||
transport.securePeers = [peerID]
|
transport.securePeers = [peerID]
|
||||||
router.flushOutbox(for: peerID) // removes W before queued callback
|
router.flushOutbox(for: peerID)
|
||||||
|
router.markDelivered("recovery-write-failure-known") // removes W before queued callback
|
||||||
|
|
||||||
// The gap save may remove W, but it must leave unseen D durable until
|
// The gap save may remove W, but it must leave unseen D durable until
|
||||||
// MessageRouter receives the pending recovery callback.
|
// MessageRouter receives the pending recovery callback.
|
||||||
|
|||||||
@@ -551,6 +551,89 @@ struct ViewSmokeTests {
|
|||||||
#expect(featureModels.privateConversationModel.selectedHeaderState?.headerPeerID == peerID)
|
#expect(featureModels.privateConversationModel.selectedHeaderState?.headerPeerID == peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Root Bluetooth alert waits for location and notices sheets")
|
||||||
|
func rootBluetoothAlertGuard_includesHeaderSheets() {
|
||||||
|
#expect(!ContentRootModalPresentationState().hasPresentation)
|
||||||
|
#expect(
|
||||||
|
ContentRootModalPresentationState(
|
||||||
|
isLocationChannelsSheetPresented: true
|
||||||
|
).hasPresentation
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
ContentRootModalPresentationState(
|
||||||
|
isNoticesSheetPresented: true
|
||||||
|
).hasPresentation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("People-sheet Bluetooth alert waits for local verification sheet")
|
||||||
|
func peopleSheetBluetoothAlertGuard_includesVerificationSheet() {
|
||||||
|
#expect(!ContentPeopleSheetModalPresentationState().hasPresentation)
|
||||||
|
#expect(
|
||||||
|
ContentPeopleSheetModalPresentationState(
|
||||||
|
isVerificationSheetPresented: true
|
||||||
|
).hasPresentation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Root Bluetooth alert waits for screenshot privacy alert")
|
||||||
|
@MainActor
|
||||||
|
func rootBluetoothAlertGuard_tracksScreenshotPrivacyState() {
|
||||||
|
let (viewModel, _, _) = makeSmokeViewModel()
|
||||||
|
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
!ContentRootModalPresentationState(
|
||||||
|
appChromeModel: featureModels.appChromeModel
|
||||||
|
).hasPresentation
|
||||||
|
)
|
||||||
|
|
||||||
|
featureModels.appChromeModel.showScreenshotPrivacyWarning = true
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
ContentRootModalPresentationState(
|
||||||
|
appChromeModel: featureModels.appChromeModel
|
||||||
|
).hasPresentation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("People-sheet Bluetooth alert waits for legacy media consent")
|
||||||
|
@MainActor
|
||||||
|
func peopleSheetBluetoothAlertGuard_tracksLegacyConsentState() async {
|
||||||
|
let (viewModel, _, _) = makeSmokeViewModel()
|
||||||
|
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
!ContentPeopleSheetModalPresentationState(
|
||||||
|
legacyPrivateMediaConsentRequest:
|
||||||
|
featureModels.conversationUIModel
|
||||||
|
.legacyPrivateMediaConsentRequest
|
||||||
|
).hasPresentation
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||||
|
for: PeerID(str: "5152535455565758"),
|
||||||
|
transferId: "legacy-consent-transfer",
|
||||||
|
messageID: "legacy-consent-message"
|
||||||
|
) { _ in }
|
||||||
|
defer { viewModel.cancelAllLegacyPrivateMediaConsents() }
|
||||||
|
|
||||||
|
let consentPropagated = await TestHelpers.waitUntil {
|
||||||
|
featureModels.conversationUIModel
|
||||||
|
.legacyPrivateMediaConsentRequest != nil
|
||||||
|
}
|
||||||
|
#expect(
|
||||||
|
consentPropagated
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
ContentPeopleSheetModalPresentationState(
|
||||||
|
legacyPrivateMediaConsentRequest:
|
||||||
|
featureModels.conversationUIModel
|
||||||
|
.legacyPrivateMediaConsentRequest
|
||||||
|
).hasPresentation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func geohashAndTextMessageViews_renderCoreBranches() {
|
func geohashAndTextMessageViews_renderCoreBranches() {
|
||||||
let (viewModel, _, _) = makeSmokeViewModel()
|
let (viewModel, _, _) = makeSmokeViewModel()
|
||||||
|
|||||||
Reference in New Issue
Block a user