mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-01 10:24:08 +00:00
155 lines
5.8 KiB
Swift
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))
|
|
}
|