mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 21:45:19 +00:00
1706 lines
63 KiB
Swift
1706 lines
63 KiB
Swift
import BitFoundation
|
|
import BitLogger
|
|
import Foundation
|
|
|
|
#if os(iOS)
|
|
import UIKit
|
|
#endif
|
|
|
|
struct LegacyPrivateMediaConsentRequest: Identifiable, Equatable {
|
|
let id: UUID
|
|
let peerID: PeerID
|
|
let peerName: String
|
|
let transferId: String
|
|
let messageID: String
|
|
}
|
|
|
|
struct PendingLegacyPrivateMediaConsent {
|
|
let request: LegacyPrivateMediaConsentRequest
|
|
let completion: @MainActor (Bool) -> Void
|
|
}
|
|
|
|
struct PrivateMediaReconnectRetryLimits: Equatable {
|
|
var maxRetainedPackets = 8
|
|
var maxRetainedBytes = 4 * 1024 * 1024
|
|
var maxRetriesPerMessage = 2
|
|
var retentionSeconds: TimeInterval = 120
|
|
var maxRetriesPerReconnect = 2
|
|
}
|
|
|
|
private struct PrivateMediaReconnectRetryRecord {
|
|
let messageID: String
|
|
let peerID: PeerID
|
|
let packet: BitchatFilePacket
|
|
var receiptSessionGeneration: UUID
|
|
var createdAt: Date
|
|
var retryCount: Int
|
|
var activeTransferID: String?
|
|
var retryAfterCompletion: Bool
|
|
var idleOutcome: PrivateMediaReconnectRetryIdleOutcome
|
|
var deferredTerminalFailureReason: String?
|
|
var expiryToken: UUID?
|
|
|
|
var retainedBytes: Int {
|
|
packet.content.count
|
|
}
|
|
}
|
|
|
|
private enum PrivateMediaReconnectRetryIdleOutcome {
|
|
case none
|
|
case locallyCompleted
|
|
case cancelled
|
|
case rejected(reason: String)
|
|
}
|
|
|
|
private struct PrivateMediaReconnectRetryCandidate {
|
|
let messageID: String
|
|
/// The receipt-capable Noise generation that owned this record when the
|
|
/// reconnect/authentication event captured it.
|
|
let receiptSessionGeneration: UUID
|
|
}
|
|
|
|
/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
|
|
///
|
|
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
|
/// minimal context it actually uses instead of holding an `unowned` back-ref
|
|
/// to the whole `ChatViewModel`. This keeps the coordinator independently
|
|
/// testable (see `ChatMediaTransferCoordinatorContextTests`) and makes its
|
|
/// true dependencies explicit.
|
|
@MainActor
|
|
protocol ChatMediaTransferContext: AnyObject {
|
|
// MARK: Composition state
|
|
var canSendMediaInCurrentContext: Bool { get }
|
|
var selectedPrivateChatPeer: PeerID? { get }
|
|
var nickname: String { get }
|
|
var myPeerID: PeerID { get }
|
|
var activeChannel: ChannelID { get }
|
|
func nicknameForPeer(_ peerID: PeerID) -> String
|
|
func currentPublicSender() -> (name: String, peerID: PeerID)
|
|
|
|
// MARK: Message state
|
|
/// Appends a private message via the single-writer store intent.
|
|
@discardableResult
|
|
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
|
|
/// Appends a public message via the single-writer store intent
|
|
/// (immediate: outgoing media placeholders must render without batching).
|
|
@discardableResult
|
|
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
|
|
func removeMessage(withID messageID: String, cleanupFile: Bool)
|
|
/// Removes a media bubble with direction-scoped cleanup instead of the
|
|
/// broad compatibility cleanup path.
|
|
func removeUntombstonedMediaMessage(withID messageID: String)
|
|
func removeOutgoingMediaMessage(withID messageID: String)
|
|
func addSystemMessage(_ content: String)
|
|
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
|
func notifyUIChanged()
|
|
|
|
// MARK: Delivery status & dedup
|
|
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
|
func normalizedContentKey(_ content: String) -> String
|
|
func recordContentKey(_ key: String, timestamp: Date)
|
|
|
|
// MARK: Mesh file transfer
|
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
|
func authenticatedPrivateMediaReceiptSessionGeneration(to peerID: PeerID) -> UUID?
|
|
func resolvePrivateMediaSendPolicy(
|
|
to peerID: PeerID,
|
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
|
)
|
|
func requestLegacyPrivateMediaConsent(
|
|
for peerID: PeerID,
|
|
transferId: String,
|
|
messageID: String,
|
|
completion: @escaping @MainActor (Bool) -> Void
|
|
)
|
|
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String)
|
|
func sendFilePrivate(
|
|
_ packet: BitchatFilePacket,
|
|
to peerID: PeerID,
|
|
transferId: String,
|
|
allowLegacyFallback: Bool
|
|
)
|
|
func sendFilePrivateReceiptRetry(
|
|
_ packet: BitchatFilePacket,
|
|
to peerID: PeerID,
|
|
transferId: String
|
|
)
|
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
|
func cancelTransfer(_ transferId: String)
|
|
/// Receiver-side stable-ID deletion commit. Implementations must invoke
|
|
/// completion only after the entire batch is durably tombstoned.
|
|
func persistDeletedPrivateMedia(
|
|
messageIDs: [String],
|
|
completion: @escaping @MainActor (Bool) -> Void
|
|
)
|
|
/// Whether any current private-chat copy of this stable ID came from a
|
|
/// remote peer and therefore requires a receiver tombstone.
|
|
func requiresPrivateMediaTombstone(messageID: String) -> Bool
|
|
}
|
|
|
|
extension ChatViewModel: ChatMediaTransferContext {
|
|
// `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`,
|
|
// `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`,
|
|
// `currentPublicSender()`,
|
|
// `appendPublicMessage(_:to:)`, `removeMessage(withID:cleanupFile:)`,
|
|
// `addSystemMessage(_:)`, `notifyUIChanged()`,
|
|
// `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`,
|
|
// and `recordContentKey(_:timestamp:)` are shared requirements with the
|
|
// other contexts or satisfied by existing `ChatViewModel` members. The
|
|
// members below flatten mesh service accesses.
|
|
|
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
|
meshService.privateMediaSendPolicy(to: peerID)
|
|
}
|
|
|
|
func authenticatedPrivateMediaReceiptSessionGeneration(
|
|
to peerID: PeerID
|
|
) -> UUID? {
|
|
meshService.authenticatedPrivateMediaReceiptSessionGeneration(
|
|
to: peerID
|
|
)
|
|
}
|
|
|
|
func resolvePrivateMediaSendPolicy(
|
|
to peerID: PeerID,
|
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
|
) {
|
|
meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion)
|
|
}
|
|
|
|
func requestLegacyPrivateMediaConsent(
|
|
for peerID: PeerID,
|
|
transferId: String,
|
|
messageID: String,
|
|
completion: @escaping @MainActor (Bool) -> Void
|
|
) {
|
|
enqueueLegacyPrivateMediaConsent(
|
|
for: peerID,
|
|
transferId: transferId,
|
|
messageID: messageID,
|
|
completion: completion
|
|
)
|
|
}
|
|
|
|
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
|
invalidateLegacyPrivateMediaConsent(
|
|
transferId: transferId,
|
|
messageID: messageID
|
|
)
|
|
}
|
|
|
|
func sendFilePrivate(
|
|
_ packet: BitchatFilePacket,
|
|
to peerID: PeerID,
|
|
transferId: String,
|
|
allowLegacyFallback: Bool
|
|
) {
|
|
meshService.sendFilePrivate(
|
|
packet,
|
|
to: peerID,
|
|
transferId: transferId,
|
|
allowLegacyFallback: allowLegacyFallback
|
|
)
|
|
}
|
|
|
|
func sendFilePrivateReceiptRetry(
|
|
_ packet: BitchatFilePacket,
|
|
to peerID: PeerID,
|
|
transferId: String
|
|
) {
|
|
meshService.sendFilePrivateReceiptRetry(
|
|
packet,
|
|
to: peerID,
|
|
transferId: transferId
|
|
)
|
|
}
|
|
|
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
|
meshService.sendFileBroadcast(packet, transferId: transferId)
|
|
}
|
|
|
|
func cancelTransfer(_ transferId: String) {
|
|
meshService.cancelTransfer(transferId)
|
|
}
|
|
|
|
func removeUntombstonedMediaMessage(withID messageID: String) {
|
|
let message = conversations.conversationsByID.values.lazy
|
|
.flatMap(\.messages)
|
|
.first { $0.id == messageID }
|
|
if let message {
|
|
if !isIncomingPrivateMessage(message) {
|
|
mediaTransferCoordinator.cleanupOutgoingLocalFile(
|
|
forMessage: message
|
|
)
|
|
}
|
|
// Legacy/raw incoming media has no durable ID-to-file ownership.
|
|
// Its basename may already belong to a pending newer arrival, so
|
|
// leave the payload for bounded quota cleanup rather than unlink
|
|
// an ambiguous path.
|
|
}
|
|
removeMessage(withID: messageID, cleanupFile: false)
|
|
}
|
|
|
|
func removeOutgoingMediaMessage(withID messageID: String) {
|
|
let message = conversations.conversationsByID.values.lazy
|
|
.flatMap(\.messages)
|
|
.first { $0.id == messageID }
|
|
if let message {
|
|
mediaTransferCoordinator.cleanupOutgoingLocalFile(
|
|
forMessage: message
|
|
)
|
|
}
|
|
removeMessage(withID: messageID, cleanupFile: false)
|
|
}
|
|
|
|
func persistDeletedPrivateMedia(
|
|
messageIDs: [String],
|
|
completion: @escaping @MainActor (Bool) -> Void
|
|
) {
|
|
guard !messageIDs.isEmpty else {
|
|
completion(true)
|
|
return
|
|
}
|
|
guard let persistence =
|
|
meshService as? any PrivateMediaDeletionPersisting else {
|
|
completion(false)
|
|
return
|
|
}
|
|
let requestedIDs = Set(messageIDs)
|
|
let incomingPathReferences = Array(
|
|
conversations.conversationsByID.values
|
|
.lazy
|
|
.flatMap(\.messages)
|
|
.compactMap { message -> (
|
|
messageID: String,
|
|
path: String
|
|
)? in
|
|
guard self.isIncomingPrivateMessage(message),
|
|
let path = self.incomingMediaRelativePath(
|
|
for: message
|
|
) else {
|
|
return nil
|
|
}
|
|
return (message.id, path)
|
|
}
|
|
)
|
|
let ownerIDsByPath = Dictionary(
|
|
grouping: incomingPathReferences,
|
|
by: { $0.path }
|
|
).mapValues { Set($0.map(\.messageID)) }
|
|
let protectedPayloadRelativePaths = Set(
|
|
ownerIDsByPath.compactMap { path, ownerIDs in
|
|
ownerIDs.isSubset(of: requestedIDs) ? nil : path
|
|
}
|
|
)
|
|
var payloadRelativePaths: [String: String] = [:]
|
|
for reference in incomingPathReferences
|
|
where requestedIDs.contains(reference.messageID)
|
|
&& ownerIDsByPath[reference.path, default: []]
|
|
.isSubset(of: requestedIDs) {
|
|
payloadRelativePaths[reference.messageID] = reference.path
|
|
}
|
|
persistence.persistDeletedPrivateMedia(
|
|
messageIDs: messageIDs,
|
|
payloadRelativePaths: payloadRelativePaths,
|
|
protectedPayloadRelativePaths:
|
|
protectedPayloadRelativePaths,
|
|
completion: completion
|
|
)
|
|
}
|
|
|
|
func requiresPrivateMediaTombstone(messageID: String) -> Bool {
|
|
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
|
return false
|
|
}
|
|
return privateChats.values.lazy.flatMap { $0 }.contains { message in
|
|
message.id == messageID && isIncomingPrivateMessage(message)
|
|
}
|
|
}
|
|
|
|
private func isIncomingPrivateMessage(
|
|
_ message: BitchatMessage
|
|
) -> Bool {
|
|
if let senderPeerID = message.senderPeerID {
|
|
return senderPeerID.toShort() != meshService.myPeerID.toShort()
|
|
}
|
|
return message.sender != nickname
|
|
&& !message.sender.hasPrefix(nickname + "#")
|
|
}
|
|
|
|
private func incomingMediaRelativePath(
|
|
for message: BitchatMessage
|
|
) -> String? {
|
|
let categories: [MimeType.Category] = [.audio, .image, .file]
|
|
guard let category = categories.first(where: {
|
|
message.content.hasPrefix($0.messagePrefix)
|
|
}),
|
|
let rawFilename = String(
|
|
message.content.dropFirst(category.messagePrefix.count)
|
|
).trimmedOrNilIfEmpty,
|
|
let safeFilename =
|
|
(rawFilename as NSString).lastPathComponent.nilIfEmpty,
|
|
safeFilename != ".",
|
|
safeFilename != ".." else {
|
|
return nil
|
|
}
|
|
return "\(category.mediaDir)/incoming/\(safeFilename)"
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class ChatMediaTransferCoordinator {
|
|
private unowned let context: any ChatMediaTransferContext
|
|
private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket
|
|
private let prepareImagePacket: @Sendable (URL) throws -> ChatPreparedImage
|
|
private let reconnectRetryLimits: PrivateMediaReconnectRetryLimits
|
|
private let now: () -> Date
|
|
private let transferIDFactory: (String) -> String
|
|
|
|
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
|
private(set) var messageIDToTransferId: [String: String] = [:]
|
|
private var preparationGeneration: UInt64 = 0
|
|
private var deletionGeneration: UInt64 = 0
|
|
private var reconnectRetryRecords: [
|
|
String: PrivateMediaReconnectRetryRecord
|
|
] = [:]
|
|
/// A newly authenticated session supersedes any raw-connect policy
|
|
/// resolution still in flight for that peer.
|
|
private var peersResolvingReconnectRetry: [
|
|
PeerID: (
|
|
id: UUID,
|
|
replacingActiveTransfer: Bool,
|
|
candidates: [PrivateMediaReconnectRetryCandidate]
|
|
)
|
|
] = [:]
|
|
private var reconnectRetryExpiryTasks: [String: Task<Void, Never>] = [:]
|
|
|
|
var retainedReconnectRetryCount: Int {
|
|
reconnectRetryRecords.count
|
|
}
|
|
|
|
var retainedReconnectRetryBytes: Int {
|
|
reconnectRetryRecords.values.reduce(0) { $0 + $1.retainedBytes }
|
|
}
|
|
|
|
init(
|
|
context: any ChatMediaTransferContext,
|
|
prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = {
|
|
try ChatMediaPreparation.prepareVoiceNotePacket(at: $0)
|
|
},
|
|
prepareImagePacket: @escaping @Sendable (URL) throws -> ChatPreparedImage = {
|
|
try ChatMediaPreparation.prepareImagePacket(from: $0)
|
|
},
|
|
reconnectRetryLimits: PrivateMediaReconnectRetryLimits =
|
|
PrivateMediaReconnectRetryLimits(),
|
|
now: @escaping () -> Date = Date.init,
|
|
transferIDFactory: @escaping (String) -> String = {
|
|
"\($0)-\(UUID().uuidString)"
|
|
}
|
|
) {
|
|
self.context = context
|
|
self.prepareVoiceNotePacket = prepareVoiceNotePacket
|
|
self.prepareImagePacket = prepareImagePacket
|
|
self.reconnectRetryLimits = reconnectRetryLimits
|
|
self.now = now
|
|
self.transferIDFactory = transferIDFactory
|
|
}
|
|
|
|
func sendVoiceNote(at url: URL) {
|
|
guard context.canSendMediaInCurrentContext else {
|
|
SecureLogger.info("Voice note blocked outside mesh/private context", category: .session)
|
|
try? FileManager.default.removeItem(at: url)
|
|
context.addSystemMessage("Voice notes are only available in mesh chats.")
|
|
return
|
|
}
|
|
|
|
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,
|
|
messageID: privateMessageID
|
|
)
|
|
let messageID = message.id
|
|
let transferId = makeTransferID(messageID: messageID)
|
|
// Own the transfer before detached preparation begins. Cancel/delete
|
|
// must be able to invalidate this exact invocation even while file I/O
|
|
// is still running off the main actor.
|
|
registerTransfer(transferId: transferId, messageID: messageID)
|
|
let prepareVoiceNotePacket = self.prepareVoiceNotePacket
|
|
let generation = preparationGeneration
|
|
|
|
Task.detached(priority: .userInitiated) { [weak self] in
|
|
do {
|
|
let packet = try prepareVoiceNotePacket(url)
|
|
|
|
await MainActor.run { [weak self] in
|
|
guard let self,
|
|
self.preparationGeneration == generation,
|
|
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
|
return
|
|
}
|
|
if let peerID = targetPeer {
|
|
self.beginPrivateMediaSend(
|
|
packet,
|
|
to: peerID,
|
|
transferId: transferId,
|
|
messageID: messageID
|
|
)
|
|
} else {
|
|
self.context.sendFileBroadcast(packet, transferId: transferId)
|
|
}
|
|
}
|
|
} catch ChatMediaPreparationError.voiceNoteTooLarge(let size) {
|
|
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
|
try? FileManager.default.removeItem(at: url)
|
|
await MainActor.run { [weak self] in
|
|
guard let self,
|
|
self.preparationGeneration == generation,
|
|
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
|
return
|
|
}
|
|
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
|
await MainActor.run { [weak self] in
|
|
guard let self,
|
|
self.preparationGeneration == generation,
|
|
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
|
return
|
|
}
|
|
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
func processThenSendImage(_ image: UIImage?) {
|
|
guard let image else { return }
|
|
let generation = preparationGeneration
|
|
Task.detached { [weak self] in
|
|
do {
|
|
let processedURL = try ImageUtils.processImage(image)
|
|
await MainActor.run { [weak self] in
|
|
guard let self, self.preparationGeneration == generation else {
|
|
try? FileManager.default.removeItem(at: processedURL)
|
|
return
|
|
}
|
|
self.sendImage(from: processedURL)
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
#elseif os(macOS)
|
|
func processThenSendImage(from url: URL?) {
|
|
guard let url else { return }
|
|
let generation = preparationGeneration
|
|
Task.detached { [weak self] in
|
|
do {
|
|
let processedURL = try ImageUtils.processImage(at: url)
|
|
await MainActor.run { [weak self] in
|
|
guard let self, self.preparationGeneration == generation else {
|
|
try? FileManager.default.removeItem(at: processedURL)
|
|
return
|
|
}
|
|
self.sendImage(from: processedURL)
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
|
|
guard context.canSendMediaInCurrentContext else {
|
|
SecureLogger.info("Image send blocked outside mesh/private context", category: .session)
|
|
cleanup?()
|
|
context.addSystemMessage("Images are only available in mesh chats.")
|
|
return
|
|
}
|
|
|
|
let targetPeer = context.selectedPrivateChatPeer
|
|
let generation = preparationGeneration
|
|
|
|
do {
|
|
try ImageUtils.validateImageSource(at: sourceURL)
|
|
} catch {
|
|
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
|
context.addSystemMessage("Failed to prepare image for sending.")
|
|
return
|
|
}
|
|
|
|
let prepareImagePacket = self.prepareImagePacket
|
|
Task.detached(priority: .userInitiated) { [weak self] in
|
|
do {
|
|
let prepared = try prepareImagePacket(sourceURL)
|
|
|
|
await MainActor.run { [weak self] in
|
|
guard let self, self.preparationGeneration == generation else {
|
|
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,
|
|
messageID: privateMessageID
|
|
)
|
|
let messageID = message.id
|
|
let transferId = self.makeTransferID(messageID: messageID)
|
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
|
if let peerID = targetPeer {
|
|
self.beginPrivateMediaSend(
|
|
prepared.packet,
|
|
to: peerID,
|
|
transferId: transferId,
|
|
messageID: messageID
|
|
)
|
|
} else {
|
|
self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
|
|
}
|
|
}
|
|
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
|
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
|
await MainActor.run { [weak self] in
|
|
guard let self, self.preparationGeneration == generation else { return }
|
|
self.context.addSystemMessage("Image is too large to send.")
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
|
await MainActor.run { [weak self] in
|
|
guard let self, self.preparationGeneration == generation else { return }
|
|
self.context.addSystemMessage("Failed to prepare image for sending.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
isRelay: false,
|
|
originalSender: nil,
|
|
isPrivate: true,
|
|
recipientNickname: context.nicknameForPeer(peerID),
|
|
senderPeerID: context.myPeerID,
|
|
deliveryStatus: .sending
|
|
)
|
|
context.appendPrivateMessage(message, to: peerID)
|
|
} else {
|
|
let (displayName, senderPeerID) = context.currentPublicSender()
|
|
message = BitchatMessage(
|
|
sender: displayName,
|
|
content: content,
|
|
timestamp: timestamp,
|
|
isRelay: false,
|
|
originalSender: nil,
|
|
isPrivate: false,
|
|
recipientNickname: nil,
|
|
senderPeerID: senderPeerID,
|
|
deliveryStatus: .sending
|
|
)
|
|
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
|
|
}
|
|
|
|
let key = context.normalizedContentKey(message.content)
|
|
context.recordContentKey(key, timestamp: timestamp)
|
|
context.notifyUIChanged()
|
|
return message
|
|
}
|
|
|
|
private func beginPrivateMediaSend(
|
|
_ packet: BitchatFilePacket,
|
|
to peerID: PeerID,
|
|
transferId: String,
|
|
messageID: String
|
|
) {
|
|
continuePrivateMediaSend(
|
|
packet,
|
|
to: peerID,
|
|
transferId: transferId,
|
|
messageID: messageID,
|
|
policy: context.privateMediaSendPolicy(to: peerID)
|
|
)
|
|
}
|
|
|
|
private func continuePrivateMediaSend(
|
|
_ packet: BitchatFilePacket,
|
|
to peerID: PeerID,
|
|
transferId: String,
|
|
messageID: String,
|
|
policy: PrivateMediaSendPolicy
|
|
) {
|
|
switch policy {
|
|
case .encrypted:
|
|
retainForReconnectRetryIfEligible(
|
|
packet,
|
|
peerID: peerID,
|
|
messageID: messageID,
|
|
activeTransferID: transferId
|
|
)
|
|
context.sendFilePrivate(
|
|
packet,
|
|
to: peerID,
|
|
transferId: transferId,
|
|
allowLegacyFallback: false
|
|
)
|
|
|
|
case .awaitingCapabilityProof:
|
|
context.resolvePrivateMediaSendPolicy(to: peerID) { [weak self] resolvedPolicy in
|
|
guard let self,
|
|
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
|
return
|
|
}
|
|
guard resolvedPolicy != .awaitingCapabilityProof else {
|
|
self.handleMediaSendFailure(
|
|
messageID: messageID,
|
|
reason: String(
|
|
localized: "content.delivery.reason.private_media_capability_unresolved",
|
|
defaultValue: "Could not confirm encrypted media support",
|
|
comment: "Failure reason when private-media capability negotiation did not resolve"
|
|
)
|
|
)
|
|
return
|
|
}
|
|
self.continuePrivateMediaSend(
|
|
packet,
|
|
to: peerID,
|
|
transferId: transferId,
|
|
messageID: messageID,
|
|
policy: resolvedPolicy
|
|
)
|
|
}
|
|
|
|
case .legacyRequiresConsent:
|
|
context.requestLegacyPrivateMediaConsent(
|
|
for: peerID,
|
|
transferId: transferId,
|
|
messageID: messageID
|
|
) { [weak self] approved in
|
|
guard let self else { return }
|
|
// Consent belongs to this exact placeholder/transfer. A late
|
|
// dialog callback after cancel/delete must never resurrect it.
|
|
guard self.messageIDToTransferId[messageID] == transferId,
|
|
self.transferIdToMessageIDs[transferId]?.contains(messageID) == true else {
|
|
return
|
|
}
|
|
guard approved else {
|
|
self.handleMediaSendFailure(
|
|
messageID: messageID,
|
|
reason: String(
|
|
localized: "content.delivery.reason.legacy_media_declined",
|
|
defaultValue: "Not sent without end-to-end encryption",
|
|
comment: "Failure reason after declining the warning for a legacy clear private-media send"
|
|
)
|
|
)
|
|
return
|
|
}
|
|
self.context.sendFilePrivate(
|
|
packet,
|
|
to: peerID,
|
|
transferId: transferId,
|
|
allowLegacyFallback: true
|
|
)
|
|
}
|
|
|
|
case .blockedDowngrade:
|
|
handleMediaSendFailure(
|
|
messageID: messageID,
|
|
reason: String(
|
|
localized: "content.delivery.reason.private_media_downgrade_blocked",
|
|
defaultValue: "Encrypted media required; ask this contact to upgrade",
|
|
comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade"
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
func registerTransfer(transferId: String, messageID: String) {
|
|
transferIdToMessageIDs[transferId, default: []].append(messageID)
|
|
messageIDToTransferId[messageID] = transferId
|
|
}
|
|
|
|
private func isRegisteredTransfer(_ transferId: String, messageID: String) -> Bool {
|
|
messageIDToTransferId[messageID] == transferId
|
|
&& transferIdToMessageIDs[transferId]?.contains(messageID) == true
|
|
}
|
|
|
|
func makeTransferID(messageID: String) -> String {
|
|
transferIDFactory(messageID)
|
|
}
|
|
|
|
func clearTransferMapping(for messageID: String) {
|
|
guard let transferId = messageIDToTransferId[messageID] else { return }
|
|
clearTransferMapping(
|
|
transferID: transferId,
|
|
messageID: messageID,
|
|
clearCurrentOwner: true
|
|
)
|
|
}
|
|
|
|
private func clearTransferMapping(
|
|
transferID: String,
|
|
messageID: String,
|
|
clearCurrentOwner: Bool
|
|
) {
|
|
if clearCurrentOwner,
|
|
messageIDToTransferId[messageID] == transferID {
|
|
messageIDToTransferId.removeValue(forKey: messageID)
|
|
}
|
|
context.cancelLegacyPrivateMediaConsent(
|
|
transferId: transferID,
|
|
messageID: messageID
|
|
)
|
|
guard var queue = transferIdToMessageIDs[transferID] else { return }
|
|
|
|
if !queue.isEmpty {
|
|
if queue.first == messageID {
|
|
queue.removeFirst()
|
|
} else if let index = queue.firstIndex(of: messageID) {
|
|
queue.remove(at: index)
|
|
}
|
|
}
|
|
|
|
transferIdToMessageIDs[transferID] = queue.isEmpty ? nil : queue
|
|
}
|
|
|
|
/// Returns the message still owned by this exact transfer. Replacement
|
|
/// retries can receive late callbacks from the cancelled predecessor;
|
|
/// those callbacks may clear only their stale queue entry.
|
|
private func currentMessageID(forTransferID transferID: String) -> String? {
|
|
guard let messageID = transferIdToMessageIDs[transferID]?.first else {
|
|
return nil
|
|
}
|
|
guard messageIDToTransferId[messageID] == transferID else {
|
|
clearTransferMapping(
|
|
transferID: transferID,
|
|
messageID: messageID,
|
|
clearCurrentOwner: false
|
|
)
|
|
return nil
|
|
}
|
|
return messageID
|
|
}
|
|
|
|
func handleMediaSendFailure(messageID: String, reason: String) {
|
|
discardReconnectRetry(
|
|
messageID: messageID,
|
|
cancelActiveTransfer: false
|
|
)
|
|
context.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
|
|
clearTransferMapping(for: messageID)
|
|
}
|
|
|
|
func handleTransferEvent(_ event: TransferProgressManager.Event) {
|
|
switch event {
|
|
case .started(let id, let total):
|
|
guard let messageID = currentMessageID(forTransferID: id) else {
|
|
return
|
|
}
|
|
if isReconnectRetryTransfer(id, messageID: messageID) {
|
|
return
|
|
}
|
|
context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total))
|
|
|
|
case .updated(let id, let sent, let total):
|
|
guard let messageID = currentMessageID(forTransferID: id) else {
|
|
return
|
|
}
|
|
if isReconnectRetryTransfer(id, messageID: messageID) {
|
|
return
|
|
}
|
|
context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))
|
|
|
|
case .completed(let id, _):
|
|
guard let messageID = currentMessageID(forTransferID: id) else {
|
|
return
|
|
}
|
|
let ownsRetainedRecord =
|
|
reconnectRetryRecords[messageID]?.activeTransferID == id
|
|
let retryAfterCompletion = ownsRetainedRecord
|
|
&& reconnectRetryRecords[messageID]?.retryAfterCompletion == true
|
|
let deferredTerminalReason = ownsRetainedRecord
|
|
? reconnectRetryRecords[messageID]?
|
|
.deferredTerminalFailureReason
|
|
: nil
|
|
if ownsRetainedRecord {
|
|
reconnectRetryRecords[messageID]?.activeTransferID = nil
|
|
reconnectRetryRecords[messageID]?.retryAfterCompletion = false
|
|
reconnectRetryRecords[messageID]?.idleOutcome =
|
|
.locallyCompleted
|
|
reconnectRetryRecords[messageID]?.createdAt = now()
|
|
}
|
|
context.updateMessageDeliveryStatus(messageID, status: .sent)
|
|
clearTransferMapping(for: messageID)
|
|
if let deferredTerminalReason {
|
|
terminalizeReconnectRetry(
|
|
messageID: messageID,
|
|
reason: deferredTerminalReason
|
|
)
|
|
} else if retryAfterCompletion {
|
|
startReconnectRetry(messageID: messageID)
|
|
} else if ownsRetainedRecord {
|
|
scheduleReconnectRetryExpiry(messageID: messageID)
|
|
}
|
|
|
|
case .cancelled(let id, _, _):
|
|
guard let messageID = currentMessageID(forTransferID: id) else {
|
|
return
|
|
}
|
|
if isRetainedPrivateMediaTransfer(id, messageID: messageID) {
|
|
finishRetainedTransfer(
|
|
id,
|
|
messageID: messageID,
|
|
outcome: .cancelled,
|
|
rejectionReason: nil
|
|
)
|
|
return
|
|
}
|
|
discardReconnectRetry(
|
|
messageID: messageID,
|
|
cancelActiveTransfer: false
|
|
)
|
|
clearTransferMapping(for: messageID)
|
|
context.removeOutgoingMediaMessage(withID: messageID)
|
|
case .rejected(let id, let reason):
|
|
guard let messageID = currentMessageID(forTransferID: id) else {
|
|
return
|
|
}
|
|
if isRetainedPrivateMediaTransfer(id, messageID: messageID) {
|
|
finishRetainedTransfer(
|
|
id,
|
|
messageID: messageID,
|
|
outcome: .rejected(reason: reason),
|
|
rejectionReason: reason
|
|
)
|
|
return
|
|
}
|
|
handleMediaSendFailure(messageID: messageID, reason: reason)
|
|
}
|
|
}
|
|
|
|
func cleanupLocalFile(forMessage message: BitchatMessage) {
|
|
cleanupLocalFile(
|
|
forMessage: message,
|
|
directions: ["outgoing", "incoming"],
|
|
searchAllCategories: true
|
|
)
|
|
}
|
|
|
|
/// `/clear` may cancel an outgoing message before receiver tombstones are
|
|
/// committed. Restrict cleanup to that message's outgoing directory so a
|
|
/// same-name incoming payload cannot be removed prematurely.
|
|
func cleanupOutgoingLocalFile(forMessage message: BitchatMessage) {
|
|
cleanupLocalFile(
|
|
forMessage: message,
|
|
directions: ["outgoing"],
|
|
searchAllCategories: false
|
|
)
|
|
}
|
|
|
|
/// Receiver cleanup runs only after any required tombstone commit. Keep it
|
|
/// scoped to the parsed media category and incoming directory so unrelated
|
|
/// outgoing or cross-category payloads with the same basename survive.
|
|
func cleanupIncomingLocalFile(forMessage message: BitchatMessage) {
|
|
cleanupLocalFile(
|
|
forMessage: message,
|
|
directions: ["incoming"],
|
|
searchAllCategories: false
|
|
)
|
|
}
|
|
|
|
private func cleanupLocalFile(
|
|
forMessage message: BitchatMessage,
|
|
directions: [String],
|
|
searchAllCategories: Bool
|
|
) {
|
|
let categories: [MimeType.Category] = [.audio, .image, .file]
|
|
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
|
|
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
|
|
let base = try? applicationFilesDirectory(),
|
|
let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty,
|
|
safeFilename != ".",
|
|
safeFilename != ".." else {
|
|
return
|
|
}
|
|
|
|
let targetCategories = searchAllCategories ? categories : [category]
|
|
let subdirs = targetCategories.flatMap { category in
|
|
directions.map { "\(category.mediaDir)/\($0)" }
|
|
}
|
|
for subdir in subdirs {
|
|
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
|
|
guard target.path.hasPrefix(base.path) else { continue }
|
|
|
|
guard FileManager.default.fileExists(atPath: target.path) else {
|
|
continue
|
|
}
|
|
guard let values = try? target.resourceValues(
|
|
forKeys: [.isRegularFileKey]
|
|
),
|
|
values.isRegularFile == true else {
|
|
SecureLogger.warning(
|
|
"Refusing to cleanup non-file media target \(safeFilename)",
|
|
category: .session
|
|
)
|
|
continue
|
|
}
|
|
do {
|
|
try FileManager.default.removeItem(at: target)
|
|
} catch CocoaError.fileNoSuchFile {
|
|
continue
|
|
} catch {
|
|
SecureLogger.error("Failed to cleanup \(safeFilename): \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
|
|
func cancelMediaSend(messageID: String) {
|
|
cancelAllMediaSendOwners(messageID: messageID)
|
|
context.removeOutgoingMediaMessage(withID: messageID)
|
|
}
|
|
|
|
/// Lets `/clear` cancel send ownership without implicitly deciding which
|
|
/// bubbles/files its deletion transaction may remove.
|
|
func cancelMediaTransferForConversationClear(messageID: String) {
|
|
cancelAllMediaSendOwners(messageID: messageID)
|
|
}
|
|
|
|
func deleteMediaMessage(messageID: String) {
|
|
// Stop every exact sender owner before the durable receiver commit.
|
|
// Otherwise a retained retry or admitted legacy send could transmit
|
|
// after the bubble and payload have been deleted.
|
|
cancelAllMediaSendOwners(messageID: messageID)
|
|
|
|
guard context.requiresPrivateMediaTombstone(
|
|
messageID: messageID
|
|
) else {
|
|
finishMediaDeletion(
|
|
messageID: messageID,
|
|
receiverJournalOwnsPayload: false
|
|
)
|
|
return
|
|
}
|
|
|
|
let generation = deletionGeneration
|
|
context.persistDeletedPrivateMedia(
|
|
messageIDs: [messageID]
|
|
) { [weak self] persisted in
|
|
guard let self,
|
|
self.deletionGeneration == generation else {
|
|
return
|
|
}
|
|
guard persisted else {
|
|
SecureLogger.error(
|
|
"Refusing to delete private media without a durable tombstone id=\(messageID.prefix(12))…",
|
|
category: .session
|
|
)
|
|
return
|
|
}
|
|
self.finishMediaDeletion(
|
|
messageID: messageID,
|
|
receiverJournalOwnsPayload: true
|
|
)
|
|
}
|
|
}
|
|
|
|
private func finishMediaDeletion(
|
|
messageID: String,
|
|
receiverJournalOwnsPayload: Bool
|
|
) {
|
|
if receiverJournalOwnsPayload {
|
|
// The journal already owns the exact path. A basename cleanup here
|
|
// could delete a different arrival that reused it after unlink.
|
|
context.removeMessage(withID: messageID, cleanupFile: false)
|
|
} else {
|
|
context.removeUntombstonedMediaMessage(withID: messageID)
|
|
}
|
|
}
|
|
|
|
private func cancelAllMediaSendOwners(messageID: String) {
|
|
// This releases the retained packet and expiry/retry owner. When its
|
|
// exact active transfer still owns the mapping, it cancels that owner
|
|
// before any deletion persistence or UI mutation can proceed.
|
|
discardReconnectRetry(
|
|
messageID: messageID,
|
|
cancelActiveTransfer: true
|
|
)
|
|
// In particular, an approved legacy send may still be waiting on
|
|
// BLEService.messageQueue. Its admission must be canceled before the
|
|
// mapping/consent owner is released.
|
|
if let transferId = messageIDToTransferId[messageID],
|
|
transferIdToMessageIDs[transferId]?.first == messageID {
|
|
context.cancelTransfer(transferId)
|
|
}
|
|
clearTransferMapping(for: messageID)
|
|
}
|
|
|
|
/// A raw link callback can arrive before the replacement Noise session
|
|
/// proves its capabilities. Resolve against the exact session before
|
|
/// releasing any retained bytes into a whole-file retry.
|
|
func peerDidReconnect(_ peerID: PeerID) {
|
|
resolveReconnectRetries(
|
|
for: peerID,
|
|
replacingActiveTransfer: false
|
|
)
|
|
}
|
|
|
|
/// Authentication supersedes a raw-connect resolution that may still
|
|
/// refer to the cached generation and replaces only stale active sends.
|
|
func peerDidAuthenticate(_ peerID: PeerID) {
|
|
resolveReconnectRetries(
|
|
for: peerID,
|
|
replacingActiveTransfer: true
|
|
)
|
|
}
|
|
|
|
/// Local fragment completion is not proof that the recipient reconstructed
|
|
/// the file. Only a remote delivery/read receipt releases retry ownership.
|
|
func confirmPrivateMediaDelivery(messageID: String) {
|
|
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
|
return
|
|
}
|
|
discardReconnectRetry(
|
|
messageID: messageID,
|
|
cancelActiveTransfer: true
|
|
)
|
|
}
|
|
|
|
/// Deterministic clock seam for focused tests. Production records also own
|
|
/// wall-clock expiry tasks.
|
|
func _test_expireReconnectRetries() {
|
|
pruneExpiredReconnectRetries()
|
|
}
|
|
|
|
/// Invalidates detached preparation work and cancels every transfer that
|
|
/// reached the transport. Stale tasks check the generation before they
|
|
/// can recreate a message or send after a panic wipe.
|
|
func resetForPanic() {
|
|
preparationGeneration &+= 1
|
|
deletionGeneration &+= 1
|
|
peersResolvingReconnectRetry.removeAll(keepingCapacity: false)
|
|
for task in reconnectRetryExpiryTasks.values {
|
|
task.cancel()
|
|
}
|
|
reconnectRetryExpiryTasks.removeAll(keepingCapacity: false)
|
|
reconnectRetryRecords.removeAll(keepingCapacity: false)
|
|
let transferIDs = Set(transferIdToMessageIDs.keys)
|
|
transferIdToMessageIDs.removeAll(keepingCapacity: false)
|
|
messageIDToTransferId.removeAll(keepingCapacity: false)
|
|
for transferID in transferIDs {
|
|
context.cancelTransfer(transferID)
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension ChatMediaTransferCoordinator {
|
|
func reconnectRetryCandidates(
|
|
for peerID: PeerID,
|
|
limit: Int?
|
|
) -> [PrivateMediaReconnectRetryCandidate] {
|
|
let records = reconnectRetryRecords.values
|
|
.filter {
|
|
$0.peerID == peerID
|
|
&& $0.retryCount
|
|
< reconnectRetryLimits.maxRetriesPerMessage
|
|
}
|
|
.sorted {
|
|
if $0.createdAt == $1.createdAt {
|
|
return $0.messageID < $1.messageID
|
|
}
|
|
return $0.createdAt < $1.createdAt
|
|
}
|
|
let selected: ArraySlice<PrivateMediaReconnectRetryRecord>
|
|
if let limit {
|
|
selected = records.prefix(max(0, limit))
|
|
} else {
|
|
selected = records[...]
|
|
}
|
|
return selected.map {
|
|
PrivateMediaReconnectRetryCandidate(
|
|
messageID: $0.messageID,
|
|
receiptSessionGeneration: $0.receiptSessionGeneration
|
|
)
|
|
}
|
|
}
|
|
|
|
func resolveReconnectRetries(
|
|
for peerID: PeerID,
|
|
replacingActiveTransfer: Bool
|
|
) {
|
|
pruneExpiredReconnectRetries()
|
|
let normalizedPeerID = peerID.toShort()
|
|
let candidates: [PrivateMediaReconnectRetryCandidate]
|
|
if let pending = peersResolvingReconnectRetry[normalizedPeerID] {
|
|
// Authentication is the only event that may supersede a raw-link
|
|
// resolution; duplicate callbacks add no new proof.
|
|
guard replacingActiveTransfer,
|
|
!pending.replacingActiveTransfer else {
|
|
return
|
|
}
|
|
candidates = reconnectRetryCandidates(
|
|
for: normalizedPeerID,
|
|
limit: nil
|
|
)
|
|
} else {
|
|
candidates = reconnectRetryCandidates(
|
|
for: normalizedPeerID,
|
|
limit: replacingActiveTransfer
|
|
? nil
|
|
: max(
|
|
0,
|
|
reconnectRetryLimits.maxRetriesPerReconnect
|
|
)
|
|
)
|
|
}
|
|
guard !candidates.isEmpty else { return }
|
|
|
|
let resolutionID = UUID()
|
|
peersResolvingReconnectRetry[normalizedPeerID] = (
|
|
id: resolutionID,
|
|
replacingActiveTransfer: replacingActiveTransfer,
|
|
candidates: candidates
|
|
)
|
|
context.resolvePrivateMediaSendPolicy(
|
|
to: normalizedPeerID
|
|
) { [weak self] policy in
|
|
guard let self,
|
|
let pending =
|
|
self.peersResolvingReconnectRetry[normalizedPeerID],
|
|
pending.id == resolutionID else {
|
|
return
|
|
}
|
|
self.peersResolvingReconnectRetry.removeValue(
|
|
forKey: normalizedPeerID
|
|
)
|
|
guard policy == .encrypted,
|
|
let provenGeneration = self.context
|
|
.authenticatedPrivateMediaReceiptSessionGeneration(
|
|
to: normalizedPeerID
|
|
) else {
|
|
self.terminalizeUnavailableCapabilityProof(
|
|
pending.candidates,
|
|
for: normalizedPeerID
|
|
)
|
|
return
|
|
}
|
|
self.scheduleReconnectRetries(
|
|
pending.candidates,
|
|
for: normalizedPeerID,
|
|
replacingActiveTransfer:
|
|
pending.replacingActiveTransfer,
|
|
provenGeneration: provenGeneration
|
|
)
|
|
}
|
|
}
|
|
|
|
func retainForReconnectRetryIfEligible(
|
|
_ packet: BitchatFilePacket,
|
|
peerID: PeerID,
|
|
messageID: String,
|
|
activeTransferID: String
|
|
) {
|
|
let normalizedPeerID = peerID.toShort()
|
|
guard let receiptSessionGeneration = context
|
|
.authenticatedPrivateMediaReceiptSessionGeneration(
|
|
to: normalizedPeerID
|
|
),
|
|
reconnectRetryLimits.maxRetainedPackets > 0,
|
|
reconnectRetryLimits.maxRetainedBytes > 0,
|
|
reconnectRetryLimits.maxRetriesPerMessage > 0,
|
|
packet.content.count
|
|
<= reconnectRetryLimits.maxRetainedBytes,
|
|
PrivateMediaMessageIdentity.isStableID(messageID),
|
|
PrivateMediaMessageIdentity.stableID(
|
|
for: packet,
|
|
senderPeerID: context.myPeerID,
|
|
recipientPeerID: normalizedPeerID
|
|
) == messageID else {
|
|
return
|
|
}
|
|
|
|
pruneExpiredReconnectRetries()
|
|
if var existing = reconnectRetryRecords[messageID] {
|
|
cancelReconnectRetryExpiry(messageID: messageID)
|
|
existing.receiptSessionGeneration = receiptSessionGeneration
|
|
existing.createdAt = now()
|
|
existing.activeTransferID = activeTransferID
|
|
existing.retryAfterCompletion = false
|
|
existing.idleOutcome = .none
|
|
existing.deferredTerminalFailureReason = nil
|
|
existing.expiryToken = nil
|
|
reconnectRetryRecords[messageID] = existing
|
|
return
|
|
}
|
|
|
|
makeReconnectRetryCapacity(for: packet.content.count)
|
|
guard reconnectRetryRecords.count
|
|
< reconnectRetryLimits.maxRetainedPackets,
|
|
retainedReconnectRetryBytes + packet.content.count
|
|
<= reconnectRetryLimits.maxRetainedBytes else {
|
|
SecureLogger.debug(
|
|
"Private media retry retention full; sending once id=\(messageID.prefix(12))…",
|
|
category: .session
|
|
)
|
|
return
|
|
}
|
|
|
|
reconnectRetryRecords[messageID] =
|
|
PrivateMediaReconnectRetryRecord(
|
|
messageID: messageID,
|
|
peerID: normalizedPeerID,
|
|
packet: packet,
|
|
receiptSessionGeneration: receiptSessionGeneration,
|
|
createdAt: now(),
|
|
retryCount: 0,
|
|
activeTransferID: activeTransferID,
|
|
retryAfterCompletion: false,
|
|
idleOutcome: .none,
|
|
deferredTerminalFailureReason: nil,
|
|
expiryToken: nil
|
|
)
|
|
}
|
|
|
|
func terminalizeUnavailableCapabilityProof(
|
|
_ candidates: [PrivateMediaReconnectRetryCandidate],
|
|
for peerID: PeerID
|
|
) {
|
|
let reason = privateMediaCapabilityUnresolvedReason
|
|
for candidate in candidates {
|
|
guard var record =
|
|
reconnectRetryRecords[candidate.messageID],
|
|
record.peerID == peerID,
|
|
record.receiptSessionGeneration
|
|
== candidate.receiptSessionGeneration else {
|
|
continue
|
|
}
|
|
if record.activeTransferID != nil {
|
|
// The original transport owner can still produce a valid
|
|
// remote receipt. Defer failure until it releases ownership.
|
|
record.deferredTerminalFailureReason = reason
|
|
reconnectRetryRecords[candidate.messageID] = record
|
|
} else {
|
|
terminalizeReconnectRetry(
|
|
messageID: candidate.messageID,
|
|
reason: reason
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func scheduleReconnectRetries(
|
|
_ candidates: [PrivateMediaReconnectRetryCandidate],
|
|
for peerID: PeerID,
|
|
replacingActiveTransfer: Bool,
|
|
provenGeneration: UUID
|
|
) {
|
|
pruneExpiredReconnectRetries()
|
|
|
|
var scheduledCount = 0
|
|
let limit = max(
|
|
0,
|
|
reconnectRetryLimits.maxRetriesPerReconnect
|
|
)
|
|
for candidate in candidates {
|
|
guard scheduledCount < limit else { break }
|
|
let messageID = candidate.messageID
|
|
guard var record = reconnectRetryRecords[messageID],
|
|
record.peerID == peerID,
|
|
record.receiptSessionGeneration
|
|
== candidate.receiptSessionGeneration,
|
|
record.retryCount
|
|
< reconnectRetryLimits.maxRetriesPerMessage else {
|
|
continue
|
|
}
|
|
if record.deferredTerminalFailureReason != nil {
|
|
record.deferredTerminalFailureReason = nil
|
|
reconnectRetryRecords[messageID] = record
|
|
}
|
|
if replacingActiveTransfer,
|
|
candidate.receiptSessionGeneration == provenGeneration {
|
|
continue
|
|
}
|
|
if record.activeTransferID != nil {
|
|
if replacingActiveTransfer {
|
|
if replaceActiveTransferAfterAuthentication(
|
|
messageID: messageID
|
|
) {
|
|
scheduledCount += 1
|
|
}
|
|
continue
|
|
}
|
|
// Arm one retry after the current transfer drains. Duplicate
|
|
// reconnect callbacks cannot chain more work.
|
|
if record.retryCount == 0 {
|
|
record.retryAfterCompletion = true
|
|
reconnectRetryRecords[messageID] = record
|
|
scheduledCount += 1
|
|
}
|
|
} else if startReconnectRetry(messageID: messageID) {
|
|
scheduledCount += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func replaceActiveTransferAfterAuthentication(
|
|
messageID: String
|
|
) -> Bool {
|
|
guard var record = reconnectRetryRecords[messageID],
|
|
let staleTransferID = record.activeTransferID,
|
|
record.retryCount
|
|
< reconnectRetryLimits.maxRetriesPerMessage else {
|
|
return false
|
|
}
|
|
record.activeTransferID = nil
|
|
record.retryAfterCompletion = false
|
|
record.createdAt = now()
|
|
reconnectRetryRecords[messageID] = record
|
|
|
|
if messageIDToTransferId[messageID] == staleTransferID {
|
|
clearTransferMapping(for: messageID)
|
|
}
|
|
context.cancelTransfer(staleTransferID)
|
|
return startReconnectRetry(messageID: messageID)
|
|
}
|
|
|
|
@discardableResult
|
|
func startReconnectRetry(messageID: String) -> Bool {
|
|
pruneExpiredReconnectRetries()
|
|
guard var record = reconnectRetryRecords[messageID],
|
|
record.activeTransferID == nil,
|
|
record.retryCount
|
|
< reconnectRetryLimits.maxRetriesPerMessage else {
|
|
return false
|
|
}
|
|
guard context.privateMediaSendPolicy(to: record.peerID)
|
|
== .encrypted,
|
|
let receiptSessionGeneration = context
|
|
.authenticatedPrivateMediaReceiptSessionGeneration(
|
|
to: record.peerID
|
|
) else {
|
|
terminalizeReconnectRetry(
|
|
messageID: messageID,
|
|
reason: privateMediaCapabilityUnresolvedReason
|
|
)
|
|
return false
|
|
}
|
|
|
|
cancelReconnectRetryExpiry(messageID: messageID)
|
|
let transferID = makeTransferID(messageID: messageID)
|
|
record.retryCount += 1
|
|
record.receiptSessionGeneration = receiptSessionGeneration
|
|
record.activeTransferID = transferID
|
|
record.retryAfterCompletion = false
|
|
record.idleOutcome = .none
|
|
record.deferredTerminalFailureReason = nil
|
|
record.expiryToken = nil
|
|
reconnectRetryRecords[messageID] = record
|
|
registerTransfer(
|
|
transferId: transferID,
|
|
messageID: messageID
|
|
)
|
|
|
|
SecureLogger.debug(
|
|
"🔄 Retrying private media after reconnect id=\(messageID.prefix(12))… attempt=\(record.retryCount)",
|
|
category: .session
|
|
)
|
|
context.sendFilePrivateReceiptRetry(
|
|
record.packet,
|
|
to: record.peerID,
|
|
transferId: transferID
|
|
)
|
|
return true
|
|
}
|
|
|
|
func finishRetainedTransfer(
|
|
_ transferID: String,
|
|
messageID: String,
|
|
outcome: PrivateMediaReconnectRetryIdleOutcome,
|
|
rejectionReason: String?
|
|
) {
|
|
guard var record = reconnectRetryRecords[messageID],
|
|
record.activeTransferID == transferID else {
|
|
return
|
|
}
|
|
let retryAfterCompletion = record.retryAfterCompletion
|
|
let deferredTerminalReason =
|
|
record.deferredTerminalFailureReason
|
|
record.activeTransferID = nil
|
|
record.retryAfterCompletion = false
|
|
record.idleOutcome = outcome
|
|
record.createdAt = now()
|
|
reconnectRetryRecords[messageID] = record
|
|
clearTransferMapping(for: messageID)
|
|
|
|
if let deferredTerminalReason {
|
|
terminalizeReconnectRetry(
|
|
messageID: messageID,
|
|
reason: deferredTerminalReason
|
|
)
|
|
} else if retryAfterCompletion {
|
|
startReconnectRetry(messageID: messageID)
|
|
} else if record.retryCount
|
|
>= reconnectRetryLimits.maxRetriesPerMessage {
|
|
terminalizeReconnectRetry(
|
|
messageID: messageID,
|
|
reason: rejectionReason
|
|
?? privateMediaNotDeliveredReason
|
|
)
|
|
} else {
|
|
scheduleReconnectRetryExpiry(messageID: messageID)
|
|
}
|
|
|
|
if let rejectionReason {
|
|
SecureLogger.debug(
|
|
"Private media retry rejected id=\(messageID.prefix(12))…: \(rejectionReason)",
|
|
category: .session
|
|
)
|
|
}
|
|
}
|
|
|
|
func isReconnectRetryTransfer(
|
|
_ transferID: String,
|
|
messageID: String
|
|
) -> Bool {
|
|
guard let record = reconnectRetryRecords[messageID] else {
|
|
return false
|
|
}
|
|
return record.retryCount > 0
|
|
&& record.activeTransferID == transferID
|
|
}
|
|
|
|
func isRetainedPrivateMediaTransfer(
|
|
_ transferID: String,
|
|
messageID: String
|
|
) -> Bool {
|
|
reconnectRetryRecords[messageID]?.activeTransferID
|
|
== transferID
|
|
}
|
|
|
|
func discardReconnectRetry(
|
|
messageID: String,
|
|
cancelActiveTransfer: Bool
|
|
) {
|
|
cancelReconnectRetryExpiry(messageID: messageID)
|
|
guard let record =
|
|
reconnectRetryRecords.removeValue(forKey: messageID) else {
|
|
return
|
|
}
|
|
guard cancelActiveTransfer,
|
|
let transferID = record.activeTransferID,
|
|
messageIDToTransferId[messageID] == transferID else {
|
|
return
|
|
}
|
|
// Release ownership before cancellation so its late callback cannot
|
|
// remove a remotely confirmed row.
|
|
clearTransferMapping(for: messageID)
|
|
context.cancelTransfer(transferID)
|
|
}
|
|
|
|
func pruneExpiredReconnectRetries() {
|
|
let current = now()
|
|
let lifetime = max(
|
|
0,
|
|
reconnectRetryLimits.retentionSeconds
|
|
)
|
|
let expiredMessageIDs: [String] =
|
|
reconnectRetryRecords.values.compactMap { record in
|
|
guard record.activeTransferID == nil,
|
|
current.timeIntervalSince(record.createdAt)
|
|
>= lifetime else {
|
|
return nil
|
|
}
|
|
return record.messageID
|
|
}
|
|
for messageID in expiredMessageIDs {
|
|
guard let record = reconnectRetryRecords[messageID],
|
|
record.activeTransferID == nil else {
|
|
continue
|
|
}
|
|
terminalizeReconnectRetry(
|
|
messageID: messageID,
|
|
reason: expiryFailureReason(for: record)
|
|
)
|
|
}
|
|
}
|
|
|
|
func makeReconnectRetryCapacity(for incomingBytes: Int) {
|
|
while reconnectRetryRecords.count
|
|
>= reconnectRetryLimits.maxRetainedPackets
|
|
|| retainedReconnectRetryBytes + incomingBytes
|
|
> reconnectRetryLimits.maxRetainedBytes {
|
|
guard let victim = reconnectRetryRecords.values
|
|
.filter({ $0.activeTransferID == nil })
|
|
.min(by: {
|
|
if $0.createdAt == $1.createdAt {
|
|
return $0.messageID < $1.messageID
|
|
}
|
|
return $0.createdAt < $1.createdAt
|
|
}) else {
|
|
return
|
|
}
|
|
terminalizeReconnectRetry(
|
|
messageID: victim.messageID,
|
|
reason: expiryFailureReason(for: victim)
|
|
)
|
|
}
|
|
}
|
|
|
|
var privateMediaNotDeliveredReason: String {
|
|
String(
|
|
localized: "content.delivery.reason.not_delivered",
|
|
defaultValue: "Not delivered",
|
|
comment: "Failure reason shown when a private media transfer could not finish"
|
|
)
|
|
}
|
|
|
|
var privateMediaCapabilityUnresolvedReason: String {
|
|
String(
|
|
localized:
|
|
"content.delivery.reason.private_media_capability_unresolved",
|
|
defaultValue: "Could not confirm encrypted media support",
|
|
comment: "Failure reason when private-media capability negotiation did not resolve"
|
|
)
|
|
}
|
|
|
|
var privateMediaDeliveryUnconfirmedReason: String {
|
|
String(
|
|
localized:
|
|
"content.delivery.reason.private_media_delivery_unconfirmed",
|
|
defaultValue: "Delivery could not be confirmed",
|
|
comment: "Failure reason when private media left this device but no delivery receipt arrived"
|
|
)
|
|
}
|
|
|
|
func expiryFailureReason(
|
|
for record: PrivateMediaReconnectRetryRecord
|
|
) -> String {
|
|
switch record.idleOutcome {
|
|
case .locallyCompleted:
|
|
return privateMediaDeliveryUnconfirmedReason
|
|
case .rejected(let reason):
|
|
return reason
|
|
case .none, .cancelled:
|
|
return privateMediaNotDeliveredReason
|
|
}
|
|
}
|
|
|
|
func terminalizeReconnectRetry(
|
|
messageID: String,
|
|
reason: String
|
|
) {
|
|
guard let record = reconnectRetryRecords[messageID],
|
|
record.activeTransferID == nil else {
|
|
return
|
|
}
|
|
cancelReconnectRetryExpiry(messageID: messageID)
|
|
guard reconnectRetryRecords.removeValue(forKey: messageID) != nil else {
|
|
return
|
|
}
|
|
context.updateMessageDeliveryStatus(
|
|
messageID,
|
|
status: .failed(reason: reason)
|
|
)
|
|
}
|
|
|
|
func scheduleReconnectRetryExpiry(messageID: String) {
|
|
guard var record = reconnectRetryRecords[messageID],
|
|
record.activeTransferID == nil else {
|
|
return
|
|
}
|
|
cancelReconnectRetryExpiry(messageID: messageID)
|
|
|
|
let lifetime = max(
|
|
0,
|
|
reconnectRetryLimits.retentionSeconds
|
|
)
|
|
let elapsed = max(
|
|
0,
|
|
now().timeIntervalSince(record.createdAt)
|
|
)
|
|
let delay = max(0, lifetime - elapsed)
|
|
let token = UUID()
|
|
record.expiryToken = token
|
|
reconnectRetryRecords[messageID] = record
|
|
|
|
let nanoseconds = UInt64(
|
|
min(
|
|
delay,
|
|
TimeInterval(UInt64.max) / 1_000_000_000
|
|
) * 1_000_000_000
|
|
)
|
|
reconnectRetryExpiryTasks[messageID] = Task {
|
|
@MainActor [weak self] in
|
|
if nanoseconds > 0 {
|
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
|
}
|
|
guard !Task.isCancelled,
|
|
let self,
|
|
let current =
|
|
self.reconnectRetryRecords[messageID],
|
|
current.activeTransferID == nil,
|
|
current.expiryToken == token else {
|
|
return
|
|
}
|
|
self.terminalizeReconnectRetry(
|
|
messageID: messageID,
|
|
reason: self.expiryFailureReason(for: current)
|
|
)
|
|
}
|
|
}
|
|
|
|
func cancelReconnectRetryExpiry(messageID: String) {
|
|
reconnectRetryExpiryTasks.removeValue(
|
|
forKey: messageID
|
|
)?.cancel()
|
|
if reconnectRetryRecords[messageID]?.expiryToken != nil {
|
|
reconnectRetryRecords[messageID]?.expiryToken = nil
|
|
}
|
|
}
|
|
|
|
func applicationFilesDirectory() throws -> URL {
|
|
let base = try FileManager.default.url(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask,
|
|
appropriateFor: nil,
|
|
create: true
|
|
)
|
|
let filesDirectory = base.appendingPathComponent("files", isDirectory: true)
|
|
try FileManager.default.createDirectory(
|
|
at: filesDirectory,
|
|
withIntermediateDirectories: true,
|
|
attributes: nil
|
|
)
|
|
return filesDirectory
|
|
}
|
|
}
|