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

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
@@ -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
}
]
}