Files
photo-live/apps/mobile/lib/features/uploads/native_transport.dart
T
浅念 4e35bdc0b2 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>
2026-09-19 03:22:42 +08:00

422 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 上传传输抽象与双实现(P3 计划 Task 7)。
///
/// 引擎的传输步骤(presign PUT)经 [Transport] 接口委托:
/// - **[DartHttpTransport]**(Task 6 逻辑原样抽出):裸 dio 流式 PUT +
/// 进度计数流 + 取消令牌——iOS / 桌面 / 调试路径(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 切后台/锁屏后续传
/// 由系统调度承担。
///
/// 失败语义(两实现归一为 [TransportException] 族,引擎分类见
/// upload_engine.dart `_classifyFailure`):
/// | 情形 | 异常 |
/// |---|---|
/// | 网络不可达/超时/取消令牌触发前的连接层失败 | `TransportException(network)` |
/// | HTTP 403(presign 凭证过期) | [TransportRenewRequiredException](引擎 renew 后重发) |
/// | 其余 HTTP 非 2xx | `TransportException(http, statusCode)` |
/// | 源文件缺失(原生 worker 发现) | `TransportException(sourceMissing)` |
/// | 平台通道调用失败 | `TransportException(channel)` |
/// | 取消令牌触发 | `TransportException(cancelled)`(引擎静默收敛) |
library;
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/services.dart';
/// presign PUT 传输接口(引擎只委托文件 PUT;init/complete/renew 仍在
/// Dart 引擎内,保持服务端交互语义单源)。
abstract interface class Transport {
/// 单次文件 PUT。
///
/// - 成功返回 [TransportResult](ETag 供诊断;完成核验以服务端为准);
/// - 失败抛 [TransportException] 族(见库级注释分类表);
/// - [cancelToken] 取消时抛 `TransportException(cancelled)`。
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,
});
/// 释放底层资源(引擎 dispose 调用;注入方自持传输时自行管理)。
void dispose();
}
/// PUT 成功结果。
class TransportResult {
const TransportResult({this.etag});
/// 服务端返回的 ETag 响应头(可空;仅诊断用途)。
final String? etag;
}
/// 传输失败码(原生侧事件码与本侧异常码的归一层)。
abstract final class TransportErrorCodes {
/// 网络不可达/超时/连接层失败。
static const String network = 'NETWORK';
/// HTTP 层失败(statusCode 保留在异常上)。
static const String http = 'HTTP';
/// 取消令牌触发(非失败分类;引擎静默收敛)。
static const String cancelled = 'CANCELLED';
/// 源文件缺失(受管副本被清理,不可恢复)。
static const String sourceMissing = 'SOURCE_MISSING';
/// 平台通道调用失败(桥接层异常)。
static const String channel = 'CHANNEL';
}
/// 传输失败(两实现统一的异常类型)。
class TransportException implements Exception {
const TransportException(this.code, {this.statusCode, this.cause});
/// 失败分类([TransportErrorCodes])。
final String code;
/// HTTP 状态码(http 类失败携带)。
final int? statusCode;
/// 底层异常(诊断)。
final Object? cause;
@override
String toString() =>
'TransportException($code${statusCode != null ? ', status=$statusCode' : ''}'
'${cause != null ? ', cause=$cause' : ''})';
}
/// HTTP 403(presign 凭证过期):引擎 renewUpload 换新凭证后重发。
class TransportRenewRequiredException extends TransportException {
const TransportRenewRequiredException({super.cause})
: super(TransportErrorCodes.http, statusCode: 403);
}
// ── Dart(dio)实现 ──────────────────────────────────────────────────────
/// Task 6 的裸 dio 直传实现(iOS / 桌面 / FORCE_DART_TRANSPORT 调试)。
///
/// 严格携带 credential.headers(+ Content-Length——流式发送 dio 无法自动
/// 推断,显式声明);进度经计数流上报(不依赖适配器对 onSendProgress 的
/// 实现)。
class DartHttpTransport implements Transport {
DartHttpTransport({Dio? dio})
: _dio = dio,
_ownedDio = dio == null;
final Dio? _dio;
final bool _ownedDio;
/// 自建实例惰性创建(注入 dio 时不建)。
Dio? _created;
Dio get _client => _dio ?? (_created ??= Dio());
@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 {
final file = File(filePath);
final length = await file.length();
var sent = 0;
final progressStream = file.openRead().map((List<int> chunk) {
sent += chunk.length;
onProgress?.call(sent, length);
return chunk;
});
try {
final resp = await _client.put<void>(
url,
data: progressStream,
cancelToken: cancelToken,
options: Options(
method: method,
headers: {
...headers,
Headers.contentLengthHeader: length,
},
// 响应体非 JSON(MinIO XML/空),防 transformer 解析报错。
responseType: ResponseType.plain,
receiveDataWhenStatusError: true,
),
);
final etag = resp.headers.value(HttpHeaders.etagHeader);
return TransportResult(etag: etag);
} on DioException catch (e) {
throw _mapDioException(e);
}
}
/// dio 异常 → 传输异常归一(403 → renew;连接层 → network;取消 →
/// cancelled;其余 badResponse → http)。
static TransportException _mapDioException(DioException e) {
if (e.type == DioExceptionType.cancel) {
return const TransportException(TransportErrorCodes.cancelled);
}
final status = e.response?.statusCode;
if (status != null) {
if (status == 403) return const TransportRenewRequiredException();
return TransportException(TransportErrorCodes.http,
statusCode: status, cause: e);
}
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.sendTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.transformTimeout ||
e.type == DioExceptionType.connectionError ||
e.type == DioExceptionType.badCertificate ||
e.type == DioExceptionType.unknown) {
return TransportException(TransportErrorCodes.network, cause: e);
}
// badResponse 无响应必经上方分支,防御性兜底为网络类。
return TransportException(TransportErrorCodes.network, cause: e);
}
@override
void dispose() {
if (_ownedDio) {
_client.close();
}
}
}
// ── 原生(WorkManager)实现 ──────────────────────────────────────────────
/// 平台通道名(与 Android UploadPlugin 一致;Kotlin 侧常量见
/// UploadProtocol.kt)。
abstract final class UploadBridgeChannels {
/// 命令通道:registerTask / cancelTask / queryTask / ackEvents。
static const String methods = 'photolive/uploads';
/// 事件流:progress / completed / failed / needsRenew(带 seq,
/// 终态事件未 ack 前原生侧缓存重放——生命周期安全)。
static const String events = 'photolive/uploads/events';
}
/// 平台通道绑定薄层(默认真实通道;测试注入替身)。
class NativeUploadChannel {
const NativeUploadChannel();
static const MethodChannel _methods = MethodChannel(
UploadBridgeChannels.methods,
);
static const EventChannel _events = EventChannel(
UploadBridgeChannels.events,
);
Future<Object?> invoke(String method, Map<String, Object?> args) =>
_methods.invokeMethod<Object?>(method, args);
Stream<dynamic> get events => _events.receiveBroadcastStream();
}
/// 原生事件等待句柄。
class _NativeWaiter {
_NativeWaiter(this.onProgress);
final void Function(int sent, int total)? onProgress;
final Completer<TransportResult> completer = Completer<TransportResult>();
void complete(TransportResult result) {
if (!completer.isCompleted) completer.complete(result);
}
void completeError(Object error) {
if (!completer.isCompleted) completer.completeError(error);
}
}
/// Android 原生传输:registerTask 入队 WorkManager(expedited + dataSync
/// 前台服务),事件流回流终态/进度。
///
/// 生命周期安全:事件带单调 seq;终态事件在 Dart ack 前由原生侧缓存,
/// 重新 listen 时重放——进程重启/切后台恢复后不丢完成信号。进度事件不
/// 重放(瞬态)。
class NativeTransport implements Transport {
NativeTransport({NativeUploadChannel? channel})
: _channel = channel ?? const NativeUploadChannel();
final NativeUploadChannel _channel;
StreamSubscription<dynamic>? _eventsSub;
final Map<String, _NativeWaiter> _waiters = <String, _NativeWaiter>{};
void _ensureSubscribed() {
if (_eventsSub != null) return;
_eventsSub = _channel.events.listen(
_onEvent,
onError: (Object _) {
// 事件流出错:丢弃订阅,下次 send 重建(注册重试语义兜底)。
_eventsSub?.cancel();
_eventsSub = null;
},
onDone: () => _eventsSub = null,
);
}
void _onEvent(dynamic raw) {
if (raw is! Map) return;
final event = Map<String, Object?>.from(raw);
final taskId = event['taskId'] as String?;
final waiter = taskId == null ? null : _waiters[taskId];
switch (event['event']) {
case 'progress':
final sent = (event['sent'] as num?)?.toInt();
final total = (event['total'] as num?)?.toInt();
if (waiter != null && sent != null && total != null) {
waiter.onProgress?.call(sent, total);
}
case 'completed':
waiter?.complete(
TransportResult(etag: event['etag'] as String?),
);
_ackTerminal(event);
case 'needsRenew':
waiter?.completeError(const TransportRenewRequiredException());
_ackTerminal(event);
case 'failed':
waiter?.completeError(_mapFailedEvent(event));
_ackTerminal(event);
default:
break; // 未知事件:前向兼容忽略。
}
}
/// 原生失败码 → 传输异常。
static TransportException _mapFailedEvent(Map<String, Object?> event) {
final status = (event['status'] as num?)?.toInt();
switch (event['code']) {
case 'NETWORK':
return const TransportException(TransportErrorCodes.network);
case 'SOURCE_MISSING':
return const TransportException(TransportErrorCodes.sourceMissing);
case 'HTTP_4XX':
case 'SERVER_ERROR':
return TransportException(
TransportErrorCodes.http,
statusCode: status,
);
default:
return TransportException(
event['code'] as String? ?? TransportErrorCodes.channel,
statusCode: status,
);
}
}
void _ackTerminal(Map<String, Object?> event) {
final seq = (event['seq'] as num?)?.toInt();
if (seq == null) return;
unawaited(
_channel
.invoke('ackEvents', <String, Object?>{'seq': seq})
.catchError((Object _) => null),
);
}
@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 {
_ensureSubscribed();
final waiter = _NativeWaiter(onProgress);
_waiters[taskId] = waiter;
// 取消令牌联动:cancelTask(原生尽力停止 worker)+ 等待句柄立即失败。
cancelToken?.whenCancel.then((_) {
// 已终结则 no-op(isCompleted 守护)。
waiter.completeError(
const TransportException(TransportErrorCodes.cancelled),
);
unawaited(
_channel
.invoke('cancelTask', <String, Object?>{'taskId': taskId})
.catchError((Object _) => null),
);
});
try {
await _channel.invoke('registerTask', <String, Object?>{
'taskId': taskId,
'url': url,
'method': method,
'headers': headers,
'filePath': filePath,
'metadata': metadata ?? const <String, Object?>{},
});
} on PlatformException catch (e) {
// 注册失败:摘除等待句柄后归一为通道异常。
if (_waiters[taskId] == waiter) _waiters.remove(taskId);
throw TransportException(TransportErrorCodes.channel, cause: e);
}
try {
return await waiter.completer.future;
} finally {
if (_waiters[taskId] == waiter) _waiters.remove(taskId);
}
}
/// 查询原生侧工作状态(WorkInfo 概览;Task 9/11 校准用)。
Future<Map<String, Object?>?> queryTask(String taskId) async {
try {
final result = await _channel
.invoke('queryTask', <String, Object?>{'taskId': taskId});
return result == null
? null
: Map<String, Object?>.from(result as Map<dynamic, dynamic>);
} on PlatformException {
return null;
}
}
@override
void dispose() {
_eventsSub?.cancel();
_eventsSub = null;
}
}
// ── 传输选择 ─────────────────────────────────────────────────────────────
/// dart-define FORCE_DART_TRANSPORT=true:Android 也强制 Dart dio 路径
/// (桥接层问题排查用)。
const bool kForceDartTransport = bool.fromEnvironment('FORCE_DART_TRANSPORT');
/// 传输选择纯函数(可测):Android 且未强制 Dart → 原生。
bool preferNativeTransport({
required bool isAndroid,
bool forceDartTransport = kForceDartTransport,
}) =>
isAndroid && !forceDartTransport;
/// 默认传输解析:Android → [NativeTransport](WorkManager 后台续传);
/// 其余平台 / 强制覆盖 → [DartHttpTransport]。
Transport defaultUploadTransport() {
if (preferNativeTransport(isAndroid: !kIsWeb && Platform.isAndroid)) {
return NativeTransport();
}
return DartHttpTransport();
}