Files
photo-live/apps/mobile/lib/features/uploads/native_transport.dart
T
2026-09-28 14:10:29 +08:00

442 lines
16 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;Task 8 扩展 iOS 选择分支)。
///
/// 引擎的传输步骤(presign PUT)经 [Transport] 接口委托:
/// - **[DartHttpTransport]**(Task 6 逻辑原样抽出):裸 dio 流式 PUT +
/// 进度计数流 + 取消令牌——桌面 / 调试路径(dart-define
/// `FORCE_DART_TRANSPORT=true` 强制启用);
/// - **[NativeTransport]**(Task 7 新增,Android;Task 8 起含 iOS):
/// MethodChannel `photolive/uploads` 注册原生后台上传——Android =
/// WorkManager 后台工作(expedited + dataSync 前台服务);iOS =
/// URLSession background(identifier com.photolive.photo_live.uploads,
/// 编译留 G3)。两平台协议逐字一致(Kotlin `UploadProtocol.kt` /
/// Swift `UploadBridge.swift`),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;
var lastReported = 0;
// 256KB 步长节流(与原生 UploadPutExecutor 一致);末块必报 sent==length。
const progressStep = 256 * 1024;
final progressStream = file.openRead().map((List<int> chunk) {
sent += chunk.length;
if (sent - lastReported >= progressStep || sent == length) {
lastReported = sent;
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();
}
}
}
// ── 原生(Android WorkManager / iOS URLSession background)实现 ─────────
/// 平台通道名(与 Android `UploadPlugin.kt` / iOS `UploadPlugin.swift`
/// 一致;原生常量见 UploadProtocol.kt 与 UploadBridge.swift——Task 8 起
/// iOS 为同一协议的 Swift 镜像,无协议分歧)。
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/iOS 原生传输:registerTask 入队原生后台上传(Android =
/// WorkManager;iOS = URLSession background),事件流回流终态/进度。
///
/// 生命周期安全:事件带单调 seq;终态事件在 Dart ack 前由原生侧缓存,
/// 重新 listen 时重放——进程重启/切后台恢复后不丢完成信号(iOS 侧缓存
/// 另落盘,系统后台拉起投递的终态在进程重启后同样重放)。进度事件不
/// 重放(瞬态)。
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/iOS 也强制 Dart dio
/// 路径(桥接层问题排查用)。
const bool kForceDartTransport = bool.fromEnvironment('FORCE_DART_TRANSPORT');
/// 传输选择纯函数(可测):Android 或 iOS 且未强制 Dart → 原生。
///
/// Task 8 起含 iOS:协议与 Android 逐字一致(同一 [NativeTransport]
/// 经同一通道名驱动 Swift 侧 UploadPlugin——iOS 编译/真机验证留 G3,
/// 代码路径在此之前即纳入原生选择)。
bool preferNativeTransport({
required bool isAndroid,
required bool isIOS,
bool forceDartTransport = kForceDartTransport,
}) =>
(isAndroid || isIOS) && !forceDartTransport;
/// 默认传输解析:Android/iOS → [NativeTransport](WorkManager /
/// URLSession background 后台续传);其余平台 / 强制覆盖 →
/// [DartHttpTransport]。
Transport defaultUploadTransport() {
if (preferNativeTransport(
isAndroid: !kIsWeb && Platform.isAndroid,
isIOS: !kIsWeb && Platform.isIOS,
)) {
return NativeTransport();
}
return DartHttpTransport();
}