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。实际验证结果另行归档。