import Foundation private struct DirectMediaRequest: Codable { let role: String let url: String let method: String let headers: [String: String] let bodyPath: String } private struct DirectMediaDescriptor: Codable { let attachmentId: String let expiresAt: Int64 let media: DirectMediaRequest let manifest: DirectMediaRequest let complete: DirectMediaRequest } /// Thin transport-only adapter. Rust supplies immutable request files and all /// retry/expiry metadata; Swift only gives those files to background URLSession. final class DirectMediaTransfer: NSObject, URLSessionTaskDelegate, URLSessionDelegate { static let shared = DirectMediaTransfer() static let sessionIdentifier = "eu.twonly.direct-media-transfer" private let defaults = UserDefaults.standard private let descriptorPrefix = "direct-media-descriptor-" private var backgroundCompletion: (() -> Void)? private var sessionStorage: URLSession? private lazy var decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return decoder }() private var session: URLSession { if let sessionStorage { return sessionStorage } let configuration = URLSessionConfiguration.background( withIdentifier: Self.sessionIdentifier ) configuration.sessionSendsLaunchEvents = true configuration.isDiscretionary = false configuration.waitsForConnectivity = true configuration.allowsCellularAccess = true let created = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) sessionStorage = created return created } func schedule(json: String) -> Bool { guard let data = json.data(using: .utf8), let descriptor = try? decoder.decode(DirectMediaDescriptor.self, from: data), descriptor.expiresAt > Int64(Date().timeIntervalSince1970), FileManager.default.fileExists(atPath: descriptor.media.bodyPath), FileManager.default.fileExists(atPath: descriptor.manifest.bodyPath) else { return false } defaults.set(data, forKey: descriptorPrefix + descriptor.attachmentId) // Both durable transfers are created before either is resumed, so process // death cannot leave a media object with no corresponding manifest task. guard let mediaTask = makeTask(descriptor.media, attachmentId: descriptor.attachmentId), let manifestTask = makeTask(descriptor.manifest, attachmentId: descriptor.attachmentId) else { return false } mediaTask.resume() manifestTask.resume() return true } func handleEvents(identifier: String, completion: @escaping () -> Void) -> Bool { guard identifier == Self.sessionIdentifier else { return false } backgroundCompletion = completion _ = session return true } private func makeTask(_ request: DirectMediaRequest, attachmentId: String) -> URLSessionUploadTask? { guard let url = URL(string: request.url) else { return nil } let fileURL = URL(fileURLWithPath: request.bodyPath) guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil } var urlRequest = URLRequest(url: url) urlRequest.httpMethod = request.method for (name, value) in request.headers { urlRequest.setValue(value, forHTTPHeaderField: name) } let task = session.uploadTask(with: urlRequest, fromFile: fileURL) task.taskDescription = "\(attachmentId)|\(request.role)" return task } private func descriptor(attachmentId: String) -> DirectMediaDescriptor? { guard let data = defaults.data(forKey: descriptorPrefix + attachmentId) else { return nil } return try? decoder.decode(DirectMediaDescriptor.self, from: data) } private func request(_ role: String, from descriptor: DirectMediaDescriptor) -> DirectMediaRequest? { switch role { case "media": return descriptor.media case "manifest": return descriptor.manifest case "complete": return descriptor.complete default: return nil } } func urlSession( _ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error? ) { guard let description = task.taskDescription, description.split(separator: "|", maxSplits: 1).count == 2 else { return } let parts = description.split(separator: "|", maxSplits: 1) let attachmentId = String(parts[0]) let role = String(parts[1]) guard let descriptor = descriptor(attachmentId: attachmentId) else { return } let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0 let expired = descriptor.expiresAt <= Int64(Date().timeIntervalSince1970) let success = error == nil && (200...299).contains(status) if success && role == "media" { makeTask(descriptor.complete, attachmentId: attachmentId)?.resume() return } if success { return } // Authentication/policy failures are permanent. Connectivity failures, // throttling and server failures remain retryable for the slot lifetime. let retryable = error != nil || status == 0 || status == 408 || status == 429 || status >= 500 if !expired, retryable, let retry = request(role, from: descriptor) { let retryTask = makeTask(retry, attachmentId: attachmentId) retryTask?.earliestBeginDate = Date().addingTimeInterval(15) retryTask?.resume() } } func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { DispatchQueue.main.async { [weak self] in let completion = self?.backgroundCompletion self?.backgroundCompletion = nil completion?() } } } /// C ABI called directly by Rust. Flutter and Dart never participate in /// scheduling or executing these background uploads. @_cdecl("twonly_schedule_direct_media_uploads") func twonlyScheduleDirectMediaUploads(_ descriptor: UnsafePointer?) -> Bool { guard let descriptor else { return false } return DirectMediaTransfer.shared.schedule(json: String(cString: descriptor)) }