同步移动端工程、设备控制改进与发布资料
This commit is contained in:
@@ -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`;遇到已有症状先沿用已验证方法,不从头试错。
|
||||
- 测试中出现失败、临时修正或恢复操作,完成后必须复盘。可复用的已验证解法更新对应测试流程、排障或构建文档;单次日志、耗时和重测结果写测试归档,未知根因明确保留,不把重测通过当作根因证明。
|
||||
- 最终反馈须说明经验已写入哪些文档及未解决项;无新增经验时不重复造条目。不仅在聊天里解释,也不能只留一次性报告。遵守根目录秘密与文档路径规则。
|
||||
@@ -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、采集或视觉测试程序,不主动切换实屏测试图案。专用测试手机授权不等于视觉检查授权。不适用时记录“不适用”。
|
||||
@@ -0,0 +1,10 @@
|
||||
# 奇妙小屏幕 Android
|
||||
|
||||
先读 `../开发要求/移动端需求.md` 和 [详细设计](安卓开发详细设计.md)。工程放 `安卓程序源代码/`,共享逻辑和共享界面也在该工程内,为未来 iOS 保留接口。
|
||||
|
||||
- [测试流程](如何安卓测试/README.md)
|
||||
- [测试设备登记](如何安卓测试/测试设备登记/README.md)
|
||||
- [构建与发布](构建与发布/README.md)
|
||||
- [实际实施状态](测试结果归档/实施状态.md)
|
||||
|
||||
Android Studio 正常安装完成前不声称已构建 APK。本轮只用 debug 签名。视觉检查须按 `../AGENTS.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),不得重复试用已知不可靠的输入或转储方法。
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/system/bin/sh
|
||||
exec su -c "$*"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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. 测试完成后的固定复盘
|
||||
|
||||
每轮检查:本轮有哪些失败/调整、是否已有可重复防范步骤、恢复是否核验、哪些原因仍未知。已验证解法更新本页和对应流程/构建说明;一次性证据留归档,不能将未证实推测写成固定根因。
|
||||
|
||||
最终反馈写明经验已更新的文档,以及尚未解决的问题或未验证边界。若没有新增经验,说明沿用现有方法即可,不复制重复条目。遵守根协作规则:不写真实凭据、手机标识、开发机绝对路径,文档更新后执行仓库卫生检查。
|
||||
@@ -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 "<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 密码、账户或其他应用数据。第二台手机可以由用户手动安装并辅助验收,无需自动登记其身份。
|
||||
@@ -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())
|
||||
@@ -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", "<fill>"), ("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()
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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详情/刷新率与性能入口描述由此节替代。
|
||||
@@ -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 仅用于测试,生产会话必须每次生成新的临时密钥。
|
||||
@@ -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")
|
||||
}
|
||||
+122
@@ -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))
|
||||
}
|
||||
}
|
||||
+240
@@ -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<DiscoveredDevice>()
|
||||
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<String, String>()
|
||||
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() }
|
||||
}
|
||||
}
|
||||
+41
@@ -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("关闭")
|
||||
}
|
||||
}
|
||||
+252
@@ -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<AccessibilityNodeInfo>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<application android:label="奇妙小屏幕" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false" android:supportsRtl="true">
|
||||
<activity android:name=".MainActivity" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
+179
@@ -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<Unit>()
|
||||
private val messages = Channel<ByteArray>(256)
|
||||
private val writes = Mutex()
|
||||
@Volatile private var pending: CompletableDeferred<Unit>? = null
|
||||
@Volatile private var gatt: BluetoothGatt? = null
|
||||
@Volatile private var rx: BluetoothGattCharacteristic? = null
|
||||
@Volatile private var closed = false
|
||||
private var signalPending: CompletableDeferred<Int?>? = 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<Unit>()
|
||||
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<Int?>()
|
||||
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
|
||||
}
|
||||
}
|
||||
+65
@@ -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<Array<String>> = 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() }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
Binary file not shown.
@@ -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
|
||||
Vendored
+248
@@ -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" "$@"
|
||||
Vendored
+82
@@ -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%
|
||||
@@ -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")
|
||||
@@ -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")) }
|
||||
}
|
||||
}
|
||||
+339
@@ -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<RememberedDevice> = 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<DiscoveredDevice> = emptyList(), val identity: JsonObject? = null,
|
||||
val status: JsonObject? = null, val storage: JsonObject? = null,
|
||||
val settings: JsonObject? = null, val wifi: JsonObject? = null,
|
||||
val library: List<JsonObject> = emptyList(), val networks: List<JsonObject> = emptyList(),
|
||||
val thumbnails: Map<String, String> = emptyMap(), val frame: String? = null,
|
||||
val 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<ControllerState> = mutable.asStateFlow()
|
||||
private fun loadRemembered(): List<RememberedDevice> = 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<RememberedDevice>) {
|
||||
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<String> = 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<String, JsonObject>()
|
||||
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<JsonObject> = emptyList()
|
||||
for (attempt in 0..1) {
|
||||
try {
|
||||
val loaded = mutableListOf<JsonObject>()
|
||||
var cursor: String? = null
|
||||
do {
|
||||
val result = call("library.list", buildJsonObject { put("limit", 50); cursor?.let { put("cursor", it) } })
|
||||
loaded += result.getValue("items").jsonArray.map { it.jsonObject }
|
||||
cursor = result["next_cursor"]?.jsonPrimitive?.contentOrNull
|
||||
} while (cursor != null)
|
||||
items = loaded
|
||||
break
|
||||
} catch (e: DeviceFailure) { if (e.code != "CONFLICT" || attempt == 1) throw e }
|
||||
}
|
||||
val defaultContent = call("content.default.get")
|
||||
change { it.copy(library = items, defaultContent = defaultContent) }
|
||||
}
|
||||
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 }
|
||||
+74
@@ -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<Int, ByteArray> = withTimeout(15_000) {
|
||||
var result: Pair<Int, ByteArray>? = 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() }
|
||||
}
|
||||
+70
@@ -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<ByteArray> {
|
||||
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<Int, ByteArray>? {
|
||||
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
|
||||
}
|
||||
}
|
||||
+9
@@ -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
|
||||
}
|
||||
+202
@@ -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<String, String>()
|
||||
val links = mutableListOf<SimulatedLink>()
|
||||
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<ByteArray>(Channel.UNLIMITED)
|
||||
private var sequence = 0L
|
||||
var failure: String? = null
|
||||
var blockStorage = false
|
||||
var storageEntered = CompletableDeferred<Unit>()
|
||||
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() }
|
||||
}
|
||||
|
||||
}
|
||||
+21
@@ -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<Int, ByteArray>? = 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<IllegalArgumentException> { Reassembler().accept(frames[1]) }
|
||||
val rx = Reassembler()
|
||||
frames.forEach { rx.accept(it) }
|
||||
assertFailsWith<IllegalArgumentException> { rx.accept(frames[0]) }
|
||||
}
|
||||
}
|
||||
+115
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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")) }
|
||||
}
|
||||
}
|
||||
+28
@@ -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<DeviceTransportFailure> { rpc.call("status.get") }
|
||||
assertTrue(error.message!!.contains("status.get"))
|
||||
assertTrue(closed)
|
||||
assertFailsWith<IllegalStateException> { rpc.call("status.get") }
|
||||
Unit
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+283
@@ -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<String?>(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<RememberedDevice?>(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<String>, 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("确定") } })
|
||||
}
|
||||
@@ -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 "<Android Studio的jbr目录>" --sdk "<Android 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 版本。
|
||||
- 每次交付放 `交付包/<App版本>-<versionCode>/`: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 "<Android 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)。
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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 包含校验值、调试证书摘要、工具链与源码文件摘要;不包含本机路径或真实设备标识。
|
||||
@@ -0,0 +1 @@
|
||||
2eedd9fccd27dcc0664e6e0f1003554677b632580d65ae17f95e7ea285a0529e 奇妙小屏幕-0.1.0-debug.apk
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -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`。
|
||||
@@ -0,0 +1 @@
|
||||
f7c1dc215aa5934359a37fd001e1bc3cbf959b4a386662b1a886efb1ab73ea46 奇妙小屏幕-0.1.0-debug.apk
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -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`。
|
||||
@@ -0,0 +1 @@
|
||||
9adc8e572384bb30a8a78115be43a8fefeefee6d25bda0136dff106e2a8bcab3 奇妙小屏幕-0.2.0-debug.apk
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -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 编译通过。未完成项仍以总实施状态和需求验收映射为准。
|
||||
@@ -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。
|
||||
|
||||
安装包下载核验完成后按用户指令停下等待正常安装。安装完成从环境确认与工程创建继续,不重复覆盖测试机登记,不启动正式签名或视觉检查。
|
||||
@@ -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 的蓝牙权限授予,不自动扩大用途。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 包保留用于双手机测试追溯,不自动替换第二台手机上的版本。
|
||||
@@ -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 升级验收。
|
||||
@@ -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。未修改显示驱动或输出实现,视觉检查不适用;未开启摄像头或切换实屏测试图案。
|
||||
@@ -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 定时恢复。偶发扫描失败的根因仍未确认;本记录不将服务恢复后通过当作根因证明。后续每轮测试完成反馈都要说明经验文档更新。
|
||||
@@ -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,不推进设备发布版本。
|
||||
@@ -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 并依据实时语义位置,禁止无障碍绕过。特殊情况或权限不足必须停止等待用户。视觉和物理门禁不变。
|
||||
@@ -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。
|
||||
@@ -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。实际验证结果另行归档。
|
||||
Reference in New Issue
Block a user