mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 01:05:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb515cad1f | ||
|
|
67afb5d131 | ||
|
|
319e7fcbd0 | ||
|
|
50f79673b7 | ||
|
|
ae0563e830 | ||
|
|
02f567229c |
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,5 +3,11 @@ import BitFoundation
|
|||||||
extension PeerCapabilities {
|
extension PeerCapabilities {
|
||||||
/// Capabilities this build advertises in its announce packets.
|
/// Capabilities this build advertises in its announce packets.
|
||||||
/// Each feature adds its bit here when it ships.
|
/// Each feature adds its bit here when it ships.
|
||||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups, .privateMedia]
|
static let localSupported: PeerCapabilities = [
|
||||||
|
.vouch,
|
||||||
|
.prekeys,
|
||||||
|
.groups,
|
||||||
|
.privateMedia,
|
||||||
|
.privateMediaReceipts
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,10 +32,85 @@ struct BLEFileTransferHandlerEnvironment {
|
|||||||
_ fallbackExtension: String?,
|
_ fallbackExtension: String?,
|
||||||
_ defaultPrefix: String
|
_ defaultPrefix: String
|
||||||
) -> URL?
|
) -> URL?
|
||||||
|
/// Resolves the durable receiver decision for a stable private-media ID.
|
||||||
|
let privateMediaReceiptState: (
|
||||||
|
_ messageID: String
|
||||||
|
) -> BLEPrivateMediaReceiptState
|
||||||
|
/// Atomically records a stable private-media ID after the payload save.
|
||||||
|
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
|
||||||
|
/// Rolls back a saved payload when its durable receipt commit fails.
|
||||||
|
let removeIncomingFile: (_ storedURL: URL) -> Void
|
||||||
|
/// Releases the allocator's save-to-UI ownership guard after synchronous
|
||||||
|
/// conversation insertion has completed.
|
||||||
|
let finishIncomingFileDelivery: (_ storedURL: URL) -> Void
|
||||||
|
/// 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
|
||||||
/// Delivers `.messageReceived` to the UI as one main-actor hop.
|
/// Acknowledges stable private media only after its synchronous
|
||||||
let deliverMessage: (BitchatMessage) -> Void
|
/// conversation delivery has completed.
|
||||||
|
let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void
|
||||||
|
/// Delivers `.messageReceived` as one main-actor hop while
|
||||||
|
/// `shouldDeliver` remains true before and after the synchronous sink.
|
||||||
|
/// The completion authorizes the stable-media ACK. Finalization runs after
|
||||||
|
/// every delivery attempt, including rejection, so allocator ownership
|
||||||
|
/// cannot leak indefinitely.
|
||||||
|
let deliverMessage: (
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
_ shouldDeliver: @escaping () -> Bool,
|
||||||
|
_ completion: @escaping () -> Void,
|
||||||
|
_ finalization: @escaping (TransportEventDeliveryOutcome) -> Void
|
||||||
|
) -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-lifetime reservation cache for stable private-media IDs.
|
||||||
|
///
|
||||||
|
/// The first arrival reserves its ID before quota enforcement. Concurrent
|
||||||
|
/// arrivals remain coalesced in memory, while accepted state is resolved from
|
||||||
|
/// the durable ID-to-file ledger so it survives relaunch and becomes retryable
|
||||||
|
/// if quota cleanup removed the file.
|
||||||
|
private final class PrivateMediaArrivalDeduplicator {
|
||||||
|
enum Reservation {
|
||||||
|
case reserved
|
||||||
|
case pending
|
||||||
|
case accepted(URL)
|
||||||
|
case tombstoned
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var pending: Set<String> = []
|
||||||
|
|
||||||
|
func reserve(
|
||||||
|
_ messageID: String,
|
||||||
|
receiptState: () -> BLEPrivateMediaReceiptState
|
||||||
|
) -> Reservation {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if pending.contains(messageID) {
|
||||||
|
return .pending
|
||||||
|
}
|
||||||
|
|
||||||
|
switch receiptState() {
|
||||||
|
case .accepted(let existingURL):
|
||||||
|
return .accepted(existingURL)
|
||||||
|
case .tombstoned:
|
||||||
|
return .tombstoned
|
||||||
|
case .unavailable:
|
||||||
|
return .unavailable
|
||||||
|
case .absent:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.insert(messageID)
|
||||||
|
return .reserved
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish(_ messageID: String) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
pending.remove(messageID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
|
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
|
||||||
@@ -43,6 +118,7 @@ struct BLEFileTransferHandlerEnvironment {
|
|||||||
/// 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
|
||||||
@@ -86,6 +162,7 @@ final class BLEFileTransferHandler {
|
|||||||
senderNickname: senderNickname,
|
senderNickname: senderNickname,
|
||||||
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
||||||
isPrivate: deliveryPlan.isPrivateMessage,
|
isPrivate: deliveryPlan.isPrivateMessage,
|
||||||
|
usesDurableReceipts: false,
|
||||||
env: env
|
env: env
|
||||||
)
|
)
|
||||||
// Once authenticated, a local decode/quota/save failure is not proof
|
// Once authenticated, a local decode/quota/save failure is not proof
|
||||||
@@ -116,6 +193,11 @@ final class BLEFileTransferHandler {
|
|||||||
senderNickname: senderNickname,
|
senderNickname: senderNickname,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
|
// Every authenticated Noise private-file keeps the stable ID/ACK
|
||||||
|
// contract introduced with capability bit 8. Bit 9 advertises
|
||||||
|
// sender-side automatic retry support; it must not downgrade
|
||||||
|
// prior iOS clients to random IDs or single-check delivery.
|
||||||
|
usesDurableReceipts: true,
|
||||||
env: env
|
env: env
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -126,9 +208,11 @@ final class BLEFileTransferHandler {
|
|||||||
senderNickname: String,
|
senderNickname: String,
|
||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
isPrivate: Bool,
|
isPrivate: Bool,
|
||||||
|
usesDurableReceipts: Bool,
|
||||||
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 +233,89 @@ 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 = usesDurableReceipts
|
||||||
|
? PrivateMediaMessageIdentity.stableID(
|
||||||
|
for: filePacket,
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipientPeerID: localPeerID
|
||||||
|
)
|
||||||
|
: nil
|
||||||
|
if let messageID {
|
||||||
|
switch privateMediaArrivals.reserve(
|
||||||
|
messageID,
|
||||||
|
receiptState: { env.privateMediaReceiptState(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(let existingFile):
|
||||||
|
env.updatePeerLastSeen(peerID)
|
||||||
|
let message = incomingMessage(
|
||||||
|
messageID: messageID,
|
||||||
|
senderNickname: senderNickname,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isPrivate: true,
|
||||||
|
peerID: peerID,
|
||||||
|
destination: existingFile,
|
||||||
|
category: storedMediaCategory(
|
||||||
|
for: existingFile,
|
||||||
|
fallback: mime.category
|
||||||
|
),
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
SecureLogger.debug(
|
||||||
|
"📁 Restored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))… -> \(existingFile.lastPathComponent)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
deliverStableMessage(
|
||||||
|
message,
|
||||||
|
messageID: messageID,
|
||||||
|
peerID: peerID,
|
||||||
|
expectedURL: existingFile,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
case .tombstoned:
|
||||||
|
// Explicit deletion is a durable terminal receiver decision.
|
||||||
|
env.updatePeerLastSeen(peerID)
|
||||||
|
env.acknowledgePrivateMedia(messageID, peerID)
|
||||||
|
SecureLogger.debug(
|
||||||
|
"📁 Dropped explicitly deleted private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
case .unavailable:
|
||||||
|
// Never turn an unreadable ledger into an empty ledger. The
|
||||||
|
// sender can retry after the transient storage failure clears.
|
||||||
|
SecureLogger.warning(
|
||||||
|
"📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
if let messageID {
|
||||||
|
privateMediaArrivals.finish(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
|
|
||||||
@@ -162,13 +329,102 @@ final class BLEFileTransferHandler {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let messageID,
|
||||||
|
!env.commitPrivateMediaFile(messageID, destination) {
|
||||||
|
// A payload without its durable ID mapping cannot safely suppress
|
||||||
|
// a retry after relaunch. Roll it back and withhold UI/ACK.
|
||||||
|
env.removeIncomingFile(destination)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if isPrivate {
|
if isPrivate {
|
||||||
env.updatePeerLastSeen(peerID)
|
env.updatePeerLastSeen(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = BitchatMessage(
|
let message = incomingMessage(
|
||||||
|
messageID: messageID,
|
||||||
|
senderNickname: senderNickname,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isPrivate: isPrivate,
|
||||||
|
peerID: peerID,
|
||||||
|
destination: destination,
|
||||||
|
category: mime.category,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
|
||||||
|
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||||
|
|
||||||
|
if let messageID {
|
||||||
|
deliverStableMessage(
|
||||||
|
message,
|
||||||
|
messageID: messageID,
|
||||||
|
peerID: peerID,
|
||||||
|
expectedURL: destination,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
env.deliverMessage(
|
||||||
|
message,
|
||||||
|
{ true },
|
||||||
|
{},
|
||||||
|
{ outcome in
|
||||||
|
if outcome == .rejected {
|
||||||
|
// Raw media has no durable receipt that can redeliver
|
||||||
|
// it later. Do not leave a newly saved, UI-unowned file
|
||||||
|
// available for a stale fallback path to misidentify.
|
||||||
|
env.removeIncomingFile(destination)
|
||||||
|
} else {
|
||||||
|
// Plain delegates are invoked without synchronous
|
||||||
|
// insertion confirmation. Preserve the payload for
|
||||||
|
// that supported delivery path.
|
||||||
|
env.finishIncomingFileDelivery(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deliverStableMessage(
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
messageID: String,
|
||||||
|
peerID: PeerID,
|
||||||
|
expectedURL: URL,
|
||||||
|
env: BLEFileTransferHandlerEnvironment
|
||||||
|
) {
|
||||||
|
env.deliverMessage(
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
guard case .accepted(let resolvedURL) =
|
||||||
|
env.privateMediaReceiptState(messageID) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return resolvedURL.standardizedFileURL
|
||||||
|
== expectedURL.standardizedFileURL
|
||||||
|
},
|
||||||
|
{
|
||||||
|
env.acknowledgePrivateMedia(messageID, peerID)
|
||||||
|
},
|
||||||
|
{ _ in
|
||||||
|
env.finishIncomingFileDelivery(expectedURL)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func incomingMessage(
|
||||||
|
messageID: String?,
|
||||||
|
senderNickname: String,
|
||||||
|
timestamp: Date,
|
||||||
|
isPrivate: Bool,
|
||||||
|
peerID: PeerID,
|
||||||
|
destination: URL,
|
||||||
|
category: MimeType.Category,
|
||||||
|
env: BLEFileTransferHandlerEnvironment
|
||||||
|
) -> BitchatMessage {
|
||||||
|
BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
sender: senderNickname,
|
sender: senderNickname,
|
||||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
content: "\(category.messagePrefix)\(destination.lastPathComponent)",
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
@@ -176,17 +432,35 @@ final class BLEFileTransferHandler {
|
|||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: peerID,
|
senderPeerID: peerID,
|
||||||
// Received messages need an explicit status: BitchatMessage
|
// Received messages need an explicit status: BitchatMessage
|
||||||
// defaults private messages to .sending, which the media views
|
// defaults private messages to .sending, which media views render
|
||||||
// render as an in-flight send (empty reveal mask, disabled tap).
|
// as an in-flight send.
|
||||||
deliveryStatus: isPrivate
|
deliveryStatus: isPrivate
|
||||||
? .delivered(to: env.localNickname(), at: timestamp)
|
? .delivered(to: env.localNickname(), at: timestamp)
|
||||||
: nil
|
: nil
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
/// The durable URL is authoritative during reconstruction. A sender that
|
||||||
|
/// reuses a stable filename with a different MIME type must not change how
|
||||||
env.deliverMessage(message)
|
/// the already-stored payload renders.
|
||||||
return true
|
private func storedMediaCategory(
|
||||||
|
for url: URL,
|
||||||
|
fallback: MimeType.Category
|
||||||
|
) -> MimeType.Category {
|
||||||
|
let mediaDirectory = url
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.lastPathComponent
|
||||||
|
switch mediaDirectory {
|
||||||
|
case MimeType.Category.audio.mediaDir:
|
||||||
|
return .audio
|
||||||
|
case MimeType.Category.image.mediaDir:
|
||||||
|
return .image
|
||||||
|
case MimeType.Category.file.mediaDir:
|
||||||
|
return .file
|
||||||
|
default:
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every remaining raw file transfer is signed, regardless of whether it
|
/// Every remaining raw file transfer is signed, regardless of whether it
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ struct PanicRecoveryOperations {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BLEIncomingFileStore {
|
struct BLEIncomingFileStore: @unchecked Sendable {
|
||||||
enum PanicRecoveryError: Error {
|
enum PanicRecoveryError: Error {
|
||||||
case externalMarkerCommitFailed
|
case externalMarkerCommitFailed
|
||||||
case markerWriteFailed(Error)
|
case markerWriteFailed(Error)
|
||||||
@@ -103,7 +103,17 @@ struct BLEIncomingFileStore {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
struct PrivateMediaDeletionReservation: Sendable {
|
||||||
|
fileprivate let id: UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class PayloadCoordination: @unchecked Sendable {
|
||||||
|
let lock = NSLock()
|
||||||
|
var pendingDeliveryPaths: Set<String> = []
|
||||||
|
var deletionReservations: [UUID: Set<String>] = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let defaultQuotaBytes: Int64 = 100 * 1024 * 1024
|
||||||
/// Kept outside `files/` so deleting the media tree cannot erase the
|
/// Kept outside `files/` so deleting the media tree cannot erase the
|
||||||
/// fail-closed startup decision before the full panic has committed.
|
/// fail-closed startup decision before the full panic has committed.
|
||||||
private static let panicRecoveryPendingMarkerFileName =
|
private static let panicRecoveryPendingMarkerFileName =
|
||||||
@@ -120,7 +130,6 @@ struct BLEIncomingFileStore {
|
|||||||
"files/incoming",
|
"files/incoming",
|
||||||
"files/outgoing"
|
"files/outgoing"
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Name prefix of in-flight live voice captures (progressively written by
|
/// Name prefix of in-flight live voice captures (progressively written by
|
||||||
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
||||||
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
|
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
|
||||||
@@ -134,6 +143,9 @@ struct BLEIncomingFileStore {
|
|||||||
private let baseDirectory: URL?
|
private let baseDirectory: URL?
|
||||||
private let dateProvider: () -> Date
|
private let dateProvider: () -> Date
|
||||||
private let panicMarkerWriter: (Data, URL) throws -> Void
|
private let panicMarkerWriter: (Data, URL) throws -> Void
|
||||||
|
private let quotaBytes: Int64
|
||||||
|
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
|
||||||
|
private let payloadCoordination: PayloadCoordination
|
||||||
|
|
||||||
init(
|
init(
|
||||||
fileManager: FileManager = .default,
|
fileManager: FileManager = .default,
|
||||||
@@ -141,12 +153,20 @@ struct BLEIncomingFileStore {
|
|||||||
dateProvider: @escaping () -> Date = Date.init,
|
dateProvider: @escaping () -> Date = Date.init,
|
||||||
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
|
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
|
||||||
try $0.write(to: $1, options: .atomic)
|
try $0.write(to: $1, options: .atomic)
|
||||||
}
|
},
|
||||||
|
quotaBytes: Int64 = Self.defaultQuotaBytes
|
||||||
) {
|
) {
|
||||||
self.fileManager = fileManager
|
self.fileManager = fileManager
|
||||||
self.baseDirectory = baseDirectory
|
self.baseDirectory = baseDirectory
|
||||||
self.dateProvider = dateProvider
|
self.dateProvider = dateProvider
|
||||||
self.panicMarkerWriter = panicMarkerWriter
|
self.panicMarkerWriter = panicMarkerWriter
|
||||||
|
self.quotaBytes = max(0, quotaBytes)
|
||||||
|
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
|
||||||
|
fileManager: fileManager,
|
||||||
|
baseDirectory: baseDirectory,
|
||||||
|
now: dateProvider
|
||||||
|
)
|
||||||
|
self.payloadCoordination = PayloadCoordination()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Panic-wipe every managed incoming and outgoing media artifact before
|
/// Panic-wipe every managed incoming and outgoing media artifact before
|
||||||
@@ -159,6 +179,22 @@ struct BLEIncomingFileStore {
|
|||||||
func panicWipe(
|
func panicWipe(
|
||||||
hasDurablePendingMarker: Bool = false
|
hasDurablePendingMarker: Bool = false
|
||||||
) throws {
|
) throws {
|
||||||
|
// The receipt index caches tombstones as well as accepted payloads,
|
||||||
|
// while payload coordination retains save/delete reservations. Always
|
||||||
|
// invalidate both on return, including partial-failure paths, so no
|
||||||
|
// pre-panic receiver decision survives after identity reset.
|
||||||
|
defer {
|
||||||
|
privateMediaReceipts.resetForPanic()
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
payloadCoordination.pendingDeliveryPaths.removeAll(
|
||||||
|
keepingCapacity: false
|
||||||
|
)
|
||||||
|
payloadCoordination.deletionReservations.removeAll(
|
||||||
|
keepingCapacity: false
|
||||||
|
)
|
||||||
|
payloadCoordination.lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
let markerError: Error?
|
let markerError: Error?
|
||||||
do {
|
do {
|
||||||
try markPanicRecoveryPending()
|
try markPanicRecoveryPending()
|
||||||
@@ -240,6 +276,9 @@ struct BLEIncomingFileStore {
|
|||||||
fallbackExtension: String?,
|
fallbackExtension: String?,
|
||||||
defaultPrefix: String
|
defaultPrefix: String
|
||||||
) -> URL? {
|
) -> URL? {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer { payloadCoordination.lock.unlock() }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||||
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
||||||
@@ -248,8 +287,26 @@ struct BLEIncomingFileStore {
|
|||||||
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
||||||
fallbackExtension: fallbackExtension
|
fallbackExtension: fallbackExtension
|
||||||
)
|
)
|
||||||
let destination = uniqueFileURL(in: base, fileName: sanitized)
|
let reservedPaths = privateMediaReceipts.reservedPayloadPaths()
|
||||||
|
let deletionPaths = payloadCoordination
|
||||||
|
.deletionReservations.values.reduce(into: Set<String>()) {
|
||||||
|
$0.formUnion($1)
|
||||||
|
}
|
||||||
|
let allocationReservations = deletionPaths.union(
|
||||||
|
payloadCoordination.pendingDeliveryPaths
|
||||||
|
)
|
||||||
|
let destination = uniqueFileURL(
|
||||||
|
in: base,
|
||||||
|
fileName: sanitized,
|
||||||
|
reservedPaths: (reservedPaths ?? []).union(
|
||||||
|
allocationReservations
|
||||||
|
),
|
||||||
|
forceRandomizedName: reservedPaths == nil
|
||||||
|
)
|
||||||
try data.write(to: destination, options: .atomic)
|
try data.write(to: destination, options: .atomic)
|
||||||
|
payloadCoordination.pendingDeliveryPaths.insert(
|
||||||
|
destination.standardizedFileURL.path
|
||||||
|
)
|
||||||
return destination
|
return destination
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
|
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
|
||||||
@@ -257,12 +314,111 @@ struct BLEIncomingFileStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func privateMediaReceiptState(
|
||||||
|
messageID: String
|
||||||
|
) -> BLEPrivateMediaReceiptState {
|
||||||
|
privateMediaReceipts.state(for: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitPrivateMediaFile(
|
||||||
|
messageID: String,
|
||||||
|
storedURL: URL
|
||||||
|
) -> Bool {
|
||||||
|
privateMediaReceipts.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: storedURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserves every receipt/UI path before the asynchronous deletion
|
||||||
|
/// barrier. Allocation and reservation share one lock, so either an
|
||||||
|
/// in-flight raw arrival is observed and deletion fails closed, or the
|
||||||
|
/// arrival is forced onto a different filename.
|
||||||
|
func reservePrivateMediaDeletion(
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String]
|
||||||
|
) -> PrivateMediaDeletionReservation? {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer { payloadCoordination.lock.unlock() }
|
||||||
|
|
||||||
|
guard let paths = privateMediaReceipts
|
||||||
|
.prospectiveDeletionPayloadPaths(
|
||||||
|
messageIDs: messageIDs,
|
||||||
|
payloadRelativePaths: payloadRelativePaths
|
||||||
|
),
|
||||||
|
paths.isDisjoint(
|
||||||
|
with: payloadCoordination.pendingDeliveryPaths
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let reservation = PrivateMediaDeletionReservation(id: UUID())
|
||||||
|
payloadCoordination.deletionReservations[reservation.id] = paths
|
||||||
|
return reservation
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitPrivateMediaDeletion(
|
||||||
|
reservation: PrivateMediaDeletionReservation,
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String],
|
||||||
|
protectedPayloadRelativePaths: Set<String>
|
||||||
|
) -> Bool {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer {
|
||||||
|
payloadCoordination.deletionReservations.removeValue(
|
||||||
|
forKey: reservation.id
|
||||||
|
)
|
||||||
|
payloadCoordination.lock.unlock()
|
||||||
|
}
|
||||||
|
guard payloadCoordination.deletionReservations[reservation.id] != nil
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return privateMediaReceipts.recordDeleted(
|
||||||
|
messageIDs: messageIDs,
|
||||||
|
payloadRelativePaths: payloadRelativePaths,
|
||||||
|
protectedPayloadRelativePaths: protectedPayloadRelativePaths
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases the short window between disk save and synchronous
|
||||||
|
/// conversation insertion. Before this callback, a deletion transaction
|
||||||
|
/// may not infer ownership from a stale bubble that names the same path.
|
||||||
|
func finishIncomingFileDelivery(at storedURL: URL) {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer { payloadCoordination.lock.unlock() }
|
||||||
|
payloadCoordination.pendingDeliveryPaths.remove(
|
||||||
|
storedURL.standardizedFileURL.path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort rollback for a payload whose durable receipt commit failed.
|
||||||
|
func removeIncomingFile(at storedURL: URL) {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer { payloadCoordination.lock.unlock() }
|
||||||
|
payloadCoordination.pendingDeliveryPaths.remove(
|
||||||
|
storedURL.standardizedFileURL.path
|
||||||
|
)
|
||||||
|
guard isURLInsideFilesDirectory(storedURL) else { return }
|
||||||
|
do {
|
||||||
|
try fileManager.removeItem(at: storedURL)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"⚠️ Failed to roll back uncommitted incoming media: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Frees least-recently-modified incoming files until `reservingBytes`
|
/// Frees least-recently-modified incoming files until `reservingBytes`
|
||||||
/// fits under the quota. Files named `voice_live_*` (in-flight live
|
/// fits under the quota. Files named `voice_live_*` (in-flight live
|
||||||
/// captures) are never evicted regardless of who triggers enforcement —
|
/// captures) are never evicted regardless of who triggers enforcement —
|
||||||
/// a finalized transfer can arrive at quota while a burst is still
|
/// a finalized transfer can arrive at quota while a burst is still
|
||||||
/// streaming — but they still count toward usage.
|
/// streaming — but they still count toward usage.
|
||||||
func enforceQuota(reservingBytes: Int) {
|
func enforceQuota(reservingBytes: Int) {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer { payloadCoordination.lock.unlock() }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let base = try filesDirectory()
|
let base = try filesDirectory()
|
||||||
let incomingDirs = [
|
let incomingDirs = [
|
||||||
@@ -288,14 +444,26 @@ struct BLEIncomingFileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
||||||
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
|
let targetUsage = quotaBytes - Int64(reservingBytes)
|
||||||
guard currentUsage > targetUsage else { return }
|
guard currentUsage > targetUsage else { return }
|
||||||
|
|
||||||
let needToFree = currentUsage - targetUsage
|
let needToFree = currentUsage - targetUsage
|
||||||
|
let activeDeletionPaths = payloadCoordination
|
||||||
|
.deletionReservations.values.reduce(into: Set<String>()) {
|
||||||
|
$0.formUnion($1)
|
||||||
|
}
|
||||||
|
let protectedPaths = activeDeletionPaths.union(
|
||||||
|
payloadCoordination.pendingDeliveryPaths
|
||||||
|
)
|
||||||
var freedSpace: Int64 = 0
|
var freedSpace: Int64 = 0
|
||||||
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
||||||
guard freedSpace < needToFree else { break }
|
guard freedSpace < needToFree else { break }
|
||||||
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
|
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
|
||||||
|
guard !protectedPaths.contains(
|
||||||
|
file.url.standardizedFileURL.path
|
||||||
|
) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
try fileManager.removeItem(at: file.url)
|
try fileManager.removeItem(at: file.url)
|
||||||
freedSpace += file.size
|
freedSpace += file.size
|
||||||
@@ -349,6 +517,13 @@ struct BLEIncomingFileStore {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isURLInsideFilesDirectory(_ url: URL) -> Bool {
|
||||||
|
guard let filesDirectory = try? filesDirectory().standardizedFileURL else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return url.standardizedFileURL.path.hasPrefix(filesDirectory.path + "/")
|
||||||
|
}
|
||||||
|
|
||||||
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||||
var candidate = (name ?? "")
|
var candidate = (name ?? "")
|
||||||
.replacingOccurrences(of: "\0", with: "")
|
.replacingOccurrences(of: "\0", with: "")
|
||||||
@@ -376,11 +551,20 @@ struct BLEIncomingFileStore {
|
|||||||
return candidate.isEmpty ? defaultName : candidate
|
return candidate.isEmpty ? defaultName : candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
|
private func uniqueFileURL(
|
||||||
|
in directory: URL,
|
||||||
|
fileName: String,
|
||||||
|
reservedPaths: Set<String>,
|
||||||
|
forceRandomizedName: Bool
|
||||||
|
) -> URL {
|
||||||
let directoryPath = directory.standardizedFileURL.path
|
let directoryPath = directory.standardizedFileURL.path
|
||||||
func isInsideDirectory(_ url: URL) -> Bool {
|
func isInsideDirectory(_ url: URL) -> Bool {
|
||||||
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
|
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
|
||||||
}
|
}
|
||||||
|
func isAvailable(_ url: URL) -> Bool {
|
||||||
|
!reservedPaths.contains(url.standardizedFileURL.path)
|
||||||
|
&& !fileManager.fileExists(atPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
var candidate = directory.appendingPathComponent(fileName)
|
var candidate = directory.appendingPathComponent(fileName)
|
||||||
guard isInsideDirectory(candidate) else {
|
guard isInsideDirectory(candidate) else {
|
||||||
@@ -388,19 +572,27 @@ struct BLEIncomingFileStore {
|
|||||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !fileManager.fileExists(atPath: candidate.path) {
|
let baseName = (fileName as NSString).deletingPathExtension
|
||||||
|
let ext = (fileName as NSString).pathExtension
|
||||||
|
if forceRandomizedName {
|
||||||
|
let suffix = UUID().uuidString
|
||||||
|
let randomizedName = ext.isEmpty
|
||||||
|
? "\(baseName)_\(suffix)"
|
||||||
|
: "\(baseName)_\(suffix).\(ext)"
|
||||||
|
return directory.appendingPathComponent(randomizedName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAvailable(candidate) {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
let baseName = (fileName as NSString).deletingPathExtension
|
|
||||||
let ext = (fileName as NSString).pathExtension
|
|
||||||
for counter in 1..<100 {
|
for counter in 1..<100 {
|
||||||
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
||||||
candidate = directory.appendingPathComponent(newName)
|
candidate = directory.appendingPathComponent(newName)
|
||||||
guard isInsideDirectory(candidate) else {
|
guard isInsideDirectory(candidate) else {
|
||||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||||
}
|
}
|
||||||
if !fileManager.fileExists(atPath: candidate.path) {
|
if isAvailable(candidate) {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1119,6 +1119,51 @@ final class BLEService: NSObject {
|
|||||||
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
|
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to peerID: PeerID
|
||||||
|
) -> UUID? {
|
||||||
|
let normalizedPeerID = peerID.toShort()
|
||||||
|
let currentNoiseGeneration =
|
||||||
|
noiseService.sessionGeneration(for: normalizedPeerID)
|
||||||
|
return collectionsQueue.sync {
|
||||||
|
guard let generation =
|
||||||
|
privateMediaSessionGenerations[normalizedPeerID],
|
||||||
|
generation == currentNoiseGeneration,
|
||||||
|
let authenticated =
|
||||||
|
authenticatedPeerStates[normalizedPeerID],
|
||||||
|
authenticated.sessionGeneration == generation,
|
||||||
|
authenticated.capabilities.contains(.privateMedia),
|
||||||
|
authenticated.capabilities.contains(
|
||||||
|
.privateMediaReceipts
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return generation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func privateMediaPolicyFingerprint(
|
||||||
|
for peerID: PeerID,
|
||||||
|
expectedSessionGeneration: UUID?
|
||||||
|
) -> String? {
|
||||||
|
let normalizedPeerID = peerID.toShort()
|
||||||
|
if let expectedSessionGeneration,
|
||||||
|
noiseService.sessionGeneration(for: normalizedPeerID)
|
||||||
|
== expectedSessionGeneration,
|
||||||
|
let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID),
|
||||||
|
noiseService.sessionGeneration(for: normalizedPeerID)
|
||||||
|
== expectedSessionGeneration {
|
||||||
|
// The exact authenticated Noise static key is stronger than a
|
||||||
|
// registry entry populated by a public announce.
|
||||||
|
return fingerprint
|
||||||
|
}
|
||||||
|
return collectionsQueue.sync {
|
||||||
|
peerRegistry.info(for: normalizedPeerID)?
|
||||||
|
.noisePublicKey?
|
||||||
|
.sha256Fingerprint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||||
let normalizedPeerID = peerID.toShort()
|
let normalizedPeerID = peerID.toShort()
|
||||||
let state: (
|
let state: (
|
||||||
@@ -1146,7 +1191,10 @@ final class BLEService: NSObject {
|
|||||||
return .awaitingCapabilityProof
|
return .awaitingCapabilityProof
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let fingerprint = state.fingerprint else {
|
guard let fingerprint = privateMediaPolicyFingerprint(
|
||||||
|
for: normalizedPeerID,
|
||||||
|
expectedSessionGeneration: state.sessionGeneration
|
||||||
|
) ?? state.fingerprint else {
|
||||||
// A raw fallback must be bound to the stable Noise key from a
|
// A raw fallback must be bound to the stable Noise key from a
|
||||||
// verified registry entry; a routing ID alone can rotate or be
|
// verified registry entry; a routing ID alone can rotate or be
|
||||||
// spoofed. Without that key neither proof nor safe migration state
|
// spoofed. Without that key neither proof nor safe migration state
|
||||||
@@ -1200,11 +1248,13 @@ final class BLEService: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let fingerprint: String? = self.collectionsQueue.sync {
|
let generation = self.collectionsQueue.sync {
|
||||||
self.peerRegistry.info(for: normalizedPeerID)?
|
self.privateMediaSessionGenerations[normalizedPeerID]
|
||||||
.noisePublicKey?
|
|
||||||
.sha256Fingerprint()
|
|
||||||
}
|
}
|
||||||
|
let fingerprint = self.privateMediaPolicyFingerprint(
|
||||||
|
for: normalizedPeerID,
|
||||||
|
expectedSessionGeneration: generation
|
||||||
|
)
|
||||||
guard let fingerprint else {
|
guard let fingerprint else {
|
||||||
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
|
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
|
||||||
return
|
return
|
||||||
@@ -1576,6 +1626,36 @@ final class BLEService: NSObject {
|
|||||||
to peerID: PeerID,
|
to peerID: PeerID,
|
||||||
transferId: String,
|
transferId: String,
|
||||||
allowLegacyFallback: Bool
|
allowLegacyFallback: Bool
|
||||||
|
) {
|
||||||
|
sendFilePrivate(
|
||||||
|
filePacket,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
allowLegacyFallback: allowLegacyFallback,
|
||||||
|
requiresAuthenticatedPrivateMediaReceipts: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFilePrivateReceiptRetry(
|
||||||
|
_ filePacket: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String
|
||||||
|
) {
|
||||||
|
sendFilePrivate(
|
||||||
|
filePacket,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
allowLegacyFallback: false,
|
||||||
|
requiresAuthenticatedPrivateMediaReceipts: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendFilePrivate(
|
||||||
|
_ filePacket: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
allowLegacyFallback: Bool,
|
||||||
|
requiresAuthenticatedPrivateMediaReceipts: Bool
|
||||||
) {
|
) {
|
||||||
// Register before enqueueing onto messageQueue. This closes the window
|
// Register before enqueueing onto messageQueue. This closes the window
|
||||||
// where cancel/delete could run first, observe no scheduler state, and
|
// where cancel/delete could run first, observe no scheduler state, and
|
||||||
@@ -1688,6 +1768,25 @@ final class BLEService: NSObject {
|
|||||||
self.privateMediaTransferAdmissions.finish(transferId)
|
self.privateMediaTransferAdmissions.finish(transferId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if requiresAuthenticatedPrivateMediaReceipts,
|
||||||
|
self.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to: targetID
|
||||||
|
) == nil {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Private media retry blocked without current authenticated receipt support for \(targetID.id.prefix(8))…",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
TransferProgressManager.shared.rejectBeforeStart(
|
||||||
|
id: transferId,
|
||||||
|
reason: String(
|
||||||
|
localized: "content.delivery.reason.private_media_capability_unresolved",
|
||||||
|
defaultValue: "Could not confirm encrypted media support",
|
||||||
|
comment: "Failure reason when private-media capability negotiation did not resolve"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.privateMediaTransferAdmissions.finish(transferId)
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else {
|
guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else {
|
||||||
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
||||||
TransferProgressManager.shared.rejectBeforeStart(
|
TransferProgressManager.shared.rejectBeforeStart(
|
||||||
@@ -1698,6 +1797,21 @@ final class BLEService: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard self.noiseService.hasEstablishedSession(with: targetID) else {
|
guard self.noiseService.hasEstablishedSession(with: targetID) else {
|
||||||
|
if requiresAuthenticatedPrivateMediaReceipts {
|
||||||
|
// A retry belongs to one exact authenticated generation.
|
||||||
|
// Never let it enter the ordinary pending queue where a
|
||||||
|
// bit-8-only replacement session could later flush it.
|
||||||
|
TransferProgressManager.shared.rejectBeforeStart(
|
||||||
|
id: transferId,
|
||||||
|
reason: String(
|
||||||
|
localized: "content.delivery.reason.private_media_capability_unresolved",
|
||||||
|
defaultValue: "Could not confirm encrypted media support",
|
||||||
|
comment: "Failure reason when private-media capability negotiation did not resolve"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.privateMediaTransferAdmissions.finish(transferId)
|
||||||
|
return
|
||||||
|
}
|
||||||
let queued = self.collectionsQueue.sync(flags: .barrier) {
|
let queued = self.collectionsQueue.sync(flags: .barrier) {
|
||||||
self.privateMediaTransferAdmissions.withActive(transferId) {
|
self.privateMediaTransferAdmissions.withActive(transferId) {
|
||||||
self.pendingNoiseSessionQueues.appendTypedPayload(
|
self.pendingNoiseSessionQueues.appendTypedPayload(
|
||||||
@@ -1729,7 +1843,12 @@ final class BLEService: NSObject {
|
|||||||
self.privateMediaTransferAdmissions.finish(transferId)
|
self.privateMediaTransferAdmissions.finish(transferId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let packet = try self.makeEncryptedNoisePacket(typedPayload, to: targetID)
|
let packet = try self.makeEncryptedNoisePacket(
|
||||||
|
typedPayload,
|
||||||
|
to: targetID,
|
||||||
|
requiresAuthenticatedPrivateMediaReceipts:
|
||||||
|
requiresAuthenticatedPrivateMediaReceipts
|
||||||
|
)
|
||||||
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
|
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
|
||||||
self.privateMediaTransferAdmissions.finish(transferId)
|
self.privateMediaTransferAdmissions.finish(transferId)
|
||||||
return
|
return
|
||||||
@@ -2527,12 +2646,63 @@ final class BLEService: NSObject {
|
|||||||
defaultPrefix: defaultPrefix
|
defaultPrefix: defaultPrefix
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
privateMediaReceiptState: { [weak self] messageID in
|
||||||
|
self?.incomingFileStore.privateMediaReceiptState(
|
||||||
|
messageID: messageID
|
||||||
|
) ?? .unavailable
|
||||||
|
},
|
||||||
|
commitPrivateMediaFile: { [weak self] messageID, storedURL in
|
||||||
|
self?.incomingFileStore.commitPrivateMediaFile(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: storedURL
|
||||||
|
) ?? false
|
||||||
|
},
|
||||||
|
removeIncomingFile: { [weak self] storedURL in
|
||||||
|
self?.incomingFileStore.removeIncomingFile(at: storedURL)
|
||||||
|
},
|
||||||
|
finishIncomingFileDelivery: { [weak self] storedURL in
|
||||||
|
// Serialize pending-owner release behind deletion barriers.
|
||||||
|
// If /clear snapshots before this UI insertion, its already
|
||||||
|
// queued barrier must still observe the path as pending. If
|
||||||
|
// insertion wins first, the next MainActor snapshot sees the
|
||||||
|
// new bubble and protects the path explicitly.
|
||||||
|
self?.messageQueue.async(flags: .barrier) {
|
||||||
|
self?.incomingFileStore.finishIncomingFileDelivery(
|
||||||
|
at: storedURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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)
|
||||||
},
|
},
|
||||||
deliverMessage: { [weak self] message in
|
acknowledgePrivateMedia: { [weak self] messageID, peerID in
|
||||||
// Single main-actor hop delivering `.messageReceived`.
|
guard let self,
|
||||||
self?.emitTransportEvent(.messageReceived(message))
|
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID),
|
||||||
|
!self.identityManager.isBlocked(
|
||||||
|
fingerprint: senderStaticKey.sha256Fingerprint()
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
|
},
|
||||||
|
deliverMessage: { [weak self] message, shouldDeliver, completion, finalization in
|
||||||
|
self?.emitTransportEvent(
|
||||||
|
.messageReceived(message),
|
||||||
|
shouldDeliver: shouldDeliver,
|
||||||
|
completion: completion,
|
||||||
|
finalization: finalization
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3322,6 +3492,18 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _test_emitTransportEvent(
|
||||||
|
_ event: TransportEvent,
|
||||||
|
completion: @escaping () -> Void,
|
||||||
|
finalization: @escaping (TransportEventDeliveryOutcome) -> Void
|
||||||
|
) {
|
||||||
|
emitTransportEvent(
|
||||||
|
event,
|
||||||
|
completion: completion,
|
||||||
|
finalization: finalization
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
|
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
|
||||||
if preseedPeer {
|
if preseedPeer {
|
||||||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
// Ensure the synthetic peer is known and marked verified for public-message tests
|
||||||
@@ -4227,8 +4409,87 @@ extension BLEService {
|
|||||||
// No alias rotation or advertising restarts required.
|
// No alias rotation or advertising restarts required.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Media Deletion
|
||||||
|
|
||||||
|
extension BLEService: PrivateMediaDeletionPersisting {
|
||||||
|
@MainActor
|
||||||
|
func persistDeletedPrivateMedia(
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String],
|
||||||
|
protectedPayloadRelativePaths: Set<String>,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
) {
|
||||||
|
let fileStore = incomingFileStore
|
||||||
|
messageQueue.async(flags: .barrier) {
|
||||||
|
guard let reservation = fileStore
|
||||||
|
.reservePrivateMediaDeletion(
|
||||||
|
messageIDs: messageIDs,
|
||||||
|
payloadRelativePaths: payloadRelativePaths
|
||||||
|
) else {
|
||||||
|
Task { @MainActor in
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let persisted = fileStore
|
||||||
|
.commitPrivateMediaDeletion(
|
||||||
|
reservation: reservation,
|
||||||
|
messageIDs: messageIDs,
|
||||||
|
payloadRelativePaths: payloadRelativePaths,
|
||||||
|
protectedPayloadRelativePaths:
|
||||||
|
protectedPayloadRelativePaths
|
||||||
|
)
|
||||||
|
Task { @MainActor in
|
||||||
|
completion(persisted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
enum TransportEventDeliveryOutcome: Equatable {
|
||||||
|
/// A synchronous sink inserted the message and revalidation succeeded.
|
||||||
|
case accepted
|
||||||
|
/// A supported plain delegate was invoked, but insertion cannot be
|
||||||
|
/// confirmed synchronously.
|
||||||
|
case invokedUnconfirmed
|
||||||
|
/// No sink accepted the event, or receipt revalidation rejected it.
|
||||||
|
case rejected
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TransportEventDeliveryGate {
|
||||||
|
/// Runs finalization exactly once for every attempted main-actor delivery,
|
||||||
|
/// including pre-insertion rejection, a missing/rejecting sink, and
|
||||||
|
/// post-insertion revalidation failure. Only a fully accepted delivery
|
||||||
|
/// runs `completion` (for example, a stable-media ACK).
|
||||||
|
@MainActor
|
||||||
|
static func attempt(
|
||||||
|
shouldDeliver: () -> Bool,
|
||||||
|
deliver: () -> TransportEventDeliveryOutcome,
|
||||||
|
completion: () -> Void,
|
||||||
|
finalization: (TransportEventDeliveryOutcome) -> Void
|
||||||
|
) {
|
||||||
|
var outcome = TransportEventDeliveryOutcome.rejected
|
||||||
|
defer { finalization(outcome) }
|
||||||
|
guard shouldDeliver() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch deliver() {
|
||||||
|
case .rejected:
|
||||||
|
return
|
||||||
|
case .invokedUnconfirmed:
|
||||||
|
outcome = .invokedUnconfirmed
|
||||||
|
return
|
||||||
|
case .accepted:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
guard shouldDeliver() else { return }
|
||||||
|
outcome = .accepted
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension BLEService {
|
extension BLEService {
|
||||||
|
|
||||||
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
||||||
@@ -4249,18 +4510,74 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func emitTransportEvent(_ event: TransportEvent) {
|
private func emitTransportEvent(
|
||||||
notifyUI { [weak self] in
|
_ event: TransportEvent,
|
||||||
self?.deliverTransportEvent(event)
|
shouldDeliver: (() -> Bool)? = nil,
|
||||||
|
completion: (() -> Void)? = nil,
|
||||||
|
finalization: ((TransportEventDeliveryOutcome) -> Void)? = nil
|
||||||
|
) {
|
||||||
|
guard let generation = capturePanicLifecycleGeneration() else {
|
||||||
|
Task { @MainActor in
|
||||||
|
finalization?(.rejected)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.isCurrentPanicLifecycleGeneration(generation) else {
|
||||||
|
finalization?(.rejected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
TransportEventDeliveryGate.attempt(
|
||||||
|
shouldDeliver: {
|
||||||
|
self.isCurrentPanicLifecycleGeneration(generation)
|
||||||
|
&& (shouldDeliver?() ?? true)
|
||||||
|
},
|
||||||
|
deliver: {
|
||||||
|
return self.deliverTransportEvent(event)
|
||||||
|
},
|
||||||
|
completion: { completion?() },
|
||||||
|
finalization: { finalization?($0) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func deliverTransportEvent(_ event: TransportEvent) {
|
@discardableResult
|
||||||
|
private func deliverTransportEvent(
|
||||||
|
_ event: TransportEvent
|
||||||
|
) -> TransportEventDeliveryOutcome {
|
||||||
|
if case .messageReceived(let message) = event {
|
||||||
|
if let synchronousDelegate =
|
||||||
|
eventDelegate as? SynchronousMessageTransportEventDelegate {
|
||||||
|
return synchronousDelegate
|
||||||
|
.didReceiveTransportMessageSynchronously(message)
|
||||||
|
? .accepted
|
||||||
|
: .rejected
|
||||||
|
}
|
||||||
|
if let eventDelegate {
|
||||||
|
eventDelegate.didReceiveTransportEvent(event)
|
||||||
|
return .invokedUnconfirmed
|
||||||
|
}
|
||||||
|
if let synchronousDelegate =
|
||||||
|
delegate as? SynchronousMessageTransportEventDelegate {
|
||||||
|
return synchronousDelegate
|
||||||
|
.didReceiveTransportMessageSynchronously(message)
|
||||||
|
? .accepted
|
||||||
|
: .rejected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let eventDelegate {
|
if let eventDelegate {
|
||||||
eventDelegate.didReceiveTransportEvent(event)
|
eventDelegate.didReceiveTransportEvent(event)
|
||||||
|
return .accepted
|
||||||
} else {
|
} else {
|
||||||
delegate?.receiveTransportEvent(event)
|
guard let delegate else { return .rejected }
|
||||||
|
delegate.receiveTransportEvent(event)
|
||||||
|
if case .messageReceived = event {
|
||||||
|
return .invokedUnconfirmed
|
||||||
|
}
|
||||||
|
return .accepted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5062,7 +5379,11 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket {
|
private func makeEncryptedNoisePacket(
|
||||||
|
_ typedPayload: Data,
|
||||||
|
to peerID: PeerID,
|
||||||
|
requiresAuthenticatedPrivateMediaReceipts: Bool = false
|
||||||
|
) throws -> BitchatPacket {
|
||||||
let encrypted: Data
|
let encrypted: Data
|
||||||
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
|
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
|
||||||
if isPrivateFile {
|
if isPrivateFile {
|
||||||
@@ -5072,6 +5393,13 @@ extension BLEService {
|
|||||||
let authenticated = authenticatedPeerStates[peerID],
|
let authenticated = authenticatedPeerStates[peerID],
|
||||||
authenticated.sessionGeneration == generation,
|
authenticated.sessionGeneration == generation,
|
||||||
authenticated.capabilities.contains(.privateMedia) else { return nil }
|
authenticated.capabilities.contains(.privateMedia) else { return nil }
|
||||||
|
if requiresAuthenticatedPrivateMediaReceipts {
|
||||||
|
guard authenticated.capabilities.contains(
|
||||||
|
.privateMediaReceipts
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return generation
|
return generation
|
||||||
}
|
}
|
||||||
guard let provenGeneration else {
|
guard let provenGeneration else {
|
||||||
|
|||||||
@@ -97,10 +97,30 @@ enum PrivateMediaSendPolicy: Equatable {
|
|||||||
case blockedDowngrade
|
case blockedDowngrade
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Receiver-only persistence surface for explicit private-media deletion.
|
||||||
|
/// Kept separate from `Transport` so sender retry branches can rebase without
|
||||||
|
/// inheriting or implementing receiver storage concerns.
|
||||||
|
protocol PrivateMediaDeletionPersisting: AnyObject {
|
||||||
|
@MainActor
|
||||||
|
func persistDeletedPrivateMedia(
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String],
|
||||||
|
protectedPayloadRelativePaths: Set<String>,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
protocol TransportEventDelegate: AnyObject {
|
protocol TransportEventDelegate: AnyObject {
|
||||||
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Optional typed-event contract for sinks that can synchronously decide
|
||||||
|
/// whether an inbound message was accepted.
|
||||||
|
protocol SynchronousMessageTransportEventDelegate: TransportEventDelegate {
|
||||||
|
@MainActor
|
||||||
|
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
protocol Transport: AnyObject {
|
protocol Transport: AnyObject {
|
||||||
// Event sink
|
// Event sink
|
||||||
var delegate: BitchatDelegate? { get set }
|
var delegate: BitchatDelegate? { get set }
|
||||||
@@ -183,6 +203,14 @@ protocol Transport: AnyObject {
|
|||||||
transferId: String,
|
transferId: String,
|
||||||
allowLegacyFallback: Bool
|
allowLegacyFallback: Bool
|
||||||
)
|
)
|
||||||
|
/// Automatic whole-file retry is admitted only while this exact Noise
|
||||||
|
/// generation authenticates bit 9. It must never queue across a session
|
||||||
|
/// replacement or enter the signed raw legacy path.
|
||||||
|
func sendFilePrivateReceiptRetry(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String
|
||||||
|
)
|
||||||
func cancelTransfer(_ transferId: String)
|
func cancelTransfer(_ transferId: String)
|
||||||
|
|
||||||
// Live voice / push-to-talk (mesh transports only): one encoded
|
// Live voice / push-to-talk (mesh transports only): one encoded
|
||||||
@@ -229,6 +257,11 @@ protocol Transport: AnyObject {
|
|||||||
/// empty for peers that predate the capabilities TLV.
|
/// empty for peers that predate the capabilities TLV.
|
||||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
|
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
|
||||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
||||||
|
/// The exact current Noise generation that authenticated both encrypted
|
||||||
|
/// private media (bit 8) and durable receipts/retry (bit 9).
|
||||||
|
func authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to peerID: PeerID
|
||||||
|
) -> UUID?
|
||||||
func resolvePrivateMediaSendPolicy(
|
func resolvePrivateMediaSendPolicy(
|
||||||
to peerID: PeerID,
|
to peerID: PeerID,
|
||||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
@@ -304,6 +337,11 @@ extension Transport {
|
|||||||
func broadcastGroupMessage(_ envelope: Data) {}
|
func broadcastGroupMessage(_ envelope: Data) {}
|
||||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
||||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
|
||||||
|
func authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to peerID: PeerID
|
||||||
|
) -> UUID? {
|
||||||
|
nil
|
||||||
|
}
|
||||||
func resolvePrivateMediaSendPolicy(
|
func resolvePrivateMediaSendPolicy(
|
||||||
to peerID: PeerID,
|
to peerID: PeerID,
|
||||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
@@ -338,6 +376,11 @@ extension Transport {
|
|||||||
guard !allowLegacyFallback else { return }
|
guard !allowLegacyFallback else { return }
|
||||||
sendFilePrivate(packet, to: peerID, transferId: transferId)
|
sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||||
}
|
}
|
||||||
|
func sendFilePrivateReceiptRetry(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String
|
||||||
|
) {}
|
||||||
func cancelTransfer(_ transferId: String) {}
|
func cancelTransfer(_ transferId: String) {}
|
||||||
|
|
||||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ enum TransportConfig {
|
|||||||
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
|
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
|
||||||
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
|
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
|
||||||
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
|
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
|
||||||
|
/// Accepted private-media receipts and explicit-deletion tombstones each
|
||||||
|
/// receive this independent capacity.
|
||||||
|
static let privateMediaReceivedLedgerCapacity: Int = 4_096
|
||||||
|
/// A bounded retry horizon prevents stable receipt state from growing into
|
||||||
|
/// permanent application history.
|
||||||
|
static let privateMediaReceivedLedgerTTLSeconds: TimeInterval =
|
||||||
|
7 * 24 * 60 * 60
|
||||||
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
||||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
||||||
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ extension ChatViewModel: ChatDeliveryContext {
|
|||||||
|
|
||||||
func markMessageDelivered(_ messageID: String) {
|
func markMessageDelivered(_ messageID: String) {
|
||||||
messageRouter.markDelivered(messageID)
|
messageRouter.markDelivered(messageID)
|
||||||
|
mediaTransferCoordinator.confirmPrivateMediaDelivery(
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -272,8 +279,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,
|
||||||
@@ -287,7 +302,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)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -100,9 +100,14 @@ final class ChatPeerListCoordinator: @unchecked Sendable {
|
|||||||
|
|
||||||
func didUpdatePeerList(_ peers: [PeerID]) {
|
func didUpdatePeerList(_ peers: [PeerID]) {
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.handlePeerListUpdate(peers)
|
self?.didUpdatePeerListSynchronously(peers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didUpdatePeerListSynchronously(_ peers: [PeerID]) {
|
||||||
|
handlePeerListUpdate(peers)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ChatPeerListCoordinator {
|
private extension ChatPeerListCoordinator {
|
||||||
|
|||||||
@@ -163,21 +163,20 @@ final class ChatTransportEventCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {
|
func didReceiveMessage(_ message: BitchatMessage) {
|
||||||
runOnMain { context in
|
runOnMain { [self] context in
|
||||||
guard !context.isMessageBlocked(message) else { return }
|
handleReceivedMessage(message, in: context)
|
||||||
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
|
|
||||||
|
|
||||||
if message.isPrivate {
|
|
||||||
context.handlePrivateMessage(message)
|
|
||||||
} else {
|
|
||||||
context.handlePublicMessage(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
context.checkForMentions(message)
|
|
||||||
context.sendHapticFeedback(for: message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Typed transport events already arrive on the main actor. Handle them
|
||||||
|
/// synchronously so observers see the ConversationStore mutation before
|
||||||
|
/// the transport completes delivery.
|
||||||
|
@MainActor
|
||||||
|
@discardableResult
|
||||||
|
func didReceiveMessageSynchronously(_ message: BitchatMessage) -> Bool {
|
||||||
|
handleReceivedMessage(message, in: context)
|
||||||
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(
|
func didReceivePublicMessage(
|
||||||
from peerID: PeerID,
|
from peerID: PeerID,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
@@ -185,28 +184,36 @@ final class ChatTransportEventCoordinator {
|
|||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
messageID: String?
|
messageID: String?
|
||||||
) {
|
) {
|
||||||
runOnMain { context in
|
runOnMain { [self] context in
|
||||||
let normalized = content.trimmed
|
handlePublicMessage(
|
||||||
let mentions = context.parseMentions(from: normalized)
|
from: peerID,
|
||||||
let message = BitchatMessage(
|
nickname: nickname,
|
||||||
id: messageID,
|
content: content,
|
||||||
sender: nickname,
|
|
||||||
content: normalized,
|
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
messageID: messageID,
|
||||||
originalSender: nil,
|
in: context
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: peerID,
|
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
|
||||||
)
|
)
|
||||||
|
|
||||||
context.handlePublicMessage(message)
|
|
||||||
context.checkForMentions(message)
|
|
||||||
context.sendHapticFeedback(for: message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didReceivePublicMessageSynchronously(
|
||||||
|
from peerID: PeerID,
|
||||||
|
nickname: String,
|
||||||
|
content: String,
|
||||||
|
timestamp: Date,
|
||||||
|
messageID: String?
|
||||||
|
) {
|
||||||
|
handlePublicMessage(
|
||||||
|
from: peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
messageID: messageID,
|
||||||
|
in: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func didReceiveNoisePayload(
|
func didReceiveNoisePayload(
|
||||||
from peerID: PeerID,
|
from peerID: PeerID,
|
||||||
type: NoisePayloadType,
|
type: NoisePayloadType,
|
||||||
@@ -224,59 +231,134 @@ final class ChatTransportEventCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didReceiveNoisePayloadSynchronously(
|
||||||
|
from peerID: PeerID,
|
||||||
|
type: NoisePayloadType,
|
||||||
|
payload: Data,
|
||||||
|
timestamp: Date
|
||||||
|
) {
|
||||||
|
handleNoisePayload(
|
||||||
|
from: peerID,
|
||||||
|
type: type,
|
||||||
|
payload: payload,
|
||||||
|
timestamp: timestamp,
|
||||||
|
in: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func didConnectToPeer(_ peerID: PeerID) {
|
func didConnectToPeer(_ peerID: PeerID) {
|
||||||
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
|
runOnMain { [weak self] _ in
|
||||||
|
self?.didConnectToPeerSynchronously(peerID)
|
||||||
runOnMain { context in
|
|
||||||
context.isConnected = true
|
|
||||||
context.registerEphemeralSession(peerID: peerID)
|
|
||||||
context.notifyUIChanged()
|
|
||||||
|
|
||||||
if let peer = context.unifiedPeer(for: peerID) {
|
|
||||||
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
|
|
||||||
context.cacheStablePeerID(stablePeerID, for: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
context.flushRouterOutbox(for: peerID)
|
|
||||||
context.retryCourierDeposits(via: peerID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didConnectToPeerSynchronously(_ peerID: PeerID) {
|
||||||
|
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
|
||||||
|
|
||||||
|
context.isConnected = true
|
||||||
|
context.registerEphemeralSession(peerID: peerID)
|
||||||
|
context.notifyUIChanged()
|
||||||
|
|
||||||
|
if let peer = context.unifiedPeer(for: peerID) {
|
||||||
|
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
|
||||||
|
context.cacheStablePeerID(stablePeerID, for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.flushRouterOutbox(for: peerID)
|
||||||
|
context.retryCourierDeposits(via: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
func didDisconnectFromPeer(_ peerID: PeerID) {
|
func didDisconnectFromPeer(_ peerID: PeerID) {
|
||||||
|
runOnMain { [weak self] _ in
|
||||||
|
self?.didDisconnectFromPeerSynchronously(peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didDisconnectFromPeerSynchronously(_ peerID: PeerID) {
|
||||||
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
|
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
|
||||||
|
|
||||||
runOnMain { context in
|
context.removeEphemeralSession(peerID: peerID)
|
||||||
context.removeEphemeralSession(peerID: peerID)
|
|
||||||
|
|
||||||
var stablePeerID = context.cachedStablePeerID(for: peerID)
|
var stablePeerID = context.cachedStablePeerID(for: peerID)
|
||||||
if stablePeerID == nil,
|
if stablePeerID == nil,
|
||||||
let key = context.noiseSessionPublicKeyData(for: peerID) {
|
let key = context.noiseSessionPublicKeyData(for: peerID) {
|
||||||
let derivedPeerID = PeerID(hexData: key)
|
let derivedPeerID = PeerID(hexData: key)
|
||||||
context.cacheStablePeerID(derivedPeerID, for: peerID)
|
context.cacheStablePeerID(derivedPeerID, for: peerID)
|
||||||
stablePeerID = derivedPeerID
|
stablePeerID = derivedPeerID
|
||||||
}
|
|
||||||
|
|
||||||
if let currentPeerID = context.selectedPrivateChatPeer,
|
|
||||||
currentPeerID == peerID,
|
|
||||||
let stablePeerID {
|
|
||||||
self.migrateSelectedConversationIfNeeded(
|
|
||||||
from: peerID,
|
|
||||||
to: stablePeerID,
|
|
||||||
in: context
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let receiptIDs = context.privateMessages(for: peerID)
|
|
||||||
.filter { $0.senderPeerID == peerID }
|
|
||||||
.map(\.id)
|
|
||||||
context.unmarkReadReceiptsSent(receiptIDs)
|
|
||||||
|
|
||||||
context.notifyUIChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let currentPeerID = context.selectedPrivateChatPeer,
|
||||||
|
currentPeerID == peerID,
|
||||||
|
let stablePeerID {
|
||||||
|
migrateSelectedConversationIfNeeded(
|
||||||
|
from: peerID,
|
||||||
|
to: stablePeerID,
|
||||||
|
in: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let receiptIDs = context.privateMessages(for: peerID)
|
||||||
|
.filter { $0.senderPeerID == peerID }
|
||||||
|
.map(\.id)
|
||||||
|
context.unmarkReadReceiptsSent(receiptIDs)
|
||||||
|
|
||||||
|
context.notifyUIChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ChatTransportEventCoordinator {
|
private extension ChatTransportEventCoordinator {
|
||||||
|
@MainActor
|
||||||
|
func handlePublicMessage(
|
||||||
|
from peerID: PeerID,
|
||||||
|
nickname: String,
|
||||||
|
content: String,
|
||||||
|
timestamp: Date,
|
||||||
|
messageID: String?,
|
||||||
|
in context: any ChatTransportEventContext
|
||||||
|
) {
|
||||||
|
let normalized = content.trimmed
|
||||||
|
let mentions = context.parseMentions(from: normalized)
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: nickname,
|
||||||
|
content: normalized,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: false,
|
||||||
|
recipientNickname: nil,
|
||||||
|
senderPeerID: peerID,
|
||||||
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
|
)
|
||||||
|
|
||||||
|
context.handlePublicMessage(message)
|
||||||
|
context.checkForMentions(message)
|
||||||
|
context.sendHapticFeedback(for: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@discardableResult
|
||||||
|
func handleReceivedMessage(
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
in context: any ChatTransportEventContext
|
||||||
|
) -> Bool {
|
||||||
|
guard !context.isMessageBlocked(message) else { return false }
|
||||||
|
guard !message.content.trimmed.isEmpty || message.isPrivate else { return false }
|
||||||
|
|
||||||
|
if message.isPrivate {
|
||||||
|
context.handlePrivateMessage(message)
|
||||||
|
} else {
|
||||||
|
context.handlePublicMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.checkForMentions(message)
|
||||||
|
context.sendHapticFeedback(for: message)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) {
|
func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) {
|
||||||
Task { @MainActor [weak context = self.context] in
|
Task { @MainActor [weak context = self.context] in
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ protocol ChatVerificationContext: AnyObject {
|
|||||||
func noiseStaticPublicKeyData() -> Data
|
func noiseStaticPublicKeyData() -> Data
|
||||||
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 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)
|
||||||
|
|
||||||
@@ -116,6 +117,10 @@ extension ChatViewModel: ChatVerificationContext {
|
|||||||
meshService.noiseStaticPublicKeyData()
|
meshService.noiseStaticPublicKeyData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {
|
||||||
|
mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort())
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -129,6 +134,10 @@ extension ChatViewModel: ChatVerificationContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ChatVerificationContext {
|
||||||
|
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ChatVerificationCoordinator {
|
final class ChatVerificationCoordinator {
|
||||||
struct PendingVerification {
|
struct PendingVerification {
|
||||||
@@ -197,6 +206,7 @@ final class ChatVerificationCoordinator {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
||||||
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
|
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
|
||||||
|
self.context.privateMediaPeerDidAuthenticate(peerID)
|
||||||
|
|
||||||
if self.context.isVerifiedFingerprint(fingerprint) {
|
if self.context.isVerifiedFingerprint(fingerprint) {
|
||||||
self.context.setEncryptionStatus(.noiseVerified, for: peerID)
|
self.context.setEncryptionStatus(.noiseVerified, for: peerID)
|
||||||
|
|||||||
@@ -109,10 +109,20 @@ struct PanicNetworkLifecycle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct PendingPrivateChatClear {
|
||||||
|
let peerID: PeerID
|
||||||
|
let sourceConversationID: ConversationID
|
||||||
|
let messages: [BitchatMessage]
|
||||||
|
let otherMessageIDs: Set<String>
|
||||||
|
let localPeerID: PeerID
|
||||||
|
let nickname: String
|
||||||
|
let outgoingMedia: [BitchatMessage]
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages the application state and business logic for BitChat.
|
/// Manages the application state and business logic for BitChat.
|
||||||
/// Acts as the primary coordinator between UI components and backend services,
|
/// Acts as the primary coordinator between UI components and backend services,
|
||||||
/// implementing the BitchatDelegate protocol to handle network events.
|
/// implementing the BitchatDelegate protocol to handle network events.
|
||||||
final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
|
final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessageTransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
|
||||||
// Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled)
|
// Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled)
|
||||||
typealias Patterns = MessageFormattingEngine.Patterns
|
typealias Patterns = MessageFormattingEngine.Patterns
|
||||||
|
|
||||||
@@ -376,6 +386,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
@Published var bluetoothAlertMessage = ""
|
@Published var bluetoothAlertMessage = ""
|
||||||
@Published var bluetoothState: CBManagerState = .unknown
|
@Published var bluetoothState: CBManagerState = .unknown
|
||||||
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
|
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
|
||||||
|
@MainActor private var queuedPrivateChatClears: [
|
||||||
|
PendingPrivateChatClear
|
||||||
|
] = []
|
||||||
|
@MainActor private var privateChatClearInFlight = false
|
||||||
|
@MainActor private var privateChatClearGeneration: UInt64 = 0
|
||||||
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
|
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
|
||||||
|
|
||||||
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
||||||
@@ -495,6 +510,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
@@ -637,7 +658,271 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
/// Empties the peer's chat but keeps the conversation alive (`/clear`).
|
/// Empties the peer's chat but keeps the conversation alive (`/clear`).
|
||||||
@MainActor
|
@MainActor
|
||||||
func clearPrivateChat(_ peerID: PeerID) {
|
func clearPrivateChat(_ peerID: PeerID) {
|
||||||
conversations.clear(.directPeer(peerID))
|
let sourceConversationID = ConversationID.directPeer(peerID)
|
||||||
|
// An active live-voice row owns an open FileHandle and may be
|
||||||
|
// republished as frames/final media arrive. Treat it like an in-flight
|
||||||
|
// arrival rather than unlinking its capture or removing its bubble.
|
||||||
|
let messages = privateMessages(for: peerID).filter {
|
||||||
|
!liveVoiceCoordinator.isLiveVoiceMessage($0)
|
||||||
|
}
|
||||||
|
let localPeerID = meshService.myPeerID.toShort()
|
||||||
|
let currentNickname = nickname
|
||||||
|
let mediaPrefixes = [
|
||||||
|
MimeType.Category.audio.messagePrefix,
|
||||||
|
MimeType.Category.image.messagePrefix,
|
||||||
|
MimeType.Category.file.messagePrefix
|
||||||
|
]
|
||||||
|
let outgoingMedia = messages.filter { message in
|
||||||
|
guard mediaPrefixes.contains(where: {
|
||||||
|
message.content.hasPrefix($0)
|
||||||
|
}) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if let senderPeerID = message.senderPeerID {
|
||||||
|
return senderPeerID.toShort() == localPeerID
|
||||||
|
}
|
||||||
|
return message.sender == currentNickname
|
||||||
|
|| message.sender.hasPrefix(currentNickname + "#")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send ownership is canceled at command time even when another clear
|
||||||
|
// transaction is ahead in the queue. UI and files remain untouched
|
||||||
|
// until this request's receiver journal commit succeeds.
|
||||||
|
for message in outgoingMedia {
|
||||||
|
mediaTransferCoordinator
|
||||||
|
.cancelMediaTransferForConversationClear(
|
||||||
|
messageID: message.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
queuedPrivateChatClears.append(PendingPrivateChatClear(
|
||||||
|
peerID: peerID,
|
||||||
|
sourceConversationID: sourceConversationID,
|
||||||
|
messages: messages,
|
||||||
|
otherMessageIDs: Set(
|
||||||
|
privateChats
|
||||||
|
.filter { $0.key != peerID }
|
||||||
|
.flatMap { $0.value.map(\.id) }
|
||||||
|
),
|
||||||
|
localPeerID: localPeerID,
|
||||||
|
nickname: currentNickname,
|
||||||
|
outgoingMedia: outgoingMedia
|
||||||
|
))
|
||||||
|
startNextPrivateChatClearIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func startNextPrivateChatClearIfNeeded() {
|
||||||
|
guard !privateChatClearInFlight,
|
||||||
|
!queuedPrivateChatClears.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
privateChatClearInFlight = true
|
||||||
|
let request = queuedPrivateChatClears.removeFirst()
|
||||||
|
let generation = privateChatClearGeneration
|
||||||
|
performPrivateChatClear(
|
||||||
|
request,
|
||||||
|
generation: generation
|
||||||
|
) { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.privateChatClearGeneration == generation else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.privateChatClearInFlight = false
|
||||||
|
self.startNextPrivateChatClearIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func performPrivateChatClear(
|
||||||
|
_ request: PendingPrivateChatClear,
|
||||||
|
generation: UInt64,
|
||||||
|
completion: @escaping @MainActor () -> Void
|
||||||
|
) {
|
||||||
|
guard privateChatClearGeneration == generation else {
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let peerID = request.peerID
|
||||||
|
let selectedConversationID = request.sourceConversationID
|
||||||
|
let messagesToClear = request.messages
|
||||||
|
guard !messagesToClear.isEmpty else {
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the transaction's exact UI set before any off-main receipt
|
||||||
|
// I/O. Messages arriving while the journal is written are not part of
|
||||||
|
// this command and must remain visible.
|
||||||
|
let capturedMessageIDs = Set(messagesToClear.map(\.id))
|
||||||
|
let survivingMessageIDs = request.otherMessageIDs
|
||||||
|
let mediaPrefixes = [
|
||||||
|
MimeType.Category.audio.messagePrefix,
|
||||||
|
MimeType.Category.image.messagePrefix,
|
||||||
|
MimeType.Category.file.messagePrefix
|
||||||
|
]
|
||||||
|
let localPeerID = request.localPeerID
|
||||||
|
let isMedia: (BitchatMessage) -> Bool = { message in
|
||||||
|
mediaPrefixes.contains(where: message.content.hasPrefix)
|
||||||
|
}
|
||||||
|
let isFromMe: (BitchatMessage) -> Bool = { [nickname = request.nickname] message in
|
||||||
|
if let senderPeerID = message.senderPeerID {
|
||||||
|
return senderPeerID.toShort() == localPeerID
|
||||||
|
}
|
||||||
|
return message.sender == nickname
|
||||||
|
|| message.sender.hasPrefix(nickname + "#")
|
||||||
|
}
|
||||||
|
|
||||||
|
let outgoingMedia = request.outgoingMedia
|
||||||
|
let outgoingMediaIDs = Set(outgoingMedia.map(\.id))
|
||||||
|
|
||||||
|
let capturedExclusiveIDs =
|
||||||
|
capturedMessageIDs.subtracting(survivingMessageIDs)
|
||||||
|
let capturedIncomingMedia = messagesToClear.filter {
|
||||||
|
isMedia($0) && !isFromMe($0)
|
||||||
|
}
|
||||||
|
let capturedStableMediaIDs = Set(
|
||||||
|
capturedIncomingMedia.compactMap { message in
|
||||||
|
PrivateMediaMessageIdentity.isStableID(message.id)
|
||||||
|
? message.id
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func currentRemovalPlan() -> [ConversationID: Set<String>] {
|
||||||
|
// Identity handoff removes the source conversation and inserts its
|
||||||
|
// rows elsewhere. The old source may then be recreated by a new
|
||||||
|
// arrival before journal I/O finishes, so always scan all direct
|
||||||
|
// conversations. Only IDs exclusive at command time may follow a
|
||||||
|
// migration; shared aliases remain outside the source.
|
||||||
|
var plan: [ConversationID: Set<String>] = [:]
|
||||||
|
for (conversationID, conversation) in
|
||||||
|
conversations.conversationsByID {
|
||||||
|
guard case .direct = conversationID else { continue }
|
||||||
|
let eligibleIDs = conversationID == selectedConversationID
|
||||||
|
? capturedMessageIDs
|
||||||
|
: capturedExclusiveIDs
|
||||||
|
let matchingIDs = Set(conversation.messages.map(\.id))
|
||||||
|
.intersection(eligibleIDs)
|
||||||
|
if !matchingIDs.isEmpty {
|
||||||
|
plan[conversationID] = matchingIDs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasRemainingCopy(
|
||||||
|
of messageID: String,
|
||||||
|
after plan: [ConversationID: Set<String>]
|
||||||
|
) -> Bool {
|
||||||
|
conversations.conversationsByID.contains { conversationID, conversation in
|
||||||
|
guard case .direct = conversationID else { return false }
|
||||||
|
return conversation.messages.contains { message in
|
||||||
|
message.id == messageID
|
||||||
|
&& plan[conversationID]?.contains(messageID) != true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func continueClear(
|
||||||
|
persisted: Bool,
|
||||||
|
durableStableIDs: Set<String>
|
||||||
|
) {
|
||||||
|
guard privateChatClearGeneration == generation else {
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard persisted else {
|
||||||
|
SecureLogger.error(
|
||||||
|
"Refusing to clear private chat without durable media tombstones peer=\(peerID.id.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let plan = currentRemovalPlan()
|
||||||
|
let newlyLastStableIDs = Set(
|
||||||
|
capturedStableMediaIDs.filter {
|
||||||
|
!durableStableIDs.contains($0)
|
||||||
|
&& !hasRemainingCopy(of: $0, after: plan)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if !newlyLastStableIDs.isEmpty {
|
||||||
|
persistDeletedPrivateMedia(
|
||||||
|
messageIDs: Array(newlyLastStableIDs).sorted()
|
||||||
|
) { persisted in
|
||||||
|
continueClear(
|
||||||
|
persisted: persisted,
|
||||||
|
durableStableIDs:
|
||||||
|
durableStableIDs.union(newlyLastStableIDs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stable receiver tombstone is global for that message ID.
|
||||||
|
// Remove any alias that arrived while journal I/O was in flight.
|
||||||
|
if !durableStableIDs.isEmpty {
|
||||||
|
let directConversationIDs = conversations
|
||||||
|
.conversationsByID.keys.filter {
|
||||||
|
if case .direct = $0 { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for conversationID in directConversationIDs {
|
||||||
|
conversations.removeMessages(from: conversationID) {
|
||||||
|
durableStableIDs.contains($0.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for message in outgoingMedia {
|
||||||
|
mediaTransferCoordinator.cleanupOutgoingLocalFile(
|
||||||
|
forMessage: message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if !outgoingMediaIDs.isEmpty {
|
||||||
|
let directConversationIDs = conversations
|
||||||
|
.conversationsByID.keys.filter {
|
||||||
|
if case .direct = $0 { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for conversationID in directConversationIDs {
|
||||||
|
conversations.removeMessages(from: conversationID) {
|
||||||
|
outgoingMediaIDs.contains($0.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable payload cleanup belongs entirely to the durable receiver
|
||||||
|
// journal. Legacy/raw incoming payloads have no durable identity,
|
||||||
|
// so their basenames may already belong to a pending new arrival;
|
||||||
|
// leave those files for bounded quota cleanup.
|
||||||
|
let finalPlan = currentRemovalPlan()
|
||||||
|
|
||||||
|
for (conversationID, messageIDs) in finalPlan {
|
||||||
|
conversations.removeMessages(from: conversationID) {
|
||||||
|
messageIDs.contains($0.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialPlan = currentRemovalPlan()
|
||||||
|
let initialStableIDs = Set(
|
||||||
|
capturedStableMediaIDs.filter {
|
||||||
|
!hasRemainingCopy(of: $0, after: initialPlan)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
persistDeletedPrivateMedia(
|
||||||
|
messageIDs: Array(initialStableIDs).sorted()
|
||||||
|
) { persisted in
|
||||||
|
continueClear(
|
||||||
|
persisted: persisted,
|
||||||
|
durableStableIDs: initialStableIDs
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes the peer's chat entirely, including unread state.
|
/// Removes the peer's chat entirely, including unread state.
|
||||||
@@ -1269,6 +1554,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// handles before clearing state or removing the media directory.
|
// handles before clearing state or removing the media directory.
|
||||||
mediaTransferCoordinator.resetForPanic()
|
mediaTransferCoordinator.resetForPanic()
|
||||||
liveVoiceCoordinator.resetForPanic()
|
liveVoiceCoordinator.resetForPanic()
|
||||||
|
privateChatClearGeneration &+= 1
|
||||||
|
queuedPrivateChatClears.removeAll(keepingCapacity: false)
|
||||||
|
privateChatClearInFlight = false
|
||||||
|
|
||||||
// Deny and release any clear-media confirmations before identities,
|
// Deny and release any clear-media confirmations before identities,
|
||||||
// message state, and local files are wiped.
|
// message state, and local files are wiped.
|
||||||
@@ -1701,7 +1989,82 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func didReceiveTransportEvent(_ event: TransportEvent) {
|
func didReceiveTransportEvent(_ event: TransportEvent) {
|
||||||
receiveTransportEvent(event)
|
switch event {
|
||||||
|
case .messageReceived(let message):
|
||||||
|
_ = didReceiveTransportMessageSynchronously(message)
|
||||||
|
|
||||||
|
case let .publicMessageReceived(
|
||||||
|
peerID,
|
||||||
|
nickname,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
messageID
|
||||||
|
):
|
||||||
|
transportEventCoordinator.didReceivePublicMessageSynchronously(
|
||||||
|
from: peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
|
||||||
|
case let .noisePayloadReceived(peerID, type, payload, timestamp):
|
||||||
|
transportEventCoordinator.didReceiveNoisePayloadSynchronously(
|
||||||
|
from: peerID,
|
||||||
|
type: type,
|
||||||
|
payload: payload,
|
||||||
|
timestamp: timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
case let .groupMessageReceived(payload, timestamp):
|
||||||
|
groupCoordinator.handleGroupMessagePayload(
|
||||||
|
payload,
|
||||||
|
timestamp: timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
case let .publicVoiceFrameReceived(
|
||||||
|
peerID,
|
||||||
|
nickname,
|
||||||
|
payload,
|
||||||
|
timestamp
|
||||||
|
):
|
||||||
|
liveVoiceCoordinator.handlePublicVoiceFramePayload(
|
||||||
|
from: peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
payload: payload,
|
||||||
|
timestamp: timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
case .peerConnected(let peerID):
|
||||||
|
transportEventCoordinator.didConnectToPeerSynchronously(peerID)
|
||||||
|
mediaTransferCoordinator.peerDidReconnect(peerID)
|
||||||
|
|
||||||
|
case .peerDisconnected(let peerID):
|
||||||
|
transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID)
|
||||||
|
|
||||||
|
case .peerListUpdated(let peers):
|
||||||
|
peerListCoordinator.didUpdatePeerListSynchronously(peers)
|
||||||
|
// A peer-list update follows every verified announce, which is
|
||||||
|
// where a peer's `.vouch` capability actually arrives.
|
||||||
|
vouchCoordinator.peersUpdated(peers)
|
||||||
|
|
||||||
|
case .peerSnapshotsUpdated:
|
||||||
|
break
|
||||||
|
|
||||||
|
case let .messageDeliveryStatusUpdated(messageID, status):
|
||||||
|
deliveryCoordinator.didUpdateMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: status
|
||||||
|
)
|
||||||
|
|
||||||
|
case .bluetoothStateUpdated(let state):
|
||||||
|
updateBluetoothState(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool {
|
||||||
|
transportEventCoordinator.didReceiveMessageSynchronously(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {
|
func didReceiveMessage(_ message: BitchatMessage) {
|
||||||
@@ -1764,6 +2127,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
func didConnectToPeer(_ peerID: PeerID) {
|
func didConnectToPeer(_ peerID: PeerID) {
|
||||||
transportEventCoordinator.didConnectToPeer(peerID)
|
transportEventCoordinator.didConnectToPeer(peerID)
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.mediaTransferCoordinator.peerDidReconnect(peerID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func didDisconnectFromPeer(_ peerID: PeerID) {
|
func didDisconnectFromPeer(_ peerID: PeerID) {
|
||||||
|
|||||||
@@ -1030,6 +1030,44 @@ struct BLEServiceCoreTests {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicSuspension_finalizesStaleTransportEventsAsRejected() async {
|
||||||
|
let ble = makeService()
|
||||||
|
let delegate = TransportEventCaptureDelegate()
|
||||||
|
ble.eventDelegate = delegate
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "pre-panic-finalization",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "must be rejected",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: "Me",
|
||||||
|
senderPeerID: PeerID(str: "1122334455667788")
|
||||||
|
)
|
||||||
|
var completions = 0
|
||||||
|
var outcomes: [TransportEventDeliveryOutcome] = []
|
||||||
|
|
||||||
|
ble._test_emitTransportEvent(
|
||||||
|
.messageReceived(message),
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { outcomes.append($0) }
|
||||||
|
)
|
||||||
|
ble.suspendForPanicReset()
|
||||||
|
ble._test_emitTransportEvent(
|
||||||
|
.messageReceived(message),
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { outcomes.append($0) }
|
||||||
|
)
|
||||||
|
for _ in 0..<4 {
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(delegate.messageIDs.isEmpty)
|
||||||
|
#expect(completions == 0)
|
||||||
|
#expect(outcomes == [.rejected, .rejected])
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
||||||
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -104,6 +104,20 @@ private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMess
|
|||||||
/// no `ChatViewModel`.
|
/// no `ChatViewModel`.
|
||||||
struct ChatPeerListCoordinatorContextTests {
|
struct ChatPeerListCoordinatorContextTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func synchronousPeerListUpdate_appliesBeforeReturning() {
|
||||||
|
let context = MockChatPeerListContext()
|
||||||
|
let coordinator = ChatPeerListCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "0011223344556677")
|
||||||
|
|
||||||
|
coordinator.didUpdatePeerListSynchronously([peerID])
|
||||||
|
|
||||||
|
#expect(context.isConnected)
|
||||||
|
#expect(context.registeredEphemeralSessions == [peerID])
|
||||||
|
#expect(context.updateEncryptionStatusForPeersCount == 1)
|
||||||
|
#expect(context.cleanupOldReadReceiptsCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
|
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
|
||||||
let context = MockChatPeerListContext()
|
let context = MockChatPeerListContext()
|
||||||
|
|||||||
@@ -220,26 +220,85 @@ 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. Stable-media ACK authorization
|
||||||
coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true))
|
// belongs to BLEFileTransferHandler after its durable commit and this
|
||||||
|
// synchronous acceptance result, not to the generic UI coordinator.
|
||||||
|
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.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func synchronousMessageDeliveryReportsAcceptanceForAckGating() {
|
||||||
|
let context = MockChatTransportEventContext()
|
||||||
|
let coordinator = ChatTransportEventCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
let blocked = makeMessage(
|
||||||
|
id: "blocked-private-media",
|
||||||
|
isPrivate: true,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
context.blockedMessageIDs = [blocked.id]
|
||||||
|
|
||||||
|
#expect(coordinator.didReceiveMessageSynchronously(blocked) == false)
|
||||||
|
#expect(context.handledPrivateMessages.isEmpty)
|
||||||
|
|
||||||
|
let accepted = makeMessage(
|
||||||
|
id: "accepted-private-media",
|
||||||
|
isPrivate: true,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
#expect(coordinator.didReceiveMessageSynchronously(accepted) == true)
|
||||||
|
#expect(context.handledPrivateMessages.map(\.id) == [accepted.id])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -295,6 +354,32 @@ struct ChatTransportEventCoordinatorContextTests {
|
|||||||
#expect(context.notifyUIChangedCount == 2)
|
#expect(context.notifyUIChangedCount == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func synchronousConnectAndDisconnect_applyBeforeReturning() {
|
||||||
|
let context = MockChatTransportEventContext()
|
||||||
|
let coordinator = ChatTransportEventCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "2233445566778899")
|
||||||
|
let incoming = makeMessage(
|
||||||
|
id: "incoming-receipt",
|
||||||
|
isPrivate: true,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
context.privateChats[peerID] = [incoming]
|
||||||
|
|
||||||
|
coordinator.didConnectToPeerSynchronously(peerID)
|
||||||
|
|
||||||
|
#expect(context.isConnected)
|
||||||
|
#expect(context.registeredEphemeralSessions == [peerID])
|
||||||
|
#expect(context.flushedOutboxPeerIDs == [peerID])
|
||||||
|
#expect(context.courierRetryPeerIDs == [peerID])
|
||||||
|
|
||||||
|
coordinator.didDisconnectFromPeerSynchronously(peerID)
|
||||||
|
|
||||||
|
#expect(context.removedEphemeralSessions == [peerID])
|
||||||
|
#expect(context.unmarkedReadReceiptBatches == [[incoming.id]])
|
||||||
|
#expect(context.notifyUIChangedCount == 2)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async {
|
func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async {
|
||||||
let context = MockChatTransportEventContext()
|
let context = MockChatTransportEventContext()
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
|||||||
var noiseSessionKeysByPeerID: [PeerID: Data] = [:]
|
var noiseSessionKeysByPeerID: [PeerID: Data] = [:]
|
||||||
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 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)] = []
|
||||||
|
|
||||||
@@ -113,6 +114,9 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
|||||||
establishedNoiseSessions.contains(peerID)
|
establishedNoiseSessions.contains(peerID)
|
||||||
}
|
}
|
||||||
func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) }
|
func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) }
|
||||||
|
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {
|
||||||
|
privateMediaAuthenticatedPeers.append(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
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))
|
||||||
@@ -277,6 +281,7 @@ struct ChatVerificationCoordinatorContextTests {
|
|||||||
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
|
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
|
||||||
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
|
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
|
||||||
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
|
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
|
||||||
|
#expect(context.privateMediaAuthenticatedPeers == [peerID])
|
||||||
|
|
||||||
// Handshake required -> handshaking status.
|
// Handshake required -> handshaking status.
|
||||||
callbacks?.onHandshakeRequired(peerID)
|
callbacks?.onHandshakeRequired(peerID)
|
||||||
|
|||||||
@@ -864,6 +864,80 @@ struct ChatViewModelPublicConversationTests {
|
|||||||
|
|
||||||
struct ChatViewModelPeerTests {
|
struct ChatViewModelPeerTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func typedPeerLifecycleEvents_applyBeforeReturning() {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
let incoming = BitchatMessage(
|
||||||
|
id: "typed-peer-incoming",
|
||||||
|
sender: "Alice",
|
||||||
|
content: "Hello",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
viewModel.seedPrivateChat([incoming], for: peerID)
|
||||||
|
viewModel.sentReadReceipts.insert(incoming.id)
|
||||||
|
|
||||||
|
viewModel.didReceiveTransportEvent(.peerConnected(peerID))
|
||||||
|
|
||||||
|
#expect(viewModel.isConnected)
|
||||||
|
|
||||||
|
viewModel.didReceiveTransportEvent(.peerDisconnected(peerID))
|
||||||
|
|
||||||
|
#expect(!viewModel.sentReadReceipts.contains(incoming.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func typedPeerListDeliveryAndBluetoothEvents_applyBeforeReturning() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let stalePeer = PeerID(str: "00000000000000a2")
|
||||||
|
let deliveryPeer = PeerID(str: "0102030405060708")
|
||||||
|
let messageID = "typed-delivery-status"
|
||||||
|
let delivered = DeliveryStatus.delivered(
|
||||||
|
to: "Alice",
|
||||||
|
at: Date(timeIntervalSince1970: 1_234)
|
||||||
|
)
|
||||||
|
let outgoing = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
content: "On the way",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: "Alice",
|
||||||
|
senderPeerID: transport.myPeerID,
|
||||||
|
deliveryStatus: .sent
|
||||||
|
)
|
||||||
|
viewModel.markPrivateChatUnread(stalePeer)
|
||||||
|
viewModel.seedPrivateChat([outgoing], for: deliveryPeer)
|
||||||
|
|
||||||
|
viewModel.didReceiveTransportEvent(.peerListUpdated([]))
|
||||||
|
#expect(!viewModel.unreadPrivateMessages.contains(stalePeer))
|
||||||
|
|
||||||
|
viewModel.didReceiveTransportEvent(
|
||||||
|
.messageDeliveryStatusUpdated(
|
||||||
|
messageID: messageID,
|
||||||
|
status: delivered
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateMessages(for: deliveryPeer).first?.deliveryStatus
|
||||||
|
== delivered
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.didReceiveTransportEvent(.bluetoothStateUpdated(.poweredOff))
|
||||||
|
#expect(viewModel.bluetoothState == .poweredOff)
|
||||||
|
#expect(viewModel.showBluetoothAlert)
|
||||||
|
|
||||||
|
// Snapshot events belong to TransportPeerEventsDelegate and are
|
||||||
|
// intentionally ignored at this typed sink.
|
||||||
|
viewModel.didReceiveTransportEvent(.peerSnapshotsUpdated([]))
|
||||||
|
#expect(viewModel.bluetoothState == .poweredOff)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func didConnectToPeer_notifiesDelegate() async {
|
func didConnectToPeer_notifiesDelegate() async {
|
||||||
let (_, transport) = makeTestableViewModel()
|
let (_, transport) = makeTestableViewModel()
|
||||||
@@ -1120,6 +1194,759 @@ struct ChatViewModelBluetoothTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Media Deletion Tests
|
||||||
|
|
||||||
|
struct ChatViewModelPrivateMediaDeletionTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deleteMediaMessageTombstonesIncomingButNotOutgoingStableMedia() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: String(repeating: "8", count: 64))
|
||||||
|
let incomingID = "media-\(String(repeating: "e", count: 32))"
|
||||||
|
let outgoingID = "media-\(String(repeating: "f", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: incomingID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "incoming.jpg"
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: outgoingID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
senderPeerID: transport.myPeerID,
|
||||||
|
recipient: "Peer",
|
||||||
|
filename: "outgoing.jpg"
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
|
||||||
|
viewModel.deleteMediaMessage(messageID: outgoingID)
|
||||||
|
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||||
|
#expect(viewModel.privateChats[peerID]?.map(\.id) == [incomingID])
|
||||||
|
|
||||||
|
viewModel.deleteMediaMessage(messageID: incomingID)
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaRelativePaths
|
||||||
|
== [[incomingID: "images/incoming/incoming.jpg"]]
|
||||||
|
)
|
||||||
|
#expect((viewModel.privateChats[peerID] ?? []).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func stableDeleteProtectsPathSharedWithLegacyBubble() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.persistDeletedPrivateMediaResult = false
|
||||||
|
let peerID = PeerID(str: String(repeating: "6", count: 64))
|
||||||
|
let stableID = "media-\(String(repeating: "5", count: 32))"
|
||||||
|
let legacyID = UUID().uuidString
|
||||||
|
let filename = "shared-migration.jpg"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: stableID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: filename
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: legacyID,
|
||||||
|
sender: "Old client",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: filename
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
|
||||||
|
viewModel.deleteMediaMessage(messageID: stableID)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[stableID]]
|
||||||
|
)
|
||||||
|
#expect(transport.deletedPrivateMediaRelativePaths == [[:]])
|
||||||
|
#expect(
|
||||||
|
transport.protectedPrivateMediaRelativePaths == [[
|
||||||
|
"images/incoming/\(filename)"
|
||||||
|
]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[peerID]?.map(\.id)
|
||||||
|
== [stableID, legacyID]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func legacyIncomingDeleteLeavesAmbiguousPayloadForQuota() throws {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: String(repeating: "3", count: 64))
|
||||||
|
let incoming = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent(
|
||||||
|
"files/images/incoming",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: incoming,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let payload = incoming.appendingPathComponent(
|
||||||
|
"legacy-delete-\(UUID().uuidString).jpg"
|
||||||
|
)
|
||||||
|
try Data([0x01]).write(to: payload)
|
||||||
|
defer { try? FileManager.default.removeItem(at: payload) }
|
||||||
|
let legacyID = UUID().uuidString
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: legacyID,
|
||||||
|
sender: "Old client",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: payload.lastPathComponent
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
|
||||||
|
viewModel.deleteMediaMessage(messageID: legacyID)
|
||||||
|
|
||||||
|
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||||
|
#expect((viewModel.privateChats[peerID] ?? []).isEmpty)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatTombstonesIncomingAndCancelsOutgoingBeforeClear() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: String(repeating: "1", count: 64))
|
||||||
|
let incomingID = "media-\(String(repeating: "a", count: 32))"
|
||||||
|
let outgoingID = "media-\(String(repeating: "b", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: incomingID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "incoming.jpg"
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: outgoingID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
senderPeerID: transport.myPeerID,
|
||||||
|
recipient: "Peer",
|
||||||
|
filename: "outgoing.jpg"
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id: "ordinary-message",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "hello",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
viewModel.registerTransfer(
|
||||||
|
transferId: "outgoing-clear-transfer",
|
||||||
|
messageID: outgoingID
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(peerID)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaRelativePaths
|
||||||
|
== [[incomingID: "images/incoming/incoming.jpg"]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
transport.cancelledTransfers == ["outgoing-clear-transfer"]
|
||||||
|
)
|
||||||
|
#expect(viewModel.messageIDToTransferId[outgoingID] == nil)
|
||||||
|
#expect(viewModel.privateChats[peerID]?.isEmpty == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatPreservesCapturedMessagesWhenTombstoneFails() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.persistDeletedPrivateMediaResult = false
|
||||||
|
let peerID = PeerID(str: String(repeating: "2", count: 64))
|
||||||
|
let incomingID = "media-\(String(repeating: "c", count: 32))"
|
||||||
|
let outgoingID = "media-\(String(repeating: "7", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: incomingID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "incoming.jpg"
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: outgoingID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
senderPeerID: transport.myPeerID,
|
||||||
|
recipient: "Peer",
|
||||||
|
filename: "outgoing.jpg"
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id: "ordinary-message",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "keep me on failure",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
viewModel.registerTransfer(
|
||||||
|
transferId: "failed-clear-outgoing",
|
||||||
|
messageID: outgoingID
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(peerID)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[peerID]?.map(\.id)
|
||||||
|
== [incomingID, outgoingID, "ordinary-message"]
|
||||||
|
)
|
||||||
|
#expect(transport.cancelledTransfers == ["failed-clear-outgoing"])
|
||||||
|
#expect(viewModel.messageIDToTransferId[outgoingID] == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearFailurePreservesSameNameIncomingPayload() throws {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.persistDeletedPrivateMediaResult = false
|
||||||
|
let peerID = PeerID(str: String(repeating: "7", count: 64))
|
||||||
|
let incomingID = "media-\(String(repeating: "8", count: 32))"
|
||||||
|
let outgoingID = "media-\(String(repeating: "9", count: 32))"
|
||||||
|
let filename = "clear-collision-\(UUID().uuidString).jpg"
|
||||||
|
let filesDirectory = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent("files/images", isDirectory: true)
|
||||||
|
let incomingDirectory = filesDirectory.appendingPathComponent(
|
||||||
|
"incoming",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
let outgoingDirectory = filesDirectory.appendingPathComponent(
|
||||||
|
"outgoing",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: incomingDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: outgoingDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let incomingURL = incomingDirectory.appendingPathComponent(filename)
|
||||||
|
let outgoingURL = outgoingDirectory.appendingPathComponent(filename)
|
||||||
|
try Data("incoming".utf8).write(to: incomingURL, options: .atomic)
|
||||||
|
try Data("outgoing".utf8).write(to: outgoingURL, options: .atomic)
|
||||||
|
defer {
|
||||||
|
try? FileManager.default.removeItem(at: incomingURL)
|
||||||
|
try? FileManager.default.removeItem(at: outgoingURL)
|
||||||
|
}
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: incomingID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: filename
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: outgoingID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
senderPeerID: transport.myPeerID,
|
||||||
|
recipient: "Peer",
|
||||||
|
filename: filename
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(peerID)
|
||||||
|
|
||||||
|
#expect(FileManager.default.fileExists(atPath: incomingURL.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: outgoingURL.path))
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaRelativePaths
|
||||||
|
== [[incomingID: "images/incoming/\(filename)"]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[peerID]?.map(\.id)
|
||||||
|
== [incomingID, outgoingID]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatPreservesArrivalDuringTombstoneIO() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.deferDeletedPrivateMediaPersistence = true
|
||||||
|
let peerID = PeerID(str: String(repeating: "3", count: 64))
|
||||||
|
let incomingID = "media-\(String(repeating: "d", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: incomingID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "incoming.jpg"
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id: "captured-text",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "old",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(peerID)
|
||||||
|
let arrival = BitchatMessage(
|
||||||
|
id: "concurrent-arrival",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "new",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
#expect(viewModel.appendPrivateMessage(arrival, to: peerID))
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[peerID]?.map(\.id)
|
||||||
|
== ["concurrent-arrival"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatPreservesActiveLiveVoiceAssembly() throws {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: String(repeating: "7", count: 64))
|
||||||
|
viewModel.selectedPrivateChatPeer = peerID
|
||||||
|
let burstID = Data(
|
||||||
|
repeating: 0xE1,
|
||||||
|
count: VoiceBurstPacket.burstIDSize
|
||||||
|
)
|
||||||
|
let start = try #require(VoiceBurstPacket(
|
||||||
|
burstID: burstID,
|
||||||
|
seq: 0,
|
||||||
|
kind: .start(codec: .aacLC16kMono)
|
||||||
|
))
|
||||||
|
let cancel = try #require(VoiceBurstPacket(
|
||||||
|
burstID: burstID,
|
||||||
|
seq: 1,
|
||||||
|
kind: .canceled
|
||||||
|
))
|
||||||
|
let coordinator = viewModel.liveVoiceCoordinator
|
||||||
|
defer {
|
||||||
|
coordinator.handleVoiceFramePayload(
|
||||||
|
from: peerID,
|
||||||
|
payload: cancel.encode(),
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
coordinator.handleVoiceFramePayload(
|
||||||
|
from: peerID,
|
||||||
|
payload: start.encode(),
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
let liveMessage = try #require(
|
||||||
|
viewModel.privateChats[peerID]?.first
|
||||||
|
)
|
||||||
|
#expect(coordinator.isLiveVoiceMessage(liveMessage))
|
||||||
|
let ordinary = BitchatMessage(
|
||||||
|
id: "clear-around-live-voice",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "old text",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
#expect(viewModel.appendPrivateMessage(ordinary, to: peerID))
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(peerID)
|
||||||
|
|
||||||
|
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[peerID]?.map(\.id)
|
||||||
|
== [liveMessage.id]
|
||||||
|
)
|
||||||
|
#expect(coordinator.isLiveVoiceMessage(liveMessage))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func overlappingClearsTombstoneTheLastMirroredStableAlias() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.deferDeletedPrivateMediaPersistence = true
|
||||||
|
let firstPeerID = PeerID(str: String(repeating: "b", count: 64))
|
||||||
|
let secondPeerID = PeerID(str: String(repeating: "c", count: 64))
|
||||||
|
let sharedID = "media-\(String(repeating: "1", count: 32))"
|
||||||
|
let firstUniqueID = "media-\(String(repeating: "2", count: 32))"
|
||||||
|
let secondUniqueID = "media-\(String(repeating: "3", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: sharedID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "shared.jpg"
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: firstUniqueID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "first.jpg"
|
||||||
|
)
|
||||||
|
], for: firstPeerID)
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: sharedID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: secondPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "shared.jpg"
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: secondUniqueID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: secondPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "second.jpg"
|
||||||
|
)
|
||||||
|
], for: secondPeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(firstPeerID)
|
||||||
|
viewModel.clearPrivateChat(secondPeerID)
|
||||||
|
let queuedArrival = BitchatMessage(
|
||||||
|
id: "arrival-after-queued-clear",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "new",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: secondPeerID
|
||||||
|
)
|
||||||
|
#expect(viewModel.appendPrivateMessage(
|
||||||
|
queuedArrival,
|
||||||
|
to: secondPeerID
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches
|
||||||
|
== [[firstUniqueID]]
|
||||||
|
)
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [
|
||||||
|
[firstUniqueID],
|
||||||
|
[secondUniqueID, sharedID].sorted()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect((viewModel.privateChats[firstPeerID] ?? []).isEmpty)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[secondPeerID]?.map(\.id)
|
||||||
|
== ["arrival-after-queued-clear"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearFollowsCapturedRowsAcrossPeerIdentityMigration() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.deferDeletedPrivateMediaPersistence = true
|
||||||
|
let sourcePeerID = PeerID(str: String(repeating: "d", count: 64))
|
||||||
|
let destinationPeerID = PeerID(str: String(repeating: "e", count: 64))
|
||||||
|
let thirdPeerID = PeerID(str: String(repeating: "f", count: 64))
|
||||||
|
let stableID = "media-\(String(repeating: "4", count: 32))"
|
||||||
|
viewModel.selectedPrivateChatPeer = sourcePeerID
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: stableID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: sourcePeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "migrated.jpg"
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id: "captured-before-migration",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "old",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: sourcePeerID
|
||||||
|
)
|
||||||
|
], for: sourcePeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(sourcePeerID)
|
||||||
|
viewModel.migratePrivateChat(
|
||||||
|
from: sourcePeerID,
|
||||||
|
to: destinationPeerID
|
||||||
|
)
|
||||||
|
viewModel.selectedPrivateChatPeer = thirdPeerID
|
||||||
|
let arrival = BitchatMessage(
|
||||||
|
id: "arrival-after-migration",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "new",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: destinationPeerID
|
||||||
|
)
|
||||||
|
#expect(viewModel.appendPrivateMessage(
|
||||||
|
arrival,
|
||||||
|
to: destinationPeerID
|
||||||
|
))
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[destinationPeerID]?.map(\.id)
|
||||||
|
== ["arrival-after-migration"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearFollowsMigrationWhenOldSourceIsRecreated() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.deferDeletedPrivateMediaPersistence = true
|
||||||
|
let sourcePeerID = PeerID(str: String(repeating: "1", count: 64))
|
||||||
|
let destinationPeerID = PeerID(str: String(repeating: "2", count: 64))
|
||||||
|
let stableID = "media-\(String(repeating: "6", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: stableID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: sourcePeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "migrated-recreated.jpg"
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id: "captured-before-recreation",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "old",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: sourcePeerID
|
||||||
|
)
|
||||||
|
], for: sourcePeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(sourcePeerID)
|
||||||
|
viewModel.migratePrivateChat(
|
||||||
|
from: sourcePeerID,
|
||||||
|
to: destinationPeerID
|
||||||
|
)
|
||||||
|
let recreatedArrival = BitchatMessage(
|
||||||
|
id: "arrival-recreating-source",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "new",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: sourcePeerID
|
||||||
|
)
|
||||||
|
#expect(viewModel.appendPrivateMessage(
|
||||||
|
recreatedArrival,
|
||||||
|
to: sourcePeerID
|
||||||
|
))
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
(viewModel.privateChats[destinationPeerID] ?? []).isEmpty
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[sourcePeerID]?.map(\.id)
|
||||||
|
== ["arrival-recreating-source"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatKeepsMediaReferencedByAnotherConversation() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let firstPeerID = PeerID(str: String(repeating: "4", count: 64))
|
||||||
|
let aliasPeerID = PeerID(str: String(repeating: "5", count: 64))
|
||||||
|
let messageID = "media-\(String(repeating: "6", count: 32))"
|
||||||
|
let message = privateMediaMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "mirrored.jpg"
|
||||||
|
)
|
||||||
|
viewModel.seedPrivateChat([message], for: firstPeerID)
|
||||||
|
viewModel.seedPrivateChat([message], for: aliasPeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(firstPeerID)
|
||||||
|
|
||||||
|
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||||
|
#expect(viewModel.privateChats[firstPeerID]?.isEmpty == true)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[aliasPeerID]?.map(\.id) == [messageID]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatLeavesAmbiguousLegacyFileForQuotaCleanup()
|
||||||
|
throws {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let firstPeerID = PeerID(str: String(repeating: "9", count: 64))
|
||||||
|
let aliasPeerID = PeerID(str: String(repeating: "a", count: 64))
|
||||||
|
let incomingDirectory = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent("files/images/incoming", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: incomingDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let fileURL = incomingDirectory.appendingPathComponent(
|
||||||
|
"legacy-clear-\(UUID().uuidString).jpg"
|
||||||
|
)
|
||||||
|
try Data("legacy-image".utf8).write(to: fileURL, options: .atomic)
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let message = privateMediaMessage(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
sender: "Old client",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: fileURL.lastPathComponent
|
||||||
|
)
|
||||||
|
viewModel.seedPrivateChat([message], for: firstPeerID)
|
||||||
|
viewModel.seedPrivateChat([message], for: aliasPeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(firstPeerID)
|
||||||
|
|
||||||
|
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
#expect(viewModel.privateChats[aliasPeerID]?.map(\.id) == [message.id])
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(aliasPeerID)
|
||||||
|
|
||||||
|
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
#expect((viewModel.privateChats[aliasPeerID] ?? []).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicInvalidatesActiveAndQueuedPrivateChatClears() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.deferDeletedPrivateMediaPersistence = true
|
||||||
|
let firstPeerID = PeerID(str: String(repeating: "4", count: 64))
|
||||||
|
let secondPeerID = PeerID(str: String(repeating: "5", count: 64))
|
||||||
|
let firstID = "media-\(String(repeating: "6", count: 32))"
|
||||||
|
let secondID = "media-\(String(repeating: "7", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: firstID,
|
||||||
|
sender: "First",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "first-pre-panic.jpg"
|
||||||
|
)
|
||||||
|
], for: firstPeerID)
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: secondID,
|
||||||
|
sender: "Second",
|
||||||
|
senderPeerID: secondPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "second-pre-panic.jpg"
|
||||||
|
)
|
||||||
|
], for: secondPeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(firstPeerID)
|
||||||
|
viewModel.clearPrivateChat(secondPeerID)
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[firstID]]
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = viewModel.panicClearAllData(restartServices: false)
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: firstID,
|
||||||
|
sender: "First",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "first-post-panic.jpg"
|
||||||
|
)
|
||||||
|
], for: firstPeerID)
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: secondID,
|
||||||
|
sender: "Second",
|
||||||
|
senderPeerID: secondPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "second-post-panic.jpg"
|
||||||
|
)
|
||||||
|
], for: secondPeerID)
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[firstID]]
|
||||||
|
)
|
||||||
|
#expect(viewModel.privateChats[firstPeerID]?.map(\.id) == [firstID])
|
||||||
|
#expect(viewModel.privateChats[secondPeerID]?.map(\.id) == [secondID])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func privateMediaMessage(
|
||||||
|
id: String,
|
||||||
|
sender: String,
|
||||||
|
senderPeerID: PeerID,
|
||||||
|
recipient: String,
|
||||||
|
filename: String
|
||||||
|
) -> BitchatMessage {
|
||||||
|
BitchatMessage(
|
||||||
|
id: id,
|
||||||
|
sender: sender,
|
||||||
|
content: "\(MimeType.Category.image.messagePrefix)\(filename)",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: recipient,
|
||||||
|
senderPeerID: senderPeerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Panic Clear Tests
|
// MARK: - Panic Clear Tests
|
||||||
|
|
||||||
struct ChatViewModelPanicTests {
|
struct ChatViewModelPanicTests {
|
||||||
|
|||||||
@@ -364,6 +364,217 @@ struct PrivateMediaEndToEndTests {
|
|||||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
|
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func privateMediaRetryRequiresExactAuthenticatedBit9Proof() async throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"private-media-receipt-proof-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let alice = makeService(
|
||||||
|
baseDirectory: root.appendingPathComponent(
|
||||||
|
"alice",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let bob = makeService(
|
||||||
|
baseDirectory: root.appendingPathComponent(
|
||||||
|
"bob",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let bothCapabilities: PeerCapabilities = [
|
||||||
|
.privateMedia,
|
||||||
|
.privateMediaReceipts
|
||||||
|
]
|
||||||
|
|
||||||
|
// A public bit-9 announce is discovery only.
|
||||||
|
alice._test_seedConnectedPeer(
|
||||||
|
bob.myPeerID,
|
||||||
|
nickname: "Bob",
|
||||||
|
capabilities: bothCapabilities,
|
||||||
|
noisePublicKey: bob.noiseStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to: bob.myPeerID
|
||||||
|
) == nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let proofs = try await establishSessionCapturingPeerState(
|
||||||
|
alice: alice,
|
||||||
|
bob: bob
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to: bob.myPeerID
|
||||||
|
) == nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bit 8 alone preserves encrypted transfer compatibility but cannot
|
||||||
|
// authorize automatic resend.
|
||||||
|
let privateMediaOnly = try authenticatedPeerStatePacket(
|
||||||
|
from: bob,
|
||||||
|
to: alice,
|
||||||
|
capabilities: .privateMedia
|
||||||
|
)
|
||||||
|
alice._test_handlePacket(
|
||||||
|
privateMediaOnly,
|
||||||
|
fromPeerID: bob.myPeerID
|
||||||
|
)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{
|
||||||
|
alice.privateMediaSendPolicy(to: bob.myPeerID)
|
||||||
|
== .encrypted
|
||||||
|
},
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
#expect(
|
||||||
|
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to: bob.myPeerID
|
||||||
|
) == nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let receiptCapable = try authenticatedPeerStatePacket(
|
||||||
|
from: bob,
|
||||||
|
to: alice,
|
||||||
|
capabilities: bothCapabilities
|
||||||
|
)
|
||||||
|
alice._test_handlePacket(
|
||||||
|
receiptCapable,
|
||||||
|
fromPeerID: bob.myPeerID
|
||||||
|
)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{
|
||||||
|
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to: bob.myPeerID
|
||||||
|
) != nil
|
||||||
|
},
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
|
||||||
|
bob._test_handlePacket(
|
||||||
|
proofs.alice,
|
||||||
|
fromPeerID: alice.myPeerID
|
||||||
|
)
|
||||||
|
alice._test_onOutboundPacket = nil
|
||||||
|
bob._test_onOutboundPacket = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func receiptRetryRechecksBit9AtDeferredTransportBoundary() async throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"private-media-retry-proof-race-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let alice = makeService(
|
||||||
|
baseDirectory: root.appendingPathComponent(
|
||||||
|
"alice",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let bob = makeService(
|
||||||
|
baseDirectory: root.appendingPathComponent(
|
||||||
|
"bob",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let receiptCapabilities: PeerCapabilities = [
|
||||||
|
.privateMedia,
|
||||||
|
.privateMediaReceipts
|
||||||
|
]
|
||||||
|
alice._test_seedConnectedPeer(
|
||||||
|
bob.myPeerID,
|
||||||
|
nickname: "Bob",
|
||||||
|
capabilities: receiptCapabilities,
|
||||||
|
noisePublicKey: bob.noiseStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
bob._test_seedConnectedPeer(
|
||||||
|
alice.myPeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
capabilities: receiptCapabilities,
|
||||||
|
noisePublicKey: alice.noiseStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
try await establishSession(alice: alice, bob: bob)
|
||||||
|
#expect(
|
||||||
|
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to: bob.myPeerID
|
||||||
|
) != nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let privateMediaOnly = try authenticatedPeerStatePacket(
|
||||||
|
from: bob,
|
||||||
|
to: alice,
|
||||||
|
capabilities: .privateMedia
|
||||||
|
)
|
||||||
|
let transferID =
|
||||||
|
"receipt-proof-race-\(UUID().uuidString)"
|
||||||
|
let tap = PacketTap()
|
||||||
|
let boundaryProofs = ReceiptCapabilityRecorder()
|
||||||
|
let rejections = TransferCancellationRecorder()
|
||||||
|
let cancellable = TransferProgressManager.shared.publisher.sink {
|
||||||
|
rejections.record($0)
|
||||||
|
}
|
||||||
|
alice._test_onOutboundPacket = tap.record
|
||||||
|
alice._test_beforePrivateMediaDeferredSend = { id in
|
||||||
|
guard id == transferID else { return }
|
||||||
|
boundaryProofs.record(
|
||||||
|
alice
|
||||||
|
.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to: bob.myPeerID
|
||||||
|
) != nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
alice._test_beforePrivateMediaDeferredSend = nil
|
||||||
|
alice._test_onOutboundPacket = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate authenticated state before the deferred retry reaches its
|
||||||
|
// admission boundary.
|
||||||
|
alice._test_handlePacket(
|
||||||
|
privateMediaOnly,
|
||||||
|
fromPeerID: bob.myPeerID
|
||||||
|
)
|
||||||
|
let content = Data("%PDF-1.7\nreceipt-proof-race".utf8)
|
||||||
|
alice.sendFilePrivateReceiptRetry(
|
||||||
|
BitchatFilePacket(
|
||||||
|
fileName: "receipt-proof-race.pdf",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
content: content
|
||||||
|
),
|
||||||
|
to: bob.myPeerID,
|
||||||
|
transferId: transferID
|
||||||
|
)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ boundaryProofs.snapshot() == [false] },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
await alice._test_drainPrivateMediaSendPipeline()
|
||||||
|
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ rejections.contains(transferID) },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
#expect(tap.snapshot().allSatisfy {
|
||||||
|
$0.type != MessageType.fileTransfer.rawValue
|
||||||
|
&& !(
|
||||||
|
$0.type == MessageType.noiseEncrypted.rawValue
|
||||||
|
&& $0.version == 2
|
||||||
|
)
|
||||||
|
})
|
||||||
|
let state = alice._test_privateMediaTransferState(
|
||||||
|
transferId: transferID
|
||||||
|
)
|
||||||
|
#expect(!state.admissionActive)
|
||||||
|
#expect(!state.pendingNoise)
|
||||||
|
_ = cancellable
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws {
|
func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws {
|
||||||
let root = FileManager.default.temporaryDirectory
|
let root = FileManager.default.temporaryDirectory
|
||||||
@@ -682,13 +893,18 @@ struct PrivateMediaEndToEndTests {
|
|||||||
#expect(!identity.hasObservedPrivateMediaCapability(
|
#expect(!identity.hasObservedPrivateMediaCapability(
|
||||||
fingerprint: impostorKey.sha256Fingerprint()
|
fingerprint: impostorKey.sha256Fingerprint()
|
||||||
))
|
))
|
||||||
|
#expect(identity.hasObservedPrivateMediaCapability(
|
||||||
|
fingerprint: bob.noiseStaticPublicKeyData().sha256Fingerprint()
|
||||||
|
))
|
||||||
alice._test_seedConnectedPeer(
|
alice._test_seedConnectedPeer(
|
||||||
bob.myPeerID,
|
bob.myPeerID,
|
||||||
nickname: "Bob",
|
nickname: "Bob",
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
noisePublicKey: impostorKey
|
noisePublicKey: impostorKey
|
||||||
)
|
)
|
||||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
|
// The exact live Noise identity remains authoritative over a later
|
||||||
|
// impostor registry rewrite.
|
||||||
|
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -866,7 +1082,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,
|
||||||
@@ -1137,6 +1353,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)
|
||||||
@@ -1279,6 +1506,8 @@ struct PrivateMediaEndToEndTests {
|
|||||||
|
|
||||||
return enumerator.compactMap { item in
|
return enumerator.compactMap { item in
|
||||||
guard let url = item as? URL,
|
guard let url = item as? URL,
|
||||||
|
!url.pathComponents.contains(".private-media-receipts"),
|
||||||
|
url.lastPathComponent != ".private-media-receipts.json",
|
||||||
(try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else {
|
(try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1379,6 +1608,23 @@ private final class PrivateMediaPolicyRecorder: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class ReceiptCapabilityRecorder: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var values: [Bool] = []
|
||||||
|
|
||||||
|
func record(_ value: Bool) {
|
||||||
|
lock.lock()
|
||||||
|
values.append(value)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot() -> [Bool] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var messages: [BitchatMessage] = []
|
private var messages: [BitchatMessage] = []
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import BitFoundation
|
|||||||
|
|
||||||
/// Mock Transport implementation for testing ChatViewModel in isolation.
|
/// Mock Transport implementation for testing ChatViewModel in isolation.
|
||||||
/// Records all method calls and allows test code to verify interactions.
|
/// Records all method calls and allows test code to verify interactions.
|
||||||
final class MockTransport: Transport {
|
final class MockTransport: Transport, PrivateMediaDeletionPersisting {
|
||||||
|
|
||||||
// MARK: - Protocol Properties
|
// MARK: - Protocol Properties
|
||||||
|
|
||||||
@@ -38,6 +38,11 @@ final class MockTransport: Transport {
|
|||||||
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
|
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
|
||||||
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
|
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
|
||||||
private(set) var cancelledTransfers: [String] = []
|
private(set) var cancelledTransfers: [String] = []
|
||||||
|
private(set) var deletedPrivateMediaMessageIDBatches: [[String]] = []
|
||||||
|
private(set) var deletedPrivateMediaRelativePaths: [
|
||||||
|
[String: String]
|
||||||
|
] = []
|
||||||
|
private(set) var protectedPrivateMediaRelativePaths: [Set<String>] = []
|
||||||
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = []
|
private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = []
|
||||||
@@ -60,6 +65,11 @@ final class MockTransport: Transport {
|
|||||||
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 persistDeletedPrivateMediaResult = true
|
||||||
|
var deferDeletedPrivateMediaPersistence = false
|
||||||
|
private var pendingDeletedPrivateMediaCompletions: [
|
||||||
|
@MainActor (Bool) -> Void
|
||||||
|
] = []
|
||||||
private let mockKeychain = MockKeychain()
|
private let mockKeychain = MockKeychain()
|
||||||
|
|
||||||
// MARK: - Transport Protocol Implementation
|
// MARK: - Transport Protocol Implementation
|
||||||
@@ -217,6 +227,34 @@ final class MockTransport: Transport {
|
|||||||
cancelledTransfers.append(transferId)
|
cancelledTransfers.append(transferId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func persistDeletedPrivateMedia(
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String],
|
||||||
|
protectedPayloadRelativePaths: Set<String>,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
) {
|
||||||
|
deletedPrivateMediaMessageIDBatches.append(messageIDs)
|
||||||
|
deletedPrivateMediaRelativePaths.append(payloadRelativePaths)
|
||||||
|
protectedPrivateMediaRelativePaths.append(
|
||||||
|
protectedPayloadRelativePaths
|
||||||
|
)
|
||||||
|
if deferDeletedPrivateMediaPersistence {
|
||||||
|
pendingDeletedPrivateMediaCompletions.append(completion)
|
||||||
|
} else {
|
||||||
|
completion(persistDeletedPrivateMediaResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func resolveNextDeletedPrivateMediaPersistence(
|
||||||
|
_ result: Bool? = nil
|
||||||
|
) {
|
||||||
|
guard !pendingDeletedPrivateMediaCompletions.isEmpty else { return }
|
||||||
|
let completion = pendingDeletedPrivateMediaCompletions.removeFirst()
|
||||||
|
completion(result ?? persistDeletedPrivateMediaResult)
|
||||||
|
}
|
||||||
|
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||||
sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA))
|
sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA))
|
||||||
}
|
}
|
||||||
@@ -264,6 +302,9 @@ final class MockTransport: Transport {
|
|||||||
sentBroadcastFiles.removeAll()
|
sentBroadcastFiles.removeAll()
|
||||||
sentPrivateFiles.removeAll()
|
sentPrivateFiles.removeAll()
|
||||||
cancelledTransfers.removeAll()
|
cancelledTransfers.removeAll()
|
||||||
|
deletedPrivateMediaMessageIDBatches.removeAll()
|
||||||
|
deletedPrivateMediaRelativePaths.removeAll()
|
||||||
|
protectedPrivateMediaRelativePaths.removeAll()
|
||||||
sentVerifyChallenges.removeAll()
|
sentVerifyChallenges.removeAll()
|
||||||
sentVerifyResponses.removeAll()
|
sentVerifyResponses.removeAll()
|
||||||
startServicesCallCount = 0
|
startServicesCallCount = 0
|
||||||
|
|||||||
@@ -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,17 +13,109 @@ 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 receiptStates: [String: BLEPrivateMediaReceiptState] = [:]
|
||||||
|
var receiptCommits: [(messageID: String, storedURL: URL)] = []
|
||||||
|
var receiptCommitSucceeds = true
|
||||||
|
var removedIncomingFiles: [URL] = []
|
||||||
|
var finishedIncomingFileDeliveries: [URL] = []
|
||||||
var lastSeenUpdates: [PeerID] = []
|
var lastSeenUpdates: [PeerID] = []
|
||||||
|
var deliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||||
var deliveredMessages: [BitchatMessage] = []
|
var deliveredMessages: [BitchatMessage] = []
|
||||||
|
var shouldAcceptDelivery = true
|
||||||
|
var deliveryOutcome = TransportEventDeliveryOutcome.accepted
|
||||||
|
var saveOverride: ((
|
||||||
|
_ data: Data,
|
||||||
|
_ preferredName: String?,
|
||||||
|
_ subdirectory: String,
|
||||||
|
_ fallbackExtension: String?,
|
||||||
|
_ defaultPrefix: String
|
||||||
|
) -> URL?)?
|
||||||
|
var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)?
|
||||||
|
var receiptCommitOverride: ((String, URL) -> Bool)?
|
||||||
|
var removeIncomingFileOverride: ((URL) -> Void)?
|
||||||
|
var finishIncomingFileDeliveryOverride: ((URL) -> Void)?
|
||||||
}
|
}
|
||||||
|
|
||||||
private let localPeerID = PeerID(str: "0102030405060708")
|
private let localPeerID = PeerID(str: "0102030405060708")
|
||||||
private let remotePeerID = PeerID(str: "1122334455667788")
|
private let remotePeerID = PeerID(str: "1122334455667788")
|
||||||
private let sampleSigningKey = Data(repeating: 0xAB, count: 32)
|
private let sampleSigningKey = Data(repeating: 0xAB, count: 32)
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deliveryGateFinalizesInitialRejection() {
|
||||||
|
var completions = 0
|
||||||
|
var finalizations = 0
|
||||||
|
|
||||||
|
TransportEventDeliveryGate.attempt(
|
||||||
|
shouldDeliver: { false },
|
||||||
|
deliver: {
|
||||||
|
Issue.record("delivery sink must not run")
|
||||||
|
return .accepted
|
||||||
|
},
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { _ in finalizations += 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(completions == 0)
|
||||||
|
#expect(finalizations == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deliveryGateFinalizesMissingOrRejectingSink() {
|
||||||
|
var completions = 0
|
||||||
|
var finalizations = 0
|
||||||
|
|
||||||
|
TransportEventDeliveryGate.attempt(
|
||||||
|
shouldDeliver: { true },
|
||||||
|
deliver: { .rejected },
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { _ in finalizations += 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(completions == 0)
|
||||||
|
#expect(finalizations == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deliveryGateFinalizesPostInsertionRejection() {
|
||||||
|
var deliveryChecks = 0
|
||||||
|
var completions = 0
|
||||||
|
var finalizations = 0
|
||||||
|
|
||||||
|
TransportEventDeliveryGate.attempt(
|
||||||
|
shouldDeliver: {
|
||||||
|
deliveryChecks += 1
|
||||||
|
return deliveryChecks == 1
|
||||||
|
},
|
||||||
|
deliver: { .accepted },
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { _ in finalizations += 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(deliveryChecks == 2)
|
||||||
|
#expect(completions == 0)
|
||||||
|
#expect(finalizations == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deliveryGatePreservesInvokedUnconfirmedOutcomeWithoutAck() {
|
||||||
|
var completions = 0
|
||||||
|
var outcomes: [TransportEventDeliveryOutcome] = []
|
||||||
|
|
||||||
|
TransportEventDeliveryGate.attempt(
|
||||||
|
shouldDeliver: { true },
|
||||||
|
deliver: { .invokedUnconfirmed },
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { outcomes.append($0) }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(completions == 0)
|
||||||
|
#expect(outcomes == [.invokedUnconfirmed])
|
||||||
|
}
|
||||||
|
|
||||||
private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler {
|
private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler {
|
||||||
let environment = BLEFileTransferHandlerEnvironment(
|
let environment = BLEFileTransferHandlerEnvironment(
|
||||||
localPeerID: { [localPeerID] in localPeerID },
|
localPeerID: { [localPeerID] in localPeerID },
|
||||||
@@ -46,13 +138,56 @@ 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
|
||||||
},
|
},
|
||||||
|
privateMediaReceiptState: { messageID in
|
||||||
|
if let receiptStateOverride = recorder.receiptStateOverride {
|
||||||
|
return receiptStateOverride(messageID)
|
||||||
|
}
|
||||||
|
return recorder.receiptStates[messageID] ?? .absent
|
||||||
|
},
|
||||||
|
commitPrivateMediaFile: { messageID, storedURL in
|
||||||
|
recorder.receiptCommits.append((messageID, storedURL))
|
||||||
|
if let receiptCommitOverride = recorder.receiptCommitOverride {
|
||||||
|
return receiptCommitOverride(messageID, storedURL)
|
||||||
|
}
|
||||||
|
guard recorder.receiptCommitSucceeds else { return false }
|
||||||
|
recorder.receiptStates[messageID] = .accepted(storedURL)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
removeIncomingFile: { storedURL in
|
||||||
|
recorder.removedIncomingFiles.append(storedURL)
|
||||||
|
recorder.removeIncomingFileOverride?(storedURL)
|
||||||
|
},
|
||||||
|
finishIncomingFileDelivery: { storedURL in
|
||||||
|
recorder.finishedIncomingFileDeliveries.append(storedURL)
|
||||||
|
recorder.finishIncomingFileDeliveryOverride?(storedURL)
|
||||||
|
},
|
||||||
|
isPrivateMediaSenderBlocked: { peerID in
|
||||||
|
recorder.blockedPeers.contains(peerID)
|
||||||
|
},
|
||||||
updatePeerLastSeen: { peerID in
|
updatePeerLastSeen: { peerID in
|
||||||
recorder.lastSeenUpdates.append(peerID)
|
recorder.lastSeenUpdates.append(peerID)
|
||||||
},
|
},
|
||||||
deliverMessage: { message in
|
acknowledgePrivateMedia: { messageID, peerID in
|
||||||
|
recorder.deliveryAcks.append((messageID, peerID))
|
||||||
|
},
|
||||||
|
deliverMessage: { message, shouldDeliver, completion, finalization in
|
||||||
|
var outcome = TransportEventDeliveryOutcome.rejected
|
||||||
|
defer { finalization(outcome) }
|
||||||
|
guard recorder.shouldAcceptDelivery else { return }
|
||||||
|
guard shouldDeliver() else { return }
|
||||||
recorder.deliveredMessages.append(message)
|
recorder.deliveredMessages.append(message)
|
||||||
|
guard shouldDeliver() else { return }
|
||||||
|
if recorder.deliveryOutcome == .invokedUnconfirmed {
|
||||||
|
outcome = .invokedUnconfirmed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outcome = .accepted
|
||||||
|
completion()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return BLEFileTransferHandler(environment: environment)
|
return BLEFileTransferHandler(environment: environment)
|
||||||
@@ -284,6 +419,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).
|
||||||
@@ -291,13 +427,14 @@ struct BLEFileTransferHandlerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws {
|
func bit8EncryptedPrivateFileKeepsStableIDAndAckWithoutBit9Proof() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
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 +453,480 @@ 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
|
||||||
|
))
|
||||||
|
#expect(recorder.receiptCommits.count == 1)
|
||||||
|
#expect(recorder.deliveryAcks.count == 1)
|
||||||
|
#expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func rejectedStableDeliveryReleasesPendingPayloadOwnership() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||||
|
"private-media-handler-rejected-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true
|
||||||
|
)]
|
||||||
|
recorder.shouldAcceptDelivery = false
|
||||||
|
recorder.saveOverride = {
|
||||||
|
store.save(
|
||||||
|
data: $0,
|
||||||
|
preferredName: $1,
|
||||||
|
subdirectory: $2,
|
||||||
|
fallbackExtension: $3,
|
||||||
|
defaultPrefix: $4
|
||||||
|
)
|
||||||
|
}
|
||||||
|
recorder.receiptStateOverride = {
|
||||||
|
store.privateMediaReceiptState(messageID: $0)
|
||||||
|
}
|
||||||
|
recorder.receiptCommitOverride = {
|
||||||
|
store.commitPrivateMediaFile(messageID: $0, storedURL: $1)
|
||||||
|
}
|
||||||
|
recorder.removeIncomingFileOverride = {
|
||||||
|
store.removeIncomingFile(at: $0)
|
||||||
|
}
|
||||||
|
recorder.finishIncomingFileDeliveryOverride = {
|
||||||
|
store.finishIncomingFileDelivery(at: $0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
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 stableID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: remotePeerID,
|
||||||
|
recipientPeerID: localPeerID,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(makeHandler(recorder: recorder).handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.finishedIncomingFileDeliveries.count == 1)
|
||||||
|
#expect(store.reservePrivateMediaDeletion(
|
||||||
|
messageIDs: [stableID],
|
||||||
|
payloadRelativePaths: [:]
|
||||||
|
) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true,
|
||||||
|
signingPublicKey: sampleSigningKey
|
||||||
|
)]
|
||||||
|
recorder.signatureVerifies = true
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let packet = try makeFileTransferPacket(
|
||||||
|
sender: remotePeerID,
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content,
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(handler.handle(packet, from: remotePeerID))
|
||||||
|
#expect(recorder.receiptCommits.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func rejectedRawDeliveryRemovesUIUnownedPayload() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true,
|
||||||
|
signingPublicKey: sampleSigningKey
|
||||||
|
)]
|
||||||
|
recorder.signatureVerifies = true
|
||||||
|
recorder.shouldAcceptDelivery = false
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let packet = try makeFileTransferPacket(
|
||||||
|
sender: remotePeerID,
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: Data([0xFF, 0xD8, 0xFF, 0xD9]),
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
fileName: "raw-rejected.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(handler.handle(packet, from: remotePeerID))
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
#expect(recorder.removedIncomingFiles.count == 1)
|
||||||
|
#expect(recorder.removedIncomingFiles.first == recorder.saveResult)
|
||||||
|
#expect(recorder.finishedIncomingFileDeliveries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func plainDelegateRawDeliveryPreservesPayloadWithoutSynchronousAck() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true,
|
||||||
|
signingPublicKey: sampleSigningKey
|
||||||
|
)]
|
||||||
|
recorder.signatureVerifies = true
|
||||||
|
recorder.deliveryOutcome = .invokedUnconfirmed
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let packet = try makeFileTransferPacket(
|
||||||
|
sender: remotePeerID,
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: Data([0xFF, 0xD8, 0xFF, 0xD9]),
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
fileName: "plain-delegate.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(handler.handle(packet, from: remotePeerID))
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.removedIncomingFiles.isEmpty)
|
||||||
|
#expect(recorder.finishedIncomingFileDeliveries.count == 1)
|
||||||
|
#expect(
|
||||||
|
recorder.finishedIncomingFileDeliveries.first
|
||||||
|
== recorder.saveResult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 lostCapabilityProofThenStableRetryReusesDurableIDWithoutSecondDiskWrite() 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
|
||||||
|
))
|
||||||
|
|
||||||
|
// First encrypted arrival may precede the sender's authenticated bit-9
|
||||||
|
// proof. It still uses the bit-8 stable ID/ACK contract.
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
// A later automatic retry after proof must resolve the same durable ID
|
||||||
|
// rather than create a legacy random-ID bubble.
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(recorder.quotaReservations == [content.count])
|
||||||
|
#expect(recorder.saveCalls.count == 1)
|
||||||
|
// The handler re-offers a durable duplicate so a relaunched UI can
|
||||||
|
// restore its bubble; the synchronous conversation sink deduplicates.
|
||||||
|
#expect(recorder.deliveredMessages.count == 2)
|
||||||
|
#expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID])
|
||||||
|
#expect(recorder.deliveryAcks.count == 2)
|
||||||
|
#expect(recorder.deliveryAcks.allSatisfy {
|
||||||
|
$0.messageID == expectedID && $0.peerID == remotePeerID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func acceptedPrivateMediaAfterRelaunchRedeliversDurableURLBeforeAck() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||||
|
"private-media-handler-relaunch-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
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())
|
||||||
|
|
||||||
|
func configure(_ recorder: Recorder) {
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true
|
||||||
|
)]
|
||||||
|
recorder.saveOverride = { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
||||||
|
store.save(
|
||||||
|
data: data,
|
||||||
|
preferredName: preferredName,
|
||||||
|
subdirectory: subdirectory,
|
||||||
|
fallbackExtension: fallbackExtension,
|
||||||
|
defaultPrefix: defaultPrefix
|
||||||
|
)
|
||||||
|
}
|
||||||
|
recorder.receiptStateOverride = {
|
||||||
|
store.privateMediaReceiptState(messageID: $0)
|
||||||
|
}
|
||||||
|
recorder.receiptCommitOverride = {
|
||||||
|
store.commitPrivateMediaFile(messageID: $0, storedURL: $1)
|
||||||
|
}
|
||||||
|
recorder.removeIncomingFileOverride = {
|
||||||
|
store.removeIncomingFile(at: $0)
|
||||||
|
}
|
||||||
|
recorder.finishIncomingFileDeliveryOverride = {
|
||||||
|
store.finishIncomingFileDelivery(at: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let first = Recorder()
|
||||||
|
configure(first)
|
||||||
|
#expect(makeHandler(recorder: first).handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
let originalMessage = try #require(first.deliveredMessages.first)
|
||||||
|
#expect(first.deliveryAcks.count == 1)
|
||||||
|
|
||||||
|
// A fresh handler models process relaunch: its in-memory reservation
|
||||||
|
// cache is empty, so only the durable receipt can suppress disk work.
|
||||||
|
let relaunched = Recorder()
|
||||||
|
configure(relaunched)
|
||||||
|
#expect(makeHandler(recorder: relaunched).handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(relaunched.quotaReservations.isEmpty)
|
||||||
|
#expect(relaunched.saveCalls.isEmpty)
|
||||||
|
#expect(relaunched.receiptCommits.isEmpty)
|
||||||
|
#expect(relaunched.deliveredMessages.count == 1)
|
||||||
|
#expect(relaunched.deliveredMessages.first?.id == originalMessage.id)
|
||||||
|
#expect(relaunched.deliveredMessages.first?.content == originalMessage.content)
|
||||||
|
#expect(relaunched.deliveryAcks.count == 1)
|
||||||
|
#expect(relaunched.deliveryAcks.first?.messageID == originalMessage.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.deliveryAcks.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.deliveryAcks.count == 1)
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func unavailableDurableReceiptStateWithholdsDiskDeliveryAndAck() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true
|
||||||
|
)]
|
||||||
|
let fileName =
|
||||||
|
"img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
let messageID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: remotePeerID,
|
||||||
|
recipientPeerID: localPeerID,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
recorder.receiptStates[messageID] = .unavailable
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let payload = try #require(BitchatFilePacket(
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
).encode())
|
||||||
|
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(recorder.quotaReservations.isEmpty)
|
||||||
|
#expect(recorder.saveCalls.isEmpty)
|
||||||
|
#expect(recorder.receiptCommits.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func durableReceiptCommitFailureRollsBackAndWithholdsDeliveryAck() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true
|
||||||
|
)]
|
||||||
|
recorder.receiptCommitSucceeds = false
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let payload = try #require(BitchatFilePacket(
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
).encode())
|
||||||
|
|
||||||
|
#expect(!handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(recorder.saveCalls.count == 1)
|
||||||
|
#expect(recorder.receiptCommits.count == 1)
|
||||||
|
#expect(recorder.removedIncomingFiles.count == 1)
|
||||||
|
#expect(recorder.removedIncomingFiles.first == recorder.saveResult)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.deliveryAcks.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)
|
||||||
|
#expect(recorder.deliveryAcks.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -337,6 +948,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.deliveryAcks.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.isEmpty)
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +1080,94 @@ struct BLEFileTransferHandlerTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicWipeClearsCachedPrivateMediaReceiptDecisions() throws {
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"panic-receipt-cache-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: base) }
|
||||||
|
let messageID = "media-00112233445566778899aabbccddeeff"
|
||||||
|
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: base)
|
||||||
|
let payload = base
|
||||||
|
.appendingPathComponent(
|
||||||
|
"files/images/incoming",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent("panic-receipt.jpg")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: payload.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try Data("secret".utf8).write(to: payload, options: .atomic)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
#expect(seed.recordDeleted(messageID: messageID))
|
||||||
|
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||||
|
#expect(
|
||||||
|
store.privateMediaReceiptState(messageID: messageID)
|
||||||
|
== .tombstoned
|
||||||
|
)
|
||||||
|
|
||||||
|
try store.panicWipe()
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
store.privateMediaReceiptState(messageID: messageID)
|
||||||
|
== .absent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicWipeInvalidatesPayloadCoordinationReservations() throws {
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"panic-payload-coordination-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: base) }
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||||
|
let pendingName = "pending-before-panic.jpg"
|
||||||
|
let pendingURL = try #require(store.save(
|
||||||
|
data: Data("old".utf8),
|
||||||
|
preferredName: pendingName,
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
))
|
||||||
|
let messageID = "media-aabbccddeeff00112233445566778899"
|
||||||
|
let reservation = try #require(store.reservePrivateMediaDeletion(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/delete-before-panic.jpg"
|
||||||
|
]
|
||||||
|
))
|
||||||
|
|
||||||
|
try store.panicWipe()
|
||||||
|
|
||||||
|
#expect(!store.commitPrivateMediaDeletion(
|
||||||
|
reservation: reservation,
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/delete-before-panic.jpg"
|
||||||
|
],
|
||||||
|
protectedPayloadRelativePaths: []
|
||||||
|
))
|
||||||
|
let postPanicURL = try #require(store.save(
|
||||||
|
data: Data("new".utf8),
|
||||||
|
preferredName: pendingName,
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
))
|
||||||
|
#expect(pendingURL.lastPathComponent == pendingName)
|
||||||
|
#expect(postPanicURL.lastPathComponent == pendingName)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
||||||
enum MarkerFailure: Error { case unavailable }
|
enum MarkerFailure: Error { case unavailable }
|
||||||
|
|||||||
@@ -111,5 +111,8 @@ struct BLENoiseReconnectPolicyTests {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
#expect(PeerCapabilities.localSupported.contains(.privateMedia))
|
#expect(PeerCapabilities.localSupported.contains(.privateMedia))
|
||||||
|
#expect(
|
||||||
|
PeerCapabilities.localSupported.contains(.privateMediaReceipts)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,866 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
struct BLEPrivateMediaReceiptStoreTests {
|
||||||
|
private struct TestError: Error {}
|
||||||
|
private struct ReceiptFixture: Codable {
|
||||||
|
let kind: String
|
||||||
|
let relativePath: String?
|
||||||
|
let recordedAt: Date
|
||||||
|
}
|
||||||
|
private struct JournalEntryFixture: Codable {
|
||||||
|
let relativePaths: [String]
|
||||||
|
let recordedAt: Date
|
||||||
|
}
|
||||||
|
private struct JournalFixture: Codable {
|
||||||
|
let version: Int
|
||||||
|
let entries: [String: JournalEntryFixture]
|
||||||
|
}
|
||||||
|
|
||||||
|
private let messageID = "media-00112233445566778899aabbccddeeff"
|
||||||
|
private let secondMessageID = "media-ffeeddccbbaa99887766554433221100"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func acceptedReceiptPersistsAcrossStoreInstances() throws {
|
||||||
|
let root = makeRoot("persist")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
|
||||||
|
let first = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(first.commitAccepted(messageID: messageID, storedURL: payload))
|
||||||
|
#expect(first.state(for: messageID) == .accepted(payload))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .accepted(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func acceptedReceiptRejectsOutgoingPayloadAndCrossIDPathReuse() throws {
|
||||||
|
let root = makeRoot("accepted-ownership")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let incoming = try makePayload(in: root)
|
||||||
|
let outgoingDirectory = root.appendingPathComponent(
|
||||||
|
"files/images/outgoing",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: outgoingDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let outgoing = outgoingDirectory.appendingPathComponent("image.jpg")
|
||||||
|
try Data([0x01]).write(to: outgoing)
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
|
||||||
|
#expect(!store.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: outgoing
|
||||||
|
))
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: incoming
|
||||||
|
))
|
||||||
|
#expect(!store.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: incoming
|
||||||
|
))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: incoming.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: outgoing.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func liveAcceptedPathSurvivesTTLAndCapacityPressure() throws {
|
||||||
|
let root = makeRoot("accepted-retention")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||||
|
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||||
|
let recordedAt = Date(timeIntervalSince1970: 2_000)
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
capacity: 1,
|
||||||
|
ttl: 1,
|
||||||
|
now: { recordedAt }
|
||||||
|
)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: firstPayload
|
||||||
|
))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
capacity: 1,
|
||||||
|
ttl: 1,
|
||||||
|
now: { recordedAt.addingTimeInterval(2) }
|
||||||
|
)
|
||||||
|
#expect(relaunched.state(for: messageID) == .accepted(firstPayload))
|
||||||
|
#expect(!relaunched.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: secondPayload
|
||||||
|
))
|
||||||
|
#expect(relaunched.state(for: messageID) == .accepted(firstPayload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func incomingAllocationDoesNotReuseReceiptOwnedMissingPath() throws {
|
||||||
|
let root = makeRoot("accepted-reservation")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let original = try makePayload(in: root)
|
||||||
|
#expect(BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root
|
||||||
|
).commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: original
|
||||||
|
))
|
||||||
|
try FileManager.default.removeItem(at: original)
|
||||||
|
|
||||||
|
let stored = BLEIncomingFileStore(baseDirectory: root).save(
|
||||||
|
data: Data([0x03]),
|
||||||
|
preferredName: "image.jpg",
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(stored?.lastPathComponent != "image.jpg")
|
||||||
|
#expect(stored.map {
|
||||||
|
FileManager.default.fileExists(atPath: $0.path)
|
||||||
|
} == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func pendingRawArrivalBlocksDeletionFallbackPathReuse() throws {
|
||||||
|
let root = makeRoot("pending-raw-arrival")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let incoming = BLEIncomingFileStore(baseDirectory: root)
|
||||||
|
|
||||||
|
// The old stable bubble names image.jpg, but its receipt and payload
|
||||||
|
// have already been pruned. A raw arrival saves that basename before
|
||||||
|
// its main-actor bubble is inserted.
|
||||||
|
let rawArrival = incoming.save(
|
||||||
|
data: Data([0x03]),
|
||||||
|
preferredName: "image.jpg",
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
)
|
||||||
|
#expect(rawArrival?.lastPathComponent == "image.jpg")
|
||||||
|
|
||||||
|
let reservation = incoming.reservePrivateMediaDeletion(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/image.jpg"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
#expect(reservation == nil)
|
||||||
|
#expect(rawArrival.map {
|
||||||
|
FileManager.default.fileExists(atPath: $0.path)
|
||||||
|
} == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func quotaDoesNotEvictOrReusePendingDeliveryPath() throws {
|
||||||
|
let root = makeRoot("pending-quota")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let incoming = BLEIncomingFileStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
quotaBytes: 1
|
||||||
|
)
|
||||||
|
let first = try #require(incoming.save(
|
||||||
|
data: Data([0x01, 0x02]),
|
||||||
|
preferredName: "image.jpg",
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
))
|
||||||
|
|
||||||
|
incoming.enforceQuota(reservingBytes: 1)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: first.path))
|
||||||
|
|
||||||
|
let second = try #require(incoming.save(
|
||||||
|
data: Data([0x03]),
|
||||||
|
preferredName: "image.jpg",
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
))
|
||||||
|
#expect(second != first)
|
||||||
|
#expect(second.lastPathComponent == "image (1).jpg")
|
||||||
|
#expect(FileManager.default.fileExists(atPath: first.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: second.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func invalidReceiptAndJournalCannotTargetOutgoingPayload() throws {
|
||||||
|
let root = makeRoot("invalid-owned-path")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let outgoingDirectory = root.appendingPathComponent(
|
||||||
|
"files/images/outgoing",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: outgoingDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let victim = outgoingDirectory.appendingPathComponent("victim.jpg")
|
||||||
|
try Data([0x02]).write(to: victim)
|
||||||
|
let receiptURL = receiptRecord(in: root)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: receiptURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let fixture = ReceiptFixture(
|
||||||
|
kind: "tombstone",
|
||||||
|
relativePath: "images/outgoing/victim.jpg",
|
||||||
|
recordedAt: Date()
|
||||||
|
)
|
||||||
|
try JSONEncoder().encode(fixture).write(
|
||||||
|
to: receiptURL,
|
||||||
|
options: .atomic
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
.state(for: messageID) == .unavailable
|
||||||
|
)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: victim.path))
|
||||||
|
|
||||||
|
try FileManager.default.removeItem(at: receiptURL)
|
||||||
|
let journal = JournalFixture(
|
||||||
|
version: 1,
|
||||||
|
entries: [messageID: JournalEntryFixture(
|
||||||
|
relativePaths: ["images/outgoing/victim.jpg"],
|
||||||
|
recordedAt: fixture.recordedAt
|
||||||
|
)]
|
||||||
|
)
|
||||||
|
try JSONEncoder().encode(journal).write(
|
||||||
|
to: deletionJournal(in: root),
|
||||||
|
options: .atomic
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
.state(for: messageID) == .unavailable
|
||||||
|
)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: victim.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws {
|
||||||
|
let root = makeRoot("list-failure")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
let record = receiptRecord(in: root)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
|
||||||
|
var shouldFail = true
|
||||||
|
let store = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
directoryReader: { directory in
|
||||||
|
if shouldFail {
|
||||||
|
shouldFail = false
|
||||||
|
throw TestError()
|
||||||
|
}
|
||||||
|
return try FileManager.default.contentsOfDirectory(
|
||||||
|
at: directory,
|
||||||
|
includingPropertiesForKeys: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(store.state(for: messageID) == .unavailable)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
#expect(store.state(for: messageID) == .accepted(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func recordReadFailureIsUnavailableAndPreservesReceiptForRetry() throws {
|
||||||
|
let root = makeRoot("read-failure")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
let record = receiptRecord(in: root)
|
||||||
|
|
||||||
|
var shouldFail = true
|
||||||
|
let store = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
dataReader: { url in
|
||||||
|
if shouldFail {
|
||||||
|
shouldFail = false
|
||||||
|
throw TestError()
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(store.state(for: messageID) == .unavailable)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
#expect(store.state(for: messageID) == .accepted(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func decodeFailureIsUnavailableAndDoesNotDeleteOrCachePastRepair() throws {
|
||||||
|
let root = makeRoot("decode-failure")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
let record = receiptRecord(in: root)
|
||||||
|
let durableBytes = try Data(contentsOf: record)
|
||||||
|
let corruptBytes = Data("{not-json".utf8)
|
||||||
|
try corruptBytes.write(to: record, options: .atomic)
|
||||||
|
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.state(for: messageID) == .unavailable)
|
||||||
|
#expect(!store.commitAccepted(messageID: messageID, storedURL: payload))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
#expect(try Data(contentsOf: record) == corruptBytes)
|
||||||
|
|
||||||
|
try durableBytes.write(to: record, options: .atomic)
|
||||||
|
#expect(store.state(for: messageID) == .accepted(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func unreadableTombstoneNeverBecomesAbsentOrGetsDeleted() throws {
|
||||||
|
let root = makeRoot("tombstone-decode")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(seed.commitAccepted(messageID: messageID, storedURL: payload))
|
||||||
|
#expect(seed.recordDeleted(messageID: messageID))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
|
||||||
|
let record = receiptRecord(in: root)
|
||||||
|
let durableBytes = try Data(contentsOf: record)
|
||||||
|
try Data([0xFF, 0x00, 0x7B]).write(to: record, options: .atomic)
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .unavailable)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
|
||||||
|
try durableBytes.write(to: record, options: .atomic)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func deletionBatchCommitsEveryIDBeforeRemovingPayloads() throws {
|
||||||
|
let root = makeRoot("batch")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||||
|
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: firstPayload
|
||||||
|
))
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: secondPayload
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(store.recordDeleted(
|
||||||
|
messageIDs: [secondMessageID, messageID]
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(store.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(store.state(for: secondMessageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: secondPayload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func failedJournalCommitPreservesAcceptedStateAndPayloads() throws {
|
||||||
|
let root = makeRoot("journal-failure")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||||
|
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: firstPayload
|
||||||
|
))
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: secondPayload
|
||||||
|
))
|
||||||
|
|
||||||
|
let failing = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
dataWriter: { _, _, _ in throw TestError() }
|
||||||
|
)
|
||||||
|
#expect(!failing.recordDeleted(
|
||||||
|
messageIDs: [messageID, secondMessageID]
|
||||||
|
))
|
||||||
|
|
||||||
|
// A failed commit must not install a volatile tombstone. Otherwise a
|
||||||
|
// sender retry could be ACKed although the caller kept both bubbles.
|
||||||
|
#expect(failing.state(for: messageID) == .accepted(firstPayload))
|
||||||
|
#expect(
|
||||||
|
failing.state(for: secondMessageID) == .accepted(secondPayload)
|
||||||
|
)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: firstPayload.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: secondPayload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func journalRecoversBatchAfterCrashDuringMaterialization() throws {
|
||||||
|
let root = makeRoot("crash-recovery")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||||
|
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: firstPayload
|
||||||
|
))
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: secondPayload
|
||||||
|
))
|
||||||
|
|
||||||
|
let interrupted = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
dataWriter: { data, url, options in
|
||||||
|
let isJournal =
|
||||||
|
url.lastPathComponent == ".deletion-journal.json"
|
||||||
|
let isFirstRecord =
|
||||||
|
url.deletingPathExtension().lastPathComponent == messageID
|
||||||
|
guard isJournal || isFirstRecord else {
|
||||||
|
throw TestError()
|
||||||
|
}
|
||||||
|
try data.write(to: url, options: options)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
#expect(interrupted.recordDeleted(
|
||||||
|
messageIDs: [messageID, secondMessageID]
|
||||||
|
))
|
||||||
|
#expect(FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: secondPayload.path))
|
||||||
|
#expect(interrupted.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(interrupted.state(for: secondMessageID) == .tombstoned)
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(relaunched.state(for: secondMessageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: secondPayload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func journalRetriesPayloadUnlinkAfterRelaunch() throws {
|
||||||
|
let root = makeRoot("unlink-recovery")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
|
||||||
|
let unlinkFailure = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
payloadRemover: { _ in throw TestError() }
|
||||||
|
)
|
||||||
|
#expect(unlinkFailure.recordDeleted(messageID: messageID))
|
||||||
|
#expect(unlinkFailure.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func expiredReceiptUsesFallbackPathForCrashSafeCleanup() throws {
|
||||||
|
let root = makeRoot("expired-fallback")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let recordedAt = Date(timeIntervalSince1970: 1_000)
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
ttl: 10,
|
||||||
|
now: { recordedAt }
|
||||||
|
)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
// Simulate a receipt pruned by an older app version while its bubble
|
||||||
|
// and payload remain. Current code retains live accepted-path owners.
|
||||||
|
try FileManager.default.removeItem(at: receiptRecord(in: root))
|
||||||
|
|
||||||
|
let afterExpiry = recordedAt.addingTimeInterval(11)
|
||||||
|
let interrupted = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
ttl: 10,
|
||||||
|
now: { afterExpiry },
|
||||||
|
payloadRemover: { _ in throw TestError() }
|
||||||
|
)
|
||||||
|
#expect(interrupted.recordDeleted(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/image.jpg"
|
||||||
|
]
|
||||||
|
))
|
||||||
|
#expect(interrupted.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
ttl: 10,
|
||||||
|
now: { afterExpiry }
|
||||||
|
)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func retryAtSuffixedPathDeletesReceiptAndBubblePayloads() throws {
|
||||||
|
let root = makeRoot("retry-suffix")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let original = try makePayload(in: root)
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: original
|
||||||
|
))
|
||||||
|
|
||||||
|
// Simulate an older build pruning only the receipt. A retry must use a
|
||||||
|
// suffixed filename while the old bubble still references image.jpg.
|
||||||
|
try FileManager.default.removeItem(at: receiptRecord(in: root))
|
||||||
|
let retry = try makePayload(in: root, name: "image (1).jpg")
|
||||||
|
let retried = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(retried.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: retry
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(retried.recordDeleted(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/image.jpg"
|
||||||
|
]
|
||||||
|
))
|
||||||
|
#expect(retried.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: original.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: retry.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: original.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: retry.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func protectedUIPathRejectsAcceptedReceiptDeletion() throws {
|
||||||
|
let root = makeRoot("protected-ui-owner")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(!store.recordDeleted(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
protectedPayloadRelativePaths: [
|
||||||
|
"images/incoming/image.jpg"
|
||||||
|
]
|
||||||
|
))
|
||||||
|
#expect(store.state(for: messageID) == .accepted(payload))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func pathlessNewDeletionFailsWithoutChangingReceiverState() {
|
||||||
|
let root = makeRoot("pathless-delete")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
|
||||||
|
#expect(!store.recordDeleted(messageID: messageID))
|
||||||
|
#expect(store.state(for: messageID) == .absent)
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func completedTombstoneNeverDeletesReusedPayloadPath() throws {
|
||||||
|
let root = makeRoot("path-reuse")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let original = try makePayload(in: root)
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: original
|
||||||
|
))
|
||||||
|
#expect(store.recordDeleted(messageID: messageID))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: original.path))
|
||||||
|
|
||||||
|
let reused = try makePayload(in: root)
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: reused
|
||||||
|
))
|
||||||
|
#expect(store.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: reused.path))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: reused.path))
|
||||||
|
#expect(
|
||||||
|
relaunched.state(for: secondMessageID) == .accepted(reused)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func expiredLegacyPathfulTombstoneDoesNotDeleteAcceptedOwner() throws {
|
||||||
|
let root = makeRoot("legacy-path-conflict")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let receiptDirectory = receiptRecord(in: root)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: receiptDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let recordedAt = Date(timeIntervalSince1970: 3_000)
|
||||||
|
try JSONEncoder().encode(ReceiptFixture(
|
||||||
|
kind: "tombstone",
|
||||||
|
relativePath: "images/incoming/image.jpg",
|
||||||
|
recordedAt: recordedAt
|
||||||
|
)).write(
|
||||||
|
to: receiptRecord(in: root),
|
||||||
|
options: .atomic
|
||||||
|
)
|
||||||
|
try JSONEncoder().encode(ReceiptFixture(
|
||||||
|
kind: "accepted",
|
||||||
|
relativePath: "images/incoming/image.jpg",
|
||||||
|
recordedAt: recordedAt
|
||||||
|
)).write(
|
||||||
|
to: receiptRecord(
|
||||||
|
in: root,
|
||||||
|
messageID: secondMessageID
|
||||||
|
),
|
||||||
|
options: .atomic
|
||||||
|
)
|
||||||
|
|
||||||
|
let store = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
ttl: 1,
|
||||||
|
now: { recordedAt.addingTimeInterval(2) }
|
||||||
|
)
|
||||||
|
#expect(store.state(for: messageID) == .absent)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(
|
||||||
|
store.state(for: secondMessageID) == .accepted(payload)
|
||||||
|
)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func expiredLegacyPathfulTombstonePreservesAmbiguousPayload() throws {
|
||||||
|
let root = makeRoot("legacy-expired-cleanup")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let receiptURL = receiptRecord(in: root)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: receiptURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let recordedAt = Date(timeIntervalSince1970: 3_000)
|
||||||
|
try JSONEncoder().encode(ReceiptFixture(
|
||||||
|
kind: "tombstone",
|
||||||
|
relativePath: "images/incoming/image.jpg",
|
||||||
|
recordedAt: recordedAt
|
||||||
|
)).write(to: receiptURL, options: .atomic)
|
||||||
|
|
||||||
|
let store = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
ttl: 1,
|
||||||
|
now: { recordedAt.addingTimeInterval(2) }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(store.state(for: messageID) == .absent)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: receiptURL.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func deletionJournalNeverRecursivelyRemovesDirectoryTarget() throws {
|
||||||
|
let root = makeRoot("journal-directory")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let directory = root.appendingPathComponent(
|
||||||
|
"files/images/incoming/archive",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let child = directory.appendingPathComponent("child.jpg")
|
||||||
|
try Data([0x01]).write(to: child)
|
||||||
|
let receiptDirectory = receiptRecord(in: root)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: receiptDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
#expect(!BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root
|
||||||
|
).recordDeleted(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/archive"
|
||||||
|
]
|
||||||
|
))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
try JSONEncoder().encode(JournalFixture(
|
||||||
|
version: 1,
|
||||||
|
entries: [messageID: JournalEntryFixture(
|
||||||
|
relativePaths: ["images/incoming/archive"],
|
||||||
|
recordedAt: Date()
|
||||||
|
)]
|
||||||
|
)).write(to: deletionJournal(in: root), options: .atomic)
|
||||||
|
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: directory.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: child.path))
|
||||||
|
#expect(FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func corruptDeletionJournalFailsClosedWithoutRemovingPayload() throws {
|
||||||
|
let root = makeRoot("corrupt-journal")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
#expect(BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root
|
||||||
|
).commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
let journal = deletionJournal(in: root)
|
||||||
|
try Data("{not-json".utf8).write(to: journal, options: .atomic)
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .unavailable)
|
||||||
|
#expect(!relaunched.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(try Data(contentsOf: journal) == Data("{not-json".utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func unreleasedAggregateLedgerIsIgnoredAndLeftUntouched() throws {
|
||||||
|
let root = makeRoot("no-legacy-migration")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let files = root.appendingPathComponent("files", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: files,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let legacy = files.appendingPathComponent(
|
||||||
|
".private-media-receipts.json",
|
||||||
|
isDirectory: false
|
||||||
|
)
|
||||||
|
let bytes = Data(
|
||||||
|
#"{"entries":{"media-00112233445566778899aabbccddeeff":{"relativePath":"images/incoming/old.jpg","acceptedAt":0}}}"#
|
||||||
|
.utf8
|
||||||
|
)
|
||||||
|
try bytes.write(to: legacy, options: .atomic)
|
||||||
|
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.state(for: messageID) == .absent)
|
||||||
|
#expect(try Data(contentsOf: legacy) == bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRoot(_ label: String) -> URL {
|
||||||
|
FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||||
|
"private-media-receipt-\(label)-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePayload(
|
||||||
|
in root: URL,
|
||||||
|
name: String = "image.jpg"
|
||||||
|
) throws -> URL {
|
||||||
|
let directory = root.appendingPathComponent(
|
||||||
|
"files/images/incoming",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let payload = directory.appendingPathComponent(name)
|
||||||
|
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receiptRecord(
|
||||||
|
in root: URL,
|
||||||
|
messageID requestedMessageID: String? = nil
|
||||||
|
) -> URL {
|
||||||
|
root
|
||||||
|
.appendingPathComponent(
|
||||||
|
"files/.private-media-receipts",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent(requestedMessageID ?? messageID)
|
||||||
|
.appendingPathExtension("json")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deletionJournal(in root: URL) -> URL {
|
||||||
|
root
|
||||||
|
.appendingPathComponent(
|
||||||
|
"files/.private-media-receipts",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent(".deletion-journal.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,13 @@ peer's Noise session before BLE fragmentation.
|
|||||||
`0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a
|
`0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a
|
||||||
no-proof timeout reaches the explicit legacy-consent path for an unpinned
|
no-proof timeout reaches the explicit legacy-consent path for an unpinned
|
||||||
peer. No timeout automatically sends raw bytes.
|
peer. No timeout automatically sends raw bytes.
|
||||||
|
- `PeerCapabilities.privateMediaReceipts` is bit 9. Its exact-session
|
||||||
|
authenticated proof enables bounded sender-side automatic retry; a public
|
||||||
|
announce never does. It does not replace bit 8: encrypted `0x20` media from
|
||||||
|
bit-8-only prior iOS clients keeps the same deterministic stable ID and
|
||||||
|
delivery ACK. Receivers durably commit that ID before UI delivery or ACK, so
|
||||||
|
a lost proof followed by a later bit-9 retry cannot create a second,
|
||||||
|
random-ID bubble.
|
||||||
- An unpinned peer with a stable Noise key but without that capability is
|
- An unpinned peer with a stable Noise key but without that capability is
|
||||||
eligible for one signed, directed
|
eligible for one signed, directed
|
||||||
`fileTransfer`, matching the pre-migration wire form used by older iOS and
|
`fileTransfer`, matching the pre-migration wire form used by older iOS and
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
|
|||||||
/// before outer BLE fragmentation. Peers that omit this bit require the
|
/// before outer BLE fragmentation. Peers that omit this bit require the
|
||||||
/// signed directed raw-file migration fallback.
|
/// signed directed raw-file migration fallback.
|
||||||
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
|
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
|
||||||
|
/// Stable private-media IDs are durably deduplicated by the receiver and
|
||||||
|
/// correlated delivery/read receipts permit bounded automatic resend.
|
||||||
|
///
|
||||||
|
/// Bit 8 remains the encrypted-media compatibility contract. Bit 9 only
|
||||||
|
/// enables sender-side automatic retry after exact-session proof.
|
||||||
|
public static let privateMediaReceipts =
|
||||||
|
PeerCapabilities(rawValue: 1 << 9)
|
||||||
/// Reserved for test builds that briefly advertised non-destructive Noise
|
/// Reserved for test builds that briefly advertised non-destructive Noise
|
||||||
/// replacement. Current clients intentionally do not advertise or act on
|
/// replacement. Current clients intentionally do not advertise or act on
|
||||||
/// this bit; keep it decodable so the wire assignment is never reused.
|
/// this bit; keep it decodable so the wire assignment is never reused.
|
||||||
|
|||||||
@@ -18,14 +18,30 @@ struct PeerCapabilitiesTests {
|
|||||||
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
||||||
#expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01]))
|
#expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01]))
|
||||||
|
|
||||||
let high = PeerCapabilities(rawValue: 1 << 9)
|
#expect(
|
||||||
#expect(high.encoded() == Data([0x00, 0x02]))
|
PeerCapabilities.privateMediaReceipts.encoded()
|
||||||
|
== Data([0x00, 0x02])
|
||||||
|
)
|
||||||
#expect(
|
#expect(
|
||||||
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
|
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
|
||||||
== Data([0x00, 0x04])
|
== Data([0x00, 0x04])
|
||||||
)
|
)
|
||||||
|
|
||||||
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
|
let high = PeerCapabilities(rawValue: 1 << 11)
|
||||||
|
#expect(high.encoded() == Data([0x00, 0x08]))
|
||||||
|
|
||||||
|
let all: PeerCapabilities = [
|
||||||
|
.prekeys,
|
||||||
|
.wifiBulk,
|
||||||
|
.gateway,
|
||||||
|
.groups,
|
||||||
|
.board,
|
||||||
|
.vouch,
|
||||||
|
.meshDiagnostics,
|
||||||
|
.privateMedia,
|
||||||
|
.privateMediaReceipts,
|
||||||
|
.nonDestructiveNoiseReplacement
|
||||||
|
]
|
||||||
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
||||||
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
||||||
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
||||||
|
|||||||
Reference in New Issue
Block a user