twonly-app/ios/Runner/DirectMediaTransfer.swift
2026-08-31 15:24:18 +02:00

155 lines
5.8 KiB
Swift

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<CChar>?) -> Bool {
guard let descriptor else { return false }
return DirectMediaTransfer.shared.schedule(json: String(cString: descriptor))
}