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
@@ -7,6 +7,7 @@
library;
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:dio/dio.dart';
@@ -15,6 +16,7 @@ import 'package:photo_live/core/api_client.dart';
import 'package:photo_live/core/device_id.dart';
import 'package:photo_live/core/error_codes.dart';
import 'package:photo_live/core/session_store.dart';
import 'package:photo_live/features/uploads/native_transport.dart';
import 'package:photo_live/features/uploads/preparer.dart';
import 'package:photo_live/features/uploads/task_state.dart';
import 'package:photo_live/features/uploads/task_store.dart';
@@ -81,12 +83,14 @@ class Harness {
int maxParallel = 2,
int keepCompletedDays = 14,
void Function(String taskId, int sent, int total)? onProgress,
Transport? transport,
}) =>
UploadEngine(
store,
api,
deviceId,
objectDio: objectDio,
objectDio: transport == null ? objectDio : null,
transport: transport,
now: () => clock,
maxParallel: maxParallel,
keepCompletedDays: keepCompletedDays,
@@ -246,6 +250,68 @@ class _FixedDeviceIdStore implements DeviceIdStore {
Future<void> write(String value) async {}
}
/// 一次 send 调用的快照(Task 7 传输委托断言)。
class ScriptedTransportCall {
const ScriptedTransportCall(
this.taskId,
this.url,
this.method,
this.headers,
this.filePath,
this.metadata,
);
final String taskId;
final String url;
final String method;
final Map<String, String> headers;
final String filePath;
final Map<String, Object?>? metadata;
}
/// 脚本化传输替身(P3 计划 Task 7:引擎 × Transport 接口集成)。
///
/// 每任务按队列依次弹出手本:[TransportResult] 即成功返回;其余视为
/// 异常抛出(TransportException 族)。无手本任务默认成功并补发一次
/// 全量进度回调。
class ScriptedTransport implements Transport {
final Map<String, ListQueue<Object>> script = {};
final List<ScriptedTransportCall> calls = [];
bool progressBeforeComplete = true;
void enqueue(String taskId, List<Object> outcomes) =>
script.putIfAbsent(taskId, ListQueue.new).addAll(outcomes);
@override
Future<TransportResult> send({
required String taskId,
required String url,
required String method,
required Map<String, String> headers,
required String filePath,
Map<String, Object?>? metadata,
void Function(int sent, int total)? onProgress,
CancelToken? cancelToken,
}) async {
calls.add(
ScriptedTransportCall(taskId, url, method, headers, filePath, metadata),
);
final queue = script[taskId];
final outcome = queue == null || queue.isEmpty
? const TransportResult()
: queue.removeFirst();
if (outcome is TransportResult) {
if (progressBeforeComplete) {
onProgress?.call(1024, 1024);
}
return outcome;
}
throw outcome;
}
@override
void dispose() {}
}
void main() {
late Harness h;
late Directory tmp;
@@ -872,4 +938,163 @@ void main() {
// 文件检查在 PUT 阶段(init 已发生、无 PUT/complete)。
expect(h.events, ['init:ct-q']);
});
group('传输委托(Task 7 Transport 接口)', () {
late ScriptedTransport transport;
setUp(() {
transport = ScriptedTransport();
});
test('引擎 PUT 委托 Transport:凭证透传 + init/complete 仍在 Dart 引擎',
() async {
final file = photoFile('t1.jpg');
final progress = <String, List<int>>{};
engine = h.buildEngine(
transport: transport,
onProgress: (id, sent, total) =>
progress.putIfAbsent(id, () => <int>[]).add(sent),
);
engine.start('alice', period: const Duration(hours: 1));
final seeded = await h.seedTask(file, clientTaskId: 'ct-t1');
await cycle();
final t = await task(seeded.id);
expect(t.state, UploadTaskState.completed);
expect(t.serverUploadId, 'upl1');
// 原生路径绕过 dio:对象适配器无请求,API 序列只含 init/complete。
expect(h.objectAdapter.requests, isEmpty);
expect(h.events, ['init:ct-t1', 'complete:upl1']);
// 委托参数:init 返回的凭证原样透传(URL/方法/头/文件路径/元数据)。
final call = transport.calls.single;
expect(call.taskId, seeded.id);
expect(call.url, 'http://object.test.local/staging/upl1/pho1.jpg');
expect(call.method, 'PUT');
expect(call.headers, {'Content-Type': 'image/jpeg'});
expect(call.filePath, file.path);
expect(call.metadata?['uploadId'], 'upl1');
expect(call.metadata?['attempt'], 0);
// 进度回调由 Transport 上报(队列 UI 挂接点不变)。
expect(progress[seeded.id], [1024]);
});
test('原生 needsRenew:引擎 renew 后携带新 URL 重新委托', () async {
final file = photoFile('t2.jpg');
engine = h.buildEngine(transport: transport);
engine.start('alice', period: const Duration(hours: 1));
final seeded = await h.seedTask(file, clientTaskId: 'ct-t2');
transport.enqueue(seeded.id, [
const TransportRenewRequiredException(),
const TransportResult(),
]);
await cycle();
final t = await task(seeded.id);
expect(t.state, UploadTaskState.completed);
expect(h.renewCalls, 1);
expect(transport.calls, hasLength(2));
expect(transport.calls.first.url, endsWith('upl1/pho1.jpg'));
expect(transport.calls.last.url, endsWith('upl1/pho1.jpg?renewed=1'));
expect(h.events, ['init:ct-t2', 'renew:upl1', 'complete:upl1']);
});
test('原生 network 失败 → waitingNetwork 退避;恢复后重新委托完成',
() async {
final file = photoFile('t3.jpg');
engine = h.buildEngine(transport: transport);
engine.start('alice', period: const Duration(hours: 1));
final seeded = await h.seedTask(file, clientTaskId: 'ct-t3');
transport.enqueue(seeded.id, [
const TransportException(TransportErrorCodes.network),
]);
await cycle();
var t = await task(seeded.id);
expect(t.state, UploadTaskState.waitingNetwork);
expect(t.lastError, ErrorCodes.networkError);
expect(t.attempt, 1);
expect(t.nextRetryAt, h.clock.add(const Duration(seconds: 5)));
// 到期重试:无手本 → 默认成功。
h.clock = h.clock.add(const Duration(seconds: 10));
await cycle();
t = await task(seeded.id);
expect(t.state, UploadTaskState.completed);
expect(transport.calls, hasLength(2));
});
test('原生 HTTP_4XX(400) → retryableFailed(自动重试退避)', () async {
final file = photoFile('t4.jpg');
engine = h.buildEngine(transport: transport);
engine.start('alice', period: const Duration(hours: 1));
final seeded = await h.seedTask(file, clientTaskId: 'ct-t4');
transport.enqueue(seeded.id, [
const TransportException(TransportErrorCodes.http, statusCode: 400),
]);
await cycle();
final t = await task(seeded.id);
expect(t.state, UploadTaskState.retryableFailed);
expect(t.lastError, ErrorCodes.httpError);
expect(t.attempt, 1);
expect(t.nextRetryAt, isNotNull, reason: 'attempt 1 < 3:仍自动重试');
});
test('原生 HTTP 401 → waitingNetwork 短退避(会话语义)', () async {
final file = photoFile('t5.jpg');
engine = h.buildEngine(transport: transport);
engine.start('alice', period: const Duration(hours: 1));
final seeded = await h.seedTask(file, clientTaskId: 'ct-t5');
transport.enqueue(seeded.id, [
const TransportException(TransportErrorCodes.http, statusCode: 401),
]);
await cycle();
final t = await task(seeded.id);
expect(t.state, UploadTaskState.waitingNetwork);
expect(t.lastError, ErrorCodes.authTokenExpired);
expect(t.attempt, 0, reason: '会话过期不烧次数');
expect(t.nextRetryAt, h.clock.add(const Duration(seconds: 30)));
});
test('原生 SOURCE_MISSING → 不自动重试的 retryableFailed', () async {
final file = photoFile('t6.jpg');
engine = h.buildEngine(transport: transport);
engine.start('alice', period: const Duration(hours: 1));
final seeded = await h.seedTask(file, clientTaskId: 'ct-t6');
transport.enqueue(seeded.id, [
const TransportException(TransportErrorCodes.sourceMissing),
]);
await cycle();
final t = await task(seeded.id);
expect(t.state, UploadTaskState.retryableFailed);
expect(t.lastError, UploadEngine.sourceMissingCode);
expect(t.nextRetryAt, isNull);
});
test('原生 SERVER_ERROR(500) → retryableFailed(5xx 语义桶)', () async {
final file = photoFile('t7.jpg');
engine = h.buildEngine(transport: transport);
engine.start('alice', period: const Duration(hours: 1));
final seeded = await h.seedTask(file, clientTaskId: 'ct-t7');
transport.enqueue(seeded.id, [
const TransportException(TransportErrorCodes.http, statusCode: 500),
]);
await cycle();
final t = await task(seeded.id);
expect(t.state, UploadTaskState.retryableFailed);
expect(t.lastError, ErrorCodes.internalError);
expect(t.attempt, 1);
});
});
}