diff --git a/.gitignore b/.gitignore index 9c28801..40a870a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,14 @@ # Private device and workstation credentials. Keep the example next to it tracked. /测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.txt +/移动端相关内容/安卓app/如何安卓测试/测试设备登记/测试设备.local.json + +# Android local toolchain, generated builds and signing material. +/移动端相关内容/安卓app/安卓程序源代码/**/.gradle/ +/移动端相关内容/安卓app/安卓程序源代码/**/.kotlin/ +/移动端相关内容/安卓app/安卓程序源代码/**/build/ +/移动端相关内容/安卓app/安卓程序源代码/**/local.properties +*.jks +*.keystore # Local secrets and private keys. .env diff --git a/AGENTS.md b/AGENTS.md index 29b6f14..5cfe441 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,10 @@ ## 2. 需求与测试编号 +移动端是独立维护的程序,入口为 `移动端相关内容/README.md`,协作规则见 `移动端相关内容/AGENTS.md`。本轮已授权 Android 首版;未来设备端更新不得自动迭代或发布 App。设备公共接口、协议、驱动边界及部署变化必须在 `移动端相关内容/开发要求/跨端兼容与变更记录.md` 登记影响;对外契约不变的驱动内部修改不要求手机更新。App、设备及协议分别管理版本。正式 APK 签名只在用户明确命令后执行,当前只允许 debug 签名。 + +移动测试设备真实登记唯一位置为 `移动端相关内容/安卓app/如何安卓测试/测试设备登记/测试设备.local.json`,精确忽略;提交同目录空白 `测试设备.example.json` 和门禁脚本。此登记只存手机测试信息,不复制核桃派或开发机凭据;自动化前检查登记及 ADB 授权,不回显真实设备标识,不覆盖已有登记。 + - `WEB-*`:网页、REST、WebSocket、交互和安全入口。 - `DISPLAY-*`:显示服务、帧、方向、字体、动画、mock 与 H618 真实驱动。 - `CONFIG-*`:schema、默认值、迁移、持久数据和损坏保护。 @@ -36,7 +40,9 @@ - 核桃派无负载验证编译、寄存器映射、线程调度、双缓冲、截止统计和异常清屏;在该门槛通过前禁止接屏。 - HUB75 实屏测试按全黑、红、绿、蓝、白、四角、关键行、方向、文字、图片、动画逐项执行;上一项失败就停止,只排查对应线组。 - ADC 测试按总线、地址、原始读取、同点万用表校准、正常档、单个低压档、恢复逐项执行,不合并人工动作。 -- 有清晰摄像头时由 Codex 判断;画面缺失或有拍频、曝光、视角歧义时必须等待用户确认。 +- 普通网页前端、手机界面、BLE 控制入口开发没有改变驱动或显示输出实现时,不新增实屏视觉验收;优先协议、状态、输出帧与驱动统计,不适用时记录“不适用”。 +- 仅在驱动、扫描时序、底层输出变化或具体显示异常确需视觉证据时提出视觉检查。必须先停止相关步骤、说明原因、询问用户下一步如何启动,并等待本次明确指示;不得自动启动 DroidCam、摄像头、采集或视觉测试程序,也不得主动切换实屏测试图案。专用测试手机的一般自动化授权不能替代此门禁。 +- 用户明确启动后,有清晰摄像头时可由 Codex 判断;画面缺失或有拍频、曝光、视角歧义时仍等待用户确认。 ## 4. 人工停顿与电气安全 @@ -92,3 +98,7 @@ - 普通开发不检查历史 IMG 是否齐全;只有用户明确要求处理某个镜像时,才核对该任务实际需要的输入文件。官方基线和其他离线依赖按各自规则管理。 - 仓库内描述工作区文件时使用相对路径或 `<仓库根目录>`、`<受保护临时目录>` 等文字占位符,不固化盘符、Windows 用户目录、macOS 用户目录或开发者 home。核桃派上的 `/opt`、`/var/lib`、`/run`、`/boot`、`/usr/local` 等固定部署路径不属于此限制。 - 正式 IMG 通过仓库外的发行渠道保存,根 `.gitignore` 必须忽略所有 `*.img`;Git 只保留对应 README、manifest、SHA-256、发布记录等元数据,不得让源码、文档命令或后续构建依赖仓库中存在 IMG。OTA、离线依赖、编辑器程序和历史归档仍由私有 Git 仓库保留,大体积二进制可使用 Git LFS;秘密、本机状态、运行数据和可再生缓存继续按各自规则排除。 + +## 9. 测试经验与交付反馈 + +每轮测试完成后,将测试中遇到的问题及已验证解法补到对应可重复测试流程、排障指南或构建文档,防止后续重复试错;单次失败证据、恢复及重测结果留在测试归档。已知问题先查已有经验,不把未证实推测固化成根因。最终反馈说明经验更新位置和仍未解决的边界;没有新增经验时沿用已有记录,不重复复制。文档不得包含真实凭据、测试设备标识或开发机绝对路径。 diff --git a/README.md b/README.md index 3f0fd81..3619617 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ - `核桃派软件源代码/`:FastAPI 服务、网页、H618 HUB75 原生驱动、部署与发布脚本。 - `整体开发需求/`:带稳定编号的功能和部署需求。 +- `移动端相关内容/`:独立 KMP 手机控制器需求、BLE 协议、Android 源码/测试/交付;入口见该目录 README,当前进度见 Android 测试结果归档。 - `测试相关资料/如何测试/`:本机、板端、实屏与 ADC 的分层测试流程。 - `硬件相关资料和硬件的连接/`:核桃派、HUB75 和 ADS1110 接线资料。 - `发布更新相关/`:官方基线 IMG、正式导出包、OTA、离线依赖和镜像编辑器。 diff --git a/发布更新相关/OTA数据包/1.1.2/README.md b/发布更新相关/OTA数据包/1.1.2/README.md new file mode 100644 index 0000000..d7f2e98 --- /dev/null +++ b/发布更新相关/OTA数据包/1.1.2/README.md @@ -0,0 +1,13 @@ +# 奇妙小屏幕控制器 OTA 1.1.2 + +- 软件版本:`1.1.2` +- 导出时间:`2026-09-26T16:59:03+08:00` +- 说明:发布当前工作区设备端更新:BLE 控制与网络配置改进、OTA 内核恢复和运行期保护;包含新增离线 Python 依赖。 +- 产物:`matrix-screen-controller-1.1.2.ota` + +- 包类型:应用更新包 +- 前置系列基线:`1.1.0` + +在系统设置上传 OTA;每个 minor 的 .0 必须按顺序安装,同系列补丁允许跳过。1.1.0 含 frpc 0.71.0 离线组件;已有完整组件会跳过,否则修复并保留配置和启停状态。初次安装默认关闭。禁止跳过失败测试;失败自动恢复。 + +本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。 diff --git a/发布更新相关/OTA数据包/1.1.2/manifest.json b/发布更新相关/OTA数据包/1.1.2/manifest.json new file mode 100644 index 0000000..b77b168 --- /dev/null +++ b/发布更新相关/OTA数据包/1.1.2/manifest.json @@ -0,0 +1,13 @@ +{ + "format_version": 2, + "artifact_type": "ota", + "software_version": "1.1.2", + "created_at": "2026-09-26T16:59:03+08:00", + "notes": "发布当前工作区设备端更新:BLE 控制与网络配置改进、OTA 内核恢复和运行期保护;包含新增离线 Python 依赖。", + "artifact": "matrix-screen-controller-1.1.2.ota", + "artifact_bytes": 27005144, + "artifact_sha256": "8ee91e5103a9901f76245c642fe1c3efd43da75c611667788be49f8bcedf9a4f", + "package_kind": "application", + "required_checkpoint": "1.1.0", + "is_checkpoint": false +} diff --git a/发布更新相关/OTA数据包/1.1.2/matrix-screen-controller-1.1.2.ota b/发布更新相关/OTA数据包/1.1.2/matrix-screen-controller-1.1.2.ota new file mode 100644 index 0000000..3876d03 --- /dev/null +++ b/发布更新相关/OTA数据包/1.1.2/matrix-screen-controller-1.1.2.ota @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ee91e5103a9901f76245c642fe1c3efd43da75c611667788be49f8bcedf9a4f +size 27005144 diff --git a/发布更新相关/OTA数据包/1.1.2/matrix-screen-controller-1.1.2.ota.sha256 b/发布更新相关/OTA数据包/1.1.2/matrix-screen-controller-1.1.2.ota.sha256 new file mode 100644 index 0000000..5785348 --- /dev/null +++ b/发布更新相关/OTA数据包/1.1.2/matrix-screen-controller-1.1.2.ota.sha256 @@ -0,0 +1 @@ +8ee91e5103a9901f76245c642fe1c3efd43da75c611667788be49f8bcedf9a4f matrix-screen-controller-1.1.2.ota diff --git a/发布更新相关/其他依赖/DEPENDENCIES.json b/发布更新相关/其他依赖/DEPENDENCIES.json index 8002f3b..391e20a 100644 --- a/发布更新相关/其他依赖/DEPENDENCIES.json +++ b/发布更新相关/其他依赖/DEPENDENCIES.json @@ -13,7 +13,11 @@ "path": "debian12-aarch64", "reason": "媒体导入需要 ffmpeg、ffprobe、zscale、tonemap 和 heif-convert", "source": "清华 TUNA 的 Debian bookworm、bookworm-updates 与 bookworm-security AArch64 镜像;仓库索引和包均校验 SHA-256", - "roots": ["ffmpeg", "libheif-examples", "python3.11-venv"], + "roots": [ + "ffmpeg", + "libheif-examples", + "python3.11-venv" + ], "package_count": 84, "baseline": "debian12-aarch64/BASE_IMAGE_PACKAGES.tsv", "resolution": "debian12-aarch64/RESOLUTION.json", @@ -45,6 +49,17 @@ "introduced_in": "1.1.0", "development_baseline": "1.0.6 调试设备已预装;正式旧版本不包含此组件", "ota_lifecycle": "仅软件安装节点携带二进制,普通补丁校验已安装组件;事务失败恢复原文件与状态" + }, + { + "id": "mobile-ble-python", + "path": "aarch64-py311", + "reason": "BLE D-Bus GATT 与免配对应用层加密", + "packages": [ + "dbus-next==0.2.3", + "cryptography==46.0.5" + ], + "source": "PyPI official wheels, SHA256SUMS verified", + "introduced_in": "Android 首版开发,未推进设备发布版本" } ], "retired": [] diff --git a/发布更新相关/其他依赖/README.md b/发布更新相关/其他依赖/README.md index d9bc81d..bbb8d5f 100644 --- a/发布更新相关/其他依赖/README.md +++ b/发布更新相关/其他依赖/README.md @@ -12,4 +12,6 @@ 依赖生命周期以 `DEPENDENCIES.json` 为准。新增运行依赖必须同时加入文件、摘要和用途;删除依赖时删除二进制,并在 `retired` 中记录停用版本和原因。 +移动控制器首版新增 `cryptography==46.0.5`、`cffi==2.1.1`、`pycparser==3.0` 和 `dbus-next==0.2.3`,已保存到 `aarch64-py311/` 并登记 SHA-256 与生命周期。前者实现 BLE 临时 ECDH/HKDF/AES-GCM,后者接入系统 BlueZ D-Bus;cffi/pycparser 为依赖闭包。普通开发部署在电脑校验后上传,使用设备 venv 的 pip `--no-index --find-links` 安装,不在核桃派访问 PyPI。蓝牙固件与 hciattach 复用官方板端已有组件,不以安装 Python 包替代适配器实测。 + frpc 正式引入版本为 1.1.0;此前 1.0.6 仅有开发设备预装。OTA 安装节点按源码 `UPGRADE_POLICY.json` 选择离线材料,普通补丁不重复打入 frpc。组件事务使用 Debian 基础镜像已包含的 `systemd`(systemctl/systemd-run)、`coreutils`(stat/sha256sum/install)与 `diffutils`(cmp),版本基线见 `debian12-aarch64/BASE_IMAGE_PACKAGES.tsv`,不增加板端下载。 diff --git a/发布更新相关/其他依赖/aarch64-py311/SHA256SUMS b/发布更新相关/其他依赖/aarch64-py311/SHA256SUMS index 8b0c7db..b27816e 100644 --- a/发布更新相关/其他依赖/aarch64-py311/SHA256SUMS +++ b/发布更新相关/其他依赖/aarch64-py311/SHA256SUMS @@ -1,8 +1,11 @@ f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 annotated_types-0.8.0-py3-none-any.whl 9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 anyio-4.14.2-py3-none-any.whl 62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 certifi-2026.7.22-py3-none-any.whl +3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 click-8.4.2-py3-none-any.whl 4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 colorama-0.4.6-py2.py3-none-any.whl +c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl +58948f9aff9db08316734c0be2a120f6dc502124d9642f55e90ac82ffb16a18b dbus_next-0.2.3-py3-none-any.whl c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565 fastapi-0.116.1-py3-none-any.whl 2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl 63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 h11-0.16.0-py3-none-any.whl @@ -14,8 +17,9 @@ f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 iniconfig-2.3. d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c packaging-26.3-py3-none-any.whl bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 pluggy-1.6.0-py3-none-any.whl -7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl +b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 pycparser-3.0-py3-none-any.whl 45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba pydantic-2.13.4-py3-none-any.whl +7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl 81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 pygments-2.20.0-py3-none-any.whl 539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7 pytest-8.4.1-py3-none-any.whl 1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a python_dotenv-1.2.2-py3-none-any.whl diff --git a/发布更新相关/其他依赖/aarch64-py311/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl b/发布更新相关/其他依赖/aarch64-py311/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl new file mode 100644 index 0000000..e165a74 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 +size 218716 diff --git a/发布更新相关/其他依赖/aarch64-py311/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl b/发布更新相关/其他依赖/aarch64-py311/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl new file mode 100644 index 0000000..c945219 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b +size 4275637 diff --git a/发布更新相关/其他依赖/aarch64-py311/dbus_next-0.2.3-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/dbus_next-0.2.3-py3-none-any.whl new file mode 100644 index 0000000..b7cef02 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/dbus_next-0.2.3-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58948f9aff9db08316734c0be2a120f6dc502124d9642f55e90ac82ffb16a18b +size 57885 diff --git a/发布更新相关/其他依赖/aarch64-py311/pycparser-3.0-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/pycparser-3.0-py3-none-any.whl new file mode 100644 index 0000000..d025d70 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/pycparser-3.0-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 +size 48172 diff --git a/发布更新相关/导出包/1111/11223344.img.sha256 b/发布更新相关/导出包/1111/11223344.img.sha256 new file mode 100644 index 0000000..6b5b955 --- /dev/null +++ b/发布更新相关/导出包/1111/11223344.img.sha256 @@ -0,0 +1 @@ +66a82db730b00f2e3554a1503c946330d14ae103823b8d3242ec5c922049d2b2 11223344.img diff --git a/发布更新相关/核桃派镜像/2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.rar b/发布更新相关/核桃派镜像/2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.rar deleted file mode 100644 index 69b87d2..0000000 --- a/发布更新相关/核桃派镜像/2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.rar +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:78a54e0e04a28bb7247c68101a61defb9a40f666a3813138a22a35f97fb226cd -size 630053154 diff --git a/发布记录.json b/发布记录.json index f59a50d..4627afe 100644 --- a/发布记录.json +++ b/发布记录.json @@ -89,6 +89,29 @@ "artifact_sha256": "8356b75ef970891db951a08429f8e2d21a6c0c4933065001778e10d74022c19d", "validation_path": "各种归档/20260908_090953_IMAGE1.1.1实卡验收_bb9884/实机验收.json", "validated_at": "2026-09-08T09:09:53+00:00" + }, + { + "version": "1.1.2", + "artifact_type": "ota", + "created_at": "2026-09-26T16:59:03+08:00", + "notes": "发布当前工作区设备端更新:BLE 控制与网络配置改进、OTA 内核恢复和运行期保护;包含新增离线 Python 依赖。", + "artifact_path": "发布更新相关/OTA数据包/1.1.2/matrix-screen-controller-1.1.2.ota", + "artifact_sha256": "8ee91e5103a9901f76245c642fe1c3efd43da75c611667788be49f8bcedf9a4f", + "package_kind": "application", + "required_checkpoint": "1.1.0", + "is_checkpoint": false, + "component_checkpoints": [ + { + "version": "1.1.0", + "components": { + "frpc": { + "version": "0.71.0", + "sha256": "6e8e45fd0c7514b636fd8d049212f8a5715e8b33c412cf968988252e7a8a00f2", + "bundle": "frp/0.71.0/linux-arm64" + } + } + } + ] } ] } diff --git a/整体开发需求/01_网页配置端需求.md b/整体开发需求/01_网页配置端需求.md index 7dc91e2..8216c9c 100644 --- a/整体开发需求/01_网页配置端需求.md +++ b/整体开发需求/01_网页配置端需求.md @@ -2,6 +2,8 @@ 本文描述核桃派 ZeroW 在局域网内启动的网页配置端。这个网页是用户后续操作点阵屏幕的主要入口,当前阶段提供可叠加的像素与多文字内容编辑,以及纯色、诊断、清屏等独占设备测试能力。 +移动端是独立控制入口,设备接入要求见 `03_移动端接入需求.md`,手机需求见 `../移动端相关内容/开发要求/移动端需求.md`。网页布局调整不应影响 BLE 协议;设备功能改动须登记移动端兼容影响,不自动更新 App。 + 项目默认一台核桃派只做这一件事:开机后自动启动网页服务和屏幕控制服务,同一局域网内的手机、电脑或平板通过 `http://核桃派IP:8080` 访问。 ## 0. 需求编号索引 @@ -81,6 +83,7 @@ | `CONFIG-PERFORMANCE-MODE` | CPU 性能模式请求 | 配置 v9 严格保存布尔字段 `performance_mode_enabled`,v8 升级与新设备均默认关闭;请求状态与实际 governor 分开报告。 | | `CONFIG-ANIMATION-PREVIEW-CONCURRENCY` | 动态缩略图并发上限 | 配置 v10 严格保存整数 `animation_preview_max_concurrent`,v9 升级与新设备均默认 `2`,只允许 `1..50`;系统设置在性能模式下方保存该值,并明确提示高并发会造成严重性能问题。 | | `DEPLOY-KERNEL-ROLLBACK` | AXP313A 候选内核与自动回滚 | 已验证候选以带摘要的预编译离线载荷交付;原内核、DTB、模块和 boot 脚本保持可启动,未通过内核、cpufreq、服务和 GPIO 健康检查时下一次自动回到原内核。 | +| `DEPLOY-OTA-KERNEL-RECOVERY` | OTA 内核能力守护与轻量恢复 | OTA 前后比较内核、cpufreq、性能模式和候选启动标记;正常能力退化时回滚应用。已回退设备可更新应用,只有本机候选内核、DTB、模块及回滚设施通过固定摘要校验时,更新完成后才独立重启尝试恢复一次;失败不得自动重试,缺失或损坏材料只报告需要专用修复包,普通 OTA 不携带内核归档。 | | `DEPLOY-MEDIA-DECODERS` | 媒体解码依赖与隔离 | Debian 提供 FFmpeg/ffprobe/libheif;独立转换 unit 限制 CPU、内存、IO、设备和网络访问。 | | `WEB-SECURITY` | 局域网安全边界 | 第一阶段默认可信局域网,保留访问密码和上传限制扩展入口。 | | `WEB-ERRORS` | 日志和错误展示 | 后端记录关键事件,前端显示屏幕可用性和最近操作结果。 | @@ -248,12 +251,12 @@ WantedBy=multi-user.target 生产核桃派只运行本控制器及其必需的网络、监测和系统服务,必须使用可重复、可检查的专用主机配置: -- 默认启动目标为 `multi-user.target`;LightDM、`display-manager`、Xorg、桌面会话和蓝牙服务必须停用并屏蔽,不得在控制器运行时自动恢复。 +- 默认启动目标为 `multi-user.target`;LightDM、`display-manager`、Xorg 和桌面会话必须停用并屏蔽。移动控制器接入后允许必要的 BlueZ 与板载蓝牙初始化服务,按 `DEPLOY-MOBILE-BLE` 同步修改专用检查与部署;未完成该实施步骤前不得只手动解除屏蔽交付。 - 板载音频必须通过 boot 配置关闭,`snd_bcm2835` 必须加入模块黑名单且在控制器启动前确认未加载;WirePlumber、PipeWire 及其用户级 socket 必须全局屏蔽,SSH 登录不得重新拉起音频会话。不得通过关闭矩阵硬件脉冲来兼容板载音频。 - Wi-Fi、SSH、Avahi、`/dev/i2c-1`、Unit ADC、电压保护和 swap 必须保留;专用化不得修改无线网络、SSH 凭据、持久数据根或用户配置。 - `scripts/dedicated_host.py check` 只读报告全部专用条件;`apply` 以幂等方式更新 boot 配置、模块黑名单和 systemd 启用状态,但不得自动重启。boot 配置更新必须原子替换、保留无关内容,不创建多代备份。 - systemd 在启动应用前执行只读专用主机检查。任一硬件脉冲前置条件不满足时服务必须明确失败并记录原因,不能静默进入画面不稳定的软件脉冲模式。 -- 未来需要声音时优先使用不加载 `snd_bcm2835` 的 USB 音频;未来确需桌面或蓝牙时,必须先修改本需求和专用主机脚本,再重新执行刷新率、资源负载和完整实屏验收。不得临时取消屏蔽后直接交付。 +- 未来需要声音时优先使用不加载 `snd_bcm2835` 的 USB 音频;恢复桌面或增加蓝牙必须同步需求、专用脚本,并检查刷新率、资源负载和输出帧。普通前端/BLE 入口改动不强制实屏视觉验收;仅驱动、扫描时序、底层输出改变或具体异常确需视觉证据时,先暂停询问用户如何启动,获明确指示后检查。不得自动启动摄像头、DroidCam、采集或视觉程序,也不得主动切换实屏测试图案。 多语言文字部署还必须满足 `DEPLOY-FONTS`: diff --git a/整体开发需求/02_屏幕底层控制接口需求.md b/整体开发需求/02_屏幕底层控制接口需求.md index 63385f1..3ae8c26 100644 --- a/整体开发需求/02_屏幕底层控制接口需求.md +++ b/整体开发需求/02_屏幕底层控制接口需求.md @@ -46,6 +46,8 @@ ## 1. 设计原则 +移动端接入契约见 `03_移动端接入需求.md` 和 `../移动端相关内容/开发要求/设备通信协议.md`。手机通过公共控制服务使用本显示层,不能操作网页 DOM 或绕过保护直接访问驱动。驱动或输出契约变化必须登记移动端兼容影响;内部实现变化不等于需要 App 升级。普通前端/BLE 入口改动不强制实屏视觉验收,确需视觉检查须先停下询问用户如何启动并等待指示。 + ### 1.1 固定帧接口(`DISPLAY-FRAME`) 项目内部提交到 `DisplayService` 和真实/模拟驱动时,统一使用 `64x64 RGB` 帧作为显示边界。网页场景中的透明文字等 `RGBA` 图层属于上层合成中间产物,必须先按顺序扁平化到 RGB,不能直接交给驱动。 diff --git a/整体开发需求/03_移动端接入需求.md b/整体开发需求/03_移动端接入需求.md new file mode 100644 index 0000000..8f73197 --- /dev/null +++ b/整体开发需求/03_移动端接入需求.md @@ -0,0 +1,28 @@ +# 03 移动端接入需求 + +本文件定义核桃派侧行为,手机产品需求与协议分别见 `../移动端相关内容/开发要求/移动端需求.md`、`../移动端相关内容/开发要求/设备通信协议.md`。当前是已确认的实施契约,不代表已部署。 + +| 编号 | 要求 | +|---|---| +| DEPLOY-MOBILE-BLE | 启用必要 BlueZ 和板载蓝牙初始化服务,修改 dedicated_host 的 apply/check 及部署生命周期;不启用桌面或音频。依赖同步离线材料与摘要。 | +| DEVICE-MOBILE-CONTROL | 网页与 BLE 共享公共业务服务;保留显示仲裁、低压保护、持久化和修订冲突;不经网页 DOM/HTTP 回环调用实现手机控制。 | +| DEVICE-MOBILE-SESSION | 协议 1.0、自动加密、单连接/单控制会话、握手和活跃超时、连接暂停及断开恢复广播;无效请求不得执行。 | +| DEVICE-MOBILE-IDENTITY | 持久随机 UUID 和可改昵称;短编号仅用于展示;旧设备升级保留身份,新镜像不得携带构建主机或测试设备身份。 | +| DEVICE-MOBILE-COMPAT | 主版本与能力声明,兼容新增不破坏旧客户端;破坏变更登记移动端待办,不自动更新手机程序。 | +| WEB-MOBILE-PRESENCE | 手机握手成功时网页短提示,连接期间顶栏常驻手机名,断开更新;初次加载只显示当前状态;昵称安全文本渲染。 | +| DEVICE-WIFI-SCAN | NetworkManager 扫描并报告 SSID/安全类型/信号/当前连接,支持手动隐藏 SSID;参数化调用、有界等待、结果脱敏。 | +| DEVICE-MOBILE-SETTINGS | 字段级变更与共享修订,网页也更新修订;旧草稿冲突不静默覆盖。WiFi 保存和连接完成分别报告,不返回已存密码。 | +| DEVICE-MOBILE-LIFECYCLE | 蓝牙异常不拖垮显示服务;服务停启使会话与密钥失效,恢复后重新握手;真实 systemd 验证持久/运行目录边界。 | +| TEST-MOBILE-INTEGRATION | 本机契约、网页回归、真机 BLE、WiFi、双手机、性能和独立重建分别记录;模拟结果不替代物理 BLE。 | + +DEVICE-MOBILE-SETTINGS 的网络往返约束:NetworkManager 读回多个 DNS 时须兼容逗号及换行分隔,接口统一返回独立地址数组,读回合法配置可保持密码重新提交。网页与 BLE 复用同一解析规则,不要求 App 为某一 nmcli 输出格式做适配。 + +DEPLOY-MOBILE-BLE/DEVICE-MOBILE-LIFECYCLE 的升级约束:包含移动端安装脚本的后续 OTA,即使不携带新的 FRP 二进制,也必须进入系统组件事务。修改蓝牙前持久备份 main.conf、首次原配置副本、两个项目 drop-in、两个蓝牙服务的启用/屏蔽及运行状态;失败恢复原内容,删除本次新增文件,并恢复原服务状态。旧事务日志没有移动端字段时保持旧恢复行为。本轮只实现并验证该逻辑,不导出 OTA 或推进发布版本。 + +设备当前屏幕和 ADC 已接好,不能冒充无负载测试。蓝牙启用前后检查刷新率、截止丢失、资源与输出帧;普通前端/BLE 接入不强制实屏视觉检查。驱动或底层输出改变、出现具体显示异常才提出视觉检查,先暂停询问用户下一步如何启动并等待明确指示;不自动开摄像头、DroidCam、采集或视觉测试程序,不主动切换实屏测试图案。 + +测试步骤与映射见 `../测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md` 和移动端的 `安卓app/如何安卓测试/README.md`。本任务只手工部署,不自动导出 IMG/OTA;只有可部署功能修改才更新 FEATURE_UPDATED_AT,文档/登记脚本不改该时间。 + +## 2026-09-26 接口修订 +DEVICE-MOBILE-CONTROL:BLE library.list 与网页共用 LibraryOrderStore 的用户混合顺序,演示条目保持固定位置;顺序参与分页修订。 +DEVICE-MOBILE-SETTINGS:WiFi 激活任务可选 error_code 为 AUTH_FAILED、NETWORK_UNAVAILABLE、IP_CONFIG_FAILED、UNKNOWN;原因来自 NetworkManager 的结构化状态,仅可靠认证证据可报告 AUTH_FAILED,不输出原始命令错误或密码。 diff --git a/核桃派软件源代码/FEATURE_UPDATED_AT b/核桃派软件源代码/FEATURE_UPDATED_AT index 478e45e..7ee01e2 100644 --- a/核桃派软件源代码/FEATURE_UPDATED_AT +++ b/核桃派软件源代码/FEATURE_UPDATED_AT @@ -1 +1 @@ -2026-09-10T11:10+08:00 +2026-09-26T16:47+08:00 diff --git a/核桃派软件源代码/README.md b/核桃派软件源代码/README.md index d1e89ba..d95be62 100644 --- a/核桃派软件源代码/README.md +++ b/核桃派软件源代码/README.md @@ -4,6 +4,10 @@ ## 路径与运行方式 +移动端控制器独立维护,入口见 `../移动端相关内容/README.md`;设备侧新增契约见 `../整体开发需求/03_移动端接入需求.md`。设备更新只评估并登记手机兼容影响,不自动迭代 App。BLE 与网页应复用公共业务层;不得让 App 依赖网页布局。当前蓝牙接入尚待实现,既有脚本仍执行原禁用策略,不能将需求文档当成设备已启用蓝牙。 + +普通前端/BLE 入口改动不新增实屏视觉验收。确需视觉检查时先停止、解释原因、询问用户如何启动并等待指示,不自动开摄像头、DroidCam 或视觉程序,不主动切换实屏测试图案。软件统计和逻辑帧检查按正常自动测试执行。 + 下列源码运行、编译和手工部署命令均在本文件所在的 `核桃派软件源代码/` 目录执行;发布命令另行标明从工作区根目录执行。Windows 本机测试使用根目录下 `测试相关资料/如何测试/本机测试环境/README.md` 的命令。 - 程序:`/opt/matrix-screen-controller` @@ -78,6 +82,8 @@ sudo sh scripts/update_walnutpi.sh ## 浏览器全量 OTA +OTA 会记录更新前的候选内核、cpufreq 和性能模式状态。原本正常的设备若在更新中失去这些能力,应用更新按事务回滚;原本已回退到原内核的设备允许先更新应用。更新完成后,若已安装的候选 Image、两份 DTB、模块树、受管启动脚本和健康服务均与登记材料一致,独立任务仅安排一次候选重启;结果保存在持久根的 `kernel-recovery.json`,通过 `/api/ota/status.kernel_recovery` 查询。失败后不会在后续 OTA 自动重试。缺失或损坏候选材料时只提示需要专用内核修复包,普通 OTA 不携带大型内核归档。 + 系统设置显示 `VERSION` 中的正式软件版本和 `FEATURE_UPDATED_AT` 中固定的北京时间,并允许上传完整 `.ota` 文件。页面不展示最近 OTA 结果,但上传、安装、断线恢复和失败反馈保持可见;失败后会自动打开可滚动、可复制的完整诊断日志,关闭后仍可重新查看或复制。设备只在 `/var/lib/matrix-screen-controller/ota/last-failure.log` 原子保留最近一份不超过 1 MiB 的脱敏失败日志,下一次成功更新会将其删除;`GET /api/ota/failure-log` 仅在最近结果失败且日志存在时返回 `text/plain`,并禁止缓存。设备不连接更新服务器,不使用增量、加密或签名;包内 SHA-256 只检查文件损坏。同版和旧版在停服前拒绝,新版失败自动恢复更新前程序与用户数据。 从 1.0.3 首次升级到包含该能力的版本时,安装过程仍由 1.0.3 的旧 worker 控制,因此新日志页面只能在升级成功后使用。新版本测试入口兼容旧 worker 传入的隔离变量,并将 pytest、应用数据、运行数据和 Python 临时文件全部约束到 OTA 事务目录;它不会为安装日志功能而忽略测试失败或绕过原子回滚。 diff --git a/核桃派软件源代码/VERSION b/核桃派软件源代码/VERSION index 524cb55..45a1b3f 100644 --- a/核桃派软件源代码/VERSION +++ b/核桃派软件源代码/VERSION @@ -1 +1 @@ -1.1.1 +1.1.2 diff --git a/核桃派软件源代码/app/control.py b/核桃派软件源代码/app/control.py new file mode 100644 index 0000000..f646603 --- /dev/null +++ b/核桃派软件源代码/app/control.py @@ -0,0 +1,47 @@ +"""Shared device operations used by REST and mobile adapters.""" +from __future__ import annotations + +import hashlib +import json +import threading + +from app.config.store import validate_config + + +class DeviceControl: + def __init__(self, store, display, power_monitor, performance_mode): + self.store = store + self.display = display + self.power_monitor = power_monitor + self.performance_mode = performance_mode + self.lock = threading.RLock() + + @staticmethod + def revision(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False, + separators=(",", ":"), allow_nan=False).encode("utf-8")).hexdigest() + + def update_config(self, values): + with self.lock: + config = validate_config({**self.store.config, **values}) + if "low_voltage_protection_enabled" in values: + enabled = config["low_voltage_protection_enabled"] + self.store.update({"low_voltage_protection_enabled": enabled}) + self.power_monitor.set_protection_enabled(enabled) + if enabled: + self.power_monitor.sample_now() + if "orientation" in values: + self.display.set_orientation(config["orientation"]) + if "brightness" in values: + self.display.set_brightness(config["brightness"]) + if "matrix_refresh_rate_limit_hz" in values: + self.display.set_refresh_rate_limit(config["matrix_refresh_rate_limit_hz"]) + if "performance_mode_enabled" in values: + self.performance_mode.transact(config["performance_mode_enabled"], + lambda enabled: self.store.update({"performance_mode_enabled": enabled})) + remaining = {key: config[key] for key in ( + "default_font", "default_text_size", "preview_refresh_interval_ms", + "custom_test_color", "workspace_order", "animation_preview_max_concurrent") if key in values} + if remaining: + self.store.update(remaining) + return self.store.config diff --git a/核桃派软件源代码/app/main.py b/核桃派软件源代码/app/main.py index 576186b..6cacc3f 100644 --- a/核桃派软件源代码/app/main.py +++ b/核桃派软件源代码/app/main.py @@ -15,6 +15,10 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr, field_validator, model_validator +from app.control import DeviceControl +from app.mobile.control import MobileControl +from app.mobile.session import SessionManager +from app.mobile.bluez import BluezRuntime from app.config.store import ( COLOR_PALETTE_LIMIT, DEFAULT_DISPLAY, @@ -561,6 +565,8 @@ def create_app( staging_root=ota_staging_root, ) + control = DeviceControl(store, display, power_monitor, performance_mode) + def resolve_content(reference: dict[str, str]) -> dict[str, Any]: checked = normalize_default_display(reference) content_type = checked["type"] @@ -831,8 +837,9 @@ def create_app( }, ) - @app.get("/api/status") - def get_status() -> dict: + mobile_runtime = None + + def read_device_status() -> dict: status = display.get_status() status["service"] = { "app_version": app.state.app_version, @@ -855,8 +862,25 @@ def create_app( store.config["performance_mode_enabled"] ), } + status["mobile"] = mobile_runtime.status() if mobile_runtime else {"available": False, "connected": False, "reason": "initialization_failed"} return status + try: + mobile = MobileControl(control, network, templates, animations, resolve_content, + apply_resolved_content, effective_default_content, set_default_content, + read_device_status, storage_monitor.get_status, library_order=library_order) + mobile_sessions = SessionManager(mobile.dispatch, mobile.identity) + mobile_runtime = BluezRuntime(mobile_sessions, enabled=os.environ.get("MATRIX_BLE_ENABLED") == "1") + app.state.mobile_control = mobile + app.state.mobile_sessions = mobile_sessions + except Exception: + logger.exception("Mobile control initialization failed; display remains available") + app.state.mobile_runtime = mobile_runtime + + @app.get("/api/status") + def get_status() -> dict: + return read_device_status() + @app.get("/api/ota/status") def get_ota_status() -> dict: return { @@ -1123,41 +1147,7 @@ def create_app( def put_config(update: ConfigUpdate) -> dict: values = update.model_dump(exclude_none=True) try: - config = validate_config({**store.config, **values}) - if "low_voltage_protection_enabled" in values: - enabled = config["low_voltage_protection_enabled"] - store.update({"low_voltage_protection_enabled": enabled}) - power_monitor.set_protection_enabled(enabled) - if enabled: - power_monitor.sample_now() - if "orientation" in values: - display.set_orientation(config["orientation"]) - if "brightness" in values: - display.set_brightness(config["brightness"]) - if "matrix_refresh_rate_limit_hz" in values: - display.set_refresh_rate_limit( - config["matrix_refresh_rate_limit_hz"] - ) - if "performance_mode_enabled" in values: - performance_mode.transact( - config["performance_mode_enabled"], - lambda enabled: store.update({"performance_mode_enabled": enabled}), - ) - remaining = { - key: config[key] - for key in ( - "default_font", - "default_text_size", - "preview_refresh_interval_ms", - "custom_test_color", - "workspace_order", - "animation_preview_max_concurrent", - ) - if key in values - } - if remaining: - store.update(remaining) - return store.config + return control.update_config(values) except (ConfigError, PerformanceModeError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: @@ -2023,6 +2013,8 @@ def create_app( @app.on_event("startup") def startup() -> None: + if mobile_runtime: + mobile_runtime.start() performance_mode.start(store.config["performance_mode_enabled"]) media_imports.start() power_monitor.sample_now() @@ -2034,8 +2026,13 @@ def create_app( network.start() except Exception: logger.exception("Failed to restore normal content after OTA completion") + try: + ota.schedule_kernel_recovery_after_ota() + except Exception: + logger.exception("Failed to evaluate kernel recovery after OTA completion") ota.set_completion_callback(restore_after_ota) + ota.finalize_kernel_recovery_after_boot() if ota.resume_or_start_monitor(restore_after_ota): logger.warning("OTA transaction is active; keeping the update indicator visible") elif maintenance_black: @@ -2046,6 +2043,8 @@ def create_app( @app.on_event("shutdown") def shutdown() -> None: + if mobile_runtime: + mobile_runtime.close() logger.info("Shutting down; clearing display") media_imports.stop() power_monitor.close() diff --git a/核桃派软件源代码/app/mobile/__init__.py b/核桃派软件源代码/app/mobile/__init__.py new file mode 100644 index 0000000..0e8e929 --- /dev/null +++ b/核桃派软件源代码/app/mobile/__init__.py @@ -0,0 +1 @@ +"""Versioned mobile control transport, independent of web presentation.""" diff --git a/核桃派软件源代码/app/mobile/bluez.py b/核桃派软件源代码/app/mobile/bluez.py new file mode 100644 index 0000000..52ebabb --- /dev/null +++ b/核桃派软件源代码/app/mobile/bluez.py @@ -0,0 +1,252 @@ +"""BlueZ GATT adapter. Failure is isolated from the display service.""" +import asyncio +import logging +import threading + +from dbus_next import Variant, DBusError, BusType, PropertyAccess +from dbus_next.aio import MessageBus +from dbus_next.service import ServiceInterface, method, dbus_property + +from .protocol import fragments + +log = logging.getLogger(__name__) +ROOT = '/org/qimiaoscreen/mobile' +SERVICE = ROOT + '/service0' +AD = ROOT + '/advertisement0' +SERVICE_UUID = '9f57a001-6c31-4c58-bc22-1f728b641001' +RX_UUID = '9f57a002-6c31-4c58-bc22-1f728b641001' +TX_UUID = '9f57a003-6c31-4c58-bc22-1f728b641001' + + +class ObjectManager(ServiceInterface): + def __init__(self, objects): + super().__init__('org.freedesktop.DBus.ObjectManager') + self.objects = objects + + @method() + def GetManagedObjects(self) -> 'a{oa{sa{sv}}}': + return {path: {obj.name: obj.properties()} for path, obj in self.objects.items()} + + +class GattService(ServiceInterface): + def __init__(self): + super().__init__('org.bluez.GattService1') + + @dbus_property(access=PropertyAccess.READ) + def UUID(self) -> 's': + return SERVICE_UUID + + @dbus_property(access=PropertyAccess.READ) + def Primary(self) -> 'b': + return True + + def properties(self): + return dict(UUID=Variant('s', self.UUID), Primary=Variant('b', True)) + + +class Characteristic(ServiceInterface): + def __init__(self, uuid, runtime): + super().__init__('org.bluez.GattCharacteristic1') + self.uuid, self.runtime = uuid, runtime + self.notifying = False + + @dbus_property(access=PropertyAccess.READ) + def UUID(self) -> 's': + return self.uuid + + @dbus_property(access=PropertyAccess.READ) + def Service(self) -> 'o': + return SERVICE + + @dbus_property(access=PropertyAccess.READ) + def Flags(self) -> 'as': + return ['write'] if self.uuid == RX_UUID else ['notify'] + + @dbus_property(access=PropertyAccess.READ) + def Value(self) -> 'ay': + return b'' + + @dbus_property(access=PropertyAccess.READ) + def Notifying(self) -> 'b': + return self.notifying + + @method() + async def WriteValue(self, value: 'ay', options: 'a{sv}'): + if self.uuid != RX_UUID: + raise DBusError('org.bluez.Error.NotPermitted', 'Not permitted') + await self.runtime.write(value, options) + + @method() + def StartNotify(self): + if self.uuid != TX_UUID: + raise DBusError('org.bluez.Error.NotSupported', 'Not supported') + self.notifying = True + + @method() + def StopNotify(self): + self.notifying = False + + def properties(self): + return dict(UUID=Variant('s', self.UUID), Service=Variant('o', SERVICE), Flags=Variant('as', self.Flags)) + + +class Advertisement(ServiceInterface): + def __init__(self, short_id): + super().__init__('org.bluez.LEAdvertisement1') + self.local_name = 'QMS-' + short_id + + @dbus_property(access=PropertyAccess.READ) + def Type(self) -> 's': + return 'peripheral' + + @dbus_property(access=PropertyAccess.READ) + def ServiceUUIDs(self) -> 'as': + return [SERVICE_UUID] + + @dbus_property(access=PropertyAccess.READ) + def LocalName(self) -> 's': + return self.local_name + + @method() + def Release(self): + pass + + +class BluezRuntime: + def __init__(self, sessions, enabled=False): + self.sessions = sessions + self.enabled = enabled + self.stop_event = threading.Event() + self.thread = None + self.state = dict(available=False, reason='disabled') + self.bus = self.manager = self.ad_manager = None + self.advertising = False + self.tx = None + self.message_id = 0 + self.write_lock = None + self.counters = dict(rx_fragments=0, tx_fragments=0, rx_bytes=0, tx_bytes=0, last_response_kind=0, mtu=23) + + def status(self): + return {**self.state, **self.sessions.status(), 'transport': dict(self.counters)} + + def start(self): + if not self.enabled or self.thread: + return + self.thread = threading.Thread(target=lambda: asyncio.run(self._run()), name='mobile-ble', daemon=True) + self.thread.start() + + def close(self): + self.stop_event.set() + if self.thread: + self.thread.join(timeout=8) + + async def _proxy(self, path): + introspection = await asyncio.wait_for(self.bus.introspect('org.bluez', path), 5) + return self.bus.get_proxy_object('org.bluez', path, introspection) + + async def _disconnect(self, owner): + was_owner = self.sessions.owner == owner + try: + proxy = await self._proxy(owner) + await asyncio.wait_for(proxy.get_interface('org.bluez.Device1').call_disconnect(), 3) + except Exception: + pass + self.sessions.disconnect(owner) + if was_owner: + self.message_id = 0 + + async def write(self, value, options): + self.counters['rx_fragments'] += 1 + self.counters['rx_bytes'] += len(value) + owner = options.get('device') + if not owner or options.get('offset', Variant('q', 0)).value != 0: + raise DBusError('org.bluez.Error.NotAuthorized', 'Invalid request') + owner = owner.value + if not self.sessions.connect(owner): + await self._disconnect(owner) + raise DBusError('org.bluez.Error.NotAuthorized', 'Busy') + async with self.write_lock: + try: + if not self.tx.notifying: + raise ValueError() + response = await asyncio.to_thread(self.sessions.accept, owner, bytes(value)) + if response is None: + return + mtu = min(self.sessions.peer_mtu, max(23, options.get('mtu', Variant('q', 23)).value)) + self.counters['mtu'] = mtu + self.counters['last_response_kind'] = response[0] + for fragment in fragments(response[0], self.message_id, response[1], mtu): + self.tx.emit_properties_changed({'Value': fragment}) + self.counters['tx_fragments'] += 1 + self.counters['tx_bytes'] += len(fragment) + await asyncio.sleep(0.002) + self.message_id += 1 + except Exception: + await self._disconnect(owner) + raise DBusError('org.bluez.Error.NotAuthorized', 'Session rejected') from None + + async def _run(self): + while not self.stop_event.is_set(): + try: + await self._serve() + except Exception: + self.state = dict(available=False, reason='bluetooth_unavailable') + log.warning('Mobile BLE unavailable; display service remains active') + finally: + if self.sessions.owner is not None: + self.sessions.disconnect(self.sessions.owner) + self.message_id = 0 + self.advertising = False + if self.bus: + self.bus.disconnect() + self.bus = None + for _ in range(5): + if self.stop_event.is_set(): + return + await asyncio.sleep(1) + + async def _serve(self): + self.bus = await asyncio.wait_for(MessageBus(bus_type=BusType.SYSTEM).connect(), 5) + root_proxy = await self._proxy('/') + self.manager = root_proxy.get_interface('org.freedesktop.DBus.ObjectManager') + objects = await asyncio.wait_for(self.manager.call_get_managed_objects(), 5) + adapters = [path for path, interfaces in objects.items() if 'org.bluez.GattManager1' in interfaces and 'org.bluez.LEAdvertisingManager1' in interfaces] + if not adapters: + raise RuntimeError('No GATT peripheral adapter') + adapter = await self._proxy(sorted(adapters)[0]) + properties = adapter.get_interface('org.freedesktop.DBus.Properties') + await properties.call_set('org.bluez.Adapter1', 'Powered', Variant('b', True)) + await properties.call_set('org.bluez.Adapter1', 'Pairable', Variant('b', False)) + self.ad_manager = adapter.get_interface('org.bluez.LEAdvertisingManager1') + self.tx = Characteristic(TX_UUID, self) + exports = {SERVICE: GattService(), SERVICE + '/rx': Characteristic(RX_UUID, self), SERVICE + '/tx': self.tx} + self.bus.export(ROOT, ObjectManager(exports)) + for path, interface in exports.items(): + self.bus.export(path, interface) + self.bus.export(AD, Advertisement(self.sessions.identity()['short_id'])) + await asyncio.wait_for(adapter.get_interface('org.bluez.GattManager1').call_register_application(ROOT, {}), 5) + self.write_lock = asyncio.Lock() + self.state = dict(available=True, reason=None) + while not self.stop_event.is_set(): + objects = await asyncio.wait_for(self.manager.call_get_managed_objects(), 5) + connected = [path for path, interfaces in objects.items() + if interfaces.get('org.bluez.Device1', {}).get('Connected', Variant('b', False)).value] + owner = self.sessions.owner + if owner and owner not in connected: + self.sessions.disconnect(owner) + self.message_id = 0 + for path in connected: + if not self.sessions.connect(path): + await self._disconnect(path) + if self.sessions.expired(): + await self._disconnect(self.sessions.owner) + should_advertise = self.sessions.owner is None + if should_advertise != self.advertising: + if should_advertise: + await asyncio.wait_for(self.ad_manager.call_register_advertisement(AD, {}), 5) + else: + await asyncio.wait_for(self.ad_manager.call_unregister_advertisement(AD), 5) + self.advertising = should_advertise + await asyncio.sleep(0.5) + if self.sessions.owner: + await self._disconnect(self.sessions.owner) diff --git a/核桃派软件源代码/app/mobile/control.py b/核桃派软件源代码/app/mobile/control.py new file mode 100644 index 0000000..6d987fb --- /dev/null +++ b/核桃派软件源代码/app/mobile/control.py @@ -0,0 +1,222 @@ +"""Explicit mobile capability surface, sharing existing device business operations.""" +from __future__ import annotations + +import base64 +import json +import time +import threading +from io import BytesIO +from pathlib import Path +from uuid import UUID, uuid4 +from contextlib import nullcontext + +from app.persistence import atomic_write_bytes +from app.demo_library import demo_template, demo_animation +from app.display.service import ANIMATION_PLAYBACK_SPEEDS, AnimationPlaybackConflictError +from app.templates.store import TemplateConflictError, TemplateNotFoundError +from .session import RpcError, checked_name + +SETTINGS = ("brightness", "orientation", "matrix_refresh_rate_limit_hz", "performance_mode_enabled") + + +class MobileControl: + def __init__(self, control, network, templates, animations, resolve, apply, get_default, set_default, + status, storage, library_order=None): + self.control, self.network = control, network + self.templates, self.animations = templates, animations + self.resolve, self.apply = resolve, apply + self.get_default, self.set_default = get_default, set_default + self.status, self.storage = status, storage + self.tasks = {} + self.library_order = library_order + self.path = Path(control.store.data_dir) / "mobile/identity.json" + if self.path.exists(): + identity = json.loads(self.path.read_text(encoding="utf-8")) + if set(identity) != {"schema", "device_id", "name"} or identity["schema"] != 1: + raise ValueError("Unsupported mobile identity") + UUID(identity["device_id"]) + checked_name(identity["name"]) + self.identity_record = identity + else: + self.identity_record = dict(schema=1, device_id=str(uuid4()), name="奇妙小屏幕") + self._persist_identity(self.identity_record) + + def _persist_identity(self, identity): + atomic_write_bytes(self.path, (json.dumps(identity, ensure_ascii=False) + "\n").encode("utf-8")) + + def identity(self): + record = self.identity_record + return dict(device_id=record["device_id"], device_name=record["name"], + short_id=record["device_id"].replace("-", "")[:8], protocol_major=1, protocol_minor=0, + capabilities=["status", "settings", "library", "playback", "frame", "device_name", "wifi", "wifi_scan"], + limits=dict(max_message_bytes=65536, library_page_size=50, preview_interval_ms=2000)) + + def settings(self): + config = self.control.store.config + values = {key: config[key] for key in SETTINGS} + values["prompt_delay_seconds"] = self.network.get_cached_status()["prompt_delay_seconds"] + return dict(revision=self.control.revision(values), values=values, writable_fields=list(values), + allowed=dict(orientation=[0, 90, 180, 270], matrix_refresh_rate_limit_hz=[15, 20, 30, 45, 60, 80, 100], + playback_speeds=list(ANIMATION_PLAYBACK_SPEEDS))) + + def _content(self, params): + reference = {key: params.get(key) for key in ("type", "id")} + resolved = self.resolve(reference) + if params.get("revision") != resolved["revision"]: + raise RpcError("CONFLICT", "内容已改变,请刷新") + return resolved + + @staticmethod + def _png(image): + output = BytesIO() + image.save(output, format="PNG") + return dict(mime="image/png", data_base64=base64.b64encode(output.getvalue()).decode("ascii")) + + def dispatch(self, method, params): + try: + with self.control.lock, (self.network.configuration_lock if method in ('wifi.get', 'wifi.set') else nullcontext()): + return self._dispatch(method, params) + except (TemplateConflictError, AnimationPlaybackConflictError): + raise RpcError("CONFLICT", "内容或播放会话已改变,请刷新") from None + except TemplateNotFoundError: + raise RpcError("NOT_FOUND", "内容不存在") from None + except (ValueError, TypeError, KeyError): + raise RpcError("BAD_REQUEST", "参数无效,请检查后重试") from None + + def _dispatch(self, method, params): + if method == 'wifi.scan': + return dict(networks=self.network.backend.scan(), scanned_at_ms=int(time.time() * 1000)) + if method == 'wifi.get': + return self.wifi() + if method == 'wifi.set': + before = self.wifi() + if params.get('expected_revision') != before['revision']: + raise RpcError('CONFLICT', '网络设置已改变,请刷新') + action = params.get('password_action') + security = params.get('security') + if security not in ('open', 'wpa-psk') or action not in ('keep', 'replace', 'none'): + raise ValueError() + saved = before.get('saved') or {} + if security == 'open': + if action != 'none' or params.get('password'): + raise ValueError() + elif action == 'keep': + if params.get('ssid') != saved.get('ssid') or not saved.get('password_configured') or params.get('password'): + raise ValueError() + elif action != 'replace' or not params.get('password'): + raise ValueError() + values = {key: params[key] for key in ('ssid', 'security', 'ipv4_mode', 'address', 'prefix', 'gateway', 'dns_servers', 'activation') if key in params} + values['password'] = params.get('password') if action == 'replace' else None + result = self.network.save_settings(values) + task_id = result['operation_id'] + if len(self.tasks) >= 64: + del self.tasks[next(iter(self.tasks))] + self.tasks[task_id] = dict(state='pending' if result['activation'] == 'immediate' else 'succeeded', stage='saved') + if result['activation'] == 'immediate': + threading.Thread(target=self._activate, args=(task_id,), name='mobile-wifi', daemon=True).start() + return dict(revision=self.wifi()['revision'], task_id=task_id) + if method == 'task.get': + task_id = params.get('task_id') + if not isinstance(task_id, str) or task_id not in self.tasks: + raise RpcError('NOT_FOUND', '任务不存在或已过期') + return dict(self.tasks[task_id]) + if method == "device.rename": + identity = {**self.identity_record, "name": checked_name(params.get("name"))} + self._persist_identity(identity) + self.identity_record = identity + return self.identity() + if method == "status.get": + return self.status() + if method == "storage.get": + return self.storage() + if method == "settings.get": + return self.settings() + if method == "settings.patch": + if params.get("expected_revision") != self.settings()["revision"]: + raise RpcError("CONFLICT", "设置已改变,请刷新") + changes = params.get("changes") + if not isinstance(changes, dict) or not changes or set(changes) - {*SETTINGS, "prompt_delay_seconds"}: + raise RpcError("BAD_REQUEST", "不支持的设置字段") + # Separate NetworkManager persistence from display configuration. + if "prompt_delay_seconds" in changes and len(changes) != 1: + raise RpcError("BAD_REQUEST", "网络提示延迟须单独提交") + if "prompt_delay_seconds" in changes: + self.network.save_prompt_delay(changes["prompt_delay_seconds"]) + else: + self.control.update_config(changes) + return self.settings() + if method == "library.list": + items = [] + for kind, records in (("template", [demo_template()] + self.templates.list()["templates"]), + ("animation", [demo_animation()] + self.animations.list()["animations"])): + for item in records: + items.append(dict(type=kind, id=item["id"], name=item["name"], revision=item["revision"], + is_demo=bool(item.get("is_demo", False)), playable=kind == "template" or item.get("frame_count", 0) > 0, + thumbnail_revision=item["revision"])) + if self.library_order is not None: + user_items = [item for item in items if not item['is_demo']] + order = self.library_order.get([{'type': item['type'], 'id': item['id']} for item in user_items])['items'] + by_key = {(item['type'], item['id']): item for item in user_items} + items = [item for item in items if item['is_demo']] + [by_key[(ref['type'], ref['id'])] for ref in order] + revision = self.control.revision(items) + limit = params.get("limit", 20) + if type(limit) is not int or not 1 <= limit <= 50: + raise ValueError() + offset = 0 + if params.get("cursor"): + cursor = params["cursor"].split(":") + if len(cursor) != 2 or cursor[0] != revision: + raise RpcError("CONFLICT", "内容列表已改变,请重新加载") + offset = int(cursor[1]) + if not 0 <= offset <= len(items): + raise ValueError() + next_offset = offset + limit + return dict(library_revision=revision, items=items[offset:next_offset], + next_cursor=f"{revision}:{next_offset}" if next_offset < len(items) else None) + if method == "library.thumbnail": + content = self._content(params) + return self._png(content["image"] if content["type"] == "template" else content["frames"][0]["image"]) + if method in ("content.play", "content.default.set"): + content = self._content(params) + if method == "content.default.set": + return self.set_default({"type": content["type"], "id": content["id"]}) + self.apply(content, notify_activity=True) + return {key: content[key] for key in ("type", "id", "name", "revision", "is_demo")} + if method == "content.default.get": + content = self.get_default() + return {key: content[key] for key in ("type", "id", "name", "revision", "is_demo")} + if method == "playback.patch": + if set(params) - {"session_id", "paused", "position_ms", "speed"}: + raise ValueError() + playback = self.control.display.control_animation_playback(params.get("session_id"), + paused=params.get("paused"), position_ms=params.get("position_ms"), speed=params.get("speed")) + return dict(animation_playback=playback, state_revision=self.control.display.get_state_revision()) + if method == "frame.get": + image = self.control.display.get_current_frame() + revision = self.control.revision(base64.b64encode(image.tobytes()).decode("ascii")) + if params.get("known_revision") == revision: + return dict(unchanged=True, frame_revision=revision) + return dict(unchanged=False, frame_revision=revision, sampled_at_ms=int(time.time() * 1000), **self._png(image)) + raise RpcError("UNSUPPORTED_CAPABILITY", "设备不支持此操作") + + def wifi(self): + status = self.network.get_status(include_secret=False) + saved = status.get('saved') + if saved: + saved = {key: value for key, value in saved.items() if key != 'password'} + saved.setdefault('security', 'wpa-psk' if saved.get('password_configured') else 'open') + revision = self.control.revision(dict(saved=saved, generation=self.network.settings_revision)) + return {**status, 'saved': saved, 'revision': revision} + + def _activate(self, task_id): + self.tasks[task_id] = dict(state='running', stage='connecting') + try: + self.network.activate_saved(task_id) + operation = self.network.get_cached_status().get('operation') or {} + state = operation.get('state') if operation.get('id') == task_id else 'failed' + self.tasks[task_id] = dict(state='succeeded' if state == 'succeeded' else 'failed', stage='finished') + if state != 'succeeded': + code = operation.get('error_code', 'UNKNOWN') + self.tasks[task_id]['error_code'] = code if code in {'AUTH_FAILED', 'NETWORK_UNAVAILABLE', 'IP_CONFIG_FAILED', 'UNKNOWN'} else 'UNKNOWN' + except Exception: + self.tasks[task_id] = dict(state='failed', stage='finished', error_code='UNKNOWN') diff --git a/核桃派软件源代码/app/mobile/protocol.py b/核桃派软件源代码/app/mobile/protocol.py new file mode 100644 index 0000000..35074b2 --- /dev/null +++ b/核桃派软件源代码/app/mobile/protocol.py @@ -0,0 +1,165 @@ +"""QMS BLE v1 framing and ephemeral authenticated encryption. + +This encrypts traffic, but does not authenticate the identity of an App. +Each instance belongs to exactly one connection; discard it after any error. +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import struct +import time + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +MAX_MESSAGE = 65536 +HEADER = struct.Struct("!HBBIHHI") +LABEL = b"QMS-BLE-1" + + +class ProtocolError(ValueError): + """Fixed public error: never include untrusted payload or crypto details.""" + + +def fragments(kind: int, message_id: int, payload: bytes, mtu: int): + if kind not in (1, 2, 3, 4) or not 0 <= message_id <= 0xFFFFFFFF: + raise ProtocolError("BAD_REQUEST") + if not 1 <= len(payload) <= MAX_MESSAGE or not 23 <= mtu <= 517: + raise ProtocolError("BAD_REQUEST") + capacity = mtu - 19 + count = (len(payload) + capacity - 1) // capacity + for index in range(count): + yield HEADER.pack(0x514D, 1, kind, message_id, index, count, len(payload)) + payload[index * capacity:(index + 1) * capacity] + + +class Reassembler: + def __init__(self, clock=time.monotonic): + self.clock = clock + self.next_message = 0 + self.pending = None + self.buffer = bytearray() + self.index = 0 + self.started = 0.0 + + def accept(self, part: bytes): + if not 16 < len(part) <= 514: + raise ProtocolError("BAD_REQUEST") + magic, wire, kind, mid, index, count, total = HEADER.unpack(part[:16]) + if magic != 0x514D or wire != 1 or kind not in (1, 2, 3, 4) or not 1 <= count <= total <= MAX_MESSAGE: + raise ProtocolError("BAD_REQUEST") + signature = (kind, mid, count, total) + if self.pending is None: + if index != 0 or mid != self.next_message: + raise ProtocolError("BAD_REQUEST") + self.pending = signature + self.started = self.clock() + if self.clock() - self.started >= 15: + raise ProtocolError("TIMEOUT") + if self.pending != signature or index != self.index or index >= count: + raise ProtocolError("BAD_REQUEST") + self.buffer.extend(part[16:]) + self.index += 1 + if len(self.buffer) > total or (self.index < count and len(self.buffer) >= total): + raise ProtocolError("BAD_REQUEST") + if self.index != count: + return None + if len(self.buffer) != total: + raise ProtocolError("BAD_REQUEST") + result = kind, bytes(self.buffer) + self.pending = None + self.buffer.clear() + self.index = 0 + self.next_message += 1 + return result + + +def json_object(raw: bytes) -> dict: + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate") + result[key] = value + return result + try: + value = json.loads(raw.decode("utf-8"), object_pairs_hook=unique, + parse_constant=lambda _: (_ for _ in ()).throw(ValueError())) + if not isinstance(value, dict): + raise ValueError() + return value + except (ValueError, UnicodeError, RecursionError): + raise ProtocolError("BAD_REQUEST") from None + + +class Handshake: + def __init__(self, *, private_key=None, random_bytes=None): + # Explicit values are exclusively for public interoperability fixtures. + self.key = private_key or ec.generate_private_key(ec.SECP256R1()) + self.random = random_bytes if random_bytes is not None else os.urandom(32) + if len(self.random) != 32: + raise ValueError("random length") + public = self.key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint) + self.hello = json.dumps(dict(protocol_major=1, protocol_minor=0, + public_key=base64.b64encode(public).decode("ascii"), + random=base64.b64encode(self.random).decode("ascii")), separators=(",", ":")).encode("utf-8") + + def finish(self, peer_hello: bytes, *, server: bool): + if len(peer_hello) > 1024: + raise ProtocolError("BAD_REQUEST") + peer = json_object(peer_hello) + if type(peer.get("protocol_major")) is not int or peer["protocol_major"] != 1: + raise ProtocolError("UNSUPPORTED_VERSION") + if type(peer.get("protocol_minor")) is not int or peer["protocol_minor"] < 0: + raise ProtocolError("BAD_REQUEST") + try: + public = base64.b64decode(peer["public_key"], validate=True) + random = base64.b64decode(peer["random"], validate=True) + if len(public) != 65 or public[0] != 4 or len(random) != 32: + raise ValueError() + peer_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), public) + secret = self.key.exchange(ec.ECDH(), peer_key) + except (KeyError, TypeError, ValueError): + raise ProtocolError("BAD_REQUEST") from None + c, s = (peer_hello, self.hello) if server else (self.hello, peer_hello) + cr, sr = (random, self.random) if server else (self.random, random) + transcript = hashlib.sha256(struct.pack("!I", len(c)) + c + struct.pack("!I", len(s)) + s).digest() + material = HKDF(algorithm=hashes.SHA256(), length=72, + salt=hashlib.sha256(cr + sr).digest(), info=LABEL + transcript).derive(secret) + return Cipher(material, transcript, server=server) + + +class Cipher: + def __init__(self, material: bytes, transcript: bytes, *, server: bool): + self.tx_direction = 1 if server else 0 + self.rx_direction = 1 - self.tx_direction + self.keys = (AESGCM(material[:32]), AESGCM(material[32:64])) + self.prefixes = (material[64:68], material[68:72]) + self.transcript = transcript + self.tx_sequence = self.rx_sequence = 0 + self.failed = False + + def encrypt(self, payload: bytes) -> bytes: + if self.failed or not 1 <= len(payload) <= MAX_MESSAGE - 24 or self.tx_sequence >= 2**64: + raise ProtocolError("BAD_REQUEST") + seq = struct.pack("!Q", self.tx_sequence) + d = self.tx_direction + result = seq + self.keys[d].encrypt(self.prefixes[d] + seq, payload, LABEL + self.transcript + bytes([d]) + seq) + self.tx_sequence += 1 + return result + + def decrypt(self, record: bytes) -> bytes: + try: + if self.failed or not 25 <= len(record) <= MAX_MESSAGE or int.from_bytes(record[:8], "big") != self.rx_sequence: + raise ValueError() + seq, d = record[:8], self.rx_direction + result = self.keys[d].decrypt(self.prefixes[d] + seq, record[8:], LABEL + self.transcript + bytes([d]) + seq) + self.rx_sequence += 1 + return result + except Exception: + self.failed = True + raise ProtocolError("BAD_REQUEST") from None diff --git a/核桃派软件源代码/app/mobile/session.py b/核桃派软件源代码/app/mobile/session.py new file mode 100644 index 0000000..f0656ba --- /dev/null +++ b/核桃派软件源代码/app/mobile/session.py @@ -0,0 +1,133 @@ +"""Single-owner encrypted RPC session; transport cannot bypass this gate.""" +from __future__ import annotations + +import json +import threading +import time + +from .protocol import Handshake, ProtocolError, Reassembler + + +class RpcError(Exception): + def __init__(self, code, message): + self.code, self.message = code, message + super().__init__(code) + + +def checked_name(value): + if not isinstance(value, str) or not value.strip() or len(value) > 40: + raise RpcError("BAD_REQUEST", "名称须为 1 至 40 个字符") + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise RpcError("BAD_REQUEST", "名称包含不支持的字符") + return value.strip() + + +class SessionManager: + def __init__(self, dispatch, identity, *, clock=time.monotonic): + self.dispatch = dispatch + self.identity = identity + self.clock = clock + self.lock = threading.RLock() + self.owner = None + self.receiver = None + self.cipher = None + self.opened = False + self.client_name = None + self.last_request = -1 + self.connected_at = self.last_activity = 0 + self.generation = 0 + self.peer_mtu = 23 + + def connect(self, owner): + with self.lock: + if self.owner is not None: + return self.owner == owner + self.owner = owner + self.receiver = Reassembler(self.clock) + self.connected_at = self.last_activity = self.clock() + return True + + def disconnect(self, owner): + with self.lock: + if self.owner != owner: + return + self.owner = self.receiver = self.cipher = self.client_name = None + self.opened = False + self.last_request = -1 + self.peer_mtu = 23 + self.generation += 1 + + def expired(self): + with self.lock: + if self.owner is None: + return False + deadline = self.last_activity + 20 if self.opened else self.connected_at + 10 + return self.clock() >= deadline or ( + self.receiver.pending is not None and self.clock() - self.receiver.started >= 15) + + def status(self): + with self.lock: + return dict(connected=self.opened, client_name=self.client_name if self.opened else None, + generation=self.generation) + + def accept(self, owner, fragment): + with self.lock: + if owner != self.owner or self.expired(): + raise ProtocolError("NOT_READY") + message = self.receiver.accept(fragment) + if message is None: + return None + kind, payload = message + if self.cipher is None: + if kind != 1: + raise ProtocolError("NOT_READY") + handshake = Handshake() + self.cipher = handshake.finish(payload, server=True) + return 2, handshake.hello + if kind != 3: + raise ProtocolError("BAD_REQUEST") + from .protocol import json_object + request = json_object(self.cipher.decrypt(payload)) + request_id = request.get("id") + if not isinstance(request_id, str) or not 1 <= len(request_id) <= 64 or not request_id.isascii() or not request_id.isdecimal(): + raise ProtocolError("BAD_REQUEST") + number = int(request_id) + response = dict(id=request_id) + try: + if number <= self.last_request: + raise RpcError("CONFLICT", "请求编号已使用") + self.last_request = number + method, params = request.get("method"), request.get("params") + if not isinstance(method, str) or not isinstance(params, dict): + raise RpcError("BAD_REQUEST", "请求格式无效") + if not self.opened: + if method != "session.open": + raise ProtocolError("NOT_READY") + name = checked_name(params.get("client_name")) + peer_mtu = params.get('receive_mtu', 23) + if type(peer_mtu) is not int or not 23 <= peer_mtu <= 517: + raise RpcError('BAD_REQUEST', '接收分片上限无效') + result = self.identity() + self.peer_mtu = peer_mtu + self.client_name = name + self.opened = True + self.generation += 1 + elif method == "session.ping": + result = dict(alive=True) + elif method == "session.rename": + self.client_name = checked_name(params.get("client_name")) + result = dict(client_name=self.client_name) + elif method == "session.open": + raise RpcError("CONFLICT", "会话已经建立") + else: + result = self.dispatch(method, params) + response.update(ok=True, result=result) + self.last_activity = self.clock() + except RpcError as error: + response.update(ok=False, error=dict(code=error.code, message=error.message, retryable=False)) + except ProtocolError: + raise + except Exception: + response.update(ok=False, error=dict(code="DEVICE_ERROR", message="设备操作失败,请刷新状态", retryable=False)) + raw = json.dumps(response, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8") + return 3, self.cipher.encrypt(raw) diff --git a/核桃派软件源代码/app/network/manager.py b/核桃派软件源代码/app/network/manager.py index e158b91..46c5bbc 100644 --- a/核桃派软件源代码/app/network/manager.py +++ b/核桃派软件源代码/app/network/manager.py @@ -2,6 +2,8 @@ from __future__ import annotations import subprocess import threading +import time +import re from copy import deepcopy from dataclasses import dataclass from typing import Any, Protocol, Sequence @@ -10,9 +12,13 @@ from uuid import uuid4 class NetworkManagerError(RuntimeError): """A sanitized NetworkManager operation failure.""" + def __init__(self, message: str, *, error_code: str = "UNKNOWN"): + super().__init__(message) + self.error_code = error_code class NetworkBackend(Protocol): + def scan(self) -> list[dict[str, Any]]: ... def discover_connection_uuid(self) -> str | None: ... def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]: ... def read_active(self) -> dict[str, Any]: ... @@ -42,6 +48,40 @@ class NmcliNetworkManager: executable: str = "/usr/bin/nmcli" interface: str = "wlan0" + def scan(self) -> list[dict[str, Any]]: + rows = self._run(['--terse', '--escape', 'yes', '--fields', 'IN-USE,SSID,SECURITY,SIGNAL', + 'device', 'wifi', 'list', 'ifname', self.interface, '--rescan', 'yes'], timeout=15) + strongest = {} + for row in rows.splitlines(): + fields, current, escaped = [], [], False + for character in row: + if escaped: + current.append(character) + escaped = False + elif character == '\\': + escaped = True + elif character == ':': + fields.append(''.join(current)) + current = [] + else: + current.append(character) + fields.append(''.join(current)) + if len(fields) != 4 or not fields[1]: + continue + active, ssid, security, signal = fields + if not signal.isdecimal(): + continue + kind = 'open' if security in ('', '--') else ( + 'wpa-psk' if ('WPA1' in security or 'WPA2' in security) and '802.1X' not in security else 'unsupported') + item = dict(ssid=ssid, security=kind, signal_percent=min(100, max(0, int(signal))), connected=active == '*') + key = (ssid, kind) + if key not in strongest or item['signal_percent'] > strongest[key]['signal_percent']: + item['connected'] = item['connected'] or strongest.get(key, {}).get('connected', False) + strongest[key] = item + elif item['connected']: + strongest[key]['connected'] = True + return sorted(strongest.values(), key=lambda item: -item['signal_percent'])[:100] + def _run( self, arguments: Sequence[str], @@ -105,6 +145,7 @@ class NmcliNetworkManager: raw_dns = self._connection_value(connection_uuid, "ipv4.dns") password = None password_configured = False + key_mgmt = self._connection_value(connection_uuid, "802-11-wireless-security.key-mgmt") if include_secret: password = self._connection_value( connection_uuid, @@ -113,10 +154,6 @@ class NmcliNetworkManager: ) password_configured = bool(password and password != "--") else: - key_mgmt = self._connection_value( - connection_uuid, - "802-11-wireless-security.key-mgmt", - ) password_configured = bool(key_mgmt and key_mgmt != "--") prefix = None plain_address = address @@ -129,13 +166,17 @@ class NmcliNetworkManager: return { "connection_uuid": connection_uuid, "ssid": ssid, + "security": "open" if key_mgmt in ("", "--") else "wpa-psk" if key_mgmt == "wpa-psk" else "unsupported", "password": password if password_configured else None, "password_configured": password_configured, "ipv4_mode": "dhcp" if method == "auto" else "manual", "address": plain_address or None, "prefix": prefix, "gateway": self._connection_value(connection_uuid, "ipv4.gateway") or None, - "dns_servers": [item.strip() for item in raw_dns.splitlines() if item.strip()], + "dns_servers": list(dict.fromkeys( + item.strip() for line in raw_dns.splitlines() for item in line.split(",") + if item.strip() and item.strip() != "--" + )), } def read_active(self) -> dict[str, Any]: @@ -174,9 +215,10 @@ class NmcliNetworkManager: def _modify_arguments(settings: dict[str, Any]) -> list[str]: arguments = [ "802-11-wireless.ssid", settings["ssid"], - "802-11-wireless-security.key-mgmt", "wpa-psk", ] - if settings.get("password") is not None: + if settings.get('security', 'wpa-psk') != 'open': + arguments.extend(['802-11-wireless-security.key-mgmt', 'wpa-psk']) + if settings.get("password") is not None and settings.get('security') != 'open': arguments.extend(["802-11-wireless-security.psk", settings["password"]]) if settings["ipv4_mode"] == "dhcp": arguments.extend([ @@ -206,6 +248,8 @@ class NmcliNetworkManager: return connection_uuid def save_connection(self, connection_uuid: str, settings: dict[str, Any]) -> None: + if settings.get('security') == 'open': + self._run(['connection', 'modify', 'uuid', connection_uuid, 'remove', '802-11-wireless-security']) self._run([ "connection", "modify", "uuid", connection_uuid, *self._modify_arguments(settings), @@ -213,12 +257,36 @@ class NmcliNetworkManager: def activate(self, connection_uuid: str, *, timeout: int = 5) -> None: bounded = max(1, min(int(timeout), 30)) - self._run([ - "--wait", str(bounded), "connection", "up", "uuid", connection_uuid, - ], timeout=bounded + 2) + started = int(time.time()) + try: + self._run([ + "--wait", str(bounded), "connection", "up", "uuid", connection_uuid, + ], timeout=bounded + 2) + except NetworkManagerError: + code = "UNKNOWN" + try: + reason = self._run(["--get-values", "GENERAL.REASON", "device", "show", self.interface]) + number = int(reason.split(" ", 1)[0]) + if number == 53: + code = "NETWORK_UNAVAILABLE" + elif number in (5, 6, 15, 16, 17): + code = "IP_CONFIG_FAILED" + # A timeout or missing-secret request alone is not proof of a wrong password. + # Only the supplicant's explicit WRONG_KEY event from this attempt qualifies. + if code == "UNKNOWN": + result = subprocess.run(["journalctl", "-u", "wpa_supplicant.service", "--since", f"@{started}", + "--output=cat", "--no-pager"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3) + pattern = re.compile(r"^" + re.escape(self.interface) + r": CTRL-EVENT-SSID-TEMP-DISABLED .*\breason=WRONG_KEY\b", re.MULTILINE) + if result.returncode == 0 and pattern.search(result.stdout): + code = "AUTH_FAILED" + except (NetworkManagerError, ValueError, OSError, subprocess.SubprocessError): + pass + raise NetworkManagerError("WiFi activation failed", error_code=code) from None class MockNetworkManager: + def scan(self): + return [] def __init__( self, saved: dict[str, Any] | None = None, @@ -269,7 +337,7 @@ class MockNetworkManager: with self._lock: previous_password = self.saved.get("password") if self.saved else None self.saved = {"connection_uuid": connection_uuid, **deepcopy(settings)} - if settings.get("password") is None: + if settings.get("password") is None and settings.get('security') != 'open': self.saved["password"] = previous_password self.saved["password_configured"] = bool(self.saved.get("password")) diff --git a/核桃派软件源代码/app/network/service.py b/核桃派软件源代码/app/network/service.py index 5651885..90aa601 100644 --- a/核桃派软件源代码/app/network/service.py +++ b/核桃派软件源代码/app/network/service.py @@ -30,10 +30,16 @@ def validate_wifi_settings( raise WifiConfigError("ssid must contain 1..32 UTF-8 bytes") password_value = raw.get("password") + security = raw.get('security', 'wpa-psk') + if security not in ('open', 'wpa-psk'): + raise WifiConfigError('unsupported WiFi security') password = None if password_value in (None, "") else str(password_value) if password is not None and not 8 <= _utf8_length(password) <= 63: raise WifiConfigError("password must contain 8..63 UTF-8 bytes") - if previous is None or previous.get("ssid") != ssid: + if security == 'open': + if password is not None: + raise WifiConfigError('open networks cannot have a password') + elif previous is None or previous.get("ssid") != ssid: if password is None: raise WifiConfigError("a new password is required when the SSID changes") elif password is None and not previous.get("password_configured"): @@ -79,8 +85,9 @@ def validate_wifi_settings( return { "ssid": ssid, + "security": security, "password": password, - "password_configured": bool(password or (previous or {}).get("password_configured")), + "password_configured": security != 'open' and bool(password or (previous or {}).get("password_configured")), "ipv4_mode": mode, "address": address, "prefix": prefix if mode == "manual" else None, @@ -105,6 +112,8 @@ class WifiNetworkService: self.monotonic = monotonic self.poll_seconds = poll_seconds self._lock = threading.RLock() + self.configuration_lock = self._lock + self.settings_revision = str(uuid4()) self._stop = threading.Event() self._thread: threading.Thread | None = None self._activation_active = False @@ -256,6 +265,7 @@ class WifiNetworkService: else "WiFi 配置已保存,将在下次断电开机后生效" ) self.store.set_operation({"id": operation_id, "state": state, "message": message}) + self.settings_revision = str(uuid4()) return { "operation_id": operation_id, "activation": activation, @@ -297,7 +307,8 @@ class WifiNetworkService: self.store.set_operation({ "id": operation_id, "state": "failed", - "message": str(exc), + "message": "WiFi activation failed", + "error_code": getattr(exc, "error_code", "UNKNOWN"), }) finally: with self._lock: diff --git a/核桃派软件源代码/app/network/store.py b/核桃派软件源代码/app/network/store.py index 063a1fc..8febe05 100644 --- a/核桃派软件源代码/app/network/store.py +++ b/核桃派软件源代码/app/network/store.py @@ -115,6 +115,9 @@ class WifiConfigStore: "state": str(operation.get("state") or "idle"), "message": str(operation.get("message") or ""), } + if operation.get("error_code") is not None: + code = operation["error_code"] + safe["error_code"] = code if code in {"AUTH_FAILED", "NETWORK_UNAVAILABLE", "IP_CONFIG_FAILED", "UNKNOWN"} else "UNKNOWN" if safe["state"] not in {"idle", "scheduled", "applying", "succeeded", "failed"}: raise WifiConfigError("invalid wifi operation state") with self._lock: diff --git a/核桃派软件源代码/app/ota/manager.py b/核桃派软件源代码/app/ota/manager.py index d4dc44a..1990e64 100644 --- a/核桃派软件源代码/app/ota/manager.py +++ b/核桃派软件源代码/app/ota/manager.py @@ -11,6 +11,7 @@ from typing import Any, AsyncIterable, Callable from uuid import uuid4 from app.display.service import DisplayService +from app.system.kernel_recovery import KernelRecovery from .diagnostics import failure_log_metadata, read_failure_log from .package import MAX_OTA_UPLOAD_BYTES, OtaPackageError, inspect_package @@ -84,8 +85,45 @@ class OtaManager: "last_result": last_result, "failure_log_available": failure_log_available, "failure_log_bytes": failure_log_bytes, + "kernel_recovery": self.kernel_recovery_status(), } + def _production_recovery(self) -> KernelRecovery | None: + if os.name == "nt" or self.data_root != Path("/var/lib/matrix-screen-controller"): + return None + return KernelRecovery(self.data_root) + + def kernel_recovery_status(self) -> dict[str, str]: + recovery = self._production_recovery() + return recovery.status() if recovery else {"state": "not_needed", "reason": ""} + + def schedule_kernel_recovery_after_ota(self) -> None: + recovery = self._production_recovery() + if recovery is None: + return + result = read_last_result(self.data_root) + if not result or result.get("status") != "success" or result.get("target_version") != str(self.software_version): + return + result_id = f"{result.get('target_version')}:{result.get('installed_at')}" + if not recovery.prepare_attempt(result_id): + return + command = [ + "systemd-run", "--quiet", "--collect", "--unit=matrix-kernel-recovery", + "--on-active=5s", "/usr/bin/python3", + "/opt/matrix-screen-controller/app/system/kernel_recovery.py", "arm-and-reboot", + ] + try: + subprocess.run(command, check=True, capture_output=True, timeout=10) + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + recovery.write_record("failed", f"could not schedule kernel recovery: {exc}", attempts=0) + + def finalize_kernel_recovery_after_boot(self) -> None: + recovery = self._production_recovery() + if recovery is None: + return + thread = threading.Thread(target=recovery.finalize_after_boot, name="kernel-recovery-finalize", daemon=True) + thread.start() + def failure_log(self) -> bytes | None: last_result = read_last_result(self.data_root) return read_failure_log(self.data_root, last_result) diff --git a/核桃派软件源代码/app/static/index.html b/核桃派软件源代码/app/static/index.html index aafcece..b08b71a 100644 --- a/核桃派软件源代码/app/static/index.html +++ b/核桃派软件源代码/app/static/index.html @@ -41,6 +41,7 @@

控制台已就绪

+ 手机未连接 diff --git a/核桃派软件源代码/app/static/ui-copy.json b/核桃派软件源代码/app/static/ui-copy.json index 342dabe..5e30b6a 100644 --- a/核桃派软件源代码/app/static/ui-copy.json +++ b/核桃派软件源代码/app/static/ui-copy.json @@ -3618,6 +3618,18 @@ "source": "index.html::#workspace-templates > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", "text": "内容" }, + "copy.9977eafad4a6d6e9": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#topbar-mobile", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#topbar-mobile::text[0]", + "text": "手机未连接" + }, "copy.9a395662799ecd7a": { "kind": "html_text", "placeholders": [], diff --git a/核桃派软件源代码/app/static/views/device.js b/核桃派软件源代码/app/static/views/device.js index 71f5f59..1d3bbc5 100644 --- a/核桃派软件源代码/app/static/views/device.js +++ b/核桃派软件源代码/app/static/views/device.js @@ -16,6 +16,7 @@ import { } from "../current-display-playback.js"; const statusEls = { + mobile: document.getElementById("topbar-mobile"), orientation: document.getElementById("status-orientation"), brightness: document.getElementById("status-brightness"), currentContent: document.getElementById("topbar-current-content"), @@ -760,9 +761,22 @@ export async function refreshDeviceStorage() { } } +let lastMobileSession = null; + export async function refreshStatus({ source = "manual" } = {}) { try { const data = await apiJson("/api/status"); + const mobile = data.mobile; + if (statusEls.mobile) { + statusEls.mobile.textContent = mobile?.connected + ? `手机:${mobile.client_name || "已连接"}` + : (mobile?.available === false ? "手机蓝牙暂不可用" : "手机未连接"); + const session = `${data.service?.instance_id || ""}:${mobile?.generation || 0}`; + if (mobile?.connected && lastMobileSession !== null && session !== lastMobileSession) { + announce(`${mobile.client_name || "手机"}已连接`); + } + lastMobileSession = session; + } syncCurrentDisplayDialogStatus(data); statusEls.orientation.textContent = `${data.state.orientation}°`; showDeviceBrightness(data.state); diff --git a/核桃派软件源代码/app/static/views/settings.js b/核桃派软件源代码/app/static/views/settings.js index bb8d9ce..bd0c088 100644 --- a/核桃派软件源代码/app/static/views/settings.js +++ b/核桃派软件源代码/app/static/views/settings.js @@ -263,6 +263,20 @@ function applyOtaStatus(status) { otaProgressGroup.hidden = true; setOtaFeedback(""); } + if (job?.phase !== "failed") { + const recovery = status?.kernel_recovery; + if (recovery?.state === "pending") { + setOtaFeedback("应用更新完成;正在进行一次候选内核恢复重启…", "pending"); + } else if (recovery?.state === "succeeded") { + setOtaFeedback("候选内核与 CPU 调频策略已恢复。", "ready"); + } else if (recovery?.state === "failed") { + setOtaFeedback(`内核恢复失败:${recovery.reason || "请查看设备诊断"}`, "error"); + } else if (recovery?.state === "requires_package") { + setOtaFeedback("应用已更新;内核材料缺失或损坏,需要专用修复包。", "error"); + } else if (recovery?.state === "ready") { + setOtaFeedback("当前设备运行原内核,性能模式不可用;下次 OTA 将尝试轻量恢复。", "error"); + } + } } } @@ -281,7 +295,7 @@ async function pollOtaUntilFinished() { const status = await fetchOtaStatus(); if (disconnected) setOtaFeedback("设备服务已恢复,正在确认更新结果…", "pending"); applyOtaStatus(status); - if (!status.active) return; + if (!status.active && status.kernel_recovery?.state !== "pending") return; } catch { disconnected = true; setOtaFeedback("设备正在切换软件版本,等待服务恢复…", "pending"); @@ -297,7 +311,7 @@ async function loadOtaStatus() { try { const status = await fetchOtaStatus(); applyOtaStatus(status); - if (status.active) pollOtaUntilFinished(); + if (status.active || status.kernel_recovery?.state === "pending") pollOtaUntilFinished(); } catch (error) { otaCurrentVersion.textContent = "读取失败"; otaFeatureUpdatedAt.textContent = "读取失败"; diff --git a/核桃派软件源代码/app/system/kernel_recovery.py b/核桃派软件源代码/app/system/kernel_recovery.py new file mode 100644 index 0000000..b254249 --- /dev/null +++ b/核桃派软件源代码/app/system/kernel_recovery.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import time +from typing import Any +from uuid import uuid4 + + +CANDIDATE_RELEASE = "6.1.31-matrix-axp313a1" +RECORD_NAME = "kernel-recovery.json" +BOOT_HASHES = { + "Image-matrix-axp313a1": "f8b6801cb9a300ee126635fd8834c5cdc6bd55857bb4438982db7f6fd78a8541", + "sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb": "10ef0eca3b9ec2063a8e4f47d0afc5e30b6e1a2ed8c4fcf9b0450630e2c11f66", + "sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb": "d0203cd03eb2c316cb36ed94016cbeb469d499ce06d866aaeef0494c45037c12", +} +MODULE_COUNT = 3035 +MODULE_DIGEST = "8326558d841bc137c87d153d68903da574ebccaaee1bc6d6c129a8526af29346" + + +class KernelRecovery: + def __init__(self, data_root: Path, *, host_root: Path = Path("/")) -> None: + self.data_root = Path(data_root) + self.host_root = Path(host_root) + self.record_path = self.data_root / RECORD_NAME + + def host(self, absolute: str) -> Path: + return self.host_root / absolute.lstrip("/") + + @staticmethod + def _digest(path: Path) -> bytes: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.digest() + + def boot_id(self) -> str: + return self.host("/proc/sys/kernel/random/boot_id").read_text(encoding="ascii").strip() + + def release(self) -> str: + return self.host("/proc/sys/kernel/osrelease").read_text(encoding="ascii").strip() + + def capability(self) -> dict[str, Any]: + policies = {} + for policy in sorted(self.host("/sys/devices/system/cpu/cpufreq").glob("policy*")): + if not policy.is_dir(): + continue + try: + current = (policy / "scaling_governor").read_text(encoding="ascii").strip() + available = (policy / "scaling_available_governors").read_text(encoding="ascii").split() + except OSError: + current, available = "", [] + policies[policy.name] = {"current": current, "performance": "performance" in available} + try: + release = self.release() + except OSError: + release = "unavailable" + return { + "release": release, + "policies": policies, + "available": bool(policies) and all(value["current"] and value["performance"] for value in policies.values()), + "marker": self.host("/boot/matrix-kernel-good").is_file(), + } + + def verify_candidate(self) -> str | None: + boot = self.host("/boot") + for name, expected in BOOT_HASHES.items(): + path = boot / name + try: + if self._digest(path).hex() != expected: + return f"candidate file differs from registered artifact: {name}" + except OSError: + return f"candidate file is missing or unreadable: {name}" + original = ( + "Image", "sun50i-h616-walnutpi-1b.dtb", "sun50i-h616-walnutpi-1b-emmc.dtb", + "boot.cmd.matrix-original", "boot.scr.matrix-original", "matrix-original.SHA256SUMS", + ) + if any(not (boot / name).is_file() for name in original): + return "original kernel rollback files are incomplete" + try: + command = (boot / "boot.cmd").read_text(encoding="utf-8") + script = (boot / "boot.scr").read_bytes() + except (OSError, UnicodeError): + return "managed boot script is unreadable" + if command.count("matrix_kernel_candidate=1") != 1 or command.count("matrix-kernel-good") != 2: + return "managed boot script is missing or ambiguous" + if b"matrix_kernel_candidate=1" not in script or b"Image-matrix-axp313a1" not in script: + return "compiled boot script does not select the candidate" + health_unit = self.host("/etc/systemd/system/matrix-axp313a-health.service") + health_script = self.host("/opt/matrix-screen-controller-system/axp313a_kernel_health.py") + try: + unit = health_unit.read_text(encoding="utf-8") + except OSError: + return "candidate health service is missing" + if "ConditionKernelCommandLine=matrix_kernel_candidate=1" not in unit or not health_script.is_file(): + return "candidate health service is incomplete" + enabled = self.host("/etc/systemd/system/multi-user.target.wants/matrix-axp313a-health.service") + if not enabled.exists(): + return "candidate health service is not enabled" + module_root = self.host(f"/lib/modules/{CANDIDATE_RELEASE}") + if not (module_root / "modules.dep").is_file(): + return "candidate module dependency index is missing" + modules = sorted(module_root.rglob("*.ko"), key=lambda item: item.relative_to(module_root).as_posix()) + if len(modules) != MODULE_COUNT: + return "candidate module count differs from registered artifact" + aggregate = hashlib.sha256() + try: + for module in modules: + aggregate.update(module.relative_to(module_root).as_posix().encode("ascii")) + aggregate.update(b"\0") + aggregate.update(self._digest(module)) + except (OSError, UnicodeError): + return "candidate modules are unreadable" + if aggregate.hexdigest() != MODULE_DIGEST: + return "candidate modules differ from registered artifact" + return None + + def read_record(self) -> dict[str, Any] | None: + if not self.record_path.exists(): + return None + try: + value = json.loads(self.record_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return {"state": "failed", "reason": "kernel recovery record is invalid; manual inspection required"} + if not isinstance(value, dict) or value.get("schema_version") != 1: + return {"state": "failed", "reason": "kernel recovery record has an unsupported schema"} + return value + + def write_record(self, state: str, reason: str, **fields: Any) -> None: + self.data_root.mkdir(parents=True, exist_ok=True) + document = {"schema_version": 1, "state": state, "reason": reason, + "recorded_at": time.time(), **fields} + temporary = self.record_path.with_name(f".{RECORD_NAME}.{uuid4().hex}.tmp") + try: + with temporary.open("x", encoding="utf-8", newline="\n") as handle: + json.dump(document, handle, ensure_ascii=False, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, self.record_path) + finally: + temporary.unlink(missing_ok=True) + + def status(self) -> dict[str, str]: + record = self.read_record() + if record: + if record.get("state") == "pending" and record.get("attempts") == 0: + recorded_at = record.get("recorded_at") + if isinstance(recorded_at, (int, float)) and time.time() - recorded_at > 180: + self.write_record("failed", "kernel recovery task did not start", attempts=0) + return {"state": "failed", "reason": "kernel recovery task did not start"} + if record.get("state") == "pending" and record.get("attempts") == 1: + recorded_at = record.get("recorded_at") + if (isinstance(recorded_at, (int, float)) and time.time() - recorded_at > 300 + and self.boot_id() == record.get("source_boot_id")): + self.write_record("failed", "candidate reboot did not occur", attempts=1) + return {"state": "failed", "reason": "candidate reboot did not occur"} + if record.get("state") == "succeeded": + capability = self.capability() + if capability["release"] != CANDIDATE_RELEASE: + return {"state": "failed", "reason": "candidate kernel was lost after a successful recovery"} + if not capability["available"] or not capability["marker"]: + return {"state": "pending", "reason": "waiting for candidate boot health"} + return {"state": str(record.get("state", "failed")), "reason": str(record.get("reason", ""))} + capability = self.capability() + if capability["release"] == CANDIDATE_RELEASE and capability["available"] and capability["marker"]: + return {"state": "not_needed", "reason": ""} + return {"state": "ready", "reason": "candidate kernel is not active"} + + def prepare_attempt(self, ota_result_id: str) -> bool: + capability = self.capability() + if capability["release"] == CANDIDATE_RELEASE and capability["available"] and capability["marker"]: + return False + if self.read_record() is not None: + return False + if capability["release"] != "6.1.31": + self.write_record("failed", "automatic recovery only supports the original 6.1.31 kernel", + ota_result_id=ota_result_id) + return False + reason = self.verify_candidate() + if reason: + self.write_record("requires_package", reason, ota_result_id=ota_result_id) + return False + self.write_record("pending", "waiting for one candidate boot", ota_result_id=ota_result_id, + source_boot_id=self.boot_id(), attempts=0) + return True + + def arm_and_reboot(self, *, reboot: bool = True) -> None: + record = self.read_record() + if not record or record.get("state") != "pending" or record.get("attempts") != 0: + raise RuntimeError("no unused kernel recovery attempt is pending") + recorded_at = record.get("recorded_at") + if isinstance(recorded_at, (int, float)) and time.time() - recorded_at > 180: + self.write_record("failed", "kernel recovery task started too late", attempts=0) + return + if self.boot_id() != record.get("source_boot_id"): + self.write_record("failed", "boot changed before kernel recovery was armed", attempts=0) + return + if self.release() != "6.1.31": + self.write_record("failed", "automatic recovery requires the original 6.1.31 kernel", attempts=0) + return + for _ in range(60): + worker = subprocess.run(["systemctl", "is-active", "--quiet", "matrix-screen-controller-ota.service"], + check=False, timeout=5) + if worker.returncode != 0 and not (self.data_root / "ota/component-transaction").exists(): + break + time.sleep(1) + else: + self.write_record("failed", "OTA component transaction did not finish", attempts=1) + return + reason = self.verify_candidate() + if reason: + self.write_record("requires_package", reason, attempts=1) + return + marker = self.host("/boot/matrix-kernel-good") + temporary = marker.with_name(".matrix-kernel-good.recovery") + if marker.exists() or temporary.exists(): + self.write_record("failed", "candidate boot marker already exists; manual inspection required", attempts=1) + return + try: + with temporary.open("x", encoding="ascii", newline="\n") as handle: + handle.write(f"{CANDIDATE_RELEASE}\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, marker) + if hasattr(os, "sync"): + os.sync() + self.write_record("pending", "candidate reboot requested", attempts=1, + source_boot_id=record["source_boot_id"], ota_result_id=record.get("ota_result_id", "")) + if reboot: + subprocess.run(["systemctl", "reboot"], check=True, timeout=15) + except Exception as exc: + temporary.unlink(missing_ok=True) + marker.unlink(missing_ok=True) + self.write_record("failed", f"could not request candidate reboot: {exc}", attempts=1) + raise + + def finalize_after_boot(self, *, wait_seconds: int = 100) -> None: + record = self.read_record() + if record and record.get("state") == "succeeded" and self.boot_id() != record.get("successful_boot_id"): + for _ in range(wait_seconds): + capability = self.capability() + if capability["release"] != CANDIDATE_RELEASE: + self.write_record("failed", "candidate kernel was lost after a successful recovery", attempts=1) + return + if capability["available"] and capability["marker"]: + self.write_record("succeeded", "candidate kernel and cpufreq passed boot health", + attempts=1, successful_boot_id=self.boot_id()) + return + time.sleep(1) + self.write_record("failed", "candidate boot health did not complete", attempts=1) + return + if not record or record.get("state") != "pending" or record.get("attempts") != 1: + return + if self.boot_id() == record.get("source_boot_id"): + return + for _ in range(wait_seconds): + capability = self.capability() + if capability["release"] != CANDIDATE_RELEASE: + self.write_record("failed", "candidate boot fell back to the original kernel", attempts=1) + return + if capability["available"] and capability["marker"]: + self.write_record("succeeded", "candidate kernel and cpufreq passed boot health", + attempts=1, successful_boot_id=self.boot_id()) + return + time.sleep(1) + self.write_record("failed", "candidate boot health did not complete", attempts=1) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("command", choices=("arm-and-reboot", "status")) + parser.add_argument("--data-root", type=Path, default=Path("/var/lib/matrix-screen-controller")) + args = parser.parse_args() + recovery = KernelRecovery(args.data_root) + if args.command == "status": + print(json.dumps(recovery.status(), ensure_ascii=False)) + else: + recovery.arm_and_reboot() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/app/system/performance.py b/核桃派软件源代码/app/system/performance.py index bbe4d99..1933958 100644 --- a/核桃派软件源代码/app/system/performance.py +++ b/核桃派软件源代码/app/system/performance.py @@ -224,6 +224,11 @@ class PerformanceModeManager: except OSError as exc: self._last_error = f"could not read current governors: {exc}" effective_requested = self._requested if requested is None else requested + error = self._last_error + if not self._policy_paths() and error is None: + error = "当前内核未提供 CPU 调频策略;请检查候选内核是否回退" + elif self._policy_paths() and not self._restore_governors and error is None: + error = "无法读取 CPU 调频策略的原始值" effective = bool( effective_requested and current @@ -235,7 +240,7 @@ class PerformanceModeManager: "effective": effective, "current_governors": current, "restore_governors": dict(self._restore_governors), - "last_error": self._last_error, + "last_error": error, } diff --git a/核桃派软件源代码/requirements-dev.txt b/核桃派软件源代码/requirements-dev.txt index 2b7625a..1effa82 100644 --- a/核桃派软件源代码/requirements-dev.txt +++ b/核桃派软件源代码/requirements-dev.txt @@ -1,4 +1,4 @@ -r requirements.txt pytest==8.4.1 httpx==0.28.1 - +paramiko==4.0.0 diff --git a/核桃派软件源代码/requirements.txt b/核桃派软件源代码/requirements.txt index 00581e8..6b60625 100644 --- a/核桃派软件源代码/requirements.txt +++ b/核桃派软件源代码/requirements.txt @@ -2,3 +2,5 @@ fastapi==0.116.1 uvicorn[standard]==0.35.0 pillow>=10.0 fonttools>=4.0,<5 +cryptography==46.0.5 +dbus-next==0.2.3 diff --git a/核桃派软件源代码/scripts/check_repository_hygiene.py b/核桃派软件源代码/scripts/check_repository_hygiene.py index 061c036..76b1b7d 100644 --- a/核桃派软件源代码/scripts/check_repository_hygiene.py +++ b/核桃派软件源代码/scripts/check_repository_hygiene.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse import ipaddress +import json import os import re import subprocess @@ -15,7 +16,14 @@ PRIVATE_CREDENTIAL = PurePosixPath("测试相关资料/核桃派的用户名和 EXAMPLE_CREDENTIAL = PurePosixPath( "测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.example.txt" ) +PRIVATE_MOBILE_REGISTRATION = PurePosixPath( + "移动端相关内容/安卓app/如何安卓测试/测试设备登记/测试设备.local.json" +) +EXAMPLE_MOBILE_REGISTRATION = PRIVATE_MOBILE_REGISTRATION.with_name("测试设备.example.json") BINARY_EXTENSIONS = { + ".apk", + ".aab", + ".jar", ".deb", ".dll", ".docx", @@ -120,7 +128,7 @@ def _host_text_patterns() -> list[re.Pattern[str]]: def _private_value_markers() -> tuple[str, ...]: path = WORKSPACE_ROOT.joinpath(*PRIVATE_CREDENTIAL.parts) if not path.is_file(): - return () + return _mobile_value_markers() fields: dict[str, str] = {} for raw_line in path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() @@ -132,13 +140,36 @@ def _private_value_markers() -> tuple[str, ...]: key, value = (part.strip() for part in line.split(separator, 1)) fields[key] = value public_values = {fields.get(key, "") for key in PUBLIC_IMAGE_KEYS} - return tuple( + return _mobile_value_markers() + tuple( value for key in CURRENT_DEVICE_KEYS if len(value := fields.get(key, "")) >= 4 and value not in public_values ) +def _mobile_value_markers() -> tuple[str, ...]: + path = WORKSPACE_ROOT.joinpath(*PRIVATE_MOBILE_REGISTRATION.parts) + if not path.is_file(): + return () + try: + document = json.loads(path.read_text(encoding="utf-8")) + devices = document["devices"] + if not isinstance(devices, list): + raise ValueError + values = [] + for device in devices: + if not isinstance(device, dict): + raise ValueError + serial = device.get("serial", "") + if not isinstance(serial, str): + raise ValueError + if serial and "<" not in serial and len(serial) >= 4: + values.append(serial) + return tuple(values) + except (OSError, UnicodeError, ValueError, KeyError, TypeError): + raise HygieneError("移动端真实登记损坏,无法安全提取私有标识;未输出内容。") from None + + def _binary_markers(private_values: tuple[str, ...] = ()) -> list[bytes]: markers: list[bytes] = [] host_values = { @@ -178,7 +209,7 @@ def _text_issues( if any(pattern.search(text) for pattern in _host_text_patterns()): issues.append("包含开发电脑专属路径、用户名或主机名") if any(value in text for value in private_values): - issues.append("包含当前设备私有凭据值") + issues.append("包含当前设备私有凭据或测试手机标识") key_header = re.compile("-----BEGIN " + r"(?:OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----") if key_header.search(text): @@ -217,9 +248,12 @@ def audit_paths(paths: list[PurePosixPath], *, scan_binary: bool) -> list[tuple[ findings: list[tuple[str, str]] = [] private_values = _private_value_markers() for relative in paths: - if relative == PRIVATE_CREDENTIAL: + if relative in (PRIVATE_CREDENTIAL, PRIVATE_MOBILE_REGISTRATION): findings.append((relative.as_posix(), "真实凭据进入 Git 候选集合")) continue + if relative.suffix.lower() in {".jks", ".keystore"}: + findings.append((relative.as_posix(), "签名密钥文件进入 Git 候选集合")) + continue path = WORKSPACE_ROOT.joinpath(*relative.parts) if not path.is_file(): continue @@ -243,6 +277,15 @@ def _check_repository_contract(paths: list[PurePosixPath], staged: bool) -> list ignored = _git("check-ignore", "--quiet", "--", PRIVATE_CREDENTIAL.as_posix(), check=False) if ignored.returncode != 0: findings.append((PRIVATE_CREDENTIAL.as_posix(), "真实凭据未被 .gitignore 精确排除")) + mobile_ignored = _git("check-ignore", "--quiet", "--", PRIVATE_MOBILE_REGISTRATION.as_posix(), check=False) + if mobile_ignored.returncode != 0: + findings.append((PRIVATE_MOBILE_REGISTRATION.as_posix(), "真实手机登记未被 .gitignore 排除")) + if staged: + mobile_example = _git("cat-file", "-e", f":{EXAMPLE_MOBILE_REGISTRATION.as_posix()}", check=False) + if mobile_example.returncode != 0: + findings.append((EXAMPLE_MOBILE_REGISTRATION.as_posix(), "手机空白登记示例不在 Git 索引")) + elif EXAMPLE_MOBILE_REGISTRATION not in paths: + findings.append((EXAMPLE_MOBILE_REGISTRATION.as_posix(), "手机空白登记示例不在 Git 候选集合")) if staged: example_in_index = _git("cat-file", "-e", f":{EXAMPLE_CREDENTIAL.as_posix()}", check=False) diff --git a/核桃派软件源代码/scripts/dedicated_host.py b/核桃派软件源代码/scripts/dedicated_host.py index 7cd5bc6..e105742 100644 --- a/核桃派软件源代码/scripts/dedicated_host.py +++ b/核桃派软件源代码/scripts/dedicated_host.py @@ -13,8 +13,6 @@ from typing import Callable, Sequence EXPECTED_PINCTRL = "allwinner,sun50i-h616-pinctrl" DISABLED_SERVICES = ( "lightdm.service", - "bluetooth.service", - "aw859-bluetooth.service", "gpioc-server.service", "map_device.service", ) diff --git a/核桃派软件源代码/scripts/deploy_walnutpi.sh b/核桃派软件源代码/scripts/deploy_walnutpi.sh index 1ac7623..2975f8d 100644 --- a/核桃派软件源代码/scripts/deploy_walnutpi.sh +++ b/核桃派软件源代码/scripts/deploy_walnutpi.sh @@ -96,10 +96,9 @@ make -C "$STAGING/app/display/native" clean all test systemctl set-default multi-user.target systemctl disable --now \ lightdm.service \ - bluetooth.service \ - aw859-bluetooth.service \ gpioc-server.service \ map_device.service +DEFER_SERVICE_START="$DEFER_SERVICE_START" /bin/sh "$STAGING/scripts/install_mobile_bluetooth.sh" if [ "$DEFER_SERVICE_START" = "1" ]; then "$STAGING/.venv/bin/python" "$STAGING/scripts/dedicated_host.py" check --service else diff --git a/核桃派软件源代码/scripts/install_mobile_bluetooth.sh b/核桃派软件源代码/scripts/install_mobile_bluetooth.sh new file mode 100644 index 0000000..4649a7c --- /dev/null +++ b/核桃派软件源代码/scripts/install_mobile_bluetooth.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# Enable the existing WalnutPi vendor controller, without changing WiFi rfkill. +set -eu +test "$(id -u)" = 0 || { echo 'root is required' >&2; exit 1; } +test -x /usr/bin/hciattach +test -x /usr/sbin/rfkill +test -f /lib/systemd/system/aw859-bluetooth.service +install -d -m 0755 /etc/systemd/system/aw859-bluetooth.service.d +cat > /etc/systemd/system/aw859-bluetooth.service.d/50-matrix-mobile.conf <<'EOF' +[Service] +ExecStartPre= +ExecStartPre=/usr/sbin/modprobe hci_uart +ExecStartPre=/usr/sbin/rfkill unblock bluetooth +ExecStart= +ExecStart=/usr/bin/hciattach -n -s 1500000 /dev/ttyBT0 sprd +Restart=on-failure +RestartSec=5 +EOF +install -d -m 0755 /etc/systemd/system/bluetooth.service.d +cat > /etc/systemd/system/bluetooth.service.d/50-matrix-mobile.conf <<'EOF' +[Unit] +After=aw859-bluetooth.service +Wants=aw859-bluetooth.service +EOF +python3 - <<'PY' +from pathlib import Path +import re +p = Path('/etc/bluetooth/main.conf') +text = p.read_text(encoding='utf-8') +backup = p.with_name('main.conf.matrix-mobile-original') +if not backup.exists(): + with backup.open('x', encoding='utf-8') as stream: + stream.write(text) +for key, value in [('ControllerMode', 'le'), ('AlwaysPairable', 'false')]: + pattern = r'(?m)^\s*' + key + r'\s*=.*$' + if re.search(pattern, text): + text = re.sub(pattern, key + '=' + value, text) + else: + text = text.replace('[General]', '[General]\n' + key + '=' + value, 1) +p.write_text(text, encoding='utf-8') +PY +systemctl unmask bluetooth.service aw859-bluetooth.service +systemctl daemon-reload +systemctl enable bluetooth.service aw859-bluetooth.service +if [ "${DEFER_SERVICE_START:-0}" != 1 ]; then + # Bluetooth failure must not prevent deploying or running the display service. + systemctl start aw859-bluetooth.service bluetooth.service || echo 'Bluetooth unavailable; inspect mobile status' >&2 +fi diff --git a/核桃派软件源代码/scripts/ota_components.py b/核桃派软件源代码/scripts/ota_components.py index d18cdcb..01676a7 100644 --- a/核桃派软件源代码/scripts/ota_components.py +++ b/核桃派软件源代码/scripts/ota_components.py @@ -32,6 +32,8 @@ RUNTIME_GUARD_BODY = b'# Managed by the OTA component transaction\n[Service]\nRu SERVICE = 'matrix-screen-controller.service' WORKER = 'matrix-screen-controller-ota.service' FRP = 'matrix-screen-frpc.service' +MOBILE_SERVICES = ('aw859-bluetooth.service', 'bluetooth.service') +MOBILE_FILES = ('bluetooth-conf', 'bluetooth-original', 'bluetooth-dropin', 'aw859-dropin') JOURNAL = Path('ota/component-transaction') FILES = { 'frpc': Path('/usr/local/bin/frpc'), @@ -39,6 +41,10 @@ FILES = { 'frpc-dropin': Path('/etc/systemd/system/matrix-screen-frpc.service.d/user.conf'), 'app-unit': Path('/etc/systemd/system/matrix-screen-controller.service'), 'ota-unit': Path('/etc/systemd/system/matrix-screen-controller-ota.service'), + 'bluetooth-conf': Path('/etc/bluetooth/main.conf'), + 'bluetooth-original': Path('/etc/bluetooth/main.conf.matrix-mobile-original'), + 'bluetooth-dropin': Path('/etc/systemd/system/bluetooth.service.d/50-matrix-mobile.conf'), + 'aw859-dropin': Path('/etc/systemd/system/aw859-bluetooth.service.d/50-matrix-mobile.conf'), } @@ -229,15 +235,18 @@ def begin(source: Path, candidate: Path): if RUNTIME_GUARD.exists(): raise RuntimeError('存在未清理的 OTA 运行目录保护,请先恢复') bundle = source / 'system-dependencies/frpc' + mobile_installer = source / 'scripts/install_mobile_bluetooth.sh' if not bundle.is_dir(): check_installed(source, version) - return + if not mobile_installer.is_file(): + return # Verify the pinned binary, not just a self-reported checksum file. from app.ota.policy import required_components - expected = required_components(source, SoftwareVersion.parse(version))['frpc'] - if hashlib.sha256((bundle / 'frpc').read_bytes()).hexdigest() != expected['sha256']: - raise RuntimeError('frpc payload differs from the registered dependency') - user = account() + if bundle.is_dir(): + expected = required_components(source, SoftwareVersion.parse(version))['frpc'] + if hashlib.sha256((bundle / 'frpc').read_bytes()).hexdigest() != expected['sha256']: + raise RuntimeError('frpc payload differs from the registered dependency') + user = account() if bundle.is_dir() else None for root in (DATA, candidate): if (root / JOURNAL).exists(): raise RuntimeError('存在未完成组件事务,请先恢复') @@ -249,6 +258,13 @@ def begin(source: Path, candidate: Path): 'permissions': permission_snapshot(DATA), 'files': {}, 'active': run(['systemctl', 'is-active', '--quiet', FRP], check=False).returncode == 0, 'enabled': run(['systemctl', 'is-enabled', '--quiet', FRP], check=False).returncode == 0} + if mobile_installer.is_file(): + record['mobile_services'] = { + unit: { + 'enabled_state': run(['systemctl', 'is-enabled', unit], check=False).stdout.decode().strip(), + 'active': run(['systemctl', 'is-active', '--quiet', unit], check=False).returncode == 0, + } for unit in MOBILE_SERVICES + } try: for name, path in FILES.items(): record['files'][name] = metadata(path) if path.exists() else None @@ -273,11 +289,14 @@ def begin(source: Path, candidate: Path): '/usr/bin/python3', str(HELPER), 'watch']) protect_runtime() env = os.environ.copy() - env.update(FRPC_BUNDLE=str(bundle), FRPC_RUN_USER=user) - run(['/bin/sh', str(source / 'scripts/install_frpc_system.sh')], env=env) + if bundle.is_dir(): + env.update(FRPC_BUNDLE=str(bundle), FRPC_RUN_USER=user) + run(['/bin/sh', str(source / 'scripts/install_frpc_system.sh')], env=env) + if mobile_installer.is_file(): + run(['/bin/sh', str(mobile_installer)], env=env) mirror_permissions(candidate) check_installed(source, version) - print('FRP component transaction prepared; waiting for OTA health result') + print('System component transaction prepared; waiting for OTA health result') except BaseException: # The watcher handles subsequent worker failure. Restore immediately too, # so a failed migration never leaves new system files while rolling back. @@ -289,6 +308,10 @@ def begin(source: Path, candidate: Path): def restore_components(journal: Path, record: dict): + mobile_services = record.get('mobile_services', {}) + for unit in reversed(MOBILE_SERVICES): + if unit in mobile_services: + run(['systemctl', 'stop', unit], check=False) run(['systemctl', 'stop', FRP], check=False) for name in ('frpc', 'frpc-unit', 'frpc-dropin'): path = FILES[name] @@ -299,6 +322,15 @@ def restore_components(journal: Path, record: dict): atomic(path, (journal / name).read_bytes(), item['mode']) apply_metadata(path, item) restore_permissions(DATA, record['permissions']) + if mobile_services: + for name in MOBILE_FILES: + path = FILES[name] + item = record['files'][name] + if item is None: + path.unlink(missing_ok=True) + else: + atomic(path, (journal / name).read_bytes(), item['mode']) + apply_metadata(path, item) run(['systemctl', 'daemon-reload']) # A previously absent unit cannot be disabled; avoid treating absence as error. if record['files']['frpc-unit'] is not None: @@ -309,6 +341,24 @@ def restore_components(journal: Path, record: dict): link.unlink(missing_ok=True) if record['active']: run(['systemctl', 'start', FRP]) + for unit in MOBILE_SERVICES: + previous = mobile_services.get(unit) + if previous is None: + continue + run(['systemctl', 'unmask', unit]) + state = previous['enabled_state'] + run(['systemctl', 'disable', unit], check=False) + if state == 'enabled': + run(['systemctl', 'enable', unit]) + elif state == 'enabled-runtime': + run(['systemctl', 'enable', '--runtime', unit]) + elif state in ('masked', 'masked-runtime'): + args = ['systemctl', 'mask'] + if state == 'masked-runtime': + args.append('--runtime') + run(args + [unit]) + if previous['active']: + run(['systemctl', 'start', unit]) def locate_journal(): diff --git a/核桃派软件源代码/scripts/ota_worker.py b/核桃派软件源代码/scripts/ota_worker.py index 477b53d..ae4e757 100644 --- a/核桃派软件源代码/scripts/ota_worker.py +++ b/核桃派软件源代码/scripts/ota_worker.py @@ -14,11 +14,13 @@ import sys import time import traceback from typing import Any +import urllib.request from app.ota.diagnostics import DiagnosticLog, clear_failure_log, persist_failure_log, sha256_file from app.ota.package import extract_payload, inspect_package from app.ota.state import read_json, utc_now, write_json, write_last_result from app.ota.versioning import SoftwareVersion +from app.system.kernel_recovery import CANDIDATE_RELEASE, KernelRecovery TARGET = Path("/opt/matrix-screen-controller") RELEASES = Path("/opt/matrix-screen-controller.releases") @@ -34,6 +36,32 @@ class UpdateFailed(RuntimeError): pass +def verify_kernel_capability_retained( + before: dict[str, Any], + after: dict[str, Any], + performance_before: dict[str, Any], + performance_after: dict[str, Any], +) -> None: + previously_healthy = ( + before.get("release") == CANDIDATE_RELEASE + and before.get("available") is True + and before.get("marker") is True + ) + if not previously_healthy: + return + if (after["release"] != CANDIDATE_RELEASE or not after["available"] or not after["marker"] + or set(after["policies"]) != set(before["policies"])): + raise UpdateFailed("OTA lost the validated candidate kernel or cpufreq policy") + requested = performance_before.get("requested") + if (performance_after.get("available") is not True or performance_after.get("requested") is not requested + or (requested is True and performance_after.get("effective") is not True)): + raise UpdateFailed("OTA changed performance mode availability or effective state") + if requested is False and performance_after.get("current_governors") != { + name: value["current"] for name, value in before["policies"].items() + }: + raise UpdateFailed("OTA did not restore the original CPU governors") + + def snapshot(root: Path, *, ignored: set[str] | None = None) -> dict[str, tuple[int, str]]: records: dict[str, tuple[int, str]] = {} ignored = ignored or set() @@ -81,6 +109,9 @@ class Transaction: f"job_id={self.job_id} current_version={self.current_version} " f"target_version={self.target_version} packaged_at={request.get('packaged_at') or ''}" ) + self.kernel_recovery = KernelRecovery(DATA_ROOT) + self.kernel_before: dict[str, Any] | None = None + self.performance_before: dict[str, Any] | None = None self.job = { "id": self.job_id, "target_version": str(self.target_version), @@ -118,6 +149,13 @@ class Transaction: ) def prepare(self) -> None: + self.kernel_before = self.kernel_recovery.capability() + with urllib.request.urlopen("http://127.0.0.1:8080/api/status", timeout=8) as response: + self.performance_before = json.loads(response.read().decode("utf-8"))["system"]["performance_mode"] + self.diagnostics.write( + f"kernel_before={self.kernel_before['release']} " + f"cpufreq_available={self.kernel_before['available']} marker={self.kernel_before['marker']}" + ) if any(path.exists() for path in (self.work_root, self.release, self.data_candidate, self.data_backup)): raise UpdateFailed("OTA transaction paths already exist") self.work_root.mkdir(parents=True, mode=0o700) @@ -273,6 +311,15 @@ class Transaction: raise UpdateFailed("new service reports the wrong hardware mapping") if driver.get("oe_timing_backend") != "h618-pwm4" or driver.get("oe_pulse_faults") != 0: raise UpdateFailed("new service did not pass the PWM4 OE health check") + before = self.kernel_before or {} + if (before.get("available") and before.get("marker") + and before.get("release") == CANDIDATE_RELEASE): + verify_kernel_capability_retained( + before, self.kernel_recovery.capability(), self.performance_before or {}, + (status.get("system") or {}).get("performance_mode") or {}, + ) + else: + self.diagnostics.write("kernel_preexisting_degraded=true; application update may proceed") self.diagnostics.write( "health_summary=" f"version:{status.get('service', {}).get('software_version')} " diff --git a/核桃派软件源代码/scripts/test_kernel_recovery_systemd.py b/核桃派软件源代码/scripts/test_kernel_recovery_systemd.py new file mode 100644 index 0000000..7c7f990 --- /dev/null +++ b/核桃派软件源代码/scripts/test_kernel_recovery_systemd.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Exercise the independent recovery task after a real systemd service stop.""" + +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess +import time +from uuid import uuid4 + + +def run(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, check=True, capture_output=True, text=True, timeout=30) + + +def main() -> int: + if os.geteuid() != 0 or not Path("/run/systemd/system").is_dir(): + raise RuntimeError("real systemd lifecycle test requires root on a systemd host") + token = uuid4().hex[:12] + directory_name = f"matrix-kernel-recovery-test-{token}" + main_unit = f"{directory_name}-main" + task_unit = f"{directory_name}-task" + runtime = Path("/run") / directory_name + if runtime.exists(): + raise RuntimeError("isolated runtime directory already exists") + try: + run("systemd-run", "--quiet", f"--unit={main_unit}", + f"--property=RuntimeDirectory={directory_name}", + "--property=RuntimeDirectoryPreserve=yes", "/bin/sleep", "120") + for _ in range(30): + if runtime.is_dir(): + break + time.sleep(0.1) + else: + raise RuntimeError("temporary service did not create its runtime directory") + if run("systemctl", "show", main_unit, "--property=RuntimeDirectoryPreserve", "--value").stdout.strip() != "yes": + raise RuntimeError("temporary service did not enable runtime preservation") + run("systemctl", "stop", main_unit) + if not runtime.is_dir(): + raise RuntimeError("systemd removed the runtime directory after service stop") + proof = runtime / "independent-task.txt" + run("systemd-run", "--quiet", "--wait", "--collect", f"--unit={task_unit}", + "/usr/bin/python3", "-c", + "from pathlib import Path; import sys; Path(sys.argv[1]).write_text('ok', encoding='ascii')", + str(proof)) + if proof.read_text(encoding="ascii") != "ok": + raise RuntimeError("independent task did not survive the main service stop") + print("real systemd recovery lifecycle passed") + return 0 + finally: + subprocess.run(["systemctl", "stop", main_unit], check=False, capture_output=True, timeout=15) + if runtime.is_dir() and runtime.parent == Path("/run") and runtime.name == directory_name: + shutil.rmtree(runtime) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/systemd/matrix-screen-controller.service b/核桃派软件源代码/systemd/matrix-screen-controller.service index 702e365..a0361e7 100644 --- a/核桃派软件源代码/systemd/matrix-screen-controller.service +++ b/核桃派软件源代码/systemd/matrix-screen-controller.service @@ -1,7 +1,7 @@ [Unit] Description=WalnutPi H618 Matrix Screen Controller After=NetworkManager.service -Wants=NetworkManager.service +Wants=NetworkManager.service bluetooth.service aw859-bluetooth.service [Service] Type=simple @@ -11,6 +11,7 @@ Environment=MATRIX_DATA_DIR=/var/lib/matrix-screen-controller Environment=MATRIX_RUNTIME_DIR=/run/matrix-screen-controller Environment=PYTHONDONTWRITEBYTECODE=1 Environment=MATRIX_MEDIA_WORKER_MODE=systemd +Environment=MATRIX_BLE_ENABLED=1 StateDirectory=matrix-screen-controller StateDirectoryMode=0711 RuntimeDirectory=matrix-screen-controller diff --git a/核桃派软件源代码/tests/test_api.py b/核桃派软件源代码/tests/test_api.py index 76e625d..30e7cf4 100644 --- a/核桃派软件源代码/tests/test_api.py +++ b/核桃派软件源代码/tests/test_api.py @@ -86,7 +86,7 @@ def test_status_config_and_display_api(tmp_path): "effective": False, "current_governors": {}, "restore_governors": {}, - "last_error": None, + "last_error": "当前内核未提供 CPU 调频策略;请检查候选内核是否回退", } assert status.json()["state"]["display_test"] == { "active": False, diff --git a/核桃派软件源代码/tests/test_dedicated_host.py b/核桃派软件源代码/tests/test_dedicated_host.py index c177938..b7a6e2e 100644 --- a/核桃派软件源代码/tests/test_dedicated_host.py +++ b/核桃派软件源代码/tests/test_dedicated_host.py @@ -49,6 +49,13 @@ def test_check_profile_accepts_walnutpi_contract(tmp_path): ] +def test_bluetooth_is_not_a_display_startup_blocker(tmp_path): + def runner(args): + assert not any('bluetooth' in part for part in args) + return healthy_runner(args) + assert all(check.ok for check in check_profile(make_paths(tmp_path), runner=runner)) + + def test_service_preflight_does_not_require_default_route(tmp_path): def runner(args): assert tuple(args) != ("ip", "route", "show", "default") diff --git a/核桃派软件源代码/tests/test_kernel_recovery.py b/核桃派软件源代码/tests/test_kernel_recovery.py index 852df70..eafa348 100644 --- a/核桃派软件源代码/tests/test_kernel_recovery.py +++ b/核桃派软件源代码/tests/test_kernel_recovery.py @@ -1,7 +1,14 @@ +import hashlib from pathlib import Path +import subprocess import pytest +from app.system import kernel_recovery as kr +from app.ota.manager import OtaManager +from app.ota.state import write_json, write_last_result +from app.ota.versioning import SoftwareVersion +from scripts.ota_worker import UpdateFailed, verify_kernel_capability_retained from scripts.axp313a_kernel_health import ( HealthError, check_cpufreq, @@ -11,6 +18,159 @@ from scripts.axp313a_kernel_health import ( from scripts.render_dual_kernel_boot import BootScriptError, render +def make_recovery(tmp_path: Path, monkeypatch) -> kr.KernelRecovery: + host = tmp_path / "host" + data = tmp_path / "data" + boot = host / "boot" + boot.mkdir(parents=True) + files = { + "Image-matrix-axp313a1": b"candidate-image", + "sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb": b"candidate-sd-dtb", + "sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb": b"candidate-emmc-dtb", + } + monkeypatch.setattr(kr, "BOOT_HASHES", {name: hashlib.sha256(value).hexdigest() for name, value in files.items()}) + for name, value in files.items(): + (boot / name).write_bytes(value) + for name in ("Image", "sun50i-h616-walnutpi-1b.dtb", "sun50i-h616-walnutpi-1b-emmc.dtb", + "boot.cmd.matrix-original", "boot.scr.matrix-original", "matrix-original.SHA256SUMS"): + (boot / name).write_bytes(b"original") + (boot / "boot.cmd").write_text( + "matrix-kernel-good matrix-kernel-good matrix_kernel_candidate=1", + encoding="utf-8", + ) + (boot / "boot.scr").write_bytes(b"matrix_kernel_candidate=1 Image-matrix-axp313a1") + health = host / "etc/systemd/system/matrix-axp313a-health.service" + health.parent.mkdir(parents=True) + health.write_text("ConditionKernelCommandLine=matrix_kernel_candidate=1", encoding="utf-8") + script = host / "opt/matrix-screen-controller-system/axp313a_kernel_health.py" + script.parent.mkdir(parents=True) + script.write_text("health", encoding="utf-8") + enabled = host / "etc/systemd/system/multi-user.target.wants/matrix-axp313a-health.service" + enabled.parent.mkdir(parents=True) + enabled.write_text("enabled", encoding="utf-8") + modules = host / "lib/modules" / kr.CANDIDATE_RELEASE + (modules / "kernel").mkdir(parents=True) + (modules / "modules.dep").write_text("index", encoding="ascii") + module = modules / "kernel/example.ko" + module.write_bytes(b"module") + digest = hashlib.sha256(b"kernel/example.ko\0" + hashlib.sha256(b"module").digest()).hexdigest() + monkeypatch.setattr(kr, "MODULE_COUNT", 1) + monkeypatch.setattr(kr, "MODULE_DIGEST", digest) + release = host / "proc/sys/kernel/osrelease" + release.parent.mkdir(parents=True) + release.write_text("6.1.31", encoding="ascii") + boot_id = host / "proc/sys/kernel/random/boot_id" + boot_id.parent.mkdir(parents=True) + boot_id.write_text("old-boot", encoding="ascii") + return kr.KernelRecovery(data, host_root=host) + + +def test_candidate_validation_and_single_recovery_attempt(tmp_path, monkeypatch): + recovery = make_recovery(tmp_path, monkeypatch) + assert recovery.verify_candidate() is None + assert recovery.prepare_attempt("1.2.0:time") is True + assert recovery.prepare_attempt("1.2.0:time") is False + monkeypatch.setattr(kr.subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(args, 1)) + recovery.arm_and_reboot(reboot=False) + assert recovery.status()["state"] == "pending" + with pytest.raises(RuntimeError, match="no unused"): + recovery.arm_and_reboot(reboot=False) + recovery.host("/proc/sys/kernel/random/boot_id").write_text("new-boot", encoding="ascii") + recovery.host("/proc/sys/kernel/osrelease").write_text(kr.CANDIDATE_RELEASE, encoding="ascii") + policy = recovery.host("/sys/devices/system/cpu/cpufreq/policy0") + policy.mkdir(parents=True) + (policy / "scaling_governor").write_text("schedutil", encoding="ascii") + (policy / "scaling_available_governors").write_text("schedutil performance", encoding="ascii") + recovery.finalize_after_boot(wait_seconds=1) + assert recovery.status()["state"] == "succeeded" + + +def test_recovery_failure_and_damaged_candidate_do_not_retry(tmp_path, monkeypatch): + recovery = make_recovery(tmp_path, monkeypatch) + recovery.host("/boot/Image-matrix-axp313a1").write_bytes(b"damaged") + assert recovery.prepare_attempt("1.2.0:time") is False + assert recovery.status()["state"] == "requires_package" + assert recovery.prepare_attempt("1.2.1:later") is False + + other = make_recovery(tmp_path / "other", monkeypatch) + assert other.prepare_attempt("1.2.0:time") is True + monkeypatch.setattr(kr.subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(args, 1)) + other.arm_and_reboot(reboot=False) + other.host("/proc/sys/kernel/random/boot_id").write_text("new-boot", encoding="ascii") + other.finalize_after_boot(wait_seconds=1) + assert other.status()["state"] == "failed" + assert other.prepare_attempt("1.2.1:later") is False + + +def test_scheduled_recovery_that_never_runs_becomes_persistent_failure(tmp_path, monkeypatch): + recovery = make_recovery(tmp_path, monkeypatch) + assert recovery.prepare_attempt("1.2.0:time") is True + monkeypatch.setattr(kr.time, "time", lambda: recovery.read_record()["recorded_at"] + 181) + assert recovery.status()["state"] == "failed" + assert recovery.read_record()["state"] == "failed" + assert recovery.prepare_attempt("1.2.1:later") is False + + +def test_delayed_recovery_task_does_not_reboot(tmp_path, monkeypatch): + recovery = make_recovery(tmp_path, monkeypatch) + assert recovery.prepare_attempt("1.2.0:time") is True + monkeypatch.setattr(kr.time, "time", lambda: recovery.read_record()["recorded_at"] + 181) + monkeypatch.setattr(kr.subprocess, "run", lambda *args, **kwargs: pytest.fail("late task ran a command")) + recovery.arm_and_reboot() + assert recovery.read_record()["state"] == "failed" + + +def test_ota_rejects_new_cpufreq_loss_and_keeps_preexisting_degradation(): + before = {"release": kr.CANDIDATE_RELEASE, "available": True, "marker": True, + "policies": {"policy0": {"current": "schedutil", "performance": True}}} + after = {**before, "policies": {}, "available": False} + off = {"requested": False, "available": True, "effective": False, + "current_governors": {"policy0": "schedutil"}} + with pytest.raises(UpdateFailed, match="lost the validated"): + verify_kernel_capability_retained(before, after, off, off) + verify_kernel_capability_retained({**before, "available": False}, after, off, off) + with pytest.raises(UpdateFailed, match="governors"): + verify_kernel_capability_retained(before, before, off, {**off, "current_governors": {"policy0": "performance"}}) + + +def test_legacy_worker_completion_schedules_lightweight_recovery(tmp_path, monkeypatch): + class Display: + def start_ota_indicator(self, _path): + pass + + def stop_ota_indicator(self): + pass + + class Recovery: + def __init__(self): + self.attempts = [] + + def status(self): + return {"state": "ready", "reason": ""} + + def prepare_attempt(self, result_id): + self.attempts.append(result_id) + return len(self.attempts) == 1 + + recovery = Recovery() + manager = OtaManager( + code_root=tmp_path / "code", data_root=tmp_path / "data", runtime_root=tmp_path / "run", + software_version=SoftwareVersion.parse("1.2.0"), display=Display(), + ) + monkeypatch.setattr(manager, "_production_recovery", lambda: recovery) + calls = [] + monkeypatch.setattr("app.ota.manager.subprocess.run", lambda command, **kwargs: calls.append(command)) + write_json(manager.status_path, {"schema_version": 1, "active": True, "job": {"id": "old-worker"}}) + assert manager.resume_or_start_monitor(manager.schedule_kernel_recovery_after_ota) + write_last_result(manager.data_root, {"status": "success", "target_version": "1.2.0", + "installed_at": "2026-09-26T08:00:00Z"}) + write_json(manager.status_path, {"schema_version": 1, "active": False, "job": {"id": "old-worker"}}) + manager._monitor_thread.join(timeout=3) + assert recovery.attempts == ["1.2.0:2026-09-26T08:00:00Z"] + assert len(calls) == 1 + assert "systemd-run" in calls[0] + + BOOT_SOURCE = '''# DO NOT EDIT THIS FILE setenv docker_optimizations "on" setenv bootargs "root=/dev/mmcblk1p2" diff --git a/核桃派软件源代码/tests/test_mobile_control.py b/核桃派软件源代码/tests/test_mobile_control.py new file mode 100644 index 0000000..9f34223 --- /dev/null +++ b/核桃派软件源代码/tests/test_mobile_control.py @@ -0,0 +1,167 @@ +import pytest +from fastapi.testclient import TestClient +from app.main import create_app +from app.mobile.session import RpcError + + +def test_identity_persistence_and_rename(tmp_path): + app = create_app(project_root=tmp_path, driver_kind='mock') + mobile = app.state.mobile_control + before = mobile.identity() + renamed = mobile.dispatch('device.rename', {'name': '客厅小屏幕'}) + assert renamed['device_id'] == before['device_id'] + assert renamed['device_name'] == '客厅小屏幕' + other = create_app(project_root=tmp_path, driver_kind='mock') + assert other.state.mobile_control.identity() == renamed + + +def test_web_change_invalidates_mobile_revision(tmp_path): + app = create_app(project_root=tmp_path, driver_kind='mock') + mobile = app.state.mobile_control + settings = mobile.dispatch('settings.get', {}) + client = TestClient(app) + assert client.put('/api/config', json={'brightness': 37}).status_code == 200 + with pytest.raises(RpcError, match='CONFLICT'): + mobile.dispatch('settings.patch', {'expected_revision': settings['revision'], 'changes': {'brightness': 20}}) + revision = mobile.settings()['revision'] + mobile.dispatch('settings.patch', {'expected_revision': revision, 'changes': {'brightness': 25}}) + assert client.get('/api/config').json()['brightness'] == 25 + + +def test_library_and_frame_readback(tmp_path): + app = create_app(project_root=tmp_path, driver_kind='mock') + mobile = app.state.mobile_control + first = mobile.dispatch('library.list', {'limit': 1}) + assert len(first['items']) == 1 and first['next_cursor'] + second = mobile.dispatch('library.list', {'cursor': first['next_cursor'], 'limit': 1}) + assert second['items'][0]['id'] != first['items'][0]['id'] + item = first['items'][0] + assert mobile.dispatch('library.thumbnail', item)['mime'] == 'image/png' + mobile.dispatch('content.play', item) + frame = mobile.dispatch('frame.get', {}) + assert mobile.dispatch('frame.get', {'known_revision': frame['frame_revision']})['unchanged'] + with pytest.raises(RpcError, match='CONFLICT'): + mobile.dispatch('content.play', {**item, 'revision': 'old'}) + + +def test_forbidden_operation_and_settings_fields(tmp_path): + mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control + with pytest.raises(RpcError, match='UNSUPPORTED_CAPABILITY'): + mobile.dispatch('shell.run', {'command': 'anything'}) + with pytest.raises(RpcError, match='BAD_REQUEST'): + mobile.dispatch('settings.patch', {'expected_revision': mobile.settings()['revision'], 'changes': {'low_voltage_protection_enabled': False}}) + + +def test_wifi_open_network_and_password_not_returned(tmp_path): + mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control + revision = mobile.dispatch('wifi.get', {})['revision'] + result = mobile.dispatch('wifi.set', dict(expected_revision=revision, ssid='Test Open', security='open', + password_action='none', ipv4_mode='dhcp', dns_servers=[], activation='next_boot')) + assert mobile.dispatch('task.get', {'task_id': result['task_id']})['state'] == 'succeeded' + wifi = mobile.dispatch('wifi.get', {}) + assert 'password' not in wifi['saved'] + assert not wifi['saved']['password_configured'] + assert wifi['saved']['security'] == 'open' + assert wifi['revision'] != revision + mobile.dispatch('wifi.set', dict(expected_revision=wifi['revision'], ssid='Test Private', security='wpa-psk', + password_action='replace', password='public-test-fixture', ipv4_mode='dhcp', activation='next_boot')) + wifi = mobile.dispatch('wifi.get', {}) + assert 'password' not in wifi['saved'] and wifi['saved']['password_configured'] + + +def test_scan_escaped_ssid_deduplicates_and_marks_unsupported(): + from app.network.manager import NmcliNetworkManager + class Stub(NmcliNetworkManager): + def _run(self, args, *, timeout=10): + assert timeout == 15 and '--rescan' in args + return '*:Test\\:WiFi:WPA2:42\n:Test\\:WiFi:WPA2:81\n:Open:--:37\n:EAP:WPA2 802.1X:80' + rows = Stub().scan() + assert rows[0] == dict(ssid='Test:WiFi', security='wpa-psk', signal_percent=81, connected=True) + assert rows[1]['security'] == 'unsupported' + assert rows[2]['security'] == 'open' + + +def test_mobile_default_and_animation_controls_share_web_state(tmp_path): + app = create_app(project_root=tmp_path, driver_kind='mock') + with TestClient(app) as client: + mobile = app.state.mobile_control + items = mobile.dispatch('library.list', {})['items'] + animation = next(item for item in items if item['type'] == 'animation' and item['playable']) + mobile.dispatch('content.default.set', animation) + assert mobile.dispatch('content.default.get', {})['id'] == animation['id'] + assert client.get('/api/display/default-content').json()['id'] == animation['id'] + playback = client.get('/api/status').json()['state']['animation_playback'] + controlled = mobile.dispatch('playback.patch', dict( + session_id=playback['session_id'], paused=True, position_ms=0, speed=1.5)) + assert controlled['animation_playback']['paused'] + current = client.get('/api/status').json()['state']['animation_playback'] + assert current['position_ms'] == 0 and current['speed'] == 1.5 + mobile.dispatch('content.play', animation) + with pytest.raises(RpcError, match='CONFLICT'): + mobile.dispatch('playback.patch', dict(session_id=playback['session_id'], paused=False)) + + +def test_mobile_library_uses_persistent_mixed_order_and_invalidates_cursor(tmp_path): + from app.demo_library import demo_template + app = create_app(project_root=tmp_path, driver_kind='mock') + mobile = app.state.mobile_control + templates, animations = mobile.templates, mobile.animations + import base64 + t = templates.create('user-template', {'version': 1, 'width': 64, 'height': 64, 'pixelRgb': base64.b64encode(bytes(64*64*3)).decode('ascii'), 'elements': []}) + # Empty animations are valid library items, even though not playable. + a = animations.create('user-animation') + defaults = [{'type': 'template', 'id': t['id']}, {'type': 'animation', 'id': a['id']}] + store = app.state.library_order_store + current = store.get(defaults) + first = mobile.dispatch('library.list', {'limit': 1}) + order = list(reversed(defaults)) + store.update(order, default_items=defaults, expected_revision=current['revision']) + items = mobile.dispatch('library.list', {})['items'] + assert [{'type': i['type'], 'id': i['id']} for i in items if not i['is_demo']] == order + with pytest.raises(RpcError, match='CONFLICT'): + mobile.dispatch('library.list', {'cursor': first['next_cursor']}) + + +@pytest.mark.parametrize('reason,expected', [(53, 'NETWORK_UNAVAILABLE'), (5, 'IP_CONFIG_FAILED'), (11, 'UNKNOWN'), (7, 'UNKNOWN')]) +def test_wifi_failure_classification_does_not_guess_authentication(monkeypatch, reason, expected): + import subprocess + from app.network.manager import NmcliNetworkManager, NetworkManagerError + class Backend(NmcliNetworkManager): + def _run(self, args, *, timeout=10): + if 'up' in args: + raise NetworkManagerError('sanitized') + return str(reason) + ' (reason)' + monkeypatch.setattr(subprocess, 'run', lambda *a, **k: subprocess.CompletedProcess(a, 0, '', '')) + with pytest.raises(NetworkManagerError) as error: + Backend().activate('public-test-id') + assert error.value.error_code == expected + + +def test_wifi_auth_requires_explicit_wrong_key_event(monkeypatch): + import subprocess + from app.network.manager import NmcliNetworkManager, NetworkManagerError + class Backend(NmcliNetworkManager): + def _run(self, args, *, timeout=10): + if 'up' in args: raise NetworkManagerError('sanitized') + return '7 (no-secrets)' + monkeypatch.setattr(subprocess, 'run', lambda *a, **k: subprocess.CompletedProcess(a, 0, + 'wlan0: CTRL-EVENT-SSID-TEMP-DISABLED id=0 ssid="fixture" auth_failures=1 duration=10 reason=WRONG_KEY', '')) + with pytest.raises(NetworkManagerError) as error: + Backend().activate('public-test-id') + assert error.value.error_code == 'AUTH_FAILED' + assert str(error.value) == 'WiFi activation failed' + + +@pytest.mark.parametrize('failure', ['AUTH_FAILED', 'NETWORK_UNAVAILABLE', 'IP_CONFIG_FAILED', 'UNKNOWN']) +def test_task_returns_only_sanitized_failure_code(tmp_path, failure): + from app.network.manager import NetworkManagerError + mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control + def fail(*args, **kwargs): + raise NetworkManagerError('private backend detail must not escape', error_code=failure) + mobile.dispatch('wifi.set', dict(expected_revision=mobile.wifi()['revision'], ssid='Test Open', security='open', password_action='none', ipv4_mode='dhcp', dns_servers=[], activation='next_boot')) + mobile.network.backend.activate = fail + task = 'public-test-task' + mobile._activate(task) + result = mobile.dispatch('task.get', {'task_id': task}) + assert result == {'state': 'failed', 'stage': 'finished', 'error_code': failure} + assert 'private' not in str(result) diff --git a/核桃派软件源代码/tests/test_mobile_protocol.py b/核桃派软件源代码/tests/test_mobile_protocol.py new file mode 100644 index 0000000..53b55a6 --- /dev/null +++ b/核桃派软件源代码/tests/test_mobile_protocol.py @@ -0,0 +1,88 @@ +import json +from pathlib import Path + +import pytest + +from app.mobile.protocol import Handshake, ProtocolError, Reassembler, fragments, json_object +from cryptography.hazmat.primitives.asymmetric import ec + + +@pytest.mark.parametrize("mtu", [23, 64, 247, 517]) +def test_fragments_roundtrip_and_replay(mtu): + data = ("奇妙小屏幕" * 200).encode() + parts = list(fragments(3, 0, data, mtu)) + receiver = Reassembler() + for part in parts[:-1]: + assert receiver.accept(part) is None + assert receiver.accept(parts[-1]) == (3, data) + with pytest.raises(ProtocolError): + receiver.accept(parts[0]) + + +def test_fragment_reordering_and_deadline(): + parts = list(fragments(1, 0, b"hello world", 23)) + with pytest.raises(ProtocolError): + Reassembler().accept(parts[1]) + now = [0.0] + receiver = Reassembler(lambda: now[0]) + receiver.accept(parts[0]) + now[0] = 15 + with pytest.raises(ProtocolError, match="TIMEOUT"): + receiver.accept(parts[1]) + + +def pair(): + client, server = Handshake(), Handshake() + return client.finish(server.hello, server=False), server.finish(client.hello, server=True) + + +def test_bidirectional_encryption_and_replay(): + client, server = pair() + wire = client.encrypt('你好'.encode()) + assert server.decrypt(wire) == '你好'.encode() + assert client.decrypt(server.encrypt(b'reply')) == b'reply' + with pytest.raises(ProtocolError): + server.decrypt(wire) + with pytest.raises(ProtocolError): + server.decrypt(client.encrypt(b'next')) + + +def test_tamper_direction_and_connection_isolation(): + client, server = pair() + wire = client.encrypt(b'command') + with pytest.raises(ProtocolError): + client.decrypt(wire) + with pytest.raises(ProtocolError): + server.decrypt(wire[:-1] + bytes([wire[-1] ^ 1])) + _, other = pair() + with pytest.raises(ProtocolError): + other.decrypt(wire) + + +def test_invalid_hello_and_json(): + handshake = Handshake() + hello = json.loads(handshake.hello) + hello['protocol_major'] = 2 + with pytest.raises(ProtocolError, match='UNSUPPORTED_VERSION'): + handshake.finish(json.dumps(hello).encode(), server=True) + for raw in (b'{"a":1,"a":2}', b'{"a":NaN}', b'[]', b'\xff'): + with pytest.raises(ProtocolError): + json_object(raw) + + +def test_shared_golden_vector(): + path = Path(__file__).resolve().parents[2] / '移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/resources/protocol-v1.json' + vector = json.loads(path.read_text(encoding='utf-8')) + def raw(key): + return bytes.fromhex(vector[key]) + client = Handshake(private_key=ec.derive_private_key(int(vector['client_scalar']), ec.SECP256R1()), random_bytes=raw('client_random')) + server = Handshake(private_key=ec.derive_private_key(int(vector['server_scalar']), ec.SECP256R1()), random_bytes=raw('server_random')) + assert client.hello == raw('client_hello') + assert server.hello == raw('server_hello') + cc, sc = client.finish(server.hello, server=False), server.finish(client.hello, server=True) + assert cc.transcript == raw('transcript') + assert cc.encrypt(raw('request')) == raw('client_record') + assert sc.encrypt(raw('response')) == raw('server_record') + assert sc.decrypt(raw('client_record')) == raw('request') + assert cc.decrypt(raw('server_record')) == raw('response') + assert [p.hex() for p in fragments(3, 0, raw('client_record'), 23)] == vector['fragments_mtu23'] diff --git a/核桃派软件源代码/tests/test_mobile_session.py b/核桃派软件源代码/tests/test_mobile_session.py new file mode 100644 index 0000000..000eeb1 --- /dev/null +++ b/核桃派软件源代码/tests/test_mobile_session.py @@ -0,0 +1,74 @@ +import json +import pytest +from app.mobile.protocol import Handshake, ProtocolError, fragments +from app.mobile.session import SessionManager + + +def connection(clock=lambda: 0): + calls = [] + manager = SessionManager(lambda method, params: calls.append(method) or {}, lambda: {"device_id": "test"}, clock=clock) + assert manager.connect('a') + assert not manager.connect('b') + client = Handshake() + result = None + for part in fragments(1, 0, client.hello, 247): + result = manager.accept('a', part) + cipher = client.finish(result[1], server=False) + return manager, cipher, calls + + +def request(manager, cipher, mid, rid, method, params=None): + raw = json.dumps(dict(id=rid, method=method, params=params or {})).encode() + for part in fragments(3, mid, cipher.encrypt(raw), 247): + result = manager.accept('a', part) + return json.loads(cipher.decrypt(result[1])) + + +def test_no_control_before_open(): + manager, cipher, calls = connection() + with pytest.raises(ProtocolError): + request(manager, cipher, 1, '1', 'settings.patch') + assert calls == [] + + +def test_single_owner_duplicate_and_disconnect(): + manager, cipher, calls = connection() + assert request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机'})['ok'] + assert manager.status()['client_name'] == '手机' + assert request(manager, cipher, 2, '2', 'content.play')['ok'] + assert not request(manager, cipher, 3, '2', 'content.play')['ok'] + assert calls == ['content.play'] + manager.disconnect('b') + assert manager.status()['connected'] + manager.disconnect('a') + assert not manager.status()['connected'] + assert manager.connect('b') + + +def test_handshake_and_idle_deadlines(): + now = [0] + manager, cipher, _ = connection(lambda: now[0]) + now[0] = 10 + assert manager.expired() + manager, cipher, _ = connection(lambda: now[0]) + request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机'}) + now[0] = 29 + assert not manager.expired() + now[0] = 30 + assert manager.expired() + + +def test_negotiated_receive_mtu_resets_on_disconnect(): + manager, cipher, _ = connection() + assert manager.peer_mtu == 23 + assert request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机', 'receive_mtu': 247})['ok'] + assert manager.peer_mtu == 247 + manager.disconnect('a') + assert manager.peer_mtu == 23 + + +@pytest.mark.parametrize('mtu', [0, 22, 518, True, '247']) +def test_invalid_receive_mtu_never_opens_session(mtu): + manager, cipher, _ = connection() + assert not request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机', 'receive_mtu': mtu})['ok'] + assert not manager.opened diff --git a/核桃派软件源代码/tests/test_ota_checkpoints.py b/核桃派软件源代码/tests/test_ota_checkpoints.py index aba5bd0..a1a3b38 100644 --- a/核桃派软件源代码/tests/test_ota_checkpoints.py +++ b/核桃派软件源代码/tests/test_ota_checkpoints.py @@ -133,6 +133,31 @@ def test_failed_install_restores_system_bytes_and_running_state(host): assert ['systemctl', 'start', c.FRP] in host +def test_mobile_rollback_restores_files_masks_and_running_state(host): + journal, record = make_journal() + record['mobile_services'] = { + 'aw859-bluetooth.service': {'enabled_state': 'masked', 'active': False}, + 'bluetooth.service': {'enabled_state': 'enabled', 'active': True}, + } + record['files']['bluetooth-original'] = None + for name in c.MOBILE_FILES: + c.FILES[name].write_bytes(b'changed-by-mobile-install') + c.restore_components(journal, record) + assert not c.FILES['bluetooth-original'].exists() + for name in set(c.MOBILE_FILES) - {'bluetooth-original'}: + assert c.FILES[name].read_bytes() == ('old-' + name).encode() + assert ['systemctl', 'mask', 'aw859-bluetooth.service'] in host + assert ['systemctl', 'enable', 'bluetooth.service'] in host + assert ['systemctl', 'start', 'bluetooth.service'] in host + assert ['systemctl', 'start', 'aw859-bluetooth.service'] not in host + + +def test_legacy_component_journal_does_not_touch_bluetooth(host): + journal, record = make_journal() + c.restore_components(journal, record) + assert not any(unit in args for args in host for unit in c.MOBILE_SERVICES) + + def test_power_loss_between_data_renames_recovers_original(host): journal, record = make_journal() (c.DATA/'user-content').write_bytes(b'unchanged') @@ -181,6 +206,35 @@ def test_normal_migration_does_not_touch_components(host): assert host == [] +def test_mobile_only_patch_enters_durable_component_transaction(host, monkeypatch): + from types import SimpleNamespace + source = c.RELEASES / '1.1.2-mobilepatch' + source.mkdir() + (source / 'VERSION').write_text('1.1.2', encoding='utf-8') + (source / 'scripts').mkdir() + (source / 'scripts/install_mobile_bluetooth.sh').write_text('# test fixture', encoding='utf-8') + candidate = c.DATA.with_name('matrix-screen-controller.ota.mobilepatch') + candidate.mkdir() + c.RUNTIME.mkdir(parents=True, exist_ok=True) + (c.RUNTIME / 'ota-request.json').write_text(json.dumps({ + 'job_id': 'mobilepatch', 'current_version': '1.1.1', 'target_version': '1.1.2' + }), encoding='utf-8') + monkeypatch.setattr(c.os, 'geteuid', lambda: 0, raising=False) + monkeypatch.setattr(c.os, 'uname', lambda: SimpleNamespace(machine='aarch64'), raising=False) + monkeypatch.setattr(c, 'check_installed', lambda *args: None) + monkeypatch.setattr(c, 'protect_runtime', lambda: None) + monkeypatch.setattr(c, 'mirror_permissions', lambda *args: None) + # Host test verifies transaction ordering; POSIX durability is covered by + # real Linux lifecycle checks, not Windows read-only descriptor fsync. + monkeypatch.setattr(c.os, 'fsync', lambda *args: None) + c.begin(source, candidate) + record = json.loads((c.DATA / c.JOURNAL / 'state.json').read_text(encoding='utf-8')) + assert set(record['mobile_services']) == set(c.MOBILE_SERVICES) + assert (candidate / c.JOURNAL / 'state.json').is_file() + assert ['/bin/sh', str(source / 'scripts/install_mobile_bluetooth.sh')] in host + assert not any('install_frpc_system.sh' in str(arg) for args in host for arg in args) + + def release_project(tmp_path, monkeypatch): from scripts import export_release as exporter source = tmp_path / 'software' diff --git a/核桃派软件源代码/tests/test_performance_mode.py b/核桃派软件源代码/tests/test_performance_mode.py index 17ff283..6bd82ff 100644 --- a/核桃派软件源代码/tests/test_performance_mode.py +++ b/核桃派软件源代码/tests/test_performance_mode.py @@ -114,7 +114,7 @@ def test_unavailable_cpufreq_is_reported_without_fake_success(tmp_path): "effective": False, "current_governors": {}, "restore_governors": {}, - "last_error": None, + "last_error": "当前内核未提供 CPU 调频策略;请检查候选内核是否回退", } with pytest.raises(PerformanceModeError, match="unavailable"): manager.transact(True, lambda _enabled: None) diff --git a/核桃派软件源代码/tests/test_repository_hygiene.py b/核桃派软件源代码/tests/test_repository_hygiene.py index e2fe9b8..da61a93 100644 --- a/核桃派软件源代码/tests/test_repository_hygiene.py +++ b/核桃派软件源代码/tests/test_repository_hygiene.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.util +import json from pathlib import Path, PurePosixPath import pytest @@ -110,3 +111,30 @@ def test_repository_contract_has_example_and_ignored_private_file(): assert hygiene.EXAMPLE_CREDENTIAL in paths assert hygiene.PRIVATE_CREDENTIAL not in paths assert hygiene._check_repository_contract(paths, staged=False) == [] + + +def test_mobile_markers_are_checked_even_without_board_credentials(tmp_path, monkeypatch): + monkeypatch.setattr(hygiene, "WORKSPACE_ROOT", tmp_path) + private = tmp_path.joinpath(*hygiene.PRIVATE_MOBILE_REGISTRATION.parts) + private.parent.mkdir(parents=True) + private.write_text(json.dumps({"devices": [{"serial": "FAKE-SERIAL-PRIVATE-123"}]}), encoding="utf-8") + markers = hygiene._private_value_markers() + assert "FAKE-SERIAL-PRIVATE-123" in markers + assert hygiene._text_issues(PurePosixPath("docs/report.md"), "FAKE-SERIAL-PRIVATE-123", markers) + + +def test_corrupt_mobile_registration_fails_closed(tmp_path, monkeypatch): + monkeypatch.setattr(hygiene, "WORKSPACE_ROOT", tmp_path) + private = tmp_path.joinpath(*hygiene.PRIVATE_MOBILE_REGISTRATION.parts) + private.parent.mkdir(parents=True) + private.write_text("broken", encoding="utf-8") + with pytest.raises(hygiene.HygieneError, match="移动端真实登记损坏"): + hygiene._private_value_markers() + + +def test_mobile_private_and_signing_files_are_rejected(): + findings = hygiene.audit_paths( + [hygiene.PRIVATE_MOBILE_REGISTRATION, PurePosixPath("keys/debug.keystore")], + scan_binary=False, + ) + assert len(findings) == 2 diff --git a/核桃派软件源代码/tests/test_wifi.py b/核桃派软件源代码/tests/test_wifi.py index c19d811..34cd6b6 100644 --- a/核桃派软件源代码/tests/test_wifi.py +++ b/核桃派软件源代码/tests/test_wifi.py @@ -7,11 +7,25 @@ import pytest from app.config.store import ConfigStore from app.display.service import DisplayService from app.display.wifi_indicator import render_wifi_indicator -from app.network.manager import MockNetworkManager +from app.network.manager import MockNetworkManager, NmcliNetworkManager from app.network.service import WifiNetworkService, validate_wifi_settings from app.network.store import WifiConfigError, WifiConfigStore +@pytest.mark.parametrize('raw_dns', ['192.0.2.1,192.0.2.2', '192.0.2.1\n192.0.2.2', '192.0.2.1, 192.0.2.2\n192.0.2.1']) +def test_nmcli_multiple_dns_can_roundtrip_without_password(monkeypatch, raw_dns): + backend = NmcliNetworkManager() + values = {'802-11-wireless.ssid': 'test-network', 'ipv4.method': 'manual', + 'ipv4.addresses': '192.0.2.20/24', 'ipv4.gateway': '192.0.2.1', + 'ipv4.dns': raw_dns, '802-11-wireless-security.key-mgmt': 'wpa-psk'} + monkeypatch.setattr(backend, '_connection_value', lambda uuid, field, **kwargs: values.get(field, '')) + saved = backend.read_saved('test-profile') + assert saved['dns_servers'] == ['192.0.2.1', '192.0.2.2'] + checked = validate_wifi_settings(saved, previous=saved) + assert checked['dns_servers'] == saved['dns_servers'] + assert checked['password'] is None + + UUID = "12345678-1234-5678-1234-567812345678" diff --git a/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md b/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md index ed79316..9648166 100644 --- a/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md +++ b/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md @@ -2,6 +2,18 @@ 本文件只维护可重复步骤、分层边界和合格标准,不记录某一次已经通过的结果。任何物理操作都按 `AGENTS.md` 的人工停顿规则执行。 +## 视觉检查的适用范围与启动门禁 + +普通网页前端、手机界面、BLE 控制入口开发未改变驱动或显示输出实现时,不新增实屏视觉验收,优先协议、状态、逻辑帧与驱动统计。不需要视觉检查记“不适用”,不阻塞交付。只有驱动、扫描时序、底层输出改变或具体异常确需视觉证据时才提出。 + +**任何视觉检查开始前必须停止相关步骤、说明原因、询问用户下一步如何启动,并等待本次明确指示。** 不自动启动 DroidCam、摄像头、采集或视觉测试程序,也不主动切换实屏测试图案。专用测试手机的一般授权不替代此门禁。下文已有实屏/摄像头步骤仅在适用且取得本次启动指示后执行;“画面清晰由 Codex 判断”不是提前启动授权。接线、断电和调压仍另行逐阶段等待。 + +## 移动端接入测试 + +NetworkManager DNS 往返:`tests/test_wifi.py::test_nmcli_multiple_dns_can_roundtrip_without_password` 覆盖逗号、换行和混合重复 DNS 返回,要求读回独立地址数组、保持密码即可通过配置校验。实机按移动端运行器的 `--reapply-current-wifi` 显式步骤验证原网络立即应用;不得将该项写成不同网络、密码替换或 DHCP/静态切换全部通过。 + +`TEST-MOBILE-INTEGRATION` 覆盖 `整体开发需求/03_移动端接入需求.md`,详细矩阵见 `移动端相关内容/安卓app/如何安卓测试/README.md`。分别验证公共业务回归、加密与分片、手机单会话、网页顶栏连接提示、WiFi 扫描/切换、资源与刷新统计、真实 systemd 恢复及私有登记门禁。BLE 软件接入本身不默认触发实屏视觉检查。双手机首次由用户安装 APK 辅助,之后相关连接逻辑变动才重测;未实测不得写成通过。 + ## 0. 测试映射 | 测试编号 | 主要覆盖 | 执行位置 | 人工停顿 | @@ -30,6 +42,7 @@ | `TEST-OTA-LOCAL` | 包协议、正式版本与功能时间、版本拒绝、迁移、回滚、进度和前端交互 | Windows / mock | 否 | | `TEST-SYSTEM-INDICATOR-PIXELS` | `DISPLAY-OTA-INDICATOR`、`DISPLAY-WIFI-INDICATOR` 的 OTA、联网 IP 与断网提示像素完整性 | Windows / mock + 核桃派/HUB75 | 用户逐画面确认 | | `TEST-OTA-REAL` | 浏览器上传、40% 更新覆盖、90% 居中画面、真实切换、数据保全和清理 | 核桃派 + HUB75 | 只在画面无法由摄像头判断时确认 | +| `TEST-OTA-KERNEL-RECOVERY` | `DEPLOY-OTA-KERNEL-RECOVERY`、能力退化回滚、候选材料校验、一次重启和结果持久化 | 本机 fake sysfs + 核桃派真实 systemd | 仅候选早期硬锁时断电一次 | | `TEST-RELEASE-VERSION` | OTA/镜像共用版本、IMG 候选/实卡晋升、失败回滚、并发和发布记录 | Windows / mock + Linux | 否 | | `TEST-IMAGE-LOCAL` | FAT16、离线载荷、双槽配置、编辑器和秘密排除 | Windows | 否 | | `TEST-IMAGE-FIRSTBOOT` | 写卡、无外设自动安装、重启、联网、SSH 和网页 | 核桃派,无屏 | 写卡与插卡通电 | @@ -73,6 +86,8 @@ node --test @($tests.FullName) `TEST-CPUFREQ-RECOVERY` 在 fake sysfs 注入多 policy、缺失 policy、单 policy 写失败和配置保存失败,要求已写 governor 全部回滚;所有 mock/fake-register 应用默认使用运行根下不可用的隔离 cpufreq 路径,只有该专项测试可通过工厂注入 fake sysfs,任何 mock 测试不得读取或改写宿主机真实 governor。保留运行根记录后模拟异常重启,不得把遗留 `performance` 当作原 governor。板端候选内核必须识别 `x-powers,axp313a`、注册 `vdd-cpu` DCDC2、消除 `cpufreq-dt` deferred,`policy0` 覆盖 CPU0-3且包含 `performance`;DCDC2 始终处于 `810000..1080000µV`,ALDO1 保持 1.8V、DLDO1 保持常开 3.3V、DCDC3 保持引导器实际设置的 1.10V,修复前后的其他 PMIC 寄存器、GPU 状态和持久数据清单不得有非预期变化。 +`TEST-OTA-KERNEL-RECOVERY` 先在本机 fake sysfs 和隔离 boot/module 树覆盖:健康候选更新前后 policy、标记或 governor 丢失必须令 OTA 回滚;既有原内核回退可完成应用更新,但核对候选 Image、两份 DTB、全部 `.ko` 摘要、原内核备份、受管启动脚本及已启用健康服务后才可安排一次独立重启。缺件、摘要不符或已失败记录不得自动重试;旧 worker 首次升级也须在新服务观察到成功结果后进入同一恢复判断。板端运行 `scripts/test_kernel_recovery_systemd.py`,验证临时主服务停止后独立任务仍可使用受保护运行目录;只清理本次随机命名的临时服务和目录。真实恢复后分别检查 `uname -r`、AXP313A、`vdd-cpu`、`policy0`、健康标记和 `/api/status`,短暂开启性能模式确认 `performance` 后关闭并恢复原 governor;候选失败则检查回退记录并停止再次重启。该逻辑不改变显示输出,视觉检查不适用。 + `TEST-KERNEL-ROLLBACK` 先在隔离目录验证 boot 脚本只出现一个候选管理块、原文件路径保持可达、健康标记在候选启动前删除。板端保留原 `Image`、DTB、模块和 `boot.scr`,候选使用独立名称;健康服务只有在内核版本、AXP313A、cpufreq、服务和 GPIO 全部通过后才重建标记。注入健康失败后必须自动重启到原内核;若候选在健康服务前早期硬锁且硬件看门狗没有重启,停止自动操作并请用户只断电重启一次,随后确认无标记路径进入原内核。 `TEST-WORKSPACE-ORDER` 在相同四类视口检查:侧栏六个项目无“设备/内容”分类标题;叉号下方提供“自定义项目位置”;进入编辑后按钮变为“保存”且项目只显示上下移动操作。首末边界不可用,项目点击不导航,叉号、遮罩和 `Escape` 只提示保存并保持侧栏打开。保存成功后侧栏保持打开,刷新页面、另一浏览器和服务重启均恢复新顺序;请求失败保留内存草稿,未保存时刷新、关闭页面或模拟崩溃后恢复上一次已保存顺序。该测试不连接 HUB75 或 ADC。 diff --git a/移动端相关内容/AGENTS.md b/移动端相关内容/AGENTS.md new file mode 100644 index 0000000..292dd72 --- /dev/null +++ b/移动端相关内容/AGENTS.md @@ -0,0 +1,52 @@ +# 移动端协作指南 + +先读根 `AGENTS.md`,再读本目录 README、开发要求和 Android 详细设计。当前为用户明确授权的首版开发;未来仅在用户明确要求移动端迭代时更新,不因设备端更新而自动修改或发布 App。 + +## 变更顺序 + +1. 修改稳定需求编号及兼容记录。 +2. 协议变化先修改通信契约与跨语言测试向量,再分别修改必要端。 +3. 修改源码、自动测试、构建说明;记录真实验证结果,未运行不得写通过。 +4. 设备侧依赖增删同步离线依赖和 SHA-256;部署沿用根目录门禁与生命周期规则。 + +## 工程边界 + +- Kotlin Multiplatform + Compose Multiplatform,Android 入口、共享业务、共享界面分模块;Android API 不得泄露到 commonMain。 +- 首版 Android minSdk 26;iOS 暂不实现,不声称 Windows 上完成 iOS 编译或蓝牙验收。 +- 设备持久 ID、协议能力和业务命令是连接契约,不依赖蓝牙 MAC、网页布局或私有驱动调用。 +- 只产出 debug APK,正式签名必须有用户明确指令;调试密钥也不进入 Git。 +- 源码、测试、发布说明都在安卓app下;构建缓存、SDK、密钥、真实设备登记不进 Git。 +- Android Studio 属于正常安装软件:下载官方安装包、校验、交给用户安装后停下等待,不安装进 Codex 工具目录。 +- 不擅自关闭 VPN、不自动修改系统设置以修复下载。版本、依赖和 Wrapper 必须锁定并可复建。 + +## 测试手机与秘密 + +真实登记唯一位置为 `安卓app/如何安卓测试/测试设备登记/测试设备.local.json`,提交空白 `测试设备.example.json` 和检查脚本。现有登记不覆盖、不重建。自动选择手机必须使用有效登记,多个设备时不能默认取第一台。设备标识不写日志、报告、示例或提交,报告使用 `android-test-a` 等代号。 + +用户已授权主测试机为专用测试设备,可安装覆盖本 App、清理其测试数据、操作相关权限与蓝牙。授权记录保存在真实登记;不需要常规 root 操作。首次第二台手机互斥验收由用户安装交付 APK 协助,以后相关连接机制改动才要求重测。 + +用户补充要求:手机调试确需 root 时,先停止对话并说明准备请求的权限和用途,等待用户回复开始授权,再发起请求,由用户点击手机授权弹窗。本轮已明确授权用 root 为本 App 授予蓝牙扫描和连接权限;同一授权范围内不重复询问。新用途重新按此步骤处理,不将 root 授权等同于视觉检查授权。 + +## 视觉和物理操作门禁 + +2026-09-25 用户另已明确授权:使用 root 关闭并恢复专用测试手机蓝牙,以验证异常连接恢复。本用途与此前本 App 蓝牙权限授予均可继续,不扩展到其他系统修改;机器重启后须重新检查 ADB、登记身份与设备服务,不沿用旧进程状态。 + +2026-09-25 用户随后明确回复“开始授权,自动点击 App”:允许 root 根据本 App 当前语义控件位置执行点击,以及使用 Home 键测试前后台;不扩展到其他 App、系统安全开关或视觉检查。该用途不再重复询问,真实执行结果分别归档。 + +2026-09-26 用户为 `ANDROID-TEST-WIFI` 另行明确授权 root 完成所需测试:在本 App 设置页按语义识别的可见列表区域滚动,以操作 WiFi 表单。此授权限本次 WiFi 专项,不扩展到其他 App、系统页面或视觉检查。 + +普通前端、手机界面、BLE 控制入口开发未改驱动或显示输出实现时,不新增实屏视觉验收。优先协议、状态、输出帧和驱动统计。只有驱动、扫描时序、底层输出改变或具体显示异常确需视觉证据时才提出。 + +**每次需要视觉检查,必须先停止相关步骤,说明原因,询问用户下一步如何启动,等待明确指示。** 不自动启动 DroidCam、摄像头、采集程序或视觉测试程序,也不主动切换实屏测试图案。专用手机的一般自动化授权不替代本次视觉授权。不需要视觉检查时记录“不适用”,不阻塞交付。 + +断电、接线、拔插、调压仍逐阶段等待用户确认。没有物理结果不得记为实屏通过。 + +## 2026-09-26 用户最新 root 测试授权(替代前述按用途重复询问规则) +本软件调试所需各种测试权限可直接通过登记测试手机的 ADB root 获取,无需询问。所有自动测试点击一律使用 root,根据本 App 实时语义控件位置定位;滚动同样使用 root。不得通过无障碍或普通注入绕过 MIUI。测试前验证登记、ADB 和 root;权限不足或特殊情况必须停止聊天等待用户,不继续不完整测试。授权仅限调试本软件所需操作,不扩大到其他应用数据,视觉与物理操作门禁继续有效。 +ANDROID-TEST-UI 增加四栏、三点菜单、昵称、长按保存设备、WiFi折叠/DHCP/确认;ANDROID-TEST-STATE 增加记忆迁移、忘记、连接中扫描、切换;ANDROID-TEST-WIFI 增加结构化失败文案;ANDROID-TEST-CONTROL 增加设备混合内容顺序。 + +## 测试经验沉淀与完成反馈 + +- 每轮测试前读 `安卓app/如何安卓测试/常见问题与排障.md`;遇到已有症状先沿用已验证方法,不从头试错。 +- 测试中出现失败、临时修正或恢复操作,完成后必须复盘。可复用的已验证解法更新对应测试流程、排障或构建文档;单次日志、耗时和重测结果写测试归档,未知根因明确保留,不把重测通过当作根因证明。 +- 最终反馈须说明经验已写入哪些文档及未解决项;无新增经验时不重复造条目。不仅在聊天里解释,也不能只留一次性报告。遵守根目录秘密与文档路径规则。 diff --git a/移动端相关内容/README.md b/移动端相关内容/README.md new file mode 100644 index 0000000..474158e --- /dev/null +++ b/移动端相关内容/README.md @@ -0,0 +1,25 @@ +# 奇妙小屏幕移动控制器 + +本目录维护独立的 Kotlin Multiplatform(KMP)移动程序。首版是 Android 8.0+ 的 BLE 控制器,未来 iOS 复用业务和 Compose Multiplatform 界面,平台蓝牙与权限分别实现。 + +## 阅读顺序 + +1. [协作约定](AGENTS.md) +2. [移动端需求](开发要求/移动端需求.md):产品行为、范围和平台差异。 +3. [设备通信协议](开发要求/设备通信协议.md):手机与设备的稳定契约。 +4. [安卓详细设计](安卓app/安卓开发详细设计.md):模块、状态、异常与实现边界。 +5. [测试入口](安卓app/如何安卓测试/README.md)、[构建与发布](安卓app/构建与发布/README.md)。 + +设备侧入口为 `../整体开发需求/`、`../核桃派软件源代码/README.md` 和 `../测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md`。 + +## 维护边界 + +- 设备版本、App 版本、协议版本分别管理。用户明确要求移动端功能迭代时才改动或发布 App;更新设备不得顺便升级手机程序。 +- 设备公共接口、驱动或部署调整必须评估移动端影响,记录在[跨端兼容记录](开发要求/跨端兼容与变更记录.md)。不影响契约的驱动内部调整不要求手机更新。 +- 首版不依赖网页 DOM、按钮名称或布局,也不直接操作 GPIO。设备公共业务层统一处理显示保护和状态。 +- 当前只允许 debug 签名。正式签名需要用户单独明确指令。 +- 本文及需求是实施契约,不是验收证据。已完成工作和未完成门槛以[实施状态](安卓app/测试结果归档/实施状态.md)为准。 + +## 视觉检查 + +普通前端、App 界面、BLE 入口开发没有改变驱动或显示输出实现时,不新增实屏视觉验收。协议、输出帧和驱动统计优先。确需视觉检查时先停止相关步骤,说明原因,询问用户如何启动并等待明确指示;不得自动开启摄像头、DroidCam、采集或视觉测试程序,不主动切换实屏测试图案。专用测试手机授权不等于视觉检查授权。不适用时记录“不适用”。 diff --git a/移动端相关内容/安卓app/README.md b/移动端相关内容/安卓app/README.md new file mode 100644 index 0000000..52a7f8a --- /dev/null +++ b/移动端相关内容/安卓app/README.md @@ -0,0 +1,10 @@ +# 奇妙小屏幕 Android + +先读 `../开发要求/移动端需求.md` 和 [详细设计](安卓开发详细设计.md)。工程放 `安卓程序源代码/`,共享逻辑和共享界面也在该工程内,为未来 iOS 保留接口。 + +- [测试流程](如何安卓测试/README.md) +- [测试设备登记](如何安卓测试/测试设备登记/README.md) +- [构建与发布](构建与发布/README.md) +- [实际实施状态](测试结果归档/实施状态.md) + +Android Studio 正常安装完成前不声称已构建 APK。本轮只用 debug 签名。视觉检查须按 `../AGENTS.md` 先询问用户启动方式,专用测试机授权不能替代这一步。 diff --git a/移动端相关内容/安卓app/如何安卓测试/README.md b/移动端相关内容/安卓app/如何安卓测试/README.md new file mode 100644 index 0000000..0581370 --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/README.md @@ -0,0 +1,113 @@ +# Android 可重复测试流程 + +本文件维护步骤与合格标准,不写一次性通过结果。每轮测试前阅读 [常见问题与排障](常见问题与排障.md),优先沿用已验证方法。结果记录到 `../测试结果归档/`。真机先检查 `测试设备登记/prepare_test_device.py`,多机精确选择,日志仅使用代号。源码与 UI 尚未实现的测试不得写成通过。 + +## 1. 门禁 + +- 用户授权主测试机可安装覆盖本 App、清理 App 测试数据、操作权限及蓝牙;自动测试点击使用 root,也不操作其他应用数据。 +- 本软件调试所需测试权限可直接通过登记手机 ADB root 获取。root 或权限不可用时停止并等待用户;不操作其他 App 数据,不扩展视觉检查授权。 +- 真实登记精确忽略,示例为空白。未授权、离线、型号/API 与登记不符时停止相关步骤,不挑另一个设备顶替。 +- 普通前端/BLE 入口改动不要求实屏视觉验收,优先协议、状态、输出帧和驱动统计。确需视觉检查先停止,说明原因,询问用户下一步如何启动,等待本次明确指示;不自动启动 DroidCam、摄像头、采集或视觉测试程序,不主动切换实屏测试图案。专用机授权不能替代这一门禁。不适用记录“不适用”。 +- 断电、接线、调压与第二台手机安装需要用户配合时逐阶段暂停。屏幕和 ADC 已连接不能记作无负载环境。 + +## 2. 测试矩阵 + +| 编号 | 覆盖 | 执行与合格标准 | +|---|---|---| +| ANDROID-TEST-REGISTER | MOBILE-TEST-PRIVACY | 标准库 unittest:缺失/占位/损坏拒绝、已有文件不覆盖、多设备不猜测、未授权拒绝、输出不带标识。 | +| ANDROID-TEST-CONTRACT | MOBILE-SECURITY、COMPAT | Python/Kotlin 黄金向量双向互验;MTU 23、分片、超长、篡改、重放、未知主版本、能力缺失均正确处理;变更不重放。 | +| ANDROID-TEST-STATE | CONNECTION、RECONNECT、BACKGROUND | fake clock/transport 验证串行操作、迟到回调、后台 30 秒、主动断开不重连、死进程超时释放。 | +| ANDROID-TEST-UI | STATUS、LIBRARY、PLAYBACK、SETTINGS、ERRORS | Compose instrumentation:四栏、空数据、错误恢复、字体放大、旋转、冲突保留草稿;稳定 testTag,不靠坐标盲点。 | +| ANDROID-TEST-PERMISSION | PLATFORM、DISCOVERY | UI Automator 验证允许、拒绝、永久拒绝、蓝牙关闭及恢复。API 26/30/31+ 模拟验证分别标注,不能当成真 BLE。 | +| ANDROID-TEST-BLE | DISCOVERY、CONNECTION、SECURITY | 主测试机真实扫描、握手、断开、后台释放、返回重连、设备服务重启、异常断线,全部无需系统配对。 | +| ANDROID-TEST-WIFI | WIFI、WIFI-SECRET | 核桃派扫描、隐藏 SSID 输入、字段校验、立即生效与确认取消、错误密码、切网后 BLE 状态仍可查;无密码回读/日志泄露。实际网络变更保留恢复配置。 | +| ANDROID-TEST-CONTROL | LIBRARY、PLAYBACK、DEFAULT、PREVIEW | 播放/默认/动画控制的返回值、状态与逻辑帧一致;预览单在途;用专用内容验证后恢复此前内容,不启动视觉程序。 | +| ANDROID-TEST-WEB | WEB-COEXIST | 网页修改手机可见,手机修改网页可见;连接短提示与常驻名称正确;初次加载不伪造连接事件;昵称 HTML 不执行。 | +| ANDROID-TEST-TWO-PHONES | CONNECTION | 提供 APK 给用户装第二台;A 连接 B 不能抢占,A 断开 B 可连接,A 异常退出可释放;真实双方结果分别记录。 | +| ANDROID-TEST-PERF | PREVIEW、STATUS | 广播、预览、WiFi 扫描与动画并发,记录实际刷新/截止丢失/CPU/内存,与启用前基线及既有标准比较,不要求新增目测。 | +| ANDROID-TEST-LIFECYCLE | 设备部署 | 真实 systemd 停启、适配器不可用/恢复、持久身份保留、运行目录与密钥失效;蓝牙异常不拖垮显示服务。 | +| ANDROID-TEST-BUILD | RELEASE | 按文档从中文/空格及隔离目录构建,debug 签名、版本、覆盖安装、SHA-256 和 Git 卫生检查通过;不读取正式签名。 | + +## 3. 执行顺序 + +1. 测试登记脚本与仓库卫生检查。 +2. 共享逻辑、设备业务、协议、现有本机回归;失败先修复。 +3. 模拟设备的 Android UI 与权限测试。 +4. 主测试机 BLE/控制/网络/网页联调,再做性能和生命周期。 +5. 首次双手机验收由用户安装交付 APK 协助;日常只用主机,连接仲裁/广播/协议变化才重测。 +6. 隔离重建、最终 debug APK 和脱敏证据归档。 +7. 复盘本轮失败与调整:将已验证解法补到排障指南及对应流程,单次证据保留归档;最终反馈说明更新了哪些经验文档、哪些问题仍未解决。 + +当前可以执行的登记单元测试(从仓库根目录): + +```powershell +python -X utf8 -m unittest discover -s "移动端相关内容/安卓app/如何安卓测试/测试设备登记" -p "test_*.py" +``` + +工程完成后,本文件必须补上经过验证的 Gradle task 与端到端脚本入口;不能先编造不存在的可执行命令。测试脚本应读取私有登记并在进程内传递目标,避免原始 ADB 输出进入报告。 + +### 已提供的真机测试入口 + +用构建脚本生成 `:androidApp:assembleDebug` 和 `:androidApp:assembleDebugAndroidTest` 后: + +```text +python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --adb "<已有ADB路径>" --build "<本轮隔离工程目录>" +``` + +脚本先验证 android-test-a 的 USB 登记,从凭据门禁后的目标核桃派读取广播短名,仅扫描该板,安装覆盖两个 debug APK,再执行 `BleIntegrationTest`。不把临时广播身份写入公开报告。此测试只读状态、设置、内容、帧与 WiFi,不播放内容,不切网;断开后再连接核对持久 ID。运行器先验证 root 并为本 App 授予必要权限;所有点击经 root 按实时语义位置执行。权限未稳定生效必须停止等待用户,不使用普通注入或无障碍备用路径。 + +脚本只有明确 `OK (1 test)` 才返回成功;测试入口存在不代表真实验收通过。结果单独归档。 + +## 4. 证据与恢复 + +开发部署的真实失败恢复测试使用 `deploy_mobile_dev.py --deploy --test-rollback`,必须先结束手机控制会话。它和普通部署一样先保存可用程序副本,然后在安装文件及 systemd 配置完成后故意返回 97,触发同一 EXIT 回滚路径。脚本比较测试前后被部署程序文件、config.json、wifi_config.json 和持久身份的摘要,并确认真实主服务 active;随后仍需执行 BLE/生命周期恢复检查。此选项会短暂停止服务,但不主动切换测试图案,也不等同正式 OTA 或断电回滚测试。恢复副本保留供排查,不能把故意失败当作普通部署完成。 + +每轮记录 App versionCode/源码摘要、设备软件版本/功能时间、协议版本、测试代号、覆盖项、结果、失败原因、恢复结果。输出包含密码或设备真实标识时先脱敏再归档。屏幕视觉不适用与未验证应区分;没有第二台手机时互斥测试标为未验证,不能用两个同机 App 冒充两个 BLE 物理客户端。 + +故障后恢复测试前网络、相关手机设置和设备显示内容;不恢复过期配置覆盖用户在测试期间的新改动。只清理明确属于本次测试的数据,不泛化删除持久资源。 +# 连接稳定性复测 + +`ControllerRecoveryTest` 的测试专用 SimulatedPlatform/SimulatedLink 使用同一分片与 RPC 路径,替换加密为显式测试直通实现,覆盖断线清缓存并重连、未完成请求取消、新旧扫描回调隔离。只在 commonTest 编译,生产扫描与 APK 不包含模拟入口;加密正确性由独立跨语言黄金向量测试覆盖。 + +默认 BLE 回归额外执行真实 `wifi.scan`,断言返回有界且不含密码,不输出网络名称;提交故意过期的设置修订,要求返回 CONFLICT 并继续 ping 成功。不会实际改配置。 + +真实网络激活验证可显式加 `--reapply-current-wifi`:先确认保存配置与当前已连网络相同,保留密码并将该原配置立即应用;轮询任务到成功,核对恢复连接及保存字段不变。可能短暂影响板端 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 因切网中断,等待原网络恢复并核验后再清理,不能把临时断链当成恢复失败或直接删除备份。 + +列表滚动统一使用 root;`--root-swipe` 保留兼容,运行器默认启用;辅助脚本先核对本 App 为前台,并将滑动坐标限制在语义识别的设置列表可见区域。该参数仅用于本 App 列表滚动,不授权其他应用或系统页面操作。一次中断后若板端仍在热点、且上次受保护副本与恢复定时器均存在,可用同一命令加 `--resume` 从静态 IPv4 阶段继续;不得在没有完整备份的情况下使用。 + +`--validate-only` 只执行表单与无效 IPv4 校验,不切网;`--final-check` 只复核原网络的 App、BLE、板端状态及恢复材料清理。所有模式均遵守相同的凭据门禁和手机登记检查。 + +`--verify-current-controls` 用当前修订重新提交现有亮度,要求回读不变;若设备已有活动动画,暂停、提交当前暂停位置和原倍速,finally 恢复原暂停状态。最后核对开机默认未改变。它不选择新内容或测试图案,也不代替改变亮度值、选择其他内容/默认内容的完整验收。无活动动画时明确记录该子项不适用。 + +部分厂商在覆盖安装返回成功后异步重置权限;已授权 `--grant-root` 时脚本最多三次授予本 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 直测分开记录。 + +若 MIUI 拒绝后台启动,测试通过 shell 显式启动本 App;若再拒绝输入注入,不直接修改系统权限。本软件测试默认启用 `--root-input`:根据语义查找到的本 App 控件当前位置发送 root input tap;后台测试仅发送 Home 键。运行器默认启用 root 输入,不提供普通输入备用分支。 + +同时已获得 root 切换手机蓝牙授权时,可再传 `--toggle-bluetooth`:在已连接页面关闭蓝牙,等待连接状态清除、扫描入口恢复,再打开蓝牙并通过 App 扫描和连接原设备;之后继续四栏及后台测试。此参数必须与 `--test activity --root-input` 同用。测试与主机运行器都在 finally 尝试恢复蓝牙,错误不能把手机留在测试关闭状态。 + +`run_ble_test.py` 使用登记手机和凭据门禁指定的开发板;覆盖安装 debug App 与测试 APK。运行器默认启用 `--grant-root`,用于覆盖安装后重新授予此 App 的蓝牙扫描/连接权限。脚本不输出真实手机编号和开发板广播编号。 + +`BleIntegrationTest` 先完成真实加密只读会话,读取状态、容量、设置、内容目录、当前帧和不含密码的网络信息;连续 60 轮间隔 2 秒读取状态与帧,主动断开后再次握手并核对持久设备 ID。随后使用生产 Controller 与真实 BLE 验证四栏数据读取、缩略图队列、短暂后台后返回取消释放、后台 30 秒释放、返回前台仅重连上次设备。测试不修改网络或显示内容。会话故障须保留具体阶段与错误,不把模拟测试结果标为实机通过。 + +`RpcTimeoutTest` 验证内部请求超时关闭传输、返回普通通信异常并拒绝继续使用旧 RPC;这与用户主动取消协程不同,后者不自动重试。 + +## 2026-09-26 用户最新 root 测试授权(替代前述按用途重复询问规则) +本软件调试所需各种测试权限可直接通过登记测试手机的 ADB root 获取,无需询问。所有自动测试点击一律使用 root,根据本 App 实时语义控件位置定位;滚动同样使用 root。不得通过无障碍或普通注入绕过 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_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 保持唤醒等设置,也必须保存、恢复并核验。权限不足停止;安全锁屏不能正常解锁时等待用户,不绕过密码。 +- root 辅助脚本保持 LF 换行;文本通过 App 内临时剪贴板和 root 按键输入,结束清理,不把密码放在命令参数中。 + +排障指南按症状记录 root 命令解析、LF 换行、布局进程重建、语义定位、表单输入、安装权限、BLE 阶段释放及 WiFi 恢复。遇到这些情况先查 [对应解法](常见问题与排障.md),不得重复试用已知不可靠的输入或转储方法。 diff --git a/移动端相关内容/安卓app/如何安卓测试/authorized_app_input.sh b/移动端相关内容/安卓app/如何安卓测试/authorized_app_input.sh new file mode 100644 index 0000000..a34ff80 --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/authorized_app_input.sh @@ -0,0 +1,37 @@ +#!/system/bin/sh +# Installed only for an explicitly authorized root input test. +set -eu +dumpsys activity activities | grep 'topResumedActivity=.*org.qimiaoscreen.controller/' >/dev/null || exit 12 +case "${1:-}" in + tap) + case "${2:-}:${3:-}" in *[!0-9:]*|:|:*|*:) exit 13 ;; esac + su -c "input tap $2 $3" + ;; + swipe) + for value in "${2:-}" "${3:-}" "${4:-}" "${5:-}" "${6:-}"; do + case "$value" in ''|*[!0-9]*) exit 13 ;; esac + done + [ "$6" -ge 100 ] && [ "$6" -le 800 ] || exit 13 + su -c "input swipe $2 $3 $4 $5 $6" + ;; + select-all) su -c 'input keycombination 113 29' ;; + clear-field) + case "${2:-}" in ''|*[!0-9]*) exit 13 ;; esac + [ "$2" -ge 1 ] && [ "$2" -le 512 ] || exit 13 + events='' + index=0 + while [ "$index" -lt "$2" ]; do events="$events 67"; index=$((index + 1)); done + su -c "input keyevent 123; input keyevent$events" + ;; + delete) su -c 'input keyevent KEYCODE_DEL' ;; + hide-keyboard) + if su -c 'dumpsys input_method' | grep -q 'mInputShown=true'; then su -c 'input keyevent KEYCODE_BACK'; fi + ;; + paste) su -c 'input keyevent 279' ;; + back) su -c 'input keyevent KEYCODE_BACK' ;; + home) su -c 'input keyevent KEYCODE_HOME' ;; + bluetooth-disable) su -c 'cmd bluetooth_manager disable' ;; + bluetooth-enable) su -c 'cmd bluetooth_manager enable' ;; + *) exit 14 ;; +esac +echo QMS_INPUT_OK diff --git a/移动端相关内容/安卓app/如何安卓测试/authorized_root_command.sh b/移动端相关内容/安卓app/如何安卓测试/authorized_root_command.sh new file mode 100644 index 0000000..519ca2a --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/authorized_root_command.sh @@ -0,0 +1,2 @@ +#!/system/bin/sh +exec su -c "$*" diff --git a/移动端相关内容/安卓app/如何安卓测试/check_mobile_lifecycle.py b/移动端相关内容/安卓app/如何安卓测试/check_mobile_lifecycle.py new file mode 100644 index 0000000..319d194 --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/check_mobile_lifecycle.py @@ -0,0 +1,83 @@ +"""Authorized real systemd stop/start smoke test; no credentials in output.""" +import argparse +import json +import shlex + +from remote_support import connect, execute + + +REMOTE = r''' +import hashlib, json, subprocess, time, urllib.request +from pathlib import Path +from uuid import uuid4 + +def service(action): + subprocess.run(['systemctl', action, 'matrix-screen-controller.service'], check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=45) + +def status(): + with urllib.request.urlopen('http://127.0.0.1:8080/api/status', timeout=3) as response: + return json.load(response) + +data = Path('/var/lib/matrix-screen-controller') +paths = [data/'config.json', data/'wifi_config.json', data/'mobile/identity.json'] +def fingerprints(): + return {str(p.relative_to(data)): hashlib.sha256(p.read_bytes()).hexdigest() if p.exists() else None for p in paths} + +before = fingerprints() +assert before['config.json'] and before['mobile/identity.json'] +initial = status() +assert not initial['mobile']['connected'], 'Phone must be disconnected before lifecycle test' +sentinel = Path('/run/matrix-screen-controller') / ('mobile-lifecycle-' + uuid4().hex) +sentinel.write_text('preservation-check', encoding='utf-8') +stopped_preserved = False +try: + service('stop') + stopped_preserved = sentinel.exists() +finally: + service('start') +try: + current = None + for _ in range(30): + try: + current = status() + if current.get('mobile', {}).get('available'): + break + except Exception: + pass + time.sleep(1) + result = dict(runtime_preserved_on_stop=stopped_preserved, + runtime_preserved_after_start=sentinel.exists(), + persistent_configuration_unchanged=before == fingerprints(), + mobile_available=bool(current and current.get('mobile', {}).get('available')), + no_phone_session=bool(current and not current.get('mobile', {}).get('connected'))) + print(json.dumps(result)) + assert all(result.values()), 'Lifecycle checks failed' +finally: + sentinel.unlink(missing_ok=True) +''' + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--restart-service', action='store_true', required=True) + parser.parse_args() + client, fields = connect() + try: + code, output, _ = execute(client, 'python3 -c ' + shlex.quote(REMOTE), + password=fields['密码'], timeout=150) + # Only a small explicit boolean schema is allowed out of this test. + lines = [line for line in output.splitlines() if line.startswith('{')] + if lines: + result = json.loads(lines[-1]) + if any(type(value) is not bool for value in result.values()): + raise RuntimeError('Unexpected lifecycle output') + print(json.dumps(result, ensure_ascii=False)) + if code or not lines: + raise RuntimeError('Real lifecycle verification failed; raw output withheld') + finally: + client.close() + + +if __name__ == '__main__': + main() diff --git a/移动端相关内容/安卓app/如何安卓测试/deploy_mobile_dev.py b/移动端相关内容/安卓app/如何安卓测试/deploy_mobile_dev.py new file mode 100644 index 0000000..297ab15 --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/deploy_mobile_dev.py @@ -0,0 +1,158 @@ +"""Explicit development deployment; preserves board data and keeps a rollback copy.""" +from __future__ import annotations +import argparse +import hashlib +import json +from pathlib import Path +import tarfile +import tempfile +from uuid import uuid4 + +from remote_support import ROOT, connect, execute, redact + +FILES = [ + 'app/main.py', 'app/control.py', 'app/network/manager.py', 'app/network/service.py', 'app/network/store.py', + 'app/static/index.html', 'app/static/views/device.js', 'app/static/ui-copy.json', + 'scripts/dedicated_host.py', 'scripts/deploy_walnutpi.sh', 'scripts/install_mobile_bluetooth.sh', + 'scripts/ota_components.py', + 'systemd/matrix-screen-controller.service', 'requirements.txt', 'FEATURE_UPDATED_AT', +] + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--deploy', action='store_true', required=True) + parser.add_argument('--test-rollback', action='store_true', help='Intentionally fail after installation and verify restoration on the real system') + args = parser.parse_args() + client, fields = connect() + token = uuid4().hex[:12] + stage = '/tmp/qms-mobile-stage-' + token + backup = '/opt/matrix-screen-controller.mobile-backup-' + token + source = ROOT / '核桃派软件源代码' + files = FILES + [p.relative_to(source).as_posix() for p in (source / 'app/mobile').glob('*.py')] + def snapshot(): + paths = ['/opt/matrix-screen-controller/' + name for name in files] + paths += ['/var/lib/matrix-screen-controller/' + name for name in ('config.json', 'wifi_config.json', 'mobile/identity.json')] + import shlex + program = ('import hashlib,json;from pathlib import Path;' + 'print(json.dumps({p:hashlib.sha256(Path(p).read_bytes()).hexdigest() if Path(p).is_file() else None for p in ' + + repr(paths) + '}))') + code, output, _ = execute(client, 'python3 -c ' + shlex.quote(program), password=fields['密码']) + if code: + raise RuntimeError('Cannot capture rollback verification snapshot') + return json.loads(output) + before = snapshot() if args.test_rollback else None + dependencies = ROOT / '发布更新相关/其他依赖/aarch64-py311' + try: + with tempfile.TemporaryDirectory(prefix='qms-deploy-') as local: + bundle = Path(local) / 'payload.tar.gz' + with tarfile.open(bundle, 'w:gz') as archive: + for name in files: + archive.add(source / name, arcname='source/' + name) + for pattern in ('cryptography-46.0.5-*.whl', 'dbus_next-0.2.3-*.whl', 'cffi-2.1.1-*.whl', 'pycparser-3.0-*.whl'): + matches = list(dependencies.glob(pattern)) + if len(matches) != 1: + raise RuntimeError('Missing or ambiguous offline wheel') + archive.add(matches[0], arcname='wheels/' + matches[0].name) + digest = hashlib.sha256(bundle.read_bytes()).hexdigest() + sftp = client.open_sftp() + sftp.mkdir(stage) + sftp.put(str(bundle), stage + '/payload.tar.gz') + sftp.close() + script = r''' +set -eu +STAGE=__STAGE__ +BACKUP=__BACKUP__ +TARGET=/opt/matrix-screen-controller +test -d "$TARGET/.venv" +test ! -e "$BACKUP" +printf '%s %s\n' '__DIGEST__' "$STAGE/payload.tar.gz" | sha256sum -c - +tar -xzf "$STAGE/payload.tar.gz" -C "$STAGE" +mkdir "$BACKUP" +cp -a "$TARGET/." "$BACKUP/" +test -x "$BACKUP/.venv/bin/python" +cp -a /etc/systemd/system/matrix-screen-controller.service "$BACKUP/original-systemd.service" +cp -a /etc/bluetooth/main.conf "$BACKUP/original-bluetooth.conf" +systemctl is-enabled bluetooth.service > "$BACKUP/bluetooth.enabled" || true +systemctl is-enabled aw859-bluetooth.service > "$BACKUP/aw859.enabled" || true +for unit in bluetooth aw859-bluetooth; do + config=/etc/systemd/system/$unit.service.d/50-matrix-mobile.conf + if test -f "$config"; then cp -a "$config" "$BACKUP/$unit.dropin"; fi +done +rollback() { + set +e + systemctl stop matrix-screen-controller.service || true + if test -d "$TARGET/.venv" && test ! -e "$STAGE/failed-venv"; then + mv "$TARGET/.venv" "$STAGE/failed-venv" + cp -a "$BACKUP/.venv" "$TARGET/.venv" + fi + cp -a "$BACKUP/app/." "$TARGET/app/" + cp -a "$BACKUP/scripts/." "$TARGET/scripts/" + cp -a "$BACKUP/systemd/." "$TARGET/systemd/" + cp -a "$BACKUP/requirements.txt" "$TARGET/requirements.txt" + cp -a "$BACKUP/FEATURE_UPDATED_AT" "$TARGET/FEATURE_UPDATED_AT" + cp -a "$BACKUP/original-systemd.service" /etc/systemd/system/matrix-screen-controller.service + cp -a "$BACKUP/original-bluetooth.conf" /etc/bluetooth/main.conf + for unit in bluetooth aw859-bluetooth; do + config=/etc/systemd/system/$unit.service.d/50-matrix-mobile.conf + if test -f "$BACKUP/$unit.dropin"; then cp -a "$BACKUP/$unit.dropin" "$config"; else rm -f -- "$config"; fi + done + systemctl daemon-reload + if ! grep -qx enabled "$BACKUP/bluetooth.enabled"; then systemctl disable --now bluetooth.service || true; fi + if ! grep -qx enabled "$BACKUP/aw859.enabled"; then systemctl disable --now aw859-bluetooth.service || true; fi + systemctl start matrix-screen-controller.service || true +} +COMMITTED=0 +trap 'if test "$COMMITTED" = 0; then rollback; fi' EXIT +trap 'exit 1' HUP INT TERM +"$TARGET/.venv/bin/python" -m pip install --no-index --find-links "$STAGE/wheels" cryptography==46.0.5 dbus-next==0.2.3 +systemctl stop matrix-screen-controller.service +cp -a "$STAGE/source/." "$TARGET/" +/bin/sh "$TARGET/scripts/install_mobile_bluetooth.sh" +install -m 0644 "$TARGET/systemd/matrix-screen-controller.service" /etc/systemd/system/matrix-screen-controller.service +systemctl daemon-reload +__FAULT_INJECTION__ +systemctl start matrix-screen-controller.service +attempt=0 +while test "$attempt" -lt 25; do + if curl --fail --silent http://127.0.0.1:8080/api/status > "$STAGE/status.json"; then + python3 - "$STAGE/status.json" <<'PY' +import json, sys +status = json.load(open(sys.argv[1], encoding='utf-8')) +print(json.dumps({'service_active': True, 'mobile_available': status.get('mobile', {}).get('available'), 'mobile_reason': status.get('mobile', {}).get('reason')}, ensure_ascii=False)) +PY + echo "Rollback copy retained: $BACKUP" + COMMITTED=1 + trap - EXIT HUP INT TERM + exit 0 + fi + attempt=$((attempt + 1)) + sleep 1 +done +exit 1 +'''.replace('__STAGE__', stage).replace('__BACKUP__', backup).replace('__DIGEST__', digest).replace('__FAULT_INJECTION__', 'echo "Intentional rollback test failure"; exit 97' if args.test_rollback else ':') + sftp = client.open_sftp() + with sftp.open(stage + '/deploy.sh', 'w') as stream: + stream.write(script) + sftp.close() + code, output, error = execute(client, '/bin/sh ' + stage + '/deploy.sh', password=fields['密码'], timeout=240) + print(redact(output, fields)) + # Raw package errors do not contain credentials; still redact known private fields. + print(redact(error, fields)) + if args.test_rollback: + if code != 97 or snapshot() != before: + raise RuntimeError('Rollback verification failed; retained backup requires inspection') + code, output, _ = execute(client, 'systemctl is-active matrix-screen-controller.service', timeout=15) + if code or output.strip() != 'active': + raise RuntimeError('Rollback restored files but service is not active') + print('Intentional failure rolled back: program and persistent file digests unchanged; real service active.') + return + if code: + raise RuntimeError('Deployment failed; inspect retained rollback copy') + print('Development deployment finished; BLE validation is still required.') + finally: + client.close() + + +if __name__ == '__main__': + main() diff --git a/移动端相关内容/安卓app/如何安卓测试/remote_support.py b/移动端相关内容/安卓app/如何安卓测试/remote_support.py new file mode 100644 index 0000000..63fcde7 --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/remote_support.py @@ -0,0 +1,64 @@ +"""Private, pinned SSH access for this workspace; never expose credential values.""" +from __future__ import annotations + +import importlib.util +import os +from pathlib import Path +import re + +import paramiko + + +ROOT = Path(__file__).resolve().parents[3] + + +def connect(): + gate_path = ROOT / "测试相关资料/核桃派的用户名和密码和ip/prepare_credentials.py" + spec = importlib.util.spec_from_file_location("qms_credentials", gate_path) + gate = importlib.util.module_from_spec(spec) + spec.loader.exec_module(gate) + if not gate.ensure_credentials(gate_path.parent): + raise RuntimeError("设备凭据门禁未通过") + fields = gate._parse_fields(gate_path.with_name(gate.PRIVATE_NAME)) + host = fields["IP"].strip() + port_match = re.search(r"(?:^|\s)-p\s+(\d+)", fields["SSH"]) + port = int(port_match.group(1)) if port_match else 22 + trust = Path.home() / ".codex-tools/tmp/qms-mobile-known_hosts" + trust.parent.mkdir(parents=True, exist_ok=True) + client = paramiko.SSHClient() + if trust.exists(): + client.load_host_keys(str(trust)) + # First-use trust is pinned in a private local store. Changed keys are + # rejected by Paramiko before this policy can be invoked. + class PinFirstUse(paramiko.MissingHostKeyPolicy): + def missing_host_key(self, ssh, hostname, key): + ssh.get_host_keys().add(hostname, key.get_name(), key) + ssh.save_host_keys(str(trust)) + client.set_missing_host_key_policy(PinFirstUse()) + try: + client.connect(host, port=port, username=fields["用户名"], password=fields["密码"], + look_for_keys=False, allow_agent=False, timeout=10, + banner_timeout=10, auth_timeout=10) + except Exception: + client.close() + raise RuntimeError("SSH 连接或主机密钥检查失败;未输出凭据") from None + return client, fields + + +def redact(text, fields): + values = sorted((v for v in fields.values() if len(v) >= 4), key=len, reverse=True) + for value in values: + text = text.replace(value, "[private]") + return text + + +def execute(client, command, *, password=None, timeout=30): + if password is not None: + import shlex + command = "sudo -S -p '' -- sh -c " + shlex.quote(command) + stdin, stdout, stderr = client.exec_command(command, timeout=timeout) + if password is not None: + stdin.write(password + "\n") + stdin.flush() + out, err = stdout.read().decode("utf-8", "replace"), stderr.read().decode("utf-8", "replace") + return stdout.channel.recv_exit_status(), out, err diff --git a/移动端相关内容/安卓app/如何安卓测试/run_ble_test.py b/移动端相关内容/安卓app/如何安卓测试/run_ble_test.py new file mode 100644 index 0000000..f5889a5 --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/run_ble_test.py @@ -0,0 +1,150 @@ +"""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 +from remote_support import connect, execute + + +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) + 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") + 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') + + +if __name__ == '__main__': + main() diff --git a/移动端相关内容/安卓app/如何安卓测试/run_ui_layout_test.py b/移动端相关内容/安卓app/如何安卓测试/run_ui_layout_test.py new file mode 100644 index 0000000..d291445 --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/run_ui_layout_test.py @@ -0,0 +1,18 @@ +"""Use the persistent Android semantic test with root input and restored settings.""" +from pathlib import Path +import argparse +import subprocess +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 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/移动端相关内容/安卓app/如何安卓测试/run_wifi_switch_test.py b/移动端相关内容/安卓app/如何安卓测试/run_wifi_switch_test.py new file mode 100644 index 0000000..b6a559d --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/run_wifi_switch_test.py @@ -0,0 +1,517 @@ +"""Run the single real Android WiFi switch matrix with a timed board recovery path. + +The hotspot and saved WiFi secrets stay in this process and in short-lived private +test files. Never print raw ADB, NetworkManager, SSH, or instrumentation output. +""" +from __future__ import annotations + +import argparse +import base64 +import importlib.util +import ipaddress +import json +import logging +from pathlib import Path +import re +import secrets +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 + +logging.getLogger("paramiko").setLevel(logging.CRITICAL) + + +def hotspot_configuration() -> dict: + script = ( + '$ErrorActionPreference="Stop"; ' + '$n=[Windows.Networking.Connectivity.NetworkInformation,Windows.Networking.Connectivity,ContentType=WindowsRuntime]; ' + '$t=[Windows.Networking.NetworkOperators.NetworkOperatorTetheringManager,Windows.Networking.NetworkOperators,ContentType=WindowsRuntime]; ' + '$m=$t::CreateFromConnectionProfile($n::GetInternetConnectionProfile()); ' + 'if($m.TetheringOperationalState.ToString() -ne "On"){throw "hotspot off"}; ' + '$c=$m.GetCurrentAccessPointConfiguration(); ' + '$a=@(Get-NetAdapter -IncludeHidden|Where-Object {$_.InterfaceDescription -like "*Wi-Fi Direct Virtual Adapter*"}); ' + '$ips=@($a|ForEach-Object {Get-NetIPAddress -InterfaceIndex $_.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue}|Where-Object {$_.PrefixLength -eq 24}); ' + 'if($ips.Count -ne 1){throw "hotspot address ambiguous"}; ' + '$j=@{ssid=$c.Ssid;password=$c.Passphrase;gateway=$ips[0].IPAddress;prefix=$ips[0].PrefixLength}|ConvertTo-Json -Compress; ' + '[Console]::Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($j)))' + ) + result = subprocess.run(["powershell.exe", "-NoProfile", "-Command", script], + capture_output=True, timeout=20) + if result.returncode: + raise RuntimeError("电脑热点配置无法安全读取") + values = json.loads(base64.b64decode(result.stdout.strip())) + if not values["ssid"] or not 8 <= len(values["password"].encode("utf-8")) <= 63: + raise RuntimeError("电脑热点配置不满足测试条件") + gateway = ipaddress.ip_address(values["gateway"]) + if not isinstance(gateway, ipaddress.IPv4Address) or values["prefix"] != 24: + raise RuntimeError("电脑热点 IPv4 参数不满足测试条件") + return values + + +def hotspot_client_ips(network: ipaddress.IPv4Network, gateway: str) -> list[str]: + if network is None: + return [] + script = ( + '$n=[Windows.Networking.Connectivity.NetworkInformation,Windows.Networking.Connectivity,ContentType=WindowsRuntime];' + '$t=[Windows.Networking.NetworkOperators.NetworkOperatorTetheringManager,Windows.Networking.NetworkOperators,ContentType=WindowsRuntime];' + '$m=$t::CreateFromConnectionProfile($n::GetInternetConnectionProfile());' + '$x=@();foreach($c in $m.GetTetheringClients()){foreach($h in $c.HostNames){$x+=$h.CanonicalName}};' + '$j=$x|ConvertTo-Json -Compress;' + '[Console]::Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($j)))' + ) + result = subprocess.run(["powershell.exe", "-NoProfile", "-Command", script], + capture_output=True, timeout=20) + if result.returncode or not result.stdout.strip(): + return [] + names = json.loads(base64.b64decode(result.stdout.strip())) + if isinstance(names, str): + names = [names] + addresses = [] + for name in names or []: + try: + address = ipaddress.ip_address(name) + except ValueError: + continue + if address in network and str(address) != gateway: + addresses.append(str(address)) + return addresses + + +def original_configuration(client, fields: dict) -> dict: + remote = r'''python3 - <<'PY' +import base64,configparser,json,pathlib,subprocess +u=json.load(open('/var/lib/matrix-screen-controller/wifi_config.json'))['managed_connection_uuid'] +assert u +names=['802-11-wireless.ssid','802-11-wireless-security.psk','ipv4.method','ipv4.addresses','ipv4.gateway','ipv4.dns'] +d={name:subprocess.check_output(['nmcli','-s','-g',name,'connection','show',u],text=True).strip() for name in names} +d['uuid']=u +for directory in ('/etc/NetworkManager/system-connections','/run/NetworkManager/system-connections'): + for path in pathlib.Path(directory).glob('*'): + if not path.is_file(): continue + cfg=configparser.ConfigParser(interpolation=None,strict=False) + try: cfg.read(path,encoding='utf-8') + except Exception: continue + if cfg.get('connection','uuid',fallback='')==u: d['path']=str(path) + if cfg.get('connection','uuid',fallback='')==u and not d['802-11-wireless-security.psk']: + d['802-11-wireless-security.psk']=cfg.get('wifi-security','psk',fallback='') +identity=json.load(open('/var/lib/matrix-screen-controller/mobile/identity.json')) +d['board_name']='QMS-'+identity['device_id'].replace('-','')[:8] +assert d.get('path') +print(base64.b64encode(json.dumps(d,ensure_ascii=False).encode()).decode()) +PY''' + code, out, _ = execute(client, remote, password=fields["密码"], timeout=50) + if code: + raise RuntimeError("不能安全读取受管网络恢复配置") + values = json.loads(base64.b64decode(out.strip())) + if not values["802-11-wireless-security.psk"]: + raise RuntimeError("原网络缺少可恢复的密钥") + if values["ipv4.method"] not in ("manual", "auto"): + raise RuntimeError("原网络 IPv4 模式不受测试恢复器支持") + return values + + +def resume_configuration(client, fields: dict) -> tuple[dict, str, str]: + current = original_configuration(client, fields) + remote = r'''python3 - <<'PY' +import base64,configparser,json,pathlib +roots=list(pathlib.Path('/var/lib/matrix-screen-controller').glob('.wifi-test-restore-*')) +assert len(roots)==1 +root=roots[0] +cfg=configparser.ConfigParser(interpolation=None) +assert cfg.read(root/'original.nmconnection',encoding='utf-8') +address=cfg.get('ipv4','address1',fallback='') +parts=address.split(',',1) +d={ + 'root':str(root),'unit':'qms-wifi-test-restore-'+root.name.rsplit('-',1)[-1], + 'ssid':cfg['wifi']['ssid'],'psk':cfg['wifi-security']['psk'], + 'mode':cfg['ipv4']['method'],'address':parts[0], + 'gateway':parts[1] if len(parts)>1 else '', + 'dns':cfg.get('ipv4','dns',fallback='').replace(';',',').strip(','), + 'uuid':cfg['connection']['uuid'], +} +print(base64.b64encode(json.dumps(d,ensure_ascii=False).encode()).decode()) +PY''' + code, out, _ = execute(client, remote, password=fields["密码"]) + if code: + raise RuntimeError("无法读取上次测试的受保护恢复副本") + backup = json.loads(base64.b64decode(out.strip())) + if backup["uuid"] != current["uuid"] or not backup["psk"]: + raise RuntimeError("恢复副本与受管网络不匹配") + current.update({ + "802-11-wireless.ssid": backup["ssid"], + "802-11-wireless-security.psk": backup["psk"], + "ipv4.method": backup["mode"], + "ipv4.addresses": backup["address"], + "ipv4.gateway": backup["gateway"], + "ipv4.dns": backup["dns"], + }) + return current, backup["root"], backup["unit"] + + +def checked_remote(client, command: str, fields: dict, *, timeout=35) -> str: + code, out, _ = execute(client, command, password=fields["密码"], timeout=timeout) + if code: + raise RuntimeError("板端测试保护操作失败") + return out.strip() + + +def adb_call(adb: Path, serial: str, *arguments: str, timeout=40) -> str: + result = subprocess.run([str(adb), "-s", serial, *arguments], + capture_output=True, timeout=timeout) + if result.returncode: + raise RuntimeError("登记手机的 ADB 操作失败") + return result.stdout.decode("utf-8", "replace") + + +def connect_at(ip: str, expected_key, fields: dict): + import paramiko + class ExactKey(paramiko.MissingHostKeyPolicy): + def missing_host_key(self, ssh, hostname, key): + if key.get_name() != expected_key.get_name() or key.asbytes() != expected_key.asbytes(): + raise RuntimeError("切网后 SSH 主机密钥不匹配") + client = paramiko.SSHClient() + client.set_missing_host_key_policy(ExactKey()) + try: + port = int(re.search(r"(?:^|\s)-p\s+(\d+)", fields["SSH"]).group(1)) if re.search(r"(?:^|\s)-p\s+(\d+)", fields["SSH"]) else 22 + client.connect(ip, port=port, username=fields["用户名"], password=fields["密码"], + look_for_keys=False, allow_agent=False, timeout=8, + banner_timeout=8, auth_timeout=8) + return client + except Exception: + client.close() + raise RuntimeError("切网后无法通过已核验的 SSH 主机密钥连接") from None + + +def upload_text(client, text: str, destination: str) -> None: + import io + with client.open_sftp() as sftp: + sftp.putfo(io.BytesIO(text.encode("utf-8")), destination) + + +def prepare_recovery(client, fields: dict, original: dict, *, minutes: int = 15) -> tuple[str, str]: + suffix = secrets.token_hex(5) + root = f"/var/lib/matrix-screen-controller/.wifi-test-restore-{suffix}" + unit = f"qms-wifi-test-restore-{suffix}" + source = original["path"] + if not source.startswith(("/etc/NetworkManager/system-connections/", "/run/NetworkManager/system-connections/")): + raise RuntimeError("受管网络文件不在预期目录") + checked_remote(client, f"install -d -m 700 '{root}' && install -m 600 '{source}' '{root}/original.nmconnection'", fields) + script = ("#!/bin/sh\nset -eu\n" + f"install -m 600 '{root}/original.nmconnection' '{source}'\n" + "nmcli connection reload\n" + f"nmcli --wait 30 connection up uuid '{original['uuid']}' >/dev/null 2>&1 || true\n") + upload_text(client, script, f"/tmp/{unit}.sh") + checked_remote(client, f"install -m 700 '/tmp/{unit}.sh' '{root}/restore.sh' && rm -f '/tmp/{unit}.sh' && sh -n '{root}/restore.sh'", fields) + checked_remote(client, f"systemd-run --unit='{unit}' --on-active={minutes}m /bin/sh '{root}/restore.sh' >/dev/null && systemctl is-active '{unit}.timer'", fields) + return root, unit + + +def prepare_boot_recovery(client, fields: dict, root: str, unit: str) -> None: + unit += "-boot" + service = f"[Unit]\nDescription=Temporary Android WiFi test recovery\n[Service]\nType=oneshot\nExecStart=/bin/sh {root}/restore.sh\n" + timer = f"[Unit]\nDescription=Temporary Android WiFi test recovery after reboot\n[Timer]\nOnBootSec=4min\nUnit={unit}.service\n[Install]\nWantedBy=timers.target\n" + upload_text(client, service, f"/tmp/{unit}.service") + upload_text(client, timer, f"/tmp/{unit}.timer") + checked_remote(client, f"install -m 644 '/tmp/{unit}.service' '/etc/systemd/system/{unit}.service' && install -m 644 '/tmp/{unit}.timer' '/etc/systemd/system/{unit}.timer' && rm -f '/tmp/{unit}.service' '/tmp/{unit}.timer' && systemctl daemon-reload && systemctl enable '{unit}.timer' >/dev/null", fields) + + +def clean_recovery(client, fields: dict, root: str, unit: str) -> None: + # These exact paths are created by this runner. Leave them if cleanup fails. + command = (f"systemctl disable --now '{unit}-boot.timer' >/dev/null 2>&1 || true; " + f"systemctl stop '{unit}.timer' '{unit}-continued.timer' >/dev/null 2>&1 || true; " + f"systemctl stop '{unit}.service' >/dev/null 2>&1 || true; " + f"rm -f '/etc/systemd/system/{unit}-boot.service' '/etc/systemd/system/{unit}-boot.timer'; " + "systemctl daemon-reload; " + f"rm -f '{root}/restore.sh' '{root}/original.nmconnection'; rmdir '{root}'") + checked_remote(client, command, fields) + + +def verify_restored(client, fields: dict, root: str) -> bool: + remote = f'''python3 - <<'PY' +import configparser,hashlib,pathlib,subprocess +backup=pathlib.Path('{root}/original.nmconnection') +cfg=configparser.ConfigParser(interpolation=None);cfg.read(backup,encoding='utf-8') +u=cfg['connection']['uuid'] +matches=[] +for directory in ('/etc/NetworkManager/system-connections','/run/NetworkManager/system-connections'): + for path in pathlib.Path(directory).glob('*'): + if not path.is_file(): continue + current=configparser.ConfigParser(interpolation=None) + try: current.read(path,encoding='utf-8') + except Exception: continue + if current.get('connection','uuid',fallback='')==u: matches.append(path) +active=subprocess.check_output(['nmcli','-g','GENERAL.CONNECTION','device','show','wlan0'],text=True).strip() +saved=subprocess.check_output(['nmcli','-g','802-11-wireless.ssid','connection','show',u],text=True).strip() +print(int(len(matches)==1 and hashlib.sha256(matches[0].read_bytes()).digest()==hashlib.sha256(backup.read_bytes()).digest() and saved==cfg['wifi']['ssid'] and active==cfg['connection']['id'])) +PY''' + return checked_remote(client, remote, fields) == "1" + + +def wifi_snapshot(client, fields: dict) -> dict: + remote = "cd /opt/matrix-screen-controller && .venv/bin/python -c 'import json; from app.network.manager import NmcliNetworkManager; u=json.load(open(\"/var/lib/matrix-screen-controller/wifi_config.json\"))[\"managed_connection_uuid\"]; n=NmcliNetworkManager(); print(json.dumps({\"saved\":n.read_saved(u),\"active\":n.read_active()},ensure_ascii=False))'" + return json.loads(checked_remote(client, remote, fields, timeout=30)) + + +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("--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") + parser.add_argument("--wrong-only", action="store_true", help="Verify the failure branch and restore the original profile") + 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() + 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") + phone = next(d for d in load_registration() if d["alias"] == "android-test-a") + check_connected(str(args.adb), phone) + root_check = subprocess.run([str(args.adb), '-s', phone['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 权限不可用;停止测试,等待用户处理') + local_only = args.validate_only or args.final_check or args.wrong_only + hotspot = dict(ssid="", password="", gateway="", prefix=24) if local_only else hotspot_configuration() + network = None + static_address = "" + if not local_only: + network = ipaddress.ip_network(f"{hotspot['gateway']}/{hotspot['prefix']}", strict=False) + static_address = str(network.network_address + 250) + if ipaddress.ip_address(static_address) not in network or static_address == hotspot["gateway"]: + raise RuntimeError("电脑热点静态地址候选不满足安全条件") + network_check = subprocess.run([ + "powershell.exe", "-NoProfile", "-Command", + f"$g=Get-NetIPAddress -IPAddress '{hotspot['gateway']}' -AddressFamily IPv4 -ErrorAction SilentlyContinue; " + f"$n=Get-NetNeighbor -IPAddress '{static_address}' -ErrorAction SilentlyContinue; " + "if($g.PrefixLength -eq 24 -and ($null -eq $n -or $n.State -eq 'Unreachable')){exit 0}else{exit 1}", + ], capture_output=True, timeout=15) + if network_check.returncode: + raise RuntimeError("热点网段或静态地址候选不满足安全条件") + if args.resume: + gate_path = Path(__file__).resolve().parents[3] / "测试相关资料/核桃派的用户名和密码和ip/prepare_credentials.py" + spec = importlib.util.spec_from_file_location("qms_credentials", gate_path) + gate = importlib.util.module_from_spec(spec) + spec.loader.exec_module(gate) + if not gate.ensure_credentials(gate_path.parent): + raise RuntimeError("设备凭据门禁未通过") + fields = gate._parse_fields(gate_path.with_name(gate.PRIVATE_NAME)) + current_ip = adb_call(args.adb, phone["serial"], "shell", "run-as", "org.qimiaoscreen.controller", "cat", "files/qms-wifi-test-ip.txt").strip() + if ipaddress.ip_address(current_ip) not in network: + raise RuntimeError("当前设备地址不是电脑热点网段") + import paramiko + keys = paramiko.HostKeys() + keys.load(str(Path.home() / ".codex-tools/tmp/qms-mobile-known_hosts")) + expected_key = next(iter(keys.lookup(fields["IP"].strip()).values())) + board = connect_at(current_ip, expected_key, fields) + original, backup_root, backup_unit = resume_configuration(board, fields) + else: + board, fields = connect() + expected_key = board.get_transport().get_remote_server_key() + original = original_configuration(board, fields) + current_ip = fields["IP"].strip() + backup_root = backup_unit = None + if not local_only and hotspot["ssid"] == original["802-11-wireless.ssid"]: + raise RuntimeError("电脑热点与原网络相同,不能进行切换测试") + host_secret = None + completed: list[str] = [] + try: + addresses = original["ipv4.addresses"].splitlines() + if original["ipv4.method"] == "manual" and not addresses: + raise RuntimeError("原静态地址无法恢复") + original_address, _, original_prefix = (addresses[0] if addresses else "").partition("/") + if args.wrong_only: + hotspot = dict(ssid=original['802-11-wireless.ssid'], password=original['802-11-wireless-security.psk'], + gateway=original['ipv4.gateway'], prefix=int(original_prefix or '24')) + values = { + "board_name": original["board_name"], + "hotspot_ssid": hotspot["ssid"], "hotspot_password": hotspot["password"], + "original_ssid": original["802-11-wireless.ssid"], + "original_password": original["802-11-wireless-security.psk"], + "original_mode": original["ipv4.method"], "original_address": original_address, + "original_prefix": original_prefix or "24", + "original_gateway": original["ipv4.gateway"], + "original_dns": ",".join(re.split(r"[,\n]+", original["ipv4.dns"])), + "static_address": static_address, "hotspot_gateway": hotspot["gateway"], + "wrong_password": "qms-invalid-password-2026", + } + private_tmp = Path.home() / ".codex-tools/tmp" + private_tmp.mkdir(parents=True, exist_ok=True) + path = private_tmp / ("qms-wifi-test-" + secrets.token_hex(8) + ".json") + path.write_text(json.dumps(values, ensure_ascii=False), encoding="utf-8") + host_secret = path + test_apk = args.build / "androidApp/build/outputs/apk/androidTest/debug/androidApp-debug-androidTest.apk" + if not test_apk.is_file(): + raise RuntimeError("专项 Android 测试 APK 不存在") + adb_call(args.adb, phone["serial"], "install", "-r", str(test_apk), timeout=120) + adb_call(args.adb, phone["serial"], "push", str(path), "/data/local/tmp/qms-wifi-test-private.json") + adb_call(args.adb, phone["serial"], "shell", "chmod", "600", "/data/local/tmp/qms-wifi-test-private.json") + helper = Path(__file__).with_name("authorized_app_input.sh") + adb_call(args.adb, phone["serial"], "push", str(helper), "/data/local/tmp/qms-authorized-input.sh") + + def run_stage(stage: str) -> None: + adb_call(args.adb, phone["serial"], "shell", "am", "force-stop", "org.qimiaoscreen.controller") + output = adb_call(args.adb, phone["serial"], "shell", "am", "instrument", "-w", "-r", + "-e", "wifi_stage", stage, "-e", "root_swipe", str(args.root_swipe).lower(), "-e", "class", + "org.qimiaoscreen.controller.WifiSwitchTest", + "org.qimiaoscreen.controller.test/androidx.test.runner.AndroidJUnitRunner", + timeout=180) + if "OK (1 test)" not in output: + safe = output + for private in sorted({*map(str, values.values()), *map(str, fields.values()), phone["serial"]}, key=len, reverse=True): + if len(private) >= 4: + safe = safe.replace(private, "[private]") + lines = [line for line in safe.splitlines() if any(marker in line for marker in + ("UI tap", "Exception", "Error", "Assertion", "WifiSwitchTest.kt", "INSTRUMENTATION_CODE", "Failure", "java.lang", "androidx.test"))] + print("脱敏诊断:" + " | ".join((lines[:5] + lines[-8:]))[:1800], flush=True) + raise RuntimeError(f"App WiFi 专项阶段失败:{stage};原始日志已抑制") + if stage == 'wrong': + print('认证错误明确分类:' + str('AUTH_FAILED_CONFIRMED' in output), flush=True) + completed.append(stage) + print(f"阶段通过:{stage}", flush=True) + + def reconnect_hotspot(preferred: str | None = None): + for _ in range(12): + candidates = ([preferred] if preferred else []) + hotspot_client_ips(network, hotspot["gateway"]) + for address in dict.fromkeys(x for x in candidates if x): + try: + return address, connect_at(address, expected_key, fields) + except Exception: + pass + time.sleep(5) + raise RuntimeError("热点切换后无法找到已核验主机密钥的核桃派") + + if args.final_check: + run_stage("verify") + snapshot = wifi_snapshot(board, fields) + if (snapshot["saved"]["ssid"] != original["802-11-wireless.ssid"] + or snapshot["active"]["ssid"] != original["802-11-wireless.ssid"] + or not snapshot["active"]["connected"]): + raise RuntimeError("最终板端原网络状态不一致") + checked_remote(board, "systemctl is-active matrix-screen-controller.service", fields) + residue = checked_remote(board, "find /var/lib/matrix-screen-controller -maxdepth 1 -name '.wifi-test-restore-*' -print | wc -l", fields) + if residue != "0": + raise RuntimeError("本轮 WiFi 恢复材料尚未清理") + print("App、BLE、板端原网络与恢复材料最终核验通过", flush=True) + return 0 + if not args.resume: + run_stage("validate") + run_stage("validate_invalid") + if args.validate_only: + print("App 字段校验通过,原网络未切换", flush=True) + return 0 + run_stage("scan") + backup_root, backup_unit = prepare_recovery(board, fields, original, minutes=3 if args.wrong_only else 15) + print("板端受保护备份与定时恢复已就绪", flush=True) + else: + checked_remote(board, f"systemd-run --unit='{backup_unit}-continued' --on-active=15m /bin/sh '{backup_root}/restore.sh' >/dev/null && systemctl is-active '{backup_unit}-continued.timer' && systemctl stop '{backup_unit}.timer'", fields) + print("已核验原备份并续设恢复定时器", flush=True) + if args.wrong_only: + run_stage("wrong") + run_stage("inspect_failure") + print("错误密码任务失败已由 App 中文状态确认;正在恢复原网络", flush=True) + return 0 + stages = ("manual", "dhcp_return") if args.resume else ("dhcp", "manual", "dhcp_return") + for stage in stages: + run_stage(stage) + if stage != "wrong": + current_ip, new_board = reconnect_hotspot(values["static_address"] if stage == "manual" else None) + board.close() + board = new_board + checked_remote(board, "systemctl is-active matrix-screen-controller.service", fields) + snapshot = wifi_snapshot(board, fields) + if snapshot["saved"]["ssid"] != hotspot["ssid"] or snapshot["active"]["ssid"] != hotspot["ssid"] or not snapshot["active"]["connected"]: + raise RuntimeError(f"板端网络状态不符合阶段 {stage}") + expected_mode = "manual" if stage == "manual" else "dhcp" + if snapshot["saved"]["ipv4_mode"] != expected_mode: + raise RuntimeError(f"板端 IPv4 模式不符合阶段 {stage}") + if stage == "manual" and snapshot["active"]["ipv4_address"] != values["static_address"]: + raise RuntimeError("静态 IPv4 地址未实际生效") + run_stage("restore") + board.close() + board = None + for _ in range(24): + time.sleep(3) + try: + board = connect_at(fields["IP"].strip(), expected_key, fields) + break + except RuntimeError: + pass + if board is None: + raise RuntimeError("立即恢复原网络后 SSH 未恢复;板端定时恢复仍启用") + checked_remote(board, "systemctl is-active matrix-screen-controller.service", fields) + run_stage("verify") + snapshot = wifi_snapshot(board, fields) + if (snapshot["saved"]["ssid"] != original["802-11-wireless.ssid"] + or snapshot["active"]["ssid"] != original["802-11-wireless.ssid"] + or not snapshot["active"]["connected"]): + raise RuntimeError("立即生效后未恢复原网络") + code, journal, _ = execute(board, "journalctl -u matrix-screen-controller.service --since '-30 min' --no-pager -o cat", password=fields["密码"], timeout=30) + if code or any(secret in journal for secret in (hotspot["password"], original["802-11-wireless-security.psk"], values["wrong_password"])): + raise RuntimeError("板端日志检查失败或含有测试密码") + checked_remote(board, f"/bin/sh '{backup_root}/restore.sh'", fields, timeout=50) + if not verify_restored(board, fields, backup_root): + raise RuntimeError("原网络未完全恢复") + clean_recovery(board, fields, backup_root, backup_unit) + backup_root = backup_unit = None + print("原网络恢复、密码日志检查和恢复材料清理通过", flush=True) + return 0 + finally: + if host_secret is not None: + host_secret.unlink(missing_ok=True) + for target in ("/data/local/tmp/qms-wifi-test-private.json", "/data/local/tmp/qms-authorized-input.sh"): + try: adb_call(args.adb, phone["serial"], "shell", "rm", "-f", target) + except Exception: pass + try: adb_call(args.adb, phone["serial"], "shell", "run-as", "org.qimiaoscreen.controller", "rm", "-f", "files/qms-wifi-test-ip.txt") + except Exception: pass + if backup_root and backup_unit: + for ip in dict.fromkeys([current_ip, *hotspot_client_ips(network, hotspot["gateway"]), fields["IP"].strip()]): + try: + if board is None: board = connect_at(ip, expected_key, fields) + try: + checked_remote(board, f"/bin/sh '{backup_root}/restore.sh'", fields, timeout=50) + except Exception: + # The connection can close while the restore switches WiFi. + pass + break + except Exception: + if board is not None: board.close() + board = None + if board is not None: + board.close() + board = None + for _ in range(40 if args.wrong_only else 12): + try: + board = connect_at(fields["IP"].strip(), expected_key, fields) + checked_remote(board, "systemctl is-active matrix-screen-controller.service", fields) + if not verify_restored(board, fields, backup_root): + raise RuntimeError("原网络恢复尚未核验") + if args.wrong_only: + code, journal, _ = execute(board, "journalctl -u matrix-screen-controller.service --since '-10 min' --no-pager -o cat", password=fields["密码"], timeout=30) + if code or any(secret in journal for secret in (hotspot["password"], original["802-11-wireless-security.psk"], values["wrong_password"])): + raise RuntimeError("错误密码阶段日志检查失败或含有测试密码") + clean_recovery(board, fields, backup_root, backup_unit) + print("失败后已核对原网络并清理保护材料", flush=True) + break + except Exception: + if board is not None: board.close() + board = None + time.sleep(5) + if board is None: + print("即时恢复无法连接;板端定时恢复仍已启用", flush=True) + if args.wrong_only: + raise RuntimeError("错误密码单测的原网络恢复尚未核验") + if board is not None: board.close() + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: + print(f"WiFi 专项测试未通过:{error}", file=sys.stderr) + raise SystemExit(1) diff --git a/移动端相关内容/安卓app/如何安卓测试/sample_mobile_performance.py b/移动端相关内容/安卓app/如何安卓测试/sample_mobile_performance.py new file mode 100644 index 0000000..c37210b --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/sample_mobile_performance.py @@ -0,0 +1,56 @@ +"""Read-only numeric performance samples; no device identity or WiFi payloads.""" +import argparse +import json +from pathlib import Path +import shlex +from remote_support import connect, execute + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--seconds', type=int, default=120) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + if not 5 <= args.seconds <= 600: + parser.error('seconds must be 5..600') + if args.output.exists(): + parser.error('output already exists; do not overwrite evidence') + script = r''' +import json,time,urllib.request +rows=[] +start=time.monotonic() +while time.monotonic()-start < DURATION: + with urllib.request.urlopen('http://127.0.0.1:8080/api/status',timeout=5) as response: + s=json.load(response) + d=s['screen']['driver_status'];r=s['resources'] + rows.append(dict(elapsed_seconds=round(time.monotonic()-start,3), + connected=s['mobile']['connected'],animation_active=s['state']['animation_active'], + refresh_hz=d['actual_refresh_rate_hz'],frames=d['completed_frames'], + deadline_misses=d['deadline_misses'],oe_faults=d['oe_pulse_faults'], + driver_error_present=bool(d['last_error'] or d['oe_timing_error']), + cpu_application_percent=r['cpu']['application_percent'], + memory_application_bytes=r['memory']['application_bytes'])) + time.sleep(1) +print(json.dumps(rows)) +'''.replace('DURATION', str(args.seconds)) + client, fields = connect() + try: + code, output, _ = execute(client, 'python3 -c ' + shlex.quote(script), timeout=args.seconds + 20) + if code: + raise RuntimeError('Performance collection failed; raw output withheld') + rows = json.loads(output) + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open('x', encoding='utf-8') as stream: + json.dump(rows, stream, ensure_ascii=False, indent=2) + stream.write('\n') + print(json.dumps(dict(samples=len(rows),connected_samples=sum(r['connected'] for r in rows), + deadline_misses_delta=rows[-1]['deadline_misses']-rows[0]['deadline_misses'], + oe_faults_delta=rows[-1]['oe_faults']-rows[0]['oe_faults'], + min_refresh_hz=min(r['refresh_hz'] for r in rows), + driver_error_seen=any(r['driver_error_present'] for r in rows)))) + finally: + client.close() + + +if __name__ == '__main__': + main() diff --git a/移动端相关内容/安卓app/如何安卓测试/常见问题与排障.md b/移动端相关内容/安卓app/如何安卓测试/常见问题与排障.md new file mode 100644 index 0000000..e640a64 --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/常见问题与排障.md @@ -0,0 +1,63 @@ +# Android 测试常见问题与排障 + +测试前阅读本文件及 [可重复测试流程](README.md)。这里维护可复用的方法;单次失败、重测次数、耗时与验收结果写入 `../测试结果归档/`。本页依据 [四栏版真机记录](../测试结果归档/2026-09-26_四栏界面优化.md) 整理,方法有效不等于所有同类错误均有相同根因。 + +## 1. root 命令在 instrumentation 中失败 + +主机 ADB 的 `su -c` 检查通过,但 `UiDevice.executeShellCommand("su -c '...'")` 失败时,先检查命令解析。该入口不能假设经过交互 shell;字符串中的引号不保证按 shell 语法处理。已验证做法是调用短时上传的 LF shell 辅助文件,由文件内部执行 `su -c "$*"`。布局检查使用 `authorized_root_command.sh`;点击、滑动、编辑使用有前台限制的 `authorized_app_input.sh`。运行器结束清理手机临时文件。 + +区分命令构造错误与真实权限不足。真实 root/ADB/授权不可用时停止等待用户,不继续测试、不使用普通注入或无障碍动作替代。辅助文件只用于已授权的本软件调试,不扩大用途。 + +## 2. Windows 写出的 shell 文件无法执行 + +CRLF 会破坏 Android/Linux shell 的解释器或参数。生成 `.sh` 使用显式 UTF-8/LF(例如 `Path.write_bytes`,或文本写入指定 `newline="\n"`),不要依赖 Windows 文本默认换行。上传前检查不含 CRLF;Git 的 `*.sh eol=lf` 不能代替对本次生成文件的检查。不要因脚本解析失败反复请求 root。 + +## 3. 布局变更终止测试进程 + +在 instrumentation 运行中改变密度、字体或方向,可能使应用/测试进程重建或终止。不要把此类 `Process crashed` 直接当成产品崩溃,也不要在同一进程里继续依赖原窗口句柄。 + +使用 `run_ui_layout_test.py`:电脑端先保存原密度覆盖、字体和旋转设置,在每组测试启动前设置目标布局;测试启动后显式启动 Activity,再读取语义节点并执行 root 输入。电脑端 `finally` 在正常结束或失败时恢复原值,原设置不存在时删除对应项,原密度未覆盖时使用 reset。字体/方向回读核验;若新增 USB 保持唤醒等设置改动,同样必须先保存、恢复并核验,不以默认值猜测。安全锁屏不能正常解锁时等待用户,不绕过密码。 + +## 4. 窗口转储看不到控件、文字重复或节点不可点击 + +独立 `uiautomator dump` 在本机 MIUI 上曾无法稳定读取 App 控件。使用已验证的持久 instrumentation 入口读取语义节点;读取不是无障碍动作注入。不要盲点坐标,也不要因此改用无障碍点击。 + +Compose 的可见文字节点未必有 clickable 标志,导航“连接”也可能与扫描结果按钮同名。先限制 App 包、当前页面/条目容器,再筛选可见且边界非空的节点;底栏可按同名可见节点位置选择。检查边界在当前屏幕内,重新读取中心点后执行 root tap。等待页面加载完成再取位置,布局变化后不可复用旧坐标。列表滚动只在语义识别出的本 App 可见滚动区域内执行 root swipe。 + +## 5. 找不到 DHCP 开关或密码可见复选框 + +Compose 语义树不保证使用 `android.widget.Switch`/`CheckBox` 类名。定位标签附近的 checkable 节点:按标签实时纵向范围筛选,并结合行内左右位置区分 DHCP 开关与密码可见复选框。点击后检查 checked 状态或静态地址字段是否出现;不要只认按钮类名,也不能用一个任意 checkable 节点代替。 + +## 6. 文本未清空、中文/密码输入失败、返回键退出 App + +Ctrl+A 在该测试环境下不可靠。现有做法:App 前台进程设置临时剪贴板,root 点击实时字段位置,按 MOVE_END,再按当前文本长度加余量执行有界 DEL,最后 root paste。清空操作计数有上限,输入后回读字段核验。剪贴板在 `finally` 清理,密码不放 ADB 参数、日志或公开转储中。 + +仅在输入法实际显示时发送 BACK 收起键盘;否则 BACK 可能退出页面。使用 helper 的 `hide-keyboard` 分支检查 `mInputShown`。等“屏幕方向”等设置数据就绪后才展开 WiFi 表单,避免数据加载改变控件位置。 + +## 7. 保存按钮的禁用断言失败 + +Compose 的禁用语义可能位于父节点,文字节点自身仍 enabled。检查文字所在控件及祖先链的 enabled 状态,并结合实际行为(空名称不能打开确认/提交)判断;不要只断言文字节点的 enabled。 + +## 8. 安装成功后权限又被 MIUI 重置 + +`adb install` 成功不证明蓝牙权限稳定。现有运行器在覆盖安装后通过 root 授予本 App 必需权限,等待 3 秒并回读;最多三轮,仍未稳定则停止等待用户。不能无限重试,不能修改其他系统安全开关。测试前仍检查登记、ADB、root、前台和必要权限。 + +## 9. 连续测试的 BLE 扫描或握手偶发失败 + +每个正常测试阶段结束时通过连接页显式断开,等待释放后再结束进程;不要只依赖紧接着的 force-stop。异常时按失败阶段记录扫描、GATT、握手/任务是否已开始,清理旧测试会话后重新扫描,不跨会话复用临时句柄。主机超时不会自动终止 instrumentation,运行器还需结束本 App 的旧测试进程。 + +确需核验板端服务恢复时,先确认手机已断开,再执行 `check_mobile_lifecycle.py --restart-service`;它会真实停启服务并核验运行目录、持久摘要和接口恢复。保留首次失败,恢复后通过不证明偶发扫描失败的根因。若重现持续存在,停止反复重启,针对具体阶段排查,不降低超时/断言来制造通过。 + +## 10. WiFi 专项不具备第二网络、错误密码时 SSH 断开 + +`--validate-only`、`--wrong-only`、`--final-check` 不要求电脑热点;错误密码单测使用当前受管网络。跨 SSID 的 DHCP/静态往返才需要第二个可用网络;缺少时标未验证,不拿重应用原网络或表单测试替代。 + +任何真实切网先保存受保护原 NetworkManager 配置,并核验定时恢复已建立。错误密码可能使 SSH 断开,继续通过 BLE 查看任务,等待定时恢复;连通后比较原配置摘要、活动网络与服务状态,再清理备份和定时器。网络恢复没有核验就不能写通过,也不能提前删除唯一恢复材料。 + +失败任务必须区分提交前握手失败与已经提交后的网络失败。“密码错误”只在可靠认证证据/结构化 AUTH_FAILED 下通过验收;超时、缺少 secret 或未知错误不能强行归类。确认密码未进入日志,清理手机私有文件和临时剪贴板,最后执行 `--final-check`。 + +## 11. 测试完成后的固定复盘 + +每轮检查:本轮有哪些失败/调整、是否已有可重复防范步骤、恢复是否核验、哪些原因仍未知。已验证解法更新本页和对应流程/构建说明;一次性证据留归档,不能将未证实推测写成固定根因。 + +最终反馈写明经验已更新的文档,以及尚未解决的问题或未验证边界。若没有新增经验,说明沿用现有方法即可,不复制重复条目。遵守根协作规则:不写真实凭据、手机标识、开发机绝对路径,文档更新后执行仓库卫生检查。 diff --git a/移动端相关内容/安卓app/如何安卓测试/测试设备登记/README.md b/移动端相关内容/安卓app/如何安卓测试/测试设备登记/README.md new file mode 100644 index 0000000..877d4d7 --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/测试设备登记/README.md @@ -0,0 +1,19 @@ +# 测试设备登记 + +`测试设备.example.json` 是可提交的空白模板;`测试设备.local.json` 是被根 .gitignore 精确排除的真实登记,不提交、不回显、不自动覆盖。手机的真实序列号仅保存在后者,报告使用 `android-test-a`。 + +主测试手机已获用户专用测试授权:可自动安装覆盖本 App、清理本 App 测试数据、操作权限与蓝牙;常规测试不需要 root。不包括自动启动视觉检查。摄像头、DroidCam、采集和视觉测试必须另行询问如何启动并等待指示。 + +从仓库根运行,ADB 路径使用已安装工具的实际位置,不写入仓库配置: + +```powershell +python -X utf8 "移动端相关内容/安卓app/如何安卓测试/测试设备登记/prepare_test_device.py" --adb "" check +``` + +首次在用户明确授权且仅连接一台已授权 USB 手机时,使用 `register-connected` 代替 `check` 自动读取型号、系统和设备标识并独占创建真实登记。脚本不执行 root、不安装程序、不启用摄像头、不修改手机设置。已有文件一律不覆盖;需要修正时由维护者只编辑真实登记。 + +无参数的 `check` 仅验证文件;带 `--adb` 同时核验在线授权、型号和 API。无登记时复制空白示例并停止,填写完成后重新检查;不能将空白模板当成真实设备使用。`--alias` 默认 android-test-a,后续多个登记必须明确代号,不自动取首台。 + +JSON 格式:schema_version 固定 1,devices 为列表。每项包含 alias、serial、model、android_release、api_level、connection(当前 usb)、dedicated_test_device、automation_allowed 和 visual_check_requires_user_instruction。最后两个授权布尔必须与用户实际授权一致,视觉门禁必须为 true;本脚本仅接受已授权的专用测试机。 + +不得将 `adb devices` 原始输出或异常完整命令放入提交报告。脚本失败只报告原因或字段名。登记不包含 WiFi 密码、root 密码、账户或其他应用数据。第二台手机可以由用户手动安装并辅助验收,无需自动登记其身份。 diff --git a/移动端相关内容/安卓app/如何安卓测试/测试设备登记/prepare_test_device.py b/移动端相关内容/安卓app/如何安卓测试/测试设备登记/prepare_test_device.py new file mode 100644 index 0000000..41d6b30 --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/测试设备登记/prepare_test_device.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from pathlib import Path + + +DIRECTORY = Path(__file__).resolve().parent +PRIVATE_NAME = "测试设备.local.json" +EXAMPLE_NAME = "测试设备.example.json" + + +class RegistrationError(RuntimeError): + """A sanitized registration error; never include device values.""" + + +def validate_document(document: object) -> list[dict]: + if not isinstance(document, dict) or document.get("schema_version") != 1: + raise RegistrationError("schema_version 必须为 1。") + devices = document.get("devices") + if not isinstance(devices, list) or not devices: + raise RegistrationError("devices 必须为非空列表。") + aliases: set[str] = set() + serials: set[str] = set() + for device in devices: + if not isinstance(device, dict): + raise RegistrationError("devices 条目格式错误。") + for field in ("alias", "serial", "model", "android_release"): + value = device.get(field) + if not isinstance(value, str) or not value.strip() or any(ord(c) < 32 for c in value): + raise RegistrationError(f"字段缺失或格式错误:{field}") + if "<" in value or ">" in value or "待填写" in value: + raise RegistrationError(f"字段仍为占位内容:{field}") + if not re.fullmatch(r"android-test-[a-z0-9-]+", device["alias"]): + raise RegistrationError("alias 必须使用 android-test- 开头的 ASCII 测试代号。") + if device["serial"] != device["serial"].strip() or any(c.isspace() for c in device["serial"]): + raise RegistrationError("serial 格式错误。") + api = device.get("api_level") + if type(api) is not int or api < 26: + raise RegistrationError("api_level 必须为不小于 26 的整数。") + if device.get("connection") != "usb": + raise RegistrationError("当前自动登记仅支持 usb。") + for field in ("dedicated_test_device", "automation_allowed", "visual_check_requires_user_instruction"): + if device.get(field) is not True: + raise RegistrationError(f"授权或门禁字段未明确启用:{field}") + if device["alias"] in aliases or device["serial"] in serials: + raise RegistrationError("登记存在重复 alias 或 serial。") + aliases.add(device["alias"]) + serials.add(device["serial"]) + return devices + + +def load_registration(directory: Path = DIRECTORY) -> list[dict]: + path = directory / PRIVATE_NAME + if not path.exists(): + try: + with path.open("xb") as stream: + stream.write((directory / EXAMPLE_NAME).read_bytes()) + except FileExistsError: + pass + raise RegistrationError("已准备空白私有登记;请填写真实登记后重新检查,当前停止真机步骤。") + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + raise RegistrationError("真实登记无法读取或不是有效 UTF-8 JSON;原文件未改动。") from None + return validate_document(document) + + +def run_adb(adb: str, arguments: list[str]) -> str: + try: + result = subprocess.run( + [adb, *arguments], capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=20, + ) + except (OSError, subprocess.TimeoutExpired): + raise RegistrationError("ADB 不可执行或操作超时;未输出命令及设备标识。") from None + if result.returncode != 0: + raise RegistrationError("ADB 操作失败;未输出原始设备日志。") + return result.stdout.strip() + + +def list_devices(adb: str) -> list[tuple[str, str, bool]]: + rows = [] + # Windows ADB often omits usb: from devices -l and returns unknown for + # get-devpath. -d selects a USB transport only; ambiguity must fail closed. + try: + single_usb_serial = run_adb(adb, ["-d", "get-serialno"]) + except RegistrationError: + single_usb_serial = "" + for line in run_adb(adb, ["devices", "-l"]).splitlines(): + fields = line.split() + if len(fields) >= 2 and fields[1] in {"device", "offline", "unauthorized", "no"}: + usb = any(v.startswith("usb:") for v in fields[2:]) or fields[0] == single_usb_serial + rows.append((fields[0], fields[1], usb)) + return rows + + +def read_identity(adb: str, serial: str) -> dict: + def prop(name: str) -> str: + return run_adb(adb, ["-s", serial, "shell", "getprop", name]) + + try: + api = int(prop("ro.build.version.sdk")) + except ValueError: + raise RegistrationError("无法读取有效的 Android API 版本。") from None + return {"serial": serial, "model": prop("ro.product.model"), + "android_release": prop("ro.build.version.release"), "api_level": api} + + +def register_connected(adb: str, alias: str, directory: Path = DIRECTORY) -> None: + path = directory / PRIVATE_NAME + if path.exists(): + raise RegistrationError("真实登记已存在,不覆盖、不重建;请使用 check。") + rows = list_devices(adb) + if len(rows) != 1: + raise RegistrationError("首次自动登记要求仅接入一台手机;当前数量不符合。") + serial, status, usb = rows[0] + if status != "device": + raise RegistrationError("手机未授权或离线;请完成 USB 调试授权。") + if not usb: + raise RegistrationError("未确认 USB 传输,拒绝将无线设备自动登记为 USB。") + device = {"alias": alias, **read_identity(adb, serial), "connection": "usb", + "dedicated_test_device": True, "automation_allowed": True, + "visual_check_requires_user_instruction": True} + document = {"schema_version": 1, "devices": [device]} + validate_document(document) + try: + with path.open("x", encoding="utf-8", newline="\n") as stream: + json.dump(document, stream, ensure_ascii=False, indent=2) + stream.write("\n") + except FileExistsError: + raise RegistrationError("真实登记已被创建;拒绝覆盖。") from None + + +def check_connected(adb: str, device: dict) -> None: + row = next((r for r in list_devices(adb) if r[0] == device["serial"]), None) + if row is None or row[1] != "device" or not row[2]: + raise RegistrationError("登记手机未通过已授权 USB 连接检查;不改用其他手机。") + current = read_identity(adb, device["serial"]) + if any(current[key] != device[key] for key in ("model", "android_release", "api_level")): + raise RegistrationError("手机系统或型号与登记不一致;请核实登记,不自动更新。") + + +def main() -> int: + parser = argparse.ArgumentParser(description="测试手机私有登记门禁;不输出真实标识") + parser.add_argument("--adb", help="已有 adb 可执行文件") + parser.add_argument("--alias", default="android-test-a") + parser.add_argument("action", choices=("check", "register-connected"), default="check", nargs="?") + args = parser.parse_args() + try: + if args.action == "register-connected": + if not args.adb: + raise RegistrationError("自动登记需要 --adb,且必须已有用户专用测试授权。") + register_connected(args.adb, args.alias) + devices = load_registration() + device = next((item for item in devices if item["alias"] == args.alias), None) + if device is None: + raise RegistrationError("未找到指定测试代号;不自动选择其他设备。") + if args.adb: + check_connected(args.adb, device) + print(f"测试登记通过:{device['alias']};未输出设备标识,视觉检查仍须单独询问。") + return 0 + except RegistrationError as error: + print(f"测试登记未通过:{error}") + return 2 + except OSError: + print("测试登记读写失败;未输出路径、设备标识或原始异常。") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/移动端相关内容/安卓app/如何安卓测试/测试设备登记/test_prepare_test_device.py b/移动端相关内容/安卓app/如何安卓测试/测试设备登记/test_prepare_test_device.py new file mode 100644 index 0000000..042370d --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/测试设备登记/test_prepare_test_device.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + + +SPEC = importlib.util.spec_from_file_location("registration", Path(__file__).with_name("prepare_test_device.py")) +registration = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(registration) + + +def valid_device(): + return {"alias": "android-test-a", "serial": "FAKE-UNIT-SERIAL", "model": "Fixture", + "android_release": "13", "api_level": 33, "connection": "usb", + "dedicated_test_device": True, "automation_allowed": True, + "visual_check_requires_user_instruction": True} + + +class RegistrationTests(unittest.TestCase): + def test_windows_usb_without_listing_marker(self): + with patch.object(registration, "run_adb", side_effect=["FAKE-USB", "List of devices attached\nFAKE-USB device product:test transport_id:1\n"]): + self.assertEqual(registration.list_devices("adb"), [("FAKE-USB", "device", True)]) + + def test_ambiguous_usb_does_not_guess_from_serial_shape(self): + with patch.object(registration, "run_adb", side_effect=[registration.RegistrationError("ambiguous"), "List of devices attached\nFAKE-USB device transport_id:1\n"]): + self.assertEqual(registration.list_devices("adb"), [("FAKE-USB", "device", False)]) + + def test_blank_copy_blocks_and_preserves_existing(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) / "中文 空格" + directory.mkdir() + payload = Path(__file__).with_name(registration.EXAMPLE_NAME).read_bytes() + (directory / registration.EXAMPLE_NAME).write_bytes(payload) + with self.assertRaises(registration.RegistrationError): + registration.load_registration(directory) + private = directory / registration.PRIVATE_NAME + self.assertEqual(private.read_bytes(), payload) + with self.assertRaises(registration.RegistrationError): + registration.load_registration(directory) + self.assertEqual(private.read_bytes(), payload) + + def test_existing_file_never_overwritten_even_if_corrupt(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + private = directory / registration.PRIVATE_NAME + private.write_bytes(b"not-json") + with patch.object(registration, "list_devices") as listing: + with self.assertRaises(registration.RegistrationError): + registration.register_connected("adb", "android-test-a", directory) + listing.assert_not_called() + self.assertEqual(private.read_bytes(), b"not-json") + + def test_ambiguous_unauthorized_and_wireless_registration_refused(self): + for rows in ([], [("A", "device", True), ("B", "device", True)], + [("A", "unauthorized", True)], [("A", "device", False)]): + with self.subTest(rows=rows), tempfile.TemporaryDirectory() as temporary: + with patch.object(registration, "list_devices", return_value=rows): + with self.assertRaises(registration.RegistrationError): + registration.register_connected("adb", "android-test-a", Path(temporary)) + self.assertFalse((Path(temporary) / registration.PRIVATE_NAME).exists()) + + def test_private_creation_and_no_device_values_in_errors(self): + device = valid_device() + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + with patch.object(registration, "list_devices", return_value=[(device["serial"], "device", True)]), \ + patch.object(registration, "read_identity", return_value={k: device[k] for k in ("serial", "model", "android_release", "api_level")}): + registration.register_connected("adb", device["alias"], directory) + self.assertEqual(registration.load_registration(directory), [device]) + with patch.object(registration, "list_devices", return_value=[("DIFFERENT-PRIVATE-SERIAL", "device", True)]): + with self.assertRaises(registration.RegistrationError) as caught: + registration.check_connected("adb", device) + self.assertNotIn(device["serial"], str(caught.exception)) + self.assertNotIn("DIFFERENT-PRIVATE-SERIAL", str(caught.exception)) + + def test_authorization_visual_gate_duplicates_and_placeholders(self): + for key, value in (("automation_allowed", False), ("visual_check_requires_user_instruction", False), + ("serial", ""), ("api_level", True)): + device = valid_device() + device[key] = value + with self.subTest(key=key), self.assertRaises(registration.RegistrationError): + registration.validate_document({"schema_version": 1, "devices": [device]}) + with self.assertRaises(registration.RegistrationError): + registration.validate_document({"schema_version": 1, "devices": [valid_device(), valid_device()]}) + + def test_system_change_blocks_without_modifying_document(self): + device = valid_device() + before = json.dumps(device) + with patch.object(registration, "list_devices", return_value=[(device["serial"], "device", True)]), \ + patch.object(registration, "read_identity", return_value={**device, "api_level": 34}): + with self.assertRaises(registration.RegistrationError): + registration.check_connected("adb", device) + self.assertEqual(json.dumps(device), before) + + +if __name__ == "__main__": + unittest.main() diff --git a/移动端相关内容/安卓app/如何安卓测试/测试设备登记/测试设备.example.json b/移动端相关内容/安卓app/如何安卓测试/测试设备登记/测试设备.example.json new file mode 100644 index 0000000..28a7ee8 --- /dev/null +++ b/移动端相关内容/安卓app/如何安卓测试/测试设备登记/测试设备.example.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "devices": [ + { + "alias": "android-test-a", + "serial": "", + "model": "", + "android_release": "", + "api_level": null, + "connection": "usb", + "dedicated_test_device": false, + "automation_allowed": false, + "visual_check_requires_user_instruction": true + } + ] +} diff --git a/移动端相关内容/安卓app/安卓开发详细设计.md b/移动端相关内容/安卓app/安卓开发详细设计.md new file mode 100644 index 0000000..dd91a91 --- /dev/null +++ b/移动端相关内容/安卓app/安卓开发详细设计.md @@ -0,0 +1,70 @@ +# Android 首版详细设计 + +本设计对应 MOBILE-* 和协议 1.0。已实现项以本文件说明及源码为准,真实通过范围见测试结果归档;测试目标不自动等于已完成验收。 + +## 1. 模块与依赖方向 + +工程使用 Kotlin DSL、Gradle Wrapper、版本目录和依赖校验;版本必须精确固定,不使用动态 latest。模块为 `androidApp`(Android 入口与平台组装)、`sharedCore`(KMP 协议/业务/状态机)、`sharedUi`(KMP Compose 界面);依赖 androidApp → sharedUi → sharedCore,androidApp 也实现 sharedCore 的平台接口。 + +应用 ID 固定 `org.qimiaoscreen.controller`,显示名称“奇妙小屏幕”,minSdk 26。首版 App 版本 `0.1.0`、versionCode 从 1 开始;交付构建增加 versionCode,不绑定设备 VERSION。debug 为当前唯一交付类型,无正式签名配置。平台工具链安装后将实际验证的 Kotlin、Compose、AGP、Gradle、JDK、SDK 版本登记在构建文档并提交锁定文件。 + +sharedCore 通过 MobilePlatform 聚合扫描、连接、手机型号、本地键值读写和 SecureChannel 工厂;DeviceLink 封装分片收发与关闭,SecureChannel 封装握手与加解密;Android BluetoothGatt、Activity、Context、Keystore 等类型不能出现在 commonMain。共享状态机使用协程与 StateFlow,取消结构化管理。JSON 使用 kotlinx.serialization,保留未知可选字段兼容策略。依赖注入显式通过构造函数完成,首版不引入额外 DI 框架。 + +Android 平台以 Java 安全 API 实现 P-256 ECDH/HKDF-SHA256/AES-GCM,公共模块定义字节契约;设备端使用成熟 Python 密码库。iOS 未来接入平台加密与 CoreBluetooth,不以 Android 类型模拟 iOS。 + +## 2. 状态机与异步行为 + +连接状态:PermissionRequired → BluetoothOff/Idle → Scanning → Connecting → Discovering → Subscribing → Handshaking → Ready;失败进入 Error(分类原因)并释放 GATT。主动断开清理订阅、密钥、分片、轮询和队列,但保留上次设备 ID。主动断开后当前前台会话不自动重连;用户再点连接或下一次进入前台才尝试。 + +扫描单轮 12 秒;点击重试开始新轮,不无限循环。自动重连扫描上次持久 ID 对应短编号,握手校验完整 ID,不因名称相同认错设备;候选完整 ID 不符则释放并报错,用户可重新扫描。App 最多一个 BluetoothGatt。每项 GATT 操作等待回调或超时后再执行下一项;迟到回调按连接 generation 丢弃。 + +用户控制队列优先,状态次之,缩略图与当前帧最低优先。单个预览在途,数据无变化不反复下载。背景立即停止轮询和缩略图,30 秒延迟任务释放连接;回前台取消延迟,Ready 则刷新,非 Ready 则重连上次设备。进程死亡不假设回调可执行,由设备心跳门限回收会话。 + +普通操作请求超时 10 秒,扫描 WiFi 20 秒,切网任务最多轮询 60 秒后转“结果尚未确认,可重新读取”;超时不是事务回滚证据,不自动重发变更。 + +## 3. 页面行为 + +设备页:未连接时扫描列表与权限/蓝牙恢复入口;连接时身份、断开、昵称修改、版本及分组状态卡。所有未知状态用“暂不可用”及原因,不置零;容量读取有缓存。手机昵称只存本地,连接时加密发送。 + +内容页:顶部当前帧及更新时间,下方当前内容/动图控制,再下方设备顺序的库列表。演示、静态、动图可辨认,缩略图懒加载;条目按钮为“播放”和“设为开机默认并播放”。当前实际 Activity 测试使用 UI Automator 的控件语义文本定位,root 点击仅采用该节点实时位置;尚未完成独立 Compose testTag 测试矩阵。动画会话变化时取消旧滑动提交,不对新动画误发旧位置。 + +设置页:先设备设置,再 WiFi,再 App 本地设置。亮度滑动结束才提交;方向、刷新率和性能模式明确操作反馈。多个未保存字段组成 changes,带修订,冲突保留草稿并要求刷新后再提交。不要完整提交缓存 settings。 + +WiFi:扫描来自设备;允许手工 SSID;密码动作“保留/替换”,开放网络不要求密码。不提供已存密码读取。只在本次编辑内暂存密码,提交或离开时清除,不写 SavedStateHandle、日志和磁盘。静态 IPv4 必填地址、前缀、网关、DNS,DHCP 模式不提交残留静态字段。保存与连接结果分别显示。未支持的 EAP 类型明确禁用,不猜测配置。 + +所有页面适应小屏、字体放大和横竖屏;按钮提供无障碍语义。UI 采用共享 Material 3,跟随系统深浅色,无 WebView。图标采用代码/向量资源,首版无需图像生成依赖。 + +## 4. 平台权限与本地数据 + +连接异常清理:GATT 建立、加密握手和 RPC 的内部期限到期属于通信失败,必须转换为普通连接错误;不能把 TimeoutCancellationException 当成页面退出引发的协程取消而吞掉。期限到期关闭 GATT,清除会话并恢复扫描入口;变更命令不重放。仅在业务握手开始前,对同一设备的 GATT 建立最多尝试两次,间隔 1 秒;用户主动取消不重试。MTU 请求值为 247,应用报告的接收上限不超过此值;厂商重复 MTU 回调只能更新保守上限,服务发现与 CCCD 订阅只启动一次。 + +Android 8–11 按平台要求申请前台定位扫描权限并解释用途,系统位置开关影响扫描时提供设置入口;12+ 申请 BLUETOOTH_SCAN/CONNECT,并声明扫描不用于定位。不申请后台定位、联系人、文件全盘访问或摄像头权限。权限拒绝后允许重试,永久拒绝显示系统设置入口;不在启动时重复轰炸权限弹窗。 + +本地 SharedPreferences 仅保存 last_device_id 与 client_name。缩略图仅驻留内存,每次断线/新连接清空;键为内容类型、条目 ID、修订,以连接隔离设备,最多 128 项或合计 8 MiB Base64 字符,按插入顺序淘汰。不是磁盘缓存,也不是 LRU。设备业务配置以核桃派为准,不自动恢复手机历史副本。禁用敏感测试数据备份;密钥和 WiFi 密码不持久化。 + +## 5. 设备侧集成 + +Android GATT 时序补充:连接后请求 MTU 247;若厂商先报告缓存值 517,不立即开始服务发现。收到不大于请求值的实际 MTU 回调后,启动一次服务发现;缺少此回调时使用 2 秒后备任务启动一次发现。连接关闭取消后备任务。这样避免尚未完成 MTU 交换时提前发现服务导致停滞;整体仍受 15 秒 GATT 建立期限约束。此处理不更改协议线格式。 + +公共控制服务提供与协议对应的业务方法,网页 REST 与 BLE 适配器复用,禁止 BLE HTTP 回环调用网页接口。现有模板解析、默认事务、动画控制和设置应用由同一服务处理。配置修订比较与写入串行,网页操作也更新修订。 + +BlueZ D-Bus 接入作为可恢复模块,单独管理连接/广播,不在刷新线程执行 IO。蓝牙不可用时设备状态公开故障,显示与网页继续工作。持久身份不进入镜像载荷。网络扫描调用参数化 nmcli,解析转义 SSID,不拼接 shell;超时与错误脱敏。 + +网页顶栏从共享状态取得 mobile.connected/client_name;已连接状态首次加载只显示常驻标记,观察到会话变更时才显示短提示,断开更新状态。昵称通过 textContent 渲染,不插入 HTML。 + +## 6. 测试、部署与恢复 + +模拟 transport 使用同一协议/业务模型,不把 fake 直接混入生产扫描列表;仅调试测试入口选择模拟。测试包括 commonTest、Android instrumentation 与 Python 设备契约测试,共享黄金向量确保加密/分片互操作。真实 BLE 不由模拟测试替代。 + +部署沿用离线依赖、持久数据保护、真实 systemd 生命周期和失败恢复,启用蓝牙同步修改 dedicated_host 检查。本轮不改驱动扫描算法,不默认视觉验收;确需视觉检查先停下询问用户启动方式。手机端安装与设备端部署分别记录版本/结果,不把任一方成功等同整体成功。 + +## 7. 当前交互补充与验收边界 + +当前帧成功读取或 unchanged 回复均刷新本机确认时间;界面显示距最近成功确认的秒数,超过 6 秒提示可能过期,不把静态内容未变化当作断线。内容页读取 content.default.get 显示开机默认名称;设置默认后重新回读。 + +临时 WiFi 密码仅存在 Compose remember 中,可切换本次可见状态,点击提交后立即清空并隐藏;离开页面不保留。DHCP 提交空 DNS 列表,不带静态地址/前缀/网关。权限拒绝时显示原因和重试/应用设置入口,该分支仍需各 Android 版本实测。 + +状态详情当前采用可展开 JSON,包含服务、显示、网络、电源、性能和容量;尚未完成所有字段的中文分组卡片。不能用 Android 13 的已授权主机测试替代 Android 8–11 的定位开关/权限验证;模拟测试只在 commonTest 内,不随生产 APK 发布。 + +## 2026-09-26 四栏实现修订 +采用稳定页面编号 0连接、1设备状态、2显示内容、3设备设置;保存设备完整 ID、昵称、短编号、最近连接时间,不保存 MAC。扫描状态与连接独立,旧回调按代数隔离;RSSI 由平台 DeviceLink 可选方法读取。顶部共用 WiFi 任务状态。菜单、折叠表单、确认弹窗使用共享 Compose;版本元数据从 Android BuildConfig 注入。非敏感 WiFi 草稿跨页保留,密码仅当前编辑驻留,离页或提交清除。原三页/JSON详情/刷新率与性能入口描述由此节替代。 diff --git a/移动端相关内容/安卓app/安卓程序源代码/README.md b/移动端相关内容/安卓app/安卓程序源代码/README.md new file mode 100644 index 0000000..24754a4 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/README.md @@ -0,0 +1,9 @@ +# KMP 工程位置 + +这里是唯一 Android/共享 KMP 工程根目录。模块为 androidApp、sharedCore、sharedUi,详细职责见相邻 `../安卓开发详细设计.md`。 + +当前工程骨架已构建 debug APK,界面仍为占位,不能作为控制器交付。Gradle Wrapper 9.5.0 固定发行摘要,版本目录锁定 Kotlin 2.4.20、Compose 1.12.1、AGP 9.3.1。实际构建使用 Android Studio 自带 JBR 25.0.3、Android SDK 37。Android 最低 API 26。 + +Windows 中文源码路径下编译可继续,但 JVM 测试出现 ClassNotFoundException;同一源码在 ASCII 隔离目录通过。使用 `../构建与发布/build_android.py` 构建,参数与说明见该目录 README。构建产物与密钥不提交。 + +`sharedCore/src/jvmTest/resources/protocol-v1.json` 是 Python/JVM 共用公开测试向量;固定标量 1、2 仅用于测试,生产会话必须每次生成新的临时密钥。 diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/build.gradle.kts b/移动端相关内容/安卓app/安卓程序源代码/androidApp/build.gradle.kts new file mode 100644 index 0000000..f1c93de --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/build.gradle.kts @@ -0,0 +1,37 @@ +import java.time.ZonedDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) +} +android { + namespace = "org.qimiaoscreen.controller" + compileSdk = 37 + defaultConfig { + applicationId = "org.qimiaoscreen.controller" + minSdk = 26 + targetSdk = 37 + versionCode = 3 + versionName = "0.2.0" + buildConfigField("String", "BUILD_DATE", "\"" + ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm XXX")) + "\"") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + buildFeatures { compose = true; buildConfig = 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) + androidTestImplementation("androidx.test:runner:1.7.0") + androidTestImplementation("androidx.test.ext:junit:1.3.0") + androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AppLifecycleTest.kt b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AppLifecycleTest.kt new file mode 100644 index 0000000..bbbbaad --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AppLifecycleTest.kt @@ -0,0 +1,122 @@ +package org.qimiaoscreen.controller + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** Semantic UI checks only: no screenshot, camera or display-content changes. */ +@RunWith(AndroidJUnit4::class) +class AppLifecycleTest { + @Test fun actualActivityReturnsFromBackground() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val ui = UiDevice.getInstance(instrumentation) + val rootInput = InstrumentationRegistry.getArguments().getString("root_input") == "true" + fun rootCommand(args: String) { + val result = ui.executeShellCommand("sh /data/local/tmp/qms-authorized-input.sh $args") + check(result.contains("QMS_INPUT_OK")) { "Authorized root input did not complete" } + } + fun click(node: UiObject2) { + check(rootInput) { "All test clicks require authorized root input" } + check(node.applicationPackage == "org.qimiaoscreen.controller") { "Input target outside App" } + val point = node.visibleCenter + rootCommand("tap ${point.x} ${point.y}") + } + ui.wakeUp() + fun launch() { + // MIUI rejects background Activity starts made by the test's app UID. + // Start the explicit authorized app through the normal shell test API. + ui.executeShellCommand("am start -W -n org.qimiaoscreen.controller/.MainActivity") + check(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000)) { + "App did not become foreground" + } + } + launch() + fun scanAndConnect() { + click(checkNotNull(ui.wait(Until.findObject(By.text("扫描设备")), 10_000))) + val name = InstrumentationRegistry.getArguments().getString("device_name")!! + val device = checkNotNull(ui.wait(Until.findObject(By.text(name)), 20_000)) + 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 (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("版本 0.2.0")), 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)) + } finally { + if (renamed) { openRename(); rename(originalName) } + } + 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)) + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/BleIntegrationTest.kt b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/BleIntegrationTest.kt new file mode 100644 index 0000000..287f487 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/BleIntegrationTest.kt @@ -0,0 +1,240 @@ +package org.qimiaoscreen.controller + +import android.Manifest +import android.os.Build +import android.os.Bundle +import android.content.Intent +import android.content.pm.PackageManager +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.* +import org.junit.Test +import org.junit.Assert.* +import org.junit.runner.RunWith +import org.qimiaoscreen.core.* + +/** Read-only by default; an explicit flag can reapply the already active WiFi profile. */ +@RunWith(AndroidJUnit4::class) +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 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 }) { + "Test permissions missing; stop and grant using authorized root runner" + } + val ble = AndroidBle(context) + val expectedName = checkNotNull(InstrumentationRegistry.getArguments().getString("device_name")) { "Explicit test board required" } + suspend fun discover(): DiscoveredDevice { + val found = CompletableDeferred() + try { + ble.scan({ if (it.name == expectedName) found.complete(it) }, { found.completeExceptionally(IllegalStateException(it)) }) + return withTimeout(20_000) { found.await() } + } finally { ble.stopScan() } + } + try { + val device = discover() + stage("scan complete") + ble.stopScan() + var identity: String? = null + repeat(2) { 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() + val link = ble.connect(target.handle) + val traced = object : DeviceLink { + override val mtu get() = link.mtu + override suspend fun send(fragment: ByteArray) { + stage("send kind=${fragment[3]} index=${fragment.readInt(8, 2)} count=${fragment.readInt(10, 2)} bytes=${fragment.size} mtu=$mtu") + link.send(fragment) + } + override suspend fun receive(): ByteArray = link.receive().also { + if (it.readInt(8, 2) == 0L) stage("receive kind=${it[3]} count=${it.readInt(10, 2)} bytes=${it.size}") + } + override fun close() = link.close() + } + val rpc = DeviceRpc(traced, JavaSecureChannel()) + stage("GATT ready $iteration") + try { + val hello = rpc.open("android-test-a") + stage("encrypted session open") + 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) + val status = rpc.call("status.get") + stage("status received") + assertTrue(status.containsKey("state")) + val settings = rpc.call("settings.get") + stage("settings received") + assertTrue(settings.getValue("values").jsonObject.containsKey("brightness")) + rpc.call("storage.get") + stage("storage received") + 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) { + val all = library.getValue("items").jsonArray.map { it.jsonObject }.toMutableList() + var cursor = library["next_cursor"]?.jsonPrimitive?.contentOrNull + while (cursor != null) { + val page = rpc.call("library.list", buildJsonObject { put("cursor", cursor); put("limit", 50) }) + all += page.getValue("items").jsonArray.map { it.jsonObject } + cursor = page["next_cursor"]?.jsonPrimitive?.contentOrNull + } + val canonical = all.filter { it["is_demo"]?.jsonPrimitive?.booleanOrNull != true } + .joinToString("\n") { it.getValue("type").jsonPrimitive.content + "|" + it.getValue("id").jsonPrimitive.content } + val digest = java.security.MessageDigest.getInstance("SHA-256").digest(canonical.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and 255) } + assertEquals("BLE must preserve device library order", expectedOrder, digest) + stage("library persistent order verified") + } + val frame = rpc.call("frame.get") + stage("frame received") + assertEquals("image/png", frame.getValue("mime").jsonPrimitive.content) + val wifi = rpc.call("wifi.get") + 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) { + 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") + try { + rpc.call("settings.patch", buildJsonObject { + put("expected_revision", "deliberately-stale-test-revision") + put("changes", buildJsonObject { put("brightness", settings.getValue("values").jsonObject.getValue("brightness")) }) + }) + fail("Stale revision must be rejected") + } 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. + val currentSettings = rpc.call("settings.get") + val sameBrightness = currentSettings.getValue("values").jsonObject.getValue("brightness") + val patched = rpc.call("settings.patch", buildJsonObject { + put("expected_revision", currentSettings.getValue("revision")) + put("changes", buildJsonObject { put("brightness", sameBrightness) }) + }) + assertEquals(sameBrightness, patched.getValue("values").jsonObject.getValue("brightness")) + val originalDefault = rpc.call("content.default.get") + val currentState = rpc.call("status.get").getValue("state").jsonObject + val originalPlayback = currentState.getValue("animation_playback").jsonObject + if (originalPlayback["active"]?.jsonPrimitive?.booleanOrNull == true) { + val session = originalPlayback.getValue("session_id") + val originalPaused = originalPlayback.getValue("paused") + try { + val paused = rpc.call("playback.patch", buildJsonObject { put("session_id", session); put("paused", true) }) + assertTrue(paused.getValue("animation_playback").jsonObject.getValue("paused").jsonPrimitive.boolean) + val held = paused.getValue("animation_playback").jsonObject + rpc.call("playback.patch", buildJsonObject { + put("session_id", session); put("position_ms", held.getValue("position_ms")); put("speed", originalPlayback.getValue("speed")) + }) + } finally { + rpc.call("playback.patch", buildJsonObject { put("session_id", session); put("paused", originalPaused) }) + } + stage("animation pause, same-position seek and same-speed write; original pause restored") + } else stage("animation write not applicable: no active animation") + assertEquals(originalDefault, rpc.call("content.default.get")) + stage("accepted settings write and default readback; selected content unchanged") + } + if (InstrumentationRegistry.getArguments().getString("reapply_current_wifi") == "true") { + val before = rpc.call("wifi.get") + val savedNetwork = before.getValue("saved").jsonObject + val activeNetwork = before.getValue("active").jsonObject + check(activeNetwork["connected"]?.jsonPrimitive?.boolean == true && + activeNetwork["ssid"] == savedNetwork["ssid"]) { "Saved network must already be active" } + val security = savedNetwork.getValue("security").jsonPrimitive.content + check(security == "open" || savedNetwork["password_configured"]?.jsonPrimitive?.boolean == true) + val request = buildJsonObject { + for (key in listOf("ssid", "security", "ipv4_mode", "address", "prefix", "gateway", "dns_servers")) + savedNetwork[key]?.let { put(key, it) } + put("expected_revision", before.getValue("revision")) + put("password_action", if (security == "open") "none" else "keep") + put("activation", "immediate") + } + val task = rpc.call("wifi.set", request).getValue("task_id") + withTimeout(45_000) { + while (true) { + val result = rpc.call("task.get", buildJsonObject { put("task_id", task) }) + val taskState = result.getValue("state").jsonPrimitive.content + check(taskState != "failed") { "Current-network activation failed" } + if (taskState == "succeeded") break + delay(1_000) + } + } + val after = rpc.call("wifi.get") + assertTrue("Current network restored", after.getValue("active").jsonObject["ssid"] == activeNetwork["ssid"]) + assertTrue("Network is connected", after.getValue("active").jsonObject.getValue("connected").jsonPrimitive.boolean) + assertTrue("Saved fields retained", listOf("ssid", "security", "ipv4_mode", "address", "prefix", "gateway", "dns_servers").all { + after.getValue("saved").jsonObject[it] == savedNetwork[it] + }) + stage("current WiFi reapplied with password retained; BLE remained usable") + } + repeat(InstrumentationRegistry.getArguments().getString("poll_cycles")?.toInt() ?: 60) { cycle -> + delay(2_000) + rpc.call("status.get") + rpc.call("frame.get") + if (cycle % 10 == 0) stage("continuous polling cycle $cycle") + } + } + } finally { stage("intentional session close $iteration"); rpc.close() } + if (iteration == 0) delay(3_000) + } + delay(3_000) + val saved = mutableMapOf() + val platform = object : MobilePlatform { + override val model = "android-test-a" + 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 controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + val controller = Controller(platform, controllerScope) + try { + withContext(Dispatchers.Main) { controller.connect(device) } + 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) } + delay(27_000) + assertNotNull("Returning before 30 seconds must cancel release", controller.state.value.identity) + withContext(Dispatchers.Main) { controller.foreground(false) } + withTimeout(35_000) { controller.state.first { it.identity == null } } + 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 } } + stage("controller foreground reconnected") + } finally { + withContext(Dispatchers.Main) { controller.disconnect() } + controllerScope.cancel() + } + } finally { ble.stopScan() } + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LayoutTest.kt b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LayoutTest.kt new file mode 100644 index 0000000..cc628b0 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LayoutTest.kt @@ -0,0 +1,41 @@ +package org.qimiaoscreen.controller + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** Read semantic bounds; inject all input through the authorized root helper. */ +@RunWith(AndroidJUnit4::class) +class LayoutTest { + @Test fun narrowLargeFontAndLandscape() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val ui = UiDevice.getInstance(instrumentation) + fun root(command: String) = ui.executeShellCommand("sh /data/local/tmp/qms-authorized-root.sh $command").trim() + check(root("id -u") == "0") { "Root unavailable; stop testing" } + fun input(command: String) { + check(ui.executeShellCommand("sh /data/local/tmp/qms-authorized-input.sh $command").contains("QMS_INPUT_OK")) + Thread.sleep(250) + } + fun click(label: String) { + check(ui.wait(Until.hasObject(By.text(label)), 15_000)) { "Layout control unavailable: $label" } + val node = checkNotNull(ui.findObjects(By.text(label)).filter { !it.visibleBounds.isEmpty } + .maxByOrNull { it.visibleCenter.y }) { "Visible layout target unavailable: $label" } + check(node.applicationPackage == "org.qimiaoscreen.controller") + val bounds = node.visibleBounds + assertTrue(bounds.left >= 0 && bounds.top >= 0 && bounds.right <= ui.displayWidth && bounds.bottom <= ui.displayHeight) + val point = node.visibleCenter + input("tap ${point.x} ${point.y}") + } + 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("关闭") + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/WifiSwitchTest.kt b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/WifiSwitchTest.kt new file mode 100644 index 0000000..bcbec7a --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/WifiSwitchTest.kt @@ -0,0 +1,252 @@ +package org.qimiaoscreen.controller + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import android.graphics.Rect +import android.view.accessibility.AccessibilityNodeInfo +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** Real App WiFi form. Secrets arrive through a short-lived shell-only file, never test arguments or logs. */ +@RunWith(AndroidJUnit4::class) +class WifiSwitchTest { + @Test fun wifiFormStage() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val ui = UiDevice.getInstance(instrumentation) + val args = InstrumentationRegistry.getArguments() + val stage = checkNotNull(args.getString("wifi_stage")) + val rootSwipe = true + val config = Json.parseToJsonElement( + ui.executeShellCommand("cat /data/local/tmp/qms-wifi-test-private.json") + ).jsonObject + fun value(key: String) = config.getValue(key).jsonPrimitive.content + fun rootCommand(command: String) { + check(ui.executeShellCommand("sh /data/local/tmp/qms-authorized-input.sh $command").contains("QMS_INPUT_OK")) + } + fun click(node: UiObject2) { + var target = node + while ((!target.isClickable || target.visibleBounds.isEmpty) && target.parent != null) target = target.parent + check(target.applicationPackage == "org.qimiaoscreen.controller") + check(!target.visibleBounds.isEmpty) + val center = target.visibleCenter + instrumentation.sendStatus(2, android.os.Bundle().apply { putString("stream", "UI tap class=${target.className} bounds=${target.visibleBounds}\n") }) + rootCommand("tap ${center.x} ${center.y}") + ui.waitForIdle(1_000) + Thread.sleep(250) + } + fun scroll(down: Boolean): Boolean { + val root = checkNotNull(instrumentation.uiAutomation.rootInActiveWindow) + val pending = ArrayDeque() + pending.add(root) + var scroller: AccessibilityNodeInfo? = null + var height = 0 + while (pending.isNotEmpty()) { + val node = pending.removeFirst() + val bounds = Rect() + node.getBoundsInScreen(bounds) + if (node.isScrollable && bounds.height() > height && bounds.width() > ui.displayWidth / 2) { + scroller = node + height = bounds.height() + } + for (index in 0 until node.childCount) node.getChild(index)?.let(pending::add) + } + checkNotNull(scroller) { "App settings list has no accessibility scroll action" } + if (rootSwipe) { + val bounds = Rect() + scroller.getBoundsInScreen(bounds) + val x = bounds.centerX() + val upper = bounds.top + bounds.height() / 5 + val lower = bounds.bottom - bounds.height() / 5 + rootCommand("swipe $x ${if (down) lower else upper} $x ${if (down) upper else lower} 350") + Thread.sleep(300) + return true + } + error("Root scrolling is required") + } + fun find(label: String): UiObject2 { + repeat(14) { + ui.wait(Until.findObject(By.text(label)), 1_000)?.let { return it } + scroll(true) + } + repeat(14) { + ui.wait(Until.findObject(By.text(label)), 1_000)?.let { return it } + scroll(false) + } + error("App control unavailable: $label") + } + fun edit(label: String, text: String) { + repeat(5) { + try { + val labelNode = find(label) + val field = generateSequence(labelNode) { it.parent }.first { it.className == "android.widget.EditText" } + repeat(40) { if (!field.isEnabled) Thread.sleep(100) } + check(field.isEnabled) { "App edit field remained disabled" } + val clipboard = instrumentation.targetContext.getSystemService(android.content.ClipboardManager::class.java) + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", text)) + try { + click(field) + rootCommand("clear-field ${(field.text.length + 2).coerceIn(2, 512)}") + if (text.isNotEmpty()) rootCommand("paste") + rootCommand("hide-keyboard") + } finally { clipboard.clearPrimaryClip() } + return + } catch (_: androidx.test.uiautomator.StaleObjectException) { + Thread.sleep(300) + } + } + error("App edit field kept changing: $label") + } + fun dhcp(enabled: Boolean) { + val label = find("自动获取IP(DHCP)") + val bounds = label.visibleBounds + val toggle = if (label.isCheckable) label else checkNotNull(ui.findObjects(By.checkable(true)) + .filter { kotlin.math.abs(it.visibleCenter.y - bounds.centerY()) < bounds.height().coerceAtLeast(48) } + .maxByOrNull { it.visibleCenter.x }) { "DHCP switch unavailable beside its label" } + if (toggle.isChecked != enabled) click(toggle) + } + fun choose(label: String) { + repeat(6) { + try { + val text = find(label) + val chip = generateSequence(text) { it.parent }.first { it.isClickable } + if (chip.isChecked) return + if (chip.isEnabled) click(chip) + Thread.sleep(500) + val updated = generateSequence(find(label)) { it.parent }.first { it.isClickable } + if (updated.isChecked) return + } catch (_: androidx.test.uiautomator.StaleObjectException) { + Thread.sleep(300) + } + } + error("App choice did not become selected: $label") + } + fun findStatus(): String { + repeat(14) { + ui.findObject(By.textStartsWith("当前WiFi:"))?.let { return it.text } + scroll(false) + } + error("Current WiFi status unavailable") + } + fun assertWifi(saved: String, active: String) { + val label = find("WiFi名称") + val field = generateSequence(label) { it.parent }.first { it.className == "android.widget.EditText" } + assertEquals(saved, field.text) + val current = findStatus() + assertTrue(current.contains(active)) + val ip = Regex("\\b(?:[0-9]{1,3}\\.){3}[0-9]{1,3}\\b").find(current)?.value + checkNotNull(ip) { "Current WiFi IP unavailable" } + File(instrumentation.targetContext.filesDir, "qms-wifi-test-ip.txt").writeText(ip, Charsets.UTF_8) + } + ui.wakeUp() + ui.executeShellCommand("am start -W -n org.qimiaoscreen.controller/.MainActivity") + check(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000)) + if (!ui.wait(Until.hasObject(By.text("断开连接")), 25_000)) { + click(find("扫描设备")) + val board = checkNotNull(ui.wait(Until.findObject(By.text(value("board_name"))), 20_000)) + click(checkNotNull(board.parent.parent.findObject(By.text("连接")))) + check(ui.wait(Until.hasObject(By.text("断开连接")), 30_000)) + } + click(find("设备设置")) + check(ui.wait(Until.hasObject(By.text("屏幕方向")), 15_000)) + ui.waitForIdle(1_000) + click(find("WiFi设置 展开")) + check(ui.wait(Until.hasObject(By.text("WiFi设置 收起")), 5_000)) + when (stage) { + "validate" -> { + edit("WiFi名称", "") + val field = generateSequence(find("WiFi名称")) { it.parent }.first { it.className == "android.widget.EditText" } + instrumentation.sendStatus(2, android.os.Bundle().apply { putString("stream", "UI tap diagnostic: SSID empty=${field.text.isEmpty()}\n") }) + val save = find("保存WiFi设置") + assertTrue("Empty SSID must disable save", generateSequence(save) { it.parent }.any { !it.isEnabled }) + edit("WiFi名称", value("original_ssid")) + assertWifi(value("original_ssid"), value("original_ssid")) + } + "validate_invalid" -> { + edit("WiFi名称", value("original_ssid")) + dhcp(false) + edit("静态IP地址", "999.999.999.999") + edit("前缀长度(例如24)", "24") + edit("网关", value("original_gateway")) + click(find("保存WiFi设置")) + check(ui.wait(Until.hasObject(By.text("立即生效WiFi设置")), 5_000)) + click(find("确定")) + check(ui.wait(Until.hasObject(By.textContains("参数无效")), 12_000)) { "Invalid IPv4 was not rejected" } + click(find("知道了")) + } + "scan" -> { + click(find("扫描WiFi")) + Thread.sleep(12_000) + check(!ui.hasObject(By.text("知道了"))) { "App WiFi scan reported an error" } + } + "wrong", "dhcp", "manual", "dhcp_return", "restore" -> { + val target = if (stage == "restore") value("original_ssid") else value("hotspot_ssid") + edit("WiFi名称", target) + if (stage == "wrong" || stage == "dhcp" || stage == "restore") { + val password = if (stage == "wrong") value("wrong_password") else if (stage == "restore") value("original_password") else value("hotspot_password") + edit("WiFi密码", password) + val show = find("显示输入的密码") + val bounds = show.visibleBounds + val checkbox = if (show.isCheckable) show else checkNotNull(ui.findObjects(By.checkable(true)) + .filter { kotlin.math.abs(it.visibleCenter.y - bounds.centerY()) < bounds.height().coerceAtLeast(48) } + .minByOrNull { it.visibleCenter.x }) + click(checkbox) + val label = find("WiFi密码") + val field = generateSequence(label) { it.parent }.first { it.className == "android.widget.EditText" } + assertEquals(password, field.text) + } + dhcp(!(stage == "manual" || stage == "restore" && value("original_mode") == "manual")) + if (stage == "manual" || stage == "restore" && value("original_mode") == "manual") { + val prefix = if (stage == "manual") "24" else value("original_prefix") + val address = if (stage == "manual") value("static_address") else value("original_address") + val gateway = if (stage == "manual") value("hotspot_gateway") else value("original_gateway") + val dns = if (stage == "manual") value("hotspot_gateway") else value("original_dns") + edit("静态IP地址", address) + edit("前缀长度(例如24)", prefix) + edit("网关", gateway) + edit("DNS(多个用逗号分隔)", dns) + } + click(find("保存WiFi设置")) + check(ui.wait(Until.hasObject(By.text("立即生效WiFi设置")), 5_000)) + click(find("确定")) + if (stage == "wrong") { + Thread.sleep(45_000) + check(listOf("密码错误", "网络连接失败", "WiFi不存在", "无法获取IP").any { ui.hasObject(By.textContains(it)) }) { + "Wrong-password attempt must show a WiFi failure" + } + instrumentation.sendStatus(2, android.os.Bundle().apply { putString("stream", + if (ui.hasObject(By.textContains("密码错误"))) "AUTH_FAILED_CONFIRMED\n" else "WIFI_FAILURE_UNCLASSIFIED\n") }) + } else { + Thread.sleep(5_000) + } + assertFalse(ui.hasObject(By.text(value("hotspot_password")))) + assertFalse(ui.hasObject(By.text(value("original_password")))) + assertFalse(ui.hasObject(By.text(value("wrong_password")))) + } + "verify" -> assertWifi(value("original_ssid"), value("original_ssid")) + "inspect_failure" -> { + val current = findStatus() + check(current.contains("失败") || current.contains("密码错误") || current.contains("不存在") || current.contains("无法获取IP")) { "Expected human-readable WiFi failure" } + } + else -> error("Unknown WiFi test stage") + } + // Release the authenticated owner before the next instrumentation process. + ui.findObject(By.text("知道了"))?.let(::click) + click(find("连接")) + val disconnect = ui.wait(Until.findObject(By.text("断开连接")), 3_000) + if (disconnect != null) click(disconnect) + Thread.sleep(2_000) + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/AndroidManifest.xml b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/AndroidManifest.xml new file mode 100644 index 0000000..df1d3be --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidBle.kt b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidBle.kt new file mode 100644 index 0000000..4d882fc --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidBle.kt @@ -0,0 +1,179 @@ +package org.qimiaoscreen.controller + +import android.annotation.SuppressLint +import android.bluetooth.* +import android.bluetooth.le.* +import android.content.Context +import android.os.ParcelUuid +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import org.qimiaoscreen.core.* +import java.util.UUID + +/** Permissions are requested by the Activity before calling this adapter. */ +@SuppressLint("MissingPermission") +class AndroidBle(private val context: Context) { + private val adapter get() = context.getSystemService(BluetoothManager::class.java).adapter + private var scanning: ScanCallback? = null + fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) { + stopScan() + val scanner = adapter?.bluetoothLeScanner ?: run { onError("请打开手机蓝牙"); return } + val callback = object : ScanCallback() { + override fun onScanResult(type: Int, result: ScanResult) { + onDevice(DiscoveredDevice(result.device.address, result.scanRecord?.deviceName ?: "奇妙小屏幕", result.rssi)) + } + override fun onScanFailed(code: Int) { onError("蓝牙扫描失败($code)") } + } + scanning = callback + scanner.startScan(listOf(ScanFilter.Builder().setServiceUuid(ParcelUuid(UUID.fromString(Protocol.SERVICE))).build()), + ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), callback) + } + fun stopScan() { scanning?.let { adapter?.bluetoothLeScanner?.stopScan(it) }; scanning = null } + suspend fun connect(handle: String): DeviceLink { + stopScan() + // Retry only GATT establishment, before any application request exists. + // Never switch device or replay an ambiguous business mutation. + repeat(2) { attempt -> + val link = GattLink() + try { + val gatt = adapter.getRemoteDevice(handle).connectGatt(context, false, link.callback, BluetoothDevice.TRANSPORT_LE) + link.attach(gatt) + withTimeout(15_000) { link.ready.await() } + return link + } catch (e: Exception) { + link.close() + if (e is CancellationException && e !is TimeoutCancellationException) throw e + if (attempt == 1) { + if (e is TimeoutCancellationException) throw DeviceTransportFailure("蓝牙连接建立超时,请重新扫描") + throw e + } + delay(1_000) + } + } + error("蓝牙连接失败") + } +} + +@SuppressLint("MissingPermission") +private class GattLink : DeviceLink { + @Volatile override var mtu = 23 + private set + val ready = CompletableDeferred() + private val messages = Channel(256) + private val writes = Mutex() + @Volatile private var pending: CompletableDeferred? = null + @Volatile private var gatt: BluetoothGatt? = null + @Volatile private var rx: BluetoothGattCharacteristic? = null + @Volatile private var closed = false + private var signalPending: CompletableDeferred? = null + private var discoveryStarted = false + private val handler = Handler(Looper.getMainLooper()) + private val discoveryFallback = Runnable { gatt?.let(::discoverOnce) } + @Synchronized private fun discoverOnce(g: BluetoothGatt) { + if (closed || discoveryStarted) return + discoveryStarted = true + handler.removeCallbacks(discoveryFallback) + if (!g.discoverServices()) fail() + } + fun attach(value: BluetoothGatt) { if (closed) value.close() else gatt = value } + private fun fail() { + val error = IllegalStateException("蓝牙连接已中断") + ready.completeExceptionally(error) + pending?.completeExceptionally(error) + messages.close(error) + close() + } + val callback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(g: BluetoothGatt, status: Int, state: Int) { + if (status != BluetoothGatt.GATT_SUCCESS || state == BluetoothProfile.STATE_DISCONNECTED) { fail(); return } + if (state == BluetoothProfile.STATE_CONNECTED) { + attach(g) + if (!g.requestMtu(247)) discoverOnce(g) + else handler.postDelayed(discoveryFallback, 2_000) + } + } + override fun onReadRemoteRssi(g: BluetoothGatt, rssi: Int, status: Int) { + signalPending?.complete(if (status == BluetoothGatt.GATT_SUCCESS) rssi else null) + } + override fun onMtuChanged(g: BluetoothGatt, value: Int, status: Int) { + // Some vendor stacks report 517 and then 247 for one request. + // Keep the advertised receive limit conservative and do not queue + // duplicate service discovery / CCCD writes on those callbacks. + if (status == BluetoothGatt.GATT_SUCCESS) mtu = value.coerceIn(23, 247) + // A cached 517 callback can precede completion of our 247 request. + // Starting discovery on it stalls this vendor stack. Wait for the + // requested size, or the bounded fallback if the stack chooses more. + if (status != BluetoothGatt.GATT_SUCCESS || value <= 247) discoverOnce(g) + } + override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { + if (status != BluetoothGatt.GATT_SUCCESS) { fail(); return } + val service = g.getService(UUID.fromString(Protocol.SERVICE)) + rx = service?.getCharacteristic(UUID.fromString(Protocol.RX)) + val tx = service?.getCharacteristic(UUID.fromString(Protocol.TX)) + val descriptor = tx?.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")) + if (rx == null || tx == null || descriptor == null || !g.setCharacteristicNotification(tx, true)) { fail(); return } + @Suppress("DEPRECATION") + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + @Suppress("DEPRECATION") + if (!g.writeDescriptor(descriptor)) fail() + } + override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { + if (status == BluetoothGatt.GATT_SUCCESS) ready.complete(Unit) else fail() + } + @Deprecated("Legacy Android callback") + override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { + @Suppress("DEPRECATION") + enqueue(characteristic, characteristic.value) + } + override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) { + enqueue(characteristic, value) + } + override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { + if (status == BluetoothGatt.GATT_SUCCESS) pending?.complete(Unit) else fail() + } + } + private fun enqueue(characteristic: BluetoothGattCharacteristic, value: ByteArray) { + if (characteristic.uuid.toString() != Protocol.TX || !messages.trySend(value.copyOf()).isSuccess) fail() + } + override suspend fun send(fragment: ByteArray) = writes.withLock { + check(!closed) + val completion = CompletableDeferred() + pending = completion + try { + val characteristic = checkNotNull(rx) + characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + @Suppress("DEPRECATION") + characteristic.value = fragment + @Suppress("DEPRECATION") + check(checkNotNull(gatt).writeCharacteristic(characteristic)) + withTimeout(5_000) { completion.await() } + } finally { pending = null } + } + override suspend fun receive(): ByteArray = messages.receive() + override suspend fun readSignal(): Int? { + if (closed) return null + val completion = CompletableDeferred() + signalPending = completion + return try { + if (gatt?.readRemoteRssi() != true) null else withTimeout(1_000) { completion.await() } + } catch (_: TimeoutCancellationException) { null } finally { signalPending = null } + } + override fun close() { + if (closed) return + closed = true + handler.removeCallbacks(discoveryFallback) + val error = IllegalStateException("蓝牙连接已关闭") + ready.completeExceptionally(error) + pending?.completeExceptionally(error) + messages.close() + gatt?.disconnect(); gatt?.close(); gatt = null + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/MainActivity.kt b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/MainActivity.kt new file mode 100644 index 0000000..e28c2c9 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/androidApp/src/main/kotlin/org/qimiaoscreen/controller/MainActivity.kt @@ -0,0 +1,65 @@ +package org.qimiaoscreen.controller + +import android.os.Bundle +import android.os.Build +import android.Manifest +import android.content.pm.PackageManager +import android.graphics.BitmapFactory +import android.util.Base64 +import android.app.AlertDialog +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.result.ActivityResultLauncher +import androidx.compose.ui.graphics.asImageBitmap +import kotlinx.coroutines.* +import org.qimiaoscreen.core.* +import org.qimiaoscreen.ui.QimiaoApp +import org.qimiaoscreen.ui.AppInfo + +class MainActivity : ComponentActivity() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private lateinit var controller: Controller + private val permissions get() = if (Build.VERSION.SDK_INT >= 31) arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + private fun permitted() = permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED } + private val requestPermission: ActivityResultLauncher> = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + if (permitted()) controller.scan() + else { + controller.reportError("蓝牙权限未授予,无法扫描和连接设备") + val permanent = permissions.any { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED && !shouldShowRequestPermissionRationale(it) } + AlertDialog.Builder(this).setTitle("需要蓝牙扫描权限") + .setMessage(if (permanent) "请在应用设置中允许附近设备权限;Android 8–11 需要定位权限用于蓝牙扫描。" else "权限只用于发现和连接小屏幕,不读取联系人或手机文件。") + .setNegativeButton("暂不", null) + .setPositiveButton(if (permanent) "打开应用设置" else "重试") { _, _ -> + if (permanent) startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))) + else requestPermission.launch(permissions) + }.show() + } + } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val ble = AndroidBle(applicationContext) + val preferences = getSharedPreferences("controller", MODE_PRIVATE) + val platform = object : MobilePlatform { + override val model = Build.MODEL + 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) + override fun stopScan() { if (permitted()) ble.stopScan() } + override suspend fun connect(handle: String) = ble.connect(handle) + override fun crypto(): SecureChannel = JavaSecureChannel() + } + controller = Controller(platform, scope) + setContent { QimiaoApp(controller, onScan = { + if (permitted()) controller.scan() else requestPermission.launch(permissions) + }, appInfo = AppInfo(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE.toString(), BuildConfig.BUILD_DATE), decode = { encoded -> + runCatching { val bytes = Base64.decode(encoded, Base64.DEFAULT); BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() }.getOrNull() + }) } + } + override fun onStart() { super.onStart(); if (::controller.isInitialized && permitted()) controller.foreground(true) } + override fun onStop() { if (::controller.isInitialized) controller.foreground(false); super.onStop() } + override fun onDestroy() { if (::controller.isInitialized) controller.disconnect(); scope.cancel(); super.onDestroy() } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/build.gradle.kts b/移动端相关内容/安卓app/安卓程序源代码/build.gradle.kts new file mode 100644 index 0000000..00aa71f --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.kmp) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.compose.multiplatform) apply false + alias(libs.plugins.compose.compiler) apply false +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/gradle.properties b/移动端相关内容/安卓app/安卓程序源代码/gradle.properties new file mode 100644 index 0000000..c9613f4 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx3072m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.workers.max=3 +android.useAndroidX=true +kotlin.code.style=official +android.overridePathCheck=true diff --git a/移动端相关内容/安卓app/安卓程序源代码/gradle/libs.versions.toml b/移动端相关内容/安卓app/安卓程序源代码/gradle/libs.versions.toml new file mode 100644 index 0000000..dcba511 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/gradle/libs.versions.toml @@ -0,0 +1,20 @@ +[versions] +kotlin = "2.4.20" +agp = "9.3.1" +compose = "1.12.1" +coroutines = "1.10.2" +serialization = "1.9.0" + +[libraries] +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } +activity-compose = { module = "androidx.activity:activity-compose", version = "1.11.0" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-kmp = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/移动端相关内容/安卓app/安卓程序源代码/gradle/wrapper/gradle-wrapper.jar b/移动端相关内容/安卓app/安卓程序源代码/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/移动端相关内容/安卓app/安卓程序源代码/gradle/wrapper/gradle-wrapper.jar differ diff --git a/移动端相关内容/安卓app/安卓程序源代码/gradle/wrapper/gradle-wrapper.properties b/移动端相关内容/安卓app/安卓程序源代码/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4b271a6 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,10 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/移动端相关内容/安卓app/安卓程序源代码/gradlew b/移动端相关内容/安卓app/安卓程序源代码/gradlew new file mode 100644 index 0000000..b9bb139 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/移动端相关内容/安卓app/安卓程序源代码/gradlew.bat b/移动端相关内容/安卓app/安卓程序源代码/gradlew.bat new file mode 100644 index 0000000..24c62d5 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/移动端相关内容/安卓app/安卓程序源代码/settings.gradle.kts b/移动端相关内容/安卓app/安卓程序源代码/settings.gradle.kts new file mode 100644 index 0000000..c766a13 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/settings.gradle.kts @@ -0,0 +1,7 @@ +pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { google(); mavenCentral() } +} +rootProject.name = "QimiaoScreen" +include(":androidApp", ":sharedCore", ":sharedUi") diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/build.gradle.kts b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/build.gradle.kts new file mode 100644 index 0000000..009e0aa --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kmp) + alias(libs.plugins.kotlin.serialization) +} +kotlin { + android { + namespace = "org.qimiaoscreen.core" + compileSdk = 37 + minSdk = 26 + withHostTestBuilder {} + } + jvm() + sourceSets { + getByName("androidMain").kotlin.srcDir("src/javaCryptoMain/kotlin") + getByName("jvmMain").kotlin.srcDir("src/javaCryptoMain/kotlin") + commonMain.dependencies { + implementation(libs.coroutines.core) + implementation(libs.serialization.json) + } + commonTest.dependencies { implementation(kotlin("test")) } + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt new file mode 100644 index 0000000..e832aba --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt @@ -0,0 +1,339 @@ +package org.qimiaoscreen.core + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.* +import kotlin.time.Clock + +interface MobilePlatform { + val model: String + fun load(key: String): String? + fun save(key: String, value: String) + fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) + fun stopScan() + suspend fun connect(handle: String): DeviceLink + fun crypto(): SecureChannel +} + +data class RememberedDevice(val id: String, val name: String, val shortId: String, val lastConnected: Long) +data class WifiDraft(val ssid: String = "", val security: String = "wpa-psk", val manual: Boolean = false, + val address: String = "", val prefix: String = "24", val gateway: String = "", val dns: String = "") +data class ControllerState( + val remembered: List = emptyList(), val signal: Int? = null, + val wifiConnecting: Boolean = false, val wifiError: String? = null, val wifiTarget: String? = null, + val wifiDraft: WifiDraft? = null, + val page: Int = 0, val phase: String = "未连接", val scanning: Boolean = false, + val busy: Boolean = false, val error: String? = null, val clientName: String = "", + val devices: List = emptyList(), val identity: JsonObject? = null, + val status: JsonObject? = null, val storage: JsonObject? = null, + val settings: JsonObject? = null, val wifi: JsonObject? = null, + val library: List = emptyList(), val networks: List = emptyList(), + val thumbnails: Map = emptyMap(), val frame: String? = null, + val frameCheckedAtMs: Long? = null, val defaultContent: JsonObject? = null, +) + +class Controller(private val platform: MobilePlatform, private val scope: CoroutineScope) { + private val mutable = MutableStateFlow(ControllerState(clientName = platform.load("client_name") ?: platform.model, remembered = loadRemembered())) + val state: StateFlow = mutable.asStateFlow() + private fun loadRemembered(): List = runCatching { + platform.load("remembered_devices")?.let { Protocol.json.parseToJsonElement(it).jsonArray.map { item -> + val o = item.jsonObject + RememberedDevice(o.getValue("id").jsonPrimitive.content, o.getValue("name").jsonPrimitive.content, + o.getValue("short_id").jsonPrimitive.content, o.getValue("last_connected").jsonPrimitive.long) + } } ?: emptyList() + }.getOrDefault(emptyList()) + private fun persistRemembered(items: List) { + platform.save("remembered_devices", JsonArray(items.map { buildJsonObject { + put("id", it.id); put("name", it.name); put("short_id", it.shortId); put("last_connected", it.lastConnected) + } }).toString()) + change { it.copy(remembered = items) } + } + fun rememberedFor(device: DiscoveredDevice) = state.value.remembered.firstOrNull { device.name == "QMS-${it.shortId}" } + fun forget(id: String) { + if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == id) disconnect() + persistRemembered(state.value.remembered.filterNot { it.id == id }) + if (platform.load("last_device_id") == id) platform.save("last_device_id", "") + platform.save("forgotten_devices", JsonArray((forgottenIds() + id).map { JsonPrimitive(it) }).toString()) + } + private fun forgottenIds(): Set = runCatching { platform.load("forgotten_devices")?.let { + Protocol.json.parseToJsonElement(it).jsonArray.map { v -> v.jsonPrimitive.content }.toSet() + } ?: emptySet() }.getOrDefault(emptySet()) + fun connectRemembered(device: RememberedDevice) { + if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == device.id) return + val found = state.value.devices.firstOrNull { it.name == "QMS-${device.shortId}" } + if (found != null) connect(found, device.id) else { requestedDeviceId = device.id; scan() } + } + private var requestedDeviceId: String? = null + private var activeLink: DeviceLink? = null + fun wifiDraft(value: WifiDraft) = change { it.copy(wifiDraft = value) } + private var rpc: DeviceRpc? = null + private val commands = Mutex() + private var scanJob: Job? = null + private var pollJob: Job? = null + private var releaseJob: Job? = null + private var foreground = true + private var frameRevision: String? = null + private var connectionJob: Job? = null + private var commandJob: Job? = null + private var connectionGeneration = 0L + private var scanGeneration = 0L + private val pendingThumbnails = linkedMapOf() + 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) } + fun scan(auto: Boolean = false) { + if (connectionJob?.isActive == true) return + scanJob?.cancel() + platform.stopScan() + val generation = ++scanGeneration + change { it.copy(scanning = true, devices = emptyList(), error = null, phase = if (it.identity != null) it.phase else if (auto) "正在重连上次设备" else "正在扫描") } + val lastId = platform.load("last_device_id")?.takeIf { it.isNotBlank() && it !in forgottenIds() } + val short = lastId?.replace("-", "")?.take(8) + platform.scan({ device -> scope.launch { + if (!state.value.scanning || generation != scanGeneration) return@launch + change { it.copy(devices = (it.devices.filterNot { old -> old.handle == device.handle } + device).sortedByDescending { d -> d.signal }) } + val requested = requestedDeviceId + if (requested != null && device.name == "QMS-${requested.replace("-", "").take(8)}") { + requestedDeviceId = null; connect(device, requested) + } else if (auto && short != null && device.name == "QMS-$short") connect(device, lastId) + } }, { error -> scope.launch { + if (generation == scanGeneration) change { it.copy(error = error, scanning = false, phase = if (it.identity != null) it.phase else "未连接") } + } }) + scanJob = scope.launch { + delay(12_000); if (generation != scanGeneration) return@launch; platform.stopScan(); requestedDeviceId = null + change { it.copy(scanning = false, phase = if (it.identity == null) "未连接" else it.phase, + error = if (auto && it.identity == null && connectionJob?.isActive != true) "未找到上次设备,可稍后重试" else it.error) } + } + } + fun connect(device: DiscoveredDevice, expectedId: String? = null) { + if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == expectedId && expectedId != null) return + if (connectionJob?.isActive == true || rpc != null) disconnect() + scanGeneration++; scanJob?.cancel(); platform.stopScan() + val generation = ++connectionGeneration + frameRevision = null; pendingThumbnails.clear() + change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, devices = it.devices, phase = "正在连接") } + connectionJob = scope.launch { + var pending: DeviceRpc? = null + try { + activeLink = platform.connect(device.handle) + pending = DeviceRpc(checkNotNull(activeLink), platform.crypto()) + val identity = pending.open(state.value.clientName) + val id = identity.getValue("device_id").jsonPrimitive.content + require(expectedId == null || expectedId == id) { "设备身份与上次连接不一致" } + require(identity.getValue("protocol_major").jsonPrimitive.int == 1) { "通信协议不兼容" } + rpc = pending + platform.save("last_device_id", id) + platform.save("forgotten_devices", JsonArray((forgottenIds() - id).map { JsonPrimitive(it) }).toString()) + persistRemembered((state.value.remembered.filterNot { it.id == id } + RememberedDevice(id, + identity.getValue("device_name").jsonPrimitive.content, identity.getValue("short_id").jsonPrimitive.content, + Clock.System.now().toEpochMilliseconds())).sortedByDescending { it.lastConnected }) + change { it.copy(identity = identity, phase = "已连接", signal = device.signal) } + refreshAll() + startPolling() + } catch (e: Exception) { + pending?.close() + if (generation == connectionGeneration) { + rpc = null; activeLink = null + if (e !is CancellationException) change { it.copy(identity = null, phase = "未连接", error = e.message ?: "连接失败") } + } + } + } + } + fun disconnect() { + 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() + 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() + change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, phase = "连接中断", error = message) } + } + fun foreground(value: Boolean) { + foreground = value + releaseJob?.cancel() + if (value) { + if (rpc == null && !platform.load("last_device_id").isNullOrBlank()) scan(auto = true) + } else { + 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() } } + 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) + private fun action(block: suspend () -> Unit) { + if (state.value.busy || rpc == null) return + change { it.copy(busy = true, error = null) } + val generation = connectionGeneration + commandJob = scope.launch { + try { commands.withLock { block() } } + catch (e: DeviceFailure) { change { it.copy(error = e.message) } } + catch (e: Exception) { + if (e !is CancellationException && generation == connectionGeneration) { + connectionLost(e.message ?: "操作结果未确认,请重连后检查设备状态"); pollJob?.cancel() + } + } finally { if (generation == connectionGeneration) change { it.copy(busy = false) } } + } + } + private suspend fun refreshAll() { commands.withLock { refreshStatus(); refreshPage() } } + private suspend fun refreshStatus() { + val result = call("status.get") + val wifi = if (supports("wifi")) call("wifi.get") else state.value.wifi + val signal = try { activeLink?.readSignal() } catch (e: CancellationException) { throw e } catch (_: Exception) { null } + currentCoroutineContext().ensureActive() + change { it.copy(status = result, wifi = wifi, signal = signal) } + } + private suspend fun refreshPage() { + when (state.value.page) { + 0 -> Unit + 1 -> if (supports("frame")) refreshFrame() + 2 -> if (supports("library")) { + var items: List = emptyList() + for (attempt in 0..1) { + try { + val loaded = mutableListOf() + 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) } + } + 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 fun startPolling() { + pollJob?.cancel() + pollJob = scope.launch { + while (isActive && rpc != null) { + delay(2_000) + if (state.value.busy || !commands.tryLock()) continue + try { + 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) + } + } + } catch (e: DeviceFailure) { + // A stale thumbnail or other business rejection does not + // invalidate the authenticated transport. + change { it.copy(error = e.message) } + } catch (e: Exception) { + if (e !is CancellationException) { + connectionLost(e.message ?: "连接已断开,请重试") + } + } finally { commands.unlock() } + } + } + } + private suspend fun refreshFrame() { + val result = call("frame.get", buildJsonObject { frameRevision?.let { put("known_revision", it) } }) + frameRevision = result["frame_revision"]?.jsonPrimitive?.content + result["data_base64"]?.jsonPrimitive?.content?.let { image -> change { it.copy(frame = image) } } + change { it.copy(frameCheckedAtMs = Clock.System.now().toEpochMilliseconds()) } + } + fun refresh() = action { refreshStatus(); refreshPage(); if (state.value.page == 2 && supports("frame")) refreshFrame() } + fun thumbnail(item: JsonObject) { + val key = thumbnailKey(item) + if (key !in state.value.thumbnails) { + if (pendingThumbnails.size < 128) pendingThumbnails[key] = item + } + } + fun play(item: JsonObject, default: Boolean = false) = action { + call(if (default) "content.default.set" else "content.play", item); refreshStatus() + if (default) { val value = call("content.default.get"); change { it.copy(defaultContent = value) } } + } + fun playback(changes: JsonObject) = action { + val playback = state.value.status?.get("state")?.jsonObject?.get("animation_playback")?.jsonObject + val session = playback?.get("session_id") ?: throw DeviceFailure("NOT_READY", "当前没有可控制的动图") + call("playback.patch", JsonObject(changes + ("session_id" to session))); refreshStatus() + } + fun setting(key: String, value: JsonElement) = action { + val revision = state.value.settings?.get("revision") ?: return@action + val result = call("settings.patch", buildJsonObject { put("expected_revision", revision); put("changes", JsonObject(mapOf(key to value))) }) + change { it.copy(settings = result) }; refreshStatus() + } + fun renameDevice(name: String, id: String? = state.value.identity?.get("device_id")?.jsonPrimitive?.content) { + if (id == null || state.value.identity?.get("device_id")?.jsonPrimitive?.content != id) { reportError("请连接设备"); return } + action { + val value = call("device.rename", buildJsonObject { put("name", name.trim()) }) + change { it.copy(identity = value) } + persistRemembered(state.value.remembered.map { if (it.id == id) it.copy(name = value.getValue("device_name").jsonPrimitive.content) else it }) + } + } + fun renamePhone(name: String) { + val normalized = name.trim() + if (normalized.isBlank() || normalized.length > 40 || normalized.any { it.code < 32 || it.code == 127 }) { + reportError("名称须为 1 至 40 个字符,不能包含控制字符"); return + } + if (state.value.busy) return + platform.save("client_name", normalized); change { it.copy(clientName = normalized) } + if (rpc != null) action { call("session.rename", buildJsonObject { put("client_name", normalized) }) } + } + fun scanWifi() = action { val result = call("wifi.scan"); change { it.copy(networks = result.getValue("networks").jsonArray.map { n -> n.jsonObject }) } } + fun saveWifi(values: JsonObject) = action { + val revision = state.value.wifi?.get("revision") ?: return@action + val target = values.getValue("ssid").jsonPrimitive.content + val result = call("wifi.set", JsonObject(values + ("expected_revision" to revision))) + change { it.copy(wifiConnecting = true, wifiError = null, wifiTarget = target) } + try { + val task = result["task_id"] + var finished = task == null + for (attempt in 0 until 60) { + if (finished) break + delay(1_000) + val value = call("task.get", buildJsonObject { put("task_id", checkNotNull(task)) }) + when (value["state"]?.jsonPrimitive?.content) { + "succeeded" -> finished = true + "failed" -> { + val message = when (value["error_code"]?.jsonPrimitive?.content) { + "AUTH_FAILED" -> "${target}密码错误" + "NETWORK_UNAVAILABLE" -> "WiFi不存在或暂不可达" + "IP_CONFIG_FAILED" -> "无法获取IP,请检查地址设置" + else -> "网络连接失败,请检查设置" + } + change { it.copy(wifiError = message) } + throw DeviceFailure("DEVICE_ERROR", message) + } + } + } + if (!finished) { change { it.copy(wifiError = "连接结果尚未确认,请刷新") }; throw DeviceFailure("TIMEOUT", "网络任务仍在进行,请刷新状态") } + } finally { change { it.copy(wifiConnecting = false) }; refreshStatus() } + refreshPage() + } +} + +fun wifiSummary(state: ControllerState): String { + if (state.identity == null) return "未连接" + if (state.wifiConnecting) return "连接中" + state.wifiError?.let { return it } + val active = state.wifi?.get("active")?.jsonObject ?: return "WiFi状态读取中" + if (active["connected"]?.jsonPrimitive?.booleanOrNull != true) return "未连接,WiFi不存在" + return listOfNotNull(active["ssid"]?.jsonPrimitive?.contentOrNull, active["ipv4_address"]?.jsonPrimitive?.contentOrNull).joinToString(" · ") +} + +fun thumbnailKey(item: JsonObject): String = listOf("type", "id", "revision") + .joinToString("|") { item.getValue(it).jsonPrimitive.content } diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/DeviceLink.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/DeviceLink.kt new file mode 100644 index 0000000..aa76cd7 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/DeviceLink.kt @@ -0,0 +1,74 @@ +package org.qimiaoscreen.core + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.serialization.json.* + +data class DiscoveredDevice(val handle: String, val name: String, val signal: Int) + +/** Platform owns the GATT connection. Handles are ephemeral, never device identity. */ +interface DeviceLink { + val mtu: Int + suspend fun send(fragment: ByteArray) + suspend fun receive(): ByteArray + fun close() + suspend fun readSignal(): Int? = null +} + +class DeviceFailure(val code: String, message: String) : Exception(message) +class DeviceTransportFailure(message: String) : Exception(message) + +class DeviceRpc(private val link: DeviceLink, private val crypto: SecureChannel, private val requestTimeoutMs: Long = 10_000) { + private val mutex = Mutex() + private var tx = 0L + private var request = 0L + private val receiver = Reassembler() + private var usable = true + private suspend fun send(kind: Int, payload: ByteArray) { + Protocol.encode(kind, tx++, payload, link.mtu).forEach { link.send(it) } + } + private suspend fun receive(): Pair = withTimeout(15_000) { + var result: Pair? = null + while (result == null) result = receiver.accept(link.receive()) + result + } + suspend fun open(name: String): JsonObject = mutex.withLock { + try { + withTimeout(10_000) { + send(1, crypto.hello) + val (kind, payload) = receive() + require(kind == 2) { "设备未接受握手" } + crypto.finish(payload) + exchange("session.open", buildJsonObject { put("client_name", name); put("receive_mtu", link.mtu) }) + } + } catch (e: TimeoutCancellationException) { + close(); throw DeviceTransportFailure("连接握手超时,请重新连接") + } catch (e: Exception) { close(); throw e } + } + suspend fun call(method: String, params: JsonObject = buildJsonObject {}): JsonObject = mutex.withLock { + check(usable) { "设备连接已关闭" } + try { + withTimeout(if (method == "wifi.scan") 20_000 else requestTimeoutMs) { exchange(method, params) } + } catch (e: TimeoutCancellationException) { + close(); throw DeviceTransportFailure("设备回复超时($method),请重新连接") + } catch (e: DeviceFailure) { throw e } + catch (e: Exception) { close(); throw e } + } + private suspend fun exchange(method: String, params: JsonObject): JsonObject { + val id = (++request).toString() + val raw = buildJsonObject { put("id", id); put("method", method); put("params", params) } + send(3, crypto.encrypt(raw.toString().encodeToByteArray())) + val (kind, record) = receive() + require(kind == 3) + val response = Protocol.json.parseToJsonElement(crypto.decrypt(record).decodeToString(throwOnInvalidSequence = true)).jsonObject + require(response["id"]?.jsonPrimitive?.content == id) + if (!response.getValue("ok").jsonPrimitive.boolean) { + val error = response.getValue("error").jsonObject + throw DeviceFailure(error.getValue("code").jsonPrimitive.content, error.getValue("message").jsonPrimitive.content) + } + return response.getValue("result").jsonObject + } + fun close() { usable = false; link.close() } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Protocol.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Protocol.kt new file mode 100644 index 0000000..208beec --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Protocol.kt @@ -0,0 +1,70 @@ +package org.qimiaoscreen.core + +import kotlinx.serialization.json.Json + +object Protocol { + const val SERVICE = "9f57a001-6c31-4c58-bc22-1f728b641001" + const val RX = "9f57a002-6c31-4c58-bc22-1f728b641001" + const val TX = "9f57a003-6c31-4c58-bc22-1f728b641001" + val json = Json { ignoreUnknownKeys = true } + const val MAX_MESSAGE = 65536 + fun encode(kind: Int, message: Long, payload: ByteArray, mtu: Int): List { + require(kind in 1..4 && message in 0..0xffffffffL && payload.size in 1..MAX_MESSAGE) + require(mtu in 23..517) + val size = mtu - 19 + require(size > 0) + val count = (payload.size + size - 1) / size + require(count <= 65535) + return (0 until count).map { index -> + val part = payload.copyOfRange(index * size, minOf(payload.size, (index + 1) * size)) + ByteArray(16 + part.size).apply { + putInt(0, 0x514d, 2); this[2] = 1; this[3] = kind.toByte() + putInt(4, message, 4); putInt(8, index.toLong(), 2) + putInt(10, count.toLong(), 2); putInt(12, payload.size.toLong(), 4) + part.copyInto(this, 16) + } + } + } +} + +fun ByteArray.putInt(offset: Int, value: Long, length: Int) { + for (i in 0 until length) this[offset + i] = (value ushr (8 * (length - i - 1))).toByte() +} +fun ByteArray.readInt(offset: Int, length: Int): Long { + require(offset + length <= size) + var result = 0L + for (i in offset until offset + length) result = (result shl 8) or (this[i].toLong() and 255) + return result +} + +class Reassembler { + private var id = -1L + private var count = 0 + private var total = 0 + private var index = 0 + private var kind = 0 + private var buffer = ByteArray(0) + private var offset = 0 + private var nextMessage = 0L + fun accept(part: ByteArray): Pair? { + require(part.size in 17..514 && part.readInt(0, 2) == 0x514dL && part[2].toInt() == 1) + val partId = part.readInt(4, 4) + val partIndex = part.readInt(8, 2).toInt() + val partCount = part.readInt(10, 2).toInt() + val partTotal = part.readInt(12, 4).toInt() + val partKind = part[3].toInt() + require(partKind in 1..4 && partCount in 1..partTotal && partTotal in 1..Protocol.MAX_MESSAGE) + if (index == 0) { + require(partIndex == 0 && partId == nextMessage) + id = partId; count = partCount; total = partTotal; kind = partKind + buffer = ByteArray(total); offset = 0 + } + require(partId == id && partIndex == index && partCount == count && partTotal == total && partKind == kind) + require(index < count && offset + part.size - 16 <= total) + part.copyInto(buffer, offset, 16); offset += part.size - 16; index++ + if (index != count) { require(offset < total); return null } + require(offset == total) + index = 0; nextMessage++ + return kind to buffer + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/SecureChannel.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/SecureChannel.kt new file mode 100644 index 0000000..555061a --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/SecureChannel.kt @@ -0,0 +1,9 @@ +package org.qimiaoscreen.core + +/** Platform crypto boundary; future iOS implementation must pass the same vectors. */ +interface SecureChannel { + val hello: ByteArray + fun finish(serverHello: ByteArray) + fun encrypt(payload: ByteArray): ByteArray + fun decrypt(record: ByteArray): ByteArray +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt new file mode 100644 index 0000000..272119a --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ControllerRecoveryTest.kt @@ -0,0 +1,202 @@ +package org.qimiaoscreen.core + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.* +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" + val preferences = mutableMapOf() + val links = mutableListOf() + val scanCallbacks = mutableListOf<(DiscoveredDevice) -> Unit>() + override fun load(key: String) = preferences[key] + 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").also { links += it } + 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 SimulatedLink(private val deviceId: String) : DeviceLink { + override val mtu = 247 + private val receiver = Reassembler() + private val messages = Channel(Channel.UNLIMITED) + private var sequence = 0L + var failure: String? = null + var blockStorage = false + var storageEntered = CompletableDeferred() + var closed = false + override suspend fun send(fragment: ByteArray) { + check(!closed) + val (kind, payload) = receiver.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 + 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"]}""" + "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"}""" + else -> "{}" + } + 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() { closed = true; messages.close() } +} + +class ControllerRecoveryTest { + private val device = DiscoveredDevice("test-handle", "test-device", -40) + + @Test fun lossClearsCachedStateAndReconnects() = runBlocking { + val platform = SimulatedPlatform() + 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(1) + withTimeout(2_000) { controller.state.first { it.frame != null && !it.busy } } + assertNotNull(controller.state.value.frameCheckedAtMs) + platform.links.last().failure = "status.get" + controller.refresh() + withTimeout(2_000) { controller.state.first { it.phase == "连接中断" } } + assertNull(controller.state.value.frame) + assertNull(controller.state.value.frameCheckedAtMs) + assertNull(controller.state.value.storage) + assertTrue(platform.links.last().closed) + controller.connect(device) + withTimeout(2_000) { controller.state.first { it.status != null } } + assertEquals(2, platform.links.size) + } finally { controller.disconnect(); scope.cancel() } + } + + @Test fun disconnectCancelsInFlightOperationWithoutCorruptingNextConnection() = runBlocking { + val platform = SimulatedPlatform() + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val controller = Controller(platform, scope) + try { + controller.connect(device) + withTimeout(2_000) { controller.state.first { it.status != null } } + val old = platform.links.last() + old.blockStorage = true + controller.refresh() + withTimeout(2_000) { old.storageEntered.await() } + controller.disconnect() + controller.connect(device) + withTimeout(2_000) { controller.state.first { it.status != null } } + assertTrue(old.closed) + assertFalse(controller.state.value.busy) + assertNull(controller.state.value.error) + assertFalse(platform.links.last().closed) + } finally { controller.disconnect(); scope.cancel() } + } + + @Test fun oldScanCallbackCannotPopulateNewScan() = runBlocking { + val platform = SimulatedPlatform() + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val controller = Controller(platform, scope) + try { + controller.scan(); controller.scan() + platform.scanCallbacks.first()(device) + yield() + assertTrue(controller.state.value.devices.isEmpty()) + platform.scanCallbacks.last()(device) + withTimeout(2_000) { controller.state.first { it.devices.size == 1 } } + Unit + } finally { controller.disconnect(); scope.cancel() } + } + @Test fun rememberedDevicesSurviveDisconnectAndForgetPreventsReconnect() = runBlocking { + val platform = SimulatedPlatform() + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val controller = Controller(platform, scope) + try { + controller.connect(device) + withTimeout(2_000) { controller.state.first { it.status != null } } + val remembered = controller.state.value.remembered.single() + assertEquals("模拟屏", remembered.name) + controller.scan() + assertNotNull(controller.state.value.identity) + assertTrue(controller.state.value.scanning) + platform.scanCallbacks.last()(device) + withTimeout(2_000) { controller.state.first { it.devices.isNotEmpty() } } + assertEquals(1, platform.links.size) + controller.disconnect() + assertEquals(remembered.id, Controller(platform, scope).state.value.remembered.single().id) + controller.forget(remembered.id) + controller.foreground(true) + assertFalse(controller.state.value.scanning) + assertTrue(controller.state.value.remembered.isEmpty()) + assertEquals("", platform.load("last_device_id")) + controller.renameDevice("不应执行", remembered.id) + assertEquals("请连接设备", controller.state.value.error) + } finally { controller.disconnect(); scope.cancel() } + } + + @Test fun selectingDeviceReleasesPreviousLinkAndForgettingActiveDisconnects() = runBlocking { + val platform = SimulatedPlatform() + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val controller = Controller(platform, scope) + try { + controller.connect(device) + withTimeout(2_000) { controller.state.first { it.status != null } } + val old = platform.links.single() + controller.connect(device.copy(handle = "second-handle")) + withTimeout(2_000) { controller.state.first { it.status != null } } + assertTrue(old.closed) + assertEquals(2, platform.links.size) + assertEquals(2, controller.state.value.remembered.size) + controller.forget("11111111-0000-4000-8000-000000000001") + assertNull(controller.state.value.identity) + assertTrue(platform.links.last().closed) + } finally { controller.disconnect(); scope.cancel() } + } + + @Test fun legacyLastDeviceMigratesAfterValidatedHandshake() = runBlocking { + val platform = SimulatedPlatform() + val id = "00000000-0000-4000-8000-000000000001" + platform.preferences["last_device_id"] = id + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val controller = Controller(platform, scope) + try { + controller.foreground(true) + platform.scanCallbacks.last()(device.copy(name = "QMS-00000000")) + withTimeout(2_000) { controller.state.first { it.status != null } } + assertEquals(id, controller.state.value.remembered.single().id) + } finally { controller.disconnect(); scope.cancel() } + } + + @Test fun renameUpdatesAuthenticatedIdentityAndRememberedName() = runBlocking { + val platform = SimulatedPlatform() + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val controller = Controller(platform, scope) + try { + controller.connect(device) + withTimeout(2_000) { controller.state.first { it.status != null } } + controller.renameDevice("客厅测试屏") + withTimeout(2_000) { controller.state.first { it.remembered.firstOrNull()?.name == "客厅测试屏" } } + assertEquals("客厅测试屏", controller.state.value.identity?.get("device_name")?.jsonPrimitive?.content) + controller.disconnect() + assertEquals("客厅测试屏", Controller(platform, scope).state.value.remembered.single().name) + } finally { controller.disconnect(); scope.cancel() } + } + +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ProtocolTest.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ProtocolTest.kt new file mode 100644 index 0000000..4dd64b6 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/commonTest/kotlin/org/qimiaoscreen/core/ProtocolTest.kt @@ -0,0 +1,21 @@ +package org.qimiaoscreen.core + +import kotlin.test.* + +class ProtocolTest { + @Test fun minimumMtuRoundTrip() { + val payload = "中文协议".repeat(20).encodeToByteArray() + val rx = Reassembler() + var result: Pair? = null + Protocol.encode(1, 0, payload, 23).forEach { result = rx.accept(it) } + assertEquals(1, result!!.first) + assertContentEquals(payload, result!!.second) + } + @Test fun rejectsOutOfOrderAndReplayedMessage() { + val frames = Protocol.encode(3, 0, ByteArray(20), 23) + assertFailsWith { Reassembler().accept(frames[1]) } + val rx = Reassembler() + frames.forEach { rx.accept(it) } + assertFailsWith { rx.accept(frames[0]) } + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/javaCryptoMain/kotlin/org/qimiaoscreen/core/JavaSecureChannel.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/javaCryptoMain/kotlin/org/qimiaoscreen/core/JavaSecureChannel.kt new file mode 100644 index 0000000..6391d88 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/javaCryptoMain/kotlin/org/qimiaoscreen/core/JavaSecureChannel.kt @@ -0,0 +1,115 @@ +package org.qimiaoscreen.core + +import java.math.BigInteger +import java.security.AlgorithmParameters +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECParameterSpec +import java.security.spec.ECPoint +import java.security.spec.ECPublicKeySpec +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.KeyAgreement +import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlinx.serialization.json.* + +/** JCA implementation shared by Android and JVM interoperability tests. */ +class JavaSecureChannel( + private val keyPair: KeyPair = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.generateKeyPair(), + private val random: ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) }, +) : SecureChannel { + private val label = "QMS-BLE-1".encodeToByteArray() + private var material: ByteArray? = null + private var transcript = ByteArray(0) + private var tx = 0L + private var rx = 0L + private var failed = false + private fun fixed(value: BigInteger): ByteArray = value.toByteArray().let { + if (it.size >= 32) it.copyOfRange(it.size - 32, it.size) else ByteArray(32 - it.size) + it + } + override val hello: ByteArray + init { + require(random.size == 32) + val point = (keyPair.public as ECPublicKey).w + val public = byteArrayOf(4) + fixed(point.affineX) + fixed(point.affineY) + hello = buildJsonObject { + put("protocol_major", 1); put("protocol_minor", 0) + put("public_key", Base64.getEncoder().encodeToString(public)) + put("random", Base64.getEncoder().encodeToString(random)) + }.toString().encodeToByteArray() + } + private fun hash(bytes: ByteArray) = MessageDigest.getInstance("SHA-256").digest(bytes) + private fun number(value: Long, length: Int) = ByteArray(length).apply { putInt(0, value, length) } + private fun mac(key: ByteArray, bytes: ByteArray) = Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(key, "HmacSHA256")); doFinal(bytes) + } + override fun finish(serverHello: ByteArray) { + check(material == null && !failed) + try { + require(serverHello.size <= 1024) + val peer = Protocol.json.parseToJsonElement(serverHello.decodeToString(throwOnInvalidSequence = true)).jsonObject + require(peer.getValue("protocol_major").jsonPrimitive.int == 1) + require(peer.getValue("protocol_minor").jsonPrimitive.int >= 0) + val public = Base64.getDecoder().decode(peer.getValue("public_key").jsonPrimitive.content) + val sr = Base64.getDecoder().decode(peer.getValue("random").jsonPrimitive.content) + require(public.size == 65 && public[0] == 4.toByte() && sr.size == 32) + val parameters = AlgorithmParameters.getInstance("EC").apply { init(ECGenParameterSpec("secp256r1")) } + .getParameterSpec(ECParameterSpec::class.java) + val point = ECPoint(BigInteger(1, public.copyOfRange(1, 33)), BigInteger(1, public.copyOfRange(33, 65))) + val peerKey = KeyFactory.getInstance("EC").generatePublic(ECPublicKeySpec(point, parameters)) + val secret = KeyAgreement.getInstance("ECDH").run { init(keyPair.private); doPhase(peerKey, true); generateSecret() } + transcript = hash(number(hello.size.toLong(), 4) + hello + number(serverHello.size.toLong(), 4) + serverHello) + val prk = mac(hash(random + sr), secret) + var block = ByteArray(0) + var expanded = ByteArray(0) + for (i in 1..3) { + block = mac(prk, block + label + transcript + byteArrayOf(i.toByte())) + expanded += block + } + material = expanded.copyOf(72) + secret.fill(0); prk.fill(0); expanded.fill(0) + } catch (e: Exception) { + failed = true + throw IllegalArgumentException("无法建立加密会话") + } + } + override fun encrypt(payload: ByteArray): ByteArray { + check(!failed && tx >= 0 && tx < Long.MAX_VALUE) + require(payload.size in 1..Protocol.MAX_MESSAGE - 24) + val seq = number(tx, 8) + val result = seq + crypt(Cipher.ENCRYPT_MODE, 0, seq, payload) + tx++ + return result + } + override fun decrypt(record: ByteArray): ByteArray { + try { + check(!failed && rx >= 0) + require(record.size in 25..Protocol.MAX_MESSAGE && record.readInt(0, 8) == rx) + val result = crypt(Cipher.DECRYPT_MODE, 1, record.copyOfRange(0, 8), record.copyOfRange(8, record.size)) + rx++ + return result + } catch (e: Exception) { + failed = true + material?.fill(0) + throw IllegalArgumentException("加密消息校验失败") + } + } + private fun crypt(mode: Int, direction: Int, seq: ByteArray, value: ByteArray): ByteArray { + val keys = checkNotNull(material) + return Cipher.getInstance("AES/GCM/NoPadding").run { + init(mode, SecretKeySpec(keys.copyOfRange(direction * 32, direction * 32 + 32), "AES"), + GCMParameterSpec(128, keys.copyOfRange(64 + direction * 4, 68 + direction * 4) + seq)) + updateAAD(label + transcript + byteArrayOf(direction.toByte()) + seq) + doFinal(value) + } + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/CryptoInteropTest.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/CryptoInteropTest.kt new file mode 100644 index 0000000..8620a22 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/CryptoInteropTest.kt @@ -0,0 +1,48 @@ +package org.qimiaoscreen.core + +import java.math.BigInteger +import java.security.AlgorithmParameters +import java.security.KeyFactory +import java.security.KeyPair +import java.security.spec.* +import kotlinx.serialization.json.* +import kotlin.test.* + +class CryptoInteropTest { + private val vector = Protocol.json.parseToJsonElement( + checkNotNull(javaClass.getResourceAsStream("/protocol-v1.json")).bufferedReader().use { it.readText() } + ).jsonObject + private fun bytes(key: String) = vector.getValue(key).jsonPrimitive.content.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + private fun channel(): JavaSecureChannel { + val params = AlgorithmParameters.getInstance("EC").apply { init(ECGenParameterSpec("secp256r1")) } + .getParameterSpec(ECParameterSpec::class.java) + val factory = KeyFactory.getInstance("EC") + val privateKey = factory.generatePrivate(ECPrivateKeySpec(BigInteger.ONE, params)) + val publicKey = factory.generatePublic(ECPublicKeySpec(params.generator, params)) + return JavaSecureChannel(KeyPair(publicKey, privateKey), bytes("client_random")) + } + @Test fun pythonGoldenRecordsMatch() { + val channel = channel() + assertContentEquals(bytes("client_hello"), channel.hello) + channel.finish(bytes("server_hello")) + assertContentEquals(bytes("client_record"), channel.encrypt(bytes("request"))) + assertContentEquals(bytes("response"), channel.decrypt(bytes("server_record"))) + assertFails { channel.decrypt(bytes("server_record")) } + } + @Test fun pythonFragmentVectorMatches() { + val expected = vector.getValue("fragments_mtu23").jsonArray.map { + it.jsonPrimitive.content.chunked(2).map { hex -> hex.toInt(16).toByte() }.toByteArray() + } + val actual = Protocol.encode(3, 0, bytes("client_record"), 23) + assertEquals(expected.size, actual.size) + expected.zip(actual).forEach { (a, b) -> assertContentEquals(a, b) } + } + @Test fun tamperedRecordInvalidatesConnection() { + val channel = channel() + channel.finish(bytes("server_hello")) + val record = bytes("server_record") + record[record.lastIndex] = (record.last().toInt() xor 1).toByte() + assertFails { channel.decrypt(record) } + assertFails { channel.decrypt(bytes("server_record")) } + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/RpcTimeoutTest.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/RpcTimeoutTest.kt new file mode 100644 index 0000000..ee0394f --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/kotlin/org/qimiaoscreen/core/RpcTimeoutTest.kt @@ -0,0 +1,28 @@ +package org.qimiaoscreen.core + +import kotlinx.coroutines.* +import kotlin.test.* + +class RpcTimeoutTest { + @Test fun timeoutClosesTransportAndIsNotCancellation() = runBlocking { + var closed = false + val link = object : DeviceLink { + override val mtu = 247 + override suspend fun send(fragment: ByteArray) {} + override suspend fun receive(): ByteArray { awaitCancellation() } + override fun close() { closed = true } + } + val 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 + } + val rpc = DeviceRpc(link, crypto, requestTimeoutMs = 25) + val error = assertFailsWith { rpc.call("status.get") } + assertTrue(error.message!!.contains("status.get")) + assertTrue(closed) + assertFailsWith { rpc.call("status.get") } + Unit + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/resources/protocol-v1.json b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/resources/protocol-v1.json new file mode 100644 index 0000000..256ee21 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/resources/protocol-v1.json @@ -0,0 +1,41 @@ +{ + "note": "Public interoperability fixture only; never use these scalars in real connections.", + "client_scalar": "1", + "server_scalar": "2", + "client_random": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "server_random": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "client_hello": "7b2270726f746f636f6c5f6d616a6f72223a312c2270726f746f636f6c5f6d696e6f72223a302c227075626c69635f6b6579223a224247735830664c684c454a482b4c7a6d35574f6b51504a33413332424c65737a6f5053684f5558596d4d4b57542b4e43347634616635754f352b744b66412b654669764f4d3164724d56374f79375a416144652f5566553d222c2272616e646f6d223a2241414543417751464267634943516f4c4441304f4478415245684d554652595847426b61477877644868383d227d", + "server_hello": "7b2270726f746f636f6c5f6d616a6f72223a312c2270726f746f636f6c5f6d696e6f72223a302c227075626c69635f6b6579223a2242487a796578694e4130392b696c4934417753314773504169576e69642f49624e61594c535078485a706c3442336456454e754f30454170505a72476e3351773237703972655938365949706e6753336e534a346339453d222c2272616e646f6d223a22494345694979516c4a69636f4b536f724c4330754c7a41784d6a4d304e5459334f446b364f7a7739506a383d227d", + "transcript": "9ca718b743dfe549aaba17180599eced0e2bcb680fd7c210e341ae74ccd17b76", + "request": "7b226964223a2231222c226d6574686f64223a2273657373696f6e2e6f70656e222c22706172616d73223a7b22636c69656e745f6e616d65223a22e6b58be8af95e6898be69cba227d7d", + "response": "7b226964223a2231222c226f6b223a747275652c22726573756c74223a7b22616c697665223a747275657d7d", + "client_record": "0000000000000000f2c854e062cd588b6de2066904d362400bf231bdeb046d786fca2a2bb6e1b539d0bd32d225461581655d7dc5066cf89d2d3bea12421e0d6e452f34ba4c39219b3e3efd3826c8461b67b8a74650976db8d47a0edd94e6cf39f526", + "server_record": "0000000000000000fd089bf6a0d3196c601f5dcbefad51b0f130e17cf38d2c76cbb9855a7da88480e53f3e89893dcc35fa175fcfbf01683f575244928462d07d13056a67", + "fragments_mtu23": [ + "514d010300000000000000190000006200000000", + "514d010300000000000100190000006200000000", + "514d0103000000000002001900000062f2c854e0", + "514d010300000000000300190000006262cd588b", + "514d01030000000000040019000000626de20669", + "514d010300000000000500190000006204d36240", + "514d01030000000000060019000000620bf231bd", + "514d0103000000000007001900000062eb046d78", + "514d01030000000000080019000000626fca2a2b", + "514d0103000000000009001900000062b6e1b539", + "514d010300000000000a001900000062d0bd32d2", + "514d010300000000000b00190000006225461581", + "514d010300000000000c001900000062655d7dc5", + "514d010300000000000d001900000062066cf89d", + "514d010300000000000e0019000000622d3bea12", + "514d010300000000000f001900000062421e0d6e", + "514d0103000000000010001900000062452f34ba", + "514d01030000000000110019000000624c39219b", + "514d01030000000000120019000000623e3efd38", + "514d010300000000001300190000006226c8461b", + "514d010300000000001400190000006267b8a746", + "514d010300000000001500190000006250976db8", + "514d0103000000000016001900000062d47a0edd", + "514d010300000000001700190000006294e6cf39", + "514d0103000000000018001900000062f526" + ] +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedUi/build.gradle.kts b/移动端相关内容/安卓app/安卓程序源代码/sharedUi/build.gradle.kts new file mode 100644 index 0000000..43fc410 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedUi/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kmp) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) +} +kotlin { + android { + namespace = "org.qimiaoscreen.ui" + compileSdk = 37 + minSdk = 26 + } + sourceSets { + commonMain.dependencies { + implementation(project(":sharedCore")) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(libs.coroutines.core) + implementation(libs.serialization.json) + } + } +} diff --git a/移动端相关内容/安卓app/安卓程序源代码/sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt b/移动端相关内容/安卓app/安卓程序源代码/sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt new file mode 100644 index 0000000..85cee65 --- /dev/null +++ b/移动端相关内容/安卓app/安卓程序源代码/sharedUi/src/commonMain/kotlin/org/qimiaoscreen/ui/ControllerApp.kt @@ -0,0 +1,283 @@ +package org.qimiaoscreen.ui + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import kotlinx.serialization.json.* +import org.qimiaoscreen.core.* +import kotlinx.coroutines.delay +import kotlin.time.Clock + +private fun JsonObject.text(key: String, fallback: String = "—") = get(key)?.jsonPrimitive?.contentOrNull ?: fallback + +data class AppInfo(val version: String, val build: String, val date: String) + +@Composable +fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> ImageBitmap?, + appInfo: AppInfo = AppInfo("—", "—", "—")) { + val state by controller.state.collectAsState() + var menu by remember { mutableStateOf(false) } + var dialog by remember { mutableStateOf(null) } + MaterialTheme { + Scaffold(bottomBar = { NavigationBar { + listOf("连接", "设备状态", "显示内容", "设备设置").forEachIndexed { index, name -> + NavigationBarItem(state.page == index, { controller.page(index) }, + icon = { Text(listOf("◉", "▣", "▦", "⚙")[index]) }, label = { Text(name) }) + } + } }) { padding -> + Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val identity = state.identity + Text(if (identity == null) "当前连接设备:未连接${if (state.phase == "正在连接") " · 连接中" else ""}" + else "当前连接设备:${identity.text("device_name")}(${identity.text("short_id")}) · ${wifiSummary(state)}", + Modifier.weight(1f), style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + Box { + TextButton({ menu = true }) { Text("⋮") } + DropdownMenu(menu, { menu = false }) { + DropdownMenuItem(text = { Text("关于") }, onClick = { menu = false; dialog = "about" }) + DropdownMenuItem(text = { Text("软件设置") }, onClick = { menu = false; dialog = "settings" }) + } + } + } + if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + state.error?.let { error -> Card { Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(error, Modifier.weight(1f)); TextButton(controller::dismissError) { Text("知道了") } + } } } + when (state.page) { + 0 -> ConnectionPage(state, controller, onScan) + 1 -> if (state.identity == null) Text("请先在连接页连接设备") else Frame(state.frame, state.frameCheckedAtMs, decode, controller::refresh, !state.busy) + 2 -> ContentPage(state, controller, decode) + 3 -> SettingsPage(state, controller) + } + } + } + if (dialog == "about") AlertDialog(onDismissRequest = { dialog = null }, title = { Text("关于奇妙小屏幕") }, + text = { Text("版本 ${appInfo.version}\n构建编号 ${appInfo.build}\n打包日期 ${appInfo.date}\n通过蓝牙控制核桃派小屏幕") }, + confirmButton = { TextButton({ dialog = null }) { Text("关闭") } }) + 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) }, + confirmButton = { TextButton({ controller.renamePhone(name); if (name.trim().isNotEmpty()) dialog = null }, enabled = !state.busy) { Text("保存") } }, + dismissButton = { TextButton({ dialog = null }) { Text("取消") } }) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ConnectionPage(state: ControllerState, c: Controller, scan: () -> Unit) { + var selected by remember { mutableStateOf(null) } + var renaming by remember { mutableStateOf(false) } + var name by remember { mutableStateOf("") } + LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { + item { Text("已保存的设备", style = MaterialTheme.typography.titleMedium) } + if (state.remembered.isEmpty()) item { Text("暂无已保存设备,扫描后选择即可连接") } + items(state.remembered, key = { it.id }) { device -> + Card(Modifier.fillMaxWidth().combinedClickable(onClick = { c.connectRemembered(device) }, onLongClick = { selected = device; renaming = false })) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { Text(device.name); Text("代号 ${device.shortId}", style = MaterialTheme.typography.bodySmall) } + Text(if (state.identity?.text("device_id") == device.id) "已连接" else "点击连接") + } + } + } + item { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(scan, enabled = !state.scanning && state.phase != "正在连接") { Text(if (state.scanning) "扫描中…" else "扫描设备") } + if (state.identity != null) OutlinedButton(c::disconnect) { Text("断开连接") } + } } + item { Text("扫描到的设备", style = MaterialTheme.typography.titleMedium) } + items(state.devices, key = { it.handle }) { device -> Card(Modifier.fillMaxWidth()) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(device.name) + Text("信号 ${device.signal} dBm${if (c.rememberedFor(device) != null) " · 已记忆" else ""}", style = MaterialTheme.typography.bodySmall) + } + Button({ c.connect(device, c.rememberedFor(device)?.id) }, enabled = state.phase != "正在连接") { Text("连接") } + } + } } + if (state.identity != null) item { Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("当前连接信息", style = MaterialTheme.typography.titleMedium) + Text("蓝牙信号:${state.signal?.let { "$it dBm" } ?: "暂不可用"}") + Text("设备WiFi:${wifiSummary(state)}") + } } } + } + selected?.let { device -> + if (!renaming) AlertDialog(onDismissRequest = { selected = null }, title = { Text(device.name) }, + text = { Text("代号 ${device.shortId}") }, + confirmButton = { TextButton({ + if (state.identity?.text("device_id") != device.id) { c.reportError("请连接设备"); selected = null } + else { name = device.name; renaming = true } + }) { Text("重命名设备") } }, + dismissButton = { Row { TextButton({ c.forget(device.id); selected = null }) { Text("取消记忆") }; TextButton({ selected = null }) { Text("关闭") } } }) + else AlertDialog(onDismissRequest = { selected = null }, title = { Text("重命名设备") }, + text = { OutlinedTextField(name, { name = it.take(40) }, label = { Text("设备昵称") }, singleLine = true) }, + confirmButton = { TextButton({ c.renameDevice(name, device.id); selected = null }, enabled = name.isNotBlank() && !state.busy) { Text("保存") } }, + dismissButton = { TextButton({ selected = null }) { Text("取消") } }) + } +} + +@Composable +private fun Frame(encoded: String?, checkedAt: Long?, decode: (String) -> ImageBitmap?, refresh: () -> Unit, enabled: Boolean) { + val bitmap = remember(encoded) { encoded?.let(decode) } + var now by remember { mutableLongStateOf(Clock.System.now().toEpochMilliseconds()) } + LaunchedEffect(checkedAt) { while (true) { now = Clock.System.now().toEpochMilliseconds(); delay(1_000) } } + Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("设备画面", Modifier.weight(1f), style = MaterialTheme.typography.titleMedium) + TextButton(refresh, enabled = enabled) { Text("手动刷新") } + } + if (bitmap != null) Image(bitmap, "设备当前输出帧", Modifier.size(160.dp)) else Text("等待画面") + if (checkedAt != null && now - checkedAt > 6_000) Text("画面可能已过期,请刷新", style = MaterialTheme.typography.bodySmall) + } } +} + +@Composable +private fun ContentPage(state: ControllerState, c: Controller, decode: (String) -> ImageBitmap?) { + if (state.identity == null) { Text("请先在连接页连接小屏幕"); return } + if (!c.supports("library")) { Text("此设备版本不支持内容控制"); return } + val playback = state.status?.get("state")?.jsonObject?.get("animation_playback")?.jsonObject + LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) { + item { Frame(state.frame, state.frameCheckedAtMs, decode, c::refresh, !state.busy) } + item { Text("开机默认:${state.defaultContent?.text("name") ?: "正在读取…"}") } + if (playback?.get("active")?.jsonPrimitive?.booleanOrNull == true) item { Playback(playback, c, !state.busy) } + item { Text("可播放内容", style = MaterialTheme.typography.titleLarge) } + 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 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")) } + Text((if (item.text("type") == "animation") "动图" else "静态模板") + if (item.text("is_demo") == "true") " · 演示内容" else "") + val enabled = !state.busy && item["playable"]?.jsonPrimitive?.booleanOrNull == true + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button({ c.play(item) }, enabled = enabled) { Text("播放") } + OutlinedButton({ c.play(item, true) }, enabled = enabled) { Text("设为默认并播放") } + } + } } + } + } +} + +@Composable +private fun Playback(value: JsonObject, c: Controller, enabled: Boolean) { + val paused = value["paused"]?.jsonPrimitive?.booleanOrNull == true + val position = value["position_ms"]?.jsonPrimitive?.longOrNull ?: 0L + val total = (value["total_duration_ms"]?.jsonPrimitive?.longOrNull ?: 1L).coerceAtLeast(1) + var seek by remember(position) { mutableFloatStateOf(position.toFloat()) } + Text("动图进度 ${position / 1000.0} / ${total / 1000.0} 秒") + Button({ c.playback(buildJsonObject { put("paused", !paused) }) }, enabled = enabled) { Text(if (paused) "继续" else "暂停") } + Slider(seek.coerceIn(0f, total.toFloat()), { seek = it }, enabled = enabled, valueRange = 0f..total.toFloat(), onValueChangeFinished = { c.playback(buildJsonObject { put("position_ms", seek.toLong()) }) }) + Choices("播放倍速", value.text("speed"), value["supported_speeds"]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList(), enabled) { + c.playback(buildJsonObject { put("speed", it.toDouble()) }) + } +} + +@Composable +private fun SettingsPage(state: ControllerState, c: Controller) { + if (state.identity == null) { Text("请先在连接页连接设备"); return } + val values = state.settings?.get("values")?.jsonObject + LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (values != null) { + item { + var brightness by remember(values.text("brightness")) { mutableFloatStateOf(values.text("brightness", "40").toFloat()) } + Text("亮度 ${brightness.toInt()}%") + Slider(brightness, { brightness = it }, enabled = !state.busy, valueRange = 1f..100f, + onValueChangeFinished = { c.setting("brightness", JsonPrimitive(brightness.toInt())) }) + } + item { Text("屏幕方向"); Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + listOf("0" to "正常", "90" to "向右90°", "180" to "倒置", "270" to "向左90°").forEach { (value, label) -> + FilterChip(values.text("orientation") == value, { c.setting("orientation", JsonPrimitive(value.toInt())) }, label = { Text(label) }, enabled = !state.busy) + } + } } + } else item { Text(if (c.supports("settings")) "正在读取设置…" else "设备不支持系统设置") } + if (c.supports("wifi")) item { WifiForm(state, c) } + if (values != null) item { + var seconds by remember(values.text("prompt_delay_seconds")) { mutableStateOf(values.text("prompt_delay_seconds")) } + OutlinedTextField(seconds, { seconds = it }, label = { Text("WiFi提示秒数") }, singleLine = true) + TextButton({ seconds.toIntOrNull()?.let { c.setting("prompt_delay_seconds", JsonPrimitive(it)) } }, enabled = !state.busy && seconds.toIntOrNull() != null) { Text("保存提示秒数") } + } + } +} + +@Composable +private fun Choices(label: String, selected: String, options: List, enabled: Boolean, select: (String) -> Unit) { + Text(label) + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + options.forEach { value -> FilterChip(selected == value, { select(value) }, label = { Text(value) }, enabled = enabled) } + } +} + +@Composable +private fun WifiForm(state: ControllerState, c: Controller) { + val saved = state.wifi?.get("saved")?.takeIf { it != JsonNull }?.jsonObject + val draft = state.wifiDraft ?: WifiDraft(saved?.text("ssid", "") ?: "", saved?.text("security", "wpa-psk") ?: "wpa-psk", + saved?.text("ipv4_mode") == "manual", saved?.text("address", "") ?: "", saved?.text("prefix", "24") ?: "24", + saved?.text("gateway", "") ?: "", saved?.get("dns_servers")?.jsonArray?.joinToString(",") { it.jsonPrimitive.content } ?: "") + var expanded by remember { mutableStateOf(false) } + var networks by remember { mutableStateOf(false) } + var password by remember { mutableStateOf("") } + var showPassword by remember { mutableStateOf(false) } + var confirm by remember { mutableStateOf(false) } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + HorizontalDivider(); Text("WiFi网络", style = MaterialTheme.typography.titleMedium) + Text("当前WiFi:${wifiSummary(state)}") + TextButton({ expanded = !expanded; if (!expanded) { password = ""; showPassword = false } }) { Text("WiFi设置 ${if (expanded) "收起" else "展开"}") } + if (expanded) { + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField(draft.ssid, { c.wifiDraft(draft.copy(ssid = it)) }, Modifier.weight(1f), label = { Text("WiFi名称") }, singleLine = true, enabled = !state.busy) + if (c.supports("wifi_scan")) TextButton({ networks = true; c.scanWifi() }, enabled = !state.busy) { Text("扫描WiFi") } + } + if (networks) { + if (state.networks.isEmpty() && !state.busy) Text("未发现WiFi,可手动输入名称") + state.networks.forEach { network -> TextButton({ + c.wifiDraft(draft.copy(ssid = network.text("ssid"), security = network.text("security"))); password = ""; networks = false + }, enabled = !state.busy && network.text("security") != "unsupported") { + Text("${network.text("ssid")} · ${network.text("signal_percent")}%${if (network.text("security") == "unsupported") " · 暂不支持" else ""}") + } } + } + Choices("网络类型", if (draft.security == "open") "开放网络" else "密码网络", listOf("密码网络", "开放网络"), !state.busy) { + c.wifiDraft(draft.copy(security = if (it == "开放网络") "open" else "wpa-psk")); password = "" + } + if (draft.security != "open") { + val configured = saved?.get("password_configured")?.jsonPrimitive?.booleanOrNull == true && saved.text("ssid", "") == draft.ssid + OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("WiFi密码") }, + placeholder = { Text(if (configured) "已设置密码,留空保留" else "请输入密码") }, + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), singleLine = true, enabled = !state.busy) + Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(showPassword, { showPassword = it }); Text("显示输入的密码") } + } + Row(verticalAlignment = Alignment.CenterVertically) { Text("自动获取IP(DHCP)", Modifier.weight(1f)); Switch(!draft.manual, { c.wifiDraft(draft.copy(manual = !it)) }, enabled = !state.busy) } + if (draft.manual) { + OutlinedTextField(draft.address, { c.wifiDraft(draft.copy(address = it)) }, label = { Text("静态IP地址") }, singleLine = true, enabled = !state.busy) + OutlinedTextField(draft.prefix, { c.wifiDraft(draft.copy(prefix = it)) }, label = { Text("前缀长度(例如24)") }, singleLine = true, enabled = !state.busy) + OutlinedTextField(draft.gateway, { c.wifiDraft(draft.copy(gateway = it)) }, label = { Text("网关") }, singleLine = true, enabled = !state.busy) + OutlinedTextField(draft.dns, { c.wifiDraft(draft.copy(dns = it)) }, label = { Text("DNS(多个用逗号分隔)") }, singleLine = true, enabled = !state.busy) + } + Button({ confirm = true }, enabled = !state.busy && draft.ssid.isNotBlank() && state.wifi != null) { Text("保存WiFi设置") } + } + } + if (confirm) AlertDialog(onDismissRequest = { confirm = false }, title = { Text("立即生效WiFi设置") }, + text = { Text("WiFi设置将立即生效,是否确定?手机仍可通过蓝牙控制设备。") }, + dismissButton = { TextButton({ confirm = false }) { Text("取消") } }, + confirmButton = { TextButton({ + confirm = false + c.saveWifi(buildJsonObject { + put("ssid", draft.ssid); put("security", draft.security) + put("password_action", if (draft.security == "open") "none" else if (password.isEmpty()) "keep" else "replace") + if (password.isNotEmpty() && draft.security != "open") put("password", password) + put("ipv4_mode", if (draft.manual) "manual" else "dhcp") + if (draft.manual) { put("address", draft.address); put("prefix", draft.prefix.toIntOrNull() ?: 0); put("gateway", draft.gateway) } + put("dns_servers", JsonArray(if (draft.manual) draft.dns.split(',').map { it.trim() }.filter { it.isNotEmpty() }.map { JsonPrimitive(it) } else emptyList())) + put("activation", "immediate") + }); password = ""; showPassword = false + }) { Text("确定") } }) +} diff --git a/移动端相关内容/安卓app/构建与发布/README.md b/移动端相关内容/安卓app/构建与发布/README.md new file mode 100644 index 0000000..91256d4 --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/README.md @@ -0,0 +1,62 @@ +# Android 环境、构建与交付 + +## 安装门槛 + +Android Studio 是正常安装软件,由用户运行已核验官方安装包。安装包放用户普通下载目录,不放仓库或 Codex bin。准备好后停止等待用户安装,不自动运行安装器。安装时建议采用默认位置,首次向导安装 Android SDK;不必创建项目,也不必先下载模拟器或 NDK。 + +首次完成后记录实际安装版本、JDK、SDK、Build Tools、Kotlin、Compose、AGP 与 Gradle 的兼容组合,锁入版本目录、Wrapper 和依赖校验文件。安装位置用环境探测/local.properties,不写到可提交源码。官方参考: + +- https://developer.android.com/studio +- https://developer.android.com/studio/install +- https://developer.android.com/kotlin/multiplatform/plugin +- https://kotlinlang.org/docs/multiplatform/multiplatform-compatibility-guide.html + +## 工程与路径 + +源码位于相邻 `安卓程序源代码/`,模块 androidApp、sharedCore、sharedUi。在 Android Studio 打开工程根,不分别打开子模块。先验证中文和空格路径;若 Android 工具拒绝该路径,通过受保护的仓库外 ASCII 构建目录同步源码并执行,结果回收到交付目录。同步只包含源码及明确资源,不复制私有登记、密钥、缓存或 `.git`;脚本记录源摘要,不覆盖用户目录。 + +已安装工具链:Android Studio Quail 4 Patch 1(用户确认中文)、JBR 25.0.3、SDK 37;工程固定 Gradle 9.5.0、AGP 9.3.1、Kotlin 2.4.20、Compose 1.12.1。Wrapper 使用官方发行地址和 SHA-256。首次 `:androidApp:assembleDebug` 与 `:sharedCore:jvmTest` 已在隔离 ASCII 路径通过;这只是骨架及协议验证,不等于功能验收。lint 与真实 BLE 已有通过记录,具体覆盖范围见测试结果归档。 + +在仓库根执行下列命令,参数替换为本机实际目录,不把替换后的路径提交: + +```text +python "移动端相关内容/安卓app/构建与发布/build_android.py" --work-root "<受保护临时目录的ASCII路径>" --jdk "" --sdk "" +``` + +脚本每次创建独立 `qms-build-*` 子目录,只复制工程源码,忽略 build、.gradle、.kotlin、local.properties。默认使用 Wrapper;可用 `--gradle` 指定已核验的同版本 Gradle,避免重复下载。默认运行 JVM 测试和 assembleDebug,可在末尾显式传任务列表。保留构建目录用于查看测试报告和 `androidApp/build/outputs/apk/debug/androidApp-debug.apk`;验证后只清理本次明确目录。Android 调试签名沿用用户默认 Android 密钥目录,不复制到源码或隔离工程。 + +路径实测:AGP 默认拒绝非 ASCII 路径;启用 `android.overridePathCheck=true` 后编译继续,但当前 JBR/Gradle 下 JVM 测试出现类加载失败。在 ASCII 隔离目录同一测试通过,故当前使用该回退。2026-09-25 已在全新且包含空格的 ASCII 构建根目录重新执行全部 92 个任务,JVM 测试及两个 debug APK 均成功;保留中文源码位置。 + +## APK 与签名 + +- 应用名“奇妙小屏幕”,applicationId 为 `org.qimiaoscreen.controller`,minSdk 26,首版版本 0.1.0。 +- 仅 debug 签名,沿用本机调试密钥并记录证书摘要(不记录私钥)。正式签名只有用户明确命令才配置或执行。 +- 调试签名更换可能无法覆盖安装;不能自动卸载并清除用户配置来掩盖。需要时先报告原因。 +- App versionCode 独立递增,设备版本/协议版本分别登记,不能因设备部署自动提升 App 版本。 +- 每次交付放 `交付包/-/`:APK、SHA256SUMS、manifest.json、README.md。manifest 包含 App/协议/兼容设备信息、源摘要、工具链版本和签名证书摘要,不包含本机路径或测试机标识。 +- APK 与发布元数据保留在私有 Git 仓库,需要时沿用 Git LFS;构建缓存、SDK、真实登记、调试及正式签名私钥都不提交。 +- 本任务不导出核桃派 IMG/OTA;设备功能时间按根规则更新,设备发布版本不因普通部署占号。 + +## 交付前检查 + +从隔离目录依文档重新构建并验证;完成相关测试、协议互操作和真实 BLE 联调;运行根仓库卫生检查。交付说明准确区分通过、未验证和不适用。第二台手机验收 APK 放到同一清晰可找到的交付目录,用户安装后再进行实机互斥测试。 + +## 可重复打包 + +```text +python "移动端相关内容/安卓app/构建与发布/package_debug.py" --build "<本轮隔离工程目录>" --sdk "" +``` + +运行前将 JAVA_HOME 指向正常安装的 Android Studio jbr。脚本逐文件核对隔离工程与当前源码的 SHA-256,调用 Build Tools 36.0.0 的 apksigner 验证 Android Debug 证书,创建版本独立目录,保存 APK、摘要、工具链和源文件清单。已存在交付目录拒绝覆盖。结果默认标为开发验证包,必须结合测试报告说明验收范围,不用打包成功替代验收。versionCode 2 包含本轮连接清理和界面信息补充,可覆盖同证书的 code 1。 + +本轮 App 0.2.0 / versionCode 3 为四栏界面及设备记忆优化,仍仅 debug 签名。关于页显示本次 Gradle 构建日期(北京时间),不修改设备发布版本。 + +## 构建排障与测试复盘 + +手动调用隔离工程 Wrapper 时,每个新 shell 都须明确使用已登记的 JDK/SDK(JAVA_HOME/ANDROID_HOME);前一工具调用的进程环境不会自动传到下一次调用。缺少 SDK location 时先检查环境,不重复下载工具、不改动中文源码位置。 + +Gradle Kotlin DSL 里 `java` 可能被插件扩展名遮蔽;构建日期实现显式 import `ZonedDateTime`、`ZoneId`、`DateTimeFormatter`,避免直接以 `java.time...` 引用引发解析错误。日期仍使用北京时间,不通过硬编码本机路径解决。 + +Python 在 Windows 缺少 IANA tzdata 时,固定北京时间时间戳可使用标准库 `timezone(timedelta(hours=8))`;这一方法只适用于明确要求的固定 UTC+08:00 时间,不当作任意地区夏令时转换方案,也不因该情况自动新增依赖。 + +打包前重新核对隔离源码、实际 APK、构建日期和签名摘要;测试源码调整也要同步隔离工程,不能测试旧测试 APK。测试/构建遇到的新问题在完成后更新相应文档并在最终反馈说明,具体输入和真机排障见 [测试排障指南](../如何安卓测试/常见问题与排障.md)。 diff --git a/移动端相关内容/安卓app/构建与发布/build_android.py b/移动端相关内容/安卓app/构建与发布/build_android.py new file mode 100644 index 0000000..2ab7d84 --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/build_android.py @@ -0,0 +1,40 @@ +"""Build from an isolated path; source stays in its original Chinese directory.""" +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--work-root', type=Path, required=True) + parser.add_argument('--jdk', type=Path, required=True) + parser.add_argument('--sdk', type=Path, required=True) + parser.add_argument('--gradle', type=Path, help='Optional verified Gradle executable; otherwise use Wrapper') + parser.add_argument('tasks', nargs='*', default=[':sharedCore:jvmTest', ':androidApp:assembleDebug']) + args = parser.parse_args() + root = args.work_root.resolve() + if not str(root).isascii(): + parser.error('work-root must use ASCII characters') + if not (args.jdk / 'bin/java.exe').is_file() or not (args.sdk / 'platforms').is_dir(): + parser.error('JDK or SDK is missing') + root.mkdir(parents=True, exist_ok=True) + destination = Path(tempfile.mkdtemp(prefix='qms-build-', dir=root)) + source = Path(__file__).resolve().parents[1] / '安卓程序源代码' + shutil.copytree(source, destination, dirs_exist_ok=True, + ignore=shutil.ignore_patterns('build', '.gradle', '.kotlin', 'local.properties')) + environment = os.environ.copy() + environment.update(JAVA_HOME=str(args.jdk.resolve()), ANDROID_HOME=str(args.sdk.resolve())) + executable = args.gradle.resolve() if args.gradle else destination / 'gradlew.bat' + result = subprocess.run([str(executable), '--console', 'plain', *args.tasks], cwd=destination, env=environment) + # Keep this exact directory for logs, reports and APK retrieval, even on failure. + print(f'Build directory: {destination}', flush=True) + return result.returncode + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/移动端相关内容/安卓app/构建与发布/package_debug.py b/移动端相关内容/安卓app/构建与发布/package_debug.py new file mode 100644 index 0000000..d77b888 --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/package_debug.py @@ -0,0 +1,65 @@ +"""Package an independently built debug APK without overwriting an old delivery.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import shutil +import subprocess +import tomllib + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--build', type=Path, required=True) + parser.add_argument('--sdk', type=Path, required=True) + args = parser.parse_args() + root = Path(__file__).resolve().parent + source = root.parent / '安卓程序源代码' + config = (source / 'androidApp/build.gradle.kts').read_text(encoding='utf-8') + version = re.search(r'versionName = "([^"]+)"', config)[1] + code = int(re.search(r'versionCode = (\d+)', config)[1]) + entries = [] + for path in sorted(source.rglob('*')): + relative = path.relative_to(source) + if not path.is_file() or any(p in {'build', '.gradle', '.kotlin'} for p in relative.parts) or path.name == 'local.properties': + continue + built = args.build / relative + if not built.is_file() or digest(built) != digest(path): + raise RuntimeError('Build source differs from current source: ' + relative.as_posix()) + entries.append(dict(path=relative.as_posix(), sha256=digest(path))) + apk = args.build / 'androidApp/build/outputs/apk/debug/androidApp-debug.apk' + signer = args.sdk / 'build-tools/36.0.0/apksigner.bat' + result = subprocess.run([str(signer), 'verify', '--print-certs', str(apk)], + capture_output=True, text=True, encoding='utf-8', errors='replace') + if result.returncode or 'CN=Android Debug' not in result.stdout: + raise RuntimeError('APK must verify with the existing Android Debug certificate') + certificate = re.search(r'certificate SHA-256 digest: ([0-9a-fA-F]+)', result.stdout)[1].lower() + destination = root / '交付包' / f'{version}-{code}' + destination.mkdir(parents=True, exist_ok=False) + name = f'奇妙小屏幕-{version}-debug.apk' + shutil.copyfile(apk, destination / name) + versions = tomllib.loads((source / 'gradle/libs.versions.toml').read_text(encoding='utf-8'))['versions'] + build_config = (args.build / 'androidApp/build/generated/source/buildConfig/debug/org/qimiaoscreen/controller/BuildConfig.java').read_text(encoding='utf-8') + 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, + 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') + (destination / 'README.md').write_text( + f'# 奇妙小屏幕 {version} debug(versionCode {code})\n\n' + '仅 debug 签名,可覆盖安装相同调试证书的旧包。Android 8.0 以上,真实验证机为 Android 13。\n\n' + '本包为当前开发验证产物;验收范围和未验证项见 `../../../测试结果归档/实施状态.md`。' + '不能将 APK 成功生成视为所有首版验收通过。设备需已部署移动控制功能,历史设备版本号本身不代表支持 BLE。\n\n' + f'SHA-256:`{digest(apk)}`。源码摘要与签名证书摘要见 manifest.json。\n', encoding='utf-8') + print(json.dumps(dict(delivery=destination.relative_to(root).as_posix(), apk_sha256=digest(apk), certificate_sha256=certificate), ensure_ascii=False)) + + +if __name__ == '__main__': + main() diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-1/README.md b/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-1/README.md new file mode 100644 index 0000000..d55fd73 --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-1/README.md @@ -0,0 +1,14 @@ +# 奇妙小屏幕 0.1.0 debug 开发验证包 + +本目录提供当前连接修复阶段 APK,不是全部首版验收完成声明。仅 debug 签名,不包含私钥;可覆盖安装使用同一调试证书的此前测试版本。若提示签名不一致,不自动卸载或清除数据。 + +- 文件:`奇妙小屏幕-0.1.0-debug.apk`。 +- Android 8.0/API 26 及以上;真实验证机为 Android 13。 +- BLE 协议主版本 1;须配合本项目已部署移动控制服务的设备开发版本,仅有历史 1.1.1 发布版本号不能证明含 BLE 功能。 +- 2026-09-25 修复请求/连接建立超时残留状态,以及厂商缓存 MTU 回调触发过早服务发现的问题。 +- 已通过 JVM 6 项、Android lint(0 错误/14 警告)、真实 BLE 60 轮持续读取和 Controller 三页/后台释放/前台重连组合测试(290.908 秒)。实际 Activity 自动化另见测试报告。 +- 用户授权 root 输入后,实际 Activity 三页操作、后台 33 秒后返回自动重连及主动断开通过(49.45 秒)。 +- 仍需完成完整首版的写操作、网络切换、双手机互斥、设备恢复与性能等验收;不把只读成功当作这些项目通过。 +- 详细过程:`../../../测试结果归档/2026-09-25_重启与连接恢复.md`。 + +SHA-256:`2eedd9fccd27dcc0664e6e0f1003554677b632580d65ae17f95e7ea285a0529e`。同目录 SHA256SUMS 与 manifest.json 包含校验值、调试证书摘要、工具链与源码文件摘要;不包含本机路径或真实设备标识。 diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-1/SHA256SUMS b/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-1/SHA256SUMS new file mode 100644 index 0000000..3a50685 --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-1/SHA256SUMS @@ -0,0 +1 @@ +2eedd9fccd27dcc0664e6e0f1003554677b632580d65ae17f95e7ea285a0529e 奇妙小屏幕-0.1.0-debug.apk diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-1/manifest.json b/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-1/manifest.json new file mode 100644 index 0000000..ab7d7ed --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-1/manifest.json @@ -0,0 +1,128 @@ +{ + "app_version": "0.1.0", + "version_code": 1, + "status": "development-validation-not-final-release", + "signing": "debug", + "certificate_sha256": "b413e68c0bd2311c491ebd087ac5791c7c2ad8267793f92f6055d5521bd3ef4d", + "apk_sha256": "2eedd9fccd27dcc0664e6e0f1003554677b632580d65ae17f95e7ea285a0529e", + "protocol_major": 1, + "minimum_android_api": 26, + "target_android_api": 37, + "build_tools": "36.0.0", + "gradle": "9.5.0", + "kotlin": "2.4.20", + "compose": "1.12.1", + "agp": "9.3.1", + "device_release_tested": "1.1.1 with mobile development changes", + "date": "2026-09-25", + "sources": [ + { + "path": "androidApp/build.gradle.kts", + "sha256": "ed75cde57a6dd389e2a69d3d30ccecb3411e258d2cb36ec057a6f8c9fce20bb6" + }, + { + "path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AppLifecycleTest.kt", + "sha256": "e959936e49ad7b90acf04c8f1595d6f497d9b14d6781b13e03eb5914d59ecb1f" + }, + { + "path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/BleIntegrationTest.kt", + "sha256": "d2236734062ce523f87a9cb7f3149a8a965616bcab3df46e954fdc8ca81f76c0" + }, + { + "path": "androidApp/src/main/AndroidManifest.xml", + "sha256": "186ed22b19731ea5bf3567e40d6487b95f1bced3dd2fce605de6557cb7c5763e" + }, + { + "path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidBle.kt", + "sha256": "453c67aab3963c12be915923c88ea1c63930c24e43842c223ca62f421574bc15" + }, + { + "path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/MainActivity.kt", + "sha256": "3a688ac321f1f06c9c61be4046cd0ebfbf53893a4742a4aaa9a77aa56bf17838" + }, + { + "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": "3d53a77b5b92800475897fb55f910a7cfe008e2084e0b7185ebb5ce38e22cf53" + }, + { + "path": "sharedCore/build.gradle.kts", + "sha256": "9a420e39e3c655f676572d4486e415dbc8b4fd45904db6ad7b64d7fcd71f084b" + }, + { + "path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt", + "sha256": "792725610a2f4b802c37c708a8995b647f6482427a89f4df7d005c5b3b9f3026" + }, + { + "path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/DeviceLink.kt", + "sha256": "8d85e3182e66f7d894f3119329a6bd3d6cdbe8f3ad725f3715dcda477c359220" + }, + { + "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/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": "b9a9e0f95d301d35e19424c5a6342e0157292a77044126da908341af89ea6954" + } + ] +} diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-1/奇妙小屏幕-0.1.0-debug.apk b/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-1/奇妙小屏幕-0.1.0-debug.apk new file mode 100644 index 0000000..39f199d Binary files /dev/null and b/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-1/奇妙小屏幕-0.1.0-debug.apk differ diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-2/README.md b/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-2/README.md new file mode 100644 index 0000000..ffa1339 --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-2/README.md @@ -0,0 +1,9 @@ +# 奇妙小屏幕 0.1.0 debug(versionCode 2) + +仅 debug 签名,可覆盖安装相同调试证书的旧包。Android 8.0 以上,真实验证机为 Android 13。 + +本包为当前开发验证产物;验收范围和未验证项见 `../../../测试结果归档/实施状态.md`。不能将 APK 成功生成视为所有首版验收通过。设备需已部署移动控制功能,历史设备版本号本身不代表支持 BLE。 + +SHA-256:`f7c1dc215aa5934359a37fd001e1bc3cbf959b4a386662b1a886efb1ab73ea46`。源码摘要与签名证书摘要见 manifest.json。 + +本版实际 Activity 三页、蓝牙切换、后台释放及重连测试通过(60.105 秒)。完整通过/未验证/不适用与恢复说明见 `../../../测试结果归档/2026-09-25_本轮交付核对.md`。 diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-2/SHA256SUMS b/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-2/SHA256SUMS new file mode 100644 index 0000000..ea8c15c --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-2/SHA256SUMS @@ -0,0 +1 @@ +f7c1dc215aa5934359a37fd001e1bc3cbf959b4a386662b1a886efb1ab73ea46 奇妙小屏幕-0.1.0-debug.apk diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-2/manifest.json b/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-2/manifest.json new file mode 100644 index 0000000..1340dca --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-2/manifest.json @@ -0,0 +1,133 @@ +{ + "app_version": "0.1.0", + "version_code": 2, + "signing": "debug", + "status": "development-validation-see-test-report", + "apk_sha256": "f7c1dc215aa5934359a37fd001e1bc3cbf959b4a386662b1a886efb1ab73ea46", + "certificate_sha256": "b413e68c0bd2311c491ebd087ac5791c7c2ad8267793f92f6055d5521bd3ef4d", + "protocol_major": 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": "c786ed104d02a545a1824c6d3e4b4ba4d7eda0f8cdb2aacc90c7ac0203482457" + }, + { + "path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AppLifecycleTest.kt", + "sha256": "128df8ba46bea5c965118d6b76156389fd9d6face6129f627788ecc312383a0c" + }, + { + "path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/BleIntegrationTest.kt", + "sha256": "db8149886b54d896148b2f07cc77dfec6906ada31a6aae54421272c0d66c276c" + }, + { + "path": "androidApp/src/main/AndroidManifest.xml", + "sha256": "186ed22b19731ea5bf3567e40d6487b95f1bced3dd2fce605de6557cb7c5763e" + }, + { + "path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/AndroidBle.kt", + "sha256": "453c67aab3963c12be915923c88ea1c63930c24e43842c223ca62f421574bc15" + }, + { + "path": "androidApp/src/main/kotlin/org/qimiaoscreen/controller/MainActivity.kt", + "sha256": "d79523c52eee42041a4e14ba664d08a9a614dc79f9578b3a6ab1f8db7268edd4" + }, + { + "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": "3d53a77b5b92800475897fb55f910a7cfe008e2084e0b7185ebb5ce38e22cf53" + }, + { + "path": "sharedCore/build.gradle.kts", + "sha256": "9a420e39e3c655f676572d4486e415dbc8b4fd45904db6ad7b64d7fcd71f084b" + }, + { + "path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt", + "sha256": "1b23010730aee35f2512bb247a79040360a046a055b7029e269a17f9790686e7" + }, + { + "path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/DeviceLink.kt", + "sha256": "8d85e3182e66f7d894f3119329a6bd3d6cdbe8f3ad725f3715dcda477c359220" + }, + { + "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": "4107b56cd9a30009aa9b16ebb22e349e8a46134be1cb49078a81e1d8b5e7412b" + }, + { + "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": "4c9f1e67aca84ed025110cd36daf2a20ee1da7fbff7f525a2eed0d4203fb2819" + } + ] +} diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-2/奇妙小屏幕-0.1.0-debug.apk b/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-2/奇妙小屏幕-0.1.0-debug.apk new file mode 100644 index 0000000..1704b8e Binary files /dev/null and b/移动端相关内容/安卓app/构建与发布/交付包/0.1.0-2/奇妙小屏幕-0.1.0-debug.apk differ diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.2.0-3/README.md b/移动端相关内容/安卓app/构建与发布/交付包/0.2.0-3/README.md new file mode 100644 index 0000000..ddce8ed --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/交付包/0.2.0-3/README.md @@ -0,0 +1,13 @@ +# 奇妙小屏幕 0.2.0 debug(versionCode 3) + +仅 debug 签名,可覆盖安装相同调试证书的旧包。Android 8.0 以上,真实验证机为 Android 13。 + +本包为当前开发验证产物;验收范围和未验证项见 `../../../测试结果归档/实施状态.md`。不能将 APK 成功生成视为所有首版验收通过。设备需已部署移动控制功能,历史设备版本号本身不代表支持 BLE。 + +SHA-256:`9adc8e572384bb30a8a78115be43a8fefeefee6d25bda0136dff106e2a8bcab3`。源码摘要与签名证书摘要见 manifest.json。 + +## 本轮验证 + +四栏、菜单、设备记忆/重命名、后台重连、窄屏/横屏/大字体导航、301.514 秒完整 BLE 回归、WiFi 扫描填入及明确错误密码反馈、原网络恢复均在登记 root 手机验证通过。测试手机已覆盖安装本包,已安装 APK 摘要与交付文件一致。 + +真实双设备切换、旧 Android、跨 SSID 和实际 DHCP/静态互换未验证;不把模拟和表单测试等同于这些真机验收。详细证据与失败记录见 `../../../测试结果归档/2026-09-26_四栏界面优化.md`。 diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.2.0-3/SHA256SUMS b/移动端相关内容/安卓app/构建与发布/交付包/0.2.0-3/SHA256SUMS new file mode 100644 index 0000000..e0e1c58 --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/交付包/0.2.0-3/SHA256SUMS @@ -0,0 +1 @@ +9adc8e572384bb30a8a78115be43a8fefeefee6d25bda0136dff106e2a8bcab3 奇妙小屏幕-0.2.0-debug.apk diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.2.0-3/manifest.json b/移动端相关内容/安卓app/构建与发布/交付包/0.2.0-3/manifest.json new file mode 100644 index 0000000..f9abfb5 --- /dev/null +++ b/移动端相关内容/安卓app/构建与发布/交付包/0.2.0-3/manifest.json @@ -0,0 +1,142 @@ +{ + "app_version": "0.2.0", + "version_code": 3, + "signing": "debug", + "build_date": "2026-09-26 15:13 +08:00", + "status": "development-validation-see-test-report", + "apk_sha256": "9adc8e572384bb30a8a78115be43a8fefeefee6d25bda0136dff106e2a8bcab3", + "certificate_sha256": "b413e68c0bd2311c491ebd087ac5791c7c2ad8267793f92f6055d5521bd3ef4d", + "protocol_major": 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": "45aeaaf7cff436d7dc93e6c6f1491925b082d989bcd3b097fb5a4052e8bbe8c7" + }, + { + "path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/AppLifecycleTest.kt", + "sha256": "ba33ea5102809e6b0b5b594159f05adfc612b02a0430e329a0620b20c76cb21f" + }, + { + "path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/BleIntegrationTest.kt", + "sha256": "8dc7a28279e99e46d15c914955d8974d619c4e48a2fcab887f180b15b4b183ab" + }, + { + "path": "androidApp/src/androidTest/kotlin/org/qimiaoscreen/controller/LayoutTest.kt", + "sha256": "930c964fab8975d681e6d698468146294653af4d66a4a7be5c162e4c14659696" + }, + { + "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/MainActivity.kt", + "sha256": "7bece662b79093a19fcdf34ad344cf42a57b60291f0536823395ae06663f4f23" + }, + { + "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": "3d53a77b5b92800475897fb55f910a7cfe008e2084e0b7185ebb5ce38e22cf53" + }, + { + "path": "sharedCore/build.gradle.kts", + "sha256": "9a420e39e3c655f676572d4486e415dbc8b4fd45904db6ad7b64d7fcd71f084b" + }, + { + "path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/Controller.kt", + "sha256": "c368f57d13ba84bcb458855465ec22843d8ed617c7242bf2d5a3b4730271dca3" + }, + { + "path": "sharedCore/src/commonMain/kotlin/org/qimiaoscreen/core/DeviceLink.kt", + "sha256": "870345382351bbf6d37f4332c0fab099cb47b7b703428a98567a0086c3316be6" + }, + { + "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": "a4a4c3e2ba64b0facc1e1c491d440f91e4bdcf53eb87d9991921f4f568614f58" + }, + { + "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": "0f88692bb155d68baaadbad3beec5443975a7863b7a1ceb6733958aab064d132" + } + ] +} diff --git a/移动端相关内容/安卓app/构建与发布/交付包/0.2.0-3/奇妙小屏幕-0.2.0-debug.apk b/移动端相关内容/安卓app/构建与发布/交付包/0.2.0-3/奇妙小屏幕-0.2.0-debug.apk new file mode 100644 index 0000000..835d43d Binary files /dev/null and b/移动端相关内容/安卓app/构建与发布/交付包/0.2.0-3/奇妙小屏幕-0.2.0-debug.apk differ diff --git a/移动端相关内容/安卓app/测试结果归档/2026-09-24_开发部署断点检查.md b/移动端相关内容/安卓app/测试结果归档/2026-09-24_开发部署断点检查.md new file mode 100644 index 0000000..c79442d --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-09-24_开发部署断点检查.md @@ -0,0 +1,15 @@ +# 开发部署断点检查 + +额度中断后先读板端状态,没有重复部署。核验结果: + +- `/opt/matrix-screen-controller` 是指向既有版本目录的符号链接。 +- 第二次部署暂存目录 `/tmp/qms-mobile-stage-f50b2e0519bc` 留有有效 `status.json`;主服务与 bluetooth、aw859-bluetooth 三个服务均 active/running。 +- `/opt/matrix-screen-controller.mobile-backup-f50b2e0519bc` 是独立目录,含原程序和虚拟环境;须保留到本轮部署验收与回退演练结束。 +- 第一次失败的 `/opt/matrix-screen-controller.mobile-backup-dd700979ce9f` 只是符号链接,**不可作为回退备份**。第一次失败时已恢复被移动的原虚拟环境并重新启动原服务。 +- 当前 `app/main.py`、`app/mobile/bluez.py` SHA-256 与本地一致,功能时间为 `2026-09-24T16:43+08:00`,没有推进发布版本。 +- 存在 1 个 hci 适配器,HTTP 状态报告蓝牙可用、手机未连接;无遗留实际部署 shell。进程检查必须检查 argv,不能把探测命令自身的文本计入部署进程。 +- 未改原生驱动实现,未启动摄像头、实屏视觉检查或测试图案。视觉检查不适用。 + +以上证明开发部署已生效,**不证明真实 BLE 握手、Android 页面、断线恢复及 WiFi 切换通过**。下一步先运行只读真机 BLE 仪器测试,验证加密、状态、设置、内容列表、当前帧、密码不回传以及断开后重连。 + +本机已有 Python 协议/会话/控制/WiFi/专用主机检查组合 36 项通过,旧 API 28 项通过;Kotlin/JVM 5 项通过,开发 debug APK 编译通过。未完成项仍以总实施状态和需求验收映射为准。 diff --git a/移动端相关内容/安卓app/测试结果归档/2026-09-24_文档与登记门禁.md b/移动端相关内容/安卓app/测试结果归档/2026-09-24_文档与登记门禁.md new file mode 100644 index 0000000..a663f9a --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-09-24_文档与登记门禁.md @@ -0,0 +1,32 @@ +# 文档与测试登记阶段证据 + +## 已完成 + +- 建立移动需求、协议 1.0 设计、详细设计、测试矩阵、构建说明和跨端维护入口。 +- 根协作指南、设备需求、设备开发说明及双方测试流程同步视觉检查门禁。普通 UI/BLE 入口不强制实屏,确需时先询问用户如何启动。 +- 私有测试机代号 `android-test-a` 经 USB 授权检查并登记;真实设备标识未写入本报告,空白示例保持空白,真实文件由精确 Git 忽略规则排除。 +- Windows ADB 无 usb 标记时,用 USB-only 查询确认传输;无法判定时拒绝,不从序列号形式猜测。 +- 仓库卫生检查加入手机私有标识、真实登记和签名密钥检查。 + +## 验证 + +| 检查 | 结果 | +|---|---| +| 登记脚本 unittest | 8 项通过;空白/损坏/已存在/多机/未授权/USB 判断/系统变化/隐私门禁 | +| test_repository_hygiene.py | 8 项通过,包含原设备凭据回归及新增手机标识检查 | +| 主测试机登记和在线检查 | 通过,只输出测试代号 | +| Git 私有登记忽略 | 通过 | +| 完整仓库卫生检查(包含二进制扫描) | 通过,504 个候选文件 | +| Markdown 相对链接检查 | 11 个文档,无失效链接(检查时数量) | +| git diff --check | 通过;Git 提示的现有行尾规范转换不属于空白错误 | +| 实屏/摄像头 | 不适用;未启动视觉程序或切换视觉测试图案 | + +## 尚未完成 + +Android Studio 用户安装、KMP 源码、协议黄金向量、BLE/公共业务实现、板端部署、debug APK 和真实联调尚未完成。本文不能作为设备功能或 App 交付通过证据。未修改设备功能时间及发布版本,未导出 IMG/OTA。 + +## 继续位置 + +官方安装包 `android-studio-quail4-patch1-windows.exe` 已保存至 `<用户普通下载目录>/AndroidStudio/`,共 1484419056 字节。来源为 Android 官方下载页面提供的 Google 分发链接(2026.1.4.8)。SHA-256 与官网一致:`07fb2a6d54d14b137e2a5db274ba0e70be9291eb4245707f53164bb30cd69e95`;Windows Authenticode 为 Valid,签名者 Google LLC。未启动安装器,未替用户安装 Android Studio。 + +安装包下载核验完成后按用户指令停下等待正常安装。安装完成从环境确认与工程创建继续,不重复覆盖测试机登记,不启动正式签名或视觉检查。 diff --git a/移动端相关内容/安卓app/测试结果归档/2026-09-24_断线与重连排查.md b/移动端相关内容/安卓app/测试结果归档/2026-09-24_断线与重连排查.md new file mode 100644 index 0000000..82a5929 --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-09-24_断线与重连排查.md @@ -0,0 +1,32 @@ +# 断线与重连排查(进行中) + +用户反馈:App 连接后可能自行断开,返回重连不成功。测试手机使用代号 android-test-a;不记录真实蓝牙地址、序列号和网络凭据。 + +## 已实施 + +- RPC/握手内部超时转换为通信失败,关闭旧 GATT,避免被 CancellationException 分支吞掉而残留连接界面。 +- GATT 建立超时也转换为通信失败;仅在握手前针对同一设备最多两次尝试,用户取消不重试,业务写请求不重放。 +- 该手机曾对一次 MTU 请求报告 517 和 247;App 接收上限保守限制为 247,服务发现只启动一次,防止重复发现/订阅。 +- 设备端应用声明 MTU 与 BlueZ 上限取较小值的修复已在此前开发部署,本轮未改动显示驱动。 +- 真机测试增加持续轮询、生产 Controller 三页读取及前后台释放/重连。运行器允许 `--poll-cycles 0` 单独定位连接/Controller 问题,但此模式不算持续轮询验收;默认仍为 60。运行器主机超时时停止本 App 的 instrumentation,防止遗留连接。 + +## 实际结果 + +| 项目 | 结果 | +|---|---| +| 核桃派主服务、bluetooth、aw859-bluetooth | 三者 active;未发现上次部署中途停服 | +| Android debug 与 instrumentation APK 构建 | 通过;源码在原中文路径,构建使用隔离 ASCII 路径 | +| JVM 测试 | 6 项通过:跨语言加密 3、协议 2、RPC 超时清理 1 | +| 60 轮真实状态/当前帧读取及重新握手 | 一次通过,耗时 195.316 秒 | +| 最新重复 MTU 回调修复后的底层读取 | 增强测试完成底层部分,随后 Controller 等待首屏数据失败;整体不能判通过 | +| 生产 Controller 三页及前后台 | 等待首屏容量数据 30 秒超时,未完成后续步骤;已补充 phase/error 诊断 | +| 随后针对性复测 | 扫描成功,GATT 建立失败;尚未进入 Controller 检查 | +| 间歇性 GATT 建立失败 | Android 曾报告状态码 62;同环境后续可连接并完成持续测试,原因未确认 | +| 仓库卫生检查 | 已通过 550 个候选文件;后续新文档仍需最终复查 | +| 视觉检查 | 不适用;未启动相机或切换实屏测试图案 | + +以上不等同首版交付通过,也不将一次稳定通信视为偶发问题已经完全解决。当前修复 APK 已覆盖安装在主测试机,只有 debug 签名。 + +## 下一步 + +先确认测试期间没有人工操作干扰及设备距离,继续定位 GATT 建立失败;随后重新完成生产 Controller 状态机测试、蓝牙关闭/恢复、服务重启及异常断线恢复。按用户要求,新 root 用途必须先停止对话说明并等待开始授权;此前授权仅覆盖本 App 的蓝牙权限授予,不自动扩大用途。 diff --git a/移动端相关内容/安卓app/测试结果归档/2026-09-25_BLE_WiFi性能样本.json b/移动端相关内容/安卓app/测试结果归档/2026-09-25_BLE_WiFi性能样本.json new file mode 100644 index 0000000..f0afcef --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-09-25_BLE_WiFi性能样本.json @@ -0,0 +1,1466 @@ +[ + { + "elapsed_seconds": 0.152, + "connected": false, + "animation_active": true, + "refresh_hz": 97.317, + "frames": 42547, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.36916835699797, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 1.285, + "connected": false, + "animation_active": true, + "refresh_hz": 97.317, + "frames": 42658, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.36916835699797, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 2.41, + "connected": false, + "animation_active": true, + "refresh_hz": 97.317, + "frames": 42768, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.36916835699797, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 3.554, + "connected": false, + "animation_active": true, + "refresh_hz": 97.317, + "frames": 42878, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.36916835699797, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 4.684, + "connected": false, + "animation_active": true, + "refresh_hz": 97.409, + "frames": 42989, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.20967741935484, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 8.914, + "connected": false, + "animation_active": true, + "refresh_hz": 97.409, + "frames": 43101, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.20967741935484, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 10.047, + "connected": false, + "animation_active": true, + "refresh_hz": 97.344, + "frames": 43511, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.484018264840184, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 11.173, + "connected": false, + "animation_active": true, + "refresh_hz": 97.344, + "frames": 43622, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.484018264840184, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 12.311, + "connected": false, + "animation_active": true, + "refresh_hz": 97.344, + "frames": 43731, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.484018264840184, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 13.438, + "connected": false, + "animation_active": true, + "refresh_hz": 97.344, + "frames": 43842, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.484018264840184, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 14.565, + "connected": false, + "animation_active": true, + "refresh_hz": 97.405, + "frames": 43952, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.484018264840184, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 15.703, + "connected": false, + "animation_active": true, + "refresh_hz": 97.405, + "frames": 44062, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 25.992961287078934, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 16.828, + "connected": false, + "animation_active": true, + "refresh_hz": 97.405, + "frames": 44173, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 25.992961287078934, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 17.955, + "connected": true, + "animation_active": true, + "refresh_hz": 97.405, + "frames": 44282, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 25.992961287078934, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 19.095, + "connected": true, + "animation_active": true, + "refresh_hz": 97.405, + "frames": 44392, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 25.992961287078934, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 20.222, + "connected": true, + "animation_active": true, + "refresh_hz": 97.426, + "frames": 44503, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 28.78787878787879, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 21.625, + "connected": true, + "animation_active": true, + "refresh_hz": 97.426, + "frames": 44613, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 28.78787878787879, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 26.514, + "connected": true, + "animation_active": true, + "refresh_hz": 97.426, + "frames": 44749, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 28.78787878787879, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 27.65, + "connected": true, + "animation_active": true, + "refresh_hz": 97.405, + "frames": 45227, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.609657947686117, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 28.777, + "connected": true, + "animation_active": true, + "refresh_hz": 97.405, + "frames": 45337, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.609657947686117, + "memory_application_bytes": 244150272 + }, + { + "elapsed_seconds": 29.918, + "connected": true, + "animation_active": true, + "refresh_hz": 97.425, + "frames": 45446, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.529588766298897, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 31.047, + "connected": true, + "animation_active": true, + "refresh_hz": 97.425, + "frames": 45557, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.529588766298897, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 32.353, + "connected": true, + "animation_active": true, + "refresh_hz": 97.425, + "frames": 45667, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.529588766298897, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 33.485, + "connected": true, + "animation_active": true, + "refresh_hz": 97.425, + "frames": 45795, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.529588766298897, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 34.614, + "connected": true, + "animation_active": true, + "refresh_hz": 97.412, + "frames": 45905, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.542799597180263, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 35.757, + "connected": true, + "animation_active": true, + "refresh_hz": 97.412, + "frames": 46015, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.542799597180263, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 36.882, + "connected": true, + "animation_active": true, + "refresh_hz": 97.412, + "frames": 46126, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.542799597180263, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 38.006, + "connected": true, + "animation_active": true, + "refresh_hz": 97.412, + "frames": 46236, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.542799597180263, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 39.138, + "connected": true, + "animation_active": true, + "refresh_hz": 97.412, + "frames": 46345, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.542799597180263, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 40.289, + "connected": true, + "animation_active": true, + "refresh_hz": 97.411, + "frames": 46456, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.377956718671363, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 41.432, + "connected": true, + "animation_active": true, + "refresh_hz": 97.411, + "frames": 46568, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.377956718671363, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 42.558, + "connected": true, + "animation_active": true, + "refresh_hz": 97.411, + "frames": 46679, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.377956718671363, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 43.687, + "connected": true, + "animation_active": true, + "refresh_hz": 97.411, + "frames": 46789, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.377956718671363, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 44.825, + "connected": true, + "animation_active": true, + "refresh_hz": 97.42, + "frames": 46899, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.83661119515885, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 45.951, + "connected": true, + "animation_active": true, + "refresh_hz": 97.42, + "frames": 47009, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.83661119515885, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 47.131, + "connected": true, + "animation_active": true, + "refresh_hz": 97.42, + "frames": 47119, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.83661119515885, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 48.256, + "connected": true, + "animation_active": true, + "refresh_hz": 97.42, + "frames": 47234, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.83661119515885, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 49.383, + "connected": true, + "animation_active": true, + "refresh_hz": 97.407, + "frames": 47344, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.83661119515885, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 50.724, + "connected": true, + "animation_active": true, + "refresh_hz": 97.407, + "frames": 47454, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.341389728096676, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 51.851, + "connected": true, + "animation_active": true, + "refresh_hz": 97.407, + "frames": 47584, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.341389728096676, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 52.987, + "connected": true, + "animation_active": true, + "refresh_hz": 97.407, + "frames": 47694, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.341389728096676, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 54.112, + "connected": true, + "animation_active": true, + "refresh_hz": 97.407, + "frames": 47805, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.341389728096676, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 55.244, + "connected": true, + "animation_active": true, + "refresh_hz": 97.408, + "frames": 47914, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.377956718671363, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 56.387, + "connected": true, + "animation_active": true, + "refresh_hz": 97.408, + "frames": 48024, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.377956718671363, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 57.524, + "connected": true, + "animation_active": true, + "refresh_hz": 97.408, + "frames": 48136, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.377956718671363, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 58.659, + "connected": true, + "animation_active": true, + "refresh_hz": 97.408, + "frames": 48247, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.377956718671363, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 59.785, + "connected": true, + "animation_active": true, + "refresh_hz": 97.426, + "frames": 48357, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.487437185929647, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 60.976, + "connected": true, + "animation_active": true, + "refresh_hz": 97.426, + "frames": 48467, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.487437185929647, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 62.114, + "connected": true, + "animation_active": true, + "refresh_hz": 97.426, + "frames": 48583, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.487437185929647, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 63.252, + "connected": true, + "animation_active": true, + "refresh_hz": 97.426, + "frames": 48694, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.487437185929647, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 64.408, + "connected": false, + "animation_active": true, + "refresh_hz": 97.429, + "frames": 48805, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.487437185929647, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 65.532, + "connected": false, + "animation_active": true, + "refresh_hz": 97.429, + "frames": 48917, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.395885599598596, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 66.671, + "connected": false, + "animation_active": true, + "refresh_hz": 97.429, + "frames": 49027, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.395885599598596, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 67.81, + "connected": true, + "animation_active": true, + "refresh_hz": 97.429, + "frames": 49138, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.395885599598596, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 68.953, + "connected": true, + "animation_active": true, + "refresh_hz": 97.429, + "frames": 49249, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.395885599598596, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 71.042, + "connected": true, + "animation_active": true, + "refresh_hz": 97.402, + "frames": 49360, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 28.203834510595357, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 72.168, + "connected": true, + "animation_active": true, + "refresh_hz": 97.402, + "frames": 49563, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 28.203834510595357, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 73.311, + "connected": false, + "animation_active": true, + "refresh_hz": 97.402, + "frames": 49674, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 28.203834510595357, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 74.436, + "connected": false, + "animation_active": true, + "refresh_hz": 97.403, + "frames": 49784, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 28.203834510595357, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 75.561, + "connected": false, + "animation_active": true, + "refresh_hz": 97.403, + "frames": 49894, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.892028254288597, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 76.701, + "connected": false, + "animation_active": true, + "refresh_hz": 97.403, + "frames": 50004, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.892028254288597, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 78.017, + "connected": true, + "animation_active": true, + "refresh_hz": 97.403, + "frames": 50115, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.892028254288597, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 79.18, + "connected": true, + "animation_active": true, + "refresh_hz": 97.403, + "frames": 50243, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.892028254288597, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 80.305, + "connected": true, + "animation_active": true, + "refresh_hz": 97.423, + "frames": 50356, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.43718592964824, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 81.433, + "connected": true, + "animation_active": true, + "refresh_hz": 97.423, + "frames": 50466, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.43718592964824, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 82.567, + "connected": true, + "animation_active": true, + "refresh_hz": 97.423, + "frames": 50576, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.43718592964824, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 83.692, + "connected": true, + "animation_active": true, + "refresh_hz": 97.423, + "frames": 50686, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.43718592964824, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 84.83, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 50796, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.40963855421687, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 85.958, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 50907, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.40963855421687, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 87.098, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 51016, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.40963855421687, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 88.339, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 51127, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.40963855421687, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 92.714, + "connected": true, + "animation_active": true, + "refresh_hz": 97.411, + "frames": 51248, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.40963855421687, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 93.856, + "connected": true, + "animation_active": true, + "refresh_hz": 97.411, + "frames": 51675, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 28.082536487166582, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 94.998, + "connected": true, + "animation_active": true, + "refresh_hz": 97.421, + "frames": 51786, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 25.767488676396578, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 96.125, + "connected": true, + "animation_active": true, + "refresh_hz": 97.421, + "frames": 51897, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 25.767488676396578, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 97.25, + "connected": true, + "animation_active": true, + "refresh_hz": 97.421, + "frames": 52007, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 25.767488676396578, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 98.394, + "connected": true, + "animation_active": true, + "refresh_hz": 97.421, + "frames": 52116, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 25.767488676396578, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 99.682, + "connected": true, + "animation_active": true, + "refresh_hz": 97.415, + "frames": 52228, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 25.767488676396578, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 100.83, + "connected": true, + "animation_active": true, + "refresh_hz": 97.415, + "frames": 52353, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.774031202818318, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 102.077, + "connected": true, + "animation_active": true, + "refresh_hz": 97.415, + "frames": 52465, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.774031202818318, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 103.209, + "connected": true, + "animation_active": true, + "refresh_hz": 97.415, + "frames": 52587, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.774031202818318, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 104.343, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 52697, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.774031202818318, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 105.47, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 52808, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.16733870967742, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 106.609, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 52917, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.16733870967742, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 107.736, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 53028, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.16733870967742, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 108.865, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 53138, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.16733870967742, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 110.028, + "connected": true, + "animation_active": true, + "refresh_hz": 97.422, + "frames": 53248, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.78034102306921, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 111.153, + "connected": true, + "animation_active": true, + "refresh_hz": 97.422, + "frames": 53361, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.78034102306921, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 112.278, + "connected": true, + "animation_active": true, + "refresh_hz": 97.422, + "frames": 53471, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.78034102306921, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 113.403, + "connected": true, + "animation_active": true, + "refresh_hz": 97.422, + "frames": 53580, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.78034102306921, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 114.529, + "connected": true, + "animation_active": true, + "refresh_hz": 97.413, + "frames": 53690, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.78034102306921, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 115.669, + "connected": true, + "animation_active": true, + "refresh_hz": 97.413, + "frames": 53800, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.09904474610357, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 116.81, + "connected": true, + "animation_active": true, + "refresh_hz": 97.413, + "frames": 53911, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.09904474610357, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 117.937, + "connected": true, + "animation_active": true, + "refresh_hz": 97.413, + "frames": 54022, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.09904474610357, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 119.078, + "connected": true, + "animation_active": true, + "refresh_hz": 97.413, + "frames": 54132, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.09904474610357, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 120.35, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 54243, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.272727272727273, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 121.492, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 54367, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.272727272727273, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 122.619, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 54478, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.272727272727273, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 123.748, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 54588, + "deadline_misses": 0, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 27.272727272727273, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 124.889, + "connected": true, + "animation_active": true, + "refresh_hz": 97.421, + "frames": 54698, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.88821752265861, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 126.013, + "connected": true, + "animation_active": true, + "refresh_hz": 97.421, + "frames": 54809, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.88821752265861, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 127.151, + "connected": true, + "animation_active": true, + "refresh_hz": 97.421, + "frames": 54918, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.88821752265861, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 128.279, + "connected": true, + "animation_active": true, + "refresh_hz": 97.421, + "frames": 55029, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.88821752265861, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 129.403, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 55139, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.88821752265861, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 130.545, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 55249, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.676752395360566, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 131.673, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 55360, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.676752395360566, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 132.812, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 55470, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.676752395360566, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 133.937, + "connected": true, + "animation_active": true, + "refresh_hz": 97.41, + "frames": 55581, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.676752395360566, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 135.062, + "connected": true, + "animation_active": true, + "refresh_hz": 97.415, + "frames": 55690, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.747109100050277, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 136.191, + "connected": true, + "animation_active": true, + "refresh_hz": 97.415, + "frames": 55800, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.747109100050277, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 137.317, + "connected": true, + "animation_active": true, + "refresh_hz": 97.415, + "frames": 55910, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.747109100050277, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 138.453, + "connected": true, + "animation_active": true, + "refresh_hz": 97.415, + "frames": 56020, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.747109100050277, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 139.596, + "connected": true, + "animation_active": true, + "refresh_hz": 97.432, + "frames": 56130, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.747109100050277, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 140.728, + "connected": true, + "animation_active": true, + "refresh_hz": 97.432, + "frames": 56242, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.25250501002004, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 141.862, + "connected": true, + "animation_active": true, + "refresh_hz": 97.432, + "frames": 56352, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.25250501002004, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 142.988, + "connected": true, + "animation_active": true, + "refresh_hz": 97.432, + "frames": 56462, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.25250501002004, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 144.117, + "connected": true, + "animation_active": true, + "refresh_hz": 97.432, + "frames": 56572, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.25250501002004, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 145.248, + "connected": true, + "animation_active": true, + "refresh_hz": 97.424, + "frames": 56682, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.609657947686117, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 146.395, + "connected": true, + "animation_active": true, + "refresh_hz": 97.424, + "frames": 56792, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.609657947686117, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 147.537, + "connected": true, + "animation_active": true, + "refresh_hz": 97.424, + "frames": 56904, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.609657947686117, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 148.663, + "connected": true, + "animation_active": true, + "refresh_hz": 97.424, + "frames": 57015, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.609657947686117, + "memory_application_bytes": 244420608 + }, + { + "elapsed_seconds": 149.787, + "connected": true, + "animation_active": true, + "refresh_hz": 97.416, + "frames": 57125, + "deadline_misses": 1, + "oe_faults": 0, + "driver_error_present": false, + "cpu_application_percent": 26.492724535875563, + "memory_application_bytes": 244420608 + } +] diff --git a/移动端相关内容/安卓app/测试结果归档/2026-09-25_本轮交付核对.md b/移动端相关内容/安卓app/测试结果归档/2026-09-25_本轮交付核对.md new file mode 100644 index 0000000..38ee06f --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-09-25_本轮交付核对.md @@ -0,0 +1,43 @@ +# 2026-09-25 本轮交付核对 + +## 产物与运行状态 + +- Android 0.1.0,versionCode 2,仅 debug 签名;APK 位于 `../构建与发布/交付包/0.1.0-2/奇妙小屏幕-0.1.0-debug.apk`。 +- APK SHA-256:`f7c1dc215aa5934359a37fd001e1bc3cbf959b4a386662b1a886efb1ab73ea46`。 +- 调试证书 SHA-256:`b413e68c0bd2311c491ebd087ac5791c7c2ad8267793f92f6055d5521bd3ef4d`,沿用旧包证书,可覆盖安装。 +- Android 主测试机已覆盖安装 code 2。最后实际 Activity 测试主动断开,板端 connected=false、BLE available=true,无驱动错误。 +- 设备 VERSION 1.1.1 不变,FEATURE_UPDATED_AT 为 2026-09-25T21:46+08:00;本轮部署清单全部源文件摘要与本地一致。不生成 IMG/OTA。 + +## 本轮已通过 + +| 验证 | 结果及边界 | +|---|---| +| Python 全套 | 461 通过,9 跳过;复用现有 FFmpeg 后另补测 6 通过、1 libheif 跳过。剩余环境项为 libheif 编码工具、Windows 目录符号链接、专用旧 worker 隔离探针。 | +| JVM | 9 项通过:加密/分片/超时和 Controller 状态恢复;模拟链路不随生产 APK 发布。 | +| 隔离构建 | 全新且带空格的 ASCII 目录执行 92 个任务,JVM/debug APK/测试 APK 全部成功;中文源码原位保留。 | +| Android lint | 已通过,0 错误;保留依赖升级等警告,不为消警告擅自升级锁定工具链。 | +| 完整 BLE | 277.369 秒通过,60 轮读取、重新扫描后第二次握手、Controller 三页、缩略图、后台释放与返回重连。此前状态 62 失败保留在历史报告。 | +| 真实控制写入 | 118.701 秒通过:当前亮度重提交、活动动画暂停/当前位置 seek/原倍速提交、恢复原暂停状态,默认内容保持不变。 | +| code 2 实际 Activity | 60.105 秒通过:蓝牙关闭/恢复、三页、后台 Home 33 秒、返回重连、主动断开。已授权 root 输入按语义节点位置操作,未截图。 | +| 网络 | 扫描、冲突后会话可用、保留密码重新应用当前静态网络通过;DNS 逗号/换行解析修复已部署。 | +| 双手机 | 用户确认普通扫描不可抢占、主机退出后第二台接入操作成功、第二台退出后广播恢复。未指定操作不扩充为全功能验证。 | +| 部署恢复 | 故意安装后退出 97,真实回滚恢复程序与配置摘要,服务 active;随后真实 stop/start 五项全部通过。 | +| 后续升级逻辑 | 蓝牙纳入持久组件事务,35 项相关单测通过;不等同真实完整 OTA 验收。 | +| 性能 | 原动画+预览/WiFi 扫描窗口约 97.4 Hz,截止丢失增加 1,OE 故障 0;详见性能 JSON,无蓝牙关闭前后因果对照。 | +| 仓库卫生 | 567 个候选文件检查通过;真实凭据、调试密钥不在产物中。 | + +## 尚未验证或尚待完善 + +- Android 8–12 等其他系统版本、定位开关、拒绝/永久拒绝权限的完整 UI 矩阵未跑完;不能由 Android 13 已授权真机推定通过。 +- 不同 SSID、错误密码、DHCP 与静态互换、下次开机生效尚未逐项真机覆盖。当前网络重新应用成功只证明该实际场景。 +- 新内容选择/保存开机默认有共享业务及网页状态一致性的本机测试,当前轮真实写入只覆盖不更换内容的操作。 +- 状态详情目前可展开 JSON,尚未完全转换成中文分组卡片;独立 Compose UI 模拟测试矩阵未完成。 +- 完整真实 OTA 与掉电恢复未在本轮执行;普通部署失败回滚、真实 systemd 停启已单独通过。 + +因此本包保留“开发验证”标记,不声称所有首版验收已完成。视觉检查不适用:没有改驱动或底层输出,没有启动摄像头,也没有切换实屏测试图案。 + +## 恢复与清理 + +最新部署前完整程序备份:`/opt/matrix-screen-controller.mobile-backup-71e6e1b8dcfe`。备份附带原主服务 unit、蓝牙配置和项目 drop-in,按 `deploy_mobile_dev.py` 的 rollback 路径恢复;程序目标可能为 symlink,不能用只备份链接代替实际目录内容。恢复不覆盖 `/var/lib/matrix-screen-controller`,恢复后检查主服务、BLE 与配置摘要。不要把故障测试中的旧备份误当最新程序。 + +本轮四个已核验的远端 staging 目录已清理,恢复副本保留;临时手机输入脚本由测试 finally 删除。旧 code 1 包保留用于双手机测试追溯,不自动替换第二台手机上的版本。 diff --git a/移动端相关内容/安卓app/测试结果归档/2026-09-25_重启与连接恢复.md b/移动端相关内容/安卓app/测试结果归档/2026-09-25_重启与连接恢复.md new file mode 100644 index 0000000..77acd86 --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-09-25_重启与连接恢复.md @@ -0,0 +1,63 @@ +# 重启后连接恢复与联调记录 + +用户确认全部机器已重启,并明确授权 root 切换专用测试手机蓝牙。重新执行手机登记/USB 授权和核桃派凭据门禁,未沿用前一日进程、连接或运行目录判断。 + +## 初始状态与授权操作 + +- android-test-a USB 调试与登记身份核验通过。 +- 核桃派主服务、bluetooth.service、aw859-bluetooth.service 均 active;mobile.available=true,初始无控制会话、收发计数为 0。 +- root 返回 UID 0;关闭手机蓝牙后验证 bluetooth_on=0,恢复后验证为 1。仅操作授权蓝牙用途,未扩展系统修改。 + +## 故障证据与修复 + +重启后首次测试失败。手机日志显示连接已建立,先回调缓存 MTU 517,随后才完成 MTU 247 协商;App 在缓存回调中提前调用 discoverServices,没有获得 onServicesDiscovered,之后连接被释放。 + +Android 修复:等待实际不大于 247 的 MTU 回调后启动一次服务发现;未出现该回调则 2 秒后备启动;关闭取消后备任务。保留此前超时清理及仅握手前有限重试。设备代码、协议格式与显示驱动未改,未重新部署设备。 + +修复后日志显示 MTU 247 回调后服务发现成功、通知订阅完成。`BleIntegrationTest --poll-cycles 0` 通过,115.718 秒:两次真实加密会话、状态/容量/设置/内容/帧/网络只读接口、生产 Controller 三页与缩略图队列、短后台返回取消释放、后台 30 秒释放以及前台重连。此模式不替代 60 轮持续读取;完整复测另外记录。 + +## 后续联调记录(按事件保留) + +DNS 修复开发部署成功,主服务与 mobile.available 均正常;回退副本保留在 `/opt/matrix-screen-controller.mobile-backup-82b8d262a76b`,本轮不导出 IMG/OTA、不推进设备 VERSION。修复后使用独立重建 APK 执行真实 BLE 原网络重新应用,123.272 秒整体通过:保存的静态 IPv4 字段不变、密码保留、网络任务成功、重新连接当前网络、BLE 后续请求与 Controller 生命周期正常。 + +真实网络激活首轮在保存前返回 BAD_REQUEST。只读诊断确认:当前为静态 IPv4,nmcli 把多个 DNS 返回为逗号分隔单行,原解析按换行,validate_wifi_settings 拒绝该混合字符串。修复设备端 read_saved 兼容逗号/换行并去重,新增三种格式的密码保留往返测试;网络/移动控制/API 共 46 项回归通过。更新设备功能时间为 2026-09-25T16:10+08:00,版本号不变;部署和重新应用网络的结果另行记录。 + +独立重建:从中文源码目录通过文档脚本复制到新建、包含空格的 ASCII 构建根目录,所有 92 个构建任务执行,JVM 测试、debug APK、instrumentation APK 均成功。此验证不依赖旧构建目录,也不要求不同构建路径产出字节相同 APK。 + +BLE WiFi 扫描、拒绝故意过期的配置修订后继续 ping、10 轮状态/帧读取及 Controller 生命周期组合测试通过(159.495 秒)。本机 mobile protocol/session/control 与 dedicated_host 共 33 项 pytest 通过;依赖产生弃用提示,未记为测试失败。 + +并行运行 150 秒性能采样,保存 122 个有效样本,详见 `2026-09-25_BLE_WiFi性能样本.json`。期间原有动画持续运行,未切换内容;完成帧增加 14578,截止丢失增加 1,OE 故障增加 0,未发现驱动错误。未连接 20 样本:平均刷新 97.379 Hz、应用 CPU 平均 26.74%;连接 102 样本:平均刷新 97.416 Hz、CPU 平均 27.07%;两组应用内存最大均 233.10 MiB。此为同一运行窗口的观察值,包含 WiFi 扫描和帧读取,不能替代蓝牙关闭前后的受控对照;不宣称零丢失。 + +第二台手机后续结果:用户明确报告连接成功,执行了一些操作且未发现问题,随后退出第二台 App。操作清单没有逐项登记,因此只记录“第二台接入和用户手动操作通过”,不推定 WiFi 切换/所有写操作已覆盖。退出后板端复核 connected=false、ActiveInstances=1,释放与恢复广播通过。主机占用时不可扫描、异常退出后第二台接入、第二台退出后广播恢复的普通双手机流程已完成。 + +双手机分阶段验收:用户在第二台手机扫描,报告看不到设备;同步板端确认主测试机控制会话仍 connected=true,BlueZ ActiveInstances=0,符合占用时暂停广播。随后通过正常 ADB force-stop 主测试 App 验证异常退出释放;板端确认 connected=false、ActiveInstances=1,广播恢复。当时请求第二台重新扫描并连接;后续用户已确认成功,见本节第二台结果。本项仅证明普通扫描入口未提供抢占机会,不把它夸大为恶意客户端无法尝试连接的证明。 + +真实 systemd 主服务 stop/start 测试通过:RuntimeDirectory 的测试 sentinel 在停服及重新启动后均保留;config.json、wifi_config.json、mobile/identity.json 摘要不变;服务启动后 mobile.available=true 且无残留手机会话。测试 sentinel 已清理。随后正常启动主测试 App 并确认重新连接成功。该项不等同完整故障回滚或 OTA 测试。 + +用户随后明确授权 root 自动点击本 App。直接从 instrumentation 执行带引号的命令未达到预期点击效果,改用只在授权模式推送的 shell 辅助脚本:核对本 App 为前台、限制 tap 的数字参数/Home、验证成功标记,结束删除脚本。真实 `AppLifecycleTest --root-input` 通过,49.45 秒:自动连接、内容/设置/设备页切换、Home 后等待 33 秒、返回自动重连和主动断开。未操作其他 App、系统安全开关或摄像头。 + +实际 Activity 测试结果:第一次被 MIUI 的后台启动限制拒绝;测试改为正常 shell 显式启动 App 后,App 前台与自动连接检查通过,但点击“内容”时被系统拒绝,错误为 INJECT_EVENTS 权限不足。不能记为三页 UI 自动化通过。已准备可选 `--test activity --root-input`,只根据本 App 语义控件的当前中心位置点击,并允许 Home 键验证后台;不改系统权限/安全开关、不操作其他 App。当时属于新 root 用途,因而暂停;用户随后明确授权并通过测试,结果见上段。测试 APK 编译通过。 + +完整 `BleIntegrationTest` 随后通过,290.908 秒,包含默认 60 轮持续读取及全部 Controller 前后台流程。`lintDebug` 通过,0 错误、14 警告;警告主要为依赖可升级提示、版本目录建议、KTX 建议、低版本忽略高版本权限属性,以及应用图标/备份规则配置建议。未为消除升级提示改动已锁定工具链。APK 使用现有 Android Debug 证书,签名验证通过。 + +上述已完成项目均分别记录;其余未覆盖场景见实施状态,当前不宣称全部首版验收完成。视觉检查不适用,未启动相机或切换实屏图案。 + +## 断线状态清理版本的复测 + +新增 ControllerRecoveryTest 三项测试通过:传输失败后清空旧画面与状态并可重连、主动断开取消在途操作、旧扫描回调不能污染新扫描。真实 Activity 蓝牙切换/三页/后台释放/返回重连通过,65.637 秒。 + +该版之后的 BLE 持续回归完成 60 轮读取;测试主动关闭第一会话后,第二次 GATT 建立两次返回状态 62,整项失败,191.663 秒。手机日志显示第一会话由测试正常 close,不能将这次失败描述为持续读取期间的莫名掉线。板端复核 available=true、connected=false,广播实例为 1。测试修改为第二次连接前重新扫描,与正式 App 路径一致;仅凭此修改不能证明地址复用就是根因,复测结果须另行登记。 + +## 本机完整回归 + +全套 pytest 首次为 456 通过、1 失败、9 跳过。唯一失败是 index.html 添加手机连接状态后 ui-copy.json 未重新生成;运行正式生成器更新 424 条文案索引后,test_ui_copy_editor.py 的 10 项全部通过。新增生成索引也加入开发部署清单。跳过项不能计作通过,不用本机 mock 结果替代板端硬件测试。 + +仓库卫生检查通过,562 个 Git 候选文件,未输出真实凭据。未启动正式签名、IMG 或 OTA 导出。 + +重新扫描连接版本完整 BLE 真机复测通过,277.369 秒:60 轮状态/帧读取、第二次扫描与加密握手、生产 Controller 三页数据、缩略图、后台释放及返回重连。该 APK 同时包含预览确认时间/过期提示与开机默认内容回读。之前 GATT 状态 62 的失败仍保留,不以单次成功宣称全部无线环境绝无断线。 + +真实开发部署故障恢复通过:安装文件与 unit 后故意退出 97,同一 EXIT 回滚路径恢复程序;被部署文件及 config.json、wifi_config.json、mobile/identity.json 摘要与测试前一致,服务 active。随后真实 stop/start 五项全部通过,BLE available 且无残留会话。故障测试副本保留于 `/opt/matrix-screen-controller.mobile-backup-e224b2adf70b`;这不是正式 OTA 或掉电测试。 + +控制写入专项真实 BLE 回归通过,118.701 秒:使用当前修订重新提交原亮度并回读一致;活动动画暂停、当前位置 seek、原倍速写入成功,finally 恢复原暂停状态,开机默认内容保持一致。测试未选择新内容。首轮曾因厂商在覆盖安装后异步撤回蓝牙权限而未进入 BLE 测试;已授权 root 授予后的延时复核修正后通过。 + +后续升级兼容:蓝牙文件/服务状态纳入 ota_components 的持久系统事务,无 FRP 载荷的移动端补丁也会运行安装入口;旧事务日志不触碰蓝牙。新增测试包括无 FRP 载荷进入事务、失败恢复文件/屏蔽状态、旧日志兼容,组件与生命周期单测 35 项通过。此处尚不代表完整真实 OTA 升级验收。 diff --git a/移动端相关内容/安卓app/测试结果归档/2026-09-26_WIFI切换专项.md b/移动端相关内容/安卓app/测试结果归档/2026-09-26_WIFI切换专项.md new file mode 100644 index 0000000..c875629 --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-09-26_WIFI切换专项.md @@ -0,0 +1,25 @@ +# 2026-09-26 Android WiFi 切换专项 + +结论:`ANDROID-TEST-WIFI` 在已登记的专用 Android 手机上通过。第二网络为本机移动热点;核桃派原网络最终恢复。只验收本编号,其他 Android 版本和测试编号未执行。真实设备标识、SSID、IP 和密码不进入本归档。 + +## 测试版本与方式 + +- Android App:debug 构建,`versionCode 2`、`versionName 0.1.0`;UI 源码 SHA-256 `4C9F1E67ACA84ED025110CD36DAF2A20EE1DA7FBFF7F525A2EED0D4203FB2819`,控制器源码 SHA-256 `1B23010730AEE35F2512BB247A79040360A046A055B7029E269A17F9790686E7`。 +- 核桃派已部署版本 `1.1.1`,功能时间 `2026-09-25T21:46+08:00`;BLE 协议主/次版本 `1.0`。 +- 先通过私有凭据门禁、手机登记和 ADB 授权检查。电脑热点参数只在测试进程内读取;原 NetworkManager 文件以受保护权限备份,并为每次切网设置超时恢复。App 的语义控件操作使用本次获授权的 root 输入辅助,范围限于本 App 设置页。 + +| 场景 | 实机结果 | +|---|---| +| 设备扫描与手填 | App 设置页设备扫描无错误;另行核对扫描列表含电脑热点。手动输入 SSID 提交成功。手填能力通过;真正隐藏的接入点未验证。 | +| 字段校验 | 空 SSID 时保存按钮禁用;通过 App 提交无效静态 IPv4 时收到参数错误,板端原网络未切换。 | +| 密码显示与清除 | App 的“显示本次输入的密码”只在提交前显示临时输入;提交后密码文本消失。App 的 BLE WiFi 详情无 `password` 字段。 | +| 错误密码 | App 曾显示“网络连接失败”;独立复测时 App 经 BLE 读到任务 `failed`,即使板端 WiFi 暂时失联,BLE 仍可查询。短时恢复计时器执行后,原配置文件摘要、保存与活动网络、服务状态通过核对,保护材料已清理。 | +| 电脑热点 DHCP | 通过 App 立即切换;已核验热点网段内的板端连接、SSH 主机密钥、服务状态、BLE 保存配置和活动连接,IPv4 模式为 DHCP。 | +| 静态 IPv4 | 通过 App 提交热点静态地址;BLE 保存配置为静态,板端实际连接地址与提交值一致。 | +| 返回 DHCP | 通过 App 再提交 DHCP;BLE 保存配置和板端活动连接均回到热点 DHCP。 | +| 下次开机生效 | 通过 App 保存原网络及原 IPv4 配置,保存后活动连接仍为热点;受控软件重启后,App、BLE 与板端均确认连接原网络。 | +| 最终恢复与保密 | 原 NetworkManager 文件和受保护副本 SHA-256 相同;保存和活动网络均为测试前网络,服务运行。热点、原网络和错误密码未出现在所查板端服务日志中。临时恢复目录、计时器及手机短时私有测试文件已清理;最终 App、BLE、板端复核通过。 | + +测试过程中曾有两类测试器误判:一次在软件重启后提前要求 NetworkManager 文件字节自行恢复,一次只按固定时间寻找 App 错误提示。已分别改为先验证实际连接、再恢复原文件,以及单独用 BLE 任务终态核验错误密码。每次中断后均核验原网络恢复;最终独立复测通过。 + +本轮没有真实隐藏热点,隐藏 SSID 的无线连接分支标为未验证;已验证手填 SSID。未修改显示驱动或输出实现,视觉检查不适用;未开启摄像头或切换实屏测试图案。 diff --git a/移动端相关内容/安卓app/测试结果归档/2026-09-26_四栏界面优化.md b/移动端相关内容/安卓app/测试结果归档/2026-09-26_四栏界面优化.md new file mode 100644 index 0000000..a890468 --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/2026-09-26_四栏界面优化.md @@ -0,0 +1,39 @@ +# 安卓四栏界面与连接体验优化 + +App:0.2.0,versionCode 3,debug 签名。设备协议主版本保持 1,设备发布版本仍为 1.1.1;功能时间为 2026-09-26T14:30+08:00。未导出 OTA 或 IMG。 + +## 实现范围 + +四栏导航、设备与 WiFi 顶部摘要、保存设备及长按管理、已连接时独立扫描、GATT RSSI、精简共享预览、设备持久内容排序、中文设置、折叠 WiFi 表单、DHCP/静态字段、立即生效确认、密码保留/替换、关于与手机昵称设置已实现。设备端增加保守的脱敏 WiFi 失败分类,密码不回读。需求、详细设计、兼容记录及 root 测试规则已同步。 + +## 已完成验证 + +- 仓库外独立复制构建:JVM 13 项通过;主 APK、测试 APK、Android lint 通过(无错误,16 项工具链、资源与代码建议警告)。源码摘要匹配,debug 签名校验通过。 +- 设备相关业务与协议回归:46 项通过;隔离复制后同组 46 项通过。手机登记门禁单测 8 项通过。仓库卫生检查通过。 +- 登记手机 android-test-a:ADB 与 root 门禁通过,所有自动点击、滑动及输入经 root;语义节点仅用于读取目标实时位置。 +- 约 340dp 宽度,字体 1.0/1.3 的竖屏及字体 1.3 横屏:四栏、三点菜单和关于操作通过,分别 8.656/8.983/9.191 秒。字体、旋转、密度均恢复。该检查验证可见控件及操作,不代表所有设备布局均已验证。 +- 实际 Activity:91.814 秒通过。四栏、精简页面、菜单、后台 30 秒释放及返回重连、永久昵称写入后重新连接、恢复原昵称、取消记忆后断开及手动重新连接通过。 +- 最新完整 BLE 真机回归:301.514 秒通过。60 轮状态和画面读取,设备持久混合内容顺序摘要一致,两次加密会话、配置冲突后会话保持、当前 WiFi 立即重应用并保留密码、原动画暂停/位置/速度恢复、亮度当前值写入、默认内容读回、Controller 内容/设置读取、后台释放及前台重连均通过。 +- 最新 root 真机 WiFi 专项通过:validate、validate_invalid、scan、wrong、inspect_failure。扫描填入、确认取消不提交、密码替换输入、明确 AUTH_FAILED 与“密码错误”中文反馈通过。错误后核对原网络配置摘要及连接,日志不含测试密码,恢复备份/定时器和私有测试材料已清理。 +- WiFi 表单先前实测通过:空名称不能保存、DHCP 关闭展开静态字段、确认弹窗、取消不提交、非法 IP 拒绝;此过程未改变原网络。 +- 真实 systemd stop/start:运行目录保留、配置/网络/持久身份摘要不变、移动接口恢复及无遗留手机会话均通过。 + +## 失败与修复记录 + +布局测试初版的直接命令引号不被 UiAutomation 解释,随后显示配置变更终止 instrumentation;改由电脑端调整并在 finally 恢复,启动测试后仅读取语义位置和 root 输入。重复文字选择改为可见节点定位后,三种布局通过。没有使用无障碍动作注入。 + +WiFi 专项首次 wrong 阶段在提交前建立 BLE 失败;未把此失败算作密码测试。正常阶段增加显式断开并在无手机会话下恢复服务后重跑,各阶段通过。 + +最新 Activity 首次未找到扫描目标,51.460 秒失败;真实服务生命周期恢复后重测通过。保留失败,不声称已定位此偶发问题的根因。 + +## 边界 + +真实多设备切换需要两台可用设备,本轮仅模拟验证;旧 Android 版本未实测。跨 SSID、实际 DHCP/静态地址互换缺少可用第二网络,未验证。驱动与底层显示输出未修改,实屏视觉检查不适用;未启动摄像头、未主动切换测试图案。 + +## 交付 + +见 `../构建与发布/交付包/0.2.0-3/`。SHA-256、北京时间构建日期、工具链和源码摘要见 manifest.json 与 SHA256SUMS。最终 verify 阶段通过:App 显示原网络及实际 IP,板端原网络连接、服务运行、恢复材料清理均核验通过。测试手机最终覆盖安装 0.2.0 / code 3,读取已安装 base.apk 的 SHA-256 与交付 APK 完全一致。最后仓库卫生检查通过(579 个候选文件),未输出秘密值。 + +## 可复用经验同步 + +本轮已验证处理方法已整理到 [常见问题与排障](../如何安卓测试/常见问题与排障.md),并同步测试流程、构建说明及根/移动端协作规则。包括命令解析与 LF、布局变更导致测试终止、可见语义节点选择、Compose 开关定位、root 文本编辑和键盘判断、权限安装后复核、阶段主动断开及 WiFi 定时恢复。偶发扫描失败的根因仍未确认;本记录不将服务恢复后通过当作根因证明。后续每轮测试完成反馈都要说明经验文档更新。 diff --git a/移动端相关内容/安卓app/测试结果归档/实施状态.md b/移动端相关内容/安卓app/测试结果归档/实施状态.md new file mode 100644 index 0000000..624c18a --- /dev/null +++ b/移动端相关内容/安卓app/测试结果归档/实施状态.md @@ -0,0 +1,36 @@ +# 当前实施状态 + +更新时间:2026-09-26。当前 App 为 **0.2.0 / versionCode 3**,四栏界面优化已实现。交付包:`../构建与发布/交付包/0.2.0-3/`,本轮证据与边界见 `2026-09-26_四栏界面优化.md`。设备功能已部署,协议主版本仍为 1;不导出 OTA/IMG、不推进设备发布版本。 + +已通过:独立构建、JVM 13 项、设备相关回归 46 项、root 真机四栏/菜单/设备重命名与记忆/后台连接生命周期、窄屏大字体横屏导航、真实 systemd 生命周期。完整 BLE(301.514 秒)、错误密码明确分类及原网络恢复、最终覆盖安装摘要核对均通过,详见本轮报告;真实多设备、旧 Android、跨 SSID 与实际 DHCP/静态互换不能按模拟或表单测试记为通过。 + +以下保留上一轮历史状态,历史 APK 及三页 UI 结果不能代替本轮验收。 + +# 首版历史实施状态 + +更新时间:2026-09-25。本文件是续作入口,不能将设计目标或局部通过当作全量交付。 + +| 项目 | 已确认结果 | +|---|---| +| 环境 | 用户安装 Android Studio 并确认中文可用;KMP/Compose 三模块、锁定工具链、Wrapper 可构建。 | +| 独立构建 | 中文源码保留原位,工具不支持时按脚本复制到仓库外 ASCII 目录;全新且带空格目录的 92 个任务通过。 | +| Android 连接修复 | 修复内部超时被取消异常吞掉、MTU 缓存回调导致发现服务停滞、断线旧状态残留与迟到扫描回调问题。 | +| 真机 Activity | 主测试机 root 用途均已由用户明确授权。最新连接恢复版三页、蓝牙关闭/恢复、后台释放和返回重连通过,65.637 秒。 | +| 连续 BLE | 早前完整测试通过,290.908 秒。最新复测 60 轮完成,但主动断开后的第二次 GATT 建立返回状态 62;不得记作整轮通过,按正式 App 重新扫描后,完整复测 277.369 秒通过;保留此前失败,不宣称已证明根因。 | +| 双手机 | 主机占用时第二台扫描不到;主机异常退出后第二台连接并操作成功;用户退出第二台后会话释放且广播恢复。用户未逐项说明操作,不扩大为所有写功能通过。 | +| 网络 | 真机扫描、过期配置冲突、重新应用当前静态网络并保留密码通过。修复 nmcli 多 DNS 的逗号/换行解析;不同 SSID、错误密码和 DHCP/静态互换尚未全部实测。 | +| 设备部署 | 最新代码已部署,功能时间 2026-09-25T21:46+08:00,设备 VERSION 仍为 1.1.1。最新恢复副本 `/opt/matrix-screen-controller.mobile-backup-71e6e1b8dcfe`。 | +| 生命周期 | 真实 systemd stop/start、运行目录保留、配置/网络/持久身份摘要不变、BLE 恢复通过;不等同完整故障回滚或 OTA 验收。 | +| 性能 | 150 秒 122 样本;原动画持续,刷新约 97.4 Hz、截止丢失增加 1、OE 故障 0,无驱动错误;未做蓝牙关闭的因果对照。 | +| 本机回归 | 修复与新增测试后全套 461 通过、9 跳过;复用已安装 FFmpeg 后补测 6 项通过,剩余 libheif、Windows 目录符号链接、专用旧 worker 隔离探针未在此轮运行。 | +| 当前 APK | 最新包为 `../构建与发布/交付包/0.1.0-2/`,源码独立重建、debug 签名验证通过;实际 Activity 三页/蓝牙切换/后台重连通过,60.105 秒。code 1 保留为此前双手机测试包。 | +| 视觉 | 不适用;没有修改显示驱动或输出实现,未启动摄像头或切换实屏测试图案。 | + +## 当前进行中的工作 + +1. 最新 UI 补充预览确认时间/过期提示、当前开机默认名称、内容类型、临时密码可见开关,以及拒绝权限后的说明入口;DHCP 提交清除残留静态 DNS。重新构建通过,BLE 与生产 Controller 完整真机复测通过;新版实际 Activity 回归通过,已打包到 0.1.0-2。 +2. 第二次连接测试先重新扫描,避免跨会话复用临时连接句柄;保留之前状态 62 的失败证据,不宣称已证明根因。 +3. 网页生成索引和首次载入连接提示修复已部署;真实开发部署故障回滚及恢复后生命周期检查通过。后续 OTA 蓝牙事务补充已通过 35 项本机测试,已部署并核对文件摘要一致;本轮不导出 OTA。 +4. 逐项核对需求/实现/验收差异,整理最终报告、APK、签名与摘要;当前值写入/动画暂停恢复真机通过;完整内容选择、默认保存、权限各分支、旧 Android 版本和完整 OTA 未全部实测,不能冒充已验证。 + +详细历史见 `2026-09-25_重启与连接恢复.md`。未来设备更新只登记移动端影响,不自动发布 App。本轮仅 debug 签名,不导出 IMG/OTA,不推进设备发布版本。 diff --git a/移动端相关内容/开发要求/移动端需求.md b/移动端相关内容/开发要求/移动端需求.md new file mode 100644 index 0000000..d3ea8a9 --- /dev/null +++ b/移动端相关内容/开发要求/移动端需求.md @@ -0,0 +1,68 @@ +# 移动端需求 + +## 1. 产品与范围 + +名称:奇妙小屏幕。Android 首版为 BLE 控制器,支持 Android 8.0(API 26)以上。连接不要求核桃派已联网,也不要求手机与设备在同一 WiFi。设备、内容、设置为三个一级页面,中文为首版界面语言。 + +| 稳定编号 | 行为与合格标准 | +|---|---| +| MOBILE-PLATFORM | KMP 与 Compose 共享业务、界面;Android 本次交付,iOS 平台实现及发布不在本次范围。 | +| MOBILE-DISCOVERY | 前台扫描项目 BLE 服务;显示昵称、固定短编号和信号强度;首次点击目标后免系统配对连接。 | +| MOBILE-IDENTITY | 用设备持久随机 ID 区分设备;设备昵称可改,短编号不变;手机昵称默认型号,可改。蓝牙地址仅作扫描时临时连接句柄。 | +| MOBILE-CONNECTION | 一机一连接、一设备一控制会话;连接暂停广播,断开恢复;握手无效或超时断开,连接成功以握手完成为准。 | +| MOBILE-RECONNECT | 前台自动尝试上次设备,失败回到可扫描状态,不改连其他设备;主动断开后本次前台会话不立即反复重连。 | +| MOBILE-BACKGROUND | 连续后台 30 秒释放 BLE,停止预览和轮询;设备继续原显示;30 秒内返回取消释放;系统杀进程由设备会话超时释放。 | +| MOBILE-SECURITY | 免连接密码,标准算法自动加密;拒绝无效协议,不承诺鉴别正版 App 或抵抗主动仿制/中间人。无任意 shell 或文件接口。 | +| MOBILE-STATUS | 设备页展示连接、版本、协议、实际刷新率/帧数/截止丢失/最后错误、资源、网络、电压与保护、性能模式、容量及当前内容;不可用字段显示原因,不伪装为零。 | +| MOBILE-LIBRARY | 读取用户静态模板、动图、演示内容;名称、类型、静态缩略图、空列表与失效项状态清晰;不编辑、复制、排序或删除内容。 | +| MOBILE-PLAYBACK | 选择播放;动画暂停/继续、跳转、沿用设备支持的固定倍速;过期资源或播放会话提示刷新,不控制已替换会话。 | +| MOBILE-DEFAULT | 设置开机默认会保存并立即播放,界面明确说明;失败遵循设备回滚语义,不虚报成功。 | +| MOBILE-PREVIEW | 相关页可见且已连接时,每 2 秒请求当前逻辑帧,支持手动刷新;最多一个在途预览,命令优先,显示最近成功时间和过期状态。 | +| MOBILE-WIFI | 由核桃派扫描网络,手机选择或手填隐藏 SSID;输入密码、DHCP/静态 IPv4、立即/下次开机生效;区分保存成功、正在连接、连接失败和实际在线。 | +| MOBILE-WIFI-SECRET | 不读取设备已存密码;保留密码是明确的“不更改”操作,不把空文本误作清空;本次输入可临时显示,提交后清理,日志和持久缓存不保存。 | +| MOBILE-SETTINGS | 网络提示、亮度、方向、允许刷新率、性能模式;字段仅提交本次修改,全部值由设备校验和回读确认。 | +| MOBILE-WEB-COEXIST | 网页和手机并用;最近成功操作生效;手机连接网页短提示加顶栏常驻手机名;初次打开网页也显示当前状态,不伪造新连接提示。 | +| MOBILE-ERRORS | 权限未授予、蓝牙关闭、设备忙、不兼容、断线、超时、冲突和失败可区分;操作有界结束并恢复控件,保留未提交草稿。 | +| MOBILE-COMPAT | 协议能力发现及主版本协商;新增可选字段不破坏旧 App;破坏契约的设备改动必须登记移动端影响。 | +| MOBILE-RELEASE | App 独立版本,首版仅 debug APK;正式签名和后续 App 迭代必须用户明确要求。 | +| MOBILE-TEST-PRIVACY | 私有测试登记不进 Git;空白示例和脚本可提交;自动测试精确选择授权设备,证据脱敏。 | +| MOBILE-VISUAL-GATE | 一般前端/BLE 入口改动不要求实屏视觉检查;确需时先暂停询问用户如何启动,未经指示不启动采集或视觉程序,不切换实屏测试图案。 | + +MOBILE-RECONNECT/MOBILE-ERRORS 补充:断线清除旧画面、状态、缩略图队列;重新连接从新会话读取。主动断开取消未完成操作,旧连接或旧扫描的迟到回调不得覆盖新连接。业务冲突/内容不存在等正常错误回复不等同于传输失效,不应仅因此断开已经认证的会话。 + +不在首版:绘画、资源编辑/上传/导出/删除、FRP 配置、OTA 上传、电压校准、应用商店、自动更新、云账户。缩略图与当前帧读取属于控制界面预览,不等于提供图案文件导出。 + +## 2. 交互规则 + +- 连接状态始终可见;设备忙不会强行踢掉另一个手机。断开不会清屏、停止动画或改变用户配置。 +- 修改设备昵称需要已连接,修改手机昵称为 App 本地操作并同步当前会话;只显示普通文本,不作身份认证。 +- 设备昵称 1–40 Unicode 字符,手机昵称 1–40 字符;首尾空白去除,拒绝控制字符;设备编号由持久 UUID 前 8 个十六进制字符构成,冲突时显示完整 ID。 +- 模板列表保留设备现有顺序,分页读取;动图采用首帧静态缩略图,空动图不可播放。 +- WiFi 扫描不是由手机代扫,不额外读取手机 WiFi 凭据。首版支持开放网络与个人密码网络,企业 EAP 提示暂不支持;真实硬件支持能力由设备报告。 +- WiFi 切换不得依赖 HTTP 仍可达,进度经 BLE 查询;失败保留可再次修改入口,不自动替用户猜密码。 +- 修改刷新率仅限设备允许的 15、20、30、45、60、80、100 Hz;方向限 0、90、180、270。其他范围沿用设备业务校验并通过协议能力公布。 + +## 3. 平台差异 + +| 项目 | Android 本次 | iOS 后续 | +|---|---|---| +| UI/业务 | 共享 Compose、状态机、协议模型 | 复用共享模块,按平台适配布局 | +| BLE | Android BluetoothGatt,串行 GATT 操作 | CoreBluetooth,独立平台实现 | +| 权限 | API 26–30 扫描定位权限与系统要求;31+ 附近设备扫描/连接权限,不索取后台定位 | Info.plist 蓝牙用途描述,按 iOS 生命周期实现 | +| 后台 | 30 秒释放为上限目标;进程终止也能由设备释放 | 不假设后台持续运行,策略需未来真机确认 | +| 构建 | Windows、Android Studio、Gradle、debug APK | macOS/Xcode、签名及真机测试,不声称当前已验证 | + +## 4. 更新约束 + +需求变更先更新本表,再更新详细设计、协议和测试。设备更新不得自动修改 App;影响通信能力、驱动边界或部署行为时必须填写 `跨端兼容与变更记录.md`。首版开发是本次明确授权,不代表后续自动更新授权。 + +测试编号、执行位置和合格条件见 `../安卓app/如何安卓测试/README.md`。阶段进度不是需求的一部分。 + +## 2026-09-26 四栏界面修订(替代同编号旧描述) +- MOBILE-STATUS/PREVIEW:四栏为连接、设备状态、显示内容、设备设置。顶部小字显示设备昵称、代号及实际 WiFi 状态;两处预览统一“设备画面”与右侧手动刷新,正常时不显示刷新频率和确认时间,仅过期提示。删除运行状态和容量详情。 +- MOBILE-DISCOVERY/IDENTITY/RECONNECT:App 按完整 ID 记忆成功连接设备;保存列表长按重命名或忘记。只可重命名当前连接设备,忘记它则断开并取消自动重连。扫描可与已连接会话并行,首次和忘记后的设备只由手动选择连接;自动重连只尝试上次已记忆设备。切换先断开旧会话。 +- MOBILE-LIBRARY:列表使用设备保存的混合顺序;分页顺序变更返回冲突,App 重新读取完整列表。 +- MOBILE-WIFI/SETTINGS:亮度、中文方向;移除 App 刷新率/性能模式入口。WiFi 状态、默认收起的设置、提示秒数依次排列;SSID 右侧扫描,选择自动填入。DHCP 开启自动获取、关闭展开静态参数;仅立即生效,提交前确认。认证错误须有可靠设备原因,不把通用失败写成密码错误。 +- MOBILE-WIFI-SECRET:不回读原密码,“已设置密码”占位,原 SSID 留空保留,替换密码只驻留内存;取消确认不提交。 +- MOBILE-APP-SETTINGS:顶部三点菜单关于/软件设置,关于显示版本、构建编号及北京时间打包日期;手机昵称可离线修改并同步当前会话。 +- MOBILE-TEST-PRIVACY:本 App 调试所需测试权限可通过登记手机 ADB root 直接获取,无需重复询问;所有自动点击使用 root 并依据实时语义位置,禁止无障碍绕过。特殊情况或权限不足必须停止等待用户。视觉和物理门禁不变。 diff --git a/移动端相关内容/开发要求/设备通信协议.md b/移动端相关内容/开发要求/设备通信协议.md new file mode 100644 index 0000000..703c2b6 --- /dev/null +++ b/移动端相关内容/开发要求/设备通信协议.md @@ -0,0 +1,89 @@ +# 奇妙小屏幕 BLE 控制协议 1.0 + +本文是首版实现契约。Python/Kotlin 加密黄金向量、分片和 Android 13 真实 BLE 已有通过记录;各业务验收范围见测试结果归档,不能据此推断所有场景通过。命令只能进入设备公共业务服务,不调用网页 DOM,不提供 shell、任意路径或原生驱动地址。变化登记到跨端兼容记录。 + +## 1. GATT 与广播 + +| 项目 | 固定 UUID/行为 | +|---|---| +| Service | `9f57a001-6c31-4c58-bc22-1f728b641001` | +| RX | `9f57a002-6c31-4c58-bc22-1f728b641001`,Write with response;手机到设备 | +| TX | `9f57a003-6c31-4c58-bc22-1f728b641001`,Notify;设备到手机 | + +使用传统可连接 BLE 广播,包含服务 UUID;扫描响应提供 `QMS-` 加短编号。完整昵称与持久 UUID 在握手后取得并缓存;首次扫描尚无昵称时显示产品名与短编号。改名不改变 ID。持久 UUID 在设备首次启动生成,放 `/var/lib/matrix-screen-controller/mobile/identity.json`,镜像构建不得携带已生成身份。短编号为 UUID 去连字符后的前 8 位,仅展示用。 + +禁用经典蓝牙配对及不必要的音频/文件传输 profile;GATT 不要求系统 bonding。不能保证其他工具完全无法建立物理连接;保证未完成本协议握手不得执行控制。手机先订阅 TX,再发送握手。连接后停止广播;服务端另外检查并拒绝第二个连接,不以“不广播”代替互斥。 + +10 秒内未完成握手释放连接。活动会话最多间隔 5 秒发送业务请求或 ping;当前 Android 前台每 2 秒轮询状态,后台释放前每 2 秒 ping。设备连续 20 秒未收到有效消息释放。单次普通命令超时 10 秒,WiFi 扫描 20 秒;耗时切网作为任务立即返回。设备检测断开后目标 5 秒内恢复广播,蓝牙硬件不可用时显示故障并有界退避重试,不阻止显示服务。 + +## 2. 分片与消息 + +所有整数使用网络字节序。每条 RX/TX 特征值是一个分片:`magic:uint16=0x514d, wire:uint8=1, kind:uint8, message_id:uint32, index:uint16, count:uint16, total_length:uint32, payload:bytes`。头长 16 字节,payload 上限为 `ATT_MTU - 3 - 16`;必须在 MTU 23 时也可工作,不能强制大 MTU。 + +kind:1 为 client hello,2 为 server hello,3 为加密记录,4 为握手拒绝。message_id 在当前方向逐条递增,重连归零。每方向仅允许一个重组消息,index 从 0 连续递增,count 与 total_length 必须一致;总消息不超过 65536 字节。重复、乱序、超长、类型未知或 15 秒未完成重组均丢弃并关闭会话,不允许部分消息触发业务。两端操作队列串行;已开始的帧消息完整发完,控制在下一条消息优先,缩略图和预览不得积压。 + +业务载荷为 UTF-8 JSON 对象。请求:`{"id":"1","method":"status.get","params":{}}`;成功:`{"id":"1","ok":true,"result":{}}`;失败:`{"id":"1","ok":false,"error":{"code":"CONFLICT","message":"内容已改变,请刷新","retryable":false}}`。id 为当前会话递增的十进制字符串,最大 64 字符;服务端只接受新的请求编号,重复编号返回错误而不再次执行变更。连接中断后不得自动重放变更;先回读状态,必要时请用户重试。 + +通用错误:`BAD_REQUEST`、`NOT_READY`、`BUSY`、`UNSUPPORTED_VERSION`、`UNSUPPORTED_CAPABILITY`、`NOT_FOUND`、`CONFLICT`、`TIMEOUT`、`DEVICE_ERROR`。message 不包含密码、内部路径、命令行、异常堆栈或设备调试身份。握手拒绝只含固定错误码。 + +## 3. 自动加密 + +传输上限补充:ServerHello 在未建立会话时一律按 MTU 23 分片。`session.open` 可携带整数 `receive_mtu`(23~517,缺省 23),表示手机当前实际可接收的 ATT MTU;后续下行取该值与 BlueZ 写入回调 MTU 的较小值。此字段只限制分片大小,不影响加密参数。针对实际手机报告 247、BlueZ 回调报告 517 的不一致情况,禁止仅依据 BlueZ 数值发送大通知;断线重置上限为 23。 + +使用成熟库的 P-256 ECDH、HKDF-SHA256 与 AES-256-GCM,不手写椭圆曲线/AES。客户端和服务端每次连接都生成新的临时密钥和 32 字节随机数,公钥使用 65 字节 SEC1 非压缩编码。JSON 内二进制为标准 Base64,包含必要 padding。 + +ClientHello 字段:`protocol_major:1, protocol_minor:0, public_key, random`。ServerHello 字段相同。协商拒绝未知主版本,不建立降级明文会话。握手 JSON 不包含手机名称或 WiFi 信息。 + +设 `C`、`S` 为收到的两个完整原始 hello JSON UTF-8 字节(不重新序列化),`T=SHA256(uint32(len(C)) || C || uint32(len(S)) || S)`。salt 为 `SHA256(client_random || server_random)`,IKM 为 ECDH 共享秘密,info 为 ASCII `QMS-BLE-1` 加 T。HKDF 输出 72 字节:前 32 为 c2s key,接着 32 为 s2c key,最后两个 4 字节分别为 c2s/s2c nonce prefix。 + +加密记录为 `sequence:uint64 || ciphertext || tag:16bytes`。每方向 sequence 从 0 开始且必须等于期望值;nonce 为该方向 4 字节 prefix 加 sequence;AAD 为 ASCII `QMS-BLE-1`、T、方向字节(c2s=0,s2c=1)和 sequence。序号不回绕;鉴别失败、重放、跳序、非法公钥立即释放会话。会话密钥只存内存,断开销毁。 + +第一条加密请求必须为 `session.open`,带手机昵称;设备成功回复才算控制会话建立、才通知网页。应用层加密防被动窃听,不认证正版 App,不抵御主动中间人;UUID、昵称和 APK 内置常量都不是认证凭据。 + +## 4. 业务接口 + +响应包含协议主次版本与 capabilities。客户端忽略未知可选字段;缺少必需字段为协议错误。不存在某能力时隐藏或禁用相关操作并解释,不猜测默认支持。时间、大小和单位固定在字段名及模型中;不把网页内部 config 整包当作移动端契约。 + +| method | params | result 与业务边界 | +|---|---|---| +| `session.open` | `client_name` | `device_id, device_name, short_id, protocol_major, protocol_minor, capabilities, limits`;名称规则见需求。 | +| `session.ping` | 空 | `alive:true`;刷新有效会话活跃时间。 | +| `session.rename` | `client_name` | 回读当前名称并同步网页连接状态。 | +| `device.rename` | `name` | 更新持久设备昵称,返回身份元数据,不改变 UUID。 | +| `status.get` | 空 | 当前结构复用设备状态:`state, screen, service, power, resources, network, frp, ui, system, mobile` 等字段;手机使用的路径列于下文。不可用原因沿用各子对象定义,不能假设所有子对象均有 available 字段。 | +| `storage.get` | 空 | 设备与软件容量统计,复用现有有界缓存,单位 bytes。 | +| `settings.get` | 空 | 当前设置、可写字段、允许值及设置修订;不得带 WiFi 密码。 | +| `settings.patch` | `expected_revision, changes` | 仅允许亮度、方向、刷新率、性能模式、网络提示延迟;校验冲突后复用现有业务并回读。 | +| `wifi.scan` | 空 | `networks:[{ssid,security,signal_percent,connected}], scanned_at_ms`;时间为 Unix 毫秒;同 SSID/安全类型去重取最强信号,隐藏 SSID 用手动输入。 | +| `wifi.get` | 空 | 已保存配置(不含密码)、`password_configured`、实际连接状态、待生效状态。 | +| `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.thumbnail` | `type,id,revision` | 返回 `mime:image/png, data_base64`,64×64 静态缩略图;动画使用首帧。 | +| `content.play` | `type,id,revision` | 使用既有内容解析与显示服务;过期拒绝,不能顺便修改默认内容。 | +| `content.default.get` | 空 | 当前默认内容元数据。 | +| `content.default.set` | `type,id,revision` | 保存并立即播放;失败按既有默认内容事务回滚,返回最终状态。 | +| `playback.patch` | `session_id,paused?,position_ms?,speed?` | 操作既有动画会话;过期会话 CONFLICT,速度限设备已有枚举。 | +| `frame.get` | `known_revision?` | 未变返回 `unchanged:true`;变化返回逻辑 RGB 帧的 PNG Base64、frame_revision、采样时间。不传完整动图。 | + +settings 与 wifi 的 revision 为设备生成的不透明字符串,相关状态变化即更新;网页修改也必须更新同一修订。配置检查与提交在公共控制服务内串行执行,防止“检查通过后被另一个客户端覆盖”。错误不得回传旧密码或完整输入。 + +## 5. 生命周期与兼容 + +系统状态每 2 秒读取,容量进入设备页时读取,库按进入/刷新读取,缩略图仅为可见项排队且按设备 ID、条目 ID、修订缓存。后台立即停预览,30 秒关闭会话。重新连接获取完整状态,不把本地缓存写回设备。 + +BLE 只接收白名单方法。旧客户端遇新能力忽略;旧设备缺某能力时客户端明确提示。不兼容主版本停止控制并返回列表。蓝牙关闭、主服务重启、OTA 维护或适配器消失时失效会话不可复用;恢复后重新握手,不恢复旧密钥。 + +## 6. 契约测试要求 + +共享固定的测试私钥、hello 原始字节、HKDF 输出、双向密文与分片向量;测试密钥明确仅为公开夹具,不能来自设备。Python 与 Kotlin 双向互验,覆盖 MTU 23、大 MTU、边界分片、非 ASCII 昵称、错误公钥、篡改、重放、断线、库翻页冲突、任务失败和主版本拒绝。接口最终字段模型及黄金向量必须随实现提交,未经测试不能写成协议验收通过。 + +## 7. 当前状态模型的维护边界 + +`status.get` 当前复用设备只读状态构造函数,不依赖 REST URL 或网页控件;它仍与设备状态字段存在结构耦合,不能宣称只有驱动改变才需要检查手机兼容。移动端当前使用 `state.animation_playback`(active、session_id、paused、position_ms、total_duration_ms、speed、supported_speeds);其余状态作为详情展示。任何这些字段的删除、重命名、类型或含义变化必须按协议变更登记,不可随网页重构破坏旧 App。 + +`frame.get` 变化时提供 sampled_at_ms;unchanged 回复仅有 unchanged 与 frame_revision。App 用本机成功收到回复的时间表示新鲜度,避免设备与手机时钟偏差。缩略图缓存按当前连接隔离、按 type/id/revision 标识;断线清理,不把手机缓存写回设备。 + +## 2026-09-26 兼容扩展 +library.list 的用户条目遵循设备持久混合顺序,顺序变化影响 library_revision 和游标。演示条目固定在用户条目之前。 +task.get 可附加脱敏 error_code(AUTH_FAILED、NETWORK_UNAVAILABLE、IP_CONFIG_FAILED、UNKNOWN);旧客户端可忽略。没有可靠原因时用 UNKNOWN。wifi.get 仍不含密码。协议主版本保持 1。 diff --git a/移动端相关内容/开发要求/跨端兼容与变更记录.md b/移动端相关内容/开发要求/跨端兼容与变更记录.md new file mode 100644 index 0000000..3e242b1 --- /dev/null +++ b/移动端相关内容/开发要求/跨端兼容与变更记录.md @@ -0,0 +1,54 @@ +# 跨端兼容与变更记录 + +## 规则 + +- 设备版本取设备自己的 VERSION,App 版本取 Android 工程独立版本,BLE 协议主次版本独立编号。 +- 主版本相同的兼容新增通过 capabilities 宣告;旧 App 忽略未知可选字段,不发送未知能力命令。 +- 删除/重命名命令、改变单位/含义、强制字段变化或加密线格式改变是破坏兼容;需新的主版本或保留旧版本适配。不能直接让旧 App 错误控制设备。 +- 驱动内部实现变化但对外契约不变,登记“无需 App 更新”和验证依据;不能以“驱动没改”为由忽略业务协议破坏。 +- 没有用户 App 迭代指令时,只记录待办,不擅自修改或发布 App。 +- 每次跨端变更填写:需求编号、设备改动、协议版本、受影响 App、兼容策略、实际验证、待办、是否已有用户 App 授权。 + +## 当前基线 + +| 变更 | 状态 | 协议 | 移动端影响 | 授权与验证 | +|---|---|---|---|---| +| 首次 BLE 控制器、公共业务层、WiFi 扫描、网页连接提示 | 设计已确认,功能待实施 | 计划 1.0 | 新建 Android 首版,未来 iOS 复用 | 用户已明确授权首版;尚未完成设备与 APK 验收 | +| 视觉检查门禁 | 文档约束 | 无协议变动 | 普通 UI/BLE 开发不强制实屏;确需视觉检查必须先询问启动方式 | 用户明确要求;适用于本轮及后续 | + +## 后续登记模板 + +2026-09-26,WEB-PERFORMANCE-MODE/DEPLOY-OTA-KERNEL-RECOVERY:设备 REST 的 `/api/ota/status` 增加可选 `kernel_recovery` 状态,`/api/status.system.performance_mode.last_error` 对缺失 cpufreq 给出具体原因;已有字段和 BLE 协议不变。旧 App 忽略新增 REST 字段,无需 App 更新。本轮仅改设备端;本机和实机验证结果以本次交付记录为准。 + +2026-09-25,MOBILE-RECONNECT/MOBILE-ERRORS:App 增加连接/扫描代次隔离,主动断开取消未完成命令,清理失效画面与状态缓存;轮询收到业务拒绝时保留连接。模拟平台仅编入 commonTest,通过真实分片/RPC 层测试旧回调、传输失败、未完成操作取消后的恢复。协议不变、设备不需更新;本轮首版授权内实施,真机结果另行归档。 + +2026-09-25,DEVICE-MOBILE-SETTINGS/CONFIG-WIFI:实机静态网络含多个 DNS,nmcli 返回逗号分隔而旧解析仅按换行,读回配置不能直接保存。修复设备 NetworkManager 解析,保持 DNS 数组契约;网页/App 共同受益,协议不变,无需 App 功能升级或另发版本。当前已授权首版联调;需设备部署及原网络重新应用验证,结果见当日归档。 + +2026-09-25,MOBILE-CONNECTION/MOBILE-RECONNECT:实机日志证实缓存 MTU 回调早于实际交换完成,提前服务发现会停滞;Android 改为等待实际协商值,再启动一次服务发现,保留 2 秒后备启动。设备端、协议线格式与驱动未改变,无需重新部署核桃派。Android 13 的生产 Controller 三页读取、短暂后台保持、30 秒后台释放及前台重连实测通过(115.718 秒);完整持续复测与 Activity 语义测试继续进行。用户明确授权 root 切换专用手机蓝牙,已关闭并恢复成功。 + +2026-09-24,MOBILE-RECONNECT/MOBILE-ERRORS:Android 修复内部通信超时被视为主动取消而残留连接状态的问题;GATT 建立失败仅在握手前针对同一设备有限重试。对厂商重复 MTU 回调去重服务发现,接收上限保守限制为请求值 247。设备业务契约与协议版本不变,不重放写命令。用户已授权本轮 App 实现及断线排查;验证状态以测试结果归档为准。 + +首版联调补充:`session.open.receive_mtu` 为可选字段,缺省 23,声明手机实际接收分片上限。真实环境曾出现 Android MTU=247、BlueZ 回调 MTU=517,导致下行大分片丢失;现采用应用声明与 BlueZ 上限的较小值,ServerHello 固定按 MTU 23 分片。协议主版本保持 1,缺省行为可供未发送此字段的客户端使用。本轮用户已授权 Android 首版修改,Python/Kotlin 同步实现;真实 Android 13 只读会话及断开重连测试通过,尚未最终发布。 + +```text +变更日期: +需求编号: +设备侧变化: +协议主次版本及能力: +受影响的 App 版本: +兼容策略: +验证证据(通过/未验证/不适用): +移动端待办: +用户是否明确要求 App 迭代: +``` + +### 2026-09-25 验收补充 + +Android 断线清理与旧扫描回调隔离已通过模拟及 Activity 真机回归;UI 新增预览确认时间/过期提示、开机默认内容回读和临时密码可见开关。沿用既有 frame.get、content.default.get 等接口,设备协议主版本不变。DHCP 不再提交残留静态 DNS。昵称长度文档对齐已实现的 40 字符上限。网页文案生成索引同步,部署清单包含 ui-copy.json,不影响显示驱动。连续 BLE 读取完成后第二次连接出现过状态 62,保留失败记录并改为重新扫描后复测,不宣称根因已证明。 + +网页 WEB-MOBILE-PRESENCE 修正:首次载入已连接状态仅更新顶栏,不提示新的连接事件;之后检测到会话代数变化才短提示。只改网页事件判断,不改协议、App 或驱动,设备功能时间随本次可部署修复更新。 + +后续升级安装路径:ota_components 在存在移动端安装脚本时即使没有 FRP 载荷也进入持久系统组件事务,备份并恢复蓝牙配置、项目 drop-in、启用/屏蔽及运行状态;旧日志保持原行为。35 项组件/生命周期本机测试通过;真实完整 OTA 尚未执行,本轮不导出包。协议与 App 无需因此改变。 + +### 2026-09-26 四栏优化 +用户明确要求 App 迭代。App 0.2.0 / code 3:四栏、设备记忆、中文 WiFi 设置、关于与本地设置;旧记录通过后续握手迁移。设备 library.list 对齐持久顺序,task.get 增加可选 error_code;协议主版本 1,旧 App 忽略新字段。无驱动修改,无新板端依赖,不导出 OTA/IMG。实际验证结果另行归档。