Compare commits

...
Author SHA1 Message Date
jack 82ffdd560c Persist private media delivery receipts 2026-07-26 00:12:27 +02:00
jack 7e6a9c7187 Correlate private media delivery receipts 2026-07-26 00:12:26 +02:00
jack 35ef11f80f Deliver live transport events synchronously 2026-07-26 00:12:26 +02:00
26 changed files with 2557 additions and 136 deletions
+87
View File
@@ -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 {
/// Capabilities this build advertises in its announce packets.
/// 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
]
}
+258 -10
View File
@@ -32,10 +32,79 @@ struct BLEFileTransferHandlerEnvironment {
_ fallbackExtension: String?,
_ defaultPrefix: String
) -> 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
/// 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).
let updatePeerLastSeen: (PeerID) -> Void
/// Delivers `.messageReceived` to the UI as one main-actor hop.
let deliverMessage: (BitchatMessage) -> Void
/// Acknowledges stable private media only after its synchronous
/// 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.
let deliverMessage: (
_ message: BitchatMessage,
_ shouldDeliver: @escaping () -> Bool,
_ completion: @escaping () -> 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
@@ -43,6 +112,7 @@ struct BLEFileTransferHandlerEnvironment {
/// and UI delivery.
final class BLEFileTransferHandler {
private let environment: BLEFileTransferHandlerEnvironment
private let privateMediaArrivals = PrivateMediaArrivalDeduplicator()
init(environment: BLEFileTransferHandlerEnvironment) {
self.environment = environment
@@ -86,6 +156,7 @@ final class BLEFileTransferHandler {
senderNickname: senderNickname,
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
isPrivate: deliveryPlan.isPrivateMessage,
usesDurableReceipts: false,
env: env
)
// Once authenticated, a local decode/quota/save failure is not proof
@@ -116,6 +187,11 @@ final class BLEFileTransferHandler {
senderNickname: senderNickname,
timestamp: timestamp,
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
)
}
@@ -126,9 +202,11 @@ final class BLEFileTransferHandler {
senderNickname: String,
timestamp: Date,
isPrivate: Bool,
usesDurableReceipts: Bool,
env: BLEFileTransferHandlerEnvironment
) -> Bool {
let localPeerID = env.localPeerID()
let filePacket: BitchatFilePacket
let mime: MimeType
switch BLEIncomingFileValidator.validate(payload: payload) {
@@ -149,6 +227,89 @@ final class BLEFileTransferHandler {
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
env.enforceStorageQuota(filePacket.content.count)
@@ -162,13 +323,82 @@ final class BLEFileTransferHandler {
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 {
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 }, {})
}
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)
}
)
}
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,
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
content: "\(category.messagePrefix)\(destination.lastPathComponent)",
timestamp: timestamp,
isRelay: false,
originalSender: nil,
@@ -176,17 +406,35 @@ final class BLEFileTransferHandler {
recipientNickname: nil,
senderPeerID: peerID,
// Received messages need an explicit status: BitchatMessage
// defaults private messages to .sending, which the media views
// render as an in-flight send (empty reveal mask, disabled tap).
// defaults private messages to .sending, which media views render
// as an in-flight send.
deliveryStatus: isPrivate
? .delivered(to: env.localNickname(), at: timestamp)
: nil
)
}
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
env.deliverMessage(message)
return true
/// The durable URL is authoritative during reconstruction. A sender that
/// reuses a stable filename with a different MIME type must not change how
/// the already-stored payload renders.
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
@@ -134,6 +134,7 @@ struct BLEIncomingFileStore {
private let baseDirectory: URL?
private let dateProvider: () -> Date
private let panicMarkerWriter: (Data, URL) throws -> Void
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
init(
fileManager: FileManager = .default,
@@ -147,6 +148,11 @@ struct BLEIncomingFileStore {
self.baseDirectory = baseDirectory
self.dateProvider = dateProvider
self.panicMarkerWriter = panicMarkerWriter
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
fileManager: fileManager,
baseDirectory: baseDirectory,
now: dateProvider
)
}
/// Panic-wipe every managed incoming and outgoing media artifact before
@@ -159,6 +165,11 @@ struct BLEIncomingFileStore {
func panicWipe(
hasDurablePendingMarker: Bool = false
) throws {
// The receipt index caches tombstones as well as accepted payloads.
// Always invalidate it on return, including partial-failure paths, so
// no pre-panic receiver decision survives after identity reset.
defer { privateMediaReceipts.resetForPanic() }
let markerError: Error?
do {
try markPanicRecoveryPending()
@@ -257,6 +268,35 @@ struct BLEIncomingFileStore {
}
}
func privateMediaReceiptState(
messageID: String
) -> BLEPrivateMediaReceiptState {
privateMediaReceipts.state(for: messageID)
}
func commitPrivateMediaFile(
messageID: String,
storedURL: URL
) -> Bool {
privateMediaReceipts.commitAccepted(
messageID: messageID,
storedURL: storedURL
)
}
/// Best-effort rollback for a payload whose durable receipt commit failed.
func removeIncomingFile(at storedURL: URL) {
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`
/// fits under the quota. Files named `voice_live_*` (in-flight live
/// captures) are never evicted regardless of who triggers enforcement
@@ -349,6 +389,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 {
var candidate = (name ?? "")
.replacingOccurrences(of: "\0", with: "")
@@ -0,0 +1,556 @@
import BitLogger
import Foundation
enum BLEPrivateMediaReceiptState: Equatable {
/// No durable receiver decision exists for this stable message ID.
case absent
/// The payload is durably mapped to a file that still exists.
case accepted(URL)
/// The user explicitly deleted the payload; retries must not resurrect it.
case tombstoned
/// Durable state could not be read safely. Callers must fail closed and
/// must not save, deliver, or acknowledge the payload.
case unavailable
}
/// Durable, per-message receiver decisions for stable private media.
///
/// Each ID has its own atomic record so one hot lookup never rewrites or
/// decodes the entire ledger. The process-lifetime index is installed only
/// after a complete, successful directory scan. An enumeration, read, decode,
/// or structural-validation failure therefore remains retryable and cannot be
/// mistaken for an empty ledger.
final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
typealias DirectoryReader = (_ directory: URL) throws -> [URL]
typealias DataReader = (_ url: URL) throws -> Data
private static let receiptDirectoryName = ".private-media-receipts"
private struct ReceiptRecord: Codable, Equatable {
enum Kind: String, Codable {
case accepted
case tombstone
}
let kind: Kind
/// Path below the app's `files/` root. Absolute application-container
/// prefixes are not stable across updates, restores, or reinstalls.
let relativePath: String?
let recordedAt: Date
}
private final class Runtime: @unchecked Sendable {
let lock = NSLock()
var records: [String: ReceiptRecord]?
var volatileTombstones: [String: Date] = [:]
}
private let fileManager: FileManager
private let baseDirectory: URL?
private let capacity: Int
private let ttl: TimeInterval
private let now: () -> Date
private let directoryReader: DirectoryReader?
private let dataReader: DataReader?
private let runtime = Runtime()
init(
fileManager: FileManager = .default,
baseDirectory: URL? = nil,
capacity: Int = TransportConfig.privateMediaReceivedLedgerCapacity,
ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds,
now: @escaping () -> Date = Date.init,
directoryReader: DirectoryReader? = nil,
dataReader: DataReader? = nil
) {
self.fileManager = fileManager
self.baseDirectory = baseDirectory
self.capacity = max(1, capacity)
self.ttl = max(0, ttl)
self.now = now
self.directoryReader = directoryReader
self.dataReader = dataReader
}
/// Drops process-lifetime decisions after the enclosing media directory
/// has been panic-wiped. A later lookup must rebuild from the durable
/// ledger instead of retaining an accepted receipt or tombstone whose
/// backing files no longer exist.
func resetForPanic() {
runtime.lock.lock()
runtime.records = nil
runtime.volatileTombstones.removeAll(keepingCapacity: false)
runtime.lock.unlock()
}
func state(for messageID: String) -> BLEPrivateMediaReceiptState {
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
return .absent
}
runtime.lock.lock()
defer { runtime.lock.unlock() }
let date = now()
if let tombstonedAt = runtime.volatileTombstones[messageID] {
if !isExpired(tombstonedAt, at: date) {
return .tombstoned
}
runtime.volatileTombstones.removeValue(forKey: messageID)
}
guard let directory = resolvedReceiptDirectory(),
var records = loadIndexIfNeeded(from: directory, at: date) else {
return .unavailable
}
guard let record = records[messageID] else { return .absent }
if isExpired(record.recordedAt, at: date) {
records.removeValue(forKey: messageID)
runtime.records = records
removeRecord(messageID: messageID, from: directory)
return .absent
}
switch record.kind {
case .tombstone:
removePayloadRecordedByTombstone(record)
return .tombstoned
case .accepted:
guard let relativePath = record.relativePath,
let existingURL = existingPayload(relativePath: relativePath) else {
// Quota cleanup is not explicit deletion. Remove the stale
// receipt so a sender retry can restore the payload and bubble.
records.removeValue(forKey: messageID)
runtime.records = records
removeRecord(messageID: messageID, from: directory)
return .absent
}
return .accepted(existingURL)
}
}
/// Records an accepted ID only after the payload is on disk. Callers must
/// roll the payload back and withhold UI delivery/ACK when this returns
/// false.
func commitAccepted(messageID: String, storedURL: URL) -> Bool {
guard PrivateMediaMessageIdentity.isStableID(messageID),
validExistingPayload(storedURL) != nil,
let relativePath = relativePath(for: storedURL) else {
return false
}
runtime.lock.lock()
defer { runtime.lock.unlock() }
let date = now()
if let tombstonedAt = runtime.volatileTombstones[messageID],
!isExpired(tombstonedAt, at: date) {
return false
}
guard let directory = resolvedReceiptDirectory(),
var records = loadIndexIfNeeded(from: directory, at: date) else {
return false
}
if let existing = records[messageID],
existing.kind == .tombstone,
!isExpired(existing.recordedAt, at: date) {
return false
}
let victim = capacityVictim(
for: .accepted,
replacing: messageID,
in: records
)
if records[messageID]?.kind != .accepted,
records.values.lazy.filter({ $0.kind == .accepted }).count >= capacity,
victim == nil {
return false
}
let record = ReceiptRecord(
kind: .accepted,
relativePath: relativePath,
recordedAt: date
)
guard persist(record, messageID: messageID, to: directory) else {
return false
}
records[messageID] = record
if let victim, victim != messageID {
records.removeValue(forKey: victim)
removeRecord(messageID: victim, from: directory)
}
runtime.records = records
return true
}
/// Foundation for explicit media deletion. This branch does not wire the
/// chat-clear UI; it only makes a tombstone durable and fail closed.
func recordDeleted(messageID: String) -> Bool {
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
return false
}
runtime.lock.lock()
defer { runtime.lock.unlock() }
let date = now()
addVolatileTombstone(messageID, at: date)
guard let directory = resolvedReceiptDirectory(),
var records = loadIndexIfNeeded(from: directory, at: date) else {
runtime.volatileTombstones.removeValue(forKey: messageID)
return false
}
if let existing = records[messageID],
existing.kind == .tombstone,
!isExpired(existing.recordedAt, at: date) {
runtime.volatileTombstones.removeValue(forKey: messageID)
removePayloadRecordedByTombstone(existing)
return true
}
let victim = capacityVictim(
for: .tombstone,
replacing: messageID,
in: records
)
if records[messageID]?.kind != .tombstone,
records.values.lazy.filter({ $0.kind == .tombstone }).count >= capacity,
victim == nil {
runtime.volatileTombstones.removeValue(forKey: messageID)
return false
}
let tombstone = ReceiptRecord(
kind: .tombstone,
// Retain the accepted path so a crash between the atomic record
// write and payload unlink can finish cleanup after relaunch.
relativePath: records[messageID]?.relativePath,
recordedAt: date
)
guard persist(tombstone, messageID: messageID, to: directory) else {
runtime.volatileTombstones.removeValue(forKey: messageID)
return false
}
records[messageID] = tombstone
if let victim, victim != messageID {
records.removeValue(forKey: victim)
removeRecord(messageID: victim, from: directory)
}
runtime.records = records
runtime.volatileTombstones.removeValue(forKey: messageID)
removePayloadRecordedByTombstone(tombstone)
return true
}
private func loadIndexIfNeeded(
from directory: URL,
at date: Date
) -> [String: ReceiptRecord]? {
if let records = runtime.records {
return records
}
do {
try fileManager.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: nil
)
} catch {
SecureLogger.error(
"❌ Failed to create private-media receipt directory: \(error)",
category: .session
)
return nil
}
let urls: [URL]
do {
if let directoryReader {
urls = try directoryReader(directory)
} else {
urls = try fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil,
options: []
)
}
} catch {
SecureLogger.error(
"❌ Failed to enumerate private-media receipts: \(error)",
category: .session
)
return nil
}
var records: [String: ReceiptRecord] = [:]
var expired: [String] = []
var tombstones: [ReceiptRecord] = []
for url in urls {
guard url.pathExtension == "json" else { continue }
let messageID = url.deletingPathExtension().lastPathComponent
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
continue
}
let record: ReceiptRecord
do {
let data = try dataReader?(url) ?? Data(contentsOf: url)
record = try JSONDecoder().decode(ReceiptRecord.self, from: data)
} catch {
// Never delete or skip an unreadable stable-ID record. Treating
// it as absent could resurrect accepted or deleted media.
SecureLogger.error(
"❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)",
category: .session
)
return nil
}
guard isStructurallyValid(record) else {
SecureLogger.error(
"❌ Invalid private-media receipt \(messageID.prefix(12))",
category: .session
)
return nil
}
if isExpired(record.recordedAt, at: date) {
expired.append(messageID)
continue
}
records[messageID] = record
if record.kind == .tombstone {
tombstones.append(record)
}
}
let overflow = overflowVictims(in: records)
for messageID in overflow {
records.removeValue(forKey: messageID)
}
// Install the index only after every stable-ID record was read and
// validated successfully. Cleanup cannot influence a failed scan.
runtime.records = records
for messageID in expired + overflow {
removeRecord(messageID: messageID, from: directory)
}
for tombstone in tombstones {
removePayloadRecordedByTombstone(tombstone)
}
return records
}
private func isStructurallyValid(_ record: ReceiptRecord) -> Bool {
switch record.kind {
case .tombstone:
guard let relativePath = record.relativePath else { return true }
return candidatePayload(relativePath: relativePath) != nil
case .accepted:
guard let relativePath = record.relativePath else { return false }
return candidatePayload(relativePath: relativePath) != nil
}
}
private func isExpired(_ recordedAt: Date, at date: Date) -> Bool {
date.timeIntervalSince(recordedAt) > ttl
}
private func overflowVictims(
in records: [String: ReceiptRecord]
) -> [String] {
var victims: [String] = []
for kind in [ReceiptRecord.Kind.accepted, .tombstone] {
let matching = records.filter { $0.value.kind == kind }
let overflow = matching.count - capacity
guard overflow > 0 else { continue }
victims.append(contentsOf: matching.sorted { lhs, rhs in
if lhs.value.recordedAt == rhs.value.recordedAt {
return lhs.key < rhs.key
}
return lhs.value.recordedAt < rhs.value.recordedAt
}
.prefix(overflow)
.map(\.key))
}
return victims
}
/// Accepted receipts and tombstones have independent capacity. High media
/// volume cannot evict explicit deletion intent, and vice versa.
private func capacityVictim(
for incomingKind: ReceiptRecord.Kind,
replacing messageID: String,
in records: [String: ReceiptRecord]
) -> String? {
guard records[messageID]?.kind != incomingKind else { return nil }
let matching = records.filter {
$0.key != messageID && $0.value.kind == incomingKind
}
guard matching.count >= capacity else { return nil }
return matching.min { lhs, rhs in
if lhs.value.recordedAt == rhs.value.recordedAt {
return lhs.key < rhs.key
}
return lhs.value.recordedAt < rhs.value.recordedAt
}?.key
}
private func persist(
_ record: ReceiptRecord,
messageID: String,
to directory: URL
) -> Bool {
do {
try fileManager.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: nil
)
let data = try JSONEncoder().encode(record)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
let url = recordURL(messageID: messageID, in: directory)
try data.write(to: url, options: options)
return true
} catch {
SecureLogger.error(
"❌ Failed to persist private-media receipt \(messageID.prefix(12))…: \(error)",
category: .session
)
return false
}
}
private func removeRecord(messageID: String, from directory: URL) {
let url = recordURL(messageID: messageID, in: directory)
guard fileManager.fileExists(atPath: url.path) else { return }
do {
try fileManager.removeItem(at: url)
} catch {
SecureLogger.warning(
"⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)",
category: .session
)
}
}
private func recordURL(messageID: String, in directory: URL) -> URL {
directory
.appendingPathComponent(messageID, isDirectory: false)
.appendingPathExtension("json")
}
private func removePayloadRecordedByTombstone(_ record: ReceiptRecord) {
guard record.kind == .tombstone,
let relativePath = record.relativePath,
let payload = candidatePayload(relativePath: relativePath),
fileManager.fileExists(atPath: payload.path) else {
return
}
do {
try fileManager.removeItem(at: payload)
} catch {
SecureLogger.warning(
"⚠️ Failed to remove explicitly deleted private media: \(error)",
category: .session
)
}
}
private func addVolatileTombstone(_ messageID: String, at date: Date) {
runtime.volatileTombstones[messageID] = date
let overflow = runtime.volatileTombstones.count - capacity
guard overflow > 0 else { return }
let oldest = runtime.volatileTombstones.sorted {
if $0.value == $1.value { return $0.key < $1.key }
return $0.value < $1.value
}
for (oldMessageID, _) in oldest.prefix(overflow) {
runtime.volatileTombstones.removeValue(forKey: oldMessageID)
}
}
private func validExistingPayload(_ url: URL) -> URL? {
let standardized = url.standardizedFileURL
guard isInsideFilesDirectory(standardized) else { return nil }
var isDirectory: ObjCBool = false
guard fileManager.fileExists(
atPath: standardized.path,
isDirectory: &isDirectory
), !isDirectory.boolValue else {
return nil
}
return standardized
}
private func relativePath(for url: URL) -> String? {
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
return nil
}
let prefix = filesRoot.path + "/"
let standardized = url.standardizedFileURL
guard standardized.path.hasPrefix(prefix) else { return nil }
let relativePath = String(standardized.path.dropFirst(prefix.count))
return relativePath.isEmpty ? nil : relativePath
}
private func existingPayload(relativePath: String) -> URL? {
guard let candidate = candidatePayload(relativePath: relativePath) else {
return nil
}
return validExistingPayload(candidate)
}
private func candidatePayload(relativePath: String) -> URL? {
guard !relativePath.isEmpty,
let filesRoot = try? filesDirectory().standardizedFileURL else {
return nil
}
let candidate = filesRoot
.appendingPathComponent(relativePath, isDirectory: false)
.standardizedFileURL
guard candidate.path.hasPrefix(filesRoot.path + "/") else { return nil }
return candidate
}
private func isInsideFilesDirectory(_ url: URL) -> Bool {
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
return false
}
return url.standardizedFileURL.path.hasPrefix(filesRoot.path + "/")
}
private func resolvedReceiptDirectory() -> URL? {
return try? filesDirectory().appendingPathComponent(
Self.receiptDirectoryName,
isDirectory: true
)
}
private func filesDirectory() throws -> URL {
let root = try baseDirectory ?? fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let files = root.appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(
at: files,
withIntermediateDirectories: true,
attributes: nil
)
return files
}
}
+114 -12
View File
@@ -1119,6 +1119,28 @@ final class BLEService: NSObject {
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
}
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 {
let normalizedPeerID = peerID.toShort()
let state: (
@@ -1146,7 +1168,10 @@ final class BLEService: NSObject {
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
// verified registry entry; a routing ID alone can rotate or be
// spoofed. Without that key neither proof nor safe migration state
@@ -1200,11 +1225,13 @@ final class BLEService: NSObject {
return
}
let fingerprint: String? = self.collectionsQueue.sync {
self.peerRegistry.info(for: normalizedPeerID)?
.noisePublicKey?
.sha256Fingerprint()
let generation = self.collectionsQueue.sync {
self.privateMediaSessionGenerations[normalizedPeerID]
}
let fingerprint = self.privateMediaPolicyFingerprint(
for: normalizedPeerID,
expectedSessionGeneration: generation
)
guard let fingerprint else {
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
return
@@ -2527,12 +2554,50 @@ final class BLEService: NSObject {
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)
},
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
self?.updatePeerLastSeen(peerID)
},
deliverMessage: { [weak self] message in
// Single main-actor hop delivering `.messageReceived`.
self?.emitTransportEvent(.messageReceived(message))
acknowledgePrivateMedia: { [weak self] messageID, peerID in
guard let self,
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID),
!self.identityManager.isBlocked(
fingerprint: senderStaticKey.sha256Fingerprint()
) else {
return
}
self.sendDeliveryAck(for: messageID, to: peerID)
},
deliverMessage: { [weak self] message, shouldDeliver, completion in
self?.emitTransportEvent(
.messageReceived(message),
shouldDeliver: shouldDeliver,
completion: completion
)
}
)
}
@@ -4249,18 +4314,55 @@ extension BLEService {
}
}
private func emitTransportEvent(_ event: TransportEvent) {
private func emitTransportEvent(
_ event: TransportEvent,
shouldDeliver: (() -> Bool)? = nil,
completion: (() -> Void)? = nil
) {
notifyUI { [weak self] in
self?.deliverTransportEvent(event)
guard let self,
shouldDeliver?() ?? true,
self.deliverTransportEvent(event),
// Quota cleanup can race the asynchronous main-actor hop or
// the synchronous ConversationStore upsert. ACK only while
// the exact durable mapping and file still resolve.
shouldDeliver?() ?? true else {
return
}
completion?()
}
}
@MainActor
private func deliverTransportEvent(_ event: TransportEvent) {
@discardableResult
private func deliverTransportEvent(_ event: TransportEvent) -> Bool {
if case .messageReceived(let message) = event {
if let synchronousDelegate =
eventDelegate as? SynchronousMessageTransportEventDelegate {
return synchronousDelegate
.didReceiveTransportMessageSynchronously(message)
}
if let eventDelegate {
eventDelegate.didReceiveTransportEvent(event)
return false
}
if let synchronousDelegate =
delegate as? SynchronousMessageTransportEventDelegate {
return synchronousDelegate
.didReceiveTransportMessageSynchronously(message)
}
}
if let eventDelegate {
eventDelegate.didReceiveTransportEvent(event)
return true
} else {
delegate?.receiveTransportEvent(event)
guard let delegate else { return false }
delegate.receiveTransportEvent(event)
if case .messageReceived = event {
return false
}
return true
}
}
+7
View File
@@ -101,6 +101,13 @@ protocol TransportEventDelegate: AnyObject {
@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 {
// Event sink
var delegate: BitchatDelegate? { get set }
+7
View File
@@ -15,6 +15,13 @@ enum TransportConfig {
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
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 bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
@@ -10,6 +10,7 @@ import Foundation
@MainActor
protocol ChatLiveVoiceContext: AnyObject {
var nickname: String { get }
var myPeerID: PeerID { get }
var selectedPrivateChatPeer: PeerID? { get }
/// Whether the public mesh timeline is what's on screen (autoplay gate
/// for public bursts).
@@ -30,6 +31,12 @@ protocol ChatLiveVoiceContext: AnyObject {
func upsertPublicMeshMessage(_ message: BitchatMessage)
@discardableResult
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.
func removeMessage(withID messageID: String, cleanupFile: Bool)
/// 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 }
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(
id: finished.messageID,
id: replacementID,
sender: message.sender,
content: message.content,
timestamp: finished.messageTimestamp,
@@ -287,7 +302,31 @@ final class ChatLiveVoiceCoordinator {
)
switch finished.scope {
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)
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:
context.upsertPublicMeshMessage(replacement)
}
@@ -233,9 +233,17 @@ final class ChatMediaTransferCoordinator {
}
let targetPeer = context.selectedPrivateChatPeer
let privateMessageID = targetPeer.flatMap { peerID in
PrivateMediaMessageIdentity.stableID(
senderPeerID: context.myPeerID,
recipientPeerID: peerID,
fileName: url.lastPathComponent
)
}
let message = enqueueMediaMessage(
content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)",
targetPeer: targetPeer
targetPeer: targetPeer,
messageID: privateMessageID
)
let messageID = message.id
let transferId = makeTransferID(messageID: messageID)
@@ -389,9 +397,17 @@ final class ChatMediaTransferCoordinator {
try? FileManager.default.removeItem(at: prepared.outputURL)
return
}
let privateMessageID = targetPeer.flatMap { peerID in
PrivateMediaMessageIdentity.stableID(
for: prepared.packet,
senderPeerID: self.context.myPeerID,
recipientPeerID: peerID
)
}
let message = self.enqueueMediaMessage(
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
targetPeer: targetPeer
targetPeer: targetPeer,
messageID: privateMessageID
)
let messageID = message.id
let transferId = self.makeTransferID(messageID: messageID)
@@ -429,12 +445,17 @@ final class ChatMediaTransferCoordinator {
}
}
func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
func enqueueMediaMessage(
content: String,
targetPeer: PeerID?,
messageID: String? = nil
) -> BitchatMessage {
let timestamp = Date()
let message: BitchatMessage
if let peerID = targetPeer {
message = BitchatMessage(
id: messageID,
sender: context.nickname,
content: content,
timestamp: timestamp,
@@ -100,9 +100,14 @@ final class ChatPeerListCoordinator: @unchecked Sendable {
func didUpdatePeerList(_ peers: [PeerID]) {
Task { @MainActor [weak self] in
self?.handlePeerListUpdate(peers)
self?.didUpdatePeerListSynchronously(peers)
}
}
@MainActor
func didUpdatePeerListSynchronously(_ peers: [PeerID]) {
handlePeerListUpdate(peers)
}
}
private extension ChatPeerListCoordinator {
@@ -163,21 +163,20 @@ final class ChatTransportEventCoordinator {
}
func didReceiveMessage(_ message: BitchatMessage) {
runOnMain { context in
guard !context.isMessageBlocked(message) else { return }
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)
runOnMain { [self] context in
handleReceivedMessage(message, in: context)
}
}
/// 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(
from peerID: PeerID,
nickname: String,
@@ -185,28 +184,36 @@ final class ChatTransportEventCoordinator {
timestamp: Date,
messageID: String?
) {
runOnMain { context in
let normalized = content.trimmed
let mentions = context.parseMentions(from: normalized)
let message = BitchatMessage(
id: messageID,
sender: nickname,
content: normalized,
runOnMain { [self] context in
handlePublicMessage(
from: peerID,
nickname: nickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: mentions.isEmpty ? nil : mentions
messageID: messageID,
in: context
)
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(
from peerID: PeerID,
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) {
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
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)
runOnMain { [weak self] _ in
self?.didConnectToPeerSynchronously(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) {
runOnMain { [weak self] _ in
self?.didDisconnectFromPeerSynchronously(peerID)
}
}
@MainActor
func didDisconnectFromPeerSynchronously(_ peerID: PeerID) {
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
runOnMain { context in
context.removeEphemeralSession(peerID: peerID)
context.removeEphemeralSession(peerID: peerID)
var stablePeerID = context.cachedStablePeerID(for: peerID)
if stablePeerID == nil,
let key = context.noiseSessionPublicKeyData(for: peerID) {
let derivedPeerID = PeerID(hexData: key)
context.cacheStablePeerID(derivedPeerID, for: peerID)
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()
var stablePeerID = context.cachedStablePeerID(for: peerID)
if stablePeerID == nil,
let key = context.noiseSessionPublicKeyData(for: peerID) {
let derivedPeerID = PeerID(hexData: key)
context.cacheStablePeerID(derivedPeerID, for: peerID)
stablePeerID = derivedPeerID
}
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 {
@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) {
Task { @MainActor [weak context = self.context] in
guard let context else { return }
+82 -2
View File
@@ -112,7 +112,7 @@ struct PanicNetworkLifecycle {
/// Manages the application state and business logic for BitChat.
/// Acts as the primary coordinator between UI components and backend services,
/// 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)
typealias Patterns = MessageFormattingEngine.Patterns
@@ -495,6 +495,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`.
/// Returns `false` when one was already recorded the caller must skip sending.
@MainActor
@@ -1701,7 +1707,81 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
@MainActor
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)
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) {
@@ -14,6 +14,7 @@ import BitFoundation
@MainActor
private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
var nickname = "me"
var myPeerID = PeerID(str: "0102030405060708")
var selectedPrivateChatPeer: PeerID?
var isViewingPublicMeshTimeline = false
var blockedPeers: Set<PeerID> = []
@@ -23,7 +24,10 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = []
private(set) var upsertedPublicMessages: [BitchatMessage] = []
private(set) var removedMessageIDs: [String] = []
private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
private(set) var talkerUpdates: [String?] = []
private(set) var privateMutationLog: [String] = []
private var readReceiptMessageIDs: Set<String> = []
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
func resolveNickname(for peerID: PeerID) -> String { "alice" }
@@ -31,6 +35,7 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) }
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
upsertedMessages.append((message, peerID))
privateMutationLog.append("upsert:\(message.id)")
}
func upsertPublicMeshMessage(_ message: BitchatMessage) {
upsertedPublicMessages.append(message)
@@ -38,8 +43,18 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
@discardableResult
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
removedMessageIDs.append(messageID)
privateMutationLog.append("remove:\(messageID)")
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) {
removedMessageIDs.append(messageID)
}
@@ -151,17 +166,29 @@ struct ChatLiveVoiceCoordinatorTests {
@Test func absorbsFinalizedNoteIntoLiveBubble() throws {
let context = MockChatLiveVoiceContext()
context.selectedPrivateChatPeer = peer
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0xB2)
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: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
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(
id: stableMessageID,
sender: "alice",
content: "[voice] voice_\(hex).m4a",
content: "[voice] \(fileName)",
timestamp: Date(),
isRelay: false,
isPrivate: true,
@@ -170,12 +197,21 @@ struct ChatLiveVoiceCoordinatorTests {
)
#expect(coordinator.absorbFinalizedVoiceNote(note))
// The note replaced the live bubble in place: same message ID, new
// content, partial capture deleted.
// The finalized note adopts the sender-correlatable ID, removes the
// 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)
#expect(replacement.message.id == bubble.id)
#expect(replacement.message.id == stableMessageID)
#expect(replacement.message.content == note.content)
#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.
let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
#expect(!FileManager.default.fileExists(atPath: url.path))
@@ -449,7 +485,8 @@ struct ChatLiveVoiceCoordinatorTests {
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
)
#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 {
@@ -479,8 +516,9 @@ struct ChatLiveVoiceCoordinatorTests {
)
#expect(coordinator.absorbFinalizedVoiceNote(note))
let replacement = try #require(context.upsertedMessages.last)
#expect(replacement.message.id == victimBubble.id)
#expect(replacement.message.id == note.id)
#expect(replacement.peerID == peer)
#expect(context.removedMessageIDs.contains(victimBubble.id))
// The attacker's note can only ever claim the attacker's own bubble.
let attackerNote = BitchatMessage(
@@ -489,8 +527,9 @@ struct ChatLiveVoiceCoordinatorTests {
)
#expect(coordinator.absorbFinalizedVoiceNote(attackerNote))
let attackerReplacement = try #require(context.upsertedMessages.last)
#expect(attackerReplacement.message.id == attackerBubble.id)
#expect(attackerReplacement.message.id == attackerNote.id)
#expect(attackerReplacement.peerID == attacker)
#expect(context.removedMessageIDs.contains(attackerBubble.id))
// Both registry entries are consumed nothing left to hijack.
#expect(!coordinator.absorbFinalizedVoiceNote(note))
@@ -8,7 +8,7 @@
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Real file/codec work remains covered by `ChatMediaPreparationTests`. These
// tests inject a paused voice-note preparer to exercise cancellation ownership
// tests inject paused media preparers to exercise cancellation ownership
// across the detached-preparation/MainActor boundary deterministically.
//
@@ -89,7 +89,11 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
}
// Mesh file transfer
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
private(set) var privateFileSends: [(
packet: BitchatFilePacket,
peerID: PeerID,
transferId: String
)] = []
private(set) var privateFileLegacyAllowances: [Bool] = []
private(set) var broadcastFileSends: [String] = []
private(set) var cancelledTransfers: [String] = []
@@ -154,7 +158,7 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
transferId: String,
allowLegacyFallback: Bool
) {
privateFileSends.append((peerID, transferId))
privateFileSends.append((packet, peerID, transferId))
privateFileLegacyAllowances.append(allowLegacyFallback)
}
@@ -172,19 +176,8 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
private var started = false
private var released = false
private var finished = false
private let packet: BitchatFilePacket
init() {
let content = Data("voice".utf8)
packet = BitchatFilePacket(
fileName: "paused.m4a",
fileSize: UInt64(content.count),
mimeType: "audio/mp4",
content: content
)
}
func prepare(_: URL) throws -> BitchatFilePacket {
func prepare(_ url: URL) throws -> BitchatFilePacket {
condition.lock()
started = true
condition.broadcast()
@@ -194,7 +187,13 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
finished = true
condition.broadcast()
condition.unlock()
return packet
let content = Data("voice".utf8)
return BitchatFilePacket(
fileName: url.lastPathComponent,
fileSize: UInt64(content.count),
mimeType: "audio/mp4",
content: content
)
}
var hasStarted: Bool {
@@ -217,6 +216,59 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
}
}
private final class StaticVoiceNotePreparer: @unchecked Sendable {
private let packet: BitchatFilePacket
init(fileName: String, content: Data = Data("voice".utf8)) {
packet = BitchatFilePacket(
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: "audio/mp4",
content: content
)
}
func prepare(_ _: URL) throws -> BitchatFilePacket {
packet
}
}
private final class DeterministicMediaTransferIDFactory:
@unchecked Sendable {
private let lock = NSLock()
private var nextOrdinal = 0
func make(messageID: String) -> String {
lock.lock()
defer {
nextOrdinal += 1
lock.unlock()
}
return "\(messageID)-attempt-\(nextOrdinal)"
}
}
private final class MutableMediaRetryClock: @unchecked Sendable {
private let lock = NSLock()
private var value: Date
init(_ value: Date) {
self.value = value
}
func now() -> Date {
lock.lock()
defer { lock.unlock() }
return value
}
func advance(by interval: TimeInterval) {
lock.lock()
value = value.addingTimeInterval(interval)
lock.unlock()
}
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatMediaTransferCoordinator` against
@@ -452,6 +504,109 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(coordinator.transferIdToMessageIDs.isEmpty)
}
@Test @MainActor
func privateVoiceNoteUsesWireDerivableMessageID() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("voice_receipt_\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let message = try #require(context.privateChats[peerID]?.first)
let sentPacket = try #require(context.privateFileSends.first?.packet)
#expect(message.id == PrivateMediaMessageIdentity.stableID(
for: sentPacket,
senderPeerID: context.myPeerID,
recipientPeerID: peerID
))
}
@Test @MainActor
func privateImageUsesWireDerivableMessageID() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "99aabbccddeeff00")
context.selectedPrivateChatPeer = peerID
let sourceURL = try makeCoordinatorTestImageURL()
defer { try? FileManager.default.removeItem(at: sourceURL) }
coordinator.sendImage(from: sourceURL)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let message = try #require(context.privateChats[peerID]?.first)
let sentPacket = try #require(context.privateFileSends.first?.packet)
#expect(message.id == PrivateMediaMessageIdentity.stableID(
for: sentPacket,
senderPeerID: context.myPeerID,
recipientPeerID: peerID
))
coordinator.cleanupLocalFile(forMessage: message)
}
@Test @MainActor
func panicDuringImagePreparationDeletesStaleOutputWithoutSideEffects() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "99aabbccddeeff00")
context.selectedPrivateChatPeer = peerID
let sourceURL = try makeCoordinatorTestImageURL()
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-stale-image-\(UUID().uuidString).jpg"
)
let preparer = PausedImagePreparer(outputURL: outputURL)
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareImagePacket: { url in try preparer.prepare(url) }
)
defer {
preparer.release()
try? FileManager.default.removeItem(at: sourceURL)
try? FileManager.default.removeItem(at: outputURL)
}
coordinator.sendImage(from: sourceURL)
#expect(await TestHelpers.waitUntil(
{ preparer.hasStarted },
timeout: TestConstants.longTimeout
))
DispatchQueue.global(qos: .userInitiated).asyncAfter(
deadline: .now() + .milliseconds(100)
) {
preparer.release()
}
coordinator.resetForPanic()
#expect(await TestHelpers.waitUntil(
{ preparer.hasFinished },
timeout: TestConstants.longTimeout
))
#expect(await TestHelpers.waitUntil(
{ !FileManager.default.fileExists(atPath: outputURL.path) },
timeout: TestConstants.longTimeout
))
#expect(context.privateChats[peerID]?.isEmpty != false)
#expect(context.appendedPublicMessages.isEmpty)
#expect(context.privateFileSends.isEmpty)
#expect(context.broadcastFileSends.isEmpty)
#expect(context.systemMessages.isEmpty)
#expect(context.deliveryStatusUpdates.isEmpty)
#expect(coordinator.transferIdToMessageIDs.isEmpty)
#expect(coordinator.messageIDToTransferId.isEmpty)
}
@Test @MainActor
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
let context = MockChatMediaTransferContext()
@@ -104,6 +104,20 @@ private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMess
/// no `ChatViewModel`.
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
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
let context = MockChatPeerListContext()
@@ -220,26 +220,85 @@ struct ChatTransportEventCoordinatorContextTests {
func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
// 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-private",
isPrivate: true,
senderPeerID: peerID
))
// Empty public content is dropped too.
coordinator.didReceiveMessage(makeMessage(id: "empty", content: " "))
await drainMainActorTasks()
#expect(context.handledPublicMessages.isEmpty)
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.mentionCheckedMessageIDs.isEmpty)
#expect(context.meshDeliveryAcks.isEmpty)
// Private goes to the private handler, public to the public handler;
// both get mention checks and haptics.
coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true))
// both get mention checks and haptics. Stable-media ACK authorization
// 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"))
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.mentionCheckedMessageIDs == ["pm", "pub"])
#expect(context.hapticMessageIDs == ["pm", "pub"])
#expect(context.mentionCheckedMessageIDs == [
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
@@ -295,6 +354,32 @@ struct ChatTransportEventCoordinatorContextTests {
#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
func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async {
let context = MockChatTransportEventContext()
+74
View File
@@ -864,6 +864,80 @@ struct ChatViewModelPublicConversationTests {
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
func didConnectToPeer_notifiesDelegate() async {
let (_, transport) = makeTestableViewModel()
@@ -682,13 +682,18 @@ struct PrivateMediaEndToEndTests {
#expect(!identity.hasObservedPrivateMediaCapability(
fingerprint: impostorKey.sha256Fingerprint()
))
#expect(identity.hasObservedPrivateMediaCapability(
fingerprint: bob.noiseStaticPublicKeyData().sha256Fingerprint()
))
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: [],
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
@@ -866,7 +871,7 @@ struct PrivateMediaEndToEndTests {
+ marker
+ Data(repeating: 0x4A, count: 6 * 1024)
try await assertPrivateMediaRoundTrip(
fileName: "private.jpg",
fileName: "img_20260725_120000_11111111-1111-1111-1111-111111111111.jpg",
mimeType: "image/jpeg",
content: content,
marker: marker,
@@ -1137,6 +1142,17 @@ struct PrivateMediaEndToEndTests {
#expect(message.isPrivate)
#expect(message.senderPeerID == alice.myPeerID)
#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)
#expect(stored.count == 1)
@@ -1279,6 +1295,8 @@ struct PrivateMediaEndToEndTests {
return enumerator.compactMap { item in
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 {
return nil
}
@@ -1,3 +1,4 @@
import BitFoundation
import XCTest
@testable import bitchat
@@ -73,4 +74,85 @@ final class BitchatFilePacketTests: XCTestCase {
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
XCTAssertEqual(decoded.content, content)
}
func testPrivateMediaMessageIdentityConvergesAcrossPeerIDAliases() throws {
let senderKey = Data(repeating: 0x11, count: 32)
let recipientKey = Data(repeating: 0x22, count: 32)
let senderStable = PeerID(hexData: senderKey)
let recipientStable = PeerID(hexData: recipientKey)
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
let senderID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
senderPeerID: senderStable.toShort(),
recipientPeerID: PeerID(str: "mesh:\(recipientStable.toShort().bare)"),
fileName: fileName
))
let receiverID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
senderPeerID: senderStable,
recipientPeerID: recipientStable.toShort(),
fileName: fileName
))
XCTAssertEqual(senderID, receiverID)
XCTAssertTrue(senderID.hasPrefix("media-"))
XCTAssertEqual(senderID.count, 38)
XCTAssertTrue(PrivateMediaMessageIdentity.isStableID(senderID))
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "A", count: 32))"))
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "a", count: 31))"))
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID(UUID().uuidString))
}
func testPrivateMediaMessageIdentitySeparatesDirectionAndFilename() throws {
let alice = PeerID(str: "0011223344556677")
let bob = PeerID(str: "8899aabbccddeeff")
let firstName = "voice_20260725_105708_11111111-1111-1111-1111-111111111111.m4a"
let secondName = "voice_20260725_105709_22222222-2222-2222-2222-222222222222.m4a"
let first = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: firstName
))
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
senderPeerID: bob,
recipientPeerID: alice,
fileName: firstName
))
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: secondName
))
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: nil
))
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: "photo.jpg"
))
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: "img_11111111-1111-1111-1111-111111111111.pdf"
))
XCTAssertNotNil(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: "voice_0011223344556677.m4a"
))
}
func testPrivateMediaMessageIdentityMatchesVersionOneGoldenVector() {
XCTAssertEqual(
PrivateMediaMessageIdentity.stableID(
senderPeerID: PeerID(str: "0011223344556677"),
recipientPeerID: PeerID(str: "8899aabbccddeeff"),
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
),
"media-910bd42c65060ab76bb6406f220c4516"
)
}
}
@@ -13,11 +13,27 @@ struct BLEFileTransferHandlerTests {
var signatureVerifyCount = 0
var signedNameQueries: [PeerID] = []
var blockedPeers: Set<PeerID> = []
var trackedPackets: [BitchatPacket] = []
var quotaReservations: [Int] = []
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 lastSeenUpdates: [PeerID] = []
var deliveryAcks: [(messageID: String, peerID: PeerID)] = []
var deliveredMessages: [BitchatMessage] = []
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)?
}
private let localPeerID = PeerID(str: "0102030405060708")
@@ -46,13 +62,44 @@ struct BLEFileTransferHandlerTests {
},
saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix))
if let saveOverride = recorder.saveOverride {
return saveOverride(data, preferredName, subdirectory, fallbackExtension, defaultPrefix)
}
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)
},
isPrivateMediaSenderBlocked: { peerID in
recorder.blockedPeers.contains(peerID)
},
updatePeerLastSeen: { peerID in
recorder.lastSeenUpdates.append(peerID)
},
deliverMessage: { message in
acknowledgePrivateMedia: { messageID, peerID in
recorder.deliveryAcks.append((messageID, peerID))
},
deliverMessage: { message, shouldDeliver, completion in
guard shouldDeliver() else { return }
recorder.deliveredMessages.append(message)
guard shouldDeliver() else { return }
completion()
}
)
return BLEFileTransferHandler(environment: environment)
@@ -284,6 +331,7 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.lastSeenUpdates == [remotePeerID])
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.isPrivate == true)
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
// Must be explicit: BitchatMessage defaults private messages to
// .sending, which the media views render as an in-flight send
// (empty reveal mask, disabled reveal tap).
@@ -291,13 +339,14 @@ struct BLEFileTransferHandlerTests {
}
@Test
func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws {
func bit8EncryptedPrivateFileKeepsStableIDAndAckWithoutBit9Proof() 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: "secret.jpg",
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
@@ -316,6 +365,357 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.isPrivate == true)
#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 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 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)
}
}
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
@@ -337,6 +737,7 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.lastSeenUpdates.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}
@@ -468,6 +869,33 @@ 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)
#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 panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
enum MarkerFailure: Error { case unavailable }
@@ -111,5 +111,8 @@ struct BLENoiseReconnectPolicyTests {
)
)
#expect(PeerCapabilities.localSupported.contains(.privateMedia))
#expect(
PeerCapabilities.localSupported.contains(.privateMediaReceipts)
)
}
}
@@ -0,0 +1,206 @@
import Foundation
import Testing
@testable import bitchat
struct BLEPrivateMediaReceiptStoreTests {
private struct TestError: Error {}
private let messageID = "media-00112233445566778899aabbccddeeff"
@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 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 failedTombstonePersistenceDoesNotPoisonVolatileState() throws {
let root = makeRoot("failed-tombstone-write")
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(store.state(for: messageID) == .absent)
// Force the atomic record write itself to fail after the store has
// successfully loaded its empty index.
let record = receiptRecord(in: root)
try FileManager.default.createDirectory(
at: record,
withIntermediateDirectories: true
)
#expect(!store.recordDeleted(messageID: messageID))
try FileManager.default.removeItem(at: record)
// The UI must be able to report the deletion failure without a
// process-lifetime tombstone silently hiding a later retry.
#expect(store.state(for: messageID) == .absent)
}
@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) throws -> URL {
let directory = root.appendingPathComponent(
"files/images/incoming",
isDirectory: true
)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
let payload = directory.appendingPathComponent("image.jpg")
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
return payload
}
private func receiptRecord(in root: URL) -> URL {
root
.appendingPathComponent(
"files/.private-media-receipts",
isDirectory: true
)
.appendingPathComponent(messageID)
.appendingPathExtension("json")
}
}
+7
View File
@@ -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
no-proof timeout reaches the explicit legacy-consent path for an unpinned
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
eligible for one signed, directed
`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
/// signed directed raw-file migration fallback.
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
/// replacement. Current clients intentionally do not advertise or act on
/// 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.privateMedia.encoded() == Data([0x00, 0x01]))
let high = PeerCapabilities(rawValue: 1 << 9)
#expect(high.encoded() == Data([0x00, 0x02]))
#expect(
PeerCapabilities.privateMediaReceipts.encoded()
== Data([0x00, 0x02])
)
#expect(
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
== 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: high.encoded()) == high)
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])