同步移动端工程、设备控制改进与发布资料
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user