Improve controller behavior and update project requirements

This commit is contained in:
2026-09-30 15:42:36 +08:00
parent 55374356ed
commit 071f06d365
52 changed files with 2205 additions and 335 deletions
+6
View File
@@ -50,3 +50,9 @@ ANDROID-TEST-UI 增加四栏、三点菜单、昵称、长按保存设备、WiFi
- 每轮测试前读 `安卓app/如何安卓测试/常见问题与排障.md`;遇到已有症状先沿用已验证方法,不从头试错。
- 测试中出现失败、临时修正或恢复操作,完成后必须复盘。可复用的已验证解法更新对应测试流程、排障或构建文档;单次日志、耗时和重测结果写测试归档,未知根因明确保留,不把重测通过当作根因证明。
- 最终反馈须说明经验已写入哪些文档及未解决项;无新增经验时不重复造条目。不仅在聊天里解释,也不能只留一次性报告。遵守根目录秘密与文档路径规则。
## Android 按影响选测(当前规则)
每轮先评估直接和共享依赖影响,记录选测理由;测试矩阵不是每轮全量清单。未改且不受影响的页面/设置不跑真机回归。纯逻辑优先 JVM,普通 UI 优先独立模拟器;BLE、实际权限/后台厂商差异等才做必要的定向真机验证。root 授权不代表真机必测;root 输入规则仅适用于登记真机,模拟器使用标准 Compose 测试 API。必要但没做的验证不能写成不适用。切网、长轮询、性能、生命周期和全量验收均需具体影响理由,不能顺带执行。
具体触发条件、命令和证据格式见 [按影响选测](安卓app/如何安卓测试/按影响选测.md)。该规则替代历史专项“每轮执行”要求,视觉/物理操作、凭据和真机登记门禁不变。
+4
View File
@@ -23,3 +23,7 @@
## 视觉检查
普通前端、App 界面、BLE 入口开发没有改变驱动或显示输出实现时,不新增实屏视觉验收。协议、输出帧和驱动统计优先。确需视觉检查时先停止相关步骤,说明原因,询问用户如何启动并等待明确指示;不得自动开启摄像头、DroidCam、采集或视觉测试程序,不主动切换实屏测试图案。专用测试手机授权不等于视觉检查授权。不适用时记录“不适用”。
## Android 选测入口
遵循 [按影响选测](安卓app/如何安卓测试/按影响选测.md)。日常变更不自动执行模拟器或 root 真机全量矩阵;独立 uiTest 使用真实 Compose 界面与 Controller、测试专用假平台,不替代物理 BLE。历史版本专项不作为每轮门槛。
@@ -1,5 +1,7 @@
# Android 可重复测试流程
**先读 [按影响选测](按影响选测.md)。以下矩阵列出可选覆盖,不是每轮必跑清单;历史版本专项仅在相关影响存在时执行。**
本文件维护步骤与合格标准,不写一次性通过结果。每轮测试前阅读 [常见问题与排障](常见问题与排障.md),优先沿用已验证方法。结果记录到 `../测试结果归档/`。真机先检查 `测试设备登记/prepare_test_device.py`,多机精确选择,日志仅使用代号。源码与 UI 尚未实现的测试不得写成通过。
远端联调脚本在电脑端使用 `requirements-host.txt` 的 Paramiko;它不属于核桃派离线安装依赖,不能加入设备端 `requirements-dev.txt` 或 OTA wheelhouse。
@@ -24,6 +26,7 @@
| ANDROID-TEST-BLE | DISCOVERY、CONNECTION、SECURITY | 主测试机真实扫描、握手、断开、后台释放、返回重连、设备服务重启、异常断线,全部无需系统配对。 |
| ANDROID-TEST-WIFI | WIFI、WIFI-SECRET | 核桃派扫描、隐藏 SSID 输入、字段校验、立即生效与确认取消、错误密码、切网后 BLE 状态仍可查;无密码回读/日志泄露。实际网络变更保留恢复配置。 |
| ANDROID-TEST-CONTROL | LIBRARY、PLAYBACK、DEFAULT、PREVIEW | 播放/默认/动画控制的返回值、状态与逻辑帧一致;预览单在途;用专用内容验证后恢复此前内容,不启动视觉程序。 |
| ANDROID-TEST-LIBRARY-CACHE | MOBILE-LIBRARY-CACHE、DEVICE-MOBILE-LIBRARY-CACHE | 16×16 先于 64×64、设备修订变化清理两端失效图、重连复用、清空与损坏保护、旧设备回退;真实 BLE 仅只读,不切换显示内容。 |
| ANDROID-TEST-WEB | WEB-COEXIST | 网页修改手机可见,手机修改网页可见;连接短提示与常驻名称正确;初次加载不伪造连接事件;昵称 HTML 不执行。 |
| ANDROID-TEST-TWO-PHONES | CONNECTION | 提供 APK 给用户装第二台;A 连接 B 不能抢占,A 断开 B 可连接,A 异常退出可释放;真实双方结果分别记录。 |
| ANDROID-TEST-PERF | PREVIEW、STATUS | 广播、预览、WiFi 扫描与动画并发,记录实际刷新/截止丢失/CPU/内存,与启用前基线及既有标准比较,不要求新增目测。 |
@@ -32,13 +35,11 @@
## 3. 执行顺序
1. 测试登记脚本与仓库卫生检查。
2. 共享逻辑、设备业务、协议、现有本机回归;失败先修复。
3. 模拟设备的 Android UI 与权限测试。
4. 主测试机 BLE/控制/网络/网页联调,再做性能和生命周期。
5. 首次双手机验收由用户安装交付 APK 协助;日常只用主机,连接仲裁/广播/协议变化才重测。
6. 隔离重建、最终 debug APK 和脱敏证据归档。
7. 复盘本轮失败与调整:将已验证解法补到排障指南及对应流程,单次证据保留归档;最终反馈说明更新了哪些经验文档、哪些问题仍未解决。
1. 记录变更和共享影响,按选测规则选择编号、场景、配置;跳过无影响项。
2. 执行相关宿主逻辑与构建检查;UI 改动按需运行独立模拟器场景。
3. 只有必要的真机场景才检查登记、ADB/root 和所需板端凭据;不因测试手机不在线阻塞纯逻辑/UI 工作。
4. 根据选中场景执行定向联调,恢复所有本轮改动;失败不得跳过必要证据。
5. 只有构建/交付需要才隔离重建、打包。归档实际覆盖及未验证边界,沉淀已验证经验。
当前可以执行的登记单元测试(从仓库根目录):
@@ -46,20 +47,31 @@
python -X utf8 -m unittest discover -s "移动端相关内容/安卓app/如何安卓测试/测试设备登记" -p "test_*.py"
```
工程完成后,本文件必须补上经过验证的 Gradle task 与端到端脚本入口;不能先编造不存在的可执行命令。测试脚本应读取私有登记并在进程内传递目标,避免原始 ADB 输出进入报告。
Gradle task 与按场景执行命令见 [按影响选测](按影响选测.md)。测试脚本应读取私有登记并在进程内传递目标,避免原始 ADB 输出进入报告。
### 已提供的真机测试入口
用构建脚本生成 `:androidApp:assembleDebug` 和 `:androidApp:assembleDebugAndroidTest` 后:
```text
python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --adb "<已有ADB路径>" --build "<本轮隔离工程目录>"
python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --scenario connection --adb "<已有ADB路径>" --build "<本轮隔离工程目录>"
```
脚本先验证 android-test-a 的 USB 登记,从凭据门禁后的目标核桃派读取广播短名,仅扫描该板,安装覆盖两个 debug APK,再执行 `BleIntegrationTest`。不把临时广播身份写入公开报告。此测试只读状态、设置、内容、帧与 WiFi,不播放内容,不切网;断开后再连接核对持久 ID。运行器先验证 root 并为本 App 授予必要权限;所有点击经 root 按实时语义位置执行。权限未稳定生效必须停止等待用户,不使用普通注入或无障碍备用路径。
脚本先验证 android-test-a 的 USB 登记,从凭据门禁后的目标核桃派读取广播短名,仅扫描该板,安装覆盖两个 debug APK,再执行所选 `BleIntegrationTest` 场景。不把临时广播身份写入公开报告。connection 仅握手、断开再连接核对持久 ID;其它读取、轮询、WiFi 扫描和后台恢复分别显式选择。运行器先验证 root 并为本 App 授予必要权限;所有点击经 root 按实时语义位置执行。权限未稳定生效必须停止等待用户,不使用普通注入或无障碍备用路径。
脚本只有明确 `OK (1 test)` 才返回成功;测试入口存在不代表真实验收通过。结果单独归档。
### “显示内容”两档图片与缓存
先用 `:sharedCore:jvmTest` 检查两轮顺序、修订变化、断线取消和旧设备回退;用独立模拟器的 `content`、`settings` 场景检查预览替换和清空确认。需要验证 Android 文件缓存时选 `cache-storage`,只访问登记手机;需要验证真实板端图片读取和未变化重连时再选 `library-cache`,先运行仓库的板端凭据准备门禁。两种真机场景都使用隔离构建目录中的产品 debug APK 与 androidTest APK:
```text
python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --scenario cache-storage --adb "<已有ADB路径>" --build "<本轮隔离工程目录>"
python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --scenario library-cache --adb "<已有ADB路径>" --build "<本轮隔离工程目录>"
```
合格标准:板端摘要的目录修订能反映条目或顺序变化,16×16 与 64×64 PNG 均可解码,失效小图被清理;App 首轮小图全部尝试后再读完整图,单图失败仍继续;目录未变时复用有效缓存且第二次图片请求为零,目录变化时删去旧修订,跨设备隔离,损坏文件按未命中处理,清空只影响内容缓存。记录首次和重连的图片请求数及 PNG 负载字节数,不将负载当作无线链路总字节数。真实 BLE 测试只读,不选择新显示内容;“当前设备画面”仍走原帧接口。
## 4. 证据与恢复
开发部署的真实失败恢复测试使用 `deploy_mobile_dev.py --deploy --test-rollback`,必须先结束手机控制会话。它和普通部署一样先保存可用程序副本,然后在安装文件及 systemd 配置完成后故意返回 97,触发同一 EXIT 回滚路径。脚本比较测试前后被部署程序文件、config.json、wifi_config.json 和持久身份的摘要,并确认真实主服务 active;随后仍需执行 BLE/生命周期恢复检查。此选项会短暂停止服务,但不主动切换测试图案,也不等同正式 OTA 或断电回滚测试。恢复副本保留供排查,不能把故意失败当作普通部署完成。
@@ -71,9 +83,9 @@ python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --ad
`ControllerRecoveryTest` 的测试专用 SimulatedPlatform/SimulatedLink 使用同一分片与 RPC 路径,替换加密为显式测试直通实现,覆盖断线清缓存并重连、未完成请求取消、新旧扫描回调隔离。只在 commonTest 编译,生产扫描与 APK 不包含模拟入口;加密正确性由独立跨语言黄金向量测试覆盖。
默认 BLE 回归额外执行真实 `wifi.scan`,断言返回有界且不含密码,不输出网络名称;提交故意过期的设置修订,要求返回 CONFLICT 并继续 ping 成功。不会实际改配置。
显式选择 `--scenario wifi-scan` 才执行真实 `wifi.scan`,断言返回有界且不含密码,不输出网络名称;另选 `--scenario conflict` 提交故意过期的设置修订,要求返回 CONFLICT 并继续 ping 成功。不会实际改配置。
真实网络激活验证可显式加 `--reapply-current-wifi`:先确认保存配置与当前已连网络相同,保留密码并将该原配置立即应用;轮询任务到成功,核对恢复连接及保存字段不变。可能短暂影响板端 WiFi,执行前说明;不替代不同 SSID、替换密码、DHCP/静态地址互换的单独验收。默认不启用,不能拿重新应用原网络当作所有网络场景都通过。
真实网络激活验证显式选择 `--scenario wifi-reapply`:先确认保存配置与当前已连网络相同,保留密码并将该原配置立即应用;轮询任务到成功,核对恢复连接及保存字段不变。可能短暂影响板端 WiFi,执行前说明;不替代不同 SSID、替换密码、DHCP/静态地址互换的单独验收。默认不启用,不能拿重新应用原网络当作所有网络场景都通过。
`ANDROID-TEST-WIFI` 的双网络专项入口是 `run_wifi_switch_test.py --adb <已有ADB路径> --build <本轮隔离工程目录>`,需要先构建 `:androidApp:assembleDebugAndroidTest`。脚本通过 Windows 当前移动热点接口在进程内读取第二网络参数;App 仪器测试经短时私有文件读取密码,不把密码放在 ADB 参数或报告中。错误密码会令唯一受管网络暂时失联,必须单独加 `--wrong-only` 运行;该分支经 App 提交、BLE 任务失败状态与无密码日志核验,并用短时恢复计时器回到原网络。确认此分支后再加 `--skip-wrong`,依次验证热点 DHCP、静态 IPv4、回到 DHCP、立即恢复原网络和原网络复核。两种模式都先执行字段校验与 App 扫描,并在板端保存受保护的原 NetworkManager 配置;结束时检查恢复副本和定时器的清理结果。失败时若 SSH 因切网中断,等待原网络恢复并核验后再清理,不能把临时断链当成恢复失败或直接删除备份。
@@ -81,42 +93,42 @@ python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --ad
`--validate-only` 只执行表单与无效 IPv4 校验,不切网;`--final-check` 只复核原网络的 App、BLE、板端状态及恢复材料清理。所有模式均遵守相同的凭据门禁和手机登记检查。
`--verify-current-controls` 用当前修订重新提交现有亮度,要求回读不变;若设备已有活动动画,暂停、提交当前暂停位置和原倍速,finally 恢复原暂停状态。最后核对开机默认未改变。它不选择新内容或测试图案,也不代替改变亮度值、选择其他内容/默认内容的完整验收。无活动动画时明确记录该子项不适用。
`--scenario controls` 用当前修订重新提交现有亮度,要求回读不变;若设备已有活动动画,暂停、提交当前暂停位置和原倍速,finally 恢复原暂停状态。最后核对开机默认未改变。它不选择新内容或测试图案,也不代替改变亮度值、选择其他内容/默认内容的完整验收。无活动动画时明确记录该子项不适用。
部分厂商在覆盖安装返回成功后异步重置权限;已授权 `--grant-root` 时脚本最多三次授予本 App 蓝牙权限,每次等待 3 秒再复核,未稳定授予就停止。不得以此授权修改其他系统安全开关。
部分厂商在覆盖安装返回成功后异步重置权限;选中需要 BLE 的真机场景时脚本最多三次授予本 App 蓝牙权限,每次等待 3 秒再复核,未稳定授予就停止。不得以此授权修改其他系统安全开关。
性能观察使用 `sample_mobile_performance.py --seconds 150 --output <新的报告JSON路径>`:仅保存刷新率、帧计数、截止丢失、OE 故障、CPU、内存、连接/动画布尔状态;不保存 SSID、标识、密码或完整状态。可与 BLE 回归同时运行。该采样属于运行观察,蓝牙始终开启时不能宣称已完成启用前后的因果对照实验。
真实服务生命周期检查:主测试手机先主动断开,运行 `python "移动端相关内容/安卓app/如何安卓测试/check_mobile_lifecycle.py" --restart-service`。脚本经过板端凭据门禁,实际 systemctl stop/start 主服务,以临时 sentinel 验证 RuntimeDirectory 在显式停服及启动后均保留;核对 config.json、wifi_config.json、mobile/identity.json 内容摘要不变,只输出布尔结果;最后删除自身 sentinel。停止后无论中间断言如何都尝试启动服务;不能用这项烟雾测试代替完整部署故障回滚、OTA 或物理显示验收。
可使用同一运行器 `--test activity` 执行 `AppLifecycleTest`:通过 UI Automator 语义节点操作实际 Activity,核验四栏入口、后台超过 30 秒再返回自动重连及主动断开。此测试不截图、不启动摄像头、不改变设备显示内容;与 Controller 直测分开记录。
可使用同一运行器 `--scenario activity-background` 执行 `AppLifecycleTest`:通过 UI Automator 语义节点操作实际 Activity,核验后台超过 30 秒再返回自动重连及主动断开;四栏菜单另选 navigation。此测试不截图、不启动摄像头、不改变设备显示内容;与 Controller 直测分开记录。
若 MIUI 拒绝后台启动,测试通过 shell 显式启动本 App;若再拒绝输入注入,不直接修改系统权限。本软件测试默认启用 `--root-input`:根据语义查找到的本 App 控件当前位置发送 root input tap;后台测试仅发送 Home 键。运行器默认启用 root 输入,不提供普通输入备用分支。
若 MIUI 拒绝后台启动,测试通过 shell 显式启动本 App;若再拒绝输入注入,不直接修改系统权限。登记真机的 Activity 场景默认启用 root 输入:根据语义查找到的本 App 控件当前位置发送 root input tap;后台测试仅发送 Home 键。运行器默认启用 root 输入,不提供普通输入备用分支。
同时已获得 root 切换手机蓝牙授权时,可再传 `--toggle-bluetooth`:在已连接页面关闭蓝牙,等待连接状态清除、扫描入口恢复,再打开蓝牙并通过 App 扫描和连接原设备;之后继续四栏及后台测试。此参数必须与 `--test activity --root-input` 同用。测试与主机运行器都在 finally 尝试恢复蓝牙,错误不能把手机留在测试关闭状态。
同时已获得 root 切换手机蓝牙授权时,显式选择 `--scenario bluetooth-toggle`:在已连接页面关闭蓝牙,等待连接状态清除、扫描入口恢复,再打开蓝牙并通过 App 扫描和连接原设备;该场景只验证蓝牙开关恢复,不捆绑四栏及后台测试。测试与主机运行器都在 finally 尝试恢复蓝牙,错误不能把手机留在测试关闭状态。
`run_ble_test.py` 使用登记手机和凭据门禁指定的开发板;覆盖安装 debug App 与测试 APK。运行器默认启用 `--grant-root`,用于覆盖安装后重新授予此 App 的蓝牙扫描/连接权限。脚本不输出真实手机编号和开发板广播编号。
`run_ble_test.py` 使用登记手机和凭据门禁指定的开发板;覆盖安装 debug App 与测试 APK。运行器在 BLE 场景中使用既有 root 授权,用于覆盖安装后重新授予此 App 的蓝牙扫描/连接权限。脚本不输出真实手机编号和开发板广播编号。
`BleIntegrationTest` 先完成真实加密只读会话,读取状态、容量、设置、内容目录、当前帧和不含密码的网络信息;连续 60 轮间隔 2 秒读取状态与帧,主动断开后再次握手并核对持久设备 ID。随后使用生产 Controller 与真实 BLE 验证四栏数据读取、缩略图队列、短暂后台后返回取消释放、后台 30 秒释放、返回前台仅重连上次设备。测试不修改网络或显示内容。会话故障须保留具体阶段与错误,不把模拟测试结果标为实机通过。
历史完整 BLE 验收的覆盖如下(现已拆分为明确场景,不能单跑一项声称全部通过):`BleIntegrationTest` 先完成真实加密只读会话,读取状态、容量、设置、内容目录、当前帧和不含密码的网络信息;连续 60 轮间隔 2 秒读取状态与帧,主动断开后再次握手并核对持久设备 ID。随后使用生产 Controller 与真实 BLE 验证四栏数据读取、缩略图队列、短暂后台后返回取消释放、后台 30 秒释放、返回前台仅重连上次设备。测试不修改网络或显示内容。会话故障须保留具体阶段与错误,不把模拟测试结果标为实机通过。
`RpcTimeoutTest` 验证内部请求超时关闭传输、返回普通通信异常并拒绝继续使用旧 RPC;这与用户主动取消协程不同,后者不自动重试。
## 2026-09-26 用户最新 root 测试授权(替代前述按用途重复询问规则)
本软件调试所需各种测试权限可直接通过登记测试手机的 ADB root 获取,无需询问。所有自动测试点击一律使用 root,根据本 App 实时语义控件位置定位;滚动同样使用 root。不得通过无障碍或普通注入绕过 MIUI。测试前验证登记、ADB 和 root;权限不足或特殊情况必须停止聊天等待用户,不继续不完整测试。授权仅限调试本软件所需操作,不扩大到其他应用数据,视觉与物理操作门禁继续有效。
本软件调试所需各种测试权限可直接通过登记测试手机的 ADB root 获取,无需询问。登记真机的自动测试点击和滚动一律使用 root,根据本 App 实时语义控件位置定位;独立模拟器使用标准 Compose 测试 API。不得通过无障碍或普通注入绕过 MIUI。测试前验证登记、ADB 和 root;权限不足或特殊情况必须停止聊天等待用户,不继续不完整测试。授权仅限调试本软件所需操作,不扩大到其他应用数据,视觉与物理操作门禁继续有效。
ANDROID-TEST-UI 增加四栏、三点菜单、昵称、长按保存设备、WiFi折叠/DHCP/确认;ANDROID-TEST-STATE 增加记忆迁移、忘记、连接中扫描、切换;ANDROID-TEST-WIFI 增加结构化失败文案;ANDROID-TEST-CONTROL 增加设备混合内容顺序。
### 四栏版专项入口
- `run_ble_test.py` 默认验证 root 并授予本 App 蓝牙权限;Activity 所有输入均为 root。`--test activity` 覆盖四栏、菜单、真实重命名和重连核验、恢复原昵称、忘记和再次手动连接。BLE 测试将设备网页顺序摘要与完整 BLE 分页列表摘要比较,不输出条目 ID。
- `run_ble_test.py` 执行选中真机场景时验证 root;仅 BLE 场景授予本 App 蓝牙权限;Activity 所有输入均为 root。navigation、activity-background、rename、remember 分别覆盖导航、后台恢复、昵称恢复和忘记重连,不相互捆绑。library-order 场景将设备网页顺序摘要与完整 BLE 分页列表摘要比较,不输出条目 ID。
- `run_wifi_switch_test.py --validate-only` 不需要电脑热点,验证空 SSID、确认弹窗、静态 IP 校验;`--wrong-only` 使用当前受管网络及故意错误测试密码,先备份并建立定时恢复,最后核验原网络和清理。只有跨 SSID 的 DHCP/静态往返才需要电脑热点,按前述 `--skip-wrong` 流程运行。
- `run_ui_layout_test.py --adb <已有ADB路径> --build <本轮隔离工程目录>` 在登记手机上临时测试约 340dp 宽度、1.3 倍字体和横屏。输入通过 root,语义窗口只用于定位和检查,不执行无障碍动作;电脑端 finally 恢复密度、字体和方向,字体/方向回读核验;若额外改变 USB 保持唤醒等设置,也必须保存、恢复并核验。权限不足停止;安全锁屏不能正常解锁时等待用户,不绕过密码。
- `run_ui_layout_test.py --scenario layout --adb <已有ADB路径> --build <本轮隔离工程目录>` 在登记手机上临时测试约 340dp 宽度、1.3 倍字体和横屏。输入通过 root,语义窗口只用于定位和检查,不执行无障碍动作;电脑端 finally 恢复密度、字体和方向,字体/方向回读核验;若额外改变 USB 保持唤醒等设置,也必须保存、恢复并核验。权限不足停止;安全锁屏不能正常解锁时等待用户,不绕过密码。
- root 辅助脚本保持 LF 换行;文本通过 App 内临时剪贴板和 root 按键输入,结束清理,不把密码放在命令参数中。
排障指南按症状记录 root 命令解析、LF 换行、布局进程重建、语义定位、表单输入、安装权限、BLE 阶段释放及 WiFi 恢复。遇到这些情况先查 [对应解法](常见问题与排障.md),不得重复试用已知不可靠的输入或转储方法。
### 0.2.1 顶栏、连接条目与居中验收
ANDROID-TEST-STATE:共享逻辑验证自动/手动连接后关联、重新扫描保留、旧回调隔离、切换和失败/异常清理。ANDROID-TEST-UI:运行 run_ui_layout_test.py,在每组字体/方向下连接真实目标,按稳定语义资源标签检查连接条目断开、再次扫描保留、实际断开和再连;两页预览图片与标题中心距卡片中心不超过3物理像素,刷新位于右侧且无重叠。所有输入通过 root;测试末尾主动断开,运行器恢复布局设置。
ANDROID-TEST-STATE:共享逻辑验证自动/手动连接后关联、重新扫描保留、旧回调隔离、切换和失败/异常清理。ANDROID-TEST-UI:仅需厂商真机布局补证时运行 run_ui_layout_test.py --scenario layout,普通布局改动优先独立模拟器。真机专项在每组字体/方向下连接真实目标,按稳定语义资源标签检查连接条目断开、再次扫描保留、实际断开和再连;两页预览图片与标题中心距卡片中心不超过3物理像素,刷新位于右侧且无重叠。所有输入通过 root;测试末尾主动断开,运行器恢复布局设置。
顶栏保留完整文字语义,菜单位于固定区域;长文本循环参数由共享实现及编译核验,语义边界不证明文字实际运动,不记为目视验收。未取得本次视觉指示时不截图、不启动摄像头。纯手机界面调整的实屏视觉检查不适用,不需切网、显示内容切换或驱动测试。
@@ -0,0 +1,206 @@
"""Run registered BLE tests; WiFi reapplication requires its explicit option."""
import argparse
import json
from pathlib import Path
import subprocess
import sys
import time
sys.path.insert(0, str(Path(__file__).resolve().parent / '测试设备登记'))
from prepare_test_device import load_registration, check_connected
def main(argv=None):
parser = argparse.ArgumentParser()
parser.add_argument('--scenario', required=True)
parser.add_argument('--adb', required=True)
parser.add_argument('--build', type=Path, required=True)
parser.add_argument('--poll-cycles', type=int, default=60)
parser.add_argument('--test', choices=('ble', 'activity', 'layout'), default='ble')
parser.add_argument('--root-input', action='store_true', help='Compatibility flag; activity tests always use authorized root input')
parser.add_argument('--reapply-current-wifi', action='store_true', help='Explicitly reapply the already active saved WiFi profile over BLE; may briefly reconnect WiFi')
parser.add_argument('--verify-current-controls', action='store_true', help='Reapply current brightness; briefly pause active animation and restore without selecting new content')
parser.add_argument('--toggle-bluetooth', action='store_true', help='Requires explicit root Bluetooth-switch authorization plus activity root input')
parser.add_argument('--grant-root', action='store_true', help='Compatibility flag; runner always verifies and grants authorized App permissions')
args = parser.parse_args(argv)
from run_ble_test import SCENARIOS
from run_emulator_ui_test import passed
if args.scenario not in SCENARIOS or args.test != SCENARIOS[args.scenario][0]:
parser.error('Scenario and instrumentation family do not match')
if args.reapply_current_wifi != (args.scenario == 'wifi-reapply') or args.verify_current_controls != (args.scenario == 'controls') or args.toggle_bluetooth != (args.scenario == 'bluetooth-toggle'):
parser.error('Mutation flags must match the explicit scenario')
if args.poll_cycles and args.scenario != 'polling': parser.error('Polling requires polling scenario')
args.grant_root = True
if args.test in ("activity", "layout"): args.root_input = True
if not 0 <= args.poll_cycles <= 60:
parser.error('poll-cycles must be between 0 and 60')
if args.root_input and args.test == 'ble':
parser.error('root-input only applies to activity tests')
if args.reapply_current_wifi and args.test != 'ble':
parser.error('reapply-current-wifi only applies to BLE tests')
if args.verify_current_controls and args.test != 'ble':
parser.error('verify-current-controls only applies to BLE tests')
if args.toggle_bluetooth and not (args.test == 'activity' and args.root_input):
parser.error('toggle-bluetooth requires activity and root-input')
device = next(d for d in load_registration() if d['alias'] == 'android-test-a')
check_connected(args.adb, device)
root_check = subprocess.run([args.adb, '-s', device['serial'], 'shell', 'su', '-c', 'id -u'], capture_output=True, text=True, timeout=15)
if root_check.returncode or root_check.stdout.strip() != '0':
raise RuntimeError('测试手机 root 权限不可用;停止测试,等待用户处理,不使用备用注入方式')
board_id = board_name = library_hash = ""
if SCENARIOS[args.scenario][1]:
from remote_support import connect, execute
client, fields = connect()
try:
code, _, _ = execute(client, "command -v python3 >/dev/null && python3 --version", password=fields['密码'])
if code: raise RuntimeError('Remote Python unavailable')
code, output, error = execute(client, "python3 -c \"import json; p=json.load(open('/var/lib/matrix-screen-controller/mobile/identity.json')); print(json.dumps({'device_id':p['device_id']}))\"", password=fields['密码'])
if code:
raise RuntimeError('Cannot read authorized board identity')
board_id = json.loads(output)['device_id']
from uuid import UUID
UUID(board_id)
board_name = 'QMS-' + board_id.replace('-', '')[:8]
library_hash = ""
if args.scenario == "library-order":
code, output, _ = execute(client, "python3 -c \"import urllib.request,json,hashlib; d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/library/order',timeout=5)); print(hashlib.sha256('\\n'.join(i['type']+'|'+i['id'] for i in d['items']).encode()).hexdigest())\"", password=fields['密码'])
library_hash = output.strip()
if code or len(library_hash) != 64 or any(c not in '0123456789abcdef' for c in library_hash):
raise RuntimeError('Cannot read device library order digest')
if not board_name.startswith('QMS-') or len(board_name) != 12:
raise RuntimeError('Invalid board identity')
finally:
client.close()
def adb(*command, timeout=120, quiet=False):
try:
result = subprocess.run([args.adb, '-s', device['serial'], *command], capture_output=True,
text=True, encoding='utf-8', errors='replace', timeout=timeout)
except subprocess.TimeoutExpired:
# ADB's host timeout does not stop Android instrumentation itself.
if command[:3] == ('shell', 'am', 'instrument'):
subprocess.run([args.adb, '-s', device['serial'], 'shell', 'am', 'force-stop',
'org.qimiaoscreen.controller'], capture_output=True, timeout=15)
raise RuntimeError('ADB operation timed out; device identifiers omitted') from None
output = result.stdout + result.stderr
for secret, alias in ((device['serial'], '[test-phone]'), (board_name, '[test-board]'), (board_id, '[test-board-id]')):
if secret: output = output.replace(secret, alias)
if not quiet:
print(output, flush=True)
if result.returncode:
raise RuntimeError('ADB command failed')
return output
api = int(adb('shell', 'getprop', 'ro.build.version.sdk', quiet=True).strip())
permissions = ('BLUETOOTH_SCAN', 'BLUETOOTH_CONNECT') if api >= 31 else ('ACCESS_FINE_LOCATION',)
permission_original = {}
if args.scenario == 'navigation':
existing = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True)
permission_original = {p: 'android.permission.' + p + ': granted=true' in existing for p in permissions}
layout_original = None
layout_density = None
try:
for relative in ('androidApp/build/outputs/apk/debug/androidApp-debug.apk',
'androidApp/build/outputs/apk/androidTest/debug/androidApp-debug-androidTest.apk'):
apk = args.build / relative
if not apk.is_file():
raise RuntimeError('APK missing')
if 'Success' not in adb('install', '-r', str(apk)):
raise RuntimeError('APK installation did not succeed')
adb('shell', 'am', 'force-stop', 'org.qimiaoscreen.controller')
if args.grant_root and args.scenario != 'navigation':
# Some vendor package managers asynchronously reset permissions after
# install succeeds. Verify after a settling window, not just at grant.
for attempt in range(3):
for permission in permissions:
adb('shell', 'su', '-c', 'pm grant org.qimiaoscreen.controller android.permission.' + permission)
time.sleep(3)
verified = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True)
if all('android.permission.' + p + ': granted=true' in verified for p in permissions):
break
else:
raise RuntimeError('Bluetooth permission did not remain granted after installation')
if args.scenario == 'navigation':
for permission in permissions:
adb('shell', 'su', '-c', 'pm revoke org.qimiaoscreen.controller android.permission.' + permission, quiet=True)
current = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True)
if any('android.permission.' + p + ': granted=true' in current for p in permissions):
raise RuntimeError('Navigation must not retain permission to auto-connect')
if args.root_input:
adb('push', str(Path(__file__).with_name('authorized_app_input.sh')), '/data/local/tmp/qms-authorized-input.sh', quiet=True)
if args.scenario == "connection-entry":
adb("push", str(Path(__file__).with_name("authorized_root_command.sh")), "/data/local/tmp/qms-authorized-root.sh", quiet=True)
if args.scenario == "layout":
adb("push", str(Path(__file__).with_name("authorized_root_command.sh")), "/data/local/tmp/qms-authorized-root.sh", quiet=True)
def root(command):
return adb("shell", "su", "-c", command, quiet=True).strip()
import re
layout_original = {key: root("settings get system " + key) for key in ("font_scale", "accelerometer_rotation", "user_rotation")}
layout_density = re.search(r"Override density: (\d+)", root("wm density"))
layout_density = layout_density[1] if layout_density else "reset"
dimensions = re.findall(r"(\d+)x(\d+)", root("wm size"))[-1]
narrow = min(map(int, dimensions)) * 160 // 340
for font, rotation in (("1.0", "0"), ("1.3", "0"), ("1.3", "1")) if args.scenario == "layout" else ((None, None),):
if layout_original:
root("am force-stop org.qimiaoscreen.controller")
root("wm density " + str(narrow))
root("settings put system font_scale " + font)
root("settings put system accelerometer_rotation 0")
root("settings put system user_rotation " + rotation)
root("input keyevent KEYCODE_WAKEUP")
root("wm dismiss-keyguard")
root("am start -W -n org.qimiaoscreen.controller/.MainActivity")
board_args = ['-e', 'device_name', board_name] if board_name else []
if library_hash: board_args += ['-e', 'library_order_hash', library_hash]
output = adb('shell', 'am', 'instrument', '-w', '-r', *board_args,
'-e', 'scenario', args.scenario,
'-e', 'poll_cycles', str(args.poll_cycles),
'-e', 'root_input', str(args.root_input).lower(),
'-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.' + ('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)
if not passed(output):
raise RuntimeError("Instrumentation did not pass")
if layout_original:
print("Layout passed: font=" + font + ", rotation=" + rotation)
finally:
errors = []
def cleanup(label, action):
try: action()
except Exception: errors.append(label)
cleanup('stop-test', lambda: adb('shell', 'am', 'force-stop', 'org.qimiaoscreen.controller', quiet=True))
if layout_original and layout_density is not None:
cleanup('stop', lambda: root('am force-stop org.qimiaoscreen.controller'))
cleanup('density', lambda: root('wm density ' + layout_density))
def verify_density():
value = re.search(r'Override density: (\d+)', root('wm density'))
if (value[1] if value else 'reset') != layout_density:
raise RuntimeError('density restore failed')
cleanup('density verification', verify_density)
for key, value in layout_original.items():
def restore(key=key, value=value):
root('settings delete system ' + key if value == 'null' else 'settings put system ' + key + ' ' + value)
if root('settings get system ' + key) != value: raise RuntimeError('settings restore failed')
cleanup(key, restore)
for permission, granted in permission_original.items():
def restore_permission(permission=permission, granted=granted):
adb('shell', 'su', '-c', 'pm ' + ('grant' if granted else 'revoke') + ' org.qimiaoscreen.controller android.permission.' + permission, quiet=True)
actual = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True)
if ('android.permission.' + permission + ': granted=true' in actual) != granted:
raise RuntimeError('Permission restore failed')
cleanup('permission ' + permission, restore_permission)
if args.toggle_bluetooth:
cleanup('bluetooth', lambda: adb('shell', 'su', '-c', 'cmd bluetooth_manager enable', quiet=True))
for helper in ('qms-authorized-root.sh', 'qms-authorized-input.sh'):
cleanup(helper, lambda helper=helper: adb('shell', 'rm', '-f', '/data/local/tmp/' + helper, quiet=True))
if errors:
raise RuntimeError('Cleanup failed: ' + ', '.join(errors))
print('Cleanup verified', flush=True)
if not passed(output):
raise RuntimeError('BLE instrumentation did not pass')
if __name__ == '__main__':
main()
@@ -1,154 +1,68 @@
"""Run registered BLE tests; WiFi reapplication requires its explicit option."""
"""Explicit, side-effect-free planning followed by selected registered-device scenarios."""
import argparse
import json
from pathlib import Path
import subprocess
import sys
import time
sys.path.insert(0, str(Path(__file__).resolve().parent / '测试设备登记'))
from prepare_test_device import load_registration, check_connected
from remote_support import connect, execute
# name: (instrumentation family, board required, side effect)
SCENARIOS = {
'connection': ('ble', True, 'scan, handshake, disconnect and reconnect'),
'data': ('ble', True, 'read status/settings/storage/library/frame/wifi'),
'library-order': ('ble', True, 'read BLE pages and board order digest'),
'library-cache': ('ble', True, 'read two image tiers, then verify unchanged reconnect reuses app cache'),
'cache-storage': ('ble', False, 'verify private image cache isolation, corruption handling and clear'),
'conflict': ('ble', True, 'submit deliberately stale revision; expect rejection'),
'polling': ('ble', True, 'up to 60 status/frame polls'),
'controller-background': ('ble', True, 'controller background release and reconnect'),
'wifi-scan': ('ble', True, 'scan board WiFi without switching'),
'controls': ('ble', True, 'write current values; temporarily pause active animation and restore'),
'wifi-reapply': ('ble', True, 'reapply current network; brief network interruption'),
'navigation': ('activity', False, 'navigate disconnected App and menus; no board access'),
'activity-background': ('activity', True, 'Home, background release and reconnect'),
'rename': ('activity', True, 'rename device then restore name'),
'remember': ('activity', True, 'forget and reconnect test device'),
'bluetooth-toggle': ('activity', True, 'disable and restore phone Bluetooth'),
'connection-entry': ('layout', True, 'connected entry, rescan and reconnect; layout restore'),
'layout': ('layout', True, 'three layout configurations and frame bounds; layout restore'),
}
COMMON = ('connection', 'data', 'library-order', 'navigation')
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--adb', required=True)
parser.add_argument('--build', type=Path, required=True)
parser.add_argument('--poll-cycles', type=int, default=60)
parser.add_argument('--test', choices=('ble', 'activity', 'layout'), default='ble')
parser.add_argument('--root-input', action='store_true', help='Compatibility flag; activity tests always use authorized root input')
parser.add_argument('--reapply-current-wifi', action='store_true', help='Explicitly reapply the already active saved WiFi profile over BLE; may briefly reconnect WiFi')
parser.add_argument('--verify-current-controls', action='store_true', help='Reapply current brightness; briefly pause active animation and restore without selecting new content')
parser.add_argument('--toggle-bluetooth', action='store_true', help='Requires explicit root Bluetooth-switch authorization plus activity root input')
parser.add_argument('--grant-root', action='store_true', help='Compatibility flag; runner always verifies and grants authorized App permissions')
args = parser.parse_args()
args.grant_root = True
if args.test in ("activity", "layout"): args.root_input = True
if not 0 <= args.poll_cycles <= 60:
parser.error('poll-cycles must be between 0 and 60')
if args.root_input and args.test == 'ble':
parser.error('root-input only applies to activity tests')
if args.reapply_current_wifi and args.test != 'ble':
parser.error('reapply-current-wifi only applies to BLE tests')
if args.verify_current_controls and args.test != 'ble':
parser.error('verify-current-controls only applies to BLE tests')
if args.toggle_bluetooth and not (args.test == 'activity' and args.root_input):
parser.error('toggle-bluetooth requires activity and root-input')
device = next(d for d in load_registration() if d['alias'] == 'android-test-a')
check_connected(args.adb, device)
root_check = subprocess.run([args.adb, '-s', device['serial'], 'shell', 'su', '-c', 'id -u'], capture_output=True, text=True, timeout=15)
if root_check.returncode or root_check.stdout.strip() != '0':
raise RuntimeError('测试手机 root 权限不可用;停止测试,等待用户处理,不使用备用注入方式')
client, fields = connect()
try:
code, output, error = execute(client, "python3 -c \"import json; p=json.load(open('/var/lib/matrix-screen-controller/mobile/identity.json')); print(json.dumps({'device_id':p['device_id']}))\"", password=fields['密码'])
if code:
raise RuntimeError('Cannot read authorized board identity')
board_id = json.loads(output)['device_id']
from uuid import UUID
UUID(board_id)
board_name = 'QMS-' + board_id.replace('-', '')[:8]
code, output, _ = execute(client, "python3 -c \"import urllib.request,json,hashlib; d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/library/order',timeout=5)); print(hashlib.sha256('\\n'.join(i['type']+'|'+i['id'] for i in d['items']).encode()).hexdigest())\"", password=fields['密码'])
library_hash = output.strip()
if code or len(library_hash) != 64 or any(c not in '0123456789abcdef' for c in library_hash):
raise RuntimeError('Cannot read device library order digest')
if not board_name.startswith('QMS-') or len(board_name) != 12:
raise RuntimeError('Invalid board identity')
finally:
client.close()
def adb(*command, timeout=120, quiet=False):
try:
result = subprocess.run([args.adb, '-s', device['serial'], *command], capture_output=True,
text=True, encoding='utf-8', errors='replace', timeout=timeout)
except subprocess.TimeoutExpired:
# ADB's host timeout does not stop Android instrumentation itself.
if command[:3] == ('shell', 'am', 'instrument'):
subprocess.run([args.adb, '-s', device['serial'], 'shell', 'am', 'force-stop',
'org.qimiaoscreen.controller'], capture_output=True, timeout=15)
raise RuntimeError('ADB operation timed out; device identifiers omitted') from None
output = (result.stdout + result.stderr).replace(device['serial'], '[test-phone]').replace(board_name, '[test-board]').replace(board_id, '[test-board-id]')
if not quiet:
print(output, flush=True)
if result.returncode:
raise RuntimeError('ADB command failed')
return output
for relative in ('androidApp/build/outputs/apk/debug/androidApp-debug.apk',
'androidApp/build/outputs/apk/androidTest/debug/androidApp-debug-androidTest.apk'):
apk = args.build / relative
if not apk.is_file():
raise RuntimeError('APK missing')
adb('install', '-r', str(apk))
adb('shell', 'am', 'force-stop', 'org.qimiaoscreen.controller')
if args.grant_root:
# Some vendor package managers asynchronously reset permissions after
# install succeeds. Verify after a settling window, not just at grant.
for attempt in range(3):
for permission in ('BLUETOOTH_SCAN', 'BLUETOOTH_CONNECT'):
adb('shell', 'su', '-c', 'pm grant org.qimiaoscreen.controller android.permission.' + permission)
time.sleep(3)
verified = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True)
if all('android.permission.' + p + ': granted=true' in verified for p in ('BLUETOOTH_SCAN', 'BLUETOOTH_CONNECT')):
break
else:
raise RuntimeError('Bluetooth permission did not remain granted after installation')
if args.root_input:
adb('push', str(Path(__file__).with_name('authorized_app_input.sh')), '/data/local/tmp/qms-authorized-input.sh', quiet=True)
layout_original = None
if args.test == "layout":
adb("push", str(Path(__file__).with_name("authorized_root_command.sh")), "/data/local/tmp/qms-authorized-root.sh", quiet=True)
def root(command):
return adb("shell", "su", "-c", command, quiet=True).strip()
import re
layout_original = {key: root("settings get system " + key) for key in ("font_scale", "accelerometer_rotation", "user_rotation")}
layout_density = re.search(r"Override density: (\d+)", root("wm density"))
layout_density = layout_density[1] if layout_density else "reset"
dimensions = re.findall(r"(\d+)x(\d+)", root("wm size"))[-1]
narrow = min(map(int, dimensions)) * 160 // 340
try:
for font, rotation in (("1.0", "0"), ("1.3", "0"), ("1.3", "1")) if args.test == "layout" else ((None, None),):
if layout_original:
root("am force-stop org.qimiaoscreen.controller")
root("wm density " + str(narrow))
root("settings put system font_scale " + font)
root("settings put system accelerometer_rotation 0")
root("settings put system user_rotation " + rotation)
root("input keyevent KEYCODE_WAKEUP")
root("wm dismiss-keyguard")
root("am start -W -n org.qimiaoscreen.controller/.MainActivity")
output = adb('shell', 'am', 'instrument', '-w', '-r', '-e', 'device_name', board_name,
'-e', 'poll_cycles', str(args.poll_cycles),
'-e', 'library_order_hash', library_hash,
'-e', 'root_input', str(args.root_input).lower(),
'-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.' + {'ble': 'BleIntegrationTest', 'activity': 'AppLifecycleTest', 'layout': 'LayoutTest'}[args.test],
'org.qimiaoscreen.controller.test/androidx.test.runner.AndroidJUnitRunner', timeout=360)
if "OK (1 test)" not in output:
raise RuntimeError("Instrumentation did not pass")
if layout_original:
print("Layout passed: font=" + font + ", rotation=" + rotation)
finally:
if layout_original:
root("am force-stop org.qimiaoscreen.controller")
root("wm density " + layout_density)
restored_density = re.search(r"Override density: (\d+)", root("wm density"))
restored_density = restored_density[1] if restored_density else "reset"
if restored_density != layout_density: raise RuntimeError("Layout density restore failed")
for key, value in layout_original.items():
root("settings delete system " + key if value == "null" else "settings put system " + key + " " + value)
if root("settings get system " + key) != value: raise RuntimeError("Layout settings restore failed")
print("Layout settings restored and verified", flush=True)
if args.test == "layout":
adb("shell", "rm", "-f", "/data/local/tmp/qms-authorized-root.sh", quiet=True)
if args.toggle_bluetooth:
adb('shell', 'su', '-c', 'cmd bluetooth_manager enable', quiet=True)
if args.root_input:
adb('shell', 'rm', '-f', '/data/local/tmp/qms-authorized-input.sh', quiet=True)
if 'OK (1 test)' not in output:
raise RuntimeError('BLE instrumentation did not pass')
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--list-scenarios', action='store_true')
p.add_argument('--dry-run', action='store_true')
choose = p.add_mutually_exclusive_group()
choose.add_argument('--scenario', action='append', choices=SCENARIOS)
choose.add_argument('--full-common', action='store_true', help='Explicit common regression; excludes mutation, long polling and recovery tests')
p.add_argument('--adb')
p.add_argument('--build', type=Path)
p.add_argument('--poll-cycles', type=int, default=60)
args = p.parse_args(argv)
if args.list_scenarios:
for name, (family, board, effect) in SCENARIOS.items():
print(f'{name}: family={family}; board={board}; {effect}')
return 0
selected = list(COMMON) if args.full_common else list(dict.fromkeys(args.scenario or []))
if not selected:
p.print_help(); return 2
if not 1 <= args.poll_cycles <= 60: p.error('poll-cycles must be 1..60')
plan = [{'scenario': n, 'phone': 'registered root phone', 'board': SCENARIOS[n][1], 'effect': SCENARIOS[n][2]} for n in selected]
print(json.dumps({'selected': plan, 'unselected': [n for n in SCENARIOS if n not in selected]}, ensure_ascii=False), flush=True)
if args.dry_run: return 0
if not args.adb or not args.build: p.error('--adb and --build required for execution')
from device_test_runner import main as execute
for name in selected:
flags = ['--adb', args.adb, '--build', str(args.build), '--scenario', name,
'--test', SCENARIOS[name][0], '--poll-cycles', str(args.poll_cycles if name == 'polling' else 0)]
flags += {'controls': ['--verify-current-controls'], 'wifi-reapply': ['--reapply-current-wifi'],
'bluetooth-toggle': ['--toggle-bluetooth']}.get(name, [])
try: execute(flags)
except Exception:
print(json.dumps({'scenario': name, 'result': 'failed', 'remaining': 'not run'}), flush=True)
raise
print(json.dumps({'scenario': name, 'result': 'passed'}), flush=True)
return 0
if __name__ == '__main__':
main()
raise SystemExit(main())
@@ -0,0 +1,152 @@
"""Run selected isolated Compose UI tests on an explicitly named Android Studio AVD."""
import argparse
import json
from pathlib import Path
import re
import subprocess
import time
SCENARIOS = ('connection', 'status', 'content', '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'
def passed(output):
return bool(re.search(r'^OK \(1 test\)\s*$', output, re.M)) and not any(
marker in output for marker in ('FAILURES!!!', 'INSTRUMENTATION_FAILED', 'Process crashed'))
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--list-scenarios', action='store_true')
p.add_argument('--dry-run', action='store_true')
choice = p.add_mutually_exclusive_group()
choice.add_argument('--scenario', action='append', choices=SCENARIOS)
choice.add_argument('--all-ui', action='store_true')
p.add_argument('--configuration', action='append', choices=CONFIGS)
p.add_argument('--avd')
p.add_argument('--adb')
p.add_argument('--emulator')
p.add_argument('--sysdir', type=Path, help='Optional system image directory for a separately stored SDK image')
p.add_argument('--port', type=int, default=5580)
p.add_argument('--build', type=Path)
p.add_argument('--report', type=Path)
args = p.parse_args(argv)
if args.list_scenarios:
print(json.dumps({'scenarios': SCENARIOS, 'configurations': list(CONFIGS)})); return 0
selected = list(SCENARIOS) if args.all_ui else list(dict.fromkeys(args.scenario or []))
if not selected: p.print_help(); return 2
configs = list(dict.fromkeys(args.configuration or ['baseline']))
plan = [{'scenario': s, 'configuration': c, 'result': 'not run'} for c in configs for s in selected]
print(json.dumps({'selected': plan, 'physical_phone': False, 'board': False, 'root': False}), flush=True)
if args.dry_run: return 0
if not args.adb or not args.avd or not args.build: p.error('--adb, --avd and --build required')
if not re.fullmatch(r'[A-Za-z0-9_.-]+', args.avd): p.error('Invalid AVD name')
if args.port % 2 or not 5554 <= args.port <= 5682: p.error('Expected an even emulator console port')
apk = args.build / 'uiTest/build/outputs/apk/debug/uiTest-debug.apk'
if not apk.is_file(): p.error('Build :uiTest:assembleDebug first')
if args.report and args.report.exists(): p.error('Report already exists; choose a new path')
serial = 'emulator-' + str(args.port)
started = False
verified_target = False
original = {}
cleanup_errors = []
process = None
def adb(*command, timeout=30):
try:
result = subprocess.run([args.adb, '-s', serial, *command], capture_output=True,
text=True, encoding='utf-8', errors='replace', timeout=timeout)
except subprocess.TimeoutExpired:
raise RuntimeError('Emulator operation timed out') from None
if result.returncode: raise RuntimeError('Emulator operation failed')
return result.stdout + result.stderr
def cleanup(name, action):
try: action()
except Exception: cleanup_errors.append(name)
def restore_setting(key, value):
adb('shell', 'settings', 'delete', 'system', key) if value == 'null' else adb('shell', 'settings', 'put', 'system', key, value)
if adb('shell', 'settings', 'get', 'system', key).strip() != value: raise RuntimeError('Restore failed')
failure = None
try:
# An occupied port must not cause selection of another device.
state = subprocess.run([args.adb, '-s', serial, 'get-state'], capture_output=True, timeout=15)
if state.returncode:
if not args.emulator: raise RuntimeError('AVD is not running; supply --emulator to start it')
available = subprocess.run([args.emulator, '-list-avds'], capture_output=True, text=True, timeout=20)
if args.avd not in available.stdout.splitlines(): raise RuntimeError('Named AVD does not exist; create it in Android Studio Device Manager')
command = [args.emulator, '-avd', args.avd, '-port', str(args.port), '-no-window', '-no-audio', '-no-snapshot', '-no-boot-anim']
if args.sysdir: command += ['-sysdir', str(args.sysdir)]
process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
started = True
deadline = time.monotonic() + 180
while True:
if process is not None and process.poll() is not None: raise RuntimeError('Emulator exited before boot')
try:
if adb('shell', 'getprop', 'sys.boot_completed').strip() == '1': break
except RuntimeError: pass
if time.monotonic() >= deadline: raise RuntimeError('AVD boot timeout')
time.sleep(2)
if adb('shell', 'getprop', 'ro.kernel.qemu').strip() != '1': raise RuntimeError('Refusing a physical device')
if adb('emu', 'avd', 'name').splitlines()[0].strip() != args.avd: raise RuntimeError('AVD name does not match requested target')
verified_target = True
original = {k: adb('shell', 'settings', 'get', 'system', k).strip()
for k in ('font_scale', 'accelerometer_rotation', 'user_rotation')}
density = re.search(r'Override density: (\d+)', adb('shell', 'wm', 'density'))
original['density'] = density[1] if density else 'reset'
dims = re.findall(r'(\d+)x(\d+)', adb('shell', 'wm', 'size'))[-1]
result = adb('install', '-r', str(apk), timeout=120)
if 'Success' not in result: raise RuntimeError('Test APK installation failed')
for item in plan:
name, config = item['scenario'], item['configuration']
width, font, rotation = CONFIGS[config]
adb('shell', 'am', 'force-stop', PACKAGE)
adb('shell', 'wm', 'density', str(min(map(int, dims)) * 160 // width) if width else original['density'])
for key, value in [('font_scale', font), ('accelerometer_rotation', '0'), ('user_rotation', rotation)]:
adb('shell', 'settings', 'put', 'system', key, value)
try:
output = adb('shell', 'am', 'instrument', '-w', '-r', '-e', 'class',
PACKAGE + '.IsolatedUiTest#' + name,
PACKAGE + '/androidx.test.runner.AndroidJUnitRunner', timeout=120)
if not passed(output):
# Isolated fixtures contain no private data. Keep assertion diagnostics for repair.
item["diagnostic"] = output[:2500]
print(output[:2500] + ("\n[remaining diagnostics omitted]" if len(output) > 2500 else ""), flush=True)
raise RuntimeError('Selected Compose scenario failed')
item['result'] = 'passed'
except Exception:
item['result'] = 'failed'; raise
print(json.dumps(item), flush=True)
except Exception as e:
failure = str(e)
finally:
if original:
cleanup('stop-test', lambda: adb('shell', 'am', 'force-stop', PACKAGE))
for key, value in original.items():
if key != 'density': cleanup(key, lambda k=key, v=value: restore_setting(k, v))
def density_restore():
adb('shell', 'wm', 'density', original['density'])
current = re.search(r'Override density: (\d+)', adb('shell', 'wm', 'density'))
if (current[1] if current else 'reset') != original['density']: raise RuntimeError('Density restore failed')
cleanup('density', density_restore)
if started and process is not None and process.poll() is None:
if verified_target:
cleanup('stop-owned-emulator', lambda: adb('emu', 'kill'))
else:
process.terminate()
if process is not None:
try: process.wait(timeout=20)
except subprocess.TimeoutExpired:
process.terminate(); cleanup_errors.append('emulator-exit')
report = {'tests': plan, 'failure': failure, 'cleanup_errors': cleanup_errors,
'not_selected': [s for s in SCENARIOS if s not in selected]}
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
with args.report.open('x', encoding='utf-8') as f: json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps(report, ensure_ascii=False), flush=True)
return 1 if failure or cleanup_errors else 0
if __name__ == '__main__':
raise SystemExit(main())
@@ -1,18 +1,5 @@
"""Use the persistent Android semantic test with root input and restored settings."""
from pathlib import Path
import argparse
import subprocess
"""Compatibility entry; explicitly select layout or connection-entry."""
import sys
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--adb', required=True)
parser.add_argument('--build', type=Path, required=True)
args = parser.parse_args()
return subprocess.run([sys.executable, str(Path(__file__).with_name('run_ble_test.py')),
'--adb', args.adb, '--build', str(args.build), '--test', 'layout']).returncode
from run_ble_test import main
if __name__ == '__main__':
raise SystemExit(main())
raise SystemExit(main(sys.argv[1:]))
@@ -18,7 +18,7 @@ import subprocess
import sys
import time
from remote_support import connect, execute
sys.path.insert(0, str(Path(__file__).resolve().parent / "测试设备登记"))
from prepare_test_device import check_connected, load_registration
@@ -259,8 +259,10 @@ def wifi_snapshot(client, fields: dict) -> dict:
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--adb", type=Path, required=True)
parser.add_argument("--build", type=Path, required=True)
parser.add_argument("--adb", type=Path)
parser.add_argument("--list-scenarios", action="store_true")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--build", type=Path)
parser.add_argument("--resume", action="store_true", help="Continue after the saved recovery profile is active on the computer hotspot")
parser.add_argument("--root-swipe", action="store_true", help="Requires explicit root permission for scrolling only the foreground App settings list")
parser.add_argument("--skip-wrong", action="store_true", help="Use only when the wrong-password branch has been separately observed and archived")
@@ -268,6 +270,19 @@ def main() -> int:
parser.add_argument("--validate-only", action="store_true", help="Verify form and invalid IPv4 without switching networks")
parser.add_argument("--final-check", action="store_true", help="Verify App, BLE, board, and recovery cleanup on the original network")
args = parser.parse_args()
modes = ['validate_only', 'wrong_only', 'skip_wrong', 'final_check']
if args.list_scenarios:
print('validate-only: form only; wrong-only: failed activation with timed restore; skip-wrong: cross-network DHCP/static roundtrip; final-check: restored state checks')
return 0
selected = [m for m in modes if getattr(args, m)]
if len(selected) != 1: parser.error('Select exactly one explicit WiFi mode')
if args.resume and not args.skip_wrong: parser.error('--resume requires --skip-wrong')
if args.dry_run:
print(json.dumps({'scenario': selected[0], 'root_phone': True, 'board': True, 'network_change': selected[0] in ('wrong_only', 'skip_wrong')}))
return 0
if not args.adb or not args.build: parser.error('--adb and --build required')
global connect, execute
from remote_support import connect, execute
args.root_swipe = True
if not (args.wrong_only or args.skip_wrong or args.resume or args.validate_only or args.final_check):
raise RuntimeError("请选择 --validate-only、--wrong-only、--final-check 或已完成错误密码单测后的 --skip-wrong")
@@ -0,0 +1,120 @@
"""Host-only safety tests: selection must never imply device or network access."""
import contextlib
import io
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
import run_ble_test as device
import run_emulator_ui_test as emulator
class SelectionTests(unittest.TestCase):
def quiet(self): return contextlib.redirect_stdout(io.StringIO())
def test_previews_need_no_device_or_dependencies(self):
with patch('subprocess.run', side_effect=AssertionError('unexpected device access')), self.quiet():
for runner, name in ((device, 'navigation'), (emulator, 'settings')):
self.assertEqual(runner.main(['--list-scenarios']), 0)
self.assertEqual(runner.main(['--scenario', name, '--dry-run']), 0)
self.assertEqual(runner.main([]), 2)
def test_one_scenario_cannot_expand_to_ble_or_network(self):
calls = []
fake = types.SimpleNamespace(main=lambda args: calls.append(args))
with patch.dict(sys.modules, {'device_test_runner': fake}), self.quiet():
self.assertEqual(device.main(['--scenario', 'navigation', '--adb', 'fake', '--build', 'fake']), 0)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0][calls[0].index('--scenario') + 1], 'navigation')
self.assertNotIn('--reapply-current-wifi', calls[0])
self.assertEqual(calls[0][calls[0].index('--poll-cycles') + 1], '0')
def test_failure_stops_later_scenarios(self):
calls = []
def fail(args):
calls.append(args); raise RuntimeError('expected')
with patch.dict(sys.modules, {'device_test_runner': types.SimpleNamespace(main=fail)}), self.quiet():
with self.assertRaises(RuntimeError):
device.main(['--scenario', 'connection', '--scenario', 'data', '--adb', 'fake', '--build', 'fake'])
self.assertEqual(len(calls), 1)
def test_common_does_not_include_disruptive_scenarios(self):
self.assertFalse(set(device.COMMON) & {'wifi-reapply', 'controls', 'polling', 'bluetooth-toggle', 'rename', 'remember'})
def test_zero_tests_or_crash_is_not_success(self):
self.assertTrue(emulator.passed('OK (1 test)\n'))
for text in ('OK (0 tests)', 'OK (2 tests)', 'FAILURES!!!\nOK (1 test)', 'Process crashed\nOK (1 test)'):
self.assertFalse(emulator.passed(text))
def test_failed_instrumentation_restores_each_setting_and_records_failure(self):
with tempfile.TemporaryDirectory() as tmp:
b = Path(tmp); apk = b / 'uiTest/build/outputs/apk/debug/uiTest-debug.apk'
apk.parent.mkdir(parents=True); apk.write_bytes(b'fixture')
state = {'font_scale': '1.0', 'accelerometer_rotation': '1', 'user_rotation': '0'}
history = []
def run(cmd, **kwargs):
history.append(cmd)
args = cmd[3:]
out = ''
if args == ['get-state']: out = 'device'
elif args[:2] == ['shell', 'getprop']: out = '1'
elif args == ['emu', 'avd', 'name']: out = 'fixture\nOK'
elif args[:3] == ['shell', 'settings', 'get']: out = state[args[4]]
elif args[:3] == ['shell', 'settings', 'put']: state[args[4]] = args[5]
elif args == ['shell', 'wm', 'size']: out = 'Physical size: 1080x1920'
elif args == ['shell', 'wm', 'density']: out = 'Physical density: 420'
elif args[:1] == ['install']: out = 'Success'
elif args[:3] == ['shell', 'am', 'instrument']: raise subprocess.TimeoutExpired(cmd, 120)
return subprocess.CompletedProcess(cmd, 0, out, '')
report = b / 'report.json'
with patch('subprocess.run', side_effect=run), self.quiet():
result = emulator.main(['--scenario', 'navigation', '--avd', 'fixture', '--adb', 'fake', '--build', str(b), '--report', str(report)])
self.assertEqual(result, 1)
self.assertEqual(state, {'font_scale': '1.0', 'accelerometer_rotation': '1', 'user_rotation': '0'})
evidence = json.loads(report.read_text(encoding='utf-8'))
self.assertEqual(evidence['tests'][0]['result'], 'failed')
self.assertIn('timed out', evidence['failure'])
self.assertFalse(evidence['cleanup_errors'])
self.assertTrue(any(cmd[3:6] == ['shell', 'am', 'force-stop'] for cmd in history))
def test_wifi_modes_are_explicit_and_dry_run_has_no_remote_import(self):
import run_wifi_switch_test as wifi
for argv in (['run_wifi_switch_test.py', '--list-scenarios'],
['run_wifi_switch_test.py', '--validate-only', '--dry-run']):
with patch.object(sys, 'argv', argv), patch('subprocess.run', side_effect=AssertionError('unexpected access')), patch.dict(sys.modules, {'remote_support': None}), self.quiet():
self.assertEqual(wifi.main(), 0)
for argv in (['run_wifi_switch_test.py'], ['run_wifi_switch_test.py', '--wrong-only', '--skip-wrong', '--dry-run']):
with patch.object(sys, 'argv', argv), self.quiet(), contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit): wifi.main()
def test_real_navigation_adapter_has_no_board_access_and_restores_permissions(self):
import device_test_runner as runner
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
for name in ('debug/androidApp-debug.apk', 'androidTest/debug/androidApp-debug-androidTest.apk'):
apk = base / 'androidApp/build/outputs/apk' / name
apk.parent.mkdir(parents=True, exist_ok=True); apk.write_bytes(b'fixture')
grants = {'BLUETOOTH_SCAN': True, 'BLUETOOTH_CONNECT': False}
calls = []
def execute(command, **kwargs):
calls.append(command)
args = command[3:]; out = ''
if args == ['shell', 'su', '-c', 'id -u']: out = '0'
elif args == ['shell', 'getprop', 'ro.build.version.sdk']: out = '35'
elif args == ['shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller']:
out = '\n'.join('android.permission.' + k + ': granted=' + str(v).lower() for k, v in grants.items())
elif args[:3] == ['shell', 'su', '-c'] and args[3].startswith('pm '):
operation = args[3].split(); grants[operation[-1].split('.')[-1]] = operation[1] == 'grant'
elif args[:3] == ['shell', 'am', 'instrument']:
self.assertFalse(any(grants.values()))
self.assertNotIn('device_name', args)
self.assertNotIn('library_order_hash', args)
out = 'OK (1 test)\n'
elif args[:1] == ['install']: out = 'Success'
return subprocess.CompletedProcess(command, 0, out, '')
with patch.object(runner, 'load_registration', return_value=[{'alias': 'android-test-a', 'serial': 'fixture-serial'}]), patch.object(runner, 'check_connected'), patch('subprocess.run', side_effect=execute), patch.dict(sys.modules, {'remote_support': None}), self.quiet():
runner.main(['--scenario', 'navigation', '--test', 'activity', '--poll-cycles', '0', '--adb', 'fake', '--build', str(base)])
self.assertEqual(grants, {'BLUETOOTH_SCAN': True, 'BLUETOOTH_CONNECT': False})
self.assertTrue(any(cmd[3:6] == ['shell', 'am', 'force-stop'] for cmd in calls))
def test_invalid_scenario_rejected_before_any_io(self):
with patch('subprocess.run', side_effect=AssertionError('unexpected access')), self.quiet(), contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit): device.main(['--scenario', 'everything'])
if __name__ == '__main__': unittest.main()
@@ -69,3 +69,19 @@ Compose 的禁用语义可能位于父节点,文字节点自身仍 enabled。
滑动后通过两次间隔400毫秒的实时边界相等检查确认目标停稳(最多5秒),然后仅发送一次 root 点击;不因未看到中间状态而重复点击连接。记录初次连接、连接中扫描、两处预览、条目断开、手动重连等阶段。短暂“连接中”未被观测不代表未发起请求,终态以认证连接和条目状态核验为准。
本轮失败、恢复与最终结果见 `../测试结果归档/2026-09-27_界面细节优化/`;重测通过不证明此前连接未出现的唯一根因。字体、旋转和密度恢复后均回读核验。
## 13. 按影响选测与独立模拟器(2026-09-29)
- 先读 [按影响选测](按影响选测.md),选中场景后才执行环境门禁。不能因为手机登记/网络不可用而阻塞不依赖它们的 JVM、模拟器或文档检查。需要真机但环境不可用时仍是“未验证”。
- `--list-scenarios` / `--dry-run` 不导入远端联调依赖、不访问凭据或 ADB。电脑系统 Python 未装 Paramiko 时,优先复用本机测试环境的 `.venv` 并检查 import;不要给核桃派添加主机依赖。
- com.android.test 插件已由同版本 AGP 放入 classpath 时,uiTest 使用无版本 `id("com.android.test")`;显式重复版本可能导致 unknown version 冲突。uiTest 的 Compose 测试库与现有 Compose Multiplatform 锁定版本一致。
- 独立 uiTest 使用 self-instrumenting APK 和实际 Controller,不从反射篡改内部状态。fake RPC 必须遵循真实字段契约:内容条目需要 revision,缺失会在 thumbnailKey 中失败。此类夹具错误应修夹具,不弱化产品断言。
- 真机 navigation 临时撤销扫描/连接权限,防止已有记忆触发自动重连;finally 恢复原权限并回读,不访问板端身份。不将此场景用于权限允许/拒绝的产品验收。
- AVD 镜像 ZIP 手动解压不等于 SDK 包已登记。单独存放 SDK 时,avdmanager 必须定位同一 SDK 根;镜像 package.xml 保留官方 type-details/版本/许可证,复用已有 Emulator。优先使用 Android Studio SDK Manager 正常安装的包,不能靠反复下载解决根目录不一致。
- 模拟器测试出现失败,保留场景与配置结果,独立恢复字体、旋转、密度;只重跑受影响/未运行场景。失败不能记成全量通过。标准 Compose 输入仅适用于模拟器,登记真机仍用 root。
## 14. 内容预览在真实动画条目返回 BAD_REQUEST(2026-09-29)
真实用户动画的首帧可能是 `path` 指向持久图片,演示动画则可能是内嵌 `image`。新增 BLE 图片接口时要先检查 `animations.playback_snapshot` 的实际首帧结构,并沿设备既有路径验证与解码规则读取两种形式;不能把 `path` 当作无图或错误参数。空动画仍应按不可播放条目处理。复测须覆盖用户动画和演示动画,并做全库图片只读读取;本轮修正和实测见 [内容缓存验收](../测试结果归档/2026-09-29_内容缓存/README.md)。
模拟器 AVD 名称若存在但启动提示系统目录不存在,先核对该名称的配置指向和镜像登记。保留旧配置,使用已安装镜像新建专用 AVD 再测试;不要因名称存在就反复启动,也不要擅自清空原 AVD。真机 instrumentation 前同时确认产品 APK 与 androidTest APK 已构建,只有测试 APK 时先补产品构建,再运行选中场景。
@@ -0,0 +1,92 @@
# Android 按变更影响选测(当前规则)
适用 MOBILE-TEST-SELECTION、TEST-MOBILE-SELECTION、TEST-MOBILE-INTEGRATION。本文优先于历史版本专项验收中的固定执行顺序。授权决定怎样操作设备,不代表每轮必须使用设备。
## 选择范围
先列变更、直接调用与共享依赖影响,再选择能提供足够证据的最低层级。不能仅以页面文件未修改判定无影响,也不能以新功能提交/打包为由自动重跑所有页面。
| 变更 | 默认验证 | 升级为定向真机的条件 |
|---|---|---|
| 文档、映射 | 文档一致性、仓库卫生 | 无 |
| 解析、校验、业务状态 | sharedCore JVM / 双端协议测试 | 实际平台交互改变或模拟无法解释故障 |
| 文案、布局、表单、导航 | 受影响 uiTest 场景,baseline 配置 | 厂商/实际设备特有问题或模拟证据不足 |
| BLE/GATT/握手/重连 | 单元测试和对应真机连接场景 | 必须覆盖实际改变的通信路径 |
| 权限、Activity/后台 | 相应 Android 平台测试;必要真机 | 真实蓝牙权限、MIUI、后台限制改变 |
| WiFi | 校验和模拟器 settings | 激活、切换、恢复实现改变才真正切网 |
| 构建、SDK、依赖 | 构建及受影响兼容性 | 覆盖安装、关键通信或平台风险 |
| systemd、部署、驱动 | 设备侧既有流程 | 不因普通手机界面修改触发 |
共享导航/主题影响多个页面时覆盖相关页面;局部修改不测无影响页面。模拟器同样按需:默认单一 baseline;布局适配才增加 narrow/large-font/landscape;API/权限兼容变化才增加相关 API 的 AVD。不能用 API 35 的模拟界面结果冒充 API 26/30/31+ 权限验收。
全量仅在明确完整验收或影响无法可靠收敛时使用,写明原因。`--full-common` 是明确定义的四项公共回归,不等于全量验收;切网、写入、长轮询和异常恢复始终单独选择。发布候选仍按发布风险选择关键链路,打包成功不代表验收成功。
已运行且相关的低成本回归通过后,不无理由重复运行。必要的测试因环境缺失没做,记录“未验证”;无影响的测试记录“按影响跳过”。没有自动将失败改成跳过的机制。
## 场景和稳定编号映射
| 层级/入口 | 场景 | 对应编号 / 依赖与副作用 |
|---|---|---|
| sharedCore:jvmTest | 现有协议、状态、恢复、超时、加密 | ANDROID-TEST-CONTRACT / STATE;无设备 |
| uiTest 模拟器 | connection/status/content/settings/navigation/layout | ANDROID-TEST-UI;测试专用假平台,实际界面与 Controller |
| run_ble_test.py | connection | ANDROID-TEST-BLE;真实扫描/握手/断开/重新扫描重连 |
| 同上 | data / library-order | ANDROID-TEST-BLE / CONTROL;只读,只有 order 读取板端目录摘要 |
| 同上 | library-cache | ANDROID-TEST-LIBRARY-CACHE;登记手机与核桃派只读,两轮图片后重新连接,报告图片请求数与 PNG 负载字节数,不改变显示内容 |
| 同上 | cache-storage | ANDROID-TEST-LIBRARY-CACHE;登记手机的隔离缓存目录验证跨设备隔离、损坏保护与清空,不访问核桃派 |
| 同上 | conflict / wifi-scan | ANDROID-TEST-CONTRACT / WIFI;过期修订拒绝 / 板端扫描;不切网 |
| 同上 | polling | ANDROID-TEST-PERF;最多 60 轮状态/帧读取,不替代性能因果对照 |
| 同上 | controller-background / activity-background | ANDROID-TEST-STATE / BLE;后台释放与重连 |
| 同上 | navigation | ANDROID-TEST-UI;登记真机 root 导航;临时撤销并恢复蓝牙权限防止旧记忆自动连接,不访问板端 |
| 同上 | rename / remember | ANDROID-TEST-UI / STATE;昵称恢复 / 忘记后重新连接 |
| 同上 | bluetooth-toggle | ANDROID-TEST-PERMISSION;真实关闭并恢复蓝牙 |
| 同上 | connection-entry / layout | ANDROID-TEST-UI;条目重扫/断开/再连;layout 另加三组布局和帧边界 |
| 同上 | controls / wifi-reapply | ANDROID-TEST-CONTROL / WIFI;当前值写入和动画恢复 / 当前网络重新激活,须明确选择 |
| run_wifi_switch_test.py | --validate-only / --wrong-only / --skip-wrong / --final-check | ANDROID-TEST-WIFI;四者必须且只能选一个,后两类切网专项按原恢复规则 |
每个真机场景单独建立/释放会话。普通 UI 首选模拟器;真机 navigation 是厂商输入等特定问题的补证入口。`connection-entry` 不改变密度/字体/方向;`layout` 才做原有三组配置。其它设备生命周期、双手机、性能采样仍按各自编号的触发条件独立执行。
## 可复现命令
先阅读排障指南。命令从仓库根执行,路径参数使用本机已安装工具的真实路径,不把路径写进仓库。电脑远端联调使用已有安装 Paramiko 的环境;依赖见 requirements-host.txt,不加入核桃派依赖。
```text
python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --list-scenarios
python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --scenario connection --dry-run
python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --scenario connection --adb "<ADB路径>" --build "<隔离工程目录>"
python "移动端相关内容/安卓app/如何安卓测试/run_emulator_ui_test.py" --scenario settings --dry-run
python "移动端相关内容/安卓app/如何安卓测试/run_emulator_ui_test.py" --scenario settings --avd "<AVD名称>" --adb "<ADB路径>" --emulator "<已安装Emulator路径>" --build "<隔离工程目录>" --report "<新的结果JSON路径>"
python "移动端相关内容/安卓app/如何安卓测试/run_wifi_switch_test.py" --validate-only --dry-run
```
构建沿用 build_android.py,任务按需指定:`:sharedCore:jvmTest`、`:androidApp:assembleDebug`、`:androidApp:assembleDebugAndroidTest`、`:uiTest:assembleDebug`。uiTest 是 self-instrumenting 测试 APK;只安装到明确选择且核验过的 AVD,不装到登记真机,不安装产品 APK。fake platform/transport、测试 Activity 均只存在于 uiTest;不调用真实 BLE、SSH、摄像头,不声明真实权限验收。
模拟器入口支持重复 `--scenario` 和 `--configuration`;默认 baseline,只在明确完整 UI 验收时用 `--all-ui`。复用 Android Studio Device Manager 的 AVD;缺少时用 SDK avdmanager 创建专用 AVD,使用官方系统镜像。`--port` 默认 5580,启动前指定 AVD、连接后核对名称和 ro.kernel.qemu,拒绝物理手机或不匹配目标。可用 `--sysdir` 指向独立存储的系统镜像。只关闭本次启动的模拟器;字体、方向、密度恢复并核验。不能擅自 wipe-data 或覆盖已有 AVD。
所有运行器无场景时只显示用法;列表/预览不访问登记、凭据、ADB、SSH。非法组合在设备访问前拒绝。旧 `--test activity`、无参数默认 BLE 的命令已取消:分别改用明确的 `--scenario`。run_ui_layout_test.py 保留转发入口,也必须显式选 layout 或 connection-entry(推荐统一用 run_ble_test.py)。
## 证据与边界
每轮归档填写:
```text
变更及共享影响:
选中编号 / 场景 / 环境 / 配置 / 选择理由:
源码摘要、App/设备/协议版本(只记与本轮相关者):
结果:通过 / 失败 / 按影响跳过 / 必要但未验证
跳过项及理由:
失败、清理、恢复核验、重测及未解决边界:
经验更新位置:
```
宿主调度检查:`python -m unittest discover -s "移动端相关内容/安卓app/如何安卓测试" -p "test_test_selection.py"`。这是运行器选测/失败恢复测试,不代表 BLE/模拟器已经通过。
模拟 UI 已覆盖空状态、扫描错误恢复、导航、WiFi 空名称禁用及确认取消、内容读入和帧布局。没有实现的 OS 权限矩阵和完整 Activity 状态恢复不冒充已覆盖;今后相关功能变化时补相应测试。语义/边界断言不证明文字实际运动或视觉质量。视觉和电气门禁不变。
## 官方依据(2026-09-29 核对)
- [Android 测试策略](https://developer.android.com/training/testing/fundamentals/strategies):选择最低有效层级,并非每个功能都在所有层级重复验证。
- [Compose 独立测试](https://developer.android.com/develop/ui/compose/testing/common-patterns):隔离界面输入,使用测试 API 验证行为。
- [Android 测试基础](https://developer.android.com/training/testing/fundamentals):instrumentation 可运行于真机或模拟器。
- [模拟器网络能力](https://developer.android.com/studio/run/emulator-networking):新版本支持部分 Bluetooth 模拟,不能笼统写“模拟器完全不支持 BLE”;本项目 fake 测试仍不等于物理射频、实际核桃派或厂商兼容验证。
以上是制定项目选测规则的依据,具体场景划分是本项目的工程选择,不是官方强制清单。
@@ -18,7 +18,7 @@ Android 平台以 Java 安全 API 实现 P-256 ECDH/HKDF-SHA256/AES-GCM,
扫描单轮 12 秒;点击重试开始新轮,不无限循环。自动重连扫描上次持久 ID 对应短编号,握手校验完整 ID,不因名称相同认错设备;候选完整 ID 不符则释放并报错,用户可重新扫描。App 最多一个 BluetoothGatt。每项 GATT 操作等待回调或超时后再执行下一项;迟到回调按连接 generation 丢弃。
用户控制队列优先,状态次之,缩略图与当前帧最低优先。单个预览在途,数据无变化不反复下载。背景立即停止轮询和缩略图,30 秒延迟任务释放连接;回前台取消延迟,Ready 则刷新,非 Ready 则重连上次设备。进程死亡不假设回调可执行,由设备心跳门限回收会话。
用户控制队列优先,状态与当前帧次之,内容图片在空闲轮询时一次传一张。显示内容前台每 10 秒查询目录修订;全部可预览条目先传 16×16 PNG,第一轮尝试完成后再传 64×64 完整图。单个预览在途,数据无变化不反复下载。背景立即停止轮询和内容图片,30 秒延迟任务释放连接;回前台取消延迟,Ready 则刷新,非 Ready 则重连上次设备。进程死亡不假设回调可执行,由设备心跳门限回收会话。
普通操作请求超时 10 秒,扫描 WiFi 20 秒,切网任务最多轮询 60 秒后转“结果尚未确认,可重新读取”;超时不是事务回滚证据,不自动重发变更。
@@ -40,7 +40,7 @@ WiFi:扫描来自设备;允许手工 SSID;密码动作“保留/替换
Android 8–11 按平台要求申请前台定位扫描权限并解释用途,系统位置开关影响扫描时提供设置入口;12+ 申请 BLUETOOTH_SCAN/CONNECT,并声明扫描不用于定位。不申请后台定位、联系人、文件全盘访问或摄像头权限。权限拒绝后允许重试,永久拒绝显示系统设置入口;不在启动时重复轰炸权限弹窗。
本地 SharedPreferences 仅保存 last_device_id 与 client_name。缩略图仅驻留内存,每次断线/新连接清空;键为内容类型、条目 ID、修订,以连接隔离设备,最多 128 项或合计 8 MiB Base64 字符,按插入顺序淘汰。不是磁盘缓存,也不是 LRU。设备业务配置以核桃派为准,不自动恢复手机历史副本。禁用敏感测试数据备份;密钥和 WiFi 密码不持久化。
本地 SharedPreferences 保存连接记忆与手机昵称。内容目录及两档图片存于 Android 专属 cacheDir:设备 ID 目录隔离,图片按类型、条目 ID、修订和档位散列命名;目录原子写入,图片读取验证 PNG 尺寸,整体最多 128 MiB 并按最近使用时间淘汰。内存保留最多 128 张或 8 MiB Base64 字符,可见项按需从磁盘回填。目录修订未变才复用;变化后完整翻页并清理失效图,断线不显示旧列表。软件设置可清空该缓存,不清除设备记忆。当前设备画面沿用原有会话缓存,不写磁盘。设备业务配置以核桃派为准,不自动恢复手机历史副本。禁用敏感测试数据备份;密钥和 WiFi 密码不持久化。
## 5. 设备侧集成
@@ -72,3 +72,7 @@ BlueZ D-Bus 接入作为可恢复模块,单独管理连接/广播,不在
## 2026-09-27 界面修订
共享状态增加仅当前认证会话有效的 connectedDevice 扫描快照,以本次句柄关联按钮,持久身份仍使用完整设备 ID。扫描重置保留它,重复广播按句柄去重;所有会话结束清空关联。顶栏使用共享 Compose 跑马灯,只在溢出时无限循环,文本变化重启。两处预览复用居中卡片,标题按刷新按钮实际宽度预留对称空间。App 0.2.1/versionCode 4,仅 debug。
## Android 选测入口
遵循 [按影响选测](如何安卓测试/按影响选测.md)。日常变更不自动执行模拟器或 root 真机全量矩阵;独立 uiTest 使用真实 Compose 界面与 Controller、测试专用假平台,不替代物理 BLE。历史版本专项不作为每轮门槛。
@@ -13,8 +13,8 @@ android {
applicationId = "org.qimiaoscreen.controller"
minSdk = 26
targetSdk = 37
versionCode = 4
versionName = "0.2.1"
versionCode = 5
versionName = "0.2.2"
buildConfigField("String", "BUILD_DATE", "\"" + ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm XXX")) + "\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -15,6 +15,8 @@ import org.junit.runner.RunWith
class AppLifecycleTest {
@Test fun actualActivityReturnsFromBackground() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val scenario = checkNotNull(InstrumentationRegistry.getArguments().getString("scenario"))
check(scenario in setOf("navigation", "activity-background", "rename", "remember", "bluetooth-toggle"))
val ui = UiDevice.getInstance(instrumentation)
val rootInput = InstrumentationRegistry.getArguments().getString("root_input") == "true"
fun rootCommand(args: String) {
@@ -44,79 +46,82 @@ class AppLifecycleTest {
click(checkNotNull(device.parent.parent.findObject(By.text("连接"))))
assertTrue(ui.wait(Until.hasObject(By.text("断开连接")), 30_000))
}
if (!ui.wait(Until.hasObject(By.text("断开连接")), 30_000)) {
scanAndConnect()
if (scenario == "navigation") {
for (label in listOf("设备状态", "显示内容", "设备设置", "连接")) {
click(checkNotNull(ui.wait(Until.findObject(By.text(label)), 5_000)))
}
click(ui.findObject(By.text("⋮")))
click(checkNotNull(ui.wait(Until.findObject(By.text("关于")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.textContains("版本 " + BuildConfig.VERSION_NAME)), 5_000))
click(ui.findObject(By.text("关闭")))
return
}
if (InstrumentationRegistry.getArguments().getString("toggle_bluetooth") == "true") {
check(rootInput)
try {
rootCommand("bluetooth-disable")
assertTrue("Connection state must clear after Bluetooth is disabled",
ui.wait(Until.hasObject(By.text("扫描设备")), 15_000))
} finally { rootCommand("bluetooth-enable") }
Thread.sleep(3_000)
scanAndConnect()
}
click(ui.findObject(By.text("设备状态")))
assertTrue(ui.wait(Until.hasObject(By.text("设备画面")), 15_000))
check(!ui.hasObject(By.text("运行状态")))
click(ui.findObject(By.text("显示内容")))
assertTrue(ui.wait(Until.hasObject(By.text("可播放内容")), 15_000))
click(ui.findObject(By.text("设备设置")))
assertTrue(ui.wait(Until.hasObject(By.text("屏幕方向")), 15_000))
check(!ui.hasObject(By.text("性能模式")))
click(ui.findObject(By.text("连接")))
click(ui.findObject(By.text("⋮")))
click(checkNotNull(ui.wait(Until.findObject(By.text("关于")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.textContains("版本 " + BuildConfig.VERSION_NAME)), 5_000))
click(ui.findObject(By.text("关闭")))
click(ui.findObject(By.text("⋮")))
click(checkNotNull(ui.wait(Until.findObject(By.text("软件设置")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.text("当前手机昵称")), 5_000))
click(ui.findObject(By.text("取消")))
check(rootInput); rootCommand("home")
Thread.sleep(33_000)
launch()
assertTrue("Activity should reconnect after background release",
ui.wait(Until.hasObject(By.text("断开连接")), 35_000))
fun openRename() {
val marker = checkNotNull(ui.wait(Until.findObject(By.text("已连接")), 5_000))
val point = marker.visibleCenter
rootCommand("swipe ${point.x} ${point.y} ${point.x} ${point.y} 700")
click(checkNotNull(ui.wait(Until.findObject(By.text("重命名设备")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.text("设备昵称")), 5_000))
}
fun rename(value: String) {
val field = checkNotNull(ui.wait(Until.findObject(By.clazz("android.widget.EditText")), 5_000))
val clipboard = instrumentation.targetContext.getSystemService(android.content.ClipboardManager::class.java)
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", value))
try {
click(field)
rootCommand("clear-field ${(field.text.length + 2).coerceIn(2, 512)}")
rootCommand("paste")
rootCommand("hide-keyboard")
} finally { clipboard.clearPrimaryClip() }
click(checkNotNull(ui.wait(Until.findObject(By.text("保存")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.textStartsWith("当前连接设备:$value")), 15_000))
}
openRename()
val originalName = ui.findObject(By.clazz("android.widget.EditText")).text
var renamed = false
try {
rename("界面验证屏")
renamed = true
click(ui.findObject(By.text("断开连接")))
scanAndConnect()
assertTrue(ui.wait(Until.hasObject(By.textStartsWith("当前连接设备:界面验证屏")), 5_000))
if (!ui.wait(Until.hasObject(By.text("断开连接")), 30_000)) {
scanAndConnect()
}
if (InstrumentationRegistry.getArguments().getString("toggle_bluetooth") == "true") {
check(rootInput)
try {
rootCommand("bluetooth-disable")
assertTrue("Connection state must clear after Bluetooth is disabled",
ui.wait(Until.hasObject(By.text("扫描设备")), 15_000))
} finally { rootCommand("bluetooth-enable") }
Thread.sleep(3_000)
scanAndConnect()
}
if (scenario == "activity-background") {
check(rootInput); rootCommand("home")
Thread.sleep(33_000)
launch()
assertTrue("Activity should reconnect after background release",
ui.wait(Until.hasObject(By.text("断开连接")), 35_000))
}
fun openRename() {
val marker = checkNotNull(ui.wait(Until.findObject(By.text("已连接")), 5_000))
val point = marker.visibleCenter
rootCommand("swipe ${point.x} ${point.y} ${point.x} ${point.y} 700")
click(checkNotNull(ui.wait(Until.findObject(By.text("重命名设备")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.text("设备昵称")), 5_000))
}
fun rename(value: String) {
val field = checkNotNull(ui.wait(Until.findObject(By.clazz("android.widget.EditText")), 5_000))
val clipboard = instrumentation.targetContext.getSystemService(android.content.ClipboardManager::class.java)
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", value))
try {
click(field)
rootCommand("clear-field ${(field.text.length + 2).coerceIn(2, 512)}")
rootCommand("paste")
rootCommand("hide-keyboard")
} finally { clipboard.clearPrimaryClip() }
click(checkNotNull(ui.wait(Until.findObject(By.text("保存")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.textStartsWith("当前连接设备:$value")), 15_000))
}
if (scenario == "rename") {
openRename()
val originalName = ui.findObject(By.clazz("android.widget.EditText")).text
var renamed = false
try {
renamed = true
rename("界面验证屏")
click(ui.findObject(By.text("断开连接")))
scanAndConnect()
assertTrue(ui.wait(Until.hasObject(By.textStartsWith("当前连接设备:界面验证屏")), 5_000))
} finally {
if (renamed) { openRename(); rename(originalName) }
}
}
if (scenario == "remember") {
val again = ui.findObject(By.text("已连接")).visibleCenter
rootCommand("swipe ${again.x} ${again.y} ${again.x} ${again.y} 700")
click(checkNotNull(ui.wait(Until.findObject(By.text("取消记忆")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.text("暂无已保存设备,扫描后选择即可连接")), 5_000))
scanAndConnect()
}
} finally {
if (renamed) { openRename(); rename(originalName) }
launch()
ui.wait(Until.findObject(By.text("断开连接")), 5_000)?.let { click(it) }
}
val again = ui.findObject(By.text("已连接")).visibleCenter
rootCommand("swipe ${again.x} ${again.y} ${again.x} ${again.y} 700")
click(checkNotNull(ui.wait(Until.findObject(By.text("取消记忆")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.text("暂无已保存设备,扫描后选择即可连接")), 5_000))
scanAndConnect()
click(ui.findObject(By.text("断开连接")))
assertTrue(ui.wait(Until.hasObject(By.text("扫描设备")), 5_000))
}
}
@@ -24,6 +24,8 @@ class BleIntegrationTest {
@Test fun encryptedReadOnlySessionAndReconnect() = runBlocking {
val instrumentation = InstrumentationRegistry.getInstrumentation()
fun stage(name: String) { instrumentation.sendStatus(2, Bundle().apply { putString("stream", "BLE stage: $name\n") }) }
val scenario = checkNotNull(InstrumentationRegistry.getArguments().getString("scenario"))
check(scenario in setOf("connection", "data", "library-order", "conflict", "polling", "controller-background", "wifi-scan", "controls", "wifi-reapply"))
val context = instrumentation.targetContext
val permissions = if (Build.VERSION.SDK_INT >= 31) arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
@@ -44,7 +46,7 @@ class BleIntegrationTest {
stage("scan complete")
ble.stopScan()
var identity: String? = null
repeat(2) { iteration ->
repeat(if (scenario == "connection") 2 else if (scenario == "controller-background") 0 else 1) { iteration ->
// Reconnection uses a fresh scan, as production does. A scan
// handle is transient and must not be retained across sessions.
val target = if (iteration == 0) device else discover()
@@ -68,6 +70,7 @@ class BleIntegrationTest {
val id = hello.getValue("device_id").jsonPrimitive.content
if (identity == null) identity = id else assertEquals(identity, id)
assertEquals(1, hello.getValue("protocol_major").jsonPrimitive.int)
if (scenario == "data") {
val status = rpc.call("status.get")
stage("status received")
assertTrue(status.containsKey("state"))
@@ -76,11 +79,13 @@ class BleIntegrationTest {
assertTrue(settings.getValue("values").jsonObject.containsKey("brightness"))
rpc.call("storage.get")
stage("storage received")
}
if (scenario in setOf("data", "library-order")) {
val library = rpc.call("library.list")
stage("library received")
assertTrue(library.getValue("items").jsonArray.isNotEmpty())
val expectedOrder = InstrumentationRegistry.getArguments().getString("library_order_hash")
if (expectedOrder != null) {
if (scenario == "library-order") {
val all = library.getValue("items").jsonArray.map { it.jsonObject }.toMutableList()
var cursor = library["next_cursor"]?.jsonPrimitive?.contentOrNull
while (cursor != null) {
@@ -95,6 +100,8 @@ class BleIntegrationTest {
assertEquals("BLE must preserve device library order", expectedOrder, digest)
stage("library persistent order verified")
}
}
if (scenario == "data") {
val frame = rpc.call("frame.get")
stage("frame received")
assertEquals("image/png", frame.getValue("mime").jsonPrimitive.content)
@@ -102,11 +109,16 @@ class BleIntegrationTest {
val saved = wifi["saved"]
if (saved != null && saved != JsonNull) assertFalse(saved.jsonObject.containsKey("password"))
assertTrue(rpc.call("session.ping").getValue("alive").jsonPrimitive.boolean)
}
if (iteration == 0) {
if (scenario == "wifi-scan") {
val networks = rpc.call("wifi.scan").getValue("networks").jsonArray
assertTrue(networks.size <= 100)
assertTrue(networks.all { !it.jsonObject.containsKey("password") })
stage("WiFi scan completed without logging network names")
}
if (scenario == "conflict") {
val settings = rpc.call("settings.get")
try {
rpc.call("settings.patch", buildJsonObject {
put("expected_revision", "deliberately-stale-test-revision")
@@ -116,6 +128,7 @@ class BleIntegrationTest {
} catch (e: DeviceFailure) { assertEquals("CONFLICT", e.code) }
assertTrue(rpc.call("session.ping").getValue("alive").jsonPrimitive.boolean)
stage("configuration conflict preserves session")
}
if (InstrumentationRegistry.getArguments().getString("verify_current_controls") == "true") {
// Exercise accepted writes without changing the selected
// content, defaults, brightness, direction or network.
@@ -180,7 +193,7 @@ class BleIntegrationTest {
})
stage("current WiFi reapplied with password retained; BLE remained usable")
}
repeat(InstrumentationRegistry.getArguments().getString("poll_cycles")?.toInt() ?: 60) { cycle ->
if (scenario == "polling") repeat(InstrumentationRegistry.getArguments().getString("poll_cycles")?.toInt() ?: 60) { cycle ->
delay(2_000)
rpc.call("status.get")
rpc.call("frame.get")
@@ -190,7 +203,7 @@ class BleIntegrationTest {
} finally { stage("intentional session close $iteration"); rpc.close() }
if (iteration == 0) delay(3_000)
}
delay(3_000)
if (scenario == "controller-background") {
val saved = mutableMapOf<String, String>()
val platform = object : MobilePlatform {
override val model = "android-test-a"
@@ -204,21 +217,13 @@ class BleIntegrationTest {
val controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val controller = Controller(platform, controllerScope)
try {
withContext(Dispatchers.Main) { controller.connect(device) }
withContext(Dispatchers.Main) { controller.connect(discover()) }
try {
withTimeout(30_000) { controller.state.first { it.status != null } }
} catch (e: TimeoutCancellationException) {
error("Controller failed: phase=${controller.state.value.phase}, error=${controller.state.value.error}")
}
stage("controller device page ready")
withContext(Dispatchers.Main) { controller.page(2) }
withTimeout(20_000) { controller.state.first { it.library.isNotEmpty() && !it.busy } }
withContext(Dispatchers.Main) { controller.state.value.library.take(3).forEach(controller::thumbnail) }
delay(10_000)
assertNotNull(controller.state.value.identity)
withContext(Dispatchers.Main) { controller.page(3) }
withTimeout(20_000) { controller.state.first { it.wifi != null && !it.busy } }
stage("controller content and settings ready")
withContext(Dispatchers.Main) { controller.foreground(false) }
delay(5_000)
withContext(Dispatchers.Main) { controller.foreground(true) }
@@ -229,12 +234,13 @@ class BleIntegrationTest {
stage("controller background released")
delay(3_000)
withContext(Dispatchers.Main) { controller.foreground(true) }
withTimeout(35_000) { controller.state.first { it.identity != null && it.wifi != null } }
withTimeout(35_000) { controller.state.first { it.identity != null && it.status != null } }
stage("controller foreground reconnected")
} finally {
withContext(Dispatchers.Main) { controller.disconnect() }
controllerScope.cancel()
}
}
} finally { ble.stopScan() }
}
}
@@ -76,8 +76,6 @@ class LayoutTest {
}
ui.executeShellCommand("am start -W -n org.qimiaoscreen.controller/.MainActivity")
assertTrue(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000))
for (label in listOf("连接", "设备状态", "显示内容", "设备设置", "连接")) click(label)
click("⋮"); click("关于"); click("关闭")
fun tagged(tag: String): UiObject2 = checkNotNull(visible(By.res(tag), 35_000, tag == "connected_scan_disconnect")) {
"Required App semantic tag unavailable: $tag"
}
@@ -118,7 +116,7 @@ class LayoutTest {
tagged("connected_scan_disconnect")
Thread.sleep(13_000)
assertTrue(ui.hasObject(By.res("connected_scan_disconnect")))
for (page in listOf("设备状态", "显示内容")) {
if (InstrumentationRegistry.getArguments().getString("scenario") == "layout") for (page in listOf("设备状态", "显示内容")) {
stage("frame-" + if (page == "设备状态") "status" else "content")
click(page)
tagged("frame_image")
@@ -0,0 +1,141 @@
package org.qimiaoscreen.controller
import android.Manifest
import android.content.ContextWrapper
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Base64
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
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
/** Read-only BLE cache check; records aggregate image payload counts, never IDs. */
@RunWith(AndroidJUnit4::class)
class LibraryBleCacheTest {
@Test fun coldAndWarmLibrary() = runBlocking {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val permissions = if (Build.VERSION.SDK_INT >= 31) arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
check(permissions.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED })
val expectedName = checkNotNull(InstrumentationRegistry.getArguments().getString("device_name"))
val sandbox = File(context.cacheDir, "library-ble-test-${System.nanoTime()}").apply { mkdirs() }
val cache = AndroidLibraryCache(object : ContextWrapper(context) { override fun getCacheDir() = sandbox })
val ble = AndroidBle(context)
suspend fun discover(): DiscoveredDevice {
val found = CompletableDeferred<DiscoveredDevice>()
try {
ble.scan({ if (it.name == expectedName) found.complete(it) }, { found.completeExceptionally(IllegalStateException(it)) })
return withTimeout(20_000) { found.await() }
} finally { ble.stopScan() }
}
suspend fun <T> session(block: suspend (DeviceRpc, JsonObject) -> T): T {
val link = ble.connect(discover().handle)
val rpc = DeviceRpc(link, JavaSecureChannel())
try {
val identity = rpc.open("android-test-a")
assertEquals(1, identity.getValue("protocol_minor").jsonPrimitive.int)
assertTrue(identity.getValue("capabilities").jsonArray.any { it.jsonPrimitive.content == "library_progressive" })
return block(rpc, identity)
} finally { rpc.close() }
}
try {
cache.clear()
val first = session { rpc, identity ->
val deviceId = identity.getValue("device_id").jsonPrimitive.content
val summary = try { rpc.call("library.summary") }
catch (e: DeviceFailure) { error("library.summary failed: ${e.code}") }
val revision = summary.getValue("library_revision").jsonPrimitive.content
val items = mutableListOf<JsonObject>()
var cursor: String? = null
do {
val page = try { rpc.call("library.list", buildJsonObject { put("limit", 50); cursor?.let { put("cursor", it) } }) }
catch (e: DeviceFailure) { error("library.list page failed: ${e.code}") }
assertEquals(revision, page.getValue("library_revision").jsonPrimitive.content)
items += page.getValue("items").jsonArray.map { it.jsonObject }
cursor = page["next_cursor"]?.jsonPrimitive?.contentOrNull
} while (cursor != null)
assertEquals(summary.getValue("item_count").jsonPrimitive.int, items.size)
cache.saveCatalog(deviceId, CachedLibrary(revision, items))
var requests = 0
var bytes = 0L
val images = items.filter { it["playable"]?.jsonPrimitive?.booleanOrNull != false }
for ((method, tier) in listOf("library.preview" to "preview", "library.thumbnail" to "full")) {
for ((index, item) in images.withIndex()) {
val response = try { rpc.call(method, item) }
catch (e: DeviceFailure) { error("$method index=$index type=${item.getValue("type").jsonPrimitive.content} failed: ${e.code}") }
assertEquals("image/png", response.getValue("mime").jsonPrimitive.content)
val encoded = response.getValue("data_base64").jsonPrimitive.content
requests++
bytes += Base64.decode(encoded, Base64.DEFAULT).size
cache.writeImage(deviceId, item, tier, encoded)
}
}
assertEquals(2 * images.size, requests)
assertTrue(bytes > 0)
assertEquals(revision, rpc.call("library.summary").getValue("library_revision").jsonPrimitive.content)
instrumentation.sendStatus(2, Bundle().apply { putString("stream", "BLE library cold: items=${items.size}, image requests=$requests, PNG payload bytes=$bytes\n") })
Triple(deviceId, revision, images)
}
session { rpc, identity ->
assertEquals(first.first, identity.getValue("device_id").jsonPrimitive.content)
assertEquals(first.second, rpc.call("library.summary").getValue("library_revision").jsonPrimitive.content)
val catalog = cache.loadCatalog(first.first)
assertEquals(first.second, catalog?.revision)
for (item in first.third) {
assertNotNull(cache.readImage(first.first, item, "preview"))
assertNotNull(cache.readImage(first.first, item, "full"))
}
instrumentation.sendStatus(2, Bundle().apply { putString("stream", "BLE library warm: image requests=0, PNG payload bytes=0\n") })
}
var controllerWrites = 0
val countingCache = object : LibraryCache by cache {
override suspend fun writeImage(deviceId: String, item: JsonObject, tier: String, base64: String) {
controllerWrites++
cache.writeImage(deviceId, item, tier, base64)
}
}
val platform = object : MobilePlatform {
override val model = "android-test-a"
override val libraryCache: LibraryCache = countingCache
private val saved = mutableMapOf<String, String>()
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) = ble.scan(onDevice, onError)
override fun stopScan() = ble.stopScan()
override suspend fun connect(handle: String) = ble.connect(handle)
override fun crypto(): SecureChannel = JavaSecureChannel()
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val controller = Controller(platform, scope)
try {
withContext(Dispatchers.Main) { controller.connect(discover()) }
withTimeout(30_000) { controller.state.first { it.status != null } }
withContext(Dispatchers.Main) { controller.page(2) }
withTimeout(30_000) { controller.state.first { it.library.size >= first.third.size && it.thumbnails.size == first.third.size } }
delay(3_000)
assertEquals(0, controllerWrites)
instrumentation.sendStatus(2, Bundle().apply { putString("stream", "Production Controller warm: image cache writes=0\n") })
} finally {
withContext(Dispatchers.Main) { controller.disconnect() }
scope.cancel()
}
} finally { ble.stopScan(); sandbox.deleteRecursively() }
}
}
@@ -0,0 +1,55 @@
package org.qimiaoscreen.controller
import android.content.ContextWrapper
import android.graphics.Bitmap
import android.util.Base64
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.qimiaoscreen.core.CachedLibrary
import java.io.ByteArrayOutputStream
import java.io.File
@RunWith(AndroidJUnit4::class)
class LibraryCacheTest {
@Test fun revisionIsolationCorruptionAndClear() = runBlocking {
val target = InstrumentationRegistry.getInstrumentation().targetContext
val sandbox = File(target.cacheDir, "library-cache-test-${System.nanoTime()}").apply { mkdirs() }
val context = object : ContextWrapper(target) { override fun getCacheDir() = sandbox }
val cache = AndroidLibraryCache(context)
val old = buildJsonObject { put("type", "template"); put("id", "test-item"); put("revision", "one"); put("name", "测试") }
val updated = buildJsonObject { put("type", "template"); put("id", "test-item"); put("revision", "two"); put("name", "测试") }
fun png(size: Int): String {
val output = ByteArrayOutputStream()
Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).apply {
eraseColor(android.graphics.Color.RED)
compress(Bitmap.CompressFormat.PNG, 100, output)
recycle()
}
return Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP)
}
try {
cache.saveCatalog("device-a", CachedLibrary("catalog-one", listOf(old)))
cache.writeImage("device-a", old, "preview", png(16))
cache.writeImage("device-a", old, "full", png(64))
assertEquals("catalog-one", cache.loadCatalog("device-a")?.revision)
assertNotNull(cache.readImage("device-a", old, "preview"))
assertNull(cache.readImage("device-b", old, "full"))
cache.retain("device-a", listOf(updated))
assertNull(cache.readImage("device-a", old, "preview"))
cache.writeImage("device-a", updated, "preview", png(16))
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"))
assertTrue(cache.sizeBytes() > 0)
cache.clear()
assertNull(cache.loadCatalog("device-a"))
assertEquals(0L, cache.sizeBytes())
} finally { sandbox.deleteRecursively() }
}
}
@@ -0,0 +1,88 @@
package org.qimiaoscreen.controller
import android.content.Context
import android.graphics.BitmapFactory
import android.util.Base64
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.*
import org.qimiaoscreen.core.*
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
/** Only files below this app's content-cache directory are ever deleted. */
class AndroidLibraryCache(context: Context) : LibraryCache {
private val root = File(context.cacheDir, "qms-library-v1")
private val limit = 128L * 1024 * 1024
private fun hash(value: String): String = MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
private fun deviceDir(id: String) = File(root, hash(id))
private fun imageName(item: JsonObject, tier: String): String = hash(
listOf(tier, item.getValue("type").jsonPrimitive.content, item.getValue("id").jsonPrimitive.content,
item.getValue("revision").jsonPrimitive.content).joinToString("\u0000")) + ".png"
private fun imageFile(id: String, item: JsonObject, tier: String) = File(deviceDir(id), imageName(item, tier))
private fun atomicWrite(file: File, bytes: ByteArray) {
file.parentFile?.mkdirs()
val temp = File(file.parentFile, file.name + ".tmp")
try {
temp.writeBytes(bytes)
Files.move(temp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
} 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 < 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
if (options.outWidth != expected || options.outHeight != expected) return false
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) != null
}
override suspend fun loadCatalog(deviceId: String): CachedLibrary? = withContext(Dispatchers.IO) {
runCatching {
val value = Json.parseToJsonElement(File(deviceDir(deviceId), "catalog.json").readText(Charsets.UTF_8)).jsonObject
val items = value.getValue("items").jsonArray.map { it.jsonObject }
require(items.all { item -> listOf("type", "id", "revision", "name").all { it in item } })
CachedLibrary(value.getValue("revision").jsonPrimitive.content, items)
}.getOrNull()
}
override suspend fun saveCatalog(deviceId: String, catalog: CachedLibrary) = withContext(Dispatchers.IO) {
val data = buildJsonObject { put("revision", catalog.revision); put("items", JsonArray(catalog.items)) }
atomicWrite(File(deviceDir(deviceId), "catalog.json"), data.toString().toByteArray(Charsets.UTF_8))
}
override suspend fun readImage(deviceId: String, item: JsonObject, tier: String): String? = withContext(Dispatchers.IO) {
val file = imageFile(deviceId, item, tier)
val bytes = if (file.length() in 1..65536) runCatching { file.readBytes() }.getOrNull() else null
if (bytes == null || !validPng(bytes, tier)) { file.delete(); null }
else { file.setLastModified(System.currentTimeMillis()); Base64.encodeToString(bytes, Base64.NO_WRAP) }
}
override suspend fun writeImage(deviceId: String, item: JsonObject, tier: String, base64: String) = withContext(Dispatchers.IO) {
val bytes = Base64.decode(base64, Base64.DEFAULT)
require(validPng(bytes, tier)) { "设备返回的图片无效" }
atomicWrite(imageFile(deviceId, item, tier), bytes)
trim()
}
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()
deviceDir(deviceId).listFiles()?.forEach { file ->
if (file.isFile && file.name.endsWith(".png") && file.name !in expected) file.delete()
}
Unit
}
private fun cacheFiles(): List<File> = root.listFiles()?.flatMap { it.listFiles()?.filter { f -> f.isFile } ?: emptyList() } ?: emptyList()
private fun trim() {
val files = cacheFiles().sortedBy { it.lastModified() }
var total = files.sumOf { it.length() }
for (file in files) {
if (total <= limit) break
val length = file.length()
if (file.delete()) total -= length
}
}
override suspend fun sizeBytes(): Long = withContext(Dispatchers.IO) {
root.listFiles()?.sumOf { directory -> directory.listFiles()?.sumOf { it.length() } ?: 0L } ?: 0L
}
override suspend fun clear() = withContext(Dispatchers.IO) { root.deleteRecursively(); Unit }
}
@@ -45,6 +45,7 @@ class MainActivity : ComponentActivity() {
val preferences = getSharedPreferences("controller", MODE_PRIVATE)
val platform = object : MobilePlatform {
override val model = Build.MODEL
override val libraryCache: LibraryCache = AndroidLibraryCache(applicationContext)
override fun load(key: String) = preferences.getString(key, null)
override fun save(key: String, value: String) { preferences.edit().putString(key, value).apply() }
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) = ble.scan(onDevice, onError)
@@ -5,3 +5,5 @@ dependencyResolutionManagement {
}
rootProject.name = "QimiaoScreen"
include(":androidApp", ":sharedCore", ":sharedUi")
include(":uiTest")
@@ -9,6 +9,7 @@ import kotlin.time.Clock
interface MobilePlatform {
val model: String
val libraryCache: LibraryCache? get() = null
fun load(key: String): String?
fun save(key: String, value: String)
fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit)
@@ -31,7 +32,8 @@ data class ControllerState(
val status: JsonObject? = null, val storage: JsonObject? = null,
val settings: JsonObject? = null, val wifi: JsonObject? = null,
val library: List<JsonObject> = emptyList(), val networks: List<JsonObject> = emptyList(),
val thumbnails: Map<String, String> = emptyMap(), val frame: String? = null,
val previews: Map<String, String> = emptyMap(), val thumbnails: Map<String, String> = emptyMap(),
val cacheBytes: Long = 0, val frame: String? = null,
val frameCheckedAtMs: Long? = null, val defaultContent: JsonObject? = null,
)
@@ -76,11 +78,22 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
private var releaseJob: Job? = null
private var foreground = true
private var frameRevision: String? = null
private var libraryRevision: String? = null
private var libraryLoaded = false
private var lastLibraryCheckMs = 0L
private var cacheGeneration = 0L
private var connectionJob: Job? = null
private var commandJob: Job? = null
private var connectionGeneration = 0L
private var scanGeneration = 0L
private val pendingPreviews = linkedMapOf<String, JsonObject>()
private val pendingThumbnails = linkedMapOf<String, JsonObject>()
private val hydrating = mutableSetOf<String>()
init { scope.launch {
try { platform.libraryCache?.sizeBytes()?.let { size -> change { it.copy(cacheBytes = size) } } }
catch (e: CancellationException) { throw e }
catch (_: Exception) { /* Cache can disappear under storage pressure. */ }
} }
private fun change(update: (ControllerState) -> ControllerState) { mutable.update(update) }
fun dismissError() = change { it.copy(error = null) }
fun reportError(message: String) = change { it.copy(error = message) }
@@ -114,7 +127,7 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
if (connectionJob?.isActive == true || rpc != null) disconnect()
scanGeneration++; scanJob?.cancel(); platform.stopScan()
val generation = ++connectionGeneration
frameRevision = null; pendingThumbnails.clear()
frameRevision = null; resetLibraryQueue()
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, devices = it.devices, phase = "正在连接") }
connectionJob = scope.launch {
var pending: DeviceRpc? = null
@@ -150,11 +163,11 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
connectionGeneration++; scanGeneration++
connectionJob?.cancel(); commandJob?.cancel(); scanJob?.cancel(); pollJob?.cancel(); releaseJob?.cancel()
platform.stopScan(); rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; requestedDeviceId = null
pendingThumbnails.clear()
resetLibraryQueue()
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered) }
}
private fun connectionLost(message: String) {
rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; pendingThumbnails.clear()
rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; resetLibraryQueue()
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, phase = "连接中断", error = message) }
}
fun foreground(value: Boolean) {
@@ -162,13 +175,25 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
releaseJob?.cancel()
if (value) {
if (rpc == null && !platform.load("last_device_id").isNullOrBlank()) scan(auto = true)
else if (rpc != null && state.value.page == 2) action { refreshLibrary(true) }
} else {
pendingPreviews.clear(); pendingThumbnails.clear()
scanJob?.cancel(); platform.stopScan()
change { it.copy(scanning = false) }
releaseJob = scope.launch { delay(30_000); disconnect() }
}
}
fun page(value: Int) { change { it.copy(page = value) }; if (rpc != null) action { refreshPage() } }
fun page(value: Int) {
if (value != 2) { pendingPreviews.clear(); pendingThumbnails.clear() }
change { it.copy(page = value) }
if (rpc != null) action { refreshPage() }
}
private fun resetLibraryQueue() {
pendingPreviews.clear(); pendingThumbnails.clear()
hydrating.clear()
cacheGeneration++
libraryRevision = null; libraryLoaded = false; lastLibraryCheckMs = 0L
}
private fun capabilities() = state.value.identity?.get("capabilities")?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList()
fun supports(capability: String) = capability in capabilities()
private suspend fun call(method: String, params: JsonObject = buildJsonObject {}) = checkNotNull(rpc).call(method, params)
@@ -198,30 +223,102 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
when (state.value.page) {
0 -> Unit
1 -> if (supports("frame")) refreshFrame()
2 -> if (supports("library")) {
var items: List<JsonObject> = emptyList()
for (attempt in 0..1) {
try {
val loaded = mutableListOf<JsonObject>()
var cursor: String? = null
do {
val result = call("library.list", buildJsonObject { put("limit", 50); cursor?.let { put("cursor", it) } })
loaded += result.getValue("items").jsonArray.map { it.jsonObject }
cursor = result["next_cursor"]?.jsonPrimitive?.contentOrNull
} while (cursor != null)
items = loaded
break
} catch (e: DeviceFailure) { if (e.code != "CONFLICT" || attempt == 1) throw e }
}
val defaultContent = call("content.default.get")
change { it.copy(library = items, defaultContent = defaultContent) }
}
2 -> if (supports("library")) refreshLibrary(true)
3 -> {
if (supports("settings")) { val value = call("settings.get"); change { it.copy(settings = value) } }
if (supports("wifi")) { val value = call("wifi.get"); change { it.copy(wifi = value) } }
}
}
}
private suspend fun loadLibraryPages(): Pair<String, List<JsonObject>> {
for (attempt in 0..1) {
try {
val items = mutableListOf<JsonObject>()
var cursor: String? = null
var revision: String? = null
do {
val result = call("library.list", buildJsonObject { put("limit", 50); cursor?.let { put("cursor", it) } })
val pageRevision = result.getValue("library_revision").jsonPrimitive.content
if (revision != null && pageRevision != revision) throw DeviceFailure("CONFLICT", "内容列表已改变")
revision = pageRevision
items += result.getValue("items").jsonArray.map { it.jsonObject }
cursor = result["next_cursor"]?.jsonPrimitive?.contentOrNull
} while (cursor != null)
return checkNotNull(revision) to items
} catch (e: DeviceFailure) { if (e.code != "CONFLICT" || attempt == 1) throw e }
}
error("内容列表读取失败")
}
private suspend fun cachedImage(deviceId: String, item: JsonObject, tier: String): String? =
try { platform.libraryCache?.readImage(deviceId, item, tier) }
catch (e: CancellationException) { throw e }
catch (_: Exception) { null }
private fun rememberImage(key: String, image: String, full: Boolean) {
change { old ->
val values = ((if (full) old.thumbnails else old.previews) + (key to image)).toMutableMap()
while (values.size > 128 || values.values.sumOf { it.length } > 8 * 1024 * 1024) values.remove(values.keys.first())
if (full) old.copy(thumbnails = values) else old.copy(previews = values)
}
}
private suspend fun queueLibraryImages(deviceId: String, items: List<JsonObject>) {
pendingPreviews.clear(); pendingThumbnails.clear()
for (item in items) {
if (item["playable"]?.jsonPrimitive?.booleanOrNull == false) continue
val key = thumbnailKey(item)
val full = cachedImage(deviceId, item, "full")
if (full != null) { rememberImage(key, full, true); continue }
if (supports("library_progressive")) {
val preview = cachedImage(deviceId, item, "preview")
if (preview != null) rememberImage(key, preview, false)
else pendingPreviews[key] = item
pendingThumbnails[key] = item
}
}
}
private suspend fun refreshLibrary(forceQueue: Boolean) {
val deviceId = state.value.identity?.get("device_id")?.jsonPrimitive?.content ?: return
val progressive = supports("library_progressive")
val summary = if (progressive) call("library.summary") else null
val expectedRevision = summary?.get("library_revision")?.jsonPrimitive?.content
lastLibraryCheckMs = Clock.System.now().toEpochMilliseconds()
val changed = !libraryLoaded || !progressive || expectedRevision != libraryRevision
var items = state.value.library
if (changed) {
cacheGeneration++
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) {
items = cached.items
libraryRevision = cached.revision
} else {
change { it.copy(library = emptyList(), previews = emptyMap(), thumbnails = emptyMap()) }
val loaded = loadLibraryPages()
if (expectedRevision != null && loaded.first != expectedRevision) {
lastLibraryCheckMs = 0L
throw DeviceFailure("CONFLICT", "内容列表已改变,请重新加载")
}
libraryRevision = loaded.first
items = loaded.second
try { platform.libraryCache?.saveCatalog(deviceId, CachedLibrary(loaded.first, items)) }
catch (e: CancellationException) { throw e }
catch (_: Exception) { /* Cache loss must not close a working BLE connection. */ }
}
try { platform.libraryCache?.retain(deviceId, items) }
catch (e: CancellationException) { throw e }
catch (_: Exception) { /* Regenerable files can be pruned next time. */ }
libraryLoaded = true
change { it.copy(library = items, previews = emptyMap(), thumbnails = emptyMap()) }
}
if (changed || forceQueue) queueLibraryImages(deviceId, items)
if (forceQueue) {
val defaultContent = call("content.default.get")
change { it.copy(defaultContent = defaultContent) }
}
try { platform.libraryCache?.sizeBytes()?.let { size -> change { it.copy(cacheBytes = size) } } }
catch (e: CancellationException) { throw e }
catch (_: Exception) { /* Storage reporting is optional. */ }
}
private fun startPolling() {
pollJob?.cancel()
pollJob = scope.launch {
@@ -232,19 +329,28 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
if (!foreground) { call("session.ping"); continue }
refreshStatus()
if (state.value.page in 1..2 && supports("frame")) refreshFrame()
if (state.value.page == 2 && !state.value.busy && pendingThumbnails.isNotEmpty()) {
val (key, item) = pendingThumbnails.entries.first()
pendingThumbnails.remove(key)
val image = call("library.thumbnail", item).getValue("data_base64").jsonPrimitive.content
change { old ->
val cache = (old.thumbnails + (key to image)).toMutableMap()
while (cache.size > 128 || cache.values.sumOf { it.length } > 8 * 1024 * 1024) cache.remove(cache.keys.first())
old.copy(thumbnails = cache)
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.
if (e.code == "CONFLICT" || e.code == "NOT_FOUND") lastLibraryCheckMs = 0L
change { it.copy(error = e.message) }
} catch (e: Exception) {
if (e !is CancellationException) {
@@ -262,9 +368,37 @@ class Controller(private val platform: MobilePlatform, private val scope: Corout
}
fun refresh() = action { refreshStatus(); refreshPage(); if (state.value.page == 2 && supports("frame")) refreshFrame() }
fun thumbnail(item: JsonObject) {
if (item["playable"]?.jsonPrimitive?.booleanOrNull == false) return
val key = thumbnailKey(item)
if (key !in state.value.thumbnails) {
if (pendingThumbnails.size < 128) pendingThumbnails[key] = item
if (key in state.value.thumbnails || key in hydrating) return
val deviceId = state.value.identity?.get("device_id")?.jsonPrimitive?.content ?: return
val generation = connectionGeneration
val cacheEpoch = cacheGeneration
hydrating += key
scope.launch {
try {
val full = cachedImage(deviceId, item, "full")
val preview = if (full == null && supports("library_progressive")) cachedImage(deviceId, item, "preview") else null
if (generation == connectionGeneration && cacheEpoch == cacheGeneration && state.value.page == 2 &&
state.value.library.any { thumbnailKey(it) == key }) {
if (full != null) rememberImage(key, full, true)
else if (preview != null && key !in state.value.previews) rememberImage(key, preview, false)
else if (!supports("library_progressive") && key !in pendingThumbnails) pendingThumbnails[key] = item
}
} finally { hydrating.remove(key) }
}
}
fun clearContentCache() {
scope.launch {
try {
commands.withLock {
platform.libraryCache?.clear()
resetLibraryQueue()
change { it.copy(library = emptyList(), previews = emptyMap(), thumbnails = emptyMap(), cacheBytes = 0) }
if (rpc != null && state.value.page == 2) refreshLibrary(true)
}
} catch (e: CancellationException) { throw e }
catch (e: Exception) { change { it.copy(error = e.message ?: "清空缓存失败") } }
}
}
fun play(item: JsonObject, default: Boolean = false) = action {
@@ -0,0 +1,16 @@
package org.qimiaoscreen.core
import kotlinx.serialization.json.JsonObject
data class CachedLibrary(val revision: String, val items: List<JsonObject>)
/** Regenerable content-list cache. Implementations never persist device settings or credentials. */
interface LibraryCache {
suspend fun loadCatalog(deviceId: String): CachedLibrary?
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 retain(deviceId: String, items: List<JsonObject>)
suspend fun sizeBytes(): Long
suspend fun clear()
}
@@ -9,6 +9,11 @@ import kotlin.test.*
/** Test-only simulated device; never included in production scans or APK code. */
private class SimulatedPlatform : MobilePlatform {
override val model = "simulated-test-phone"
var progressive = false
var libraryRevision = "library-v1"
var itemRevision = "item-v1"
val called = mutableListOf<String>()
override val libraryCache: LibraryCache = SimulatedLibraryCache()
val preferences = mutableMapOf<String, String>()
val links = mutableListOf<SimulatedLink>()
val scanCallbacks = mutableListOf<(DiscoveredDevice) -> Unit>()
@@ -17,7 +22,7 @@ private class SimulatedPlatform : MobilePlatform {
override fun save(key: String, value: String) { preferences[key] = value }
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) { scanCallbacks += onDevice }
override fun stopScan() {}
override suspend fun connect(handle: String) = SimulatedLink(if (handle == "second-handle") "11111111-0000-4000-8000-000000000001" else "00000000-0000-4000-8000-000000000001", signal).also { links += it }
override suspend fun connect(handle: String) = SimulatedLink(if (handle == "second-handle") "11111111-0000-4000-8000-000000000001" else "00000000-0000-4000-8000-000000000001", signal, this).also { links += it }
override fun crypto() = object : SecureChannel {
override val hello = byteArrayOf(1)
override fun finish(serverHello: ByteArray) {}
@@ -26,7 +31,23 @@ private class SimulatedPlatform : MobilePlatform {
}
}
private class SimulatedLink(private val deviceId: String, private val signal: Deferred<Int?>?) : DeviceLink {
private class SimulatedLibraryCache : LibraryCache {
val catalogs = mutableMapOf<String, CachedLibrary>()
val images = mutableMapOf<String, String>()
private fun key(id: String, item: JsonObject, tier: String) = "$id|${thumbnailKey(item)}|$tier"
override suspend fun loadCatalog(deviceId: String) = catalogs[deviceId]
override suspend fun saveCatalog(deviceId: String, catalog: CachedLibrary) { catalogs[deviceId] = catalog }
override suspend fun readImage(deviceId: String, item: JsonObject, tier: String) = images[key(deviceId, item, tier)]
override suspend fun writeImage(deviceId: String, item: JsonObject, tier: String, base64: String) { images[key(deviceId, item, tier)] = base64 }
override suspend fun retain(deviceId: String, items: List<JsonObject>) {
val valid = items.flatMap { listOf(key(deviceId, it, "preview"), key(deviceId, it, "full")) }.toSet()
images.keys.removeAll { it.startsWith("$deviceId|") && it !in valid }
}
override suspend fun sizeBytes() = images.values.sumOf { it.length.toLong() }
override suspend fun clear() { catalogs.clear(); images.clear() }
}
private class SimulatedLink(private val deviceId: String, private val signal: Deferred<Int?>?, private val platform: SimulatedPlatform) : DeviceLink {
override val mtu = 247
private val receiver = Reassembler()
private val messages = Channel<ByteArray>(Channel.UNLIMITED)
@@ -41,16 +62,29 @@ private class SimulatedLink(private val deviceId: String, private val signal: De
val response = if (kind == 1) byteArrayOf(2) else {
val request = Protocol.json.parseToJsonElement(payload.decodeToString()).jsonObject
val method = request.getValue("method").jsonPrimitive.content
platform.called += method
if (method == failure) throw DeviceTransportFailure("simulated connection loss")
if (method == "status.get" && blockStorage) { storageEntered.complete(Unit); awaitCancellation() }
val result = when (method) {
"session.open" -> """{"device_id":"$deviceId","device_name":"模拟屏","short_id":"${deviceId.take(8)}","protocol_major":1,"capabilities":["status","frame"]}"""
"session.open" -> """{"device_id":"$deviceId","device_name":"模拟屏","short_id":"${deviceId.take(8)}","protocol_major":1,"capabilities":["status","frame","library"${if (platform.progressive) ",\"library_progressive\"" else ""}]}"""
"device.rename" -> buildJsonObject {
put("device_id", deviceId); put("device_name", request.getValue("params").jsonObject.getValue("name"))
put("short_id", deviceId.take(8)); put("protocol_major", 1); put("capabilities", JsonArray(emptyList()))
}.toString()
"status.get" -> """{"state":{}}"""
"frame.get" -> """{"frame_revision":"test-frame","data_base64":"test-frame-data"}"""
"library.summary" -> """{"library_revision":"${platform.libraryRevision}","item_count":2}"""
"library.list" -> buildJsonObject {
put("library_revision", platform.libraryRevision)
put("next_cursor", JsonNull)
put("items", JsonArray((1..2).map { number -> buildJsonObject {
put("type", "template"); put("id", "item-$number"); put("revision", platform.itemRevision)
put("name", "条目$number"); put("playable", true)
} }))
}.toString()
"content.default.get" -> """{"name":"默认内容"}"""
"library.preview" -> """{"mime":"image/png","data_base64":"small"}"""
"library.thumbnail" -> """{"mime":"image/png","data_base64":"full"}"""
else -> "{}"
}
buildJsonObject {
@@ -239,4 +273,53 @@ class ControllerRecoveryTest {
} finally { controller.disconnect(); scope.cancel() }
}
@Test fun progressiveLibraryUsesTwoPassesAndReusesVerifiedCache() = runBlocking {
val platform = SimulatedPlatform().apply { progressive = true }
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.connect(device)
withTimeout(2_000) { controller.state.first { it.status != null } }
controller.page(2)
withTimeout(5_000) { controller.state.first { it.library.size == 2 && !it.busy } }
withTimeout(16_000) { controller.state.first { it.thumbnails.size == 2 } }
assertEquals(listOf("library.preview", "library.preview", "library.thumbnail", "library.thumbnail"),
platform.called.filter { it == "library.preview" || it == "library.thumbnail" })
val cache = platform.libraryCache as SimulatedLibraryCache
assertEquals(4, cache.images.size)
controller.disconnect()
platform.called.clear()
controller.connect(device)
withTimeout(5_000) { controller.state.first { it.library.size == 2 && it.thumbnails.size == 2 } }
assertTrue("library.summary" in platform.called)
assertFalse(platform.called.any { it in listOf("library.list", "library.preview", "library.thumbnail") })
platform.libraryRevision = "library-v2"
platform.itemRevision = "item-v2"
controller.refresh()
withTimeout(5_000) { controller.state.first { it.library.firstOrNull()?.get("revision")?.jsonPrimitive?.content == "item-v2" && !it.busy } }
assertTrue("library.list" in platform.called)
assertFalse(cache.images.keys.any { "item-v1" in it })
controller.disconnect()
controller.clearContentCache()
withTimeout(2_000) { controller.state.first { it.cacheBytes == 0L } }
assertTrue(cache.images.isEmpty())
assertTrue(controller.state.value.remembered.isNotEmpty())
} finally { controller.disconnect(); scope.cancel() }
}
@Test fun oldDeviceFetchesVisibleFullImageWithoutNewMethods() = runBlocking {
val platform = SimulatedPlatform().apply { progressive = false }
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val controller = Controller(platform, scope)
try {
controller.connect(device)
withTimeout(2_000) { controller.state.first { it.status != null } }
controller.page(2)
withTimeout(5_000) { controller.state.first { it.library.size == 2 && !it.busy } }
controller.thumbnail(controller.state.value.library.first())
withTimeout(6_000) { controller.state.first { it.thumbnails.isNotEmpty() } }
assertFalse(platform.called.any { it in listOf("library.summary", "library.preview") })
} finally { controller.disconnect(); scope.cancel() }
}
}
@@ -78,10 +78,18 @@ fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> Im
if (dialog == "settings") {
var name by remember { mutableStateOf(state.clientName) }
AlertDialog(onDismissRequest = { dialog = null }, title = { Text("软件设置") },
text = { OutlinedTextField(name, { name = it.take(40) }, label = { Text("当前手机昵称") }, singleLine = true) },
text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(name, { name = it.take(40) }, label = { Text("当前手机昵称") }, singleLine = true)
Text("内容缓存:${state.cacheBytes / 1024} KiB")
TextButton({ dialog = "clear-cache" }, Modifier.testTag("cache_clear_open")) { Text("清空内容缓存") }
} },
confirmButton = { TextButton({ controller.renamePhone(name); if (name.trim().isNotEmpty()) dialog = null }, enabled = !state.busy) { Text("保存") } },
dismissButton = { TextButton({ dialog = null }) { Text("取消") } })
}
if (dialog == "clear-cache") AlertDialog(onDismissRequest = { dialog = "settings" }, title = { Text("清空内容缓存") },
text = { Text("删除本机保存的内容列表、预览图和完整图。再次打开显示内容时会重新从设备获取。") },
confirmButton = { TextButton({ controller.clearContentCache(); dialog = null }, Modifier.testTag("cache_clear_confirm")) { Text("清空") } },
dismissButton = { TextButton({ dialog = "settings" }) { Text("取消") } })
}
}
@@ -172,8 +180,8 @@ private fun ContentPage(state: ControllerState, c: Controller, decode: (String)
item { Text("可播放内容", style = MaterialTheme.typography.titleLarge) }
items(state.library, key = { it.text("type") + it.text("id") }) { item ->
val key = thumbnailKey(item)
LaunchedEffect(key) { c.thumbnail(item) }
val encoded = state.thumbnails[key]
val encoded = state.thumbnails[key] ?: state.previews[key]
LaunchedEffect(key, encoded) { c.thumbnail(item) }
val image = remember(encoded) { encoded?.let(decode) }
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")) }
@@ -0,0 +1,32 @@
plugins {
id("com.android.test")
alias(libs.plugins.compose.compiler)
alias(libs.plugins.compose.multiplatform)
}
android {
namespace = "org.qimiaoscreen.uitest"
compileSdk = 37
targetProjectPath = ":androidApp"
experimentalProperties["android.experimental.self-instrumenting"] = true
defaultConfig {
minSdk = 26
targetSdk = 37
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures { compose = true }
buildTypes { getByName("debug") { isDebuggable = true } }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(project(":sharedCore"))
implementation(project(":sharedUi"))
implementation(libs.activity.compose)
implementation(libs.coroutines.android)
implementation(libs.serialization.json)
implementation("org.jetbrains.compose.ui:ui-test-junit4:1.12.1")
implementation("androidx.test:runner:1.7.0")
implementation("androidx.test.ext:junit:1.3.0")
}
@@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:label="QMS UI tests" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false">
<activity android:name=".FixtureActivity" android:exported="true" />
</application>
</manifest>
@@ -0,0 +1,79 @@
package org.qimiaoscreen.uitest
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import android.os.Bundle
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.serialization.json.*
import org.qimiaoscreen.core.*
import org.qimiaoscreen.ui.QimiaoApp
import androidx.compose.ui.graphics.ImageBitmap
/** Only in the self-instrumenting test APK. No AndroidBle, network or private data. */
class FixtureActivity : ComponentActivity() {
val platform = FixturePlatform()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val controller = Controller(platform, scope)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { QimiaoApp(controller, controller::scan, { ImageBitmap(64, 64) }) }
}
override fun onDestroy() { controller.disconnect(); scope.cancel(); super.onDestroy() }
}
class FixturePlatform : MobilePlatform {
override val model = "模拟测试手机"
private val saved = mutableMapOf<String, String>()
val calls = mutableListOf<String>()
var scanError = false
var emptyLibrary = true
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) {
if (scanError) onError("模拟扫描失败") else onDevice(DiscoveredDevice("fixture", "QMS-00000000", -40))
}
override fun stopScan() {}
override suspend fun connect(handle: String): DeviceLink {
check(handle == "fixture")
return FixtureLink(this)
}
override fun crypto() = object : SecureChannel {
override val hello = byteArrayOf(1)
override fun finish(serverHello: ByteArray) {}
override fun encrypt(payload: ByteArray) = payload
override fun decrypt(record: ByteArray) = record
}
}
private class FixtureLink(val platform: FixturePlatform) : DeviceLink {
override val mtu = 247
private val reassembler = Reassembler()
private val messages = Channel<ByteArray>(Channel.UNLIMITED)
private var sequence = 0L
override suspend fun send(fragment: ByteArray) {
val (kind, payload) = reassembler.accept(fragment) ?: return
val response = if (kind == 1) byteArrayOf(2) else {
val request = Protocol.json.parseToJsonElement(payload.decodeToString()).jsonObject
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"]}"""
"session.ping" -> """{"alive":true}"""
"status.get" -> """{"state":{"animation_playback":{"active":false}}}"""
"frame.get" -> """{"frame_revision":"fixture","data_base64":"fixture-image"}"""
"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}"""
"content.default.get" -> """{"name":"测试默认"}"""
"library.preview" -> """{"data_base64":"fixture-preview"}"""
"library.thumbnail" -> """{"data_base64":"fixture-image"}"""
else -> error("Unexpected fixture RPC: $method")
}
buildJsonObject { put("id", request.getValue("id")); put("ok", true); put("result", Protocol.json.parseToJsonElement(result)) }.toString().encodeToByteArray()
}
Protocol.encode(if (kind == 1) 2 else 3, sequence++, response, mtu).forEach { messages.send(it) }
}
override suspend fun receive() = messages.receive()
override fun close() { messages.close() }
}
@@ -0,0 +1,93 @@
package org.qimiaoscreen.uitest
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.*
class IsolatedUiTest {
@get:Rule val ui = createAndroidComposeRule<FixtureActivity>()
private fun click(text: String) { ui.onNodeWithText(text).performClick() }
private fun connect() {
click("扫描设备")
ui.waitUntil(5_000) { ui.activity.controller.state.value.devices.isNotEmpty() }
ui.onNodeWithTag("scan_connect").performScrollTo().performClick()
ui.waitUntil(5_000) { ui.activity.controller.state.value.status != null }
}
@Test fun connection() {
ui.onNodeWithText("暂无已保存设备,扫描后选择即可连接").assertExists()
ui.runOnIdle { ui.activity.platform.scanError = true }
click("扫描设备")
ui.onNodeWithText("模拟扫描失败").assertExists()
click("知道了")
ui.onNodeWithTag("controller_error").assertDoesNotExist()
ui.runOnIdle { ui.activity.platform.scanError = false }
connect()
ui.onNodeWithTag("connected_scan_disconnect").performScrollTo().assertIsEnabled().performClick()
ui.waitUntil(5_000) { ui.activity.controller.state.value.identity == null }
}
@Test fun status() {
click("设备状态")
ui.onNodeWithText("请先在连接页连接设备").assertExists()
click("连接"); connect(); click("设备状态")
ui.waitUntil(5_000) { ui.activity.controller.state.value.frame != null }
ui.onNodeWithTag("frame_card").assertIsDisplayed()
ui.onNodeWithTag("frame_refresh").performClick()
ui.waitUntil(5_000) { !ui.activity.controller.state.value.busy }
assertTrue(ui.activity.platform.calls.contains("frame.get"))
}
@Test fun content() {
connect(); click("显示内容")
ui.waitUntil(5_000) { !ui.activity.controller.state.value.busy }
ui.onNodeWithText("可播放内容").performScrollTo().assertIsDisplayed()
ui.onNodeWithText("播放").assertDoesNotExist()
ui.runOnIdle { ui.activity.platform.emptyLibrary = false; ui.activity.controller.refresh() }
ui.waitUntil(5_000) { ui.activity.controller.state.value.library.isNotEmpty() }
ui.onNodeWithText("测试模板").performScrollTo().assertIsDisplayed()
ui.onNodeWithText("播放").assertIsEnabled()
ui.waitUntil(7_000) { ui.activity.controller.state.value.previews.isNotEmpty() }
ui.waitUntil(7_000) { ui.activity.controller.state.value.thumbnails.isNotEmpty() }
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 settings() {
connect(); click("设备设置")
ui.waitUntil(5_000) { ui.activity.controller.state.value.settings != null }
ui.onNodeWithText("WiFi设置 展开").performScrollTo().performClick()
ui.onNodeWithText("保存WiFi设置").performScrollTo().assertIsNotEnabled()
ui.onNodeWithText("WiFi名称").performScrollTo().performTextInput("fixture-network")
ui.onNodeWithText("保存WiFi设置").performScrollTo().performClick()
ui.onNodeWithText("立即生效WiFi设置").assertExists()
click("取消")
assertFalse(ui.activity.platform.calls.contains("wifi.set"))
}
@Test fun navigation() {
for (page in listOf("设备状态", "显示内容", "设备设置", "连接")) click(page)
click("⋮"); click("关于")
ui.onNodeWithText("关于奇妙小屏幕").assertExists(); click("关闭")
click("⋮"); click("软件设置")
ui.onNodeWithText("当前手机昵称").performTextReplacement("模拟昵称")
click("保存")
ui.runOnIdle { assertEquals("模拟昵称", ui.activity.controller.state.value.clientName) }
click("⋮"); click("软件设置")
ui.onNodeWithText("内容缓存:0 KiB").assertExists()
ui.onNodeWithTag("cache_clear_open").performClick()
ui.onNodeWithTag("cache_clear_confirm").performClick()
assertTrue(ui.activity.platform.calls.isEmpty())
}
@Test fun layout() {
connect()
for (page in listOf("设备状态", "显示内容")) {
click(page)
ui.waitUntil(5_000) { ui.activity.controller.state.value.frame != null }
val card = ui.onNodeWithTag("frame_card").fetchSemanticsNode().boundsInRoot
val image = ui.onNodeWithTag("frame_image").fetchSemanticsNode().boundsInRoot
val title = ui.onNodeWithTag("frame_title").fetchSemanticsNode().boundsInRoot
val refresh = ui.onNodeWithTag("frame_refresh").fetchSemanticsNode().boundsInRoot
assertTrue(kotlin.math.abs(image.center.x - card.center.x) <= 3)
assertTrue(kotlin.math.abs(title.center.x - card.center.x) <= 3)
assertTrue(title.right <= refresh.left)
}
}
}
@@ -39,7 +39,7 @@ python "移动端相关内容/安卓app/构建与发布/build_android.py" --work
## 交付前检查
从隔离目录依文档重新构建并验证;完成相关测试、协议互操作和真实 BLE 联调;运行根仓库卫生检查。交付说明准确区分通过、未验证和不适用。第二台手机验收 APK 放到同一清晰可找到的交付目录,用户安装后再进行实机互斥测试。
从隔离目录依文档重新构建并验证;按 [影响选测](../如何安卓测试/按影响选测.md) 完成相关验证;仅通信路径/平台行为受影响时做相应协议互操作和定向真实 BLE 联调;运行根仓库卫生检查。交付说明准确区分通过、未验证和不适用。第二台手机验收 APK 放到同一清晰可找到的交付目录,用户安装后再进行实机互斥测试。
## 可重复打包
@@ -62,3 +62,7 @@ Python 在 Windows 缺少 IANA tzdata 时,固定北京时间时间戳可使用
打包前重新核对隔离源码、实际 APK、构建日期和签名摘要;测试源码调整也要同步隔离工程,不能测试旧测试 APK。测试/构建遇到的新问题在完成后更新相应文档并在最终反馈说明,具体输入和真机排障见 [测试排障指南](../如何安卓测试/常见问题与排障.md)。
Android 0.2.1 / versionCode 4:顶栏长文本循环、连接条目断开/扫描保留、居中预览;仅debug签名。交付和本轮覆盖范围见交付包0.2.1-4及测试结果归档2026-09-27_界面细节优化。
独立模拟器 UI 测试 APK 构建任务为 `:uiTest:assembleDebug`,只用于验证,不进入产品交付包;测试框架调整不提升产品版本。
Android 0.2.2 / versionCode 5:显示内容两档图片传输、128 MiB 本地缓存及软件设置清空;协议 1.1 增量能力,仍只允许 debug 签名。设备功能部署沿用回滚开发流程,正式设备版本不因本次开发部署递增。验收以本轮测试归档为准。
@@ -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, minimum_android_api=26,
certificate_sha256=certificate, protocol_major=1, protocol_minor=1, 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.2.2 debug(versionCode 5)
仅 debug 签名,可覆盖安装相同调试证书的旧包。Android 8.0 以上,真实验证机为 Android 13。
本包为当前开发验证产物;验收范围和未验证项见 `../../../测试结果归档/实施状态.md`。不能将 APK 成功生成视为所有首版验收通过。设备需已部署移动控制功能,历史设备版本号本身不代表支持 BLE。
SHA-256:`c3ae5ab3caa252f60c715d6f8ecf177767c1e7bc3ea12652b688efe5a3fc3c07`。源码摘要与签名证书摘要见 manifest.json。
@@ -0,0 +1 @@
c3ae5ab3caa252f60c715d6f8ecf177767c1e7bc3ea12652b688efe5a3fc3c07 奇妙小屏幕-0.2.2-debug.apk
@@ -0,0 +1,175 @@
{
"app_version": "0.2.2",
"version_code": 5,
"signing": "debug",
"build_date": "2026-09-29 22:18 +08:00",
"status": "development-validation-see-test-report",
"apk_sha256": "c3ae5ab3caa252f60c715d6f8ecf177767c1e7bc3ea12652b688efe5a3fc3c07",
"certificate_sha256": "b413e68c0bd2311c491ebd087ac5791c7c2ad8267793f92f6055d5521bd3ef4d",
"protocol_major": 1,
"protocol_minor": 1,
"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": "4d1443c0c5ad4c20bd149e84ee95c600a8558a552b39102b7b049ab6897cad01"
},
{
"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": "56e99aa9fd54b39fa7bba0efbd24e875afe0e20827deacd654f999f065c83b12"
},
{
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryBleCacheTest.kt",
"sha256": "01636b443b36532e8b6aa55eaf9ef0976697122362ca24f05a1da6bb958e419f"
},
{
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LibraryCacheTest.kt",
"sha256": "a225a1d2a59bee7f693acf82feefc285e4777a63c5c8a711b61523429e547360"
},
{
"path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/WifiSwitchTest.kt",
"sha256": "5ce335ff84fee01230733971964fefde3fcc6103172d3c40fee64f50838b7bcf"
},
{
"path": "androidApp/src/main/AndroidManifest.xml",
"sha256": "186ed22b19731ea5bf3567e40d6487b95f1bced3dd2fce605de6557cb7c5763e"
},
{
"path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidBle.kt",
"sha256": "a1af9d6593c3be1af0f2a39a3869eae2e1bbbb1dab8b775e06da5dcaa739e9ff"
},
{
"path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidLibraryCache.kt",
"sha256": "c8904caa25123b0085678d8e01e6bbf27ab0eb25e3767334a539c1dc7aebdba1"
},
{
"path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/MainActivity.kt",
"sha256": "fe0dd31eccefc6881e2c1a7473c447e28e0e60673a53e6cd767675825283c96a"
},
{
"path": "build.gradle.kts",
"sha256": "feb3b6e64b846f0408cc40e95ae4b632ce9288a56bd6151bc3bbcc46f0549b49"
},
{
"path": "gradle/libs.versions.toml",
"sha256": "ed393bb2b4d81a162f6b3d21968eeca113e23c2f5d5d22c3be9d104bc592a633"
},
{
"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/Controller.kt",
"sha256": "fa5dd1b999be7e9edb126dc3bc7049a536487e6ec443c4199626ee30ff01f7d0"
},
{
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/DeviceLink.kt",
"sha256": "870345382351bbf6d37f4332c0fab099cb47b7b703428a98567a0086c3316be6"
},
{
"path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/LibraryCache.kt",
"sha256": "d260abe091fa35ad79a663cdae9284147a000e51d8ee1f87f6d7e28138de3fe6"
},
{
"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/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt",
"sha256": "98b05b59c42f714f0ff356a4ed942e1564ecf3c02ded9ed9a108fc7b1cba00e5"
},
{
"path": "sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ProtocolTest.kt",
"sha256": "5d26d3a50c5887d72dc91b2081273fbe4a5a968f01451ef75ab78903d5d42217"
},
{
"path": "sharedCore/src/javaCryptoMain/kotlin/org/qimiaoscreen/core/JavaSecureChannel.kt",
"sha256": "554d663ceb6ec1be095b31e9ff9549af3ecd02f975059c22ce566c87225b05eb"
},
{
"path": "sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/CryptoInteropTest.kt",
"sha256": "2ddb06c4476952a69bf01c0a3a7164625ae8c0bfe9194349450a2e254888d4b3"
},
{
"path": "sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/RpcTimeoutTest.kt",
"sha256": "fca298fb2266ee7e68e6df76d837f7f191e0046aa5ba321a304c4b4369e064a6"
},
{
"path": "sharedCore/src/jvmTest/resources/protocol-v1.json",
"sha256": "6280953b95a3f5d1ae531d7feaa6a62848ffa3e438e58308d2a27f04723803dc"
},
{
"path": "sharedUi/build.gradle.kts",
"sha256": "d469b330d248647e6e93f98d69eb46730adab329ebaf71cbfd896d5426fd0ff1"
},
{
"path": "sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt",
"sha256": "951851755951dbbc92d526c35e1bc4d98db153d6f226709836847468b61c4b9c"
},
{
"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": "0d9bc54d81752aeec28b14fa87a48357e1343c6450ec326a7fad6fc8be55eac1"
},
{
"path": "uiTest/src/main/kotlin/org/qimiaoscreen/uitest/IsolatedUiTest.kt",
"sha256": "1aaa2daa4005f5211c088a3c6579317a94d3de92e494a32614ec7f63053ab9a5"
}
]
}
@@ -0,0 +1,31 @@
# 2026-09-29 “显示内容”两档图片与缓存验收
## 变更与选测
核桃派协议增加 `library.summary`、`library.preview` 和能力 `library_progressive`;`library.thumbnail` 仍是 64×64 PNG。板端按条目修订保存可再生 16×16 缓存并清理失效文件。App 按设备、条目修订和画质保存目录与图片,内容页先请求全部小图,再逐项请求完整图;有缓存时以目录修订确认复用。“当前设备画面”的 `frame.get` 路径不变,断线不提供离线列表。App 更新到 0.2.2/versionCode 5,设备 `VERSION` 保持 1.1.3,功能时间为 `2026-09-29T22:15+08:00`。
直接影响目录、图片 BLE 请求、Android 私有文件缓存、内容页与软件设置。因此选择设备协议/缓存本机测试、sharedCore JVM、内容与设置的独立模拟器、登记手机的 `cache-storage` 和 `library-cache` 两个定向场景,以及 debug 构建/覆盖安装。未改动显示驱动、实际输出、WiFi、权限、后台策略或当前帧读取;这些专项按影响跳过。实屏视觉检查不适用,没有切换显示内容、测试图案或启动摄像头。
## 实际结果
| 验证 | 结果 |
|---|---|
| 板端本机 | `test_mobile_control.py` 19 项通过;与 `test_mobile_protocol.py` 合计的协议/控制定向测试 27 项通过。覆盖目录修订和顺序、16×16 PNG、旧 64×64 接口、失效小图清理、实际用户动画首帧和空动画。最终动画修复后重跑控制测试 19 项通过。 |
| App 共享逻辑 | `:sharedCore:jvmTest` 通过;覆盖两轮顺序、重连复用、修订变化删除旧项、清空、旧设备回退及断线取消。 |
| 独立模拟器 | API 35 的 content、settings、navigation 场景通过;内容页验证小图先于完整图、设置页清空确认。 |
| 登记手机缓存存储 | `cache-storage` 通过;验证设备隔离、修订清理、损坏文件按未命中处理和清空。 |
| 登记手机真实 BLE | `library-cache` 通过:现有目录 15 项,首次两档图片共 30 次请求,PNG 负载 77,058 字节;未变化的重连图片请求 0 次、PNG 负载 0 字节;生产 Controller 复用缓存时图片写入 0 次。这里统计的是 PNG 负载,不是包含协议与无线链路开销的总传输字节数。 |
| 设备部署 | 使用现有可回滚开发部署流程,最终服务 active、移动接口可用;部署的移动控制、小图缓存及功能时间文件摘要与本地一致,恢复副本保留。未修改设备 `VERSION`,未导出 OTA/IMG。 |
| App 交付 | `:androidApp:assembleDebug`、`:androidApp:assembleDebugAndroidTest` 成功;仅 debug 签名。登记手机覆盖安装后的 APK 摘要与交付包一致:`c3ae5ab3caa252f60c715d6f8ecf177767c1e7bc3ea12652b688efe5a3fc3c07`。 |
## 首次失败、修复及复测
- 首次真实 BLE 测试在一条用户动画的 `library.preview` 返回 `BAD_REQUEST`。该动画首帧使用 `path` 指向持久图片,而旧演示动画使用内嵌 `image`;预览/完整图提取现均识别并验证两种形式。控制测试和真实 BLE 全库两档读取复测通过。不能把首次失败写成网络故障。
- 既有 AVD 名称曾指向已不存在的磁盘目录。保留旧配置,使用已登记的 SDK 镜像新建专用 AVD 后完成上述独立模拟器场景;未清空或覆盖旧 AVD。
- 一次本地调用只准备了 instrumentation APK,缺少产品 APK;在同一隔离工程补构建产品 APK 后运行成功。运行真机场景前应同时确认两个 APK 都存在。
可复用方法已更新到 [测试流程](../../如何安卓测试/README.md) 和 [排障指南](../../如何安卓测试/常见问题与排障.md)。本归档不保存凭据、真实设备标识、开发机绝对路径或无线数据内容。
## 边界
真实 BLE 结果证明设备上全部现存 15 项的两档接口可读取,且未变化重连没有再次下载;首次加载的 App 逐帧呈现顺序由 JVM 和模拟器验证,不把测试探针的请求顺序冒充真实界面目视结果。没有执行长时间前后台循环、跨 SSID、第二台手机、旧 Android 系统或完整无线链路计量;本轮变更不要求这些专项。没有发现复测后仍未解决的本轮产品故障。
@@ -0,0 +1,47 @@
# 2026-09-29 Android 按影响选测改造
## 本轮范围和选择理由
改变测试基础设施、场景调度、假数据 UI 环境及协作规则。产品源码、设备部署、协议与版本不变;未导出或发布 APK。测试构建使用既有 debug 签名;源摘要及测试 APK 摘要见 manifest.json,APK 仅为临时验证产物。
本轮新增模拟器框架,因此首次验证所有六个基础场景,并仅对 layout 扩展三组配置。真机只选 connection(验证真实适配器/握手调度)和 navigation(验证 root 输入及权限恢复)。不把框架首次覆盖扩展为以后每轮全量要求。
## 结果
| 验证 | 实际结果 |
|---|---|
| sharedCore:jvmTest | 15 个测试,0 failures / errors |
| Android 产品、真机 instrumentation、独立 uiTest 构建 | 通过;隔离工程使用原有 Gradle 9.5.0 / AGP 9.3.1 / Kotlin 2.4.20 / Compose 1.12.1 |
| 宿主调度、预览、失败传播、权限/布局恢复 | 9 个 unittest 通过 |
| 原登记门禁回归 | 8 个 unittest 通过 |
| 模拟器 API 35 baseline | connection、status、content、settings、navigation、layout 均通过 |
| 模拟器 layout 配置 | narrow(约340dp)、large-font(1.3)、landscape 均通过 |
| 模拟器恢复 | 所有运行的字体、方向、密度恢复回读通过;本轮启动实例已退出 |
| 真机 android-test-a / connection | 加密握手、断开、重新扫描重连通过,instrumentation 16.476 秒;未执行数据读取/切网/轮询/后台专项 |
| 真机 android-test-a / navigation | 初次 8.19 秒通过;清理范围调整后 8.898 秒通过;独立导航不连接板端,权限恢复和辅助脚本清理通过 |
| 产品 APK 隔离检查 | dex 不含 FixtureActivity / FixturePlatform / IsolatedUiTest |
| Git diff 空白检查、Python AST | 通过 |
模拟器逐项结果见三个 JSON。首轮 connection/status 已通过,不因后续内容夹具修复重复运行;后续四项单独重测及补测通过。JSON 中 not_selected 仅描述单次运行,不能误读为整轮没测。
最终 Kotlin 调整仅减少 connection-entry 的无关菜单操作、减少 controller-background 的内容加载,并整理缩进;已重新编译。真实验证证明 connection/navigation 的执行路径;其它新拆分专项只有编译/调度证据,未声称均已真机通过。
## 本轮失败与修复
- 初次 uiTest 配置出现 com.android.test 重复插件版本,以及不存在的 compose.uiTestJUnit4 accessor:复用 AGP classpath 并使用锁定的显式 Compose 测试依赖后构建通过。
- 系统 Python 缺少 Paramiko:未进入远端测试;改用项目已有本机测试 venv 后真实连接通过,没有为设备端添加依赖。
- 新 AVD 独立镜像缺包登记且 SDK 根解析不一致:补齐官方包元数据/许可证并正确指定工具对应 SDK 根、复用原 Emulator 后成功;未覆盖现有软件。
- 模拟器首轮 content 因假条目漏 revision 而失败;补齐真实契约字段后该场景通过。首次失败保留于 ui-baseline-1.json。
- 最后安装返回值检查修改出现 Python 缩进错误,AST/宿主测试发现;修正后 AST 和全部 9 项宿主测试通过,未在错误状态下运行设备。
可复用经验更新在 [常见问题与排障](../../如何安卓测试/常见问题与排障.md) 第13节,规则和命令更新在 [按影响选测](../../如何安卓测试/按影响选测.md)。
## 按影响跳过与验证边界
本轮未执行实际切网、蓝牙开关、昵称/记忆写入、后台长等待、长轮询、双手机、性能和 systemd 停启;未部署核桃派、切换屏幕内容、启动摄像头或截图。驱动/实屏视觉不适用。
上述真机拆分专项保留为必要时的执行入口,当前实际设备结果仍为未验证,不能用宿主调度通过替代。独立模拟 UI 不证明真实射频、密码错误网络激活、Android 8–11 定位权限、MIUI 后台策略或视觉动画质量;相关修改须补对应证据。
没有发现本轮新增的未解决产品问题;现有构建提示 sharedUi commonTest 未接入 compilation 仍属于既有边界,本轮 UI 实测在独立 uiTest 内完成。
仓库卫生检查通过(624 个 Git 候选文件)。自动审批拒绝临时隔离构建目录删除,目录保留于仓库外受保护临时目录,未尝试其他删除途径。后续构建不依赖它;可复用 SDK、系统镜像和 AVD 保留供按需测试。
@@ -0,0 +1,47 @@
{
"app_version": "0.2.1",
"versionCode": 4,
"protocol": "1.0 unchanged",
"device_version": "not queried: no device deployment or contract change",
"apk_sha256": {
"androidApp-debug.apk": "75badab341484feea4a95ef697538ad5a879700cfe07850049c5cbbb9fc2094c",
"androidApp-debug-androidTest.apk": "3fa608647f8d27146d25f03af8c9bed1b830c8ccf7b09595eae6ebc45e41308d",
"uiTest-debug.apk": "20f5ac32fe1bbce70bbcf44e1673fb3c6d5d71f58d3effcbe94434f919fdb109"
},
"source_sha256": {
"androidApp/build.gradle.kts": "7bfbd5d27819700243b7ab1e318eec91024dc170059a92d587400608ec8a59b5",
"androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AppLifecycleTest.kt": "7b75279ea8bba61fd9a35848339f765e931abb2748dcb2ab08f37f85afd21084",
"androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/BleIntegrationTest.kt": "f286cf70cd00b7e872371f0fbc55f6be46e52602ef6890e974aca1c32eb006fa",
"androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LayoutTest.kt": "56e99aa9fd54b39fa7bba0efbd24e875afe0e20827deacd654f999f065c83b12",
"androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/WifiSwitchTest.kt": "5ce335ff84fee01230733971964fefde3fcc6103172d3c40fee64f50838b7bcf",
"androidApp/src/main/AndroidManifest.xml": "186ed22b19731ea5bf3567e40d6487b95f1bced3dd2fce605de6557cb7c5763e",
"androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidBle.kt": "a1af9d6593c3be1af0f2a39a3869eae2e1bbbb1dab8b775e06da5dcaa739e9ff",
"androidApp/src/main/kotlin/org/qimiaoscreen/controller/MainActivity.kt": "7bece662b79093a19fcdf34ad344cf42a57b60291f0536823395ae06663f4f23",
"build.gradle.kts": "feb3b6e64b846f0408cc40e95ae4b632ce9288a56bd6151bc3bbcc46f0549b49",
"gradle/libs.versions.toml": "ed393bb2b4d81a162f6b3d21968eeca113e23c2f5d5d22c3be9d104bc592a633",
"gradle/wrapper/gradle-wrapper.jar": "497c8c2a7e5031f6aa847f88104aa80a93532ec32ee17bdb8d1d2f67a194a9c7",
"gradle/wrapper/gradle-wrapper.properties": "2cb0f9ddf992a26fc4947c91019136bff5e1ccfbf93bd905402b1abf69d15bc5",
"gradle.properties": "e7d684b7795fd04ab2e7e96038fa2bf4f9fd633578aa3bb2f5fe367b4f5ec159",
"gradlew": "ab5c0cad16305af2e619c159c1f58dd68d07fab9c11e36701e109c0277407f7a",
"gradlew.bat": "475c4f08cd57cf2faa819e7f36d72aa93f0ad646ea23a8f7fa3ef54dee1cbc52",
"README.md": "7232ebbac0899152e312429fecaf6b3e14b31265b0c50a95c99a6213aa04b2cd",
"settings.gradle.kts": "3a1adf811d65cfd2ce28bfeb711990511a29094b8a5b5378f2860c86c9fe83e2",
"sharedCore/build.gradle.kts": "9a420e39e3c655f676572d4486e415dbc8b4fd45904db6ad7b64d7fcd71f084b",
"sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt": "48e7bd52250d541088ca2756a8a1dbd37aeedab1ffc03338c9bb7c6ed9d39d7d",
"sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/DeviceLink.kt": "870345382351bbf6d37f4332c0fab099cb47b7b703428a98567a0086c3316be6",
"sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Protocol.kt": "99264aff225076ef1b423801b9bba8797e0e06a9e759339bc52e896620e4b694",
"sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/SecureChannel.kt": "bde40388bb74c9d47a4e099a14a0baeca3b6bda1539023dbd7dca831c6f24039",
"sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt": "9cfd5291d5442ba79802e4193124bc2f22f05cd9a577001d33913d56b140a4a6",
"sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ProtocolTest.kt": "5d26d3a50c5887d72dc91b2081273fbe4a5a968f01451ef75ab78903d5d42217",
"sharedCore/src/javaCryptoMain/kotlin/org/qimiaoscreen/core/JavaSecureChannel.kt": "554d663ceb6ec1be095b31e9ff9549af3ecd02f975059c22ce566c87225b05eb",
"sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/CryptoInteropTest.kt": "2ddb06c4476952a69bf01c0a3a7164625ae8c0bfe9194349450a2e254888d4b3",
"sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/RpcTimeoutTest.kt": "fca298fb2266ee7e68e6df76d837f7f191e0046aa5ba321a304c4b4369e064a6",
"sharedCore/src/jvmTest/resources/protocol-v1.json": "6280953b95a3f5d1ae531d7feaa6a62848ffa3e438e58308d2a27f04723803dc",
"sharedUi/build.gradle.kts": "d469b330d248647e6e93f98d69eb46730adab329ebaf71cbfd896d5426fd0ff1",
"sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt": "884e61008e49fd3f1c62969cbe38a798f896734fc70f5219b4e00bcb34bfe9a3",
"uiTest/build.gradle.kts": "958d9d5509c9b0fd3076f0d22a4544b66e11a4ba47e728107b8ea357cdaffaf3",
"uiTest/src/main/AndroidManifest.xml": "ef50ab1e26f0caaadd7997799dd47d510cb7b4042b421ebe5354f4d2d42708dc",
"uiTest/src/main/kotlin/org/qimiaoscreen/uitest/FixtureActivity.kt": "86b3d340d1219a4644573a20d78a4f31ac1efbe20df8c16f291eef18507ded41",
"uiTest/src/main/kotlin/org/qimiaoscreen/uitest/IsolatedUiTest.kt": "149bf7e3b024206cbf836da8ec113f1c992ae5be0b066f090ca962f808f47188"
}
}
@@ -0,0 +1,37 @@
{
"tests": [
{
"scenario": "connection",
"configuration": "baseline",
"result": "passed"
},
{
"scenario": "status",
"configuration": "baseline",
"result": "passed"
},
{
"scenario": "content",
"configuration": "baseline",
"result": "failed"
},
{
"scenario": "settings",
"configuration": "baseline",
"result": "not run"
},
{
"scenario": "navigation",
"configuration": "baseline",
"result": "not run"
},
{
"scenario": "layout",
"configuration": "baseline",
"result": "not run"
}
],
"failure": "Selected Compose scenario failed",
"cleanup_errors": [],
"not_selected": []
}
@@ -0,0 +1,30 @@
{
"tests": [
{
"scenario": "content",
"configuration": "baseline",
"result": "passed"
},
{
"scenario": "settings",
"configuration": "baseline",
"result": "passed"
},
{
"scenario": "navigation",
"configuration": "baseline",
"result": "passed"
},
{
"scenario": "layout",
"configuration": "baseline",
"result": "passed"
}
],
"failure": null,
"cleanup_errors": [],
"not_selected": [
"connection",
"status"
]
}
@@ -0,0 +1,28 @@
{
"tests": [
{
"scenario": "layout",
"configuration": "narrow",
"result": "passed"
},
{
"scenario": "layout",
"configuration": "large-font",
"result": "passed"
},
{
"scenario": "layout",
"configuration": "landscape",
"result": "passed"
}
],
"failure": null,
"cleanup_errors": [],
"not_selected": [
"connection",
"status",
"content",
"settings",
"navigation"
]
}
@@ -1,5 +1,9 @@
# 当前实施状态
更新时间:2026-09-29。当前 App 为 **0.2.2 / versionCode 5**,设备协议 1.1 的“显示内容”两档图片与跨重连缓存已部署。交付包见 `../构建与发布/交付包/0.2.2-5/`,本轮选测、实测请求量、失败修复与未验证边界见 [内容缓存验收](2026-09-29_内容缓存/README.md)。登记手机上的最终 APK 与交付包摘要一致;核桃派服务正常,设备 `VERSION` 仍为 1.1.3,功能时间为 `2026-09-29T22:15+08:00`。本轮只做 debug 签名,没有导出 OTA/IMG。
以下为历史验收记录,不将历史通过项自动归为本轮重测:
更新时间:2026-09-27。当前App为 **0.2.1 / versionCode 4**,顶栏循环、扫描条目断开/连接保留、两页预览居中已实现。交付包 `../构建与发布/交付包/0.2.1-4/`;本轮证据及未验证边界见 [界面细节优化验收](2026-09-27_界面细节优化/README.md)。JVM15项、root三组布局、实际Activity和BLE只读回归通过,最终安装摘要一致;早期测试失败与修正保留。测试流程和排障第12节已更新。设备协议、服务和驱动不变。
以下为历史验收记录,不将历史通过项自动归为本轮重测:
@@ -72,3 +72,13 @@ MOBILE-RECONNECT/MOBILE-ERRORS 补充:断线清除旧画面、状态、缩
- MOBILE-STATUS:顶栏长文字自动向左无限循环(30 dp/秒、每轮间隔1秒),短文字静止;菜单固定,完整语义文字保留。
- MOBILE-DISCOVERY/CONNECTION:握手成功后扫描条目显示已连接及断开按钮;再次扫描保留当前连接条目,旧扫描信号不作实时信号。断开、失败和异常清理会话关联;其他条目仍可连接,连接期间禁止重复操作。
- MOBILE-PREVIEW:两处设备画面、标题、等待与过期提示按整个卡片居中,刷新按钮固定右侧;标题预留对称空间,放大字体不与按钮重叠。
## 按影响选测
MOBILE-TEST-SELECTION:先评估直接及共享依赖影响,再选最低有效层级。纯逻辑用 JVM;普通 UI 用独立模拟器;真实通信、权限/后台厂商差异用定向真机。未改页面不因发布新功能重复测试。root 仅约束登记真机输入,模拟器使用标准 Compose 测试 API。测试授权不等于每轮执行义务。矩阵及证据格式见测试入口。
## 2026-09-29 内容缓存
MOBILE-LIBRARY-CACHE:显示内容的全部条目先请求 16×16 预览,再在预览阶段全部完成一次尝试后逐项取得 64×64 完整画面。缓存按持久设备 ID、类型、条目 ID、修订和档位隔离;连接、进入内容页及内容页前台定期检查设备目录修订,未变复用本地目录与图片,变化后先完整读取目录再清理失效缓存。离线不显示旧列表。单张失败显示占位且不阻塞后续项目;旧设备退回现有图库读取方式。当前设备画面不参与此缓存。
MOBILE-CACHE-CLEAR:软件设置显示内容缓存大小,确认后仅清空内容目录与图片缓存;不清除昵称、设备记忆、当前帧或设备内容。内容页前台才进行图片后台补传,控制操作优先,切页、后台、断线停止队列。
@@ -59,6 +59,8 @@ ClientHello 字段:`protocol_major:1, protocol_minor:0, public_key, random`。
| `wifi.set` | `expected_revision, ssid, security, password_action, password?, ipv4_mode, address?, prefix?, gateway?, dns_servers, activation` | password_action 为 keep/replace/none;none 仅开放网络;activation 为 immediate/next_boot;返回配置修订与可选 task_id。 |
| `task.get` | `task_id` | `state:pending/running/succeeded/failed, stage, error?`;只允许查询本协议产生的有界任务,不提供任意系统任务入口。 |
| `library.list` | `cursor?, limit` | 默认 20、最大 50;返回库修订、条目和下一游标。条目含 type、id、revision、name、is_demo、playable、thumbnail_revision。翻页库变化返回 CONFLICT。 |
| `library.summary` | 空 | 返回 `library_revision,item_count`;与 `library.list` 使用同一目录视图和修订,供新版 App 判断缓存是否可复用。 |
| `library.preview` | `type,id,revision` | 返回 `mime:image/png, data_base64`,16×16 静态预览;动画使用首帧。 |
| `library.thumbnail` | `type,id,revision` | 返回 `mime:image/png, data_base64`,64×64 静态缩略图;动画使用首帧。 |
| `content.play` | `type,id,revision` | 使用既有内容解析与显示服务;过期拒绝,不能顺便修改默认内容。 |
| `content.default.get` | 空 | 当前默认内容元数据。 |
@@ -74,6 +76,8 @@ settings 与 wifi 的 revision 为设备生成的不透明字符串,相关状
BLE 只接收白名单方法。旧客户端遇新能力忽略;旧设备缺某能力时客户端明确提示。不兼容主版本停止控制并返回列表。蓝牙关闭、主服务重启、OTA 维护或适配器消失时失效会话不可复用;恢复后重新握手,不恢复旧密钥。
协议 1.1 增加 `library_progressive` capability;仅声明该能力的设备可调用 `library.summary` 和 `library.preview`。协议 1.0 的 `library.list`、`library.thumbnail` 和当前帧接口保持原义;旧 App 忽略新能力,新 App 遇旧设备读取完整目录并沿用 64×64 图。内容缓存不参与设备设置或控制写入。
## 6. 契约测试要求
共享固定的测试私钥、hello 原始字节、HKDF 输出、双向密文与分片向量;测试密钥明确仅为公开夹具,不能来自设备。Python 与 Kotlin 双向互验,覆盖 MTU 23、大 MTU、边界分片、非 ASCII 昵称、错误公钥、篡改、重放、断线、库翻页冲突、任务失败和主版本拒绝。接口最终字段模型及黄金向量必须随实现提交,未经测试不能写成协议验收通过。
@@ -58,3 +58,11 @@ Android 断线清理与旧扫描回调隔离已通过模拟及 Activity 真机
## 2026-09-27 Android 0.2.1 界面优化
MOBILE-STATUS/DISCOVERY/PREVIEW:顶栏自动循环、扫描条目断开与连接保留、设备画面居中。仅修改手机界面及会话显示状态;协议1.0、设备接口、广播仲裁和驱动不变,不要求设备升级。不推进设备发布版本或 FEATURE_UPDATED_AT。
## 2026-09-29 Android 测试流程
新增按影响选测、场景运行器和独立模拟器 uiTest。仅测试基础设施和协作规则变化;产品 App、设备接口、协议、驱动及部署契约不变,不升级版本、不发布 APK,不改设备功能时间。旧无场景默认真机命令改为显式 --scenario;迁移命令见测试入口。
## 2026-09-29 内容图库缓存
DEVICE-MOBILE-LIBRARY-CACHE/MOBILE-LIBRARY-CACHE:设备协议 1.1 在主版本 1 下新增 `library_progressive`、`library.summary` 和 16×16 `library.preview`,保留既有 64×64 `library.thumbnail` 与旧目录契约。Android 0.2.2 使用分档缓存和渐进加载,旧设备回退现有方式;旧 App 可继续连接新设备。设备与 App 本轮均获用户明确迭代和部署授权。验证证据待本轮测试归档,不涉及驱动或当前帧输出。