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
View File
@@ -40,6 +40,16 @@ kotlin {
} }
} }
dependencies {
// P3 Task 7 后台上传桥接:WorkManager(expedited + dataSync 前台服务)
// + OkHttp 直传 presign PUT(独立 4.12,与 Flutter 引擎无耦合)。
implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation("androidx.core:core-ktx:1.15.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation("junit:junit:4.13.2")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
}
flutter { flutter {
source = "../.." source = "../.."
} }
@@ -1,6 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- App 需要访问后端 API 与 MinIO 预签名上传(debug 连 10.0.2.2,release 连部署域名) --> <!-- App 需要访问后端 API 与 MinIO 预签名上传(debug 连 10.0.2.2,release 连部署域名) -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- P3 Task 7 后台上传桥接:WorkManager 长时工作的 dataSync 前台服务
(expedited 配额不足回退 + 传输期用户可见通知)。
POST_NOTIFICATIONS(API 33+)未授权时通知不显示但传输继续,
运行时请求接线在队列 UI 任务(Task 9)。 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application <application
android:label="photo_live" android:label="photo_live"
android:name="${applicationName}" android:name="${applicationName}"
@@ -32,6 +40,13 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- WorkManager 长时工作前台服务:targetSdk>=34 要求 manifest 声明
foregroundServiceType(库内声明无类型,此处 merge 补充 dataSync;
对应 FOREGROUND_SERVICE_DATA_SYNC 权限已声明)。 -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
@@ -1,5 +1,12 @@
package com.photolive.photo_live package com.photolive.photo_live
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// 上传桥接(P3 Task 7):MethodChannel 命令 + EventChannel 事件流。
UploadPlugin.register(applicationContext, flutterEngine)
}
}
@@ -0,0 +1,132 @@
package com.photolive.photo_live
/**
* 上传事件总线(worker → EventChannel,P3 计划 Task 7)。
*
* 生命周期安全:
* - 事件带单调 [seq];**终态**(completed / needsRenew / failed)在 Dart
* ack 前缓存(上限 [MAX_BUFFERED],超出丢最老),EventChannel 重新
* listen 时重放——App 进程重启 / 切后台恢复后不丢完成信号;
* - 进度事件瞬态,不缓存不重放;
* - 任务重新注册(renew 换 URL)或取消时 [purgeTask] 作废其缓存终态
* (新一轮事件由新 worker 产生,避免陈旧信号干扰)。
*
* 线程模型:emit 可来自 worker 的 IO 线程;sink 由插件层包装为主线程
* 投递(EventChannel 契约)。投递在锁内进行——sink 实现为非阻塞 post,
* 且锁内投递保证 seq 顺序。
*/
object UploadEventBus {
/** 缓存终态上限(防无限增长;超量丢最老——极端下 Dart 用 queryTask 校准)。 */
const val MAX_BUFFERED = 200
/** 事件出口(EventChannel sink 的主线程包装)。 */
private var sink: ((Map<String, Any?>) -> Unit)? = null
private val lock = Any()
private var seqGen = 0L
private val unacked = ArrayDeque<Map<String, Any?>>()
fun emitProgress(taskId: String, sent: Long, total: Long) {
emit(
mapOf(
"event" to UploadBridge.EVENT_PROGRESS,
"taskId" to taskId,
"sent" to sent,
"total" to total,
),
terminal = false,
)
}
fun emitCompleted(taskId: String, etag: String?) {
emit(
mapOf(
"event" to UploadBridge.EVENT_COMPLETED,
"taskId" to taskId,
"etag" to etag,
),
terminal = true,
)
}
fun emitNeedsRenew(taskId: String) {
emit(
mapOf(
"event" to UploadBridge.EVENT_NEEDS_RENEW,
"taskId" to taskId,
),
terminal = true,
)
}
fun emitFailed(taskId: String, code: String, status: Int? = null) {
emit(
mapOf(
"event" to UploadBridge.EVENT_FAILED,
"taskId" to taskId,
"code" to code,
"status" to status,
),
terminal = true,
)
}
private fun emit(event: Map<String, Any?>, terminal: Boolean) {
synchronized(lock) {
seqGen += 1
val stamped = HashMap(event)
stamped["seq"] = seqGen
if (terminal) {
unacked.addLast(stamped)
while (unacked.size > MAX_BUFFERED) {
unacked.removeFirst()
}
}
sink?.invoke(stamped)
}
}
/** EventChannel onListen:接管 sink 并重放缓存的终态事件。 */
fun attach(sink: (Map<String, Any?>) -> Unit) {
synchronized(lock) {
this.sink = sink
unacked.forEach(sink::invoke)
}
}
/** EventChannel onCancel(或新 listen 替换)。 */
fun detach() {
synchronized(lock) {
sink = null
}
}
/** Dart ack:丢弃 seq ≤ 给定值的缓存终态。 */
fun ack(seq: Long) {
synchronized(lock) {
unacked.removeAll { (it["seq"] as Long) <= seq }
}
}
/** 任务重新注册/取消:其缓存终态作废。 */
fun purgeTask(taskId: String) {
synchronized(lock) {
unacked.removeAll { it["taskId"] == taskId }
}
}
// ── 仅供测试 ──
fun resetForTest() {
synchronized(lock) {
sink = null
unacked.clear()
seqGen = 0
}
}
fun bufferedSnapshots(): List<Map<String, Any?>> = synchronized(lock) {
unacked.toList()
}
}
@@ -0,0 +1,151 @@
package com.photolive.photo_live
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.work.WorkInfo
import androidx.work.WorkManager
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* 上传桥接插件(P3 计划 Task 7):
* - MethodChannel `photolive/uploads`:registerTask(入队 WorkManager)/
* cancelTask / queryTask / ackEvents;
* - EventChannel `photolive/uploads/events`:[UploadEventBus] 终态/进度
* 事件(终态未 ack 缓存重放,生命周期安全见总线文档)。
*
* 所有事件投递统一切主线程(EventChannel 契约;worker 在 IO 线程 emit)。
*/
object UploadPlugin {
private val mainHandler = Handler(Looper.getMainLooper())
fun register(context: Context, engine: FlutterEngine) {
val messenger = engine.dartExecutor.binaryMessenger
MethodChannel(messenger, UploadBridge.METHOD_CHANNEL).setMethodCallHandler { call, result ->
handleMethodCall(context.applicationContext, call, result)
}
EventChannel(messenger, UploadBridge.EVENT_CHANNEL).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
UploadEventBus.attach { event ->
mainHandler.post { events.success(event) }
}
}
override fun onCancel(arguments: Any?) {
UploadEventBus.detach()
}
},
)
}
private fun handleMethodCall(
context: Context,
call: MethodCall,
result: MethodChannel.Result,
) {
when (call.method) {
UploadBridge.METHOD_REGISTER -> handleRegister(context, call, result)
UploadBridge.METHOD_CANCEL -> handleCancel(context, call, result)
UploadBridge.METHOD_QUERY -> handleQuery(context, call, result)
UploadBridge.METHOD_ACK -> handleAck(call, result)
else -> result.notImplemented()
}
}
private fun handleRegister(
context: Context,
call: MethodCall,
result: MethodChannel.Result,
) {
try {
@Suppress("UNCHECKED_CAST")
val raw = call.arguments as? Map<*, *>
?: throw IllegalArgumentException("arguments must be a Map")
val spec = parseRegisterSpec(raw)
// 重注册(renew 换 URL / 崩溃恢复重驱):作废旧缓存终态 +
// REPLACE 唯一工作(防同任务双跑)。
UploadEventBus.purgeTask(spec.taskId)
enqueueUpload(context, spec)
result.success(true)
} catch (e: IllegalArgumentException) {
result.error("bad_args", e.message, null)
} catch (e: Exception) {
result.error("enqueue_failed", e.message, null)
}
}
private fun handleCancel(
context: Context,
call: MethodCall,
result: MethodChannel.Result,
) {
val taskId = call.argument<String>("taskId")
if (taskId == null) {
result.error("bad_args", "missing 'taskId'", null)
return
}
val wm = WorkManager.getInstance(context)
wm.cancelUniqueWork(UploadBridge.workTag(taskId))
wm.cancelAllWorkByTag(UploadBridge.workTag(taskId))
UploadEventBus.purgeTask(taskId)
result.success(true)
}
private fun handleQuery(
context: Context,
call: MethodCall,
result: MethodChannel.Result,
) {
val taskId = call.argument<String>("taskId")
if (taskId == null) {
result.error("bad_args", "missing 'taskId'", null)
return
}
val wm = WorkManager.getInstance(context)
val future = wm.getWorkInfosForUniqueWork(UploadBridge.workTag(taskId))
future.addListener(
{
val payload = runCatching {
future.get()
.firstOrNull()
?.let { info -> workInfoPayload(info) }
}.getOrNull()
mainHandler.post { result.success(payload) }
},
Runnable::run,
)
}
private fun workInfoPayload(info: WorkInfo): Map<String, Any?> {
val progress = info.progress
val sent = progress.getLong(UploadWorker.KEY_PROGRESS_SENT, -1)
val total = progress.getLong(UploadWorker.KEY_PROGRESS_TOTAL, -1)
return mapOf(
"state" to info.state.name,
"attempt" to info.runAttemptCount,
if (sent >= 0 && total >= 0) {
"progress" to mapOf("sent" to sent, "total" to total)
} else {
"progress" to null
},
)
}
private fun handleAck(call: MethodCall, result: MethodChannel.Result) {
val seq = when (val v = call.argument<Any?>("seq")) {
is Int -> v.toLong()
is Long -> v
else -> {
result.error("bad_args", "missing or invalid 'seq'", null)
return
}
}
UploadEventBus.ack(seq)
result.success(true)
}
}
@@ -0,0 +1,102 @@
package com.photolive.photo_live
/**
* 上传桥接协议常量与 registerTask 参数解析(P3 计划 Task 7)。
*
* 通道与事件名与 Dart 侧 `lib/features/uploads/native_transport.dart`
* 的 [UploadBridgeChannels] 一致;失败码经 Dart 侧归一为
* TransportException(NETWORK/SOURCE_MISSING → 专用码,HTTP_4XX/
* SERVER_ERROR → http + status)。
*/
object UploadBridge {
/** 命令通道(registerTask / cancelTask / queryTask / ackEvents)。 */
const val METHOD_CHANNEL = "photolive/uploads"
/** 事件流(progress / completed / needsRenew / failed,带单调 seq)。 */
const val EVENT_CHANNEL = "photolive/uploads/events"
const val METHOD_REGISTER = "registerTask"
const val METHOD_CANCEL = "cancelTask"
const val METHOD_QUERY = "queryTask"
const val METHOD_ACK = "ackEvents"
const val EVENT_PROGRESS = "progress"
const val EVENT_COMPLETED = "completed"
const val EVENT_NEEDS_RENEW = "needsRenew"
const val EVENT_FAILED = "failed"
/** 网络不可达/超时(重试上限耗尽后上报)。 */
const val FAIL_NETWORK = "NETWORK"
/** 4xx 客户端错误(status 随事件携带;403 单独走 needsRenew)。 */
const val FAIL_HTTP_4XX = "HTTP_4XX"
/** 5xx 服务端错误(重试上限耗尽后上报)。 */
const val FAIL_SERVER_ERROR = "SERVER_ERROR"
/** 源文件缺失(受管副本被清理,不可恢复)。 */
const val FAIL_SOURCE_MISSING = "SOURCE_MISSING"
/**
* WorkManager unique name / tag 前缀:taskId → workId 映射由 WorkManager
* 自持(unique work + tag 双通道可查可取消)。
*/
const val WORK_TAG_PREFIX = "upload-task-"
fun workTag(taskId: String): String = WORK_TAG_PREFIX + taskId
}
/** registerTask 参数(Dart → 原生)。 */
data class UploadRegisterSpec(
val taskId: String,
val url: String,
val method: String,
val headers: Map<String, String>,
val filePath: String,
val uploadId: String?,
val attempt: Int,
)
/**
* registerTask 参数解析(纯函数,单测覆盖)。
*
* @throws IllegalArgumentException 形状不符(插件层转 bad_args 错误)
*/
fun parseRegisterSpec(args: Map<*, *>): UploadRegisterSpec {
fun str(key: String): String = when (val v = args[key]) {
is String -> v
null -> throw IllegalArgumentException("missing '$key'")
else -> throw IllegalArgumentException("'$key' must be a String")
}
val taskId = str("taskId")
val url = str("url")
val method = str("method").ifBlank { "PUT" }
val filePath = str("filePath")
val headers: Map<String, String> = when (val raw = args["headers"]) {
is Map<*, *> -> raw.entries.associate { (k, v) ->
require(k is String && v is String) {
"'headers' must be Map<String, String>"
}
k to v
}
null -> emptyMap()
else -> throw IllegalArgumentException("'headers' must be a Map")
}
val metadata = args["metadata"] as? Map<*, *>
val uploadId = metadata?.get("uploadId") as? String
val attempt: Int = when (val v = metadata?.get("attempt")) {
null -> 0
is Int -> v
is Long -> v.toInt()
else -> throw IllegalArgumentException("'metadata.attempt' must be an int")
}
return UploadRegisterSpec(
taskId = taskId,
url = url,
method = method,
headers = headers,
filePath = filePath,
uploadId = uploadId,
attempt = attempt,
)
}
@@ -0,0 +1,121 @@
package com.photolive.photo_live
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.time.Duration
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okio.BufferedSink
/**
* 单次 presign PUT 结局(worker → WorkManager Result / 上传事件的映射
* 依据;与 Dart 侧 TransportException 分类对齐)。
*/
sealed class PutOutcome {
/** 2xx:ETag 捕获自响应头(诊断用途;完成核验以服务端为准)。 */
data class Success(val etag: String?) : PutOutcome()
/** 403:presign 凭证过期(Dart renewUpload 后重注册新 URL)。 */
object RenewRequired : PutOutcome()
/** 其余 4xx:客户端错误,worker 不重试(Dart 侧分类处置)。 */
data class HttpFail(val status: Int) : PutOutcome()
/** 网络/5xx:可重试([serverError] 区分上报码)。 */
data class Retryable(val serverError: Boolean, val status: Int?) : PutOutcome()
/** 源文件缺失:不可恢复。 */
object SourceMissing : PutOutcome()
}
/**
* OkHttp presign PUT 执行器(纯 JVM 可测;worker 在 IO dispatcher 调用)。
*
* - 请求头:严格携带 Dart 传入的 credential.headers(Content-Type 经
* RequestBody.contentType 表达,Content-Length 由 okhttp 按声明长度
* 发送,二者不重复注入);
* - 进度:写 body 驱动 [onProgress](已发字节/总字节,IO 线程回调);
* - OkHttp 独立实例(4.12,与 Flutter 引擎无耦合;连接失败内置静默重试)。
*/
object UploadPutExecutor {
private val defaultClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(Duration.ofSeconds(15))
.writeTimeout(Duration.ofMinutes(3))
.readTimeout(Duration.ofSeconds(60))
.build()
fun execute(
url: String,
method: String,
headers: Map<String, String>,
filePath: String,
client: OkHttpClient = defaultClient,
onProgress: ((sent: Long, total: Long) -> Unit)? = null,
): PutOutcome {
val file = File(filePath)
if (!file.exists() || !file.isFile) {
return PutOutcome.SourceMissing
}
val total = file.length()
val contentType = headers.entries
.firstOrNull { it.key.equals("Content-Type", ignoreCase = true) }
?.value
val body = object : RequestBody() {
override fun contentType(): MediaType? =
contentType?.toMediaType()
override fun contentLength(): Long = total
override fun writeTo(sink: BufferedSink) {
file.inputStream().use { input ->
val buffer = ByteArray(64 * 1024)
var sent = 0L
while (true) {
val read = input.read(buffer)
if (read < 0) {
break
}
sink.write(buffer, 0, read)
sent += read
onProgress?.invoke(sent, total)
}
sink.flush()
}
}
}
val builder = Request.Builder()
.url(url)
.method(normalizeMethod(method), body)
for ((name, value) in headers) {
if (name.equals("Content-Type", ignoreCase = true) ||
name.equals("Content-Length", ignoreCase = true)
) {
continue
}
builder.header(name, value)
}
return try {
client.newCall(builder.build()).execute().use { resp ->
when {
resp.code in 200..299 -> PutOutcome.Success(resp.header("ETag"))
resp.code == 403 -> PutOutcome.RenewRequired
resp.code in 400..499 -> PutOutcome.HttpFail(resp.code)
else -> PutOutcome.Retryable(serverError = true, status = resp.code)
}
}
} catch (_: FileNotFoundException) {
PutOutcome.SourceMissing
} catch (_: IOException) {
// 连接失败/超时等网络层错误(含用户取消导致的流中断)。
PutOutcome.Retryable(serverError = false, status = null)
}
}
fun normalizeMethod(method: String): String =
if (method.isBlank()) "PUT" else method.uppercase()
}
@@ -0,0 +1,250 @@
package com.photolive.photo_live
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
* 后台上传 worker(P3 计划 Task 7;实现偏差登记:expedited + dataSync
* 前台服务,UIDT 主路径待 G2 真机评估后定——见技术方案 §6.3)。
*
* - **调度**:OneTimeWorkRequest,expedited(配额不足回退普通工作)+
* dataSync 前台服务(targetSdk≥34 需 manifest 类型声明与
* FOREGROUND_SERVICE_DATA_SYNC 权限,已加);网络约束 CONNECTED;
* - **重试**:网络/5xx → Result.retry(指数退避,初始 10s——WorkManager
* 下限钳位,5s 不可达),尝试上限 [MAX_WORK_ATTEMPTS](含首发)后上报
* failed{NETWORK|SERVER_ERROR};4xx(除 403)不重试直接 failed{HTTP_4XX};
* 403 → needsRenew(Dart renew 后携带新 URL 重新注册);
* - **事件**:progress(节流进通知 + setProgress 供 queryTask)与终态经
* [UploadEventBus](终态未 ack 缓存重放,见其文档);
* - **取消**:WorkManager 停止 worker(Dart 引擎先行置 cancelled 终态,
* 本侧无终态事件)。
*/
class UploadWorker(appContext: Context, params: WorkerParameters) :
CoroutineWorker(appContext, params) {
companion object {
const val KEY_TASK_ID = "taskId"
const val KEY_URL = "url"
const val KEY_METHOD = "method"
const val KEY_HEADERS = "headers"
const val KEY_FILE_PATH = "filePath"
const val KEY_UPLOAD_ID = "uploadId"
const val KEY_ATTEMPT = "attempt"
const val KEY_PROGRESS_SENT = "sent"
const val KEY_PROGRESS_TOTAL = "total"
const val NOTIFICATION_CHANNEL_ID = "uploads"
/** worker 级尝试上限(含首发;到达后 failed 事件 + Result.failure)。 */
const val MAX_WORK_ATTEMPTS = 5
/** 进度通知刷新最小间隔(ms)。 */
private const val NOTIFY_THROTTLE_MS = 500L
fun notificationId(taskId: String): Int = taskId.hashCode()
fun buildInput(spec: UploadRegisterSpec) = workDataOf(
KEY_TASK_ID to spec.taskId,
KEY_URL to spec.url,
KEY_METHOD to spec.method,
KEY_HEADERS to JSONObject(spec.headers).toString(),
KEY_FILE_PATH to spec.filePath,
KEY_UPLOAD_ID to (spec.uploadId ?: ""),
KEY_ATTEMPT to spec.attempt,
)
fun parseHeaders(json: String?): Map<String, String> {
if (json.isNullOrBlank()) {
return emptyMap()
}
val obj = JSONObject(json)
val out = LinkedHashMap<String, String>()
for (key in obj.keys()) {
out[key] = obj.getString(key)
}
return out
}
}
private var lastNotifyMs = 0L
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val taskId = inputData.getString(KEY_TASK_ID)
?: return@withContext Result.failure()
val url = inputData.getString(KEY_URL)
?: return@withContext Result.failure()
val filePath = inputData.getString(KEY_FILE_PATH)
?: return@withContext Result.failure()
val method = inputData.getString(KEY_METHOD) ?: "PUT"
val headers = parseHeaders(inputData.getString(KEY_HEADERS))
// 前台服务(dataSync):expedited 配额不足(<31)或长时间传输的
// 通知载体;后台启动受限等异常下降级为普通后台工作继续执行。
runCatching { setForeground(createForegroundInfo(taskId)) }
val outcome = UploadPutExecutor.execute(
url = url,
method = method,
headers = headers,
filePath = filePath,
) { sent, total ->
UploadEventBus.emitProgress(taskId, sent, total)
setProgressAsync(
workDataOf(
KEY_PROGRESS_SENT to sent,
KEY_PROGRESS_TOTAL to total,
),
)
updateProgressNotification(taskId, sent, total)
}
when (outcome) {
is PutOutcome.Success -> {
UploadEventBus.emitCompleted(taskId, outcome.etag)
Result.success()
}
PutOutcome.RenewRequired -> {
// 工作本身完结(success);Dart renew 后携带新 URL 重新注册。
UploadEventBus.emitNeedsRenew(taskId)
Result.success()
}
is PutOutcome.HttpFail -> {
UploadEventBus.emitFailed(taskId, UploadBridge.FAIL_HTTP_4XX, outcome.status)
Result.failure()
}
PutOutcome.SourceMissing -> {
UploadEventBus.emitFailed(taskId, UploadBridge.FAIL_SOURCE_MISSING, null)
Result.failure()
}
is PutOutcome.Retryable -> {
val attemptNo = runAttemptCount + 1
if (attemptNo >= MAX_WORK_ATTEMPTS) {
val code = if (outcome.serverError) {
UploadBridge.FAIL_SERVER_ERROR
} else {
UploadBridge.FAIL_NETWORK
}
UploadEventBus.emitFailed(taskId, code, outcome.status)
Result.failure()
} else {
Result.retry()
}
}
}
}
/** expedited 配额不足回退(<31)时 WorkManager 前台拉起所需。 */
override suspend fun getForegroundInfo(): ForegroundInfo =
createForegroundInfo(inputData.getString(KEY_TASK_ID) ?: "upload")
private fun createForegroundInfo(taskId: String): ForegroundInfo {
val context = applicationContext
ensureChannel(context)
val notification = buildNotification(context, taskId, sent = 0, total = 0, intermediate = true)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(
notificationId(taskId),
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
} else {
ForegroundInfo(notificationId(taskId), notification)
}
}
private fun updateProgressNotification(taskId: String, sent: Long, total: Long) {
val now = SystemClock.elapsedRealtime()
if (sent < total && now - lastNotifyMs < NOTIFY_THROTTLE_MS) {
return
}
lastNotifyMs = now
val context = applicationContext
val nm = context.getSystemService(NotificationManager::class.java) ?: return
runCatching {
nm.notify(notificationId(taskId), buildNotification(context, taskId, sent, total, intermediate = false))
}
}
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = context.getSystemService(NotificationManager::class.java) ?: return
nm.createNotificationChannel(
NotificationChannel(
NOTIFICATION_CHANNEL_ID,
context.getString(R.string.upload_notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
),
)
}
}
private fun buildNotification(
context: Context,
taskId: String,
sent: Long,
total: Long,
intermediate: Boolean,
): android.app.Notification {
val progress = if (intermediate || total <= 0) {
0
} else {
((sent * 100) / total).toInt().coerceIn(0, 100)
}
return NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_upload)
.setContentTitle(context.getString(R.string.upload_notification_title))
.setContentText(taskId)
.setProgress(100, progress, intermediate || total <= 0)
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
}
}
/**
* 调度辅助([UploadPlugin] 使用):expedited + dataSync + 网络约束 +
* 指数退避(初始 10s,WorkManager 下限;5s 目标不可达,偏差登记)。
*/
fun buildUploadWorkRequest(spec: UploadRegisterSpec): OneTimeWorkRequest =
OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData(UploadWorker.buildInput(spec))
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.addTag(UploadBridge.workTag(spec.taskId))
.build()
/** 入队(unique work:同 taskId 重注册 = REPLACE,天然 taskId→workId 映射)。 */
fun enqueueUpload(context: Context, spec: UploadRegisterSpec) {
WorkManager.getInstance(context).enqueueUniqueWork(
UploadBridge.workTag(spec.taskId),
ExistingWorkPolicy.REPLACE,
buildUploadWorkRequest(spec),
)
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 上传进度通知小图标(状态栏白色 alpha 形状)。 -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,3L6.5,8.5L8,10l3,-3V17h2V7l3,3l1.5,-1.5L12,3zM5,19h14v2H5V19z" />
</vector>
@@ -0,0 +1,4 @@
<resources>
<string name="upload_notification_channel_name">上传</string>
<string name="upload_notification_title">正在上传照片</string>
</resources>
@@ -0,0 +1,5 @@
<resources>
<!-- 上传前台服务通知(P3 Task 7;dataSync 类型,低重要度渠道)。 -->
<string name="upload_notification_channel_name">Uploads</string>
<string name="upload_notification_title">Uploading photo</string>
</resources>
@@ -0,0 +1,112 @@
package com.photolive.photo_live
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* 上传事件总线测试(P3 计划 Task 7):终态缓存重放(生命周期安全)、
* ack 回收、purgeTask、进度瞬态、缓冲上限。
*/
class UploadEventBusTest {
@Before
fun reset() {
UploadEventBus.resetForTest()
}
@Test
fun `terminal events replayed to late subscriber in seq order`() {
// 无 sink 时终态缓存;attach 后按 seq 顺序重放。
UploadEventBus.emitCompleted("t1", "\"etag-1\"")
UploadEventBus.emitFailed("t2", UploadBridge.FAIL_HTTP_4XX, 404)
val received = mutableListOf<Map<String, Any?>>()
UploadEventBus.attach { received.add(it) }
assertEquals(2, received.size)
assertEquals(1L, received[0]["seq"])
assertEquals("completed", received[0]["event"])
assertEquals("\"etag-1\"", received[0]["etag"])
assertEquals(2L, received[1]["seq"])
assertEquals("failed", received[1]["event"])
assertEquals("HTTP_4XX", received[1]["code"])
assertEquals(404, received[1]["status"])
}
@Test
fun `live events delivered to attached sink with seq`() {
val received = mutableListOf<Map<String, Any?>>()
UploadEventBus.attach { received.add(it) }
UploadEventBus.emitProgress("t1", 10, 100)
UploadEventBus.emitCompleted("t1", null)
assertEquals(2, received.size)
assertEquals("progress", received[0]["event"])
assertEquals(10L, received[0]["sent"])
assertEquals(100L, received[0]["total"])
assertEquals("completed", received[1]["event"])
assertTrue((received[1]["seq"] as Long) > (received[0]["seq"] as Long))
}
@Test
fun `progress events not buffered or replayed`() {
UploadEventBus.emitProgress("t1", 1, 100)
val received = mutableListOf<Map<String, Any?>>()
UploadEventBus.attach { received.add(it) }
assertTrue(received.isEmpty())
assertEquals(0, UploadEventBus.bufferedSnapshots().size)
}
@Test
fun `ack drops events up to seq and keeps later ones`() {
UploadEventBus.emitCompleted("t1", null) // seq 1
UploadEventBus.emitCompleted("t2", null) // seq 2
UploadEventBus.emitFailed("t3", UploadBridge.FAIL_NETWORK) // seq 3
UploadEventBus.ack(2)
val remaining = UploadEventBus.bufferedSnapshots()
assertEquals(1, remaining.size)
assertEquals("t3", remaining[0]["taskId"])
assertEquals(3L, remaining[0]["seq"])
}
@Test
fun `purgeTask removes only that task's buffered events`() {
UploadEventBus.emitCompleted("t1", null)
UploadEventBus.emitFailed("t2", UploadBridge.FAIL_NETWORK)
UploadEventBus.purgeTask("t1")
val remaining = UploadEventBus.bufferedSnapshots()
assertEquals(1, remaining.size)
assertEquals("t2", remaining[0]["taskId"])
}
@Test
fun `buffer capped at MAX_BUFFERED dropping oldest`() {
repeat(UploadEventBus.MAX_BUFFERED + 5) { n ->
UploadEventBus.emitCompleted("t$n", null)
}
val buffered = UploadEventBus.bufferedSnapshots()
assertEquals(UploadEventBus.MAX_BUFFERED, buffered.size)
// 最老的 5 个被丢弃。
assertEquals(6L, buffered.first()["seq"])
assertEquals((UploadEventBus.MAX_BUFFERED + 5).toLong(), buffered.last()["seq"])
}
@Test
fun `needsRenew is terminal and replayed`() {
UploadEventBus.emitNeedsRenew("t9")
val received = mutableListOf<Map<String, Any?>>()
UploadEventBus.attach { received.add(it) }
assertEquals(listOf("needsRenew"), received.map { it["event"] })
assertEquals(1, UploadEventBus.bufferedSnapshots().size)
}
@Test
fun `detach stops delivery but keeps buffer`() {
val received = mutableListOf<Map<String, Any?>>()
UploadEventBus.attach { received.add(it) }
UploadEventBus.emitCompleted("t1", null)
UploadEventBus.detach()
UploadEventBus.emitCompleted("t2", null)
assertEquals(1, received.size)
assertEquals(2, UploadEventBus.bufferedSnapshots().size)
}
}
@@ -0,0 +1,108 @@
package com.photolive.photo_live
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Test
/**
* registerTask 参数解析测试(P3 计划 Task 7)。
*/
class UploadProtocolTest {
private fun baseArgs(
metadata: Map<String, Any?>? = mapOf("uploadId" to "upl-1", "attempt" to 2),
headers: Any? = mapOf("Content-Type" to "image/jpeg"),
): Map<String, Any?> = mapOf(
"taskId" to "task-9",
"url" to "http://object.test.local/staging/a.jpg",
"method" to "PUT",
"headers" to headers,
"filePath" to "/data/uploads/a.jpg",
"metadata" to metadata,
)
@Test
fun `parses full args`() {
val spec = parseRegisterSpec(baseArgs())
assertEquals("task-9", spec.taskId)
assertEquals("http://object.test.local/staging/a.jpg", spec.url)
assertEquals("PUT", spec.method)
assertEquals(mapOf("Content-Type" to "image/jpeg"), spec.headers)
assertEquals("/data/uploads/a.jpg", spec.filePath)
assertEquals("upl-1", spec.uploadId)
assertEquals(2, spec.attempt)
}
@Test
fun `blank method defaults to PUT`() {
val spec = parseRegisterSpec(baseArgs().toMutableMap().apply { put("method", "") })
assertEquals("PUT", spec.method)
}
@Test
fun `null metadata defaults to attempt 0 and null uploadId`() {
val spec = parseRegisterSpec(baseArgs(metadata = null))
assertEquals(0, spec.attempt)
assertNull(spec.uploadId)
}
@Test
fun `missing headers defaults to empty map`() {
val spec = parseRegisterSpec(
baseArgs(headers = null).toMutableMap().apply { remove("headers") },
)
assertEquals(emptyMap<String, String>(), spec.headers)
}
@Test
fun `long attempt coerced to int`() {
val spec = parseRegisterSpec(
baseArgs(metadata = mapOf("uploadId" to null, "attempt" to 3L)),
)
assertEquals(3, spec.attempt)
}
@Test
fun `missing taskId throws`() {
assertThrows(IllegalArgumentException::class.java) {
parseRegisterSpec(baseArgs().toMutableMap().apply { remove("taskId") })
}
}
@Test
fun `non-string url throws`() {
assertThrows(IllegalArgumentException::class.java) {
parseRegisterSpec(baseArgs().toMutableMap().apply { put("url", 123) })
}
}
@Test
fun `non-string header value throws`() {
assertThrows(IllegalArgumentException::class.java) {
parseRegisterSpec(baseArgs(headers = mapOf("x" to 1)))
}
}
@Test
fun `non-map headers throws`() {
assertThrows(IllegalArgumentException::class.java) {
parseRegisterSpec(baseArgs(headers = "Content-Type: image/jpeg"))
}
}
@Test
fun `non-number attempt throws`() {
assertThrows(IllegalArgumentException::class.java) {
parseRegisterSpec(
baseArgs(metadata = mapOf("uploadId" to null, "attempt" to "two")),
)
}
}
@Test
fun `work tag prefixes task id`() {
assertEquals("upload-task-abc", UploadBridge.workTag("abc"))
assertEquals("upload-task-", UploadBridge.WORK_TAG_PREFIX)
}
}
@@ -0,0 +1,175 @@
package com.photolive.photo_live
import java.nio.file.Files
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* UploadPutExecutor 结局分类测试(P3 计划 Task 7;MockWebServer 驱动):
* 200→Success(etag)、500→Retryable(server)、403→RenewRequired、
* 4xx→HttpFail、连接失败→Retryable(network)、缺文件→SourceMissing、
* 请求头透传与进度回调。
*/
class UploadPutExecutorTest {
private lateinit var server: MockWebServer
private lateinit var file: java.io.File
private val client = OkHttpClient()
@Before
fun setUp() {
server = MockWebServer()
server.start()
val tmp = Files.createTempFile("upload-put", ".bin")
tmp.toFile().deleteOnExit()
file = tmp.toFile()
Files.write(tmp, ByteArray(3000) { (it % 251).toByte() })
}
@After
fun tearDown() {
server.shutdown()
}
private fun execute(status: Int = 200): PutOutcome =
UploadPutExecutor.execute(
url = server.url("/staging/x.jpg").toString(),
method = "PUT",
headers = mapOf("Content-Type" to "image/jpeg", "x-amz-a" to "sig-1"),
filePath = file.absolutePath,
client = client,
)
@Test
fun `2xx maps to Success with etag captured`() {
server.enqueue(
MockResponse().setResponseCode(200).addHeader("ETag", "\"etag-abc\""),
)
val outcome = execute()
assertTrue(outcome is PutOutcome.Success)
assertEquals("\"etag-abc\"", (outcome as PutOutcome.Success).etag)
val recorded = server.takeRequest()
assertEquals("PUT", recorded.method)
assertEquals("/staging/x.jpg", recorded.path)
assertEquals("image/jpeg", recorded.getHeader("Content-Type"))
assertEquals("sig-1", recorded.getHeader("x-amz-a"))
assertEquals(file.length(), recorded.bodySize)
// 请求体逐字节一致。
val sent = recorded.body.readByteArray()
assertEquals(file.length().toInt(), sent.size)
}
@Test
fun `403 maps to RenewRequired`() {
server.enqueue(MockResponse().setResponseCode(403))
assertTrue(execute() is PutOutcome.RenewRequired)
}
@Test
fun `404 maps to HttpFail with status`() {
server.enqueue(MockResponse().setResponseCode(404))
val outcome = execute()
assertTrue(outcome is PutOutcome.HttpFail)
assertEquals(404, (outcome as PutOutcome.HttpFail).status)
}
@Test
fun `400 maps to HttpFail`() {
server.enqueue(MockResponse().setResponseCode(400))
assertTrue(execute() is PutOutcome.HttpFail)
}
@Test
fun `500 maps to Retryable server error`() {
server.enqueue(MockResponse().setResponseCode(500))
val outcome = execute()
assertTrue(outcome is PutOutcome.Retryable)
outcome as PutOutcome.Retryable
assertTrue(outcome.serverError)
assertEquals(500, outcome.status)
}
@Test
fun `connection failure maps to Retryable network`() {
// 关停服务后直连 → IOException(连接层失败)→ network 重试分类。
val url = server.url("/staging/x.jpg").toString()
server.shutdown()
val outcome = UploadPutExecutor.execute(
url = url,
method = "PUT",
headers = emptyMap(),
filePath = file.absolutePath,
client = client,
)
assertTrue(outcome is PutOutcome.Retryable)
outcome as PutOutcome.Retryable
assertTrue(!outcome.serverError)
assertEquals(null, outcome.status)
}
@Test
fun `missing file maps to SourceMissing`() {
val outcome = UploadPutExecutor.execute(
url = server.url("/staging/x.jpg").toString(),
method = "PUT",
headers = emptyMap(),
filePath = java.io.File(file.parentFile, "no-such-file.bin").absolutePath,
client = client,
)
assertTrue(outcome is PutOutcome.SourceMissing)
assertEquals(0, server.requestCount)
}
@Test
fun `progress callback reports monotonic bytes up to total`() {
server.enqueue(MockResponse().setResponseCode(200))
val samples = mutableListOf<Pair<Long, Long>>()
UploadPutExecutor.execute(
url = server.url("/staging/x.jpg").toString(),
method = "PUT",
headers = mapOf("Content-Type" to "image/jpeg"),
filePath = file.absolutePath,
client = client,
) { sent, total -> samples.add(sent to total) }
assertTrue(samples.isNotEmpty())
assertEquals(file.length(), samples.last().first)
assertEquals(file.length(), samples.last().second)
samples.zipWithNext().forEach { (prev, next) ->
assertTrue(next.first >= prev.first)
}
samples.forEach { assertEquals(file.length(), it.second) }
}
@Test
fun `blank method defaults to PUT and uppercases`() {
assertEquals("PUT", UploadPutExecutor.normalizeMethod(""))
assertEquals("PUT", UploadPutExecutor.normalizeMethod("put"))
assertEquals("PUT", UploadPutExecutor.normalizeMethod("PUT"))
}
@Test
fun `content length header from credential is not duplicated`() {
// Content-Length 由 RequestBody.contentLength 声明,重复注入会破坏
// okhttp 请求;显式传入同名头应被忽略。
server.enqueue(MockResponse().setResponseCode(200))
UploadPutExecutor.execute(
url = server.url("/staging/x.jpg").toString(),
method = "PUT",
headers = mapOf(
"Content-Type" to "image/jpeg",
"Content-Length" to "999999",
),
filePath = file.absolutePath,
client = client,
)
val recorded = server.takeRequest()
assertEquals(file.length(), recorded.getHeader("Content-Length")?.toLong())
}
}
@@ -0,0 +1,421 @@
/// 上传传输抽象与双实现(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();
}
@@ -10,8 +10,8 @@
/// 介入,永不自动出队);网络恢复([onNetworkRestored])对 /// 介入,永不自动出队);网络恢复([onNetworkRestored])对
/// waitingNetwork 立即绕过退避; /// waitingNetwork 立即绕过退避;
/// - **传输三步**:initUpload(幂等,clientTaskId + sourceDeviceId)→ /// - **传输三步**:initUpload(幂等,clientTaskId + sourceDeviceId)→
/// presign PUT(dio 流式 + 进度回调 + 取消令牌;**裸 Dio**——不挂 /// presign PUT(**经 [Transport] 委托**——Android 走原生 WorkManager
/// ApiClient 拦截器,避免 Bearer 头污染 MinIO 预签名签名)→ /// 后台桥接(Task 7),其余平台裸 dio 流式;进度回调 + 取消令牌)→
/// completeUpload(幂等核验)→ completed; /// completeUpload(幂等核验)→ completed;
/// - **失败分类**([UploadEngineSourceMissing] 之外见 [_classifyFailure]): /// - **失败分类**([UploadEngineSourceMissing] 之外见 [_classifyFailure]):
/// | 错误 | 目标状态 | 自动重试 | /// | 错误 | 目标状态 | 自动重试 |
@@ -46,6 +46,7 @@ import '../../core/app_exception.dart';
import '../../core/device_id.dart'; import '../../core/device_id.dart';
import '../../core/error_codes.dart'; import '../../core/error_codes.dart';
import 'models.dart'; import 'models.dart';
import 'native_transport.dart';
import 'task_state.dart'; import 'task_state.dart';
import 'task_store.dart'; import 'task_store.dart';
@@ -75,27 +76,41 @@ const int kMaxCredentialRenewals = 2;
/// 传输引擎。 /// 传输引擎。
/// ///
/// 事件驱动 + 周期兜底;不 own isolate(传输多为 IO 等待,UI isolate /// 事件驱动 + 周期兜底;不 own isolate(传输多为 IO 等待,UI isolate
/// 足够;Android 后台延续由 Task 7 原生桥接承担)。 /// 足够;Android 后台延续由 [Transport] 原生实现承担——Task 7)。
class UploadEngine { class UploadEngine {
UploadEngine( UploadEngine(
this._store, this._store,
this._apiClient, this._apiClient,
this._deviceId, { this._deviceId, {
Dio? objectDio, Dio? objectDio,
Transport? transport,
DateTime Function()? now, DateTime Function()? now,
this.maxParallel = kMaxParallelUploads, this.maxParallel = kMaxParallelUploads,
this.keepCompletedDays = kKeepCompletedDays, this.keepCompletedDays = kKeepCompletedDays,
this.onProgress, this.onProgress,
this.onError, this.onError,
}) : _objectDio = objectDio ?? Dio(), }) : _objectDio = objectDio,
_transport = transport ??
(objectDio != null
? DartHttpTransport(dio: objectDio)
: defaultUploadTransport()),
_ownsTransport = transport == null,
_now = now ?? DateTime.now; _now = now ?? DateTime.now;
final TaskStore _store; final TaskStore _store;
final ApiClient _apiClient; final ApiClient _apiClient;
final DeviceId _deviceId; final DeviceId _deviceId;
/// presign PUT 专用裸 Dio(无鉴权拦截器,见库级注释)。 /// presign PUT 专用裸 Dio(无鉴权拦截器,见库级注释;[objectDio] 注入
final 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; final DateTime Function() _now;
/// 并行传输上限(运行时 <1 钳位为 1,见 [_effectiveParallel])。 /// 并行传输上限(运行时 <1 钳位为 1,见 [_effectiveParallel])。
@@ -294,9 +309,13 @@ class UploadEngine {
now: _now(), now: _now(),
); );
await _transferSteps(current, handle); await _transferSteps(current, handle);
} on TransportException catch (e) {
// 传输层失败(两实现归一;取消已在 _putObject 内短路,到达这里的
// 均为待分类失败)。
await _classifyFailure(task, e);
} on DioException catch (e) { } on DioException catch (e) {
// 仅来自 PUT(api 调用已归一 AppException);取消已在 // 防御性兜底(api 调用已归一 AppException;dio 异常仅可能自传输
// _transferSteps 内短路,到达这里的均为待分类失败。 // 层漏网),按原语义分类。
await _classifyFailure(task, e); await _classifyFailure(task, e);
} on AppException catch (e) { } on AppException catch (e) {
await _classifyFailure(task, e); await _classifyFailure(task, e);
@@ -333,20 +352,17 @@ class UploadEngine {
now: _now(), now: _now(),
); );
// b. presign PUT(流式 + 进度 + 取消令牌;403 → renew 换凭证重传)。 // b. presign PUT(transport 委托 + 进度 + 取消令牌;403 → renew 换
// 凭证重传——原生路径 re-register 新 URL,Dart 路径重发 dio PUT)。
var credential = init.credential; var credential = init.credential;
var renewals = 0; var renewals = 0;
while (true) { while (true) {
try { try {
await _putObject(task, credential, handle.cancelToken); await _putObject(task, credential, handle.cancelToken,
serverUploadId: init.uploadId);
break; break;
} on DioException catch (e) { } on TransportRenewRequiredException {
if (handle.cancelToken.isCancelled) { if (renewals < kMaxCredentialRenewals) {
// 用户取消:状态已置 cancelled(cancel() 内),终止协程。
return;
}
if (e.response?.statusCode == 403 &&
renewals < kMaxCredentialRenewals) {
renewals++; renewals++;
final renewed = await _apiClient.renewUpload(init.uploadId); final renewed = await _apiClient.renewUpload(init.uploadId);
credential = renewed.credential; credential = renewed.credential;
@@ -375,14 +391,15 @@ class UploadEngine {
); );
} }
/// presign PUT:裸 Dio 直传 credential.url,严格携带 credential.headers /// presign PUT:委托 [_transport](Android 原生 WorkManager 桥接 /
/// (+ Content-Length——流式发送 dio 无法自动推断,显式声明)。进度经 /// 其余 Dart dio 流式),严格携带 credential.headers。源文件存在性前置
/// 计数流上报(引擎自持,不依赖适配器对 onSendProgress 的实现)。 /// 检查保留在引擎侧(两路径共用的 sourceMissing 分类依据)。
Future<void> _putObject( Future<void> _putObject(
UploadTask task, UploadTask task,
UploadCredential credential, UploadCredential credential,
CancelToken cancelToken, CancelToken cancelToken, {
) async { String? serverUploadId,
}) async {
final file = File(task.sourceFilePath); final file = File(task.sourceFilePath);
if (!await file.exists()) { if (!await file.exists()) {
// 源文件缺失:不可恢复,分类按「不自动重试」处理。 // 源文件缺失:不可恢复,分类按「不自动重试」处理。
@@ -391,37 +408,43 @@ class UploadEngine {
message: 'source file missing', message: 'source file missing',
); );
} }
final length = await file.length(); try {
var sent = 0; await _transport.send(
final progressStream = taskId: task.id,
file.openRead().map((List<int> chunk) { url: credential.url,
sent += chunk.length;
onProgress?.call(task.id, sent, length);
return chunk;
});
await _objectDio.put<void>(
credential.url,
data: progressStream,
cancelToken: cancelToken,
options: Options(
method: credential.method, method: credential.method,
headers: { headers: credential.headers,
...credential.headers, filePath: task.sourceFilePath,
Headers.contentLengthHeader: length, metadata: <String, Object?>{
'uploadId': serverUploadId,
'attempt': task.attempt,
}, },
// 响应体非 JSON(MinIO XML/空),防 transformer 解析报错。 onProgress: (sent, total) => onProgress?.call(task.id, sent, total),
responseType: ResponseType.plain, cancelToken: cancelToken,
receiveDataWhenStatusError: true, );
), } 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,目标状态 /// 失败分类与状态落库(分类表见库级注释;attempt 每次 +1,目标状态
/// 自 uploading/uploadedConfirming 均合法迁移)。 /// 自 uploading/uploadedConfirming 均合法迁移)。
Future<void> _classifyFailure(UploadTask task, Object error) async { Future<void> _classifyFailure(UploadTask task, Object error) async {
final AppException app; final AppException app;
if (error is AppException) { if (error is AppException) {
app = error; app = error;
} else if (error is TransportException) {
app = _normalizeTransportFailure(error);
} else if (error is DioException) { } else if (error is DioException) {
app = ApiClient.mapDioException(error); app = ApiClient.mapDioException(error);
} else { } 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 已是终态)。 /// 分类落地(并发取消致 StateError 时静默——cancelled 已是终态)。
Future<void> _fail( Future<void> _fail(
UploadTask task, UploadTask task,
@@ -583,13 +664,17 @@ class UploadEngine {
return cleaned; return cleaned;
} }
/// 关闭(App 退出):停 timer、尽力断在途、释放 object Dio。 /// 关闭(App 退出):停 timer、尽力断在途、释放注入的 object Dio 与
/// 自建传输。
void dispose() { void dispose() {
stop(); stop();
for (final handle in _inFlight.values) { for (final handle in _inFlight.values) {
handle.cancelToken.cancel(); handle.cancelToken.cancel();
} }
_objectDio.close(); _objectDio?.close();
if (_ownsTransport) {
_transport.dispose();
}
} }
} }
@@ -0,0 +1,472 @@
/// 传输层测试(P3 计划 Task 7)。
///
/// 覆盖:
/// - NativeTransport:真实通道名 + 测试 messenger(TestDefaultBinaryMessenger
/// 的 mock method/stream handler)——registerTask 参数形状、事件映射
/// (progress/completed/needsRenew/failed×码表)、终态 ack、取消联动、
/// 注册失败归一、多任务路由;
/// - DartHttpTransport:dio 异常 → 传输异常归一(403→renew、取消、网络);
/// - 传输选择:Android 默认原生、FORCE_DART_TRANSPORT 覆盖、VM 缺省
/// DartHttp。
library;
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/features/uploads/native_transport.dart';
/// 捕获 Future 的异常对象(完成则失败测试)。
Future<Object> captureError(Future<Object> future) async {
try {
await future;
} on Object catch (e) {
return e;
}
throw StateError('预期失败但 Future 成功完成');
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
const methodChannel = MethodChannel(UploadBridgeChannels.methods);
const eventChannel = EventChannel(UploadBridgeChannels.events);
late NativeTransport transport;
late List<MethodCall> methodCalls;
MockStreamHandlerEventSink? sink;
setUp(() {
methodCalls = [];
sink = null;
messenger.setMockMethodCallHandler(methodChannel, (call) async {
methodCalls.add(call);
switch (call.method) {
case 'registerTask':
case 'cancelTask':
case 'ackEvents':
return true;
case 'queryTask':
return <String, Object?>{
'state': 'RUNNING',
'attempt': 1,
'progress': <String, Object?>{'sent': 5, 'total': 10},
};
default:
return null;
}
});
messenger.setMockStreamHandler(
eventChannel,
MockStreamHandler.inline(
onListen: (arguments, events) {
sink = events;
},
onCancel: (arguments) {},
),
);
transport = NativeTransport();
});
tearDown(() async {
transport.dispose();
messenger.setMockMethodCallHandler(methodChannel, null);
messenger.setMockStreamHandler(eventChannel, null);
});
/// 泵事件循环直到 registerTask 落账(事件由用例经 [sink] 注入)。
Future<void> untilRegistered() async {
for (var i = 0;
i < 100 && !methodCalls.any((c) => c.method == 'registerTask');
i++) {
await pumpEventQueue();
}
expect(methodCalls.any((c) => c.method == 'registerTask'), isTrue,
reason: 'registerTask 已发出');
}
void emitEvent(Map<String, Object?> event) => sink!.success(event);
group('NativeTransport 命令与事件映射', () {
test('registerTask 参数形状:taskId/url/method/headers/filePath/metadata',
() async {
final future = transport.send(
taskId: 'task-1',
url: 'http://obj.test/staging/a.jpg',
method: 'PUT',
headers: const {'Content-Type': 'image/jpeg'},
filePath: '/data/a.jpg',
metadata: const {'uploadId': 'upl-1', 'attempt': 2},
);
await untilRegistered();
emitEvent({
'event': 'completed',
'taskId': 'task-1',
'etag': '"e-1"',
'seq': 7,
});
final result = await future;
expect(result.etag, '"e-1"');
final call = methodCalls.firstWhere((c) => c.method == 'registerTask');
final args = Map<String, Object?>.from(call.arguments as Map);
expect(args['taskId'], 'task-1');
expect(args['url'], 'http://obj.test/staging/a.jpg');
expect(args['method'], 'PUT');
expect(args['headers'], {'Content-Type': 'image/jpeg'});
expect(args['filePath'], '/data/a.jpg');
expect(args['metadata'], {'uploadId': 'upl-1', 'attempt': 2});
// 终态事件消费后 ack 回传 seq。
await pumpEventQueue();
expect(
methodCalls.any(
(c) => c.method == 'ackEvents' && (c.arguments as Map)['seq'] == 7,
),
isTrue,
reason: '终态 ack 已回传',
);
});
test('progress 事件映射到 onProgress;completed 前不终结', () async {
final samples = <(int, int)>[];
final future = transport.send(
taskId: 'task-2',
url: 'http://obj.test/s',
method: 'PUT',
headers: const {},
filePath: '/data/b.jpg',
onProgress: (sent, total) => samples.add((sent, total)),
);
await untilRegistered();
emitEvent({
'event': 'progress',
'taskId': 'task-2',
'sent': 10,
'total': 100,
'seq': 1,
});
emitEvent({
'event': 'progress',
'taskId': 'task-2',
'sent': 90,
'total': 100,
'seq': 2,
});
await pumpEventQueue();
expect(samples, [(10, 100), (90, 100)]);
emitEvent({
'event': 'completed',
'taskId': 'task-2',
'etag': null,
'seq': 3,
});
expect((await future).etag, isNull);
});
test('needsRenew 事件 → TransportRenewRequiredException(403 语义)',
() async {
final future = transport.send(
taskId: 'task-3',
url: 'http://obj.test/s',
method: 'PUT',
headers: const {},
filePath: '/data/c.jpg',
);
await untilRegistered();
emitEvent({'event': 'needsRenew', 'taskId': 'task-3', 'seq': 1});
final err = await captureError(future);
expect(err, isA<TransportRenewRequiredException>());
expect((err as TransportException).statusCode, 403);
});
test('failed 事件码表:NETWORK / HTTP_4XX / SERVER_ERROR / SOURCE_MISSING',
() async {
Future<Object> drive(Map<String, Object?> failEvent) async {
final taskId = failEvent['taskId']! as String;
final future = transport.send(
taskId: taskId,
url: 'http://obj.test/s',
method: 'PUT',
headers: const {},
filePath: '/data/x.jpg',
);
await untilRegistered();
emitEvent(failEvent);
return captureError(future);
}
var err = await drive({
'event': 'failed',
'taskId': 't-net',
'code': 'NETWORK',
'seq': 1,
});
expect((err as TransportException).code, TransportErrorCodes.network);
expect(err.statusCode, isNull);
err = await drive({
'event': 'failed',
'taskId': 't-4xx',
'code': 'HTTP_4XX',
'status': 400,
'seq': 2,
});
expect((err as TransportException).code, TransportErrorCodes.http);
expect(err.statusCode, 400);
err = await drive({
'event': 'failed',
'taskId': 't-5xx',
'code': 'SERVER_ERROR',
'status': 502,
'seq': 3,
});
expect((err as TransportException).code, TransportErrorCodes.http);
expect(err.statusCode, 502);
err = await drive({
'event': 'failed',
'taskId': 't-src',
'code': 'SOURCE_MISSING',
'seq': 4,
});
expect(
(err as TransportException).code,
TransportErrorCodes.sourceMissing,
);
});
test('取消令牌:cancelTask 下发 + cancelled 异常收敛', () async {
final cancelToken = CancelToken();
final future = transport.send(
taskId: 'task-4',
url: 'http://obj.test/s',
method: 'PUT',
headers: const {},
filePath: '/data/d.jpg',
cancelToken: cancelToken,
);
await untilRegistered();
cancelToken.cancel('用户取消');
final err = await captureError(future);
expect((err as TransportException).code, TransportErrorCodes.cancelled);
await pumpEventQueue();
expect(
methodCalls.any(
(c) =>
c.method == 'cancelTask' &&
(c.arguments as Map)['taskId'] == 'task-4',
),
isTrue,
reason: 'cancelTask 已下发原生',
);
});
test('registerTask 通道异常 → channel 归一(注册失败不悬挂)', () async {
messenger.setMockMethodCallHandler(methodChannel, (call) async {
throw PlatformException(code: 'bad_args', message: 'missing url');
});
final err = await captureError(
transport.send(
taskId: 'task-5',
url: 'http://obj.test/s',
method: 'PUT',
headers: const {},
filePath: '/data/e.jpg',
),
);
expect((err as TransportException).code, TransportErrorCodes.channel);
});
test('并发多任务:事件按 taskId 路由互不串扰', () async {
final f1 = transport.send(
taskId: 'a',
url: 'http://obj.test/1',
method: 'PUT',
headers: const {},
filePath: '/1.jpg',
);
final f2 = transport.send(
taskId: 'b',
url: 'http://obj.test/2',
method: 'PUT',
headers: const {},
filePath: '/2.jpg',
);
await untilRegistered();
emitEvent({'event': 'completed', 'taskId': 'b', 'etag': '"b"', 'seq': 1});
emitEvent({
'event': 'failed',
'taskId': 'a',
'code': 'NETWORK',
'seq': 2,
});
expect((await f2).etag, '"b"');
final err = await captureError(f1);
expect((err as TransportException).code, TransportErrorCodes.network);
});
test('queryTask 透传原生 WorkInfo 概览', () async {
final info = await transport.queryTask('task-9');
expect(info!['state'], 'RUNNING');
expect((info['progress'] as Map)['sent'], 5);
});
});
group('DartHttpTransport dio 异常归一', () {
Dio stubDio(Future<Object> Function(RequestOptions) router) {
return Dio()..httpClientAdapter = _StubAdapter(router);
}
test('2xx 返回 TransportResult(etag 头捕获)', () async {
final dio = stubDio((o) async => const (200, '"etag-9"'));
final dartTransport = DartHttpTransport(dio: dio);
final file = _tempFile();
final result = await dartTransport.send(
taskId: 't',
url: 'http://obj.test/f.jpg',
method: 'PUT',
headers: const {'Content-Type': 'image/jpeg'},
filePath: file.path,
);
expect(result.etag, '"etag-9"');
dartTransport.dispose();
});
test('403 → TransportRenewRequiredException', () async {
final dio = stubDio((o) async => const (403, null));
final dartTransport = DartHttpTransport(dio: dio);
final err = await captureError(
dartTransport.send(
taskId: 't',
url: 'http://obj.test/f.jpg',
method: 'PUT',
headers: const {},
filePath: _tempFile().path,
),
);
expect(err, isA<TransportRenewRequiredException>());
dartTransport.dispose();
});
test('连接失败 → network;取消令牌 → cancelled', () async {
final file = _tempFile();
final dio = stubDio((o) async {
throw DioException(
requestOptions: o,
type: DioExceptionType.connectionError,
);
});
final dartTransport = DartHttpTransport(dio: dio);
final err = await captureError(
dartTransport.send(
taskId: 't',
url: 'http://obj.test/f.jpg',
method: 'PUT',
headers: const {},
filePath: file.path,
),
);
expect(
(err as TransportException).code,
TransportErrorCodes.network,
);
dartTransport.dispose();
final cancelDio = stubDio((o) async {
throw DioException(
requestOptions: o,
type: DioExceptionType.cancel,
);
});
final cancelTransport = DartHttpTransport(dio: cancelDio);
final token = CancelToken()..cancel();
final cerr = await captureError(
cancelTransport.send(
taskId: 't',
url: 'http://obj.test/f.jpg',
method: 'PUT',
headers: const {},
filePath: file.path,
cancelToken: token,
),
);
expect(
(cerr as TransportException).code,
TransportErrorCodes.cancelled,
);
cancelTransport.dispose();
});
});
group('传输选择(Android 原生 / FORCE_DART_TRANSPORT)', () {
test('选择矩阵:Android→native;强制 Dart / 非 Android→dart', () {
expect(preferNativeTransport(isAndroid: true), isTrue);
expect(preferNativeTransport(isAndroid: false), isFalse);
expect(
preferNativeTransport(isAndroid: true, forceDartTransport: true),
isFalse,
reason: 'FORCE_DART_TRANSPORT 覆盖',
);
expect(
preferNativeTransport(isAndroid: true, forceDartTransport: false),
isTrue,
);
});
test('缺省解析:VM 测试环境(非 Android)→ DartHttpTransport', () {
final resolved = defaultUploadTransport();
expect(resolved, isA<DartHttpTransport>());
resolved.dispose();
});
test('kForceDartTransport 常量缺省 false(未传 dart-define 时)', () {
expect(kForceDartTransport, isFalse);
});
});
}
File _tempFile() {
final dir = Directory.systemTemp.createTempSync('transport-');
return File('${dir.path}/f.bin')..writeAsBytesSync(List<int>.filled(16, 1));
}
/// 打桩适配器:router 返回 (status, etag) 记录或抛异常;请求流消费后按
/// 状态构造响应(headers 携带 ETag)。
class _StubAdapter implements HttpClientAdapter {
_StubAdapter(this.router);
final Future<Object> Function(RequestOptions options) router;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream? requestStream,
Future? cancelFuture,
) async {
if (requestStream != null) {
await requestStream.drain<Object?>();
}
final result = await router(options);
if (result is (int, String?)) {
final (status, etag) = result;
return ResponseBody.fromString(
'',
status,
headers: {
if (etag != null) 'etag': [etag],
},
);
}
throw result;
}
@override
void close({bool force = false}) {}
}
@@ -7,6 +7,7 @@
library; library;
import 'dart:async'; import 'dart:async';
import 'dart:collection';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; 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/device_id.dart';
import 'package:photo_live/core/error_codes.dart'; import 'package:photo_live/core/error_codes.dart';
import 'package:photo_live/core/session_store.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/preparer.dart';
import 'package:photo_live/features/uploads/task_state.dart'; import 'package:photo_live/features/uploads/task_state.dart';
import 'package:photo_live/features/uploads/task_store.dart'; import 'package:photo_live/features/uploads/task_store.dart';
@@ -81,12 +83,14 @@ class Harness {
int maxParallel = 2, int maxParallel = 2,
int keepCompletedDays = 14, int keepCompletedDays = 14,
void Function(String taskId, int sent, int total)? onProgress, void Function(String taskId, int sent, int total)? onProgress,
Transport? transport,
}) => }) =>
UploadEngine( UploadEngine(
store, store,
api, api,
deviceId, deviceId,
objectDio: objectDio, objectDio: transport == null ? objectDio : null,
transport: transport,
now: () => clock, now: () => clock,
maxParallel: maxParallel, maxParallel: maxParallel,
keepCompletedDays: keepCompletedDays, keepCompletedDays: keepCompletedDays,
@@ -246,6 +250,68 @@ class _FixedDeviceIdStore implements DeviceIdStore {
Future<void> write(String value) async {} 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() { void main() {
late Harness h; late Harness h;
late Directory tmp; late Directory tmp;
@@ -872,4 +938,163 @@ void main() {
// 文件检查在 PUT 阶段(init 已发生、无 PUT/complete)。 // 文件检查在 PUT 阶段(init 已发生、无 PUT/complete)。
expect(h.events, ['init:ct-q']); 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);
});
});
} }