feat(mobile): android background upload bridge via workmanager

Kotlin 侧(MethodChannel photolive/uploads + EventChannel photolive/uploads/events):
- UploadPlugin:registerTask 入队 WorkManager OneTimeWorkRequest(unique
  work=upload-task-{taskId},expedited + OutOfQuotaPolicy 回退、CONNECTED
  约束、指数退避 10s 起)、cancelTask / queryTask / ackEvents
- UploadWorker(CoroutineWorker):dataSync 前台服务 + 进度通知,
  OkHttp PUT(ETag 捕获、64KB 分片进度),结局分类:2xx→completed、
  403→needsRenew、4xx→failed{HTTP_4XX}、网络/5xx→Result.retry(上限
  5 次后 failed{NETWORK|SERVER_ERROR})、缺文件→failed{SOURCE_MISSING}
- UploadEventBus:终态事件单调 seq + 未 ack 缓存重放(上限 200),
  重注册/取消作废旧缓存——进程重启/切后台恢复不丢完成信号
- Manifest:FOREGROUND_SERVICE(_DATA_SYNC)/POST_NOTIFICATIONS 权限 +
  SystemForegroundService tools:node=merge 补 dataSync 类型(已验证
  合并产物)

Dart 侧:Transport 接口(init/complete/renew 留引擎,仅 PUT 委托)——
DartHttpTransport(Task 6 逻辑抽出,403→renew 异常归一)+ NativeTransport
(registerTask→事件流终态/进度映射、终态 ack、取消联动);引擎传输选择:
Android 缺省原生,dart-define FORCE_DART_TRANSPORT=true 强制 Dart 路径;
失败分类与 dio 路径语义对齐(401 短退避/429 网络桶/SOURCE_MISSING 不
自动重试等)

测试:gradle testDebugUnitTest 29 例(executor MockWebServer 分类×10、
协议解析×11、事件总线×8);flutter test 219 全绿(新增 NativeTransport
事件映射/取消/ack/并发路由、引擎×Transport 委托与 needsRenew 重注册、
传输选择矩阵);flutter analyze 0;debug apk 构建通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-19 03:22:42 +08:00
parent 3ddb0ea2d4
commit 4e35bdc0b2
18 changed files with 2454 additions and 48 deletions
@@ -10,8 +10,8 @@
/// 介入,永不自动出队);网络恢复([onNetworkRestored])对
/// waitingNetwork 立即绕过退避;
/// - **传输三步**:initUpload(幂等,clientTaskId + sourceDeviceId)→
/// presign PUT(dio 流式 + 进度回调 + 取消令牌;**裸 Dio**——不挂
/// ApiClient 拦截器,避免 Bearer 头污染 MinIO 预签名签名)→
/// presign PUT(**经 [Transport] 委托**——Android 走原生 WorkManager
/// 后台桥接(Task 7),其余平台裸 dio 流式;进度回调 + 取消令牌)→
/// completeUpload(幂等核验)→ completed;
/// - **失败分类**([UploadEngineSourceMissing] 之外见 [_classifyFailure]):
/// | 错误 | 目标状态 | 自动重试 |
@@ -46,6 +46,7 @@ import '../../core/app_exception.dart';
import '../../core/device_id.dart';
import '../../core/error_codes.dart';
import 'models.dart';
import 'native_transport.dart';
import 'task_state.dart';
import 'task_store.dart';
@@ -75,27 +76,41 @@ const int kMaxCredentialRenewals = 2;
/// 传输引擎。
///
/// 事件驱动 + 周期兜底;不 own isolate(传输多为 IO 等待,UI isolate
/// 足够;Android 后台延续由 Task 7 原生桥接承担)。
/// 足够;Android 后台延续由 [Transport] 原生实现承担——Task 7)。
class UploadEngine {
UploadEngine(
this._store,
this._apiClient,
this._deviceId, {
Dio? objectDio,
Transport? transport,
DateTime Function()? now,
this.maxParallel = kMaxParallelUploads,
this.keepCompletedDays = kKeepCompletedDays,
this.onProgress,
this.onError,
}) : _objectDio = objectDio ?? Dio(),
}) : _objectDio = objectDio,
_transport = transport ??
(objectDio != null
? DartHttpTransport(dio: objectDio)
: defaultUploadTransport()),
_ownsTransport = transport == null,
_now = now ?? DateTime.now;
final TaskStore _store;
final ApiClient _apiClient;
final DeviceId _deviceId;
/// presign PUT 专用裸 Dio(无鉴权拦截器,见库级注释)。
final Dio _objectDio;
/// presign PUT 专用裸 Dio(无鉴权拦截器,见库级注释;[objectDio] 注入
/// 时持有引用供 dispose 关闭——传输经 [DartHttpTransport] 使用)。
final Dio? _objectDio;
/// PUT 传输委托(Android 原生 WorkManager 桥接 / 其余平台 Dart dio;
/// 选择逻辑见 native_transport.dart)。
final Transport _transport;
/// dispose 时是否释放 [_transport](注入传输由注入方管理生命周期)。
final bool _ownsTransport;
final DateTime Function() _now;
/// 并行传输上限(运行时 <1 钳位为 1,见 [_effectiveParallel])。
@@ -294,9 +309,13 @@ class UploadEngine {
now: _now(),
);
await _transferSteps(current, handle);
} on TransportException catch (e) {
// 传输层失败(两实现归一;取消已在 _putObject 内短路,到达这里的
// 均为待分类失败)。
await _classifyFailure(task, e);
} on DioException catch (e) {
// 仅来自 PUT(api 调用已归一 AppException);取消已在
// _transferSteps 内短路,到达这里的均为待分类失败。
// 防御性兜底(api 调用已归一 AppException;dio 异常仅可能自传输
// 层漏网),按原语义分类。
await _classifyFailure(task, e);
} on AppException catch (e) {
await _classifyFailure(task, e);
@@ -333,20 +352,17 @@ class UploadEngine {
now: _now(),
);
// b. presign PUT(流式 + 进度 + 取消令牌;403 → renew 换凭证重传)。
// b. presign PUT(transport 委托 + 进度 + 取消令牌;403 → renew 换
// 凭证重传——原生路径 re-register 新 URL,Dart 路径重发 dio PUT)。
var credential = init.credential;
var renewals = 0;
while (true) {
try {
await _putObject(task, credential, handle.cancelToken);
await _putObject(task, credential, handle.cancelToken,
serverUploadId: init.uploadId);
break;
} on DioException catch (e) {
if (handle.cancelToken.isCancelled) {
// 用户取消:状态已置 cancelled(cancel() 内),终止协程。
return;
}
if (e.response?.statusCode == 403 &&
renewals < kMaxCredentialRenewals) {
} on TransportRenewRequiredException {
if (renewals < kMaxCredentialRenewals) {
renewals++;
final renewed = await _apiClient.renewUpload(init.uploadId);
credential = renewed.credential;
@@ -375,14 +391,15 @@ class UploadEngine {
);
}
/// presign PUT:裸 Dio 直传 credential.url,严格携带 credential.headers
/// (+ Content-Length——流式发送 dio 无法自动推断,显式声明)。进度经
/// 计数流上报(引擎自持,不依赖适配器对 onSendProgress 的实现)。
/// presign PUT:委托 [_transport](Android 原生 WorkManager 桥接 /
/// 其余 Dart dio 流式),严格携带 credential.headers。源文件存在性前置
/// 检查保留在引擎侧(两路径共用的 sourceMissing 分类依据)。
Future<void> _putObject(
UploadTask task,
UploadCredential credential,
CancelToken cancelToken,
) async {
CancelToken cancelToken, {
String? serverUploadId,
}) async {
final file = File(task.sourceFilePath);
if (!await file.exists()) {
// 源文件缺失:不可恢复,分类按「不自动重试」处理。
@@ -391,37 +408,43 @@ class UploadEngine {
message: 'source file missing',
);
}
final length = await file.length();
var sent = 0;
final progressStream =
file.openRead().map((List<int> chunk) {
sent += chunk.length;
onProgress?.call(task.id, sent, length);
return chunk;
});
await _objectDio.put<void>(
credential.url,
data: progressStream,
cancelToken: cancelToken,
options: Options(
try {
await _transport.send(
taskId: task.id,
url: credential.url,
method: credential.method,
headers: {
...credential.headers,
Headers.contentLengthHeader: length,
headers: credential.headers,
filePath: task.sourceFilePath,
metadata: <String, Object?>{
'uploadId': serverUploadId,
'attempt': task.attempt,
},
// 响应体非 JSON(MinIO XML/空),防 transformer 解析报错。
responseType: ResponseType.plain,
receiveDataWhenStatusError: true,
),
);
onProgress: (sent, total) => onProgress?.call(task.id, sent, total),
cancelToken: cancelToken,
);
} on TransportException catch (e) {
if (_isCancelledTransfer(e, cancelToken)) {
// 用户取消:状态已置 cancelled(cancel() 内),终止协程。
return;
}
rethrow;
}
}
/// 取消判定:取消令牌已触发或传输层报告 cancelled。
static bool _isCancelledTransfer(Object error, CancelToken cancelToken) =>
cancelToken.isCancelled ||
(error is TransportException &&
error.code == TransportErrorCodes.cancelled);
/// 失败分类与状态落库(分类表见库级注释;attempt 每次 +1,目标状态
/// 自 uploading/uploadedConfirming 均合法迁移)。
Future<void> _classifyFailure(UploadTask task, Object error) async {
final AppException app;
if (error is AppException) {
app = error;
} else if (error is TransportException) {
app = _normalizeTransportFailure(error);
} else if (error is DioException) {
app = ApiClient.mapDioException(error);
} else {
@@ -488,6 +511,64 @@ class UploadEngine {
);
}
/// 传输层异常 → AppException 归一(与 dio 路径 mapDioException 语义
/// 对齐:网络类/429 → 网络桶;401 → 短退避桶;sourceMissing → 不自动
/// 重试桶;其余按 HTTP 状态归桶 4)。
static AppException _normalizeTransportFailure(TransportException e) {
switch (e.code) {
case TransportErrorCodes.network:
return const AppException(
code: ErrorCodes.networkError,
message: '传输失败(网络)',
);
case TransportErrorCodes.sourceMissing:
return const AppException(
code: UploadEngine.sourceMissingCode,
message: 'source file missing',
);
case TransportErrorCodes.cancelled:
return const AppException(
code: ErrorCodes.cancelled,
message: '传输已取消',
);
case TransportErrorCodes.channel:
return AppException(
code: ErrorCodes.internalError,
message: '原生桥接通道错误: ${e.cause}',
);
case TransportErrorCodes.http:
default:
final status = e.statusCode;
if (status == 401) {
return const AppException(
code: ErrorCodes.authTokenExpired,
statusCode: 401,
message: '未认证或会话已过期',
);
}
if (status == 429 || status == 408) {
return const AppException(
code: ErrorCodes.rateLimited,
statusCode: 429,
message: '传输限速',
);
}
if (status != null) {
return AppException(
code: status >= 500
? ErrorCodes.internalError
: ErrorCodes.httpError,
statusCode: status,
message: '传输 HTTP $status',
);
}
return AppException(
code: ErrorCodes.internalError,
message: '传输失败: ${e.cause ?? e}',
);
}
}
/// 分类落地(并发取消致 StateError 时静默——cancelled 已是终态)。
Future<void> _fail(
UploadTask task,
@@ -583,13 +664,17 @@ class UploadEngine {
return cleaned;
}
/// 关闭(App 退出):停 timer、尽力断在途、释放 object Dio。
/// 关闭(App 退出):停 timer、尽力断在途、释放注入的 object Dio 与
/// 自建传输。
void dispose() {
stop();
for (final handle in _inFlight.values) {
handle.cancelToken.cancel();
}
_objectDio.close();
_objectDio?.close();
if (_ownsTransport) {
_transport.dispose();
}
}
}