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
}