From 42e1c2e34be509bd80b264840a42195d13c4e58b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Sat, 19 Sep 2026 03:42:49 +0800 Subject: [PATCH] feat(mobile): ios background upload bridge (compile-gated at G3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/mobile/ios/README-ios.md | 97 ++++ .../ios/Runner.xcodeproj/project.pbxproj | 16 + apps/mobile/ios/Runner/AppDelegate.swift | 22 + apps/mobile/ios/Runner/Info.plist | 6 + apps/mobile/ios/Runner/UploadBridge.swift | 107 ++++ apps/mobile/ios/Runner/UploadEventBus.swift | 173 ++++++ apps/mobile/ios/Runner/UploadPlugin.swift | 531 ++++++++++++++++++ apps/mobile/ios/Runner/UploadTaskStore.swift | 130 +++++ .../features/uploads/native_transport.dart | 54 +- .../uploads/native_transport_test.dart | 39 +- docs/plans/2026-09-18-P3-Flutter-App.md | 4 +- 11 files changed, 1146 insertions(+), 33 deletions(-) create mode 100644 apps/mobile/ios/README-ios.md create mode 100644 apps/mobile/ios/Runner/UploadBridge.swift create mode 100644 apps/mobile/ios/Runner/UploadEventBus.swift create mode 100644 apps/mobile/ios/Runner/UploadPlugin.swift create mode 100644 apps/mobile/ios/Runner/UploadTaskStore.swift diff --git a/apps/mobile/ios/README-ios.md b/apps/mobile/ios/README-ios.md new file mode 100644 index 0000000..fb801bc --- /dev/null +++ b/apps/mobile/ios/README-ios.md @@ -0,0 +1,97 @@ +# iOS 桥接说明(P3 Task 8——编译/真机验证留 G3) + +> 本目录的 Swift 桥接代码在 Windows 开发机上完成(无 macOS,未编译)。 +> 首次 macOS 构建前请通读本文档;所有「G3 验证」条目对应 +> `docs/03-技术实施方案.md` §6.2/§6.4。 + +## 代码清单 + +| 文件 | 职责 | Android 对应 | +|---|---|---| +| `Runner/UploadBridge.swift` | 协议常量 + registerTask 参数解析(纯函数) | `UploadProtocol.kt` | +| `Runner/UploadEventBus.swift` | 事件总线:单调 seq、终态 ack 缓存重放(**iOS 增强落盘**) | `UploadEventBus.kt` | +| `Runner/UploadTaskStore.swift` | taskId→系统任务映射持久化(Application Support plist) | WorkManager 自持映射 | +| `Runner/UploadPlugin.swift` | MethodChannel/EventChannel 注册 + URLSession background 会话 + delegate 失败分类 + 原生重试 | `UploadPlugin.kt` + `UploadWorker.kt` + `UploadPutExecutor.kt` | +| `Runner/AppDelegate.swift` | 通道注册(implicit engine)+ 启动重建会话 + 后台拉起 completionHandler 暂存 | `MainActivity.kt` | + +协议(通道名 `photolive/uploads`、事件流 `photolive/uploads/events`、 +方法/事件名、失败码)与 Android 侧**逐字一致**;Dart 侧同一 +`NativeTransport`(`lib/features/uploads/native_transport.dart`)驱动两平台, +无协议分歧登记项。 + +## Background Modes(计划偏差修正) + +**有意不声明 `UIBackgroundModes`**(Info.plist 内有注释标记):上传采用 +background URLSession(identifier `com.photolive.photo_live.uploads`), +传输与完成回调由系统调度,`fetch`/`processing` 等模式均非必需;计划原文 +的「backgroundModes(fetch/processing 按文档)」按技术方案 §6.2 与 Apple +文档修正(多声明反而引入 App Store 审核 scrutiny)。无需 Push、无需 +任何 Capability 开关。 + +## Xcode 步骤(G3 首次构建) + +1. macOS 上 `source scripts/flutter-env.sh` 的等价环境(Flutter 3.44+), + `flutter precache --ios` 后 `cd apps/mobile && flutter pub get`。 +2. 打开 `apps/mobile/ios/Runner.xcworkspace`。 +3. Runner target → Signing & Capabilities:选择开发团队(个人免费证书 + 即可真机调试;无需勾选任何额外 Capability)。 +4. `flutter run -d ` 或 Xcode 直接 Run;新 Swift 文件已登记于 + `project.pbxproj`(4 个 `Upload*.swift`),无需手动拖入。 +5. 首次编译预期**零改动通过**;若 Swift 语法/API 有出入(未编译验证的 + 风险),修复后把差异登记进验收记录偏差表。 + +### 开发联调网络 + +Info.plist 已含 `NSAppTransportSecurity.NSAllowsLocalNetworking`:模拟器/ +真机可访问宿主回环(`http://<宿主IP>:8080` 等)的本地 cn-api + MinIO; +生产仍要求 HTTPS。 + +## G3 验证清单(技术方案 §6.2 要点) + +按序执行并记录(设备/系统版本/文件大小/成功数/恢复行为/重复记录数): + +- [ ] **实际文件上传**:真实照片文件(非桩)经完整链路(presign → + URLSession PUT → complete)进入服务端;App 退后台/锁屏传输继续。 +- [ ] **持久化原生回调**:上传完成时 App 在后台被系统拉起——确认终态 + 事件已落盘(`upload-unacked-events.plist`)并在 App 回前台 + EventChannel 重听后重放、Dart ack 后清除;**在系统允许的执行窗口 + 内完成确认**(complete 调用),超窗场景由服务端对象通知/补偿扫描 + 推动发布(§5.6),App 侧不得标失败重传覆盖。 +- [ ] **重启重联校准**:上传中杀进程 → 重新启动 → 会话重联 + (`getAllTasks` + taskIdentifier 反查 taskId)、队列状态校准、 + 未完成任务续传(映射文件 `upload-task-map.plist`)。 +- [ ] **强制退出**:用户强制退出后系统**不会**自动重启 App 承接会话 + 事件——需下次启动恢复(§6.2 明文语义,UI 不承诺后台完成)。 +- [ ] **低电量/调度延迟**:低电量模式 + 断网恢复场景,任务挂起而非 + 失败;UI 显示等待状态(`waiting_network`),不承诺固定完成时间。 +- [ ] **凭证过期**:presign 403 → `needsRenew` → Dart renew 换 URL 重注册 + (含长时间后台后 renew 的可达性——§5.2 技术验证重点)。 +- [ ] **原生重试上限**:断网/5xx 下原生侧重试 5 次(指数退避 10s 起) + 后 `failed{NETWORK|SERVER_ERROR}`;退避定时器在 App 挂起期间暂停、 + 唤醒补跑(G3 确认行为符合预期)。 +- [ ] **进度节流**:进度事件 500ms 节流 + 终值必发(与 Android 每 64KB + 发事件的差异——Dart 侧 `onProgress` 语义不受影响)。 +- [ ] **批量样本**:批量 100 张真实照片(§6.4 起始样本),覆盖前台切 + 后台、锁屏 ≥15min、网络中断恢复、强制退出重开、源文件缺失 + (`failed{SOURCE_MISSING}`)、存储不足;记录重复记录数(幂等 + clientTaskId 下应为 0)。 +- [ ] **HEIC 真机样本**(§5.4,与 G2 清单合并执行)。 +- [ ] 补充单测(可选):`UploadBridge.parseRegisterSpec` / + `UploadEventBus` 重放语义的 XCTest(Android 侧有 JVM 对应单测)。 + +## 与 Android 实现的已知差异(协议外,行为层) + +1. **终态缓存落盘**:iOS 侧未 ack 终态事件持久化(系统可能后台拉起 + App 投递回调而 Dart 引擎未运行);Android 侧内存缓存即可(WorkManager + 自持任务,进程重启后 worker 重跑再发)。 +2. **重试宿主**:iOS 由插件层重建 upload task(10s 指数退避,上限 5 次 + 含首发);Android 为 WorkManager `Result.retry` 同参数。App 挂起时 + iOS 退避定时器暂停、唤醒补跑。 +3. **进度节流**:iOS 在事件层 500ms 节流(终值必发);Android 每写 + 64KB 发事件、仅通知刷新节流 500ms。 +4. **取消语义**:两侧均为「Dart 先行置 cancelled、原生静默停任务、无 + 终态事件」;iOS 额外经 `getAllTasks` 兜底取消未重挂任务。 +5. **queryTask 状态枚举**:iOS 以 URLSessionTask.state 映射 + RUNNING/ENQUEUED/CANCELLED(终态后条目清理,返回 null = 未知); + Android 为 WorkInfo 原名(SUCCEEDED/FAILED 可查)。Dart 侧仅作校准 + 参考,不依赖具体枚举。 diff --git a/apps/mobile/ios/Runner.xcodeproj/project.pbxproj b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj index 5b04849..f5bc4e5 100644 --- a/apps/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -12,6 +12,10 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 7884E8812EC3CC0A00C636F3 /* UploadBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8802EC3CC0A00C636F3 /* UploadBridge.swift */; }; + 7884E8832EC3CC0B00C636F3 /* UploadEventBus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8822EC3CC0B00C636F3 /* UploadEventBus.swift */; }; + 7884E8852EC3CC0C00C636F3 /* UploadTaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8842EC3CC0C00C636F3 /* UploadTaskStore.swift */; }; + 7884E8872EC3CC0D00C636F3 /* UploadPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8862EC3CC0D00C636F3 /* UploadPlugin.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -50,6 +54,10 @@ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7884E8802EC3CC0A00C636F3 /* UploadBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadBridge.swift; sourceTree = ""; }; + 7884E8822EC3CC0B00C636F3 /* UploadEventBus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadEventBus.swift; sourceTree = ""; }; + 7884E8842EC3CC0C00C636F3 /* UploadTaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadTaskStore.swift; sourceTree = ""; }; + 7884E8862EC3CC0D00C636F3 /* UploadPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadPlugin.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -123,6 +131,10 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 7884E8802EC3CC0A00C636F3 /* UploadBridge.swift */, + 7884E8822EC3CC0B00C636F3 /* UploadEventBus.swift */, + 7884E8842EC3CC0C00C636F3 /* UploadTaskStore.swift */, + 7884E8862EC3CC0D00C636F3 /* UploadPlugin.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -284,6 +296,10 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + 7884E8812EC3CC0A00C636F3 /* UploadBridge.swift in Sources */, + 7884E8832EC3CC0B00C636F3 /* UploadEventBus.swift in Sources */, + 7884E8852EC3CC0C00C636F3 /* UploadTaskStore.swift in Sources */, + 7884E8872EC3CC0D00C636F3 /* UploadPlugin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/mobile/ios/Runner/AppDelegate.swift b/apps/mobile/ios/Runner/AppDelegate.swift index c30b367..7b3c1a8 100644 --- a/apps/mobile/ios/Runner/AppDelegate.swift +++ b/apps/mobile/ios/Runner/AppDelegate.swift @@ -7,10 +7,32 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + // 后台会话启动即重建:App 被系统在后台拉起(Dart 引擎未起)时, + // delegate 回调即可入总线缓存(终态落盘,引擎 onListen 后重放)。 + UploadPlugin.shared.ensureSession() return super.application(application, didFinishLaunchingWithOptions: launchOptions) } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + // 上传桥接(P3 Task 8):MethodChannel 命令 + EventChannel 事件流 + // (通道/协议与 Android UploadPlugin 一致,Dart 侧同一 NativeTransport)。 + UploadPlugin.register(with: engineBridge.applicationRegistrar.messenger()) + } + + /// 系统完成后台会话事件投递前的拉起回调:暂存 completionHandler, + /// 供 urlSessionDidFinishEvents 消费(否则 App 被系统挂起惩罚)。 + override func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + super.application( + application, handleEventsForBackgroundURLSession: identifier, + completionHandler: completionHandler) + if identifier == UploadBridge.sessionIdentifier { + UploadPlugin.shared.ensureSession() + UploadPlugin.shared.storeBackgroundCompletionHandler(completionHandler) + } } } diff --git a/apps/mobile/ios/Runner/Info.plist b/apps/mobile/ios/Runner/Info.plist index 51b8f1d..914483a 100644 --- a/apps/mobile/ios/Runner/Info.plist +++ b/apps/mobile/ios/Runner/Info.plist @@ -26,6 +26,12 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSAppTransportSecurity diff --git a/apps/mobile/ios/Runner/UploadBridge.swift b/apps/mobile/ios/Runner/UploadBridge.swift new file mode 100644 index 0000000..0db9776 --- /dev/null +++ b/apps/mobile/ios/Runner/UploadBridge.swift @@ -0,0 +1,107 @@ +import Foundation + +/// 上传桥接协议常量与 registerTask 参数解析(P3 计划 Task 8)。 +/// +/// 与 Android 侧 `UploadProtocol.kt` 逐字对齐:通道名、方法名、事件名、 +/// 失败码完全一致;Dart 侧 `lib/features/uploads/native_transport.dart` +/// 的 [UploadBridgeChannels] 为同一协议的单源镜像。 +enum UploadBridge { + /// 命令通道(registerTask / cancelTask / queryTask / ackEvents)。 + static let methodChannel = "photolive/uploads" + + /// 事件流(progress / completed / needsRenew / failed,带单调 seq)。 + static let eventChannel = "photolive/uploads/events" + + static let methodRegister = "registerTask" + static let methodCancel = "cancelTask" + static let methodQuery = "queryTask" + static let methodAck = "ackEvents" + + static let eventProgress = "progress" + static let eventCompleted = "completed" + static let eventNeedsRenew = "needsRenew" + static let eventFailed = "failed" + + /// 网络不可达/超时(重试上限耗尽后上报)。 + static let failNetwork = "NETWORK" + + /// 4xx 客户端错误(status 随事件携带;403 单独走 needsRenew)。 + static let failHttp4xx = "HTTP_4XX" + + /// 5xx 服务端错误(重试上限耗尽后上报)。 + static let failServerError = "SERVER_ERROR" + + /// 源文件缺失(受管副本被清理,不可恢复)。 + static let failSourceMissing = "SOURCE_MISSING" + + /// 后台 URLSession 标识(App 重启后系统按此重联会话)。 + static let sessionIdentifier = "com.photolive.photo_live.uploads" +} + +/// registerTask 参数(Dart → 原生),字段与 Kotlin `UploadRegisterSpec` 一致。 +struct UploadRegisterSpec { + let taskId: String + let url: String + let method: String + let headers: [String: String] + let filePath: String + let uploadId: String? + let attempt: Int +} + +/// 参数形状不符(插件层转 bad_args 错误,镜像 Kotlin IllegalArgumentException)。 +struct UploadBridgeBadArgsError: Error { + let message: String +} + +/// registerTask 参数解析(纯函数;与 Kotlin `parseRegisterSpec` 同语义)。 +/// +/// 通道解码所得字典为 [String: Any](Flutter 标准编解码 null → NSNull), +/// 显式 null 与类型不符同样归为 bad_args。 +func parseRegisterSpec(_ args: [String: Any]) throws -> UploadRegisterSpec { + func str(_ key: String) throws -> String { + guard let v = args[key] as? String else { + throw UploadBridgeBadArgsError( + message: args[key] == nil ? "missing '\(key)'" : "'\(key)' must be a String" + ) + } + return v + } + let taskId = try str("taskId") + let url = try str("url") + let methodRaw = try str("method") + let method = methodRaw.isEmpty ? "PUT" : methodRaw + let filePath = try str("filePath") + var headers: [String: String] = [:] + if let raw = args["headers"] as? [String: Any] { + for (k, v) in raw { + guard let value = v as? String else { + throw UploadBridgeBadArgsError(message: "'headers' must be Map") + } + headers[k] = value + } + } else if args["headers"] != nil { + throw UploadBridgeBadArgsError(message: "'headers' must be a Map") + } + var uploadId: String? + var attempt = 0 + if let metadata = args["metadata"] as? [String: Any] { + uploadId = metadata["uploadId"] as? String + if let v = metadata["attempt"] as? NSNumber { + attempt = v.intValue + } else if let v = metadata["attempt"] as? Int { + attempt = v + } else if metadata["attempt"] != nil { + throw UploadBridgeBadArgsError(message: "'metadata.attempt' must be an int") + } + } + return UploadRegisterSpec( + taskId: taskId, + url: url, + method: method, + headers: headers, + filePath: filePath, + uploadId: uploadId, + attempt: attempt + ) +} diff --git a/apps/mobile/ios/Runner/UploadEventBus.swift b/apps/mobile/ios/Runner/UploadEventBus.swift new file mode 100644 index 0000000..fa521c2 --- /dev/null +++ b/apps/mobile/ios/Runner/UploadEventBus.swift @@ -0,0 +1,173 @@ +import Foundation + +/// 上传事件总线(session delegate → EventChannel,P3 计划 Task 8)。 +/// +/// 与 Android 侧 `UploadEventBus.kt` 同语义: +/// - 事件带单调 seq;**终态**(completed / needsRenew / failed)在 Dart +/// ack 前缓存(上限 [maxBuffered],超出丢最老),EventChannel 重新 +/// listen 时重放——App 切后台恢复 / 引擎重建后不丢完成信号; +/// - 进度事件瞬态,不缓存不重放; +/// - 任务重新注册(renew 换 URL)或取消时 [purgeTask] 作废其缓存终态。 +/// +/// **进程死亡语义(iOS 增强并落盘)**:iOS 后台会话完成后系统可能在后台 +/// 拉起 App(Dart 引擎未启动、无人监听事件)投递回调——终态事件除内存 +/// 缓存外同步持久化到 Application Support plist(seq 计数器随行),下次 +/// 进程内 EventChannel onListen 时一并重放(技术方案 §6.2「传输完成后 +/// 持久记录原生回调」)。Android 侧等价场景由 WorkManager 自持任务、 +/// 进程重启后 worker 重跑再发事件,无需落盘。 +/// +/// 事件负载为 [String: Any] 字典:null 值(etag/status 等)以 NSNull 表达 +/// ——Flutter 标准编解码两侧均映射为 Dart null,与 Kotlin 显式 null 等价。 +/// +/// 线程模型:emit 可来自 session delegate 的后台队列;sink 由插件层包装 +/// 为主线程投递(EventChannel 契约)。投递在锁内进行——sink 实现为非 +/// 阻塞 DispatchQueue.main.async,锁内投递保证 seq 顺序。 +final class UploadEventBus { + + static let shared = UploadEventBus() + + /// 缓存终态上限(防无限增长;超量丢最老——极端下 Dart 用 queryTask 校准)。 + static let maxBuffered = 200 + + private let lock = NSLock() + private var sink: (([String: Any]) -> Void)? + private var seqGen = 0 + private var unacked: [[String: Any]] = [] + private let fileURL: URL + + init(directory: URL? = nil) { + 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("upload-unacked-events.plist") + loadPersisted() + } + + // ── 事件出口(镜像 Kotlin UploadEventBus) ─────────────────────── + + func emitProgress(taskId: String, sent: Int64, total: Int64) { + emit( + [ + "event": UploadBridge.eventProgress, + "taskId": taskId, + "sent": NSNumber(value: sent), + "total": NSNumber(value: total), + ], + terminal: false + ) + } + + func emitCompleted(taskId: String, etag: String?) { + emit( + [ + "event": UploadBridge.eventCompleted, + "taskId": taskId, + "etag": etag ?? NSNull(), + ], + terminal: true + ) + } + + func emitNeedsRenew(taskId: String) { + emit( + [ + "event": UploadBridge.eventNeedsRenew, + "taskId": taskId, + ], + terminal: true + ) + } + + func emitFailed(taskId: String, code: String, status: Int? = nil) { + emit( + [ + "event": UploadBridge.eventFailed, + "taskId": taskId, + "code": code, + "status": status.map { NSNumber(value: $0) } ?? NSNull(), + ], + terminal: true + ) + } + + private func emit(_ event: [String: Any], terminal: Bool) { + lock.lock() + seqGen += 1 + var stamped = event + stamped["seq"] = NSNumber(value: seqGen) + if terminal { + unacked.append(stamped) + while unacked.count > UploadEventBus.maxBuffered { + unacked.removeFirst() + } + persistLocked() + } + if let sink = sink { + sink(stamped) + } + lock.unlock() + } + + /// EventChannel onListen:接管 sink 并重放缓存的终态事件。 + func attach(_ sink: @escaping ([String: Any]) -> Void) { + lock.lock() + self.sink = sink + for event in unacked { + sink(event) + } + lock.unlock() + } + + /// EventChannel onCancel。 + func detach() { + lock.lock() + sink = nil + lock.unlock() + } + + /// Dart ack:丢弃 seq ≤ 给定值的缓存终态。 + func ack(seq: Int) { + lock.lock() + unacked.removeAll { (($0["seq"] as? NSNumber)?.intValue ?? 0) <= seq } + persistLocked() + lock.unlock() + } + + /// 任务重新注册/取消:其缓存终态作废。 + func purgeTask(taskId: String) { + lock.lock() + unacked.removeAll { $0["taskId"] as? String == taskId } + persistLocked() + lock.unlock() + } + + // ── 终态落盘 / 恢复(进程死亡重放语义) ────────────────────────── + + /// plist 不支持 null 值——持久化前剔除 NSNull(Dart 侧以缺失等同 null)。 + private func persistLocked() { + let storable = unacked.map { event -> [String: Any] in + var out: [String: Any] = [:] + for (key, value) in event { + if let number = value as? NSNumber { + out[key] = number + } else if let string = value as? String { + out[key] = string + } + // NSNull(null 值)跳过。 + } + return out + } + let raw: [String: Any] = ["seq": NSNumber(value: seqGen), "unacked": storable] + (raw as NSDictionary).write(to: fileURL, atomically: true) + } + + private func loadPersisted() { + guard let raw = NSDictionary(contentsOf: fileURL) as? [String: Any] else { + return + } + seqGen = (raw["seq"] as? NSNumber)?.intValue ?? 0 + unacked = raw["unacked"] as? [[String: Any]] ?? [] + } +} diff --git a/apps/mobile/ios/Runner/UploadPlugin.swift b/apps/mobile/ios/Runner/UploadPlugin.swift new file mode 100644 index 0000000..50557b6 --- /dev/null +++ b/apps/mobile/ios/Runner/UploadPlugin.swift @@ -0,0 +1,531 @@ +import Flutter +import Foundation + +/// 上传桥接插件(P3 计划 Task 8;协议与 Android `UploadPlugin.kt` 逐字对齐)。 +/// +/// - MethodChannel `photolive/uploads`:registerTask(入队后台 URLSession +/// upload task)/ cancelTask / queryTask / ackEvents; +/// - EventChannel `photolive/uploads/events`:[UploadEventBus] 终态/进度 +/// 事件(终态未 ack 缓存重放 + **跨进程落盘**,见总线文档); +/// - 实现 = URLSession background(identifier `com.photolive.photo_live.uploads`): +/// 传输由系统调度,App 退后台/锁屏后继续;完成后系统在后台拉起 App 投递 +/// 回调(`handleEventsForBackgroundURLSession` → 重建会话重挂 delegate)。 +/// +/// **进程死亡语义**:iOS 对后台会话事件会在需要时重新拉起 App(区别于 +/// Android WorkManager 自持重跑)——本插件启动即重建会话(`ensureSession`), +/// delegate 回调照常入总线;终态已落盘,Dart 引擎下次 onListen 时重放, +/// 镜像 Android 的缓存重放语义。用户强制退出后系统不再代跑,重新启动时 +/// 由重联 + 重放校准(技术方案 §6.2)。 +/// +/// 失败分类(didCompleteWithError,镜像 Kotlin `UploadPutExecutor`): +/// 2xx → completed(带 ETag);403 → needsRenew;其余 4xx → failed +/// HTTP_4XX;5xx / NSURLErrorDomain 网络错误 → 原生侧重试(上限 +/// [maxWorkAttempts],指数退避初始 10s——镜像 WorkManager 语义;App 挂起 +/// 时退避定时器暂停,唤醒后续跑),耗尽后 failed SERVER_ERROR / NETWORK; +/// 源文件缺失(注册时预检)→ failed SOURCE_MISSING。 +final class UploadPlugin: NSObject { + + static let shared = UploadPlugin() + + /// 传输尝试上限(含首发;对齐 Kotlin UploadWorker.MAX_WORK_ATTEMPTS)。 + static let maxWorkAttempts = 5 + + /// 重试退避初始间隔(秒;指数翻倍,对齐 Android WorkManager 10s 初始)。 + static let retryBackoffBaseSeconds: Double = 10 + + /// 进度事件节流(秒;Android 侧每 64KB 块发事件、通知 500ms 节流—— + /// iOS 统一在事件层 500ms 节流,终值(sent==total)必发)。 + static let progressThrottleSeconds: Double = 0.5 + + private let lock = NSLock() + private let bus = UploadEventBus.shared + let store = UploadTaskStore() + + private var session: URLSession? + private var sessionDelegate: UploadSessionDelegate? + + /// 运行期内存态(锁内):活动系统任务 / 进度快照 / 静默取消标记 / + /// 进度节流时间戳 / 系统后台回调。 + private var activeTasks: [Int: URLSessionTask] = [:] + private var lastProgress: [String: (sent: Int64, total: Int64)] = [:] + private var quietCancelIdentifiers: Set = [] + private var progressEmittedAt: [String: Double] = [:] + private var backgroundCompletionHandler: (() -> Void)? + + // ── 通道注册(AppDelegate 调用) ───────────────────────────────── + + static func register(with messenger: FlutterBinaryMessenger) { + FlutterMethodChannel( + name: UploadBridge.methodChannel, + binaryMessenger: messenger + ) + .setMethodCallHandler { call, result in + UploadPlugin.shared.handleMethodCall(call, result: result) + } + FlutterEventChannel( + name: UploadBridge.eventChannel, + binaryMessenger: messenger + ) + .setStreamHandler(UploadEventStreamHandler()) + } + + /// 后台会话重建(启动 / 系统后台拉起时调用;幂等)。 + func ensureSession() { + lock.lock() + guard session == nil else { + lock.unlock() + return + } + let config = URLSessionConfiguration.background( + withIdentifier: UploadBridge.sessionIdentifier + ) + config.isDiscretionary = false + config.sessionSendsLaunchEvents = true + // 无网时挂起等待而非立即失败(镜像 Android NetworkType.CONNECTED 约束)。 + config.waitsForConnectivity = true + let delegate = UploadSessionDelegate(plugin: self) + sessionDelegate = delegate + session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + lock.unlock() + } + + /// 系统后台拉起回调暂存(urlSessionDidFinishEvents 时消费)。 + func storeBackgroundCompletionHandler(_ handler: @escaping () -> Void) { + lock.lock() + backgroundCompletionHandler = handler + lock.unlock() + } + + func finishBackgroundEvents() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.lock.lock() + let handler = self.backgroundCompletionHandler + self.backgroundCompletionHandler = nil + self.lock.unlock() + handler?() + } + } + + // ── MethodChannel(镜像 Kotlin handleMethodCall) ──────────────── + + private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case UploadBridge.methodRegister: + handleRegister(call, result: result) + case UploadBridge.methodCancel: + handleCancel(call, result: result) + case UploadBridge.methodQuery: + handleQuery(call, result: result) + case UploadBridge.methodAck: + handleAck(call, result: result) + default: + result(FlutterMethodNotImplemented) + } + } + + private func handleRegister(_ call: FlutterMethodCall, result: FlutterResult) { + guard let raw = call.arguments as? [String: Any] else { + result(FlutterError(code: "bad_args", message: "arguments must be a Map", details: nil)) + return + } + do { + let spec = try parseRegisterSpec(raw) + // 重注册(renew 换 URL / 崩溃恢复重驱):作废旧缓存终态 + + // REPLACE 旧系统任务(防同任务双跑;镜像 Kotlin unique work REPLACE)。 + bus.purgeTask(taskId: spec.taskId) + try startTask(spec: spec) + result(true) + } catch let error as UploadBridgeBadArgsError { + result(FlutterError(code: "bad_args", message: error.message, details: nil)) + } catch { + result(FlutterError(code: "enqueue_failed", message: "\(error)", details: nil)) + } + } + + private func handleCancel(_ call: FlutterMethodCall, result: FlutterResult) { + guard let taskId = taskChannelArg(call, key: "taskId") as? String else { + result(FlutterError(code: "bad_args", message: "missing 'taskId'", details: nil)) + return + } + cancelQuietly(taskId: taskId) + bus.purgeTask(taskId: taskId) + result(true) + } + + private func handleQuery(_ call: FlutterMethodCall, result: FlutterResult) { + guard let taskId = taskChannelArg(call, key: "taskId") as? String else { + result(FlutterError(code: "bad_args", message: "missing 'taskId'", details: nil)) + return + } + guard let entry = store.entry(taskId: taskId) else { + // 无映射(未注册 / 已终态清理):镜像 Kotlin 无 WorkInfo 的 null。 + result(nil) + return + } + lock.lock() + let task = activeTasks[entry.sessionTaskIdentifier] + let progress = lastProgress[taskId] + lock.unlock() + let state: String + if let task = task { + switch task.state { + case .running: state = "RUNNING" + case .suspended: state = "ENQUEUED" + case .canceling: state = "CANCELLED" + case .completed: state = "SUCCEEDED" + @unknown default: state = "ENQUEUED" + } + } else { + // 未重挂(进程重启后):系统仍持有任务,等同等待系统调度。 + state = "ENQUEUED" + } + result([ + "state": state, + "attempt": entry.executions, + "progress": progress.map { ["sent": $0.sent, "total": $0.total] }, + ]) + } + + private func handleAck(_ call: FlutterMethodCall, result: FlutterResult) { + let seq: Int? + if let v = taskChannelArg(call, key: "seq") as? NSNumber { + seq = v.intValue + } else if let v = taskChannelArg(call, key: "seq") as? Int { + seq = v + } else { + seq = nil + } + guard let seq = seq else { + result(FlutterError(code: "bad_args", message: "missing or invalid 'seq'", details: nil)) + return + } + bus.ack(seq: seq) + result(true) + } + + /// cancelTask/queryTask/ackEvents 的字符串参数取值。 + private func taskChannelArg(_ call: FlutterMethodCall, key: String) -> Any? { + (call.arguments as? [String: Any])?[key] + } + + // ── 任务生命周期 ───────────────────────────────────────────────── + + /// 创建并启动后台 upload task(registerTask / 原生重试共用; + /// [executions] 为已执行计数——重试路径沿用,注册路径自 1 起)。 + private func startTask(spec: UploadRegisterSpec, executions: Int = 1) throws { + ensureSession() + guard let session = session else { + throw NSError( + domain: "com.photolive.photo_live.uploads", code: 1, + userInfo: [NSLocalizedDescriptionKey: "session unavailable"] + ) + } + // REPLACE:作废旧系统任务(静默取消,不产生终态事件)。 + cancelQuietly(taskId: spec.taskId) + guard let url = URL(string: spec.url) else { + throw UploadBridgeBadArgsError(message: "'url' invalid") + } + // 源文件缺失预检(镜像 Kotlin UploadPutExecutor 的 File.exists 分类; + // 交由 URLSession 才失败会被误归 NETWORK)。 + guard FileManager.default.fileExists(atPath: spec.filePath) else { + bus.emitFailed(taskId: spec.taskId, code: UploadBridge.failSourceMissing, status: nil) + store.remove(taskId: spec.taskId) + return + } + var request = URLRequest(url: url) + request.httpMethod = + spec.method.isEmpty ? "PUT" : spec.method.uppercased() + for (name, value) in spec.headers { + // Content-Length 由 uploadTask(fromFile:) 按文件长度自带; + // Content-Type URLSession 不会自动设置,原样透传(与 Kotlin + // 经 RequestBody.contentType 表达等价)。 + if name.lowercased() == "content-length" { + continue + } + request.setValue(value, forHTTPHeaderField: name) + } + let task = session.uploadTask( + with: request, + fromFile: URL(fileURLWithPath: spec.filePath) + ) + store.upsert( + taskId: spec.taskId, + entry: UploadTaskStore.Entry( + url: spec.url, + method: request.httpMethod ?? "PUT", + headers: spec.headers, + filePath: spec.filePath, + uploadId: spec.uploadId, + attempt: spec.attempt, + executions: executions, + sessionTaskIdentifier: task.taskIdentifier + ) + ) + lock.lock() + activeTasks[task.taskIdentifier] = task + lastProgress[spec.taskId] = nil + progressEmittedAt[spec.taskId] = nil + lock.unlock() + task.resume() + } + + /// 静默取消(用户取消 / 重注册替换):无终态事件(镜像 Kotlin 取消语义 + /// ——Dart 引擎先行置 cancelled 终态,本侧不发事件)。 + private func cancelQuietly(taskId: String) { + let identifier = store.entry(taskId: taskId)?.sessionTaskIdentifier ?? -1 + if identifier >= 0 { + lock.lock() + quietCancelIdentifiers.insert(identifier) + if let task = activeTasks[identifier] { + task.cancel() + } + lock.unlock() + // 未重挂的任务(进程重启后)经系统任务表兜底取消。 + session?.getAllTasks { [weak self] tasks in + for task in tasks where task.taskIdentifier == identifier { + task.cancel() + } + self?.lock.lock() + self?.quietCancelIdentifiers.remove(identifier) + self?.lock.unlock() + } + } + store.remove(taskId: taskId) + lock.lock() + lastProgress[taskId] = nil + progressEmittedAt[taskId] = nil + lock.unlock() + } + + /// 终态/静默结束后的运行期清理(store 条目由调用方决定去留)。 + private func taskRuntimeCleanup(taskId: String, identifier: Int) { + lock.lock() + activeTasks[identifier] = nil + quietCancelIdentifiers.remove(identifier) + lastProgress[taskId] = nil + progressEmittedAt[taskId] = nil + lock.unlock() + } + + // ── delegate 回调入口(UploadSessionDelegate 转发) ────────────── + + fileprivate func taskId(forSessionTask identifier: Int) -> String? { + store.taskId(sessionTaskIdentifier: identifier) + } + + /// 进度节流(500ms;终值必发)。返回是否应发事件。 + fileprivate func shouldEmitProgress(taskId: String, sent: Int64, total: Int64) -> Bool { + lock.lock() + lastProgress[taskId] = (sent, total) + let now = Date().timeIntervalSince1970 + let last = progressEmittedAt[taskId] ?? 0 + let terminalValue = total > 0 && sent >= total + if terminalValue || now - last >= UploadPlugin.progressThrottleSeconds { + progressEmittedAt[taskId] = now + lock.unlock() + return true + } + lock.unlock() + return false + } + + /// 静默取消判定(用户取消 / 替换;系统 NSURLErrorCancelled 归同处理)。 + fileprivate func isQuietlyCancelled(identifier: Int) -> Bool { + lock.lock() + defer { lock.unlock() } + return quietCancelIdentifiers.contains(identifier) + } + + /// 传输结束分类(didCompleteWithError;分类表见类注释)。 + fileprivate func handleTaskCompletion( + taskId: String, + identifier: Int, + response: NSHTTPURLResponse?, + error: Error? + ) { + if isQuietlyCancelled(identifier: identifier) { + taskRuntimeCleanup(taskId: taskId, identifier: identifier) + return + } + if let nsError = error as NSError? { + if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled { + taskRuntimeCleanup(taskId: taskId, identifier: identifier) + return + } + // 网络层错误(镜像 Kotlin IOException → 可重试网络类)。 + handleRetryable(taskId: taskId, identifier: identifier, serverError: false, status: nil) + return + } + guard let status = response?.statusCode else { + handleRetryable(taskId: taskId, identifier: identifier, serverError: false, status: nil) + return + } + switch status { + case 200...299: + bus.emitCompleted(taskId: taskId, etag: response?.value(forHTTPHeaderField: "ETag")) + taskRuntimeCleanup(taskId: taskId, identifier: identifier) + store.remove(taskId: taskId) + case 403: + bus.emitNeedsRenew(taskId: taskId) + taskRuntimeCleanup(taskId: taskId, identifier: identifier) + store.remove(taskId: taskId) + case 400...499: + bus.emitFailed(taskId: taskId, code: UploadBridge.failHttp4xx, status: status) + taskRuntimeCleanup(taskId: taskId, identifier: identifier) + store.remove(taskId: taskId) + default: + handleRetryable(taskId: taskId, identifier: identifier, serverError: true, status: status) + } + } + + /// 可重试结局:未达尝试上限 → 指数退避后重建任务;达上限 → 终态事件 + /// (SERVER_ERROR / NETWORK;status 语义对齐 Kotlin——5xx 带 status, + /// 网络类不带)。退避定时器经主队列 dispatchAfter——App 挂起期间暂停、 + /// 唤醒后补跑(系统已按需后台拉起,G3 真机校准点)。 + private func handleRetryable( + taskId: String, + identifier: Int, + serverError: Bool, + status: Int? + ) { + taskRuntimeCleanup(taskId: taskId, identifier: identifier) + guard let entry = store.entry(taskId: taskId) else { return } + if entry.executions >= UploadPlugin.maxWorkAttempts { + bus.emitFailed( + taskId: taskId, + code: serverError ? UploadBridge.failServerError : UploadBridge.failNetwork, + status: status + ) + store.remove(taskId: taskId) + return + } + var next = entry + next.executions += 1 + store.upsert(taskId: taskId, entry: next) + let delay = + UploadPlugin.retryBackoffBaseSeconds * pow(2, Double(entry.executions - 1)) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.recreate(taskId: taskId) + } + } + + /// 重试重建(退避到期;期间被取消/重注册则 entry 已变——按 URL 等字段重建)。 + private func recreate(taskId: String) { + guard let entry = store.entry(taskId: taskId) else { return } + let spec = UploadRegisterSpec( + taskId: taskId, + url: entry.url, + method: entry.method, + headers: entry.headers, + filePath: entry.filePath, + uploadId: entry.uploadId, + attempt: entry.attempt + ) + do { + try startTask(spec: spec, executions: entry.executions) + } catch { + bus.emitFailed(taskId: taskId, code: UploadBridge.failNetwork, status: nil) + store.remove(taskId: taskId) + } + } + + /// delegate 持有任务引用(重挂/取消用)。 + fileprivate func trackActiveTask(_ task: URLSessionTask) { + guard let taskId = store.taskId(sessionTaskIdentifier: task.taskIdentifier) else { return } + store.mutate(taskId: taskId) { entry in + entry.sessionTaskIdentifier = task.taskIdentifier + } + lock.lock() + activeTasks[task.taskIdentifier] = task + lock.unlock() + } +} + +// ── EventChannel 流(终态缓存重放见 UploadEventBus) ──────────────────── + +/// EventChannel `photolive/uploads/events`:onListen 接管总线 sink(事件 +/// 统一主线程投递,镜像 Kotlin mainHandler.post);onCancel 摘除。 +private final class UploadEventStreamHandler: NSObject, FlutterStreamHandler { + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + UploadEventBus.shared.attach { event in + DispatchQueue.main.async { + events(event) + } + } + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + UploadEventBus.shared.detach() + return nil + } +} + +// ── URLSession delegate(系统回调 → 插件分类) ────────────────────────── + +/// 后台会话 delegate(独立对象 + weak 回指,避免 session↔plugin 环)。 +/// 回调在系统串行队列;所有插件入口自带锁。 +private final class UploadSessionDelegate: NSObject, URLSessionDataDelegate { + weak var plugin: UploadPlugin? + + init(plugin: UploadPlugin) { + self.plugin = plugin + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) { + guard let plugin = plugin, + let taskId = plugin.taskId(forSessionTask: task.taskIdentifier) + else { return } + plugin.trackActiveTask(task) + if plugin.shouldEmitProgress( + taskId: taskId, + sent: totalBytesSent, + total: totalBytesExpectedToSend + ) { + UploadEventBus.shared.emitProgress( + taskId: taskId, + sent: totalBytesSent, + total: totalBytesExpectedToSend + ) + } + } + + /// 上传任务本质是 data task:响应体(MinIO XML/空)读取后丢弃,防连接滞留。 + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + completionHandler(.discard) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + guard let plugin = plugin, + let taskId = plugin.taskId(forSessionTask: task.taskIdentifier) + else { return } + plugin.handleTaskCompletion( + taskId: taskId, + identifier: task.taskIdentifier, + response: task.response as? NSHTTPURLResponse, + error: error + ) + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + plugin?.finishBackgroundEvents() + } +} diff --git a/apps/mobile/ios/Runner/UploadTaskStore.swift b/apps/mobile/ios/Runner/UploadTaskStore.swift new file mode 100644 index 0000000..bfa3b56 --- /dev/null +++ b/apps/mobile/ios/Runner/UploadTaskStore.swift @@ -0,0 +1,130 @@ +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, + ] + } +} diff --git a/apps/mobile/lib/features/uploads/native_transport.dart b/apps/mobile/lib/features/uploads/native_transport.dart index df0ed1e..0fe4f91 100644 --- a/apps/mobile/lib/features/uploads/native_transport.dart +++ b/apps/mobile/lib/features/uploads/native_transport.dart @@ -1,14 +1,17 @@ -/// 上传传输抽象与双实现(P3 计划 Task 7)。 +/// 上传传输抽象与双实现(P3 计划 Task 7;Task 8 扩展 iOS 选择分支)。 /// /// 引擎的传输步骤(presign PUT)经 [Transport] 接口委托: /// - **[DartHttpTransport]**(Task 6 逻辑原样抽出):裸 dio 流式 PUT + -/// 进度计数流 + 取消令牌——iOS / 桌面 / 调试路径(dart-define +/// 进度计数流 + 取消令牌——桌面 / 调试路径(dart-define /// `FORCE_DART_TRANSPORT=true` 强制启用); -/// - **[NativeTransport]**(Task 7 新增,Android):MethodChannel -/// `photolive/uploads` 注册 WorkManager 后台工作(expedited + dataSync -/// 前台服务),EventChannel `photolive/uploads/events` 回流 -/// progress/completed/failed/needsRenew 事件——App 切后台/锁屏后续传 -/// 由系统调度承担。 +/// - **[NativeTransport]**(Task 7 新增,Android;Task 8 起含 iOS): +/// MethodChannel `photolive/uploads` 注册原生后台上传——Android = +/// WorkManager 后台工作(expedited + dataSync 前台服务);iOS = +/// URLSession background(identifier com.photolive.photo_live.uploads, +/// 编译留 G3)。两平台协议逐字一致(Kotlin `UploadProtocol.kt` / +/// Swift `UploadBridge.swift`),EventChannel `photolive/uploads/events` +/// 回流 progress/completed/failed/needsRenew 事件——App 切后台/锁屏后 +/// 续传由系统调度承担。 /// /// 失败语义(两实现归一为 [TransportException] 族,引擎分类见 /// upload_engine.dart `_classifyFailure`): @@ -197,10 +200,11 @@ class DartHttpTransport implements Transport { } } -// ── 原生(WorkManager)实现 ────────────────────────────────────────────── +// ── 原生(Android WorkManager / iOS URLSession background)实现 ───────── -/// 平台通道名(与 Android UploadPlugin 一致;Kotlin 侧常量见 -/// UploadProtocol.kt)。 +/// 平台通道名(与 Android `UploadPlugin.kt` / iOS `UploadPlugin.swift` +/// 一致;原生常量见 UploadProtocol.kt 与 UploadBridge.swift——Task 8 起 +/// iOS 为同一协议的 Swift 镜像,无协议分歧)。 abstract final class UploadBridgeChannels { /// 命令通道:registerTask / cancelTask / queryTask / ackEvents。 static const String methods = 'photolive/uploads'; @@ -243,11 +247,12 @@ class _NativeWaiter { } } -/// Android 原生传输:registerTask 入队 WorkManager(expedited + dataSync -/// 前台服务),事件流回流终态/进度。 +/// Android/iOS 原生传输:registerTask 入队原生后台上传(Android = +/// WorkManager;iOS = URLSession background),事件流回流终态/进度。 /// /// 生命周期安全:事件带单调 seq;终态事件在 Dart ack 前由原生侧缓存, -/// 重新 listen 时重放——进程重启/切后台恢复后不丢完成信号。进度事件不 +/// 重新 listen 时重放——进程重启/切后台恢复后不丢完成信号(iOS 侧缓存 +/// 另落盘,系统后台拉起投递的终态在进程重启后同样重放)。进度事件不 /// 重放(瞬态)。 class NativeTransport implements Transport { NativeTransport({NativeUploadChannel? channel}) @@ -400,21 +405,30 @@ class NativeTransport implements Transport { // ── 传输选择 ───────────────────────────────────────────────────────────── -/// dart-define FORCE_DART_TRANSPORT=true:Android 也强制 Dart dio 路径 -/// (桥接层问题排查用)。 +/// dart-define FORCE_DART_TRANSPORT=true:Android/iOS 也强制 Dart dio +/// 路径(桥接层问题排查用)。 const bool kForceDartTransport = bool.fromEnvironment('FORCE_DART_TRANSPORT'); -/// 传输选择纯函数(可测):Android 且未强制 Dart → 原生。 +/// 传输选择纯函数(可测):Android 或 iOS 且未强制 Dart → 原生。 +/// +/// Task 8 起含 iOS:协议与 Android 逐字一致(同一 [NativeTransport] +/// 经同一通道名驱动 Swift 侧 UploadPlugin——iOS 编译/真机验证留 G3, +/// 代码路径在此之前即纳入原生选择)。 bool preferNativeTransport({ required bool isAndroid, + required bool isIOS, bool forceDartTransport = kForceDartTransport, }) => - isAndroid && !forceDartTransport; + (isAndroid || isIOS) && !forceDartTransport; -/// 默认传输解析:Android → [NativeTransport](WorkManager 后台续传); -/// 其余平台 / 强制覆盖 → [DartHttpTransport]。 +/// 默认传输解析:Android/iOS → [NativeTransport](WorkManager / +/// URLSession background 后台续传);其余平台 / 强制覆盖 → +/// [DartHttpTransport]。 Transport defaultUploadTransport() { - if (preferNativeTransport(isAndroid: !kIsWeb && Platform.isAndroid)) { + if (preferNativeTransport( + isAndroid: !kIsWeb && Platform.isAndroid, + isIOS: !kIsWeb && Platform.isIOS, + )) { return NativeTransport(); } return DartHttpTransport(); diff --git a/apps/mobile/test/features/uploads/native_transport_test.dart b/apps/mobile/test/features/uploads/native_transport_test.dart index c10c215..bd74584 100644 --- a/apps/mobile/test/features/uploads/native_transport_test.dart +++ b/apps/mobile/test/features/uploads/native_transport_test.dart @@ -6,8 +6,8 @@ /// (progress/completed/needsRenew/failed×码表)、终态 ack、取消联动、 /// 注册失败归一、多任务路由; /// - DartHttpTransport:dio 异常 → 传输异常归一(403→renew、取消、网络); -/// - 传输选择:Android 默认原生、FORCE_DART_TRANSPORT 覆盖、VM 缺省 -/// DartHttp。 +/// - 传输选择:Android/iOS 默认原生(Task 8 起 iOS 并入同一矩阵)、 +/// FORCE_DART_TRANSPORT 覆盖、VM 缺省 DartHttp。 library; import 'dart:async'; @@ -405,22 +405,39 @@ void main() { }); }); - group('传输选择(Android 原生 / FORCE_DART_TRANSPORT)', () { - test('选择矩阵:Android→native;强制 Dart / 非 Android→dart', () { - expect(preferNativeTransport(isAndroid: true), isTrue); - expect(preferNativeTransport(isAndroid: false), isFalse); + group('传输选择(Android/iOS 原生 / FORCE_DART_TRANSPORT)', () { + test('选择矩阵:Android/iOS→native;强制 Dart / 桌面→dart', () { + // Android / iOS:原生桥接(Task 7 / Task 8——协议一致,同一通道)。 expect( - preferNativeTransport(isAndroid: true, forceDartTransport: true), - isFalse, - reason: 'FORCE_DART_TRANSPORT 覆盖', + preferNativeTransport(isAndroid: true, isIOS: false), + isTrue, + reason: 'Android → WorkManager 原生', ); expect( - preferNativeTransport(isAndroid: true, forceDartTransport: false), + preferNativeTransport(isAndroid: false, isIOS: true), + isTrue, + reason: 'iOS → URLSession background 原生(编译留 G3)', + ); + // 桌面 / 测试 VM:Dart dio。 + expect(preferNativeTransport(isAndroid: false, isIOS: false), isFalse); + // FORCE_DART_TRANSPORT 覆盖原生选择。 + expect( + preferNativeTransport(isAndroid: true, isIOS: false, forceDartTransport: true), + isFalse, + reason: 'FORCE_DART_TRANSPORT 覆盖(Android)', + ); + expect( + preferNativeTransport(isAndroid: false, isIOS: true, forceDartTransport: true), + isFalse, + reason: 'FORCE_DART_TRANSPORT 覆盖(iOS)', + ); + expect( + preferNativeTransport(isAndroid: true, isIOS: false, forceDartTransport: false), isTrue, ); }); - test('缺省解析:VM 测试环境(非 Android)→ DartHttpTransport', () { + test('缺省解析:VM 测试环境(非 Android/iOS)→ DartHttpTransport', () { final resolved = defaultUploadTransport(); expect(resolved, isA()); resolved.dispose(); diff --git a/docs/plans/2026-09-18-P3-Flutter-App.md b/docs/plans/2026-09-18-P3-Flutter-App.md index 6b53628..2d870eb 100644 --- a/docs/plans/2026-09-18-P3-Flutter-App.md +++ b/docs/plans/2026-09-18-P3-Flutter-App.md @@ -65,8 +65,8 @@ ## Task 8: iOS 桥接代码(编译留 G3) -- [ ] Swift:URLSession background(withIdentifier:) + AppDelegate wiring + MethodChannel 同协议;Info.plist backgroundModes(fetch/processing 按文档)、上传任务映射持久化(plist/sqlite 简版);README-ios.md(Xcode 步骤、签名、验证清单=技术方案 6.2 要点) -- [ ] 静态保证:dart 侧协议层与平台无关(同一 interface),iOS 分支代码完整、`flutter analyze` 通过(iOS 不编译不阻塞) +- [x] Swift:URLSession background(withIdentifier:) + AppDelegate wiring + MethodChannel 同协议;Info.plist backgroundModes(fetch/processing 按文档)、上传任务映射持久化(plist/sqlite 简版);README-ios.md(Xcode 步骤、签名、验证清单=技术方案 6.2 要点)——实现偏差:UIBackgroundModes 有意不声明(background URLSession 无需,见 README-ios.md 与 Info.plist 注释) +- [x] 静态保证:dart 侧协议层与平台无关(同一 interface),iOS 分支代码完整、`flutter analyze` 通过(iOS 不编译不阻塞) - [ ] Commit `feat(mobile): ios background upload bridge (compile-gated at G3)` ## Task 9: 队列 UI 与连拍页