Add Android animation library browsing
This commit is contained in:
@@ -137,3 +137,8 @@ ANDROID-TEST-STATE:共享逻辑验证自动/手动连接后关联、重新
|
||||
顶栏保留完整文字语义,菜单位于固定区域;长文本循环参数由共享实现及编译核验,语义边界不证明文字实际运动,不记为目视验收。未取得本次视觉指示时不截图、不启动摄像头。纯手机界面调整的实屏视觉检查不适用,不需切网、显示内容切换或驱动测试。
|
||||
|
||||
短视口连接列表验收使用 connection_list 的实时可见边界,以 root 滑动定位扫描条目,按当前条目容器内的资源标签选连接/断开,不能假设设备名称始终在首屏。布局恢复须同时回读密度覆盖、字体与旋转;运行器输出恢复核验结果。
|
||||
|
||||
|
||||
## 2026-10-10 动画浏览
|
||||
|
||||
动画浏览专项:先sharedCore JVM,再独立模拟器content / animationViewer;实际链路使用run_ble_test.py --scenario animation-library,存储回归使用--scenario cache-storage。所有真实测试仍执行登记、ADB/root和设备凭据门禁。检查点:可见动图帧推进、源时长、未完成时优先下载、完成缓存直接浏览、关闭/后台停止新请求、暖缓存无重复动画读取,BLE/WiFi块一致。只读验证,不调用content.play。
|
||||
|
||||
@@ -168,7 +168,7 @@ def main(argv=None):
|
||||
'-e', 'reapply_current_wifi', str(args.reapply_current_wifi).lower(),
|
||||
'-e', 'verify_current_controls', str(args.verify_current_controls).lower(),
|
||||
'-e', 'toggle_bluetooth', str(args.toggle_bluetooth).lower(),
|
||||
'-e', 'class', 'org.qimiaoscreen.controller.' + ('WifiTransportTest' if args.scenario.startswith('wifi-transport') else 'LibraryCacheTest' if args.scenario == 'cache-storage' else
|
||||
'-e', 'class', 'org.qimiaoscreen.controller.' + ('AnimationLibraryTest' if args.scenario == 'animation-library' else 'WifiTransportTest' if args.scenario.startswith('wifi-transport') else 'LibraryCacheTest' if args.scenario == 'cache-storage' else
|
||||
'LibraryBleCacheTest' if args.scenario == 'library-cache' else
|
||||
{'ble': 'BleIntegrationTest', 'activity': 'AppLifecycleTest', 'layout': 'LayoutTest'}[args.test]),
|
||||
'org.qimiaoscreen.controller.test/androidx.test.runner.AndroidJUnitRunner', timeout=360)
|
||||
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
|
||||
# name: (instrumentation family, board required, side effect)
|
||||
SCENARIOS = {
|
||||
'animation-library': ('ble', True, 'focused animation download, warm cache, read-only BLE/WiFi chunk equality; no network switches'),
|
||||
'wifi-transport': ('ble', True, 'encrypted WiFi upgrade, identical image transfer comparison; no mutations'),
|
||||
'wifi-transport-network': ('ble', True, 'reapply currently active board WiFi with password retained; verify hybrid routing recovery'),
|
||||
'wifi-transport-restart': ('ble', True, 'restart board main service only after authenticated WiFi readiness; verify new BLE/WiFi session'),
|
||||
|
||||
@@ -6,7 +6,7 @@ import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
SCENARIOS = ('connection', 'wifiTransport', 'status', 'content', 'settings', 'navigation', 'layout')
|
||||
SCENARIOS = ('connection', 'wifiTransport', 'status', 'content', 'animationViewer', 'settings', 'navigation', 'layout')
|
||||
CONFIGS = {'baseline': (None, '1.0', '0'), 'narrow': (340, '1.0', '0'),
|
||||
'large-font': (340, '1.3', '0'), 'landscape': (None, '1.3', '1')}
|
||||
PACKAGE = 'org.qimiaoscreen.uitest'
|
||||
|
||||
@@ -105,3 +105,11 @@ Compose 的禁用语义可能位于父节点,文字节点自身仍 enabled。
|
||||
- Windows Python 控制台在诊断输出包含替换字符时可能触发 GBK 编码错误;仅对本次 Python 进程使用 `-X utf8`,不全局调整系统设置。
|
||||
|
||||
单次启动失败与复测结果记录在 `各种归档/2026-10-01_本机环境迁移/README.md`;模拟器结果不代表任何真机或板端验收。
|
||||
|
||||
|
||||
## 2026-10-10 动画浏览
|
||||
|
||||
动画预览仅有首帧时,先核对设备是否声明library_animation及是否实际读取动画帧,不能只替换静态解码器。已缓存完整图片点开浏览器时应立即判定完成,不能等待队列再清除下载提示(完成项不会重新入队)。暖动画缓存应先校验完整高清清单并直接恢复播放,避免重新下载低清。动画两类读取必须加入HybridRpc只读集合,WiFi故障才可安全回退BLE。
|
||||
JVM构建如使用指向中文项目的ASCII联接,Gradle可能解析回中文真实路径并重现已登记的类加载失败;本次已用独立真实ASCII临时构建目录验证通过。临时构建属于本轮中间产物,验证并交付后清理;持续SDK/AVD/项目环境仍保留项目内。
|
||||
|
||||
部署数据检查:必须在更新前将逐项文件摘要保存到受保护材料,区分用户源/配置与已登记可再生缓存;不能只保留进程内字典和总差异数。更新后逐项核验,报告仅输出脱敏分类与统计。原始受保护基线不进入普通日志或Git。缺少基线时明确标记追溯验证不足,不能用启动成功替代源文件不变证据。
|
||||
|
||||
@@ -92,3 +92,8 @@ python "移动端相关内容/安卓app/如何安卓测试/run_wifi_switch_test.
|
||||
- [模拟器网络能力](https://developer.android.com/studio/run/emulator-networking):新版本支持部分 Bluetooth 模拟,不能笼统写“模拟器完全不支持 BLE”;本项目 fake 测试仍不等于物理射频、实际核桃派或厂商兼容验证。
|
||||
|
||||
以上是制定项目选测规则的依据,具体场景划分是本项目的工程选择,不是官方强制清单。
|
||||
|
||||
|
||||
## 2026-10-10 动画浏览
|
||||
|
||||
ANDROID-TEST-ANIMATION:共享JVM验证部分下载恢复、修订拒绝、当前项在完整消息边界抢占、后台暂停、暖缓存及WiFi只读回退。独立模拟器选择content / animationViewer,检查帧解码标记推进、全屏、返回及后台状态。登记真机定向选择animation-library,验证实际Controller优先补传、缓存复用及现有BLE/WiFi分块一致;cache-storage覆盖动画PNG校验、损坏记录、修订清理。上述读取不改变核桃派内容,不切网、不启用摄像头,不扩展到无关页面。
|
||||
|
||||
@@ -82,3 +82,8 @@ BlueZ D-Bus 接入作为可恢复模块,单独管理连接/广播,不在
|
||||
## Android 选测入口
|
||||
|
||||
遵循 [按影响选测](如何安卓测试/按影响选测.md)。日常变更不自动执行模拟器或 root 真机全量矩阵;独立 uiTest 使用真实 Compose 界面与 Controller、测试专用假平台,不替代物理 BLE。历史版本专项不作为每轮门槛。
|
||||
|
||||
|
||||
## 2026-10-10 动画浏览
|
||||
|
||||
动画读取使用协议1.3的帧信息与4 KiB分块接口,图片队列独立连续运行,每块结束让出命令锁。优先级:用户命令、状态/保活、全屏当前项、普通图库。普通队列先全部低清首图、再低清动画、最后高清。可见动画在后台解码并以单调时钟扣除解码耗时,保持源帧时长。全屏采用共享Compose Dialog、比例适配和1–8倍缩放;关闭300 ms撤销优先级,后台立即停新请求。动画清单、校验后的完整PNG和部分块纳入现有128 MiB缓存;完整高清缓存可直接恢复播放而无需重新请求低清。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+83
-7
@@ -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
|
||||
|
||||
+31
-4
@@ -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() }
|
||||
}
|
||||
|
||||
+21
@@ -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 }
|
||||
|
||||
@@ -70,3 +70,8 @@ Android 0.2.1 / versionCode 4:顶栏长文本循环、连接条目断开/扫
|
||||
独立模拟器 UI 测试 APK 构建任务为 `:uiTest:assembleDebug`,只用于验证,不进入产品交付包;测试框架调整不提升产品版本。
|
||||
|
||||
Android 0.2.2 / versionCode 5:显示内容两档图片传输、128 MiB 本地缓存及软件设置清空;协议 1.1 增量能力,仍只允许 debug 签名。设备功能部署沿用回滚开发流程,正式设备版本不因本次开发部署递增。验收以本轮测试归档为准。
|
||||
|
||||
|
||||
## 2026-10-10 动画浏览
|
||||
|
||||
App 0.3.1 / versionCode 7,协议1.3动画分块读取、动画缓存与全屏优先浏览。仍仅debug签名,不新增Android或板端依赖;手工部署设备应用并覆盖安装手机,保持用户内容和设备正式版本。
|
||||
|
||||
@@ -48,7 +48,7 @@ def main():
|
||||
build_date = re.search(r'BUILD_DATE = "([^"]+)"', build_config)[1]
|
||||
manifest = dict(app_version=version, version_code=code, signing='debug', build_date=build_date,
|
||||
status='development-validation-see-test-report', apk_sha256=digest(apk),
|
||||
certificate_sha256=certificate, protocol_major=1, protocol_minor=2, minimum_android_api=26,
|
||||
certificate_sha256=certificate, protocol_major=1, protocol_minor=3, minimum_android_api=26,
|
||||
target_android_api=37, build_tools='36.0.0', toolchain=versions, sources=entries)
|
||||
(destination / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
(destination / 'SHA256SUMS').write_text(f'{digest(apk)} {name}\n', encoding='utf-8')
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# 奇妙小屏幕 0.3.1 debug(versionCode 7)
|
||||
|
||||
仅 debug 签名,可覆盖安装相同调试证书的旧包。Android 8.0 以上,真实验证机为 Android 13。
|
||||
|
||||
本包为当前开发验证产物;验收范围和未验证项见 `../../../测试结果归档/实施状态.md`。不能将 APK 成功生成视为所有首版验收通过。设备需已部署移动控制功能,历史设备版本号本身不代表支持 BLE。
|
||||
|
||||
SHA-256:`4b6cc7ba57290306d72bec3c9285fef47143045779427cfb1a11d73c7713a3cc`。源码摘要与签名证书摘要见 manifest.json。
|
||||
@@ -0,0 +1 @@
|
||||
4b6cc7ba57290306d72bec3c9285fef47143045779427cfb1a11d73c7713a3cc 奇妙小屏幕-0.3.1-debug.apk
|
||||
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"app_version": "0.3.1",
|
||||
"version_code": 7,
|
||||
"signing": "debug",
|
||||
"build_date": "2026-10-10 14:36 +08:00",
|
||||
"status": "development-validation-see-test-report",
|
||||
"apk_sha256": "4b6cc7ba57290306d72bec3c9285fef47143045779427cfb1a11d73c7713a3cc",
|
||||
"certificate_sha256": "b413e68c0bd2311c491ebd087ac5791c7c2ad8267793f92f6055d5521bd3ef4d",
|
||||
"protocol_major": 1,
|
||||
"protocol_minor": 3,
|
||||
"minimum_android_api": 26,
|
||||
"target_android_api": 37,
|
||||
"build_tools": "36.0.0",
|
||||
"toolchain": {
|
||||
"kotlin": "2.4.20",
|
||||
"agp": "9.3.1",
|
||||
"compose": "1.12.1",
|
||||
"coroutines": "1.10.2",
|
||||
"serialization": "1.9.0"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"path": "androidApp/build.gradle.kts",
|
||||
"sha256": "ba98276e1cdbf0030f84279ae828fa33cf1eba7c0a24b37644e3cc5ce4026c03"
|
||||
},
|
||||
{
|
||||
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AnimationLibraryTest.kt",
|
||||
"sha256": "0c429ae357494d00eafbf1382a9a273cebd100d0d0fd43439b56f6e6eeced1e9"
|
||||
},
|
||||
{
|
||||
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AppLifecycleTest.kt",
|
||||
"sha256": "7b75279ea8bba61fd9a35848339f765e931abb2748dcb2ab08f37f85afd21084"
|
||||
},
|
||||
{
|
||||
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/BleIntegrationTest.kt",
|
||||
"sha256": "f286cf70cd00b7e872371f0fbc55f6be46e52602ef6890e974aca1c32eb006fa"
|
||||
},
|
||||
{
|
||||
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LayoutTest.kt",
|
||||
"sha256": "22a26ed18216edf21273b2fdd285865ce00c7d1e456eca61cb3e23064989bb42"
|
||||
},
|
||||
{
|
||||
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryBleCacheTest.kt",
|
||||
"sha256": "01636b443b36532e8b6aa55eaf9ef0976697122362ca24f05a1da6bb958e419f"
|
||||
},
|
||||
{
|
||||
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryCacheTest.kt",
|
||||
"sha256": "215c639f6fe014a122b006f606020614bec1cda36f78699a1b9a1960c9917c74"
|
||||
},
|
||||
{
|
||||
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/WifiSwitchTest.kt",
|
||||
"sha256": "5ce335ff84fee01230733971964fefde3fcc6103172d3c40fee64f50838b7bcf"
|
||||
},
|
||||
{
|
||||
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/WifiTransportTest.kt",
|
||||
"sha256": "541d8b2368a7b8e835d4bc2fc07c974fe932c43d72ef488b50ad22dda3a4b03c"
|
||||
},
|
||||
{
|
||||
"path": "androidApp/src/main/AndroidManifest.xml",
|
||||
"sha256": "f46f93fb739fa984ee2ad247b0d40678c41b71454cd30be0b04ea780668f1a06"
|
||||
},
|
||||
{
|
||||
"path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidBle.kt",
|
||||
"sha256": "343fb5e0aff77a0bea81895eff9863f10907663210f42368f02c1ae840161340"
|
||||
},
|
||||
{
|
||||
"path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidLibraryCache.kt",
|
||||
"sha256": "4eb5d50c1f7e5b5de0ba3371d85b63b864339ec32b5e7b6d165444cebaa05b89"
|
||||
},
|
||||
{
|
||||
"path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidWifi.kt",
|
||||
"sha256": "d94d52c524c1f23b50c283fde1157787f27f4b5e92362d5daea72ce4c435d839"
|
||||
},
|
||||
{
|
||||
"path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/MainActivity.kt",
|
||||
"sha256": "890ae503c6cc02b001f2d96a8c6240b10ad7e988b8e4d3374af85c83762422eb"
|
||||
},
|
||||
{
|
||||
"path": "build.gradle.kts",
|
||||
"sha256": "feb3b6e64b846f0408cc40e95ae4b632ce9288a56bd6151bc3bbcc46f0549b49"
|
||||
},
|
||||
{
|
||||
"path": "gradle/libs.versions.toml",
|
||||
"sha256": "ed393bb2b4d81a162f6b3d21968eeca113e23c2f5d5d22c3be9d104bc592a633"
|
||||
},
|
||||
{
|
||||
"path": "gradle/verification-metadata.xml",
|
||||
"sha256": "e44824772ec2c9913a7ac2ce3608478a6276524184b6ff9c0526d55f67cc91af"
|
||||
},
|
||||
{
|
||||
"path": "gradle/wrapper/gradle-wrapper.jar",
|
||||
"sha256": "497c8c2a7e5031f6aa847f88104aa80a93532ec32ee17bdb8d1d2f67a194a9c7"
|
||||
},
|
||||
{
|
||||
"path": "gradle/wrapper/gradle-wrapper.properties",
|
||||
"sha256": "2cb0f9ddf992a26fc4947c91019136bff5e1ccfbf93bd905402b1abf69d15bc5"
|
||||
},
|
||||
{
|
||||
"path": "gradle.properties",
|
||||
"sha256": "e7d684b7795fd04ab2e7e96038fa2bf4f9fd633578aa3bb2f5fe367b4f5ec159"
|
||||
},
|
||||
{
|
||||
"path": "gradlew",
|
||||
"sha256": "ab5c0cad16305af2e619c159c1f58dd68d07fab9c11e36701e109c0277407f7a"
|
||||
},
|
||||
{
|
||||
"path": "gradlew.bat",
|
||||
"sha256": "475c4f08cd57cf2faa819e7f36d72aa93f0ad646ea23a8f7fa3ef54dee1cbc52"
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"sha256": "7232ebbac0899152e312429fecaf6b3e14b31265b0c50a95c99a6213aa04b2cd"
|
||||
},
|
||||
{
|
||||
"path": "settings.gradle.kts",
|
||||
"sha256": "3a1adf811d65cfd2ce28bfeb711990511a29094b8a5b5378f2860c86c9fe83e2"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/build.gradle.kts",
|
||||
"sha256": "9a420e39e3c655f676572d4486e415dbc8b4fd45904db6ad7b64d7fcd71f084b"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/AnimationDownload.kt",
|
||||
"sha256": "de6b5631fdb43faa601ff723fb2c4f6c4b56f44661ef07eff1a54d3f85ad4b2f"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt",
|
||||
"sha256": "ef64f14e05ffacbe9df4b5017f461184cfaf9a0284624325db21b1794d810b9a"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/DeviceLink.kt",
|
||||
"sha256": "35673e7e28d44c5a3d784a205d3f8f86ddcf534751b31b3e27e0ea93ec756747"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/LibraryCache.kt",
|
||||
"sha256": "de8599c98ec8e9eb452becab4ef8ec59ec24a228ab4e118ee785f219369aff96"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Protocol.kt",
|
||||
"sha256": "99264aff225076ef1b423801b9bba8797e0e06a9e759339bc52e896620e4b694"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/SecureChannel.kt",
|
||||
"sha256": "bde40388bb74c9d47a4e099a14a0baeca3b6bda1539023dbd7dca831c6f24039"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/WifiTransport.kt",
|
||||
"sha256": "9dafeadf1eea77e62e516a6f783585d328352c5a6a07813a0484cd30b89953b5"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt",
|
||||
"sha256": "84e532e3ae7dbf7ade92fcf613e665be14139a619f12fa9b088293d005051938"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ProtocolTest.kt",
|
||||
"sha256": "5d26d3a50c5887d72dc91b2081273fbe4a5a968f01451ef75ab78903d5d42217"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/javaCryptoMain/kotlin/org/qimiaoscreen/core/JavaSecureChannel.kt",
|
||||
"sha256": "f5dcc82c9dc7a4ab6851ade222eb333239b298637cdbf0dd08531838310bd0c9"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/AnimationDownloadTest.kt",
|
||||
"sha256": "5afea2c9347a3b137a24efd107a49954f4606e1fd6fe4aa7d62ffddf6bd99692"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/CryptoInteropTest.kt",
|
||||
"sha256": "2ddb06c4476952a69bf01c0a3a7164625ae8c0bfe9194349450a2e254888d4b3"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/HybridRpcTest.kt",
|
||||
"sha256": "d20b29ca26d0008dd14fe79eec65839e68ad434f3c21c36603563ea774faedf7"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/RpcTimeoutTest.kt",
|
||||
"sha256": "fca298fb2266ee7e68e6df76d837f7f191e0046aa5ba321a304c4b4369e064a6"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/WifiCryptoTest.kt",
|
||||
"sha256": "ef051432f40fa9a5072efde6e590683a25f6eb2ca2ddb2fa1806089a8bfeeca0"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/jvmTest/resources/protocol-v1.json",
|
||||
"sha256": "6280953b95a3f5d1ae531d7feaa6a62848ffa3e438e58308d2a27f04723803dc"
|
||||
},
|
||||
{
|
||||
"path": "sharedCore/src/jvmTest/resources/protocol-wifi-v1.json",
|
||||
"sha256": "387fd59e65930001d190f4900b118958d41ee664c119ece9cd2ed15eed53d665"
|
||||
},
|
||||
{
|
||||
"path": "sharedUi/build.gradle.kts",
|
||||
"sha256": "d469b330d248647e6e93f98d69eb46730adab329ebaf71cbfd896d5426fd0ff1"
|
||||
},
|
||||
{
|
||||
"path": "sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt",
|
||||
"sha256": "9c6e96931d9ef9b912fffcaf62ceb9845b394b71f3404689d8e38a4f69bd4e3e"
|
||||
},
|
||||
{
|
||||
"path": "uiTest/build.gradle.kts",
|
||||
"sha256": "958d9d5509c9b0fd3076f0d22a4544b66e11a4ba47e728107b8ea357cdaffaf3"
|
||||
},
|
||||
{
|
||||
"path": "uiTest/src/main/AndroidManifest.xml",
|
||||
"sha256": "ef50ab1e26f0caaadd7997799dd47d510cb7b4042b421ebe5354f4d2d42708dc"
|
||||
},
|
||||
{
|
||||
"path": "uiTest/src/main/kotlin/org/qimiaoscreen/uitest/FixtureActivity.kt",
|
||||
"sha256": "516c0d58c8d7544cbe0bf4164b8718356f7f8e705cda5d9445f3d88f94d9d88e"
|
||||
},
|
||||
{
|
||||
"path": "uiTest/src/main/kotlin/org/qimiaoscreen/uitest/IsolatedUiTest.kt",
|
||||
"sha256": "96cef5832454f781e43d5ad70611735856c52cd5f831ea089c6df44501bcf1cc"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,32 @@
|
||||
# Android 动画与全屏浏览交付
|
||||
|
||||
App 0.3.1 / versionCode 7(debug),设备正式版本保持 1.1.3,协议 1.3。设备功能时间 2026-10-10T14:17+08:00。两端已部署;登记手机已核对版本、启动产品 App,并卸载本轮测试 APK,未清除用户数据。交付 APK 见 ../../构建与发布/交付包/0.3.1-7/。
|
||||
|
||||
## 变更与选测
|
||||
|
||||
新增帧清单和4 KiB PNG分块、低清动画→高清动画、修订隔离及128 MiB缓存、部分下载恢复、全屏与1–8倍缩放、当前项优先、关闭300 ms撤销优先级和后台停止新图片请求。读取不改变设备播放。动画按可见性播放并扣除解码耗时,完整高清缓存优先直接恢复。
|
||||
|
||||
直接影响手机缓存、Controller队列、内容/设备画面浏览、BLE/WiFi只读方法集合及设备图库读取。选择共享JVM、设备Python契约、独立模拟器content / animationViewer、登记真机cache-storage / animation-library;不执行切网、无关设置页面、驱动时序、ADC、摄像头或实屏图案验收。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- 本机设备契约:20 passed,包括分页、尺寸、分块摘要、非法参数、修订冲突及只读帧修订不变。
|
||||
- 隔离Android构建:产品debug APK、instrumentation APK、独立uiTest APK构建成功;共享JVM 27 tests,0 failures/errors/skipped。包含完整消息边界抢占、后台暂停、已缓存图片提示、分块恢复、原时长和动画读取WiFi回退。
|
||||
- 独立模拟器:content通过;最终animationViewer通过。实际解码标记覆盖两帧推进、全屏图片、关闭、设备画面浏览及后台暂停;未使用截图/摄像头。
|
||||
- 登记真机:cache-storage通过(动画PNG摘要、损坏部分记录、修订保留/清理);animation-library通过,16.67秒,生产Controller当前项优先下载完成、暖缓存零动画RPC、BLE与现有WiFi分块一致。未切换网络或写入显示内容。
|
||||
- 核桃派原子更新:510 passed / 7 skipped / 397 warnings,419.08秒;7项按既有source-only更新边界跳过,不代表完整离线发布材料验收。更新及再次重启健康检查通过。
|
||||
- 部署摘要与本机源码一致;驱动运行、无最后错误、刷新97.432 Hz、截止丢失0。设备正式版本未推进。
|
||||
- 当前持久资源严格校验通过:9个用户静态模板、6个用户动图,共220个用户源/配置文件。隔离副本执行启动整理后源文件摘要不变,见animation-data-audit.json。
|
||||
- 测试运行器选测检查9项通过;仓库卫生与diff空白检查通过。
|
||||
|
||||
## 失败、修复与边界
|
||||
|
||||
首轮指向中文路径的ASCII联接构建重现已有JVM类加载失败;真实ASCII临时源码副本构建通过。测试代码初次缺少thumbnailKey导入,补齐后编译通过。模拟器发现已完成缓存仍保留下载提示;修复打开时完成判定后重测通过。复查补齐动画WiFi只读回退集合和高清暖缓存直接恢复。
|
||||
|
||||
首次部署全持久目录摘要记录191项差异,但脚本未落盘保存逐项旧摘要,不能追溯各项变化归属,也不能将其写成“用户源文件部署前后逐项不变”通过。已核验现有源文件严格有效、隔离启动整理不会修改220个源文件;源目录既有.playback和缩略图等可再生清理行为不能单凭源码证明本次191项差异全属缓存。后续部署须保存受保护逐项基线,再分类比较。
|
||||
|
||||
经验已更新:如何安卓测试/常见问题与排障.md、按影响选测.md、README.md,以及根测试流程和构建与发布README。新能力未引入板端或Android依赖,无OTA/IMG导出,无正式APK签名。
|
||||
|
||||
## 清理
|
||||
|
||||
原子更新脚本清理自身staging、迁移运行目录和旧版本回滚材料;本轮上传目录/tmp/qms-animation-24cddd10abc6已删除。隔离数据审计目录由受保护临时上下文删除。手机测试APK及root辅助文件已清理。模拟器恢复设置并关闭本次实例,模拟器上下文创建的ASCII联接已按原入口清理。额外构建目录和qms-animation联接的清理被工具安全策略以“blocked by policy”拦截,未改用其它删除方式。待用户手动删除:<受保护临时目录>/qms-animation-ascii/、qms-animation-emulator/、qms-animation(仅联接本身,目标为项目.local/work,不删除目标);以及项目测试环境.local/work/qms-build-46tzg_6q/、animation-device-source.tar.gz和animation_deploy.py。开发日志留在项目精确忽略的.local/work/下,交付APK、源码摘要和本报告长期保留。
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"current_user_sources_valid": true,
|
||||
"source_file_count": 220,
|
||||
"copied_startup_preserves_sources": true,
|
||||
"template_count": 9,
|
||||
"animation_count": 6,
|
||||
"remote_audit_temporary_removed_on_exit": true
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"tests": [
|
||||
{
|
||||
"scenario": "animationViewer",
|
||||
"configuration": "baseline",
|
||||
"result": "passed"
|
||||
}
|
||||
],
|
||||
"failure": null,
|
||||
"cleanup_errors": [],
|
||||
"not_selected": [
|
||||
"connection",
|
||||
"wifiTransport",
|
||||
"status",
|
||||
"content",
|
||||
"settings",
|
||||
"navigation",
|
||||
"layout"
|
||||
]
|
||||
}
|
||||
@@ -46,3 +46,6 @@
|
||||
4. 逐项核对需求/实现/验收差异,整理最终报告、APK、签名与摘要;当前值写入/动画暂停恢复真机通过;完整内容选择、默认保存、权限各分支、旧 Android 版本和完整 OTA 未全部实测,不能冒充已验证。
|
||||
|
||||
详细历史见 `2026-09-25_重启与连接恢复.md`。未来设备更新只登记移动端影响,不自动发布 App。本轮仅 debug 签名,不导出 IMG/OTA,不推进设备发布版本。
|
||||
|
||||
|
||||
2026-10-10:App 0.3.1 / code 7 动画与全屏浏览已实施并部署,协议1.3设备同时更新。JVM、模拟器、登记手机动画/缓存及设备原子更新验证通过;持久数据逐项旧基线未保存的追溯限制见 [本轮记录](2026-10-10_动画浏览/README.md)。
|
||||
|
||||
@@ -89,3 +89,9 @@ MOBILE-TEST-SELECTION:先评估直接及共享依赖影响,再选最低有
|
||||
MOBILE-LIBRARY-CACHE:显示内容的全部条目先请求 16×16 预览,再在预览阶段全部完成一次尝试后逐项取得 64×64 完整画面。缓存按持久设备 ID、类型、条目 ID、修订和档位隔离;连接、进入内容页及内容页前台定期检查设备目录修订,未变复用本地目录与图片,变化后先完整读取目录再清理失效缓存。离线不显示旧列表。单张失败显示占位且不阻塞后续项目;旧设备退回现有图库读取方式。当前设备画面不参与此缓存。
|
||||
|
||||
MOBILE-CACHE-CLEAR:软件设置显示内容缓存大小,确认后仅清空内容目录与图片缓存;不清除昵称、设备记忆、当前帧或设备内容。内容页前台才进行图片后台补传,控制操作优先,切页、后台、断线停止队列。
|
||||
|
||||
|
||||
## Android 动画浏览(2026-10-10)
|
||||
|
||||
MOBILE-LIBRARY-ANIMATION:先全部低清首图,再完整低清动画,再高清;可见动图按原时长循环,动画及部分下载纳入内容缓存。
|
||||
MOBILE-IMAGE-VIEWER:图库和设备画面点击全屏、缩放、返回;未完成时自动提示“开始下载当前图像”,提升当前项优先级,关闭后 300 ms 恢复普通队列,后台立即暂停。浏览不改变设备输出。
|
||||
|
||||
@@ -103,3 +103,8 @@ library.list 的用户条目遵循设备持久混合顺序,顺序变化影响
|
||||
task.get 可附加脱敏 error_code(AUTH_FAILED、NETWORK_UNAVAILABLE、IP_CONFIG_FAILED、UNKNOWN);旧客户端可忽略。没有可靠原因时用 UNKNOWN。wifi.get 仍不含密码。协议主版本保持 1。
|
||||
|
||||
兼容说明:原 mobile.transport 为 BLE 统计对象,保持原义;实际链路新增 active_transport,不改变旧字段类型。
|
||||
|
||||
|
||||
## Android 动画浏览(2026-10-10)
|
||||
|
||||
协议 1.3 新增 library_animation capability。library.animation.info 参数 type/id/revision、offset(默认0)、limit(默认50,1..50),返回 revision、frame_count、frames(index/duration_ms)、next_offset。library.animation.chunk 参数 type/id/revision、tier(preview/full)、frame_index、offset;返回 revision、tier、frame_index、offset、total_bytes、sha256、data_base64、next_offset,每块原始 PNG 字节最多4096。帧维持16×16/64×64、源顺序与时长;错误使用现有 BAD_REQUEST/CONFLICT/NOT_FOUND。原接口与加密消息格式不变。
|
||||
|
||||
@@ -90,3 +90,8 @@ DEVICE-MOBILE-LIBRARY-CACHE/MOBILE-LIBRARY-CACHE:设备协议 1.1 在主版
|
||||
## 2026-10-09 网页图层UI修正
|
||||
|
||||
修正颜色/复选框布局、不透明复制提示和退出图层移动;只影响网页DOM/CSS与编辑交互。scene/模板/动图/配置schema、设备REST/手机协议及驱动边界不变,App无需更新,无Android真机回归。沿用原子部署,保持VERSION,更新FEATURE_UPDATED_AT;验收证据单独归档。
|
||||
|
||||
|
||||
## Android 动画浏览(2026-10-10)
|
||||
|
||||
2026-10-10:DEVICE-MOBILE-ANIMATION / MOBILE-LIBRARY-ANIMATION / MOBILE-IMAGE-VIEWER,两端新增协议1.3动画读取及Android全屏优先补传;旧App保留静态接口,新App遇旧设备提示静态回退。驱动、显示输出、配置schema不变。仅debug APK与手工部署,不导出OTA/IMG。验证结果另行归档。
|
||||
|
||||
Reference in New Issue
Block a user