mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 13:45:22 +00:00
Correlate private media delivery receipts
This commit is contained in:
@@ -154,3 +154,90 @@ struct BitchatFilePacket {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wire-compatible identity for private media exchanged by clients using the
|
||||||
|
/// current iOS entropy-bearing filenames, without extending the deployed file
|
||||||
|
/// TLV. Android clients reject unknown file tags, so eligible senders and
|
||||||
|
/// receivers derive the receipt key from fields already on the wire.
|
||||||
|
///
|
||||||
|
/// Locally-created image and voice-note filenames contain a UUID or live-voice
|
||||||
|
/// burst ID. Including the normalized direction keeps a reused filename
|
||||||
|
/// distinct across chats while allowing short and full Noise-key peer IDs to
|
||||||
|
/// converge. Android and older-iOS timestamp-only names remain ineligible and
|
||||||
|
/// retain their legacy random local IDs (transfer-compatible, no receipts).
|
||||||
|
enum PrivateMediaMessageIdentity {
|
||||||
|
private static let domain = Data("bitchat-private-media-message-v1".utf8)
|
||||||
|
private static let idPrefix = "media-"
|
||||||
|
private static let digestHexLength = 32
|
||||||
|
|
||||||
|
static func isStableID(_ candidate: String) -> Bool {
|
||||||
|
guard candidate.hasPrefix(idPrefix) else { return false }
|
||||||
|
let digest = candidate.dropFirst(idPrefix.count)
|
||||||
|
guard digest.utf8.count == digestHexLength else { return false }
|
||||||
|
return digest.utf8.allSatisfy { byte in
|
||||||
|
(UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte)
|
||||||
|
|| (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stableID(
|
||||||
|
senderPeerID: PeerID,
|
||||||
|
recipientPeerID: PeerID,
|
||||||
|
fileName: String?
|
||||||
|
) -> String? {
|
||||||
|
guard let fileName, !fileName.isEmpty else { return nil }
|
||||||
|
let leafName = (fileName as NSString).lastPathComponent
|
||||||
|
guard leafName == fileName else { return nil }
|
||||||
|
|
||||||
|
let path = leafName as NSString
|
||||||
|
let stem = path.deletingPathExtension
|
||||||
|
let fileExtension = path.pathExtension.lowercased()
|
||||||
|
switch true {
|
||||||
|
case stem.hasPrefix("img_"):
|
||||||
|
guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil }
|
||||||
|
case stem.hasPrefix("voice_"):
|
||||||
|
guard fileExtension == "m4a" else { return nil }
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let entropyToken = stem.split(separator: "_").last.map(String.init)
|
||||||
|
let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil
|
||||||
|
let voiceBurstID = stem.hasPrefix("voice_")
|
||||||
|
? String(stem.dropFirst("voice_".count))
|
||||||
|
: ""
|
||||||
|
let hasBurstEntropy = voiceBurstID.count == 16
|
||||||
|
&& voiceBurstID.allSatisfy(\.isHexDigit)
|
||||||
|
guard hasUUIDEntropy || hasBurstEntropy else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let fields = [
|
||||||
|
Data(senderPeerID.toShort().bare.utf8),
|
||||||
|
Data(recipientPeerID.toShort().bare.utf8),
|
||||||
|
Data(leafName.utf8)
|
||||||
|
]
|
||||||
|
var input = domain
|
||||||
|
for field in fields {
|
||||||
|
guard let length = UInt32(exactly: field.count) else { return nil }
|
||||||
|
var bigEndianLength = length.bigEndian
|
||||||
|
withUnsafeBytes(of: &bigEndianLength) {
|
||||||
|
input.append(contentsOf: $0)
|
||||||
|
}
|
||||||
|
input.append(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stableID(
|
||||||
|
for packet: BitchatFilePacket,
|
||||||
|
senderPeerID: PeerID,
|
||||||
|
recipientPeerID: PeerID
|
||||||
|
) -> String? {
|
||||||
|
stableID(
|
||||||
|
senderPeerID: senderPeerID,
|
||||||
|
recipientPeerID: recipientPeerID,
|
||||||
|
fileName: packet.fileName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,17 +32,63 @@ struct BLEFileTransferHandlerEnvironment {
|
|||||||
_ fallbackExtension: String?,
|
_ fallbackExtension: String?,
|
||||||
_ defaultPrefix: String
|
_ defaultPrefix: String
|
||||||
) -> URL?
|
) -> URL?
|
||||||
|
/// Checks the authenticated sender before any private-media disk work.
|
||||||
|
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
|
||||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||||
let updatePeerLastSeen: (PeerID) -> Void
|
let updatePeerLastSeen: (PeerID) -> Void
|
||||||
|
/// Re-acknowledges a stable private-media duplicate without saving or
|
||||||
|
/// re-delivering it. This lets a sender recover from a lost ACK.
|
||||||
|
let acknowledgePrivateMediaDuplicate: (_ messageID: String, _ peerID: PeerID) -> Void
|
||||||
/// Delivers `.messageReceived` to the UI as one main-actor hop.
|
/// Delivers `.messageReceived` to the UI as one main-actor hop.
|
||||||
let deliverMessage: (BitchatMessage) -> Void
|
let deliverMessage: (BitchatMessage) -> Void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Process-lifetime reservation cache for stable private-media IDs.
|
||||||
|
///
|
||||||
|
/// The first arrival reserves its ID before quota enforcement and commits it
|
||||||
|
/// only after durable save. Concurrent/retried arrivals are rejected before
|
||||||
|
/// they can create uniquified orphan files or churn the incoming-media quota.
|
||||||
|
private final class PrivateMediaArrivalDeduplicator {
|
||||||
|
enum Reservation {
|
||||||
|
case reserved
|
||||||
|
case pending
|
||||||
|
case accepted
|
||||||
|
}
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var accepted = BoundedIDSet(capacity: 4_096)
|
||||||
|
private var pending: Set<String> = []
|
||||||
|
|
||||||
|
func reserve(_ messageID: String) -> Reservation {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if accepted.contains(messageID) {
|
||||||
|
return .accepted
|
||||||
|
}
|
||||||
|
if pending.contains(messageID) {
|
||||||
|
return .pending
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.insert(messageID)
|
||||||
|
return .reserved
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish(_ messageID: String, accepted didAccept: Bool) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
pending.remove(messageID)
|
||||||
|
if didAccept {
|
||||||
|
accepted.insert(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
|
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
|
||||||
/// resolution, delivery planning, payload validation, quota-checked storage,
|
/// resolution, delivery planning, payload validation, quota-checked storage,
|
||||||
/// and UI delivery.
|
/// and UI delivery.
|
||||||
final class BLEFileTransferHandler {
|
final class BLEFileTransferHandler {
|
||||||
private let environment: BLEFileTransferHandlerEnvironment
|
private let environment: BLEFileTransferHandlerEnvironment
|
||||||
|
private let privateMediaArrivals = PrivateMediaArrivalDeduplicator()
|
||||||
|
|
||||||
init(environment: BLEFileTransferHandlerEnvironment) {
|
init(environment: BLEFileTransferHandlerEnvironment) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
@@ -129,6 +175,7 @@ final class BLEFileTransferHandler {
|
|||||||
env: BLEFileTransferHandlerEnvironment
|
env: BLEFileTransferHandlerEnvironment
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
|
||||||
|
let localPeerID = env.localPeerID()
|
||||||
let filePacket: BitchatFilePacket
|
let filePacket: BitchatFilePacket
|
||||||
let mime: MimeType
|
let mime: MimeType
|
||||||
switch BLEIncomingFileValidator.validate(payload: payload) {
|
switch BLEIncomingFileValidator.validate(payload: payload) {
|
||||||
@@ -149,6 +196,51 @@ final class BLEFileTransferHandler {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isPrivate, env.isPrivateMediaSenderBlocked(peerID) {
|
||||||
|
SecureLogger.debug(
|
||||||
|
"🚫 Dropping private media from blocked peer \(peerID.id.prefix(8))… before disk write",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageID = isPrivate
|
||||||
|
? PrivateMediaMessageIdentity.stableID(
|
||||||
|
for: filePacket,
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipientPeerID: localPeerID
|
||||||
|
)
|
||||||
|
: nil
|
||||||
|
if let messageID {
|
||||||
|
switch privateMediaArrivals.reserve(messageID) {
|
||||||
|
case .reserved:
|
||||||
|
break
|
||||||
|
case .pending:
|
||||||
|
// The first arrival has not reached durable storage yet.
|
||||||
|
// Coalesce this retry without ACKing so a failed first save
|
||||||
|
// remains retryable by the sender.
|
||||||
|
SecureLogger.debug(
|
||||||
|
"📁 Coalesced in-flight private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
case .accepted:
|
||||||
|
env.updatePeerLastSeen(peerID)
|
||||||
|
env.acknowledgePrivateMediaDuplicate(messageID, peerID)
|
||||||
|
SecureLogger.debug(
|
||||||
|
"📁 Ignored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var acceptedStableMedia = false
|
||||||
|
defer {
|
||||||
|
if let messageID {
|
||||||
|
privateMediaArrivals.finish(messageID, accepted: acceptedStableMedia)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// BCH-01-002: Enforce storage quota before saving
|
// BCH-01-002: Enforce storage quota before saving
|
||||||
env.enforceStorageQuota(filePacket.content.count)
|
env.enforceStorageQuota(filePacket.content.count)
|
||||||
|
|
||||||
@@ -167,6 +259,7 @@ final class BLEFileTransferHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
sender: senderNickname,
|
sender: senderNickname,
|
||||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
@@ -185,6 +278,7 @@ final class BLEFileTransferHandler {
|
|||||||
|
|
||||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||||
|
|
||||||
|
acceptedStableMedia = messageID != nil
|
||||||
env.deliverMessage(message)
|
env.deliverMessage(message)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2342,9 +2342,30 @@ final class BLEService: NSObject {
|
|||||||
defaultPrefix: defaultPrefix
|
defaultPrefix: defaultPrefix
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
isPrivateMediaSenderBlocked: { [weak self] peerID in
|
||||||
|
guard let self else { return false }
|
||||||
|
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID)
|
||||||
|
?? self.collectionsQueue.sync {
|
||||||
|
self.peerRegistry.info(for: peerID)?.noisePublicKey
|
||||||
|
}
|
||||||
|
guard let senderStaticKey else { return false }
|
||||||
|
return self.identityManager.isBlocked(
|
||||||
|
fingerprint: senderStaticKey.sha256Fingerprint()
|
||||||
|
)
|
||||||
|
},
|
||||||
updatePeerLastSeen: { [weak self] peerID in
|
updatePeerLastSeen: { [weak self] peerID in
|
||||||
self?.updatePeerLastSeen(peerID)
|
self?.updatePeerLastSeen(peerID)
|
||||||
},
|
},
|
||||||
|
acknowledgePrivateMediaDuplicate: { [weak self] messageID, peerID in
|
||||||
|
guard let self,
|
||||||
|
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID),
|
||||||
|
!self.identityManager.isBlocked(
|
||||||
|
fingerprint: senderStaticKey.sha256Fingerprint()
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
|
},
|
||||||
deliverMessage: { [weak self] message in
|
deliverMessage: { [weak self] message in
|
||||||
// Single main-actor hop delivering `.messageReceived`.
|
// Single main-actor hop delivering `.messageReceived`.
|
||||||
self?.emitTransportEvent(.messageReceived(message))
|
self?.emitTransportEvent(.messageReceived(message))
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
protocol ChatLiveVoiceContext: AnyObject {
|
protocol ChatLiveVoiceContext: AnyObject {
|
||||||
var nickname: String { get }
|
var nickname: String { get }
|
||||||
|
var myPeerID: PeerID { get }
|
||||||
var selectedPrivateChatPeer: PeerID? { get }
|
var selectedPrivateChatPeer: PeerID? { get }
|
||||||
/// Whether the public mesh timeline is what's on screen (autoplay gate
|
/// Whether the public mesh timeline is what's on screen (autoplay gate
|
||||||
/// for public bursts).
|
/// for public bursts).
|
||||||
@@ -30,6 +31,12 @@ protocol ChatLiveVoiceContext: AnyObject {
|
|||||||
func upsertPublicMeshMessage(_ message: BitchatMessage)
|
func upsertPublicMeshMessage(_ message: BitchatMessage)
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func removePrivateMessage(withID messageID: String) -> BitchatMessage?
|
func removePrivateMessage(withID messageID: String) -> BitchatMessage?
|
||||||
|
/// Records and sends the finalized note's read receipt after a live
|
||||||
|
/// bubble adopts its wire-derivable message ID.
|
||||||
|
func hasSentReadReceipt(_ messageID: String) -> Bool
|
||||||
|
@discardableResult
|
||||||
|
func markReadReceiptSent(_ messageID: String) -> Bool
|
||||||
|
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
/// Removes a message from whichever conversation holds it.
|
/// Removes a message from whichever conversation holds it.
|
||||||
func removeMessage(withID messageID: String, cleanupFile: Bool)
|
func removeMessage(withID messageID: String, cleanupFile: Bool)
|
||||||
/// Publishes who is currently talking live in the public mesh channel
|
/// Publishes who is currently talking live in the public mesh channel
|
||||||
@@ -257,8 +264,16 @@ final class ChatLiveVoiceCoordinator {
|
|||||||
guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false }
|
guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false }
|
||||||
let finished = entry.value
|
let finished = entry.value
|
||||||
|
|
||||||
|
// A DM live bubble starts before the finalized file exists and
|
||||||
|
// therefore has a receiver-local random ID. Adopt the finalized
|
||||||
|
// message's deterministic ID so delivery/read ACKs address the same
|
||||||
|
// row as the sender's media placeholder. Public notes retain their
|
||||||
|
// live-bubble ID because public transfers have no private receipts.
|
||||||
|
let replacementID = finished.scope == .directMessage
|
||||||
|
? message.id
|
||||||
|
: finished.messageID
|
||||||
let replacement = BitchatMessage(
|
let replacement = BitchatMessage(
|
||||||
id: finished.messageID,
|
id: replacementID,
|
||||||
sender: message.sender,
|
sender: message.sender,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
timestamp: finished.messageTimestamp,
|
timestamp: finished.messageTimestamp,
|
||||||
@@ -272,7 +287,31 @@ final class ChatLiveVoiceCoordinator {
|
|||||||
)
|
)
|
||||||
switch finished.scope {
|
switch finished.scope {
|
||||||
case .directMessage:
|
case .directMessage:
|
||||||
|
// Capture read state before rekeying. The user may have read the
|
||||||
|
// live bubble and navigated away before the finalized .m4a lands.
|
||||||
|
let shouldSendAdoptedReadReceipt =
|
||||||
|
context.hasSentReadReceipt(finished.messageID)
|
||||||
|
|| context.selectedPrivateChatPeer == finished.peerID
|
||||||
|
|
||||||
|
// Insert first so replacing the only row in a DM never
|
||||||
|
// transiently deletes its conversation, unread state, or current
|
||||||
|
// selection. Then remove the receiver-local live-bubble alias.
|
||||||
context.upsertPrivateMessage(replacement, in: finished.peerID)
|
context.upsertPrivateMessage(replacement, in: finished.peerID)
|
||||||
|
if replacementID != finished.messageID {
|
||||||
|
context.removePrivateMessage(withID: finished.messageID)
|
||||||
|
}
|
||||||
|
// The live bubble may already have emitted a receiver-local READ
|
||||||
|
// before the sender created its finalized media row. Re-emit once
|
||||||
|
// for the adopted stable ID now that the file has arrived.
|
||||||
|
if shouldSendAdoptedReadReceipt,
|
||||||
|
context.markReadReceiptSent(replacementID) {
|
||||||
|
let receipt = ReadReceipt(
|
||||||
|
originalMessageID: replacementID,
|
||||||
|
readerID: context.myPeerID,
|
||||||
|
readerNickname: context.nickname
|
||||||
|
)
|
||||||
|
context.sendMeshReadReceipt(receipt, to: finished.peerID)
|
||||||
|
}
|
||||||
case .publicMesh:
|
case .publicMesh:
|
||||||
context.upsertPublicMeshMessage(replacement)
|
context.upsertPublicMeshMessage(replacement)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,9 +171,17 @@ final class ChatMediaTransferCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let targetPeer = context.selectedPrivateChatPeer
|
let targetPeer = context.selectedPrivateChatPeer
|
||||||
|
let privateMessageID = targetPeer.flatMap { peerID in
|
||||||
|
PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: context.myPeerID,
|
||||||
|
recipientPeerID: peerID,
|
||||||
|
fileName: url.lastPathComponent
|
||||||
|
)
|
||||||
|
}
|
||||||
let message = enqueueMediaMessage(
|
let message = enqueueMediaMessage(
|
||||||
content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)",
|
content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)",
|
||||||
targetPeer: targetPeer
|
targetPeer: targetPeer,
|
||||||
|
messageID: privateMessageID
|
||||||
)
|
)
|
||||||
let messageID = message.id
|
let messageID = message.id
|
||||||
let transferId = makeTransferID(messageID: messageID)
|
let transferId = makeTransferID(messageID: messageID)
|
||||||
@@ -282,9 +290,17 @@ final class ChatMediaTransferCoordinator {
|
|||||||
|
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
let privateMessageID = targetPeer.flatMap { peerID in
|
||||||
|
PrivateMediaMessageIdentity.stableID(
|
||||||
|
for: prepared.packet,
|
||||||
|
senderPeerID: self.context.myPeerID,
|
||||||
|
recipientPeerID: peerID
|
||||||
|
)
|
||||||
|
}
|
||||||
let message = self.enqueueMediaMessage(
|
let message = self.enqueueMediaMessage(
|
||||||
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
||||||
targetPeer: targetPeer
|
targetPeer: targetPeer,
|
||||||
|
messageID: privateMessageID
|
||||||
)
|
)
|
||||||
let messageID = message.id
|
let messageID = message.id
|
||||||
let transferId = self.makeTransferID(messageID: messageID)
|
let transferId = self.makeTransferID(messageID: messageID)
|
||||||
@@ -316,12 +332,17 @@ final class ChatMediaTransferCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
|
func enqueueMediaMessage(
|
||||||
|
content: String,
|
||||||
|
targetPeer: PeerID?,
|
||||||
|
messageID: String? = nil
|
||||||
|
) -> BitchatMessage {
|
||||||
let timestamp = Date()
|
let timestamp = Date()
|
||||||
let message: BitchatMessage
|
let message: BitchatMessage
|
||||||
|
|
||||||
if let peerID = targetPeer {
|
if let peerID = targetPeer {
|
||||||
message = BitchatMessage(
|
message = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
sender: context.nickname,
|
sender: context.nickname,
|
||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
|
|||||||
@@ -467,6 +467,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a read receipt has already been recorded for `messageID`.
|
||||||
|
@MainActor
|
||||||
|
func hasSentReadReceipt(_ messageID: String) -> Bool {
|
||||||
|
sentReadReceipts.contains(messageID)
|
||||||
|
}
|
||||||
|
|
||||||
/// Records that a read receipt is being sent for `messageID`.
|
/// Records that a read receipt is being sent for `messageID`.
|
||||||
/// Returns `false` when one was already recorded — the caller must skip sending.
|
/// Returns `false` when one was already recorded — the caller must skip sending.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import BitFoundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
||||||
var nickname = "me"
|
var nickname = "me"
|
||||||
|
var myPeerID = PeerID(str: "0102030405060708")
|
||||||
var selectedPrivateChatPeer: PeerID?
|
var selectedPrivateChatPeer: PeerID?
|
||||||
var isViewingPublicMeshTimeline = false
|
var isViewingPublicMeshTimeline = false
|
||||||
var blockedPeers: Set<PeerID> = []
|
var blockedPeers: Set<PeerID> = []
|
||||||
@@ -23,7 +24,10 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
|||||||
private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = []
|
private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = []
|
||||||
private(set) var upsertedPublicMessages: [BitchatMessage] = []
|
private(set) var upsertedPublicMessages: [BitchatMessage] = []
|
||||||
private(set) var removedMessageIDs: [String] = []
|
private(set) var removedMessageIDs: [String] = []
|
||||||
|
private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
|
||||||
private(set) var talkerUpdates: [String?] = []
|
private(set) var talkerUpdates: [String?] = []
|
||||||
|
private(set) var privateMutationLog: [String] = []
|
||||||
|
private var readReceiptMessageIDs: Set<String> = []
|
||||||
|
|
||||||
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
|
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
|
||||||
func resolveNickname(for peerID: PeerID) -> String { "alice" }
|
func resolveNickname(for peerID: PeerID) -> String { "alice" }
|
||||||
@@ -31,6 +35,7 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
|||||||
func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) }
|
func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) }
|
||||||
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
|
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
|
||||||
upsertedMessages.append((message, peerID))
|
upsertedMessages.append((message, peerID))
|
||||||
|
privateMutationLog.append("upsert:\(message.id)")
|
||||||
}
|
}
|
||||||
func upsertPublicMeshMessage(_ message: BitchatMessage) {
|
func upsertPublicMeshMessage(_ message: BitchatMessage) {
|
||||||
upsertedPublicMessages.append(message)
|
upsertedPublicMessages.append(message)
|
||||||
@@ -38,8 +43,18 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
|
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
|
||||||
removedMessageIDs.append(messageID)
|
removedMessageIDs.append(messageID)
|
||||||
|
privateMutationLog.append("remove:\(messageID)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func hasSentReadReceipt(_ messageID: String) -> Bool {
|
||||||
|
readReceiptMessageIDs.contains(messageID)
|
||||||
|
}
|
||||||
|
func markReadReceiptSent(_ messageID: String) -> Bool {
|
||||||
|
readReceiptMessageIDs.insert(messageID).inserted
|
||||||
|
}
|
||||||
|
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
|
sentReadReceipts.append((receipt, peerID))
|
||||||
|
}
|
||||||
func removeMessage(withID messageID: String, cleanupFile: Bool) {
|
func removeMessage(withID messageID: String, cleanupFile: Bool) {
|
||||||
removedMessageIDs.append(messageID)
|
removedMessageIDs.append(messageID)
|
||||||
}
|
}
|
||||||
@@ -151,17 +166,29 @@ struct ChatLiveVoiceCoordinatorTests {
|
|||||||
|
|
||||||
@Test func absorbsFinalizedNoteIntoLiveBubble() throws {
|
@Test func absorbsFinalizedNoteIntoLiveBubble() throws {
|
||||||
let context = MockChatLiveVoiceContext()
|
let context = MockChatLiveVoiceContext()
|
||||||
|
context.selectedPrivateChatPeer = peer
|
||||||
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
||||||
let burstID = makeBurstID(0xB2)
|
let burstID = makeBurstID(0xB2)
|
||||||
let hex = burstID.hexEncodedString()
|
let hex = burstID.hexEncodedString()
|
||||||
|
let fileName = "voice_\(hex).m4a"
|
||||||
|
let stableMessageID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: peer,
|
||||||
|
recipientPeerID: context.myPeerID,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
|
||||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer)
|
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer)
|
||||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
|
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
|
||||||
let bubble = try #require(context.handledPrivateMessages.first)
|
let bubble = try #require(context.handledPrivateMessages.first)
|
||||||
|
// The user read the live bubble, then left before the finalized file
|
||||||
|
// arrived. Stable-ID adoption must preserve that read state.
|
||||||
|
#expect(context.markReadReceiptSent(bubble.id))
|
||||||
|
context.selectedPrivateChatPeer = nil
|
||||||
|
|
||||||
let note = BitchatMessage(
|
let note = BitchatMessage(
|
||||||
|
id: stableMessageID,
|
||||||
sender: "alice",
|
sender: "alice",
|
||||||
content: "[voice] voice_\(hex).m4a",
|
content: "[voice] \(fileName)",
|
||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
@@ -170,12 +197,21 @@ struct ChatLiveVoiceCoordinatorTests {
|
|||||||
)
|
)
|
||||||
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
||||||
|
|
||||||
// The note replaced the live bubble in place: same message ID, new
|
// The finalized note adopts the sender-correlatable ID, removes the
|
||||||
// content, partial capture deleted.
|
// receiver-local live ID, and emits a fresh READ now that the sender
|
||||||
|
// has created its finalized media row.
|
||||||
let replacement = try #require(context.upsertedMessages.last)
|
let replacement = try #require(context.upsertedMessages.last)
|
||||||
#expect(replacement.message.id == bubble.id)
|
#expect(replacement.message.id == stableMessageID)
|
||||||
#expect(replacement.message.content == note.content)
|
#expect(replacement.message.content == note.content)
|
||||||
#expect(replacement.peerID == peer)
|
#expect(replacement.peerID == peer)
|
||||||
|
#expect(context.removedMessageIDs.contains(bubble.id))
|
||||||
|
#expect(Array(context.privateMutationLog.suffix(2)) == [
|
||||||
|
"upsert:\(stableMessageID)",
|
||||||
|
"remove:\(bubble.id)"
|
||||||
|
])
|
||||||
|
#expect(context.sentReadReceipts.count == 1)
|
||||||
|
#expect(context.sentReadReceipts.first?.receipt.originalMessageID == stableMessageID)
|
||||||
|
#expect(context.sentReadReceipts.first?.peerID == peer)
|
||||||
// The promoted partial capture is deleted in favor of the note.
|
// The promoted partial capture is deleted in favor of the note.
|
||||||
let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
|
let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
|
||||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||||
@@ -449,7 +485,8 @@ struct ChatLiveVoiceCoordinatorTests {
|
|||||||
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
|
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
|
||||||
)
|
)
|
||||||
#expect(coordinator.absorbFinalizedVoiceNote(dmNote))
|
#expect(coordinator.absorbFinalizedVoiceNote(dmNote))
|
||||||
#expect(try #require(context.upsertedMessages.last).message.id == dmBubble.id)
|
#expect(try #require(context.upsertedMessages.last).message.id == dmNote.id)
|
||||||
|
#expect(context.removedMessageIDs.contains(dmBubble.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func finalizedNoteBindsToItsAuthenticatedSender() throws {
|
@Test func finalizedNoteBindsToItsAuthenticatedSender() throws {
|
||||||
@@ -479,8 +516,9 @@ struct ChatLiveVoiceCoordinatorTests {
|
|||||||
)
|
)
|
||||||
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
||||||
let replacement = try #require(context.upsertedMessages.last)
|
let replacement = try #require(context.upsertedMessages.last)
|
||||||
#expect(replacement.message.id == victimBubble.id)
|
#expect(replacement.message.id == note.id)
|
||||||
#expect(replacement.peerID == peer)
|
#expect(replacement.peerID == peer)
|
||||||
|
#expect(context.removedMessageIDs.contains(victimBubble.id))
|
||||||
|
|
||||||
// The attacker's note can only ever claim the attacker's own bubble.
|
// The attacker's note can only ever claim the attacker's own bubble.
|
||||||
let attackerNote = BitchatMessage(
|
let attackerNote = BitchatMessage(
|
||||||
@@ -489,8 +527,9 @@ struct ChatLiveVoiceCoordinatorTests {
|
|||||||
)
|
)
|
||||||
#expect(coordinator.absorbFinalizedVoiceNote(attackerNote))
|
#expect(coordinator.absorbFinalizedVoiceNote(attackerNote))
|
||||||
let attackerReplacement = try #require(context.upsertedMessages.last)
|
let attackerReplacement = try #require(context.upsertedMessages.last)
|
||||||
#expect(attackerReplacement.message.id == attackerBubble.id)
|
#expect(attackerReplacement.message.id == attackerNote.id)
|
||||||
#expect(attackerReplacement.peerID == attacker)
|
#expect(attackerReplacement.peerID == attacker)
|
||||||
|
#expect(context.removedMessageIDs.contains(attackerBubble.id))
|
||||||
|
|
||||||
// Both registry entries are consumed — nothing left to hijack.
|
// Both registry entries are consumed — nothing left to hijack.
|
||||||
#expect(!coordinator.absorbFinalizedVoiceNote(note))
|
#expect(!coordinator.absorbFinalizedVoiceNote(note))
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
import Testing
|
import Testing
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
// MARK: - Mock Context
|
// MARK: - Mock Context
|
||||||
@@ -84,7 +89,11 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mesh file transfer
|
// Mesh file transfer
|
||||||
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
|
private(set) var privateFileSends: [(
|
||||||
|
packet: BitchatFilePacket,
|
||||||
|
peerID: PeerID,
|
||||||
|
transferId: String
|
||||||
|
)] = []
|
||||||
private(set) var privateFileLegacyAllowances: [Bool] = []
|
private(set) var privateFileLegacyAllowances: [Bool] = []
|
||||||
private(set) var broadcastFileSends: [String] = []
|
private(set) var broadcastFileSends: [String] = []
|
||||||
private(set) var cancelledTransfers: [String] = []
|
private(set) var cancelledTransfers: [String] = []
|
||||||
@@ -149,7 +158,7 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|||||||
transferId: String,
|
transferId: String,
|
||||||
allowLegacyFallback: Bool
|
allowLegacyFallback: Bool
|
||||||
) {
|
) {
|
||||||
privateFileSends.append((peerID, transferId))
|
privateFileSends.append((packet, peerID, transferId))
|
||||||
privateFileLegacyAllowances.append(allowLegacyFallback)
|
privateFileLegacyAllowances.append(allowLegacyFallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,19 +176,8 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
|||||||
private var started = false
|
private var started = false
|
||||||
private var released = false
|
private var released = false
|
||||||
private var finished = false
|
private var finished = false
|
||||||
private let packet: BitchatFilePacket
|
|
||||||
|
|
||||||
init() {
|
func prepare(_ url: URL) throws -> BitchatFilePacket {
|
||||||
let content = Data("voice".utf8)
|
|
||||||
packet = BitchatFilePacket(
|
|
||||||
fileName: "paused.m4a",
|
|
||||||
fileSize: UInt64(content.count),
|
|
||||||
mimeType: "audio/mp4",
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepare(_: URL) throws -> BitchatFilePacket {
|
|
||||||
condition.lock()
|
condition.lock()
|
||||||
started = true
|
started = true
|
||||||
condition.broadcast()
|
condition.broadcast()
|
||||||
@@ -189,7 +187,13 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
|||||||
finished = true
|
finished = true
|
||||||
condition.broadcast()
|
condition.broadcast()
|
||||||
condition.unlock()
|
condition.unlock()
|
||||||
return packet
|
let content = Data("voice".utf8)
|
||||||
|
return BitchatFilePacket(
|
||||||
|
fileName: url.lastPathComponent,
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "audio/mp4",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasStarted: Bool {
|
var hasStarted: Bool {
|
||||||
@@ -339,6 +343,57 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func privateVoiceNoteUsesWireDerivableMessageID() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("voice_receipt_\(UUID().uuidString).m4a")
|
||||||
|
try Data("voice".utf8).write(to: url)
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: url)
|
||||||
|
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ context.privateFileSends.count == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
let message = try #require(context.privateChats[peerID]?.first)
|
||||||
|
let sentPacket = try #require(context.privateFileSends.first?.packet)
|
||||||
|
#expect(message.id == PrivateMediaMessageIdentity.stableID(
|
||||||
|
for: sentPacket,
|
||||||
|
senderPeerID: context.myPeerID,
|
||||||
|
recipientPeerID: peerID
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func privateImageUsesWireDerivableMessageID() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "99aabbccddeeff00")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
let sourceURL = try makeCoordinatorTestImageURL()
|
||||||
|
defer { try? FileManager.default.removeItem(at: sourceURL) }
|
||||||
|
|
||||||
|
coordinator.sendImage(from: sourceURL)
|
||||||
|
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ context.privateFileSends.count == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
let message = try #require(context.privateChats[peerID]?.first)
|
||||||
|
let sentPacket = try #require(context.privateFileSends.first?.packet)
|
||||||
|
#expect(message.id == PrivateMediaMessageIdentity.stableID(
|
||||||
|
for: sentPacket,
|
||||||
|
senderPeerID: context.myPeerID,
|
||||||
|
recipientPeerID: peerID
|
||||||
|
))
|
||||||
|
coordinator.cleanupLocalFile(forMessage: message)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
|
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
|
||||||
let context = MockChatMediaTransferContext()
|
let context = MockChatMediaTransferContext()
|
||||||
@@ -584,3 +639,35 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(context.privateFileSends.isEmpty)
|
#expect(context.privateFileSends.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeCoordinatorTestImageURL() throws -> URL {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("media-receipt-\(UUID().uuidString).png")
|
||||||
|
#if os(iOS)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 32, height: 32))
|
||||||
|
let image = renderer.image { context in
|
||||||
|
UIColor.systemBlue.setFill()
|
||||||
|
context.fill(CGRect(x: 0, y: 0, width: 32, height: 32))
|
||||||
|
}
|
||||||
|
guard let data = image.pngData() else {
|
||||||
|
throw CoordinatorMediaTestError.imageEncodingFailed
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
let image = NSImage(size: NSSize(width: 32, height: 32))
|
||||||
|
image.lockFocus()
|
||||||
|
NSColor.systemBlue.setFill()
|
||||||
|
NSRect(x: 0, y: 0, width: 32, height: 32).fill()
|
||||||
|
image.unlockFocus()
|
||||||
|
guard let tiff = image.tiffRepresentation,
|
||||||
|
let bitmap = NSBitmapImageRep(data: tiff),
|
||||||
|
let data = bitmap.representation(using: .png, properties: [:]) else {
|
||||||
|
throw CoordinatorMediaTestError.imageEncodingFailed
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CoordinatorMediaTestError: Error {
|
||||||
|
case imageEncodingFailed
|
||||||
|
}
|
||||||
|
|||||||
@@ -220,26 +220,64 @@ struct ChatTransportEventCoordinatorContextTests {
|
|||||||
func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async {
|
func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async {
|
||||||
let context = MockChatTransportEventContext()
|
let context = MockChatTransportEventContext()
|
||||||
let coordinator = ChatTransportEventCoordinator(context: context)
|
let coordinator = ChatTransportEventCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
|
||||||
// Blocked messages are dropped before any handling.
|
// Blocked messages are dropped before any handling.
|
||||||
context.blockedMessageIDs = ["blocked"]
|
context.blockedMessageIDs = ["blocked", "blocked-private"]
|
||||||
coordinator.didReceiveMessage(makeMessage(id: "blocked"))
|
coordinator.didReceiveMessage(makeMessage(id: "blocked"))
|
||||||
|
coordinator.didReceiveMessage(makeMessage(
|
||||||
|
id: "blocked-private",
|
||||||
|
isPrivate: true,
|
||||||
|
senderPeerID: peerID
|
||||||
|
))
|
||||||
// Empty public content is dropped too.
|
// Empty public content is dropped too.
|
||||||
coordinator.didReceiveMessage(makeMessage(id: "empty", content: " "))
|
coordinator.didReceiveMessage(makeMessage(id: "empty", content: " "))
|
||||||
await drainMainActorTasks()
|
await drainMainActorTasks()
|
||||||
#expect(context.handledPublicMessages.isEmpty)
|
#expect(context.handledPublicMessages.isEmpty)
|
||||||
#expect(context.handledPrivateMessages.isEmpty)
|
#expect(context.handledPrivateMessages.isEmpty)
|
||||||
#expect(context.mentionCheckedMessageIDs.isEmpty)
|
#expect(context.mentionCheckedMessageIDs.isEmpty)
|
||||||
|
#expect(context.meshDeliveryAcks.isEmpty)
|
||||||
|
|
||||||
// Private goes to the private handler, public to the public handler;
|
// Private goes to the private handler, public to the public handler;
|
||||||
// both get mention checks and haptics.
|
// both get mention checks and haptics. Only current-iOS media with a
|
||||||
coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true))
|
// cross-device stable ID and authenticated sender gets the
|
||||||
|
// durable-arrival acknowledgement. Legacy/Android-style random IDs
|
||||||
|
// remain transfer-compatible but cannot correlate receipts.
|
||||||
|
let stableMediaID = "media-\(String(repeating: "a", count: 32))"
|
||||||
|
coordinator.didReceiveMessage(makeMessage(
|
||||||
|
id: stableMediaID,
|
||||||
|
isPrivate: true,
|
||||||
|
senderPeerID: peerID
|
||||||
|
))
|
||||||
|
coordinator.didReceiveMessage(makeMessage(
|
||||||
|
id: "legacy-media",
|
||||||
|
isPrivate: true,
|
||||||
|
senderPeerID: peerID
|
||||||
|
))
|
||||||
|
coordinator.didReceiveMessage(makeMessage(id: "pm-missing-sender", isPrivate: true))
|
||||||
coordinator.didReceiveMessage(makeMessage(id: "pub"))
|
coordinator.didReceiveMessage(makeMessage(id: "pub"))
|
||||||
await drainMainActorTasks()
|
await drainMainActorTasks()
|
||||||
#expect(context.handledPrivateMessages.map(\.id) == ["pm"])
|
#expect(context.handledPrivateMessages.map(\.id) == [
|
||||||
|
stableMediaID,
|
||||||
|
"legacy-media",
|
||||||
|
"pm-missing-sender"
|
||||||
|
])
|
||||||
#expect(context.handledPublicMessages.map(\.id) == ["pub"])
|
#expect(context.handledPublicMessages.map(\.id) == ["pub"])
|
||||||
#expect(context.mentionCheckedMessageIDs == ["pm", "pub"])
|
#expect(context.mentionCheckedMessageIDs == [
|
||||||
#expect(context.hapticMessageIDs == ["pm", "pub"])
|
stableMediaID,
|
||||||
|
"legacy-media",
|
||||||
|
"pm-missing-sender",
|
||||||
|
"pub"
|
||||||
|
])
|
||||||
|
#expect(context.hapticMessageIDs == [
|
||||||
|
stableMediaID,
|
||||||
|
"legacy-media",
|
||||||
|
"pm-missing-sender",
|
||||||
|
"pub"
|
||||||
|
])
|
||||||
|
#expect(context.meshDeliveryAcks.count == 1)
|
||||||
|
#expect(context.meshDeliveryAcks.first?.messageID == stableMediaID)
|
||||||
|
#expect(context.meshDeliveryAcks.first?.peerID == peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
|
|||||||
@@ -736,7 +736,7 @@ struct PrivateMediaEndToEndTests {
|
|||||||
+ marker
|
+ marker
|
||||||
+ Data(repeating: 0x4A, count: 6 * 1024)
|
+ Data(repeating: 0x4A, count: 6 * 1024)
|
||||||
try await assertPrivateMediaRoundTrip(
|
try await assertPrivateMediaRoundTrip(
|
||||||
fileName: "private.jpg",
|
fileName: "img_20260725_120000_11111111-1111-1111-1111-111111111111.jpg",
|
||||||
mimeType: "image/jpeg",
|
mimeType: "image/jpeg",
|
||||||
content: content,
|
content: content,
|
||||||
marker: marker,
|
marker: marker,
|
||||||
@@ -1007,6 +1007,17 @@ struct PrivateMediaEndToEndTests {
|
|||||||
#expect(message.isPrivate)
|
#expect(message.isPrivate)
|
||||||
#expect(message.senderPeerID == alice.myPeerID)
|
#expect(message.senderPeerID == alice.myPeerID)
|
||||||
#expect(message.content.hasPrefix(expectedMessagePrefix))
|
#expect(message.content.hasPrefix(expectedMessagePrefix))
|
||||||
|
if let stableMessageID = PrivateMediaMessageIdentity.stableID(
|
||||||
|
for: file,
|
||||||
|
senderPeerID: alice.myPeerID,
|
||||||
|
recipientPeerID: bob.myPeerID
|
||||||
|
) {
|
||||||
|
#expect(message.id == stableMessageID)
|
||||||
|
} else {
|
||||||
|
// Generic/legacy filenames retain random per-arrival IDs so two
|
||||||
|
// unrelated "photo.jpg" transfers are never deduplicated.
|
||||||
|
#expect(!message.id.hasPrefix("media-"))
|
||||||
|
}
|
||||||
|
|
||||||
let stored = recursivelyStoredFiles(under: bobRoot)
|
let stored = recursivelyStoredFiles(under: bobRoot)
|
||||||
#expect(stored.count == 1)
|
#expect(stored.count == 1)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@@ -73,4 +74,85 @@ final class BitchatFilePacketTests: XCTestCase {
|
|||||||
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
|
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
|
||||||
XCTAssertEqual(decoded.content, content)
|
XCTAssertEqual(decoded.content, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPrivateMediaMessageIdentityConvergesAcrossPeerIDAliases() throws {
|
||||||
|
let senderKey = Data(repeating: 0x11, count: 32)
|
||||||
|
let recipientKey = Data(repeating: 0x22, count: 32)
|
||||||
|
let senderStable = PeerID(hexData: senderKey)
|
||||||
|
let recipientStable = PeerID(hexData: recipientKey)
|
||||||
|
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
|
||||||
|
let senderID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: senderStable.toShort(),
|
||||||
|
recipientPeerID: PeerID(str: "mesh:\(recipientStable.toShort().bare)"),
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
let receiverID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: senderStable,
|
||||||
|
recipientPeerID: recipientStable.toShort(),
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
|
||||||
|
XCTAssertEqual(senderID, receiverID)
|
||||||
|
XCTAssertTrue(senderID.hasPrefix("media-"))
|
||||||
|
XCTAssertEqual(senderID.count, 38)
|
||||||
|
XCTAssertTrue(PrivateMediaMessageIdentity.isStableID(senderID))
|
||||||
|
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "A", count: 32))"))
|
||||||
|
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "a", count: 31))"))
|
||||||
|
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID(UUID().uuidString))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrivateMediaMessageIdentitySeparatesDirectionAndFilename() throws {
|
||||||
|
let alice = PeerID(str: "0011223344556677")
|
||||||
|
let bob = PeerID(str: "8899aabbccddeeff")
|
||||||
|
let firstName = "voice_20260725_105708_11111111-1111-1111-1111-111111111111.m4a"
|
||||||
|
let secondName = "voice_20260725_105709_22222222-2222-2222-2222-222222222222.m4a"
|
||||||
|
let first = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: firstName
|
||||||
|
))
|
||||||
|
|
||||||
|
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: bob,
|
||||||
|
recipientPeerID: alice,
|
||||||
|
fileName: firstName
|
||||||
|
))
|
||||||
|
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: secondName
|
||||||
|
))
|
||||||
|
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: nil
|
||||||
|
))
|
||||||
|
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: "photo.jpg"
|
||||||
|
))
|
||||||
|
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: "img_11111111-1111-1111-1111-111111111111.pdf"
|
||||||
|
))
|
||||||
|
XCTAssertNotNil(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: "voice_0011223344556677.m4a"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrivateMediaMessageIdentityMatchesVersionOneGoldenVector() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: PeerID(str: "0011223344556677"),
|
||||||
|
recipientPeerID: PeerID(str: "8899aabbccddeeff"),
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
),
|
||||||
|
"media-910bd42c65060ab76bb6406f220c4516"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,20 @@ struct BLEFileTransferHandlerTests {
|
|||||||
|
|
||||||
var signatureVerifyCount = 0
|
var signatureVerifyCount = 0
|
||||||
var signedNameQueries: [PeerID] = []
|
var signedNameQueries: [PeerID] = []
|
||||||
|
var blockedPeers: Set<PeerID> = []
|
||||||
var trackedPackets: [BitchatPacket] = []
|
var trackedPackets: [BitchatPacket] = []
|
||||||
var quotaReservations: [Int] = []
|
var quotaReservations: [Int] = []
|
||||||
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
|
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
|
||||||
var lastSeenUpdates: [PeerID] = []
|
var lastSeenUpdates: [PeerID] = []
|
||||||
|
var duplicateDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||||
var deliveredMessages: [BitchatMessage] = []
|
var deliveredMessages: [BitchatMessage] = []
|
||||||
|
var saveOverride: ((
|
||||||
|
_ data: Data,
|
||||||
|
_ preferredName: String?,
|
||||||
|
_ subdirectory: String,
|
||||||
|
_ fallbackExtension: String?,
|
||||||
|
_ defaultPrefix: String
|
||||||
|
) -> URL?)?
|
||||||
}
|
}
|
||||||
|
|
||||||
private let localPeerID = PeerID(str: "0102030405060708")
|
private let localPeerID = PeerID(str: "0102030405060708")
|
||||||
@@ -46,11 +55,20 @@ struct BLEFileTransferHandlerTests {
|
|||||||
},
|
},
|
||||||
saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
||||||
recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix))
|
recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix))
|
||||||
|
if let saveOverride = recorder.saveOverride {
|
||||||
|
return saveOverride(data, preferredName, subdirectory, fallbackExtension, defaultPrefix)
|
||||||
|
}
|
||||||
return recorder.saveResult
|
return recorder.saveResult
|
||||||
},
|
},
|
||||||
|
isPrivateMediaSenderBlocked: { peerID in
|
||||||
|
recorder.blockedPeers.contains(peerID)
|
||||||
|
},
|
||||||
updatePeerLastSeen: { peerID in
|
updatePeerLastSeen: { peerID in
|
||||||
recorder.lastSeenUpdates.append(peerID)
|
recorder.lastSeenUpdates.append(peerID)
|
||||||
},
|
},
|
||||||
|
acknowledgePrivateMediaDuplicate: { messageID, peerID in
|
||||||
|
recorder.duplicateDeliveryAcks.append((messageID, peerID))
|
||||||
|
},
|
||||||
deliverMessage: { message in
|
deliverMessage: { message in
|
||||||
recorder.deliveredMessages.append(message)
|
recorder.deliveredMessages.append(message)
|
||||||
}
|
}
|
||||||
@@ -284,6 +302,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
||||||
#expect(recorder.deliveredMessages.count == 1)
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||||
|
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
|
||||||
// Must be explicit: BitchatMessage defaults private messages to
|
// Must be explicit: BitchatMessage defaults private messages to
|
||||||
// .sending, which the media views render as an in-flight send
|
// .sending, which the media views render as an in-flight send
|
||||||
// (empty reveal mask, disabled reveal tap).
|
// (empty reveal mask, disabled reveal tap).
|
||||||
@@ -296,8 +315,9 @@ struct BLEFileTransferHandlerTests {
|
|||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||||
|
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
let file = BitchatFilePacket(
|
let file = BitchatFilePacket(
|
||||||
fileName: "secret.jpg",
|
fileName: fileName,
|
||||||
fileSize: UInt64(content.count),
|
fileSize: UInt64(content.count),
|
||||||
mimeType: "image/jpeg",
|
mimeType: "image/jpeg",
|
||||||
content: content
|
content: content
|
||||||
@@ -316,6 +336,173 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(recorder.deliveredMessages.count == 1)
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||||
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
|
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
|
||||||
|
#expect(recorder.deliveredMessages.first?.id == PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: remotePeerID,
|
||||||
|
recipientPeerID: localPeerID,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: "photo.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(recorder.deliveredMessages.count == 2)
|
||||||
|
#expect(recorder.deliveredMessages[0].id != recorder.deliveredMessages[1].id)
|
||||||
|
#expect(recorder.deliveredMessages.allSatisfy { !$0.id.hasPrefix("media-") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func repeatedStablePrivateMediaIsAcknowledgedWithoutQuotaOrDiskWork() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||||
|
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
let expectedID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: remotePeerID,
|
||||||
|
recipientPeerID: localPeerID,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(recorder.quotaReservations == [content.count])
|
||||||
|
#expect(recorder.saveCalls.count == 1)
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
#expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID])
|
||||||
|
#expect(recorder.duplicateDeliveryAcks.count == 1)
|
||||||
|
#expect(recorder.duplicateDeliveryAcks.first?.messageID == expectedID)
|
||||||
|
#expect(recorder.duplicateDeliveryAcks.first?.peerID == remotePeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func inFlightStableDuplicateIsNotAcknowledgedAndFailedSaveRemainsRetryable() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
var handler: BLEFileTransferHandler!
|
||||||
|
var nestedResult: Bool?
|
||||||
|
var failFirstSave = true
|
||||||
|
recorder.saveOverride = { _, _, _, _, _ in
|
||||||
|
if failFirstSave {
|
||||||
|
failFirstSave = false
|
||||||
|
nestedResult = handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: self.remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return recorder.saveResult
|
||||||
|
}
|
||||||
|
handler = makeHandler(recorder: recorder)
|
||||||
|
|
||||||
|
// The nested arrival sees the first reservation as pending. It is
|
||||||
|
// coalesced without an ACK; then the first durable save fails.
|
||||||
|
#expect(!handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(nestedResult == true)
|
||||||
|
#expect(recorder.saveCalls.count == 1)
|
||||||
|
#expect(recorder.duplicateDeliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
|
||||||
|
// Failure released the reservation, so the sender's later retry can
|
||||||
|
// persist and deliver normally.
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_236)
|
||||||
|
))
|
||||||
|
#expect(recorder.saveCalls.count == 2)
|
||||||
|
#expect(recorder.duplicateDeliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func blockedPrivateMediaIsDroppedBeforeQuotaDiskAndDedupState() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.blockedPeers = [remotePeerID]
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(recorder.quotaReservations.isEmpty)
|
||||||
|
#expect(recorder.saveCalls.isEmpty)
|
||||||
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||||
|
#expect(recorder.duplicateDeliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
|
||||||
|
// Unblocking must allow a retry through; the blocked attempt cannot
|
||||||
|
// poison the stable-ID dedup reservation.
|
||||||
|
recorder.blockedPeers = []
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
))
|
||||||
|
#expect(recorder.saveCalls.count == 1)
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -337,6 +524,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(recorder.quotaReservations.isEmpty)
|
#expect(recorder.quotaReservations.isEmpty)
|
||||||
#expect(recorder.saveCalls.isEmpty)
|
#expect(recorder.saveCalls.isEmpty)
|
||||||
#expect(recorder.lastSeenUpdates.isEmpty)
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||||
|
#expect(recorder.duplicateDeliveryAcks.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.isEmpty)
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user