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:
浅念
2026-09-19 03:42:49 +08:00
parent 4e35bdc0b2
commit 42e1c2e34b
11 changed files with 1146 additions and 33 deletions
+107
View File
@@ -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
)
}