Add Android animation library browsing

This commit is contained in:
2026-10-10 14:51:16 +08:00
parent 5db283c05a
commit c9dd03c23b
39 changed files with 1015 additions and 46 deletions
@@ -13,8 +13,8 @@ android {
applicationId = "org.qimiaoscreen.controller"
minSdk = 26
targetSdk = 37
versionCode = 6
versionName = "0.3.0"
versionCode = 7
versionName = "0.3.1"
buildConfigField("String", "BUILD_DATE", "\"" + ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm XXX")) + "\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -0,0 +1,88 @@
package org.qimiaoscreen.controller
import android.content.ContextWrapper
import android.os.Bundle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.*
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.qimiaoscreen.core.*
import java.io.File
@RunWith(AndroidJUnit4::class)
class AnimationLibraryTest {
@Test fun priorityCacheAndWifiReadOnly() = runBlocking {
val i = InstrumentationRegistry.getInstrumentation()
val context = i.targetContext
val expected = checkNotNull(InstrumentationRegistry.getArguments().getString("device_name"))
val sandbox = File(context.cacheDir, "animation-test-${System.nanoTime()}").apply { mkdirs() }
val cache = AndroidLibraryCache(object : ContextWrapper(context) { override fun getCacheDir() = sandbox })
val ble = AndroidBle(context)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val platform = object : MobilePlatform {
override val model = "android-test-a"
override val libraryCache = cache
override fun load(key: String): String? = null
override fun save(key: String, value: String) {}
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) = ble.scan(onDevice, onError)
override fun stopScan() = ble.stopScan()
override suspend fun connect(handle: String) = ble.connect(handle)
override fun crypto(): SecureChannel = JavaSecureChannel()
}
val controller = Controller(platform, scope)
var rpc: DeviceRpc? = null
var lan: WifiRpc? = null
val wifi = AndroidWifi(context)
suspend fun discover(): DiscoveredDevice {
val found = CompletableDeferred<DiscoveredDevice>()
try {
ble.scan({ if (it.name == expected) found.complete(it) }, { found.completeExceptionally(IllegalStateException(it)) })
return withTimeout(20_000) { found.await() }
} finally { ble.stopScan() }
}
fun stage(text: String) = i.sendStatus(2, Bundle().apply { putString("stream", "Animation: $text\n") })
try {
val found = discover()
withContext(Dispatchers.Main) { controller.page(2); controller.connect(found) }
val loaded = withTimeout(40_000) { controller.state.first { it.library.any { item -> item.getValue("type").jsonPrimitive.content == "animation" } } }
assertTrue(loaded.identity!!.getValue("capabilities").jsonArray.any { it.jsonPrimitive.content == "library_animation" })
val item = loaded.library.first { it.getValue("type").jsonPrimitive.content == "animation" }
val key = thumbnailKey(item)
val deviceId = loaded.identity!!.getValue("device_id").jsonPrimitive.content
withContext(Dispatchers.Main) { controller.viewImage(item) }
assertNotNull(controller.state.value.viewerProgress)
val complete = withTimeout(150_000) { controller.state.first { it.animationClips[key]?.tier == "full" && it.viewerProgress == null } }
val clip = complete.animationClips.getValue(key)
assertTrue(clip.durations.isNotEmpty())
for (index in clip.durations.indices) assertNotNull(controller.animationFrame(item, clip, index))
stage("BLE focused animation complete, frames=${clip.durations.size}")
withContext(Dispatchers.Main) { controller.closeImage(); controller.foreground(false) }
assertFalse(controller.state.value.imagesForeground)
withContext(Dispatchers.Main) { controller.disconnect() }
delay(1_000)
val warm = AnimationDownload(item, cache)
val cached = warm.step(deviceId, "full") { _, _ -> error("Warm animation must not download") }
assertEquals(clip.durations, cached!!.durations)
stage("warm cache reused without RPC")
val link = ble.connect(discover().handle)
rpc = DeviceRpc(link, JavaSecureChannel())
rpc.open("android-test-a")
val params = JsonObject(item + mapOf("tier" to JsonPrimitive("full"), "frame_index" to JsonPrimitive(0), "offset" to JsonPrimitive(0)))
val viaBle = rpc.call("library.animation.chunk", params)
wifi.start()
withTimeout(15_000) { wifi.state.first { it.available } }
val offer = rpc.call("transport.offer")
lan = WifiRpc(wifi.connect(offer), wifi.crypto(offer)); lan.open(offer)
assertEquals(viaBle, lan.call("library.animation.chunk", params))
stage("BLE and existing WiFi animation chunks identical")
} finally {
lan?.close(); rpc?.close(); wifi.stop()
withContext(Dispatchers.Main) { controller.disconnect() }
scope.cancel(); ble.stopScan(); sandbox.deleteRecursively()
}
}
}
@@ -46,6 +46,21 @@ class LibraryCacheTest {
val cachedFile = File(sandbox, "qms-library-v1").walkTopDown().first { it.isFile && it.name.endsWith(".png") }
cachedFile.writeText("corrupt")
assertNull(cache.readImage("device-a", updated, "preview"))
val animation = buildJsonObject { put("type", "animation"); put("id", "animated"); put("revision", "one"); put("frame_count", 1) }
val encoded = png(16)
val bytes = Base64.decode(encoded, Base64.DEFAULT)
val hash = java.security.MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
cache.writeAnimationFrame("device-a", animation, "animation-preview-0", listOf(encoded), hash)
assertNotNull(cache.readImage("device-a", animation, "animation-preview-0"))
var rejected = false
try { cache.writeAnimationFrame("device-a", animation, "animation-full-0", listOf(encoded), "0".repeat(64)) } catch (_: IllegalArgumentException) { rejected = true }
assertTrue(rejected)
cache.writeRecord("device-a", animation, "animation-full-0", buildJsonObject { put("offset", 5) })
assertNull(cache.readRecord("device-a", animation, "animation-full-0"))
cache.retain("device-a", listOf(animation))
assertNotNull(cache.readImage("device-a", animation, "animation-preview-0"))
cache.retain("device-a", emptyList())
assertNull(cache.readImage("device-a", animation, "animation-preview-0"))
assertTrue(cache.sizeBytes() > 0)
cache.clear()
assertNull(cache.loadCatalog("device-a"))
@@ -32,11 +32,11 @@ class AndroidLibraryCache(context: Context) : LibraryCache {
} finally { temp.delete() }
}
private fun validPng(bytes: ByteArray, tier: String): Boolean {
if (bytes.size !in 1..65536 || tier !in listOf("preview", "full")) return false
if (bytes.size !in 1..65536 || tier !in listOf("preview", "full") && !tier.matches(Regex("animation-(preview|full)-[0-9]+"))) return false
if (bytes.size < 8 || !bytes.copyOfRange(0, 8).contentEquals(byteArrayOf(137.toByte(), 80, 78, 71, 13, 10, 26, 10))) return false
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
val expected = if (tier == "preview") 16 else 64
val expected = if (tier == "preview" || tier.startsWith("animation-preview-")) 16 else 64
if (options.outWidth != expected || options.outHeight != expected) return false
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) != null
}
@@ -64,10 +64,50 @@ class AndroidLibraryCache(context: Context) : LibraryCache {
atomicWrite(imageFile(deviceId, item, tier), bytes)
trim()
}
private fun recordFile(id: String, item: JsonObject, name: String) = File(deviceDir(id), imageName(item, name).removeSuffix(".png") + ".json")
override suspend fun readRecord(deviceId: String, item: JsonObject, name: String): JsonObject? = withContext(Dispatchers.IO) {
val file = recordFile(deviceId, item, name)
val value = if (file.length() !in 1..262144) null else runCatching {
val record = Json.parseToJsonElement(file.readText(Charsets.UTF_8)).jsonObject
if (name == "animation-info") {
val frames = record.getValue("frames").jsonArray
require(frames.isNotEmpty() && frames.mapIndexed { index, f -> f.jsonObject.getValue("index").jsonPrimitive.int == index && f.jsonObject.getValue("duration_ms").jsonPrimitive.long > 0 }.all { it })
} else {
val offset = record.getValue("offset").jsonPrimitive.int
val total = record.getValue("total_bytes").jsonPrimitive.int
require(total in 1..65536 && offset in 1 until total)
require(record.getValue("sha256").jsonPrimitive.content.matches(Regex("[a-f0-9]{64}")))
val chunks = record.getValue("chunks").jsonArray.map { Base64.decode(it.jsonPrimitive.content, Base64.DEFAULT) }
require(chunks.all { it.size in 1..4096 } && chunks.sumOf { it.size } == offset)
}
record
}.getOrNull()
if (value == null) file.delete() else file.setLastModified(System.currentTimeMillis())
value
}
override suspend fun writeRecord(deviceId: String, item: JsonObject, name: String, value: JsonObject) = withContext(Dispatchers.IO) {
atomicWrite(recordFile(deviceId, item, name), value.toString().toByteArray(Charsets.UTF_8)); trim()
}
override suspend fun writeAnimationFrame(deviceId: String, item: JsonObject, tier: String, chunks: List<String>, sha256: String) {
val encoded = withContext(Dispatchers.Default) {
val bytes = chunks.flatMap { Base64.decode(it, Base64.DEFAULT).toList() }.toByteArray()
require(MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } == sha256) { "动画帧校验失败" }
Base64.encodeToString(bytes, Base64.NO_WRAP)
}
writeImage(deviceId, item, tier, encoded)
}
override suspend fun retain(deviceId: String, items: List<JsonObject>) = withContext(Dispatchers.IO) {
val expected = items.flatMap { item -> listOf(imageName(item, "preview"), imageName(item, "full")) }.toSet()
// Frame counts come from the catalog; partial records and metadata are keyed identically.
val expected = items.flatMap { item ->
val count = item["frame_count"]?.jsonPrimitive?.intOrNull ?: 0
listOf(imageName(item, "preview"), imageName(item, "full"), recordFile(deviceId, item, "animation-info").name) +
(0 until count).flatMap { index -> listOf("preview", "full").flatMap { tier ->
val name = "animation-$tier-$index"
listOf(imageName(item, name), recordFile(deviceId, item, name).name)
} }
}.toSet()
deviceDir(deviceId).listFiles()?.forEach { file ->
if (file.isFile && file.name.endsWith(".png") && file.name !in expected) file.delete()
if (file.isFile && file.name != "catalog.json" && file.name !in expected) file.delete()
}
Unit
}
@@ -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
}
}
@@ -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
@@ -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()
@@ -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") } }
@@ -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())
@@ -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())
}
}
@@ -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
@@ -1,5 +1,16 @@
package org.qimiaoscreen.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
@@ -66,12 +77,13 @@ fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> Im
} } }
when (state.page) {
0 -> ConnectionPage(state, controller, onScan)
1 -> if (state.identity == null) Text("请先在连接页连接设备") else Frame(state.frame, state.frameCheckedAtMs, decode, controller::refresh, !state.busy)
1 -> if (state.identity == null) Text("请先在连接页连接设备") else Frame(state.frame, state.frameCheckedAtMs, decode, controller::refresh, !state.busy, { controller.viewImage() })
2 -> ContentPage(state, controller, decode)
3 -> SettingsPage(state, controller)
}
}
}
state.viewer?.let { ImageViewer(state, controller, decode) }
if (dialog == "about") AlertDialog(onDismissRequest = { dialog = null }, title = { Text("关于奇妙小屏幕") },
text = { Text("版本 ${appInfo.version}\n构建编号 ${appInfo.build}\n打包日期 ${appInfo.date}\n通过蓝牙控制核桃派小屏幕") },
confirmButton = { TextButton({ dialog = null }) { Text("关闭") } })
@@ -159,7 +171,7 @@ private fun ConnectionPage(state: ControllerState, c: Controller, scan: () -> Un
}
@Composable
private fun Frame(encoded: String?, checkedAt: Long?, decode: (String) -> ImageBitmap?, refresh: () -> Unit, enabled: Boolean) {
private fun Frame(encoded: String?, checkedAt: Long?, decode: (String) -> ImageBitmap?, refresh: () -> Unit, enabled: Boolean, open: () -> Unit = {}) {
val bitmap = remember(encoded) { encoded?.let(decode) }
var now by remember { mutableLongStateOf(Clock.System.now().toEpochMilliseconds()) }
LaunchedEffect(checkedAt) { while (true) { now = Clock.System.now().toEpochMilliseconds(); delay(1_000) } }
@@ -172,7 +184,7 @@ private fun Frame(encoded: String?, checkedAt: Long?, decode: (String) -> ImageB
TextButton(refresh, Modifier.align(Alignment.CenterEnd).testTag("frame_refresh")
.onSizeChanged { refreshWidth = with(density) { it.width.toDp() } }, enabled = enabled) { Text("手动刷新") }
}
if (bitmap != null) Image(bitmap, "设备当前输出帧", Modifier.size(160.dp).testTag("frame_image")) else Text("等待画面", textAlign = TextAlign.Center)
if (bitmap != null) Image(bitmap, "设备当前输出帧", Modifier.size(160.dp).testTag("frame_image").clickable(onClick = open)) else Text("等待画面", textAlign = TextAlign.Center)
if (checkedAt != null && now - checkedAt > 6_000) Text("画面可能已过期,请刷新", style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center)
} }
}
@@ -182,8 +194,9 @@ private fun ContentPage(state: ControllerState, c: Controller, decode: (String)
if (state.identity == null) { Text("请先在连接页连接小屏幕"); return }
if (!c.supports("library")) { Text("此设备版本不支持内容控制"); return }
val playback = state.status?.get("state")?.jsonObject?.get("animation_playback")?.jsonObject
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
item { Frame(state.frame, state.frameCheckedAtMs, decode, c::refresh, !state.busy) }
val listState = rememberLazyListState()
LazyColumn(state = listState, verticalArrangement = Arrangement.spacedBy(12.dp)) {
item { Frame(state.frame, state.frameCheckedAtMs, decode, c::refresh, !state.busy, { c.viewImage() }) }
item { Text("开机默认:${state.defaultContent?.text("name") ?: "正在读取…"}") }
if (playback?.get("active")?.jsonPrimitive?.booleanOrNull == true) item { Playback(playback, c, !state.busy) }
item { Text("可播放内容", style = MaterialTheme.typography.titleLarge) }
@@ -191,9 +204,11 @@ private fun ContentPage(state: ControllerState, c: Controller, decode: (String)
val key = thumbnailKey(item)
val encoded = state.thumbnails[key] ?: state.previews[key]
LaunchedEffect(key, encoded) { c.thumbnail(item) }
val image = remember(encoded) { encoded?.let(decode) }
val visible = listState.layoutInfo.visibleItemsInfo.any { it.key == item.text("type") + item.text("id") }
val image = ContentBitmap(item, encoded, state.animationClips[key], c, decode, state.imagesForeground && visible && state.viewer == null)
Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { if (image != null) Image(image, item.text("name"), Modifier.size(64.dp)); Text(item.text("name")) }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { if (image != null) Image(image, item.text("name"), Modifier.size(64.dp).testTag("library_image_$key").clickable { c.viewImage(item) }); Text(item.text("name")) }
if (image == null) TextButton({ c.viewImage(item) }) { Text("查看图片") }
Text((if (item.text("type") == "animation") "动图" else "静态模板") + if (item.text("is_demo") == "true") " · 演示内容" else "")
val enabled = !state.busy && item["playable"]?.jsonPrimitive?.booleanOrNull == true
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -205,6 +220,67 @@ private fun ContentPage(state: ControllerState, c: Controller, decode: (String)
}
}
@Composable
private fun ContentBitmap(item: JsonObject, fallback: String?, clip: AnimationClip?, c: Controller,
decode: (String) -> ImageBitmap?, playing: Boolean): ImageBitmap? {
var bitmap by remember(thumbnailKey(item)) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(fallback, clip, playing, thumbnailKey(item)) {
if (clip == null || !playing) {
bitmap = withContext(Dispatchers.Default) { fallback?.let(decode) }
} else {
var index = 0
while (true) {
val started = kotlin.time.TimeSource.Monotonic.markNow()
val encoded = c.animationFrame(item, clip, index)
val frame = withContext(Dispatchers.Default) { encoded?.let(decode) }
if (frame != null) bitmap = frame
delay((clip.durations[index] - started.elapsedNow().inWholeMilliseconds).coerceAtLeast(1))
index = (index + 1) % clip.durations.size
}
}
}
return bitmap
}
@Composable
private fun ImageViewer(state: ControllerState, c: Controller, decode: (String) -> ImageBitmap?) {
val item = state.viewer ?: return
val isFrame = item.text("type") == "frame"
val key = if (isFrame) "frame" else thumbnailKey(item)
val image = if (isFrame) {
var bitmap by remember { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(state.frame) { bitmap = withContext(Dispatchers.Default) { state.frame?.let(decode) } }
bitmap
} else ContentBitmap(item, state.thumbnails[key] ?: state.previews[key], state.animationClips[key], c, decode, state.imagesForeground)
var scale by remember(key) { mutableFloatStateOf(1f) }
var x by remember(key) { mutableFloatStateOf(0f) }
var y by remember(key) { mutableFloatStateOf(0f) }
Dialog(onDismissRequest = c::closeImage, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Surface(Modifier.fillMaxSize().testTag("image_viewer")) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(item.text("name"), Modifier.weight(1f))
TextButton(c::closeImage, Modifier.testTag("viewer_close")) { Text("关闭") }
}
Box(Modifier.weight(1f).fillMaxWidth().clipToBounds().pointerInput(key) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(1f, 8f)
x = if (scale == 1f) 0f else (x + pan.x).coerceIn(-size.width * (scale - 1) / 2, size.width * (scale - 1) / 2)
y = if (scale == 1f) 0f else (y + pan.y).coerceIn(-size.height * (scale - 1) / 2, size.height * (scale - 1) / 2)
}
}, contentAlignment = Alignment.Center) {
if (image != null) Image(image, item.text("name"), Modifier.fillMaxSize().testTag("viewer_image")
.graphicsLayer(scaleX = scale, scaleY = scale, translationX = x, translationY = y), contentScale = ContentScale.Fit)
else Text("等待图像")
}
if (!isFrame && item.text("type") == "animation" && !c.supports("library_animation")) Text("设备暂不支持动画预览")
state.viewerProgress?.let { Text(it, Modifier.testTag("viewer_progress")) }
state.imageErrors[key]?.let { Text(it); TextButton({ c.viewImage(item) }) { Text("重试") } }
}
}
}
}
@Composable
private fun Playback(value: JsonObject, c: Controller, enabled: Boolean) {
val paused = value["paused"]?.jsonPrimitive?.booleanOrNull == true
@@ -17,7 +17,7 @@ class FixtureActivity : ComponentActivity() {
val controller = Controller(platform, scope)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { QimiaoApp(controller, controller::scan, { ImageBitmap(64, 64) }) }
setContent { QimiaoApp(controller, controller::scan, { marker -> platform.decodedMarkers += marker; ImageBitmap(64, 64) }) }
}
override fun onDestroy() { controller.disconnect(); scope.cancel(); super.onDestroy() }
}
@@ -28,8 +28,11 @@ class FixturePlatform : MobilePlatform {
override val model = "模拟测试手机"
private val saved = mutableMapOf<String, String>()
val calls = mutableListOf<String>()
val decodedMarkers = java.util.Collections.synchronizedSet(mutableSetOf<String>())
var scanError = false
var emptyLibrary = true
var animatedLibrary = false
override val libraryCache: LibraryCache = FixtureCache()
override fun load(key: String) = saved[key]
override fun save(key: String, value: String) { saved[key] = value }
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) {
@@ -59,7 +62,7 @@ private class FixtureLink(val platform: FixturePlatform) : DeviceLink {
val method = request.getValue("method").jsonPrimitive.content
platform.calls += method
val result = when (method) {
"session.open" -> """{"device_id":"00000000-0000-4000-8000-000000000001","device_name":"模拟小屏幕","short_id":"00000000","protocol_major":1,"capabilities":["status","frame","library","library_progressive","settings","wifi","wifi_scan"${if (platform.wifiEnabled) ",\"wifi_transport\"" else ""}]}"""
"session.open" -> """{"device_id":"00000000-0000-4000-8000-000000000001","device_name":"模拟小屏幕","short_id":"00000000","protocol_major":1,"capabilities":["status","frame","library","library_progressive","library_animation","settings","wifi","wifi_scan"${if (platform.wifiEnabled) ",\"wifi_transport\"" else ""}]}"""
"transport.offer" -> """{"device_id":"00000000-0000-4000-8000-000000000001","channel_id":"00000000000000000000000000000000"}"""
"transport.close" -> """{"closed":true}"""
"session.ping" -> """{"alive":true}"""
@@ -68,8 +71,16 @@ private class FixtureLink(val platform: FixturePlatform) : DeviceLink {
"settings.get" -> """{"revision":"fixture","values":{"brightness":40,"orientation":0,"prompt_delay_seconds":5}}"""
"wifi.get" -> """{"revision":"fixture","saved":null,"active":{"connected":false}}"""
"wifi.scan" -> """{"networks":[]}"""
"library.summary" -> if (platform.emptyLibrary) """{"library_revision":"fixture-empty","item_count":0}""" else """{"library_revision":"fixture-full","item_count":1}"""
"library.list" -> if (platform.emptyLibrary) """{"library_revision":"fixture-empty","items":[],"next_cursor":null}""" else """{"library_revision":"fixture-full","items":[{"id":"fixture","type":"template","name":"测试模板","revision":"fixture-1","playable":true}],"next_cursor":null}"""
"library.summary" -> if (platform.emptyLibrary) """{"library_revision":"fixture-empty","item_count":0}""" else if (platform.animatedLibrary) """{"library_revision":"fixture-animation","item_count":1}""" else """{"library_revision":"fixture-full","item_count":1}"""
"library.list" -> if (platform.emptyLibrary) """{"library_revision":"fixture-empty","items":[],"next_cursor":null}""" else if (platform.animatedLibrary) """{"library_revision":"fixture-animation","items":[{"id":"fixture","type":"animation","name":"测试动图","revision":"fixture-1","frame_count":2,"playable":true}],"next_cursor":null}""" else """{"library_revision":"fixture-full","items":[{"id":"fixture","type":"template","name":"测试模板","revision":"fixture-1","playable":true}],"next_cursor":null}"""
"library.animation.info" -> """{"revision":"fixture-1","frame_count":2,"frames":[{"index":0,"duration_ms":80},{"index":1,"duration_ms":120}],"next_offset":null}"""
"library.animation.chunk" -> {
val params = request.getValue("params").jsonObject
buildJsonObject {
put("revision", "fixture-1"); put("tier", params.getValue("tier")); put("frame_index", params.getValue("frame_index"))
put("offset", 0); put("total_bytes", 1); put("sha256", "fixture"); put("data_base64", "AA=="); put("next_offset", JsonNull)
}.toString()
}
"content.default.get" -> """{"name":"测试默认"}"""
"library.preview" -> """{"data_base64":"fixture-preview"}"""
"library.thumbnail" -> """{"data_base64":"fixture-image"}"""
@@ -112,3 +123,19 @@ class FixtureWifi : WifiPlatform {
override fun close() { replies.close() }
}
}
private class FixtureCache : LibraryCache {
private val images = mutableMapOf<String, String>()
private val records = mutableMapOf<String, JsonObject>()
var reads = 0
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): String? { reads++; return 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) { images[tier] = tier }
override suspend fun retain(deviceId: String, items: List<JsonObject>) {}
override suspend fun sizeBytes() = 0L
override suspend fun clear() { images.clear(); records.clear() }
}
@@ -2,6 +2,7 @@ package org.qimiaoscreen.uitest
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import org.qimiaoscreen.core.thumbnailKey
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.*
@@ -65,6 +66,26 @@ class IsolatedUiTest {
assertTrue(ui.activity.platform.calls.indexOf("library.preview") < ui.activity.platform.calls.indexOf("library.thumbnail"))
assertFalse(ui.activity.platform.calls.contains("content.play"))
}
@Test fun animationViewer() {
ui.runOnIdle { ui.activity.platform.emptyLibrary = false; ui.activity.platform.animatedLibrary = true }
connect(); click("显示内容")
ui.waitUntil(10_000) { ui.activity.controller.state.value.animationClips.values.any { it.tier == "full" } }
ui.waitUntil(5_000) { ui.activity.platform.decodedMarkers.containsAll(listOf("animation-full-0", "animation-full-1")) }
val item = ui.activity.controller.state.value.library.single()
ui.onNodeWithTag("library_image_" + thumbnailKey(item)).performScrollTo().performClick()
ui.onNodeWithTag("image_viewer").assertExists()
ui.waitUntil(5_000) { ui.activity.controller.state.value.viewerProgress == null }
ui.onNodeWithTag("viewer_image").assertExists()
ui.onNodeWithTag("viewer_close").performClick()
ui.onNodeWithTag("image_viewer").assertDoesNotExist()
ui.runOnIdle { ui.activity.controller.foreground(false) }
assertFalse(ui.activity.controller.state.value.imagesForeground)
ui.runOnIdle { ui.activity.controller.foreground(true) }
ui.onNodeWithTag("frame_image").performScrollTo().performClick()
ui.onNodeWithTag("image_viewer").assertExists()
ui.onNodeWithTag("viewer_close").performClick()
assertFalse(ui.activity.platform.calls.contains("content.play"))
}
@Test fun settings() {
connect(); click("设备设置")
ui.waitUntil(5_000) { ui.activity.controller.state.value.settings != null }