feat(mobile): ios background upload bridge (compile-gated at G3)
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>
This commit is contained in:
@@ -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 <deviceId>` 或 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 侧仅作校准
|
||||||
|
参考,不依赖具体枚举。
|
||||||
@@ -12,6 +12,10 @@
|
|||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.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 */; };
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
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 = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
7884E8802EC3CC0A00C636F3 /* UploadBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadBridge.swift; sourceTree = "<group>"; };
|
||||||
|
7884E8822EC3CC0B00C636F3 /* UploadEventBus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadEventBus.swift; sourceTree = "<group>"; };
|
||||||
|
7884E8842EC3CC0C00C636F3 /* UploadTaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadTaskStore.swift; sourceTree = "<group>"; };
|
||||||
|
7884E8862EC3CC0D00C636F3 /* UploadPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadPlugin.swift; sourceTree = "<group>"; };
|
||||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
@@ -123,6 +131,10 @@
|
|||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||||
|
7884E8802EC3CC0A00C636F3 /* UploadBridge.swift */,
|
||||||
|
7884E8822EC3CC0B00C636F3 /* UploadEventBus.swift */,
|
||||||
|
7884E8842EC3CC0C00C636F3 /* UploadTaskStore.swift */,
|
||||||
|
7884E8862EC3CC0D00C636F3 /* UploadPlugin.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
);
|
);
|
||||||
path = Runner;
|
path = Runner;
|
||||||
@@ -284,6 +296,10 @@
|
|||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift 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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,10 +7,32 @@ import UIKit
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
// 后台会话启动即重建:App 被系统在后台拉起(Dart 引擎未起)时,
|
||||||
|
// delegate 回调即可入总线缓存(终态落盘,引擎 onListen 后重放)。
|
||||||
|
UploadPlugin.shared.ensureSession()
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,12 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<!-- UIBackgroundModes 有意未声明(P3 计划 Task 8 偏差登记):上传采用
|
||||||
|
background URLSession(identifier com.photolive.photo_live.uploads),
|
||||||
|
传输与完成回调由系统调度,无需 fetch/processing 等后台模式;计划
|
||||||
|
原文的「backgroundModes(fetch/processing)」按技术方案 §6.2 与
|
||||||
|
Apple 文档修正——多声明反而引入审核 scrutiny。见
|
||||||
|
ios/README-ios.md「Background Modes」。 -->
|
||||||
<!-- 开发/联调允许访问本地网络后端(模拟器宿主回环,Android 侧对应 10.0.2.2);
|
<!-- 开发/联调允许访问本地网络后端(模拟器宿主回环,Android 侧对应 10.0.2.2);
|
||||||
生产环境仍走 HTTPS,不放宽整体 ATS。 -->
|
生产环境仍走 HTTPS,不放宽整体 ATS。 -->
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
|
|||||||
@@ -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<String, String>")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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]] ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Int> = []
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
/// 上传传输抽象与双实现(P3 计划 Task 7)。
|
/// 上传传输抽象与双实现(P3 计划 Task 7;Task 8 扩展 iOS 选择分支)。
|
||||||
///
|
///
|
||||||
/// 引擎的传输步骤(presign PUT)经 [Transport] 接口委托:
|
/// 引擎的传输步骤(presign PUT)经 [Transport] 接口委托:
|
||||||
/// - **[DartHttpTransport]**(Task 6 逻辑原样抽出):裸 dio 流式 PUT +
|
/// - **[DartHttpTransport]**(Task 6 逻辑原样抽出):裸 dio 流式 PUT +
|
||||||
/// 进度计数流 + 取消令牌——iOS / 桌面 / 调试路径(dart-define
|
/// 进度计数流 + 取消令牌——桌面 / 调试路径(dart-define
|
||||||
/// `FORCE_DART_TRANSPORT=true` 强制启用);
|
/// `FORCE_DART_TRANSPORT=true` 强制启用);
|
||||||
/// - **[NativeTransport]**(Task 7 新增,Android):MethodChannel
|
/// - **[NativeTransport]**(Task 7 新增,Android;Task 8 起含 iOS):
|
||||||
/// `photolive/uploads` 注册 WorkManager 后台工作(expedited + dataSync
|
/// MethodChannel `photolive/uploads` 注册原生后台上传——Android =
|
||||||
/// 前台服务),EventChannel `photolive/uploads/events` 回流
|
/// WorkManager 后台工作(expedited + dataSync 前台服务);iOS =
|
||||||
/// progress/completed/failed/needsRenew 事件——App 切后台/锁屏后续传
|
/// 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] 族,引擎分类见
|
/// 失败语义(两实现归一为 [TransportException] 族,引擎分类见
|
||||||
/// upload_engine.dart `_classifyFailure`):
|
/// upload_engine.dart `_classifyFailure`):
|
||||||
@@ -197,10 +200,11 @@ class DartHttpTransport implements Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 原生(WorkManager)实现 ──────────────────────────────────────────────
|
// ── 原生(Android WorkManager / iOS URLSession background)实现 ─────────
|
||||||
|
|
||||||
/// 平台通道名(与 Android UploadPlugin 一致;Kotlin 侧常量见
|
/// 平台通道名(与 Android `UploadPlugin.kt` / iOS `UploadPlugin.swift`
|
||||||
/// UploadProtocol.kt)。
|
/// 一致;原生常量见 UploadProtocol.kt 与 UploadBridge.swift——Task 8 起
|
||||||
|
/// iOS 为同一协议的 Swift 镜像,无协议分歧)。
|
||||||
abstract final class UploadBridgeChannels {
|
abstract final class UploadBridgeChannels {
|
||||||
/// 命令通道:registerTask / cancelTask / queryTask / ackEvents。
|
/// 命令通道:registerTask / cancelTask / queryTask / ackEvents。
|
||||||
static const String methods = 'photolive/uploads';
|
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 前由原生侧缓存,
|
/// 生命周期安全:事件带单调 seq;终态事件在 Dart ack 前由原生侧缓存,
|
||||||
/// 重新 listen 时重放——进程重启/切后台恢复后不丢完成信号。进度事件不
|
/// 重新 listen 时重放——进程重启/切后台恢复后不丢完成信号(iOS 侧缓存
|
||||||
|
/// 另落盘,系统后台拉起投递的终态在进程重启后同样重放)。进度事件不
|
||||||
/// 重放(瞬态)。
|
/// 重放(瞬态)。
|
||||||
class NativeTransport implements Transport {
|
class NativeTransport implements Transport {
|
||||||
NativeTransport({NativeUploadChannel? channel})
|
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');
|
const bool kForceDartTransport = bool.fromEnvironment('FORCE_DART_TRANSPORT');
|
||||||
|
|
||||||
/// 传输选择纯函数(可测):Android 且未强制 Dart → 原生。
|
/// 传输选择纯函数(可测):Android 或 iOS 且未强制 Dart → 原生。
|
||||||
|
///
|
||||||
|
/// Task 8 起含 iOS:协议与 Android 逐字一致(同一 [NativeTransport]
|
||||||
|
/// 经同一通道名驱动 Swift 侧 UploadPlugin——iOS 编译/真机验证留 G3,
|
||||||
|
/// 代码路径在此之前即纳入原生选择)。
|
||||||
bool preferNativeTransport({
|
bool preferNativeTransport({
|
||||||
required bool isAndroid,
|
required bool isAndroid,
|
||||||
|
required bool isIOS,
|
||||||
bool forceDartTransport = kForceDartTransport,
|
bool forceDartTransport = kForceDartTransport,
|
||||||
}) =>
|
}) =>
|
||||||
isAndroid && !forceDartTransport;
|
(isAndroid || isIOS) && !forceDartTransport;
|
||||||
|
|
||||||
/// 默认传输解析:Android → [NativeTransport](WorkManager 后台续传);
|
/// 默认传输解析:Android/iOS → [NativeTransport](WorkManager /
|
||||||
/// 其余平台 / 强制覆盖 → [DartHttpTransport]。
|
/// URLSession background 后台续传);其余平台 / 强制覆盖 →
|
||||||
|
/// [DartHttpTransport]。
|
||||||
Transport defaultUploadTransport() {
|
Transport defaultUploadTransport() {
|
||||||
if (preferNativeTransport(isAndroid: !kIsWeb && Platform.isAndroid)) {
|
if (preferNativeTransport(
|
||||||
|
isAndroid: !kIsWeb && Platform.isAndroid,
|
||||||
|
isIOS: !kIsWeb && Platform.isIOS,
|
||||||
|
)) {
|
||||||
return NativeTransport();
|
return NativeTransport();
|
||||||
}
|
}
|
||||||
return DartHttpTransport();
|
return DartHttpTransport();
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
/// (progress/completed/needsRenew/failed×码表)、终态 ack、取消联动、
|
/// (progress/completed/needsRenew/failed×码表)、终态 ack、取消联动、
|
||||||
/// 注册失败归一、多任务路由;
|
/// 注册失败归一、多任务路由;
|
||||||
/// - DartHttpTransport:dio 异常 → 传输异常归一(403→renew、取消、网络);
|
/// - DartHttpTransport:dio 异常 → 传输异常归一(403→renew、取消、网络);
|
||||||
/// - 传输选择:Android 默认原生、FORCE_DART_TRANSPORT 覆盖、VM 缺省
|
/// - 传输选择:Android/iOS 默认原生(Task 8 起 iOS 并入同一矩阵)、
|
||||||
/// DartHttp。
|
/// FORCE_DART_TRANSPORT 覆盖、VM 缺省 DartHttp。
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
@@ -405,22 +405,39 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('传输选择(Android 原生 / FORCE_DART_TRANSPORT)', () {
|
group('传输选择(Android/iOS 原生 / FORCE_DART_TRANSPORT)', () {
|
||||||
test('选择矩阵:Android→native;强制 Dart / 非 Android→dart', () {
|
test('选择矩阵:Android/iOS→native;强制 Dart / 桌面→dart', () {
|
||||||
expect(preferNativeTransport(isAndroid: true), isTrue);
|
// Android / iOS:原生桥接(Task 7 / Task 8——协议一致,同一通道)。
|
||||||
expect(preferNativeTransport(isAndroid: false), isFalse);
|
|
||||||
expect(
|
expect(
|
||||||
preferNativeTransport(isAndroid: true, forceDartTransport: true),
|
preferNativeTransport(isAndroid: true, isIOS: false),
|
||||||
isFalse,
|
isTrue,
|
||||||
reason: 'FORCE_DART_TRANSPORT 覆盖',
|
reason: 'Android → WorkManager 原生',
|
||||||
);
|
);
|
||||||
expect(
|
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,
|
isTrue,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('缺省解析:VM 测试环境(非 Android)→ DartHttpTransport', () {
|
test('缺省解析:VM 测试环境(非 Android/iOS)→ DartHttpTransport', () {
|
||||||
final resolved = defaultUploadTransport();
|
final resolved = defaultUploadTransport();
|
||||||
expect(resolved, isA<DartHttpTransport>());
|
expect(resolved, isA<DartHttpTransport>());
|
||||||
resolved.dispose();
|
resolved.dispose();
|
||||||
|
|||||||
@@ -65,8 +65,8 @@
|
|||||||
|
|
||||||
## Task 8: iOS 桥接代码(编译留 G3)
|
## Task 8: iOS 桥接代码(编译留 G3)
|
||||||
|
|
||||||
- [ ] Swift:URLSession background(withIdentifier:) + AppDelegate wiring + MethodChannel 同协议;Info.plist backgroundModes(fetch/processing 按文档)、上传任务映射持久化(plist/sqlite 简版);README-ios.md(Xcode 步骤、签名、验证清单=技术方案 6.2 要点)
|
- [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 注释)
|
||||||
- [ ] 静态保证:dart 侧协议层与平台无关(同一 interface),iOS 分支代码完整、`flutter analyze` 通过(iOS 不编译不阻塞)
|
- [x] 静态保证:dart 侧协议层与平台无关(同一 interface),iOS 分支代码完整、`flutter analyze` 通过(iOS 不编译不阻塞)
|
||||||
- [ ] Commit `feat(mobile): ios background upload bridge (compile-gated at G3)`
|
- [ ] Commit `feat(mobile): ios background upload bridge (compile-gated at G3)`
|
||||||
|
|
||||||
## Task 9: 队列 UI 与连拍页
|
## Task 9: 队列 UI 与连拍页
|
||||||
|
|||||||
Reference in New Issue
Block a user