diff --git a/整体开发需求/03_移动端接入需求.md b/整体开发需求/03_移动端接入需求.md index 94889fb..0189ac2 100644 --- a/整体开发需求/03_移动端接入需求.md +++ b/整体开发需求/03_移动端接入需求.md @@ -50,3 +50,8 @@ TEST-MOBILE-SELECTION:运行器显式选择场景,支持不访问设备的 DEVICE-MOBILE-LIBRARY-CACHE:BLE 提供轻量目录修订查询及 16×16 静态预览;既有 64×64 缩略图接口保持兼容。设备为预览建立可再生缓存,并在启动与内容修订变化后清理失效项,不删除用户资源。目录修订覆盖条目内容与顺序。 TEST-MOBILE-LIBRARY-CACHE:验证两档图尺寸、修订一致性、网页修改后的失效与清理、旧客户端兼容;真实 BLE 对比首次加载和未变化重连的图片请求与字节数。当前帧、显示输出和驱动不变,无需新增实屏视觉验收。 + + +## Android 动画浏览(2026-10-10) + +DEVICE-MOBILE-ANIMATION:协议 1.3 新增 library_animation 能力及分页帧信息、4 KiB PNG 分块读取;保留旧静态接口,按内容修订验证,读取不改变显示。 diff --git a/核桃派软件源代码/FEATURE_UPDATED_AT b/核桃派软件源代码/FEATURE_UPDATED_AT index b03972e..928bfb7 100644 --- a/核桃派软件源代码/FEATURE_UPDATED_AT +++ b/核桃派软件源代码/FEATURE_UPDATED_AT @@ -1 +1 @@ -2026-10-10T10:18+08:00 +2026-10-10T14:17+08:00 diff --git a/核桃派软件源代码/app/mobile/control.py b/核桃派软件源代码/app/mobile/control.py index e7df00e..f3570d1 100644 --- a/核桃派软件源代码/app/mobile/control.py +++ b/核桃派软件源代码/app/mobile/control.py @@ -1,6 +1,7 @@ """Explicit mobile capability surface, sharing existing device business operations.""" from __future__ import annotations +import hashlib import base64 import json import time @@ -51,8 +52,8 @@ class MobileControl: def identity(self): record = self.identity_record return dict(device_id=record["device_id"], device_name=record["name"], - short_id=record["device_id"].replace("-", "")[:8], protocol_major=1, protocol_minor=2, - capabilities=["status", "settings", "library", "library_progressive", "playback", "frame", "device_name", "wifi", "wifi_scan", "wifi_transport"], + short_id=record["device_id"].replace("-", "")[:8], protocol_major=1, protocol_minor=3, + capabilities=["status", "settings", "library", "library_progressive", "library_animation", "playback", "frame", "device_name", "wifi", "wifi_scan", "wifi_transport"], limits=dict(max_message_bytes=65536, library_page_size=50, preview_interval_ms=2000)) def settings(self): @@ -97,7 +98,7 @@ class MobileControl: ("animation", [demo_animation()] + self.animations.list()["animations"])): for item in records: items.append(dict(type=kind, id=item["id"], name=item["name"], revision=item["revision"], - is_demo=bool(item.get("is_demo", False)), playable=kind == "template" or item.get("frame_count", 0) > 0, + frame_count=item.get("frame_count", 0), is_demo=bool(item.get("is_demo", False)), playable=kind == "template" or item.get("frame_count", 0) > 0, thumbnail_revision=item["revision"])) if self.library_order is not None: user_items = [item for item in items if not item["is_demo"]] @@ -205,6 +206,35 @@ class MobileControl: next_offset = offset + limit return dict(library_revision=revision, items=items[offset:next_offset], next_cursor=f"{revision}:{next_offset}" if next_offset < len(items) else None) + if method in ("library.animation.info", "library.animation.chunk"): + content = self._content(params) + if content["type"] != "animation" or not content["frames"]: + raise RpcError("NOT_FOUND", "内容没有动画帧") + frames = content["frames"] + if method.endswith(".info"): + offset, limit = params.get("offset", 0), params.get("limit", 50) + if type(offset) is not int or not 0 <= offset < len(frames) or type(limit) is not int or not 1 <= limit <= 50: + raise RpcError("BAD_REQUEST", "帧分页参数无效") + end = min(offset + limit, len(frames)) + return dict(revision=content["revision"], frame_count=len(frames), + frames=[dict(index=i, duration_ms=frames[i]["duration_ms"]) for i in range(offset, end)], + next_offset=end if end < len(frames) else None) + index, offset, tier = params.get("frame_index"), params.get("offset", 0), params.get("tier") + if type(index) is not int or not 0 <= index < len(frames) or type(offset) is not int or offset < 0 or tier not in ("preview", "full"): + raise RpcError("BAD_REQUEST", "帧读取参数无效") + image = self._preview_image(dict(type="animation", frames=[frames[index]])) + if tier == "preview": + image = image.resize((16, 16), Image.Resampling.LANCZOS) + output = BytesIO() + image.save(output, format="PNG", optimize=True) + data = output.getvalue() + if offset >= len(data): + raise RpcError("BAD_REQUEST", "帧字节偏移无效") + end = min(offset + 4096, len(data)) + return dict(revision=content["revision"], tier=tier, frame_index=index, offset=offset, + total_bytes=len(data), sha256=hashlib.sha256(data).hexdigest(), + data_base64=base64.b64encode(data[offset:end]).decode("ascii"), + next_offset=end if end < len(data) else None) if method == "library.thumbnail": content = self._content(params) return self._png(self._preview_image(content)) diff --git a/核桃派软件源代码/tests/test_mobile_control.py b/核桃派软件源代码/tests/test_mobile_control.py index e06e598..3c4d283 100644 --- a/核桃派软件源代码/tests/test_mobile_control.py +++ b/核桃派软件源代码/tests/test_mobile_control.py @@ -50,7 +50,7 @@ def test_progressive_preview_and_revision_cleanup(tmp_path): from PIL import Image app = create_app(project_root=tmp_path, driver_kind='mock') mobile = app.state.mobile_control - assert mobile.identity()['protocol_minor'] == 2 + assert mobile.identity()['protocol_minor'] == 3 assert 'wifi_transport' in mobile.identity()['capabilities'] assert 'library_progressive' in mobile.identity()['capabilities'] created = mobile.templates.create('预览清理', { @@ -220,3 +220,46 @@ def test_task_returns_only_sanitized_failure_code(tmp_path, failure): result = mobile.dispatch('task.get', {'task_id': task}) assert result == {'state': 'failed', 'stage': 'finished', 'error_code': failure} assert 'private' not in str(result) + + +def test_animation_chunks_and_read_only(tmp_path): + import base64 + import hashlib + from io import BytesIO + from PIL import Image + mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control + item = next(i for i in mobile.dispatch('library.list', {})['items'] if i['type'] == 'animation') + info = mobile.dispatch('library.animation.info', {**item, 'limit': 1}) + assert info['frames'][0]['index'] == 0 + frames = list(info['frames']) + while info['next_offset'] is not None: + info = mobile.dispatch('library.animation.info', {**item, 'limit': 1, 'offset': info['next_offset']}) + frames += info['frames'] + assert len(frames) == info['frame_count'] + assert all(f['duration_ms'] > 0 for f in frames) + before = mobile.dispatch('frame.get', {})['frame_revision'] + for tier, size in [('preview', 16), ('full', 64)]: + for frame in frames: + offset, data = 0, bytearray() + while True: + result = mobile.dispatch('library.animation.chunk', {**item, 'tier': tier, 'frame_index': frame['index'], 'offset': offset}) + chunk = base64.b64decode(result['data_base64'], validate=True) + assert 0 < len(chunk) <= 4096 and result['offset'] == offset + data.extend(chunk) + if result['next_offset'] is None: + break + assert result['next_offset'] == offset + len(chunk) + offset = result['next_offset'] + assert len(data) == result['total_bytes'] + assert hashlib.sha256(data).hexdigest() == result['sha256'] + with Image.open(BytesIO(data)) as image: + assert image.size == (size, size) + assert mobile.dispatch('frame.get', {})['frame_revision'] == before + for bad in [{'offset': -1}, {'limit': 51}, {'offset': True}]: + with pytest.raises(RpcError, match='BAD_REQUEST'): + mobile.dispatch('library.animation.info', {**item, **bad}) + for bad in [{'frame_index': -1}, {'frame_index': True}, {'tier': 'invalid'}, {'offset': 999999}]: + with pytest.raises(RpcError, match='BAD_REQUEST'): + mobile.dispatch('library.animation.chunk', {**item, 'tier': 'full', 'frame_index': 0, **bad}) + with pytest.raises(RpcError, match='CONFLICT'): + mobile.dispatch('library.animation.info', {**item, 'revision': 'stale'}) diff --git a/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md b/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md index a295fbc..77321e6 100644 --- a/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md +++ b/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md @@ -361,3 +361,10 @@ HTML验证经验:服务启动时读取首页模板;修改HTML后重启隔离 画笔及文字层移动后分别点击“退出图层移动”,检查仍处于全屏编辑、扩展显示和视线位置不变、面板隐藏、已提交偏移保留且撤销仍能恢复。切到视线后退出同样成立;拖动尚未松手时用键盘激活退出,候选取消且不增加历史。原来的“继续编辑”在移动图层时也应可用。可复用scripts/check_layers_browser.mjs;有持久化目标的UI操作只能针对独立测试ID,设备已有目标仅打开检查并取消,不能修改用户内容。 经验:全局input的width/min-height会作用于原生checkbox,必须以局部高优先级样式指定尺寸、padding及margin;不要改全局表单样式。CSS未定义变量且无fallback会使dialog背景声明失效,应复用已定义的surface变量。DOM颜色区新增子项需显式行列结构,不能依赖原两列grid自动排放。文案目录同时更新并执行--check。截图需避开固定顶栏遮挡,先将颜色标题滚动到顶栏下再裁取颜色区域。独立测试长名称遵守现有长度上限,测试数据创建后立即记录ID,以便后续失败也可精确清理。 + + +## 2026-10-10 动画浏览 + +TEST-MOBILE-ANIMATION对应DEVICE-MOBILE-ANIMATION:使用test_mobile_control.py验证帧信息分页、16/64尺寸、4 KiB上限、摘要和修订/参数拒绝;读取前后逻辑帧修订一致。原子更新完成后验证部署摘要、服务健康及持久数据不变;Android按影响选测,动图传输不新增实屏视觉验收。 + +部署数据检查:必须在更新前将逐项文件摘要保存到受保护材料,区分用户源/配置与已登记可再生缓存;不能只保留进程内字典和总差异数。更新后逐项核验,报告仅输出脱敏分类与统计。原始受保护基线不进入普通日志或Git。缺少基线时明确标记追溯验证不足,不能用启动成功替代源文件不变证据。 diff --git a/移动端相关内容/安卓app/如何安卓测试/README.md b/移动端相关内容/安卓app/如何安卓测试/README.md index ae552a5..563091e 100644 --- a/移动端相关内容/安卓app/如何安卓测试/README.md +++ b/移动端相关内容/安卓app/如何安卓测试/README.md @@ -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。 diff --git a/移动端相关内容/安卓app/如何安卓测试/device_test_runner.py b/移动端相关内容/安卓app/如何安卓测试/device_test_runner.py index 0ce2604..7c92028 100644 --- a/移动端相关内容/安卓app/如何安卓测试/device_test_runner.py +++ b/移动端相关内容/安卓app/如何安卓测试/device_test_runner.py @@ -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) diff --git a/移动端相关内容/安卓app/如何安卓测试/run_ble_test.py b/移动端相关内容/安卓app/如何安卓测试/run_ble_test.py index 3597f1a..f57c3cd 100644 --- a/移动端相关内容/安卓app/如何安卓测试/run_ble_test.py +++ b/移动端相关内容/安卓app/如何安卓测试/run_ble_test.py @@ -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'), diff --git a/移动端相关内容/安卓app/如何安卓测试/run_emulator_ui_test.py b/移动端相关内容/安卓app/如何安卓测试/run_emulator_ui_test.py index b2b0ba4..7bd6ffb 100644 --- a/移动端相关内容/安卓app/如何安卓测试/run_emulator_ui_test.py +++ b/移动端相关内容/安卓app/如何安卓测试/run_emulator_ui_test.py @@ -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' diff --git a/移动端相关内容/安卓app/如何安卓测试/常见问题与排障.md b/移动端相关内容/安卓app/如何安卓测试/常见问题与排障.md index 12493bb..df63b66 100644 --- a/移动端相关内容/安卓app/如何安卓测试/常见问题与排障.md +++ b/移动端相关内容/安卓app/如何安卓测试/常见问题与排障.md @@ -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。缺少基线时明确标记追溯验证不足,不能用启动成功替代源文件不变证据。 diff --git a/移动端相关内容/安卓app/如何安卓测试/按影响选测.md b/移动端相关内容/安卓app/如何安卓测试/按影响选测.md index c85f82f..222195e 100644 --- a/移动端相关内容/安卓app/如何安卓测试/按影响选测.md +++ b/移动端相关内容/安卓app/如何安卓测试/按影响选测.md @@ -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校验、损坏记录、修订清理。上述读取不改变核桃派内容,不切网、不启用摄像头,不扩展到无关页面。 diff --git a/移动端相关内容/安卓app/安卓开发详细设计.md b/移动端相关内容/安卓app/安卓开发详细设计.md index 2e62bc5..8752a6d 100644 --- a/移动端相关内容/安卓app/安卓开发详细设计.md +++ b/移动端相关内容/安卓app/安卓开发详细设计.md @@ -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缓存;完整高清缓存可直接恢复播放而无需重新请求低清。 diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/build.gradle.kts b/移动端相关内容/安卓app/安卓程序源代码/androidApp/build.gradle.kts index de9702d..cd82c46 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/androidApp/build.gradle.kts +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/build.gradle.kts @@ -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" } diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AnimationLibraryTest.kt b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AnimationLibraryTest.kt new file mode 100644 index 0000000..74d8956 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AnimationLibraryTest.kt @@ -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() + 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() + } + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryCacheTest.kt b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryCacheTest.kt index 4d037cd..6aa368b 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryCacheTest.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryCacheTest.kt @@ -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")) diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidLibraryCache.kt b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidLibraryCache.kt index bc01842..1a3ad64 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidLibraryCache.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidLibraryCache.kt @@ -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, 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) = 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 } diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/AnimationDownload.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/AnimationDownload.kt new file mode 100644 index 0000000..781e31c --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/AnimationDownload.kt @@ -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) +class AnimationDownload(val item: JsonObject, private val cache: LibraryCache) { + private var frames: List = emptyList() + private var infoOffset = 0 + private var infoComplete = false + private var index = 0 + private var activeTier = "preview" + val complete = mutableSetOf() + 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 + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt index 31e7743..293e7e3 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt @@ -36,6 +36,8 @@ data class ControllerState( val settings: JsonObject? = null, val wifi: JsonObject? = null, val library: List = emptyList(), val networks: List = emptyList(), val previews: Map = emptyMap(), val thumbnails: Map = emptyMap(), + val animationClips: Map = emptyMap(), val imagesForeground: Boolean = true, + val viewer: JsonObject? = null, val viewerProgress: String? = null, val imageErrors: Map = 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() + private val imageFailures = mutableSetOf() + 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 diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/LibraryCache.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/LibraryCache.kt index e15012a..cfa8b37 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/LibraryCache.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/LibraryCache.kt @@ -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, sha256: String) { error("动画缓存不可用") } suspend fun retain(deviceId: String, items: List) suspend fun sizeBytes(): Long suspend fun clear() diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/WifiTransport.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/WifiTransport.kt index b790999..b6e1fd9 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/WifiTransport.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/WifiTransport.kt @@ -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") } } diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt index cece1b9..bd017a3 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt @@ -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() + val releaseImage = CompletableDeferred() + val imageRequests = mutableListOf() var libraryRevision = "library-v1" var itemRevision = "item-v1" val called = mutableListOf() @@ -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()) diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/AnimationDownloadTest.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/AnimationDownloadTest.kt new file mode 100644 index 0000000..63060a8 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/AnimationDownloadTest.kt @@ -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(); val records = mutableMapOf() + 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, 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) {} + override suspend fun sizeBytes() = 0L + override suspend fun clear() { images.clear(); records.clear() } + } + @Test fun resumeAndUpgradePreserveTiming() = runBlocking { + val cache = Cache(); val offsets = mutableListOf() + 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 { + AnimationDownload(item, cache).step("device", "preview") { _, _ -> buildJsonObject { put("revision", "other") } } + } + assertTrue(cache.images.isEmpty()); assertTrue(cache.records.isEmpty()) + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/HybridRpcTest.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/HybridRpcTest.kt index a6036de..10e5001 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/HybridRpcTest.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/HybridRpcTest.kt @@ -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 diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt index 9d4b7bb..7709135 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt @@ -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(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(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 diff --git a/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/kotlin/org/qimiaoscreen/uitest/FixtureActivity.kt b/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/kotlin/org/qimiaoscreen/uitest/FixtureActivity.kt index 941d125..5beb6bf 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/kotlin/org/qimiaoscreen/uitest/FixtureActivity.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/kotlin/org/qimiaoscreen/uitest/FixtureActivity.kt @@ -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() val calls = mutableListOf() + val decodedMarkers = java.util.Collections.synchronizedSet(mutableSetOf()) 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() + private val records = mutableMapOf() + 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, sha256: String) { images[tier] = tier } + override suspend fun retain(deviceId: String, items: List) {} + override suspend fun sizeBytes() = 0L + override suspend fun clear() { images.clear(); records.clear() } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/kotlin/org/qimiaoscreen/uitest/IsolatedUiTest.kt b/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/kotlin/org/qimiaoscreen/uitest/IsolatedUiTest.kt index b793c38..c96e034 100644 --- a/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/kotlin/org/qimiaoscreen/uitest/IsolatedUiTest.kt +++ b/移动端相关内容/安卓app/安卓程序源代码/uiTest/src/main/kotlin/org/qimiaoscreen/uitest/IsolatedUiTest.kt @@ -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 } diff --git a/移动端相关内容/安卓app/构建与发布/README.md b/移动端相关内容/安卓app/构建与发布/README.md index aaf1302..45ac0f8 100644 --- a/移动端相关内容/安卓app/构建与发布/README.md +++ b/移动端相关内容/安卓app/构建与发布/README.md @@ -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或板端依赖;手工部署设备应用并覆盖安装手机,保持用户内容和设备正式版本。 diff --git a/移动端相关内容/安卓app/构建与发布/package_debug.py b/移动端相关内容/安卓app/构建与发布/package_debug.py index 5a2281d..8473178 100644 --- a/移动端相关内容/安卓app/构建与发布/package_debug.py +++ b/移动端相关内容/安卓app/构建与发布/package_debug.py @@ -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') diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.3.1-7/README.md b/移动端相关内容/安卓app/构建与发布/交付包/0.3.1-7/README.md new file mode 100644 index 0000000..225d488 --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/交付包/0.3.1-7/README.md @@ -0,0 +1,7 @@ +# 奇妙小屏幕 0.3.1 debug(versionCode 7) + +仅 debug 签名,可覆盖安装相同调试证书的旧包。Android 8.0 以上,真实验证机为 Android 13。 + +本包为当前开发验证产物;验收范围和未验证项见 `../../../测试结果归档/实施状态.md`。不能将 APK 成功生成视为所有首版验收通过。设备需已部署移动控制功能,历史设备版本号本身不代表支持 BLE。 + +SHA-256:`4b6cc7ba57290306d72bec3c9285fef47143045779427cfb1a11d73c7713a3cc`。源码摘要与签名证书摘要见 manifest.json。 diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.3.1-7/SHA256SUMS b/移动端相关内容/安卓app/构建与发布/交付包/0.3.1-7/SHA256SUMS new file mode 100644 index 0000000..0fecaae --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/交付包/0.3.1-7/SHA256SUMS @@ -0,0 +1 @@ +4b6cc7ba57290306d72bec3c9285fef47143045779427cfb1a11d73c7713a3cc 奇妙小屏幕-0.3.1-debug.apk diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.3.1-7/manifest.json b/移动端相关内容/安卓app/构建与发布/交付包/0.3.1-7/manifest.json new file mode 100644 index 0000000..d9b6d9b --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/交付包/0.3.1-7/manifest.json @@ -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" + } + ] +} diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.3.1-7/奇妙小屏幕-0.3.1-debug.apk b/移动端相关内容/安卓app/构建与发布/交付包/0.3.1-7/奇妙小屏幕-0.3.1-debug.apk new file mode 100644 index 0000000..7839ec5 Binary files /dev/null and b/移动端相关内容/安卓app/构建与发布/交付包/0.3.1-7/奇妙小屏幕-0.3.1-debug.apk differ diff --git a/移动端相关内容/安卓app/测试结果归档/2026-10-10_动画浏览/README.md b/移动端相关内容/安卓app/测试结果归档/2026-10-10_动画浏览/README.md new file mode 100644 index 0000000..86df7f5 --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-10-10_动画浏览/README.md @@ -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、源码摘要和本报告长期保留。 diff --git a/移动端相关内容/安卓app/测试结果归档/2026-10-10_动画浏览/animation-data-audit.json b/移动端相关内容/安卓app/测试结果归档/2026-10-10_动画浏览/animation-data-audit.json new file mode 100644 index 0000000..e5b2c1a --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-10-10_动画浏览/animation-data-audit.json @@ -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 +} \ No newline at end of file diff --git a/移动端相关内容/安卓app/测试结果归档/2026-10-10_动画浏览/animation-ui-final.json b/移动端相关内容/安卓app/测试结果归档/2026-10-10_动画浏览/animation-ui-final.json new file mode 100644 index 0000000..750bb42 --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-10-10_动画浏览/animation-ui-final.json @@ -0,0 +1,20 @@ +{ + "tests": [ + { + "scenario": "animationViewer", + "configuration": "baseline", + "result": "passed" + } + ], + "failure": null, + "cleanup_errors": [], + "not_selected": [ + "connection", + "wifiTransport", + "status", + "content", + "settings", + "navigation", + "layout" + ] +} \ No newline at end of file diff --git a/移动端相关内容/安卓app/测试结果归档/实施状态.md b/移动端相关内容/安卓app/测试结果归档/实施状态.md index fd060a5..bb95ad5 100644 --- a/移动端相关内容/安卓app/测试结果归档/实施状态.md +++ b/移动端相关内容/安卓app/测试结果归档/实施状态.md @@ -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)。 diff --git a/移动端相关内容/开发要求/移动端需求.md b/移动端相关内容/开发要求/移动端需求.md index b9c0165..59e425f 100644 --- a/移动端相关内容/开发要求/移动端需求.md +++ b/移动端相关内容/开发要求/移动端需求.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 恢复普通队列,后台立即暂停。浏览不改变设备输出。 diff --git a/移动端相关内容/开发要求/设备通信协议.md b/移动端相关内容/开发要求/设备通信协议.md index 9856d46..eb62cf0 100644 --- a/移动端相关内容/开发要求/设备通信协议.md +++ b/移动端相关内容/开发要求/设备通信协议.md @@ -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。原接口与加密消息格式不变。 diff --git a/移动端相关内容/开发要求/跨端兼容与变更记录.md b/移动端相关内容/开发要求/跨端兼容与变更记录.md index 16163a2..e282896 100644 --- a/移动端相关内容/开发要求/跨端兼容与变更记录.md +++ b/移动端相关内容/开发要求/跨端兼容与变更记录.md @@ -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。验证结果另行归档。