42e1c2e34b
Swift 侧(协议与 Android UploadPlugin.kt 逐字一致,编译/真机留 G3): - UploadBridge.swift:通道/方法/事件/失败码常量 + registerTask 参数解析 (镜像 UploadProtocol.kt) - UploadEventBus.swift:单调 seq + 终态 ack 缓存重放(镜像 UploadEventBus.kt),iOS 增强——未 ack 终态落盘(系统后台拉起投递的 回调在进程重启后同样重放,技术方案 §6.2 持久化原生回调) - UploadTaskStore.swift:taskId→URLSession taskIdentifier 映射 plist 持久化 (Application Support;对应 Android WorkManager 自持映射) - UploadPlugin.swift:URLSession background (com.photolive.photo_live.uploads)+ delegate 失败分类(2xx→completed+etag / 403→needsRenew / 4xx→HTTP_4XX / 网络→重试上限后 NETWORK,镜像 UploadPutExecutor/UploadWorker)、原生重试(5 次上限、10s 指数退避)、 进度 500ms 节流、queryTask/cancelTask/ackEvents、静默取消 - AppDelegate.swift:implicit engine 通道注册 + 启动重建会话 + handleEventsForBackgroundURLSession completionHandler 暂存 - Info.plist:UIBackgroundModes 有意不声明(background URLSession 无需; 计划措辞按技术方案 §6.2 修正,见 README-ios.md) - project.pbxproj 登记新 Swift 文件;README-ios.md(Xcode 步骤 + G3 验证 清单:实际文件上传/持久化回调窗口/服务端补偿/重启重联校准/强制退出/ 低电量延迟/100 张批量) Dart:NativeTransport 选择矩阵并入 iOS(Platform.isAndroid || isIOS, FORCE_DART_TRANSPORT 覆盖不变)——协议两侧一致,无新通道代码。 flutter analyze 0 issue;flutter test 219 全绿。 Co-Authored-By: Claude Code <noreply@anthropic.com>
131 lines
4.7 KiB
Swift
131 lines
4.7 KiB
Swift
import Foundation
|
||
|
||
/// 上传任务映射持久化(P3 计划 Task 8)。
|
||
///
|
||
/// `taskId → {注册参数, URLSession taskIdentifier, 执行计数}` 存 Application
|
||
/// Support 下 plist;对应 Android 侧 taskId→workId 映射由 WorkManager 自持
|
||
/// 的职责——iOS 无系统级自持映射,改为本插件显式落盘:
|
||
///
|
||
/// - App 被系统在后台拉起(或用户重启)后,`getAllTasks` 返回的系统任务经
|
||
/// taskIdentifier 反查 taskId,重挂事件归属(技术方案 §6.2「重新启动时
|
||
/// 重连系统会话并校准本地任务」);
|
||
/// - 仅存映射所需最小字段;进度快照不落盘(重联后 didSendBodyData 会
|
||
/// 继续产生新进度)。
|
||
///
|
||
/// 线程模型:通道回调在主线程、session delegate 在后台队列访问——全部
|
||
/// 经 [lock] 串行化;写盘频率低(注册/终态/取消),同步写原子 plist。
|
||
final class UploadTaskStore {
|
||
|
||
/// 单条任务映射(plist 可序列化字段)。
|
||
struct Entry {
|
||
var url: String
|
||
var method: String
|
||
var headers: [String: String]
|
||
var filePath: String
|
||
var uploadId: String?
|
||
var attempt: Int
|
||
/// 已执行传输次数(含首发;镜像 Kotlin MAX_WORK_ATTEMPTS 语义)。
|
||
var executions: Int
|
||
/// 对应 URLSessionTask.taskIdentifier(-1 = 尚未创建系统任务)。
|
||
var sessionTaskIdentifier: Int
|
||
}
|
||
|
||
private let lock = NSLock()
|
||
private let fileURL: URL
|
||
private var entries: [String: Entry] = [:]
|
||
|
||
init(directory: URL? = nil, fileName: String = "upload-task-map.plist") {
|
||
let dir =
|
||
directory
|
||
?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||
.appendingPathComponent("PhotoLiveUploads", isDirectory: true)
|
||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||
fileURL = dir.appendingPathComponent(fileName)
|
||
if let raw = NSDictionary(contentsOf: fileURL) as? [String: [String: Any]] {
|
||
entries = raw.mapValues { Entry(plist: $0) }
|
||
}
|
||
}
|
||
|
||
// ── 读写 API(锁内) ──────────────────────────────────────────────
|
||
|
||
func entry(taskId: String) -> Entry? {
|
||
lock.lock()
|
||
defer { lock.unlock() }
|
||
return entries[taskId]
|
||
}
|
||
|
||
func taskId(sessionTaskIdentifier: Int) -> String? {
|
||
lock.lock()
|
||
defer { lock.unlock() }
|
||
return entries.first { $0.value.sessionTaskIdentifier == sessionTaskIdentifier }?.key
|
||
}
|
||
|
||
func allTaskIds() -> [String] {
|
||
lock.lock()
|
||
defer { lock.unlock() }
|
||
return Array(entries.keys)
|
||
}
|
||
|
||
func upsert(taskId: String, entry: Entry) {
|
||
lock.lock()
|
||
entries[taskId] = entry
|
||
persistLocked()
|
||
lock.unlock()
|
||
}
|
||
|
||
/// 原地修改并落盘(不存在则忽略)。
|
||
func mutate(taskId: String, _ transform: (inout Entry) -> Void) {
|
||
lock.lock()
|
||
guard var entry = entries[taskId] else {
|
||
lock.unlock()
|
||
return
|
||
}
|
||
transform(&entry)
|
||
entries[taskId] = entry
|
||
persistLocked()
|
||
lock.unlock()
|
||
}
|
||
|
||
func remove(taskId: String) {
|
||
lock.lock()
|
||
entries.removeValue(forKey: taskId)
|
||
persistLocked()
|
||
lock.unlock()
|
||
}
|
||
|
||
// ── plist 编解码 ─────────────────────────────────────────────────
|
||
|
||
private func persistLocked() {
|
||
let raw = entries.mapValues { $0.asPlist() } as NSDictionary
|
||
raw.write(to: fileURL, atomically: true)
|
||
}
|
||
}
|
||
|
||
private extension UploadTaskStore.Entry {
|
||
init(plist raw: [String: Any]) {
|
||
self.init(
|
||
url: raw["url"] as? String ?? "",
|
||
method: raw["method"] as? String ?? "PUT",
|
||
headers: (raw["headers"] as? [String: String]) ?? [:],
|
||
filePath: raw["filePath"] as? String ?? "",
|
||
uploadId: raw["uploadId"] as? String,
|
||
attempt: (raw["attempt"] as? NSNumber)?.intValue ?? 0,
|
||
executions: (raw["executions"] as? NSNumber)?.intValue ?? 1,
|
||
sessionTaskIdentifier: (raw["sessionTaskIdentifier"] as? NSNumber)?.intValue ?? -1
|
||
)
|
||
}
|
||
|
||
func asPlist() -> [String: Any] {
|
||
[
|
||
"url": url,
|
||
"method": method,
|
||
"headers": headers,
|
||
"filePath": filePath,
|
||
"uploadId": uploadId ?? "",
|
||
"attempt": attempt,
|
||
"executions": executions,
|
||
"sessionTaskIdentifier": sessionTaskIdentifier,
|
||
]
|
||
}
|
||
}
|