42e1c2e34b
Swift 侧(协议与 Android UploadPlugin.kt 逐字一致,编译/真机留 G3): - UploadBridge.swift:通道/方法/事件/失败码常量 + registerTask 参数解析 (镜像 UploadProtocol.kt) - UploadEventBus.swift:单调 seq + 终态 ack 缓存重放(镜像 UploadEventBus.kt),iOS 增强——未 ack 终态落盘(系统后台拉起投递的 回调在进程重启后同样重放,技术方案 §6.2 持久化原生回调) - UploadTaskStore.swift:taskId→URLSession taskIdentifier 映射 plist 持久化 (Application Support;对应 Android WorkManager 自持映射) - UploadPlugin.swift:URLSession background (com.photolive.photo_live.uploads)+ delegate 失败分类(2xx→completed+etag / 403→needsRenew / 4xx→HTTP_4XX / 网络→重试上限后 NETWORK,镜像 UploadPutExecutor/UploadWorker)、原生重试(5 次上限、10s 指数退避)、 进度 500ms 节流、queryTask/cancelTask/ackEvents、静默取消 - AppDelegate.swift:implicit engine 通道注册 + 启动重建会话 + handleEventsForBackgroundURLSession completionHandler 暂存 - Info.plist:UIBackgroundModes 有意不声明(background URLSession 无需; 计划措辞按技术方案 §6.2 修正,见 README-ios.md) - project.pbxproj 登记新 Swift 文件;README-ios.md(Xcode 步骤 + G3 验证 清单:实际文件上传/持久化回调窗口/服务端补偿/重启重联校准/强制退出/ 低电量延迟/100 张批量) Dart:NativeTransport 选择矩阵并入 iOS(Platform.isAndroid || isIOS, FORCE_DART_TRANSPORT 覆盖不变)——协议两侧一致,无新通道代码。 flutter analyze 0 issue;flutter test 219 全绿。 Co-Authored-By: Claude Code <noreply@anthropic.com>
532 lines
21 KiB
Swift
532 lines
21 KiB
Swift
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()
|
||
}
|
||
}
|