Add Android animation library browsing
This commit is contained in:
+85
@@ -0,0 +1,85 @@
|
||||
package org.qimiaoscreen.core
|
||||
|
||||
import kotlinx.serialization.json.*
|
||||
|
||||
// One step performs at most one RPC. Switching targets never cancels a wire message.
|
||||
data class AnimationClip(val tier: String, val durations: List<Long>)
|
||||
class AnimationDownload(val item: JsonObject, private val cache: LibraryCache) {
|
||||
private var frames: List<JsonObject> = emptyList()
|
||||
private var infoOffset = 0
|
||||
private var infoComplete = false
|
||||
private var index = 0
|
||||
private var activeTier = "preview"
|
||||
val complete = mutableSetOf<String>()
|
||||
fun invalidate(tier: String) { complete.remove(tier); index = 0 }
|
||||
var progress = "开始下载当前图像"; private set
|
||||
suspend fun restore(device: String): AnimationClip? {
|
||||
val info = cache.readRecord(device, item, "animation-info") ?: return null
|
||||
val restored = runCatching { info.getValue("frames").jsonArray.map { it.jsonObject } }.getOrNull() ?: return null
|
||||
if (restored.isEmpty() || restored.mapIndexed { i, f -> f["index"]?.jsonPrimitive?.intOrNull == i && (f["duration_ms"]?.jsonPrimitive?.longOrNull ?: 0) > 0 }.any { !it }) return null
|
||||
frames = restored; infoComplete = true
|
||||
for (tier in listOf("full", "preview")) {
|
||||
if (frames.indices.all { cache.readImage(device, item, "animation-$tier-$it") != null }) {
|
||||
complete += tier
|
||||
if (tier == "full") complete += "preview"
|
||||
return AnimationClip(tier, frames.map { it.getValue("duration_ms").jsonPrimitive.long })
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
suspend fun step(device: String, tier: String, call: suspend (String, JsonObject) -> JsonObject): AnimationClip? {
|
||||
if (tier in complete) return null
|
||||
if (!infoComplete) {
|
||||
if (frames.isEmpty()) cache.readRecord(device, item, "animation-info")?.let {
|
||||
frames = it.getValue("frames").jsonArray.map { f -> f.jsonObject }
|
||||
require(frames.isNotEmpty() && frames.mapIndexed { i, f -> f.getValue("index").jsonPrimitive.int == i && f.getValue("duration_ms").jsonPrimitive.long > 0 }.all { v -> v })
|
||||
infoComplete = true
|
||||
}
|
||||
if (!infoComplete) {
|
||||
val result = call("library.animation.info", JsonObject(item + mapOf("offset" to JsonPrimitive(infoOffset), "limit" to JsonPrimitive(50))))
|
||||
require(result.getValue("revision") == item.getValue("revision"))
|
||||
val batch = result.getValue("frames").jsonArray.map { it.jsonObject }
|
||||
require(batch.isNotEmpty() && batch.mapIndexed { i, f -> f.getValue("index").jsonPrimitive.int == infoOffset + i && f.getValue("duration_ms").jsonPrimitive.long > 0 }.all { it })
|
||||
frames = frames + batch
|
||||
val next = result["next_offset"]?.jsonPrimitive?.intOrNull
|
||||
if (next != null) { require(next == frames.size); infoOffset = next; return null }
|
||||
require(frames.size == result.getValue("frame_count").jsonPrimitive.int)
|
||||
infoComplete = true
|
||||
cache.writeRecord(device, item, "animation-info", buildJsonObject { put("frames", JsonArray(frames)) })
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (activeTier != tier) { activeTier = tier; index = 0 }
|
||||
while (index < frames.size && cache.readImage(device, item, "animation-$tier-$index") != null) index++
|
||||
if (index == frames.size) {
|
||||
complete += tier
|
||||
return AnimationClip(tier, frames.map { it.getValue("duration_ms").jsonPrimitive.long })
|
||||
}
|
||||
val name = "animation-$tier-$index"
|
||||
val part = cache.readRecord(device, item, name)?.takeIf { "sha256" in it && "chunks" in it }
|
||||
val offset = part?.get("offset")?.jsonPrimitive?.intOrNull ?: 0
|
||||
val chunks = part?.get("chunks")?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList()
|
||||
val result = call("library.animation.chunk", JsonObject(item + mapOf("tier" to JsonPrimitive(tier), "frame_index" to JsonPrimitive(index), "offset" to JsonPrimitive(offset))))
|
||||
require(result.getValue("revision") == item.getValue("revision") && result.getValue("offset").jsonPrimitive.int == offset && result.getValue("frame_index").jsonPrimitive.int == index && result.getValue("tier").jsonPrimitive.content == tier)
|
||||
val hash = result.getValue("sha256").jsonPrimitive.content
|
||||
val total = result.getValue("total_bytes").jsonPrimitive.int
|
||||
require(total in 1..65536)
|
||||
require(part == null || part.getValue("sha256").jsonPrimitive.content == hash && part.getValue("total_bytes").jsonPrimitive.int == total)
|
||||
val all = chunks + result.getValue("data_base64").jsonPrimitive.content
|
||||
val next = result["next_offset"]?.jsonPrimitive?.intOrNull
|
||||
if (next == null) {
|
||||
try { cache.writeAnimationFrame(device, item, name, all, hash) }
|
||||
catch (e: Exception) { cache.writeRecord(device, item, name, buildJsonObject {}); throw e }
|
||||
// Complete PNG is now authoritative; remove resumable chunks by replacing with an empty record.
|
||||
cache.writeRecord(device, item, name, buildJsonObject {})
|
||||
index++
|
||||
} else {
|
||||
require(next > offset && next <= total && next - offset <= 4096)
|
||||
cache.writeRecord(device, item, name, buildJsonObject {
|
||||
put("offset", next); put("sha256", hash); put("total_bytes", total); put("chunks", JsonArray(all.map { JsonPrimitive(it) }))
|
||||
})
|
||||
}
|
||||
progress = "${if (tier == "preview") "低清动画" else "高清动画"} $index / ${frames.size} 帧"
|
||||
return null
|
||||
}
|
||||
}
|
||||
+100
-19
@@ -36,6 +36,8 @@ data class ControllerState(
|
||||
val settings: JsonObject? = null, val wifi: JsonObject? = null,
|
||||
val library: List<JsonObject> = emptyList(), val networks: List<JsonObject> = emptyList(),
|
||||
val previews: Map<String, String> = emptyMap(), val thumbnails: Map<String, String> = emptyMap(),
|
||||
val animationClips: Map<String, AnimationClip> = emptyMap(), val imagesForeground: Boolean = true,
|
||||
val viewer: JsonObject? = null, val viewerProgress: String? = null, val imageErrors: Map<String, String> = emptyMap(),
|
||||
val cacheBytes: Long = 0, val frame: String? = null,
|
||||
val frameCheckedAtMs: Long? = null, val defaultContent: JsonObject? = null,
|
||||
)
|
||||
@@ -79,6 +81,12 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
private val commands = Mutex()
|
||||
private var scanJob: Job? = null
|
||||
private var pollJob: Job? = null
|
||||
private var imageJob: Job? = null
|
||||
private var viewerRelease: Job? = null
|
||||
private var priorityItem: JsonObject? = null
|
||||
private val animations = linkedMapOf<String, AnimationDownload>()
|
||||
private val imageFailures = mutableSetOf<String>()
|
||||
private var pollDue = false
|
||||
private var releaseJob: Job? = null
|
||||
private var foreground = true
|
||||
private var frameRevision: String? = null
|
||||
@@ -169,7 +177,7 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
}
|
||||
fun disconnect() {
|
||||
connectionGeneration++; scanGeneration++
|
||||
connectionJob?.cancel(); commandJob?.cancel(); scanJob?.cancel(); pollJob?.cancel(); releaseJob?.cancel()
|
||||
connectionJob?.cancel(); commandJob?.cancel(); scanJob?.cancel(); pollJob?.cancel(); imageJob?.cancel(); releaseJob?.cancel()
|
||||
platform.stopScan(); rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; requestedDeviceId = null
|
||||
resetLibraryQueue()
|
||||
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered) }
|
||||
@@ -181,6 +189,7 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
}
|
||||
fun foreground(value: Boolean) {
|
||||
foreground = value
|
||||
change { it.copy(imagesForeground = value) }
|
||||
rpc?.foreground(value)
|
||||
releaseJob?.cancel()
|
||||
if (value) {
|
||||
@@ -200,7 +209,9 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
}
|
||||
private fun resetLibraryQueue() {
|
||||
pendingPreviews.clear(); pendingThumbnails.clear()
|
||||
hydrating.clear()
|
||||
hydrating.clear(); animations.clear(); imageFailures.clear(); priorityItem = null
|
||||
viewerRelease?.cancel()
|
||||
change { it.copy(animationClips = emptyMap(), viewer = null, viewerProgress = null, imageErrors = emptyMap()) }
|
||||
cacheGeneration++
|
||||
libraryRevision = null; libraryLoaded = false; lastLibraryCheckMs = 0L
|
||||
}
|
||||
@@ -284,6 +295,12 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
if (item["playable"]?.jsonPrimitive?.booleanOrNull == false) continue
|
||||
val key = thumbnailKey(item)
|
||||
val full = cachedImage(deviceId, item, "full")
|
||||
if (supports("library_animation") && item["type"]?.jsonPrimitive?.content == "animation") {
|
||||
platform.libraryCache?.let { cache ->
|
||||
val task = animations.getOrPut(key) { AnimationDownload(item, cache) }
|
||||
task.restore(deviceId)?.let { clip -> change { it.copy(animationClips = it.animationClips + (key to clip)) } }
|
||||
}
|
||||
}
|
||||
if (full != null) { rememberImage(key, full, true); continue }
|
||||
if (supports("library_progressive")) {
|
||||
val preview = cachedImage(deviceId, item, "preview")
|
||||
@@ -303,6 +320,9 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
var items = state.value.library
|
||||
if (changed) {
|
||||
cacheGeneration++
|
||||
animations.clear(); imageFailures.clear()
|
||||
change { it.copy(animationClips = emptyMap(), viewer = null, viewerProgress = null, imageErrors = emptyMap()) }
|
||||
priorityItem = null
|
||||
val cached = if (expectedRevision != null) platform.libraryCache?.loadCatalog(deviceId) else null
|
||||
if (cached != null && cached.revision == expectedRevision &&
|
||||
cached.items.size == summary.getValue("item_count").jsonPrimitive.int) {
|
||||
@@ -337,33 +357,20 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
catch (_: Exception) { /* Storage reporting is optional. */ }
|
||||
}
|
||||
private fun startPolling() {
|
||||
startImages()
|
||||
pollJob?.cancel()
|
||||
pollJob = scope.launch {
|
||||
while (isActive && rpc != null) {
|
||||
delay(2_000)
|
||||
if (state.value.busy || !commands.tryLock()) continue
|
||||
pollDue = true
|
||||
if (state.value.busy) continue
|
||||
commands.lock()
|
||||
try {
|
||||
if (!foreground) { call("session.ping"); continue }
|
||||
refreshStatus()
|
||||
if (state.value.page in 1..2 && supports("frame")) refreshFrame()
|
||||
if (state.value.page == 2 && supports("library") &&
|
||||
Clock.System.now().toEpochMilliseconds() - lastLibraryCheckMs >= 10_000) refreshLibrary(false)
|
||||
if (state.value.page == 2 && !state.value.busy && (pendingPreviews.isNotEmpty() || pendingThumbnails.isNotEmpty())) {
|
||||
val preview = pendingPreviews.isNotEmpty()
|
||||
val entry = if (preview) pendingPreviews.entries.first() else pendingThumbnails.entries.first()
|
||||
val key = entry.key
|
||||
val item = entry.value
|
||||
if (preview) pendingPreviews.remove(key) else pendingThumbnails.remove(key)
|
||||
val image = call(if (preview) "library.preview" else "library.thumbnail", item).getValue("data_base64").jsonPrimitive.content
|
||||
currentCoroutineContext().ensureActive()
|
||||
val deviceId = state.value.identity?.get("device_id")?.jsonPrimitive?.content
|
||||
if (deviceId != null) {
|
||||
try { platform.libraryCache?.writeImage(deviceId, item, if (preview) "preview" else "full", image) }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (_: Exception) { /* Disk cache may be evicted or unavailable. */ }
|
||||
rememberImage(key, image, !preview)
|
||||
}
|
||||
}
|
||||
} catch (e: DeviceFailure) {
|
||||
// A stale thumbnail or other business rejection does not
|
||||
// invalidate the authenticated transport.
|
||||
@@ -373,10 +380,84 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
|
||||
if (e !is CancellationException) {
|
||||
connectionLost(e.message ?: "连接已断开,请重试")
|
||||
}
|
||||
} finally { pollDue = false; commands.unlock() }
|
||||
}
|
||||
}
|
||||
}
|
||||
fun viewImage(item: JsonObject? = null) {
|
||||
viewerRelease?.cancel()
|
||||
val value = item ?: buildJsonObject { put("type", "frame"); put("name", "设备画面") }
|
||||
priorityItem = item
|
||||
if (item != null) imageFailures.remove(thumbnailKey(item))
|
||||
val missing = item != null && (animations[thumbnailKey(item)]?.let { "full" !in it.complete } ?: (thumbnailKey(item) !in state.value.thumbnails))
|
||||
change { it.copy(viewer = value, viewerProgress = if (missing) "开始下载当前图像" else null, imageErrors = if (item == null) it.imageErrors else it.imageErrors - thumbnailKey(item)) }
|
||||
}
|
||||
fun closeImage() {
|
||||
change { it.copy(viewer = null, viewerProgress = null) }
|
||||
viewerRelease?.cancel()
|
||||
viewerRelease = scope.launch { delay(300); priorityItem = null }
|
||||
}
|
||||
suspend fun animationFrame(item: JsonObject, clip: AnimationClip, index: Int): String? {
|
||||
val device = state.value.identity?.get("device_id")?.jsonPrimitive?.content ?: return null
|
||||
val image = cachedImage(device, item, "animation-${clip.tier}-$index")
|
||||
if (image == null) {
|
||||
val key = thumbnailKey(item)
|
||||
animations[key]?.invalidate(clip.tier)
|
||||
change { it.copy(animationClips = it.animationClips - key) }
|
||||
}
|
||||
return image
|
||||
}
|
||||
private fun startImages() {
|
||||
imageJob?.cancel()
|
||||
imageJob = scope.launch {
|
||||
while (isActive && rpc != null) {
|
||||
delay(20)
|
||||
if (!foreground || state.value.page != 2 || state.value.busy || pollDue || !commands.tryLock()) continue
|
||||
try { downloadImageStep() }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (e: DeviceTransportFailure) { connectionLost(e.message ?: "图片传输中断") }
|
||||
catch (e: Exception) {
|
||||
val key = downloadingKey
|
||||
if (key != null) { imageFailures += key; change { it.copy(imageErrors = it.imageErrors + (key to (e.message ?: "图片下载失败"))) } }
|
||||
if (e is DeviceFailure && e.code in listOf("CONFLICT", "NOT_FOUND")) lastLibraryCheckMs = 0L
|
||||
} finally { commands.unlock() }
|
||||
}
|
||||
}
|
||||
}
|
||||
private var downloadingKey: String? = null
|
||||
private suspend fun downloadImageStep() {
|
||||
val device = state.value.identity?.get("device_id")?.jsonPrimitive?.content ?: return
|
||||
val focus = priorityItem?.takeIf {
|
||||
val key = thumbnailKey(it)
|
||||
key !in imageFailures && (animations[key]?.let { task -> "full" !in task.complete } ?: (key !in state.value.thumbnails))
|
||||
}
|
||||
val low = animations.entries.firstOrNull { "preview" !in it.value.complete && it.key !in imageFailures }
|
||||
val high = animations.entries.firstOrNull { "full" !in it.value.complete && it.key !in imageFailures }
|
||||
val first = pendingPreviews.entries.firstOrNull { it.key !in imageFailures }
|
||||
val full = pendingThumbnails.entries.firstOrNull { it.key !in imageFailures && it.key !in animations }
|
||||
val item = focus ?: first?.value ?: low?.value?.item ?: full?.value ?: high?.value?.item ?: return
|
||||
val key = thumbnailKey(item); downloadingKey = key
|
||||
val generation = connectionGeneration; val epoch = cacheGeneration
|
||||
val task = animations[key]
|
||||
if (task != null && (focus != null || first == null)) {
|
||||
val tier = if ("preview" !in task.complete) "preview" else "full"
|
||||
if (tier !in task.complete) {
|
||||
val clip = task.step(device, tier, ::call)
|
||||
if (generation != connectionGeneration || epoch != cacheGeneration) return
|
||||
if (clip != null) change { it.copy(animationClips = it.animationClips + (key to clip)) }
|
||||
}
|
||||
if (focus != null && state.value.viewer?.takeIf { it["type"]?.jsonPrimitive?.content != "frame" }?.let { thumbnailKey(it) } == key) change { it.copy(viewerProgress = if ("full" in task.complete) null else task.progress) }
|
||||
return
|
||||
}
|
||||
val preview = focus == null && first != null
|
||||
if (!preview && key in state.value.thumbnails) { if (focus != null && state.value.viewer?.takeIf { it["type"]?.jsonPrimitive?.content != "frame" }?.let { thumbnailKey(it) } == key) change { it.copy(viewerProgress = null) }; return }
|
||||
val image = call(if (preview) "library.preview" else "library.thumbnail", item).getValue("data_base64").jsonPrimitive.content
|
||||
if (generation != connectionGeneration || epoch != cacheGeneration) return
|
||||
platform.libraryCache?.writeImage(device, item, if (preview) "preview" else "full", image)
|
||||
rememberImage(key, image, !preview)
|
||||
if (preview) pendingPreviews.remove(key) else pendingThumbnails.remove(key)
|
||||
if (focus != null && state.value.viewer?.takeIf { it["type"]?.jsonPrimitive?.content != "frame" }?.let { thumbnailKey(it) } == key) change { it.copy(viewerProgress = null) }
|
||||
}
|
||||
private suspend fun refreshFrame() {
|
||||
val result = call("frame.get", buildJsonObject { frameRevision?.let { put("known_revision", it) } })
|
||||
frameRevision = result["frame_revision"]?.jsonPrimitive?.content
|
||||
|
||||
+3
@@ -10,6 +10,9 @@ interface LibraryCache {
|
||||
suspend fun saveCatalog(deviceId: String, catalog: CachedLibrary)
|
||||
suspend fun readImage(deviceId: String, item: JsonObject, tier: String): String?
|
||||
suspend fun writeImage(deviceId: String, item: JsonObject, tier: String, base64: String)
|
||||
suspend fun readRecord(deviceId: String, item: JsonObject, name: String): JsonObject? = null
|
||||
suspend fun writeRecord(deviceId: String, item: JsonObject, name: String, value: JsonObject) {}
|
||||
suspend fun writeAnimationFrame(deviceId: String, item: JsonObject, tier: String, chunks: List<String>, sha256: String) { error("动画缓存不可用") }
|
||||
suspend fun retain(deviceId: String, items: List<JsonObject>)
|
||||
suspend fun sizeBytes(): Long
|
||||
suspend fun clear()
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ class HybridRpc(
|
||||
private var closed = false
|
||||
private var enabled = true
|
||||
private val readOnly = setOf("status.get", "storage.get", "settings.get", "library.list", "library.summary",
|
||||
"library.preview", "library.thumbnail", "content.default.get", "frame.get")
|
||||
"library.preview", "library.thumbnail", "library.animation.info", "library.animation.chunk", "content.default.get", "frame.get")
|
||||
init {
|
||||
scope.launch {
|
||||
try { while (isActive) { delay(2_000); ble.call("session.ping") } }
|
||||
|
||||
+37
@@ -10,6 +10,10 @@ import kotlin.test.*
|
||||
private class SimulatedPlatform : MobilePlatform {
|
||||
override val model = "simulated-test-phone"
|
||||
var progressive = false
|
||||
var blockFirstImage = false
|
||||
val imageEntered = CompletableDeferred<Unit>()
|
||||
val releaseImage = CompletableDeferred<Unit>()
|
||||
val imageRequests = mutableListOf<String>()
|
||||
var libraryRevision = "library-v1"
|
||||
var itemRevision = "item-v1"
|
||||
val called = mutableListOf<String>()
|
||||
@@ -63,6 +67,10 @@ private class SimulatedLink(private val deviceId: String, private val signal: De
|
||||
val request = Protocol.json.parseToJsonElement(payload.decodeToString()).jsonObject
|
||||
val method = request.getValue("method").jsonPrimitive.content
|
||||
platform.called += method
|
||||
if (method in listOf("library.preview", "library.thumbnail")) {
|
||||
platform.imageRequests += method + ":" + request.getValue("params").jsonObject.getValue("id").jsonPrimitive.content
|
||||
if (platform.blockFirstImage) { platform.blockFirstImage = false; platform.imageEntered.complete(Unit); platform.releaseImage.await() }
|
||||
}
|
||||
if (method == failure) throw DeviceTransportFailure("simulated connection loss")
|
||||
if (method == "status.get" && blockStorage) { storageEntered.complete(Unit); awaitCancellation() }
|
||||
val result = when (method) {
|
||||
@@ -273,6 +281,35 @@ class ControllerRecoveryTest {
|
||||
} finally { controller.disconnect(); scope.cancel() }
|
||||
}
|
||||
|
||||
@Test fun viewerPreemptsAfterMessageBoundaryAndBackgroundPauses() = runBlocking {
|
||||
val platform = SimulatedPlatform().apply { progressive = true; blockFirstImage = true }
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val controller = Controller(platform, scope)
|
||||
try {
|
||||
controller.page(2); controller.connect(device)
|
||||
withTimeout(3_000) { platform.imageEntered.await() }
|
||||
val second = controller.state.value.library[1]
|
||||
controller.viewImage(second)
|
||||
assertEquals("开始下载当前图像", controller.state.value.viewerProgress)
|
||||
assertEquals(listOf("library.preview:item-1"), platform.imageRequests)
|
||||
platform.releaseImage.complete(Unit)
|
||||
withTimeout(3_000) { controller.state.first { it.thumbnails.containsKey(thumbnailKey(second)) } }
|
||||
assertEquals("library.thumbnail:item-2", platform.imageRequests[1])
|
||||
assertNull(controller.state.value.viewerProgress)
|
||||
controller.closeImage(); controller.foreground(false)
|
||||
delay(100)
|
||||
val stopped = platform.imageRequests.size
|
||||
delay(100)
|
||||
assertEquals(stopped, platform.imageRequests.size)
|
||||
assertNull(controller.state.value.viewer)
|
||||
controller.foreground(true)
|
||||
withTimeout(3_000) { controller.state.first { it.thumbnails.size == 2 } }
|
||||
controller.viewImage(second)
|
||||
assertNull(controller.state.value.viewerProgress)
|
||||
controller.viewImage()
|
||||
assertEquals("frame", controller.state.value.viewer!!.getValue("type").jsonPrimitive.content)
|
||||
} finally { controller.disconnect(); scope.cancel() }
|
||||
}
|
||||
@Test fun progressiveLibraryUsesTwoPassesAndReusesVerifiedCache() = runBlocking {
|
||||
val platform = SimulatedPlatform().apply { progressive = true }
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package org.qimiaoscreen.core
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.*
|
||||
import kotlin.test.*
|
||||
import java.util.Base64
|
||||
import java.security.MessageDigest
|
||||
|
||||
class AnimationDownloadTest {
|
||||
private val item = buildJsonObject { put("type", "animation"); put("id", "a"); put("revision", "r") }
|
||||
private class Cache : LibraryCache {
|
||||
val images = mutableMapOf<String, String>(); val records = mutableMapOf<String, JsonObject>()
|
||||
override suspend fun loadCatalog(deviceId: String): CachedLibrary? = null
|
||||
override suspend fun saveCatalog(deviceId: String, catalog: CachedLibrary) {}
|
||||
override suspend fun readImage(deviceId: String, item: JsonObject, tier: String) = images[tier]
|
||||
override suspend fun writeImage(deviceId: String, item: JsonObject, tier: String, base64: String) { images[tier] = base64 }
|
||||
override suspend fun readRecord(deviceId: String, item: JsonObject, name: String) = records[name]
|
||||
override suspend fun writeRecord(deviceId: String, item: JsonObject, name: String, value: JsonObject) { records[name] = value }
|
||||
override suspend fun writeAnimationFrame(deviceId: String, item: JsonObject, tier: String, chunks: List<String>, sha256: String) {
|
||||
val bytes = chunks.flatMap { Base64.getDecoder().decode(it).toList() }.toByteArray()
|
||||
require(MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } == sha256)
|
||||
images[tier] = Base64.getEncoder().encodeToString(bytes)
|
||||
}
|
||||
override suspend fun retain(deviceId: String, items: List<JsonObject>) {}
|
||||
override suspend fun sizeBytes() = 0L
|
||||
override suspend fun clear() { images.clear(); records.clear() }
|
||||
}
|
||||
@Test fun resumeAndUpgradePreserveTiming() = runBlocking {
|
||||
val cache = Cache(); val offsets = mutableListOf<Int>()
|
||||
val bytes = ByteArray(5000) { (it % 251).toByte() }
|
||||
val hash = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
val rpc: suspend (String, JsonObject) -> JsonObject = { method, params ->
|
||||
if (method.endsWith(".info")) buildJsonObject {
|
||||
put("revision", "r"); put("frame_count", 2); put("next_offset", JsonNull)
|
||||
put("frames", JsonArray(listOf(75L, 250L).mapIndexed { i, duration -> buildJsonObject { put("index", i); put("duration_ms", duration) } }))
|
||||
} else {
|
||||
val offset = params.getValue("offset").jsonPrimitive.int; offsets += offset
|
||||
val end = minOf(offset + 4096, bytes.size)
|
||||
buildJsonObject {
|
||||
put("revision", "r"); put("tier", params.getValue("tier")); put("frame_index", params.getValue("frame_index")); put("offset", offset)
|
||||
put("total_bytes", bytes.size); put("sha256", hash); put("next_offset", if (end == bytes.size) JsonNull else JsonPrimitive(end))
|
||||
put("data_base64", Base64.getEncoder().encodeToString(bytes.copyOfRange(offset, end)))
|
||||
}
|
||||
}
|
||||
}
|
||||
val first = AnimationDownload(item, cache)
|
||||
assertNull(first.step("device", "preview", rpc))
|
||||
assertNull(first.step("device", "preview", rpc)) // first chunk persisted
|
||||
val resumed = AnimationDownload(item, cache)
|
||||
var clip: AnimationClip? = null
|
||||
repeat(5) { resumed.step("device", "preview", rpc)?.let { clip = it } }
|
||||
assertEquals(listOf(0, 4096, 0, 4096), offsets)
|
||||
assertEquals(listOf(75L, 250L), clip!!.durations)
|
||||
assertEquals("preview", clip!!.tier)
|
||||
assertEquals(2, cache.images.size)
|
||||
repeat(5) { resumed.step("device", "full", rpc)?.let { clip = it } }
|
||||
assertEquals("full", clip!!.tier)
|
||||
assertEquals(setOf("preview", "full"), resumed.complete)
|
||||
assertEquals(4, cache.images.size)
|
||||
}
|
||||
@Test fun mismatchedRevisionDoesNotCommit() = runBlocking {
|
||||
val cache = Cache()
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
AnimationDownload(item, cache).step("device", "preview") { _, _ -> buildJsonObject { put("revision", "other") } }
|
||||
}
|
||||
assertTrue(cache.images.isEmpty()); assertTrue(cache.records.isEmpty())
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -62,7 +62,7 @@ private class RpcFixture : WifiPlatform {
|
||||
val r = Protocol.json.parseToJsonElement(fragment.decodeToString()).jsonObject
|
||||
val method = r.getValue("method").jsonPrimitive.content
|
||||
if (method == "content.play") { wifiWrites++; if (failWrite) throw DeviceTransportFailure("lost reply") }
|
||||
if (method == "status.get") { wifiReads++; if (failRead) throw DeviceTransportFailure("lost read") }
|
||||
if (method == "status.get" || method.startsWith("library.animation.")) { wifiReads++; if (failRead) throw DeviceTransportFailure("lost read") }
|
||||
replies.send(buildJsonObject { put("id", r.getValue("id")); put("ok", true); put("result", buildJsonObject { put("wifi", true) }) }.toString().encodeToByteArray())
|
||||
}
|
||||
override suspend fun receive() = replies.receive()
|
||||
@@ -72,6 +72,18 @@ private class RpcFixture : WifiPlatform {
|
||||
|
||||
class HybridRpcTest {
|
||||
private suspend fun awaitTrue(predicate: () -> Boolean) = withTimeout(3_000) { while (!predicate()) delay(10) }
|
||||
@Test fun animationReadsRetryOnBle() = runBlocking {
|
||||
for (method in listOf("library.animation.info", "library.animation.chunk")) {
|
||||
val f = RpcFixture(); val ble = DeviceRpc(f.ble, PlainCrypto()); ble.open("test")
|
||||
var transport = "ble"
|
||||
val router = HybridRpc(ble, f.ble, f, this, "test", { t, _ -> transport = t }, { fail(it) })
|
||||
try {
|
||||
awaitTrue { transport == "wifi" }; f.failRead = true
|
||||
assertTrue(router.call(method).containsKey("ble"))
|
||||
assertEquals(1, f.wifiReads); assertEquals("ble", transport)
|
||||
} finally { router.close() }
|
||||
}
|
||||
}
|
||||
@Test fun bleFailureDuringNetworkFallbackIsHandled() = runBlocking {
|
||||
val f = RpcFixture(); val ble = DeviceRpc(f.ble, PlainCrypto()); ble.open("test")
|
||||
var mode = "ble"; var lost = false; var uncaught = false
|
||||
|
||||
Reference in New Issue
Block a user