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 */; };
|
||||
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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<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);
|
||||
生产环境仍走 HTTPS,不放宽整体 ATS。 -->
|
||||
<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] 接口委托:
|
||||
/// - **[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();
|
||||
|
||||
@@ -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<DartHttpTransport>());
|
||||
resolved.dispose();
|
||||
|
||||
@@ -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 与连拍页
|
||||
|
||||
Reference in New Issue
Block a user