Add authenticated WiFi transport with BLE fallback

This commit is contained in:
2026-10-01 14:32:18 +08:00
parent 071f06d365
commit f011d8357d
55 changed files with 6327 additions and 29 deletions
@@ -1,5 +1,7 @@
# Android 可重复测试流程
WiFi 加速与回退专项见 [WiFi 传输测试](WiFi传输测试.md),必须显式按影响选测。
**先读 [按影响选测](按影响选测.md)。以下矩阵列出可选覆盖,不是每轮必跑清单;历史版本专项仅在相关影响存在时执行。**
本文件维护步骤与合格标准,不写一次性通过结果。每轮测试前阅读 [常见问题与排障](常见问题与排障.md),优先沿用已验证方法。结果记录到 `../测试结果归档/`。真机先检查 `测试设备登记/prepare_test_device.py`,多机精确选择,日志仅使用代号。源码与 UI 尚未实现的测试不得写成通过。
@@ -0,0 +1,30 @@
# WiFi 传输按影响验证
适用 MOBILE-WIFI-TRANSPORT/FALLBACK/SAFETY、DEVICE-MOBILE-WIFI-TRANSPORT/LIFECYCLE、WEB-MOBILE-PRESENCE 和 TEST-MOBILE-WIFI-TRANSPORT。先读按影响选测和排障指南。下列场景须显式选择,不加入普通公共回归。
## 本机与模拟器
- Python:在设备源码根执行 `python -m pytest tests/test_mobile_wifi.py tests/test_mobile_session.py tests/test_mobile_protocol.py tests/test_mobile_control.py`。覆盖一次性凭据、身份、篡改、过期、重复写入、BLE 活跃超时、WiFi 超时、WebSocket、兼容统计字段和公开向量。
- Android:隔离构建运行 `:sharedCore:jvmTest`、`:androidApp:lintDebug`、`:androidApp:assembleDebug`、`:androidApp:assembleDebugAndroidTest`、`:uiTest:assembleDebug`。WifiCryptoTest 验证 Python 向量;HybridRpcTest 验证回退不重放写入、只读重取、网络变化、BLE 心跳和恢复协程异常收敛。
- 模拟器 `run_emulator_ui_test.py --scenario connection --scenario wifiTransport`,选 baseline 和 narrow。使用真实 Controller/Compose 与测试专用传输,验证 WiFi/蓝牙提示、权限原因/入口和断线清理;不代表真实局域网权限或无线链路已通过。
- API 37 运行时权限必须在对应系统验证。拒绝和撤销权限时应仍可 BLE 控制,恢复权限可再探测;低版本真机不能替代此项。
## 登记真机与核桃派
先执行凭据脚本、手机登记与 ADB/root 门禁,核对远端工具。使用 `run_ble_test.py --scenario <场景> --adb <ADB路径> --build <本轮隔离工程目录>`。不输出真实身份、SSID、地址、凭据或原始系统网络转储。
| 场景 | 操作及合格条件 |
|---|---|
| wifi-transport | BLE 加密握手、独立 WiFi 握手、相同未使用 App 缓存的缩略图比较,再由 HybridRpc 自动升级。连续帧/图片的 WiFi 计数增加,直接测试期间 BLE 字节不增加;记录驱动实际刷新率、截止丢失与错误。耗时是样本,不宣称固定加速倍数。 |
| wifi-transport-recovery | 手机初始 WiFi 必须已启用。root 关闭 WiFi,回退 BLE 后状态可读;等待平台完成关闭,再启用并等实际 WiFi 网络出现;自动恢复 WiFi。关闭蓝牙则整体断开且不能继续 RPC;最终恢复两种无线开关并核验。 |
| wifi-transport-network | 仅将核桃派当前已保存且正在使用的 WiFi 原样立即应用;保留密码,通过 BLE 查询任务,完成后核对配置并确认 WiFi 通信恢复。不是不同 SSID 或 DHCP 地址变化的证据。 |
| wifi-transport-restart | 测试会话通过 BLE 临时改名为固定测试标记;宿主仅在该标记且 WiFi 已认证时执行一次真实 systemd restart。旧会话必须关闭,重新发现、BLE 握手和 WiFi 升级后服务实例不同、设备身份相同。 |
| wifi-connection-entry | 生产 Activity 的 WiFi 文案、重扫保留、主动断开和重新连接;按实时语义位置 root 点击,不改变布局。 |
双手机互斥在第二台手机可用时专项验证,缺少设备记未验证。跨子网可达、不同 SSID、AP 隔离等真实拓扑须分别准备环境;单元测试与“不比较 SSID”的实现不能冒充真实拓扑验收。
后台生命周期保持原规则:立即停预览,30 秒关闭两通道;前台重新握手。测试前后核对配置、用户内容、服务健康与测试辅助文件清理。服务重启测试不是整机重启或 OTA 验收。普通传输修改不启动摄像头,不切换实屏测试图案。
## 证据
归档记录本轮源摘要、版本、场景、实际环境、结果、样本计数/耗时、恢复核验、失败与重测。日志仅保留固定阶段名、数字指标和测试代号;禁止把完整 status.get 或网络信息写入归档。必要未做项明确列出。
@@ -91,12 +91,15 @@ def main(argv=None):
return output
api = int(adb('shell', 'getprop', 'ro.build.version.sdk', quiet=True).strip())
permissions = ('BLUETOOTH_SCAN', 'BLUETOOTH_CONNECT') if api >= 31 else ('ACCESS_FINE_LOCATION',)
if api >= 37 and args.scenario.startswith('wifi-transport'):
permissions += ('ACCESS_LOCAL_NETWORK',)
permission_original = {}
if args.scenario == 'navigation':
existing = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True)
permission_original = {p: 'android.permission.' + p + ': granted=true' in existing for p in permissions}
layout_original = None
layout_density = None
restart_probe = None
try:
for relative in ('androidApp/build/outputs/apk/debug/androidApp-debug.apk',
'androidApp/build/outputs/apk/androidTest/debug/androidApp-debug-androidTest.apk'):
@@ -126,7 +129,11 @@ def main(argv=None):
raise RuntimeError('Navigation must not retain permission to auto-connect')
if args.root_input:
adb('push', str(Path(__file__).with_name('authorized_app_input.sh')), '/data/local/tmp/qms-authorized-input.sh', quiet=True)
if args.scenario == "connection-entry":
if args.scenario.startswith('wifi-transport'):
if 'wifi is enabled' not in adb('shell', 'cmd', 'wifi', 'status', quiet=True).lower().replace('-', ''):
raise RuntimeError('WiFi transport scenario requires initially enabled WiFi')
adb('push', str(Path(__file__).with_name('authorized_root_command.sh')), '/data/local/tmp/qms-authorized-root.sh', quiet=True)
if args.scenario in ("connection-entry", "wifi-connection-entry"):
adb("push", str(Path(__file__).with_name("authorized_root_command.sh")), "/data/local/tmp/qms-authorized-root.sh", quiet=True)
if args.scenario == "layout":
adb("push", str(Path(__file__).with_name("authorized_root_command.sh")), "/data/local/tmp/qms-authorized-root.sh", quiet=True)
@@ -150,6 +157,10 @@ def main(argv=None):
root("am start -W -n org.qimiaoscreen.controller/.MainActivity")
board_args = ['-e', 'device_name', board_name] if board_name else []
if library_hash: board_args += ['-e', 'library_order_hash', library_hash]
if args.scenario == 'wifi-transport-restart':
from wifi_restart_probe import RestartProbe
restart_probe = RestartProbe()
restart_probe.start()
output = adb('shell', 'am', 'instrument', '-w', '-r', *board_args,
'-e', 'scenario', args.scenario,
'-e', 'poll_cycles', str(args.poll_cycles),
@@ -157,7 +168,7 @@ def main(argv=None):
'-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.' + ('LibraryCacheTest' if args.scenario == 'cache-storage' else
'-e', 'class', 'org.qimiaoscreen.controller.' + ('WifiTransportTest' if args.scenario.startswith('wifi-transport') else 'LibraryCacheTest' if args.scenario == 'cache-storage' else
'LibraryBleCacheTest' if args.scenario == 'library-cache' else
{'ble': 'BleIntegrationTest', 'activity': 'AppLifecycleTest', 'layout': 'LayoutTest'}[args.test]),
'org.qimiaoscreen.controller.test/androidx.test.runner.AndroidJUnitRunner', timeout=360)
@@ -171,6 +182,14 @@ def main(argv=None):
try: action()
except Exception: errors.append(label)
cleanup('stop-test', lambda: adb('shell', 'am', 'force-stop', 'org.qimiaoscreen.controller', quiet=True))
if restart_probe is not None:
cleanup('real service restart', restart_probe.finish)
if args.scenario == 'wifi-transport-recovery':
cleanup('wifi restore', lambda: adb('shell', 'su', '-c', 'svc wifi enable', quiet=True))
cleanup('bluetooth restore', lambda: adb('shell', 'su', '-c', 'cmd bluetooth_manager enable', quiet=True))
time.sleep(3)
if 'wifi is enabled' not in adb('shell', 'cmd', 'wifi', 'status', quiet=True).lower().replace('-', ''):
errors.append('wifi restoration verification')
if layout_original and layout_density is not None:
cleanup('stop', lambda: root('am force-stop org.qimiaoscreen.controller'))
cleanup('density', lambda: root('wm density ' + layout_density))
@@ -5,6 +5,10 @@ from pathlib import Path
# name: (instrumentation family, board required, side effect)
SCENARIOS = {
'wifi-transport': ('ble', True, 'encrypted WiFi upgrade, identical image transfer comparison; no mutations'),
'wifi-transport-network': ('ble', True, 'reapply currently active board WiFi with password retained; verify hybrid routing recovery'),
'wifi-transport-restart': ('ble', True, 'restart board main service only after authenticated WiFi readiness; verify new BLE/WiFi session'),
'wifi-transport-recovery': ('ble', True, 'phone WiFi and Bluetooth disable/restore; verify fallback and whole-session termination'),
'connection': ('ble', True, 'scan, handshake, disconnect and reconnect'),
'data': ('ble', True, 'read status/settings/storage/library/frame/wifi'),
'library-order': ('ble', True, 'read BLE pages and board order digest'),
@@ -22,6 +26,7 @@ SCENARIOS = {
'remember': ('activity', True, 'forget and reconnect test device'),
'bluetooth-toggle': ('activity', True, 'disable and restore phone Bluetooth'),
'connection-entry': ('layout', True, 'connected entry, rescan and reconnect; layout restore'),
'wifi-connection-entry': ('layout', True, 'production WiFi indicator, root rescan/disconnect/reconnect; no layout changes'),
'layout': ('layout', True, 'three layout configurations and frame bounds; layout restore'),
}
COMMON = ('connection', 'data', 'library-order', 'navigation')
@@ -6,7 +6,7 @@ import re
import subprocess
import time
SCENARIOS = ('connection', 'status', 'content', 'settings', 'navigation', 'layout')
SCENARIOS = ('connection', 'wifiTransport', 'status', 'content', 'settings', 'navigation', 'layout')
CONFIGS = {'baseline': (None, '1.0', '0'), 'narrow': (340, '1.0', '0'),
'large-font': (340, '1.3', '0'), 'landscape': (None, '1.3', '1')}
PACKAGE = 'org.qimiaoscreen.uitest'
@@ -0,0 +1,41 @@
"""Read-only deployed source and health verification; emits no network or device identity."""
import hashlib
import json
import shlex
from remote_support import ROOT, connect, execute
FILES = ['app/main.py', 'app/mobile/session.py', 'app/mobile/protocol.py', 'app/mobile/control.py',
'app/mobile/wifi.py', 'app/static/index.html', 'app/static/views/device.js', 'FEATURE_UPDATED_AT']
def main():
client, fields = connect()
try:
code, _, _ = execute(client, 'command -v python3 >/dev/null && command -v systemctl >/dev/null')
if code: raise RuntimeError('Remote tools unavailable')
program = '''import hashlib,json,urllib.request
from pathlib import Path
names=NAMES
digests={n:hashlib.sha256((Path('/opt/matrix-screen-controller')/n).read_bytes()).hexdigest() for n in names}
s=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/status',timeout=10))
d=s['screen']['driver_status']
print(json.dumps({'digests':digests,'health':{'mobile_available':s['mobile']['available'],
'active_transport':s['mobile'].get('active_transport'),'driver_available':s['screen']['driver_available'],
'driver_running':d.get('running'),'actual_refresh_rate_hz':d.get('actual_refresh_rate_hz'),
'deadline_misses':d.get('deadline_misses'),'driver_error_absent':not d.get('last_error'),
'feature_updated_at':s['service']['feature_updated_at'],'software_version':s['service']['software_version']}}))
'''.replace('NAMES', repr(FILES))
code, output, _ = execute(client, 'python3 -c ' + shlex.quote(program), password=fields['密码'])
if code: raise RuntimeError('Deployment verification failed')
result = json.loads(output)
expected = {n: hashlib.sha256((ROOT / '核桃派软件源代码' / n).read_bytes()).hexdigest() for n in FILES}
if result['digests'] != expected: raise RuntimeError('Deployed source differs from current source')
code, _, _ = execute(client, 'systemctl is-active --quiet matrix-screen-controller.service')
if code: raise RuntimeError('Main service is not active')
print(json.dumps(dict(source_matches=True, service_active=True, **result['health'])))
finally:
client.close()
if __name__ == '__main__':
main()
@@ -0,0 +1,45 @@
"""Explicit real-system restart test, synchronized by a transient test session name."""
import json
import threading
import time
from remote_support import connect, execute
class RestartProbe:
def __init__(self):
self.stop = threading.Event()
self.error = None
self.completed = False
self.thread = threading.Thread(target=self.run, daemon=True)
def start(self):
self.thread.start()
def run(self):
client = None
try:
client, fields = connect()
code, _, _ = execute(client, 'command -v python3 >/dev/null && command -v systemctl >/dev/null')
if code: raise RuntimeError('Required remote tools unavailable')
deadline = time.monotonic() + 120
while not self.stop.wait(1):
if time.monotonic() >= deadline:
raise RuntimeError('Authenticated restart readiness was not observed')
code, output, _ = execute(client, "python3 -c \"import urllib.request,json; s=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/status',timeout=5)); m=s.get('mobile',{}); print(json.dumps({'ready':m.get('connected') and m.get('active_transport')=='wifi' and m.get('client_name')=='android-test-restart-ready'}))\"")
if code or not json.loads(output).get('ready'):
continue
code, _, _ = execute(client, 'systemctl restart matrix-screen-controller.service && systemctl is-active --quiet matrix-screen-controller.service', password=fields['密码'], timeout=60)
if code: raise RuntimeError('Real service restart failed')
self.completed = True
return
except Exception:
self.error = 'Real service restart coordination failed; no private diagnostics emitted'
finally:
if client is not None: client.close()
def finish(self):
self.stop.set()
self.thread.join(timeout=65)
if self.thread.is_alive() or self.error or not self.completed:
raise RuntimeError(self.error or 'Real service restart did not complete')
@@ -1,5 +1,14 @@
# Android 测试常见问题与排障
## WiFi 传输经验
- 原 `mobile.transport` 是 BLE 计数对象,不能改为字符串。实际业务链路使用新增 `mobile.active_transport`;以 BluezRuntime 组合后的状态测试,避免只测试 SessionManager 漏掉字段覆盖。
- `cmd wifi status` 的启用文字可能为 `Wifi` 或 `Wi-Fi`,只在内存中忽略大小写与连字符后判断,不输出包含 SSID/IP 的原始内容。
- `invokeOnCompletion` 不是协程异常处理器。网络监听的 collectLatest 内恢复屏障也可能因 BLE 已断开而失败;必须在 launch 主体捕获并结束会话,不能仅在完成回调做清理,否则可能断开成功后仍崩溃。
- 网络回调取消不能截断正在发出的加密 BLE 消息。通道协商/撤销须有界地完成 BLE 交换,再响应取消;旧网络回调按注册实例隔离。
- 无线开关恢复分为“启用命令完成”和“网络实际可用”。测试先等关闭稳定,再启用并等待 Network 回调;一次重测通过不能单独证明此前恢复超时的根因。
- 模拟器冷启动超时不代表 Compose 测试失败。先检查已有 Emulator、加速能力和登记系统镜像,不重复下载;可用独立进程记录启动诊断、确认 boot_completed 后运行选定场景。只关闭本次启动实例。
测试前阅读本文件及 [可重复测试流程](README.md)。这里维护可复用的方法;单次失败、重测次数、耗时与验收结果写入 `../测试结果归档/`。本页依据 [四栏版真机记录](../测试结果归档/2026-09-26_四栏界面优化.md) 整理,方法有效不等于所有同类错误均有相同根因。
## 1. root 命令在 instrumentation 中失败
@@ -1,5 +1,7 @@
# Android 按变更影响选测(当前规则)
WiFi 通道新增场景、前置条件和验收标准见 [WiFi 传输测试](WiFi传输测试.md)。`wifi-transport-recovery` 切手机无线开关,`wifi-transport-network` 重新应用设备当前网络,`wifi-transport-restart` 重启主服务,均不得并入普通全量或公共回归。`wifiTransport` 是独立模拟器场景,不接实机。
适用 MOBILE-TEST-SELECTION、TEST-MOBILE-SELECTION、TEST-MOBILE-INTEGRATION。本文优先于历史版本专项验收中的固定执行顺序。授权决定怎样操作设备,不代表每轮必须使用设备。
## 选择范围