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:
@@ -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,
|
||||
)
|
||||
}
|
||||
+121
@@ -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)
|
||||
}
|
||||
}
|
||||
+175
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user