Add Android animation library browsing
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
+88
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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"))
|
||||
|
||||
+44
-4
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user