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
@@ -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 连部署域名) -->
<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
android:label="photo_live"
android:name="${applicationName}"
@@ -32,6 +40,13 @@
<meta-data
android:name="flutterEmbedding"
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>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
@@ -1,5 +1,12 @@
package com.photolive.photo_live
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())
}
}