同步移动端工程、设备控制改进与发布资料

This commit is contained in:
2026-09-26 19:21:12 +08:00
parent 912ba432cf
commit 98eadc5c8b
137 changed files with 9274 additions and 85 deletions
+10
View File
@@ -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")
}
@@ -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))
}
}
@@ -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() }
}
}
@@ -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("关闭")
}
}
@@ -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>
@@ -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
}
}
@@ -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" }
@@ -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
@@ -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" "$@"
@@ -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")) }
}
}
@@ -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 }
@@ -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() }
}
@@ -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
}
}
@@ -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
}
@@ -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() }
}
}
@@ -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]) }
}
}
@@ -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)
}
}
}
@@ -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")) }
}
}
@@ -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)
}
}
}
@@ -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"
}
]
}
@@ -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"
}
]
}
@@ -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"
}
]
}
@@ -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 的蓝牙权限授予,不自动扩大用途。
@@ -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,不推进设备发布版本。