"""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 def main(argv=None): parser = argparse.ArgumentParser() parser.add_argument('--scenario', required=True) 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(argv) from run_ble_test import SCENARIOS from run_emulator_ui_test import passed if args.scenario not in SCENARIOS or args.test != SCENARIOS[args.scenario][0]: parser.error('Scenario and instrumentation family do not match') if args.reapply_current_wifi != (args.scenario == 'wifi-reapply') or args.verify_current_controls != (args.scenario == 'controls') or args.toggle_bluetooth != (args.scenario == 'bluetooth-toggle'): parser.error('Mutation flags must match the explicit scenario') if args.poll_cycles and args.scenario != 'polling': parser.error('Polling requires polling scenario') 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 权限不可用;停止测试,等待用户处理,不使用备用注入方式') board_id = board_name = library_hash = "" if SCENARIOS[args.scenario][1]: from remote_support import connect, execute client, fields = connect() try: code, _, _ = execute(client, "command -v python3 >/dev/null && python3 --version", password=fields['密码']) if code: raise RuntimeError('Remote Python unavailable') 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] library_hash = "" if args.scenario == "library-order": 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 for secret, alias in ((device['serial'], '[test-phone]'), (board_name, '[test-board]'), (board_id, '[test-board-id]')): if secret: output = output.replace(secret, alias) if not quiet: print(output, flush=True) if result.returncode: raise RuntimeError('ADB command failed') 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'): apk = args.build / relative if not apk.is_file(): raise RuntimeError('APK missing') if 'Success' not in adb('install', '-r', str(apk)): raise RuntimeError('APK installation did not succeed') adb('shell', 'am', 'force-stop', 'org.qimiaoscreen.controller') if args.grant_root and args.scenario != 'navigation': # 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 permissions: 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 permissions): break else: raise RuntimeError('Bluetooth permission did not remain granted after installation') if args.scenario == 'navigation': for permission in permissions: adb('shell', 'su', '-c', 'pm revoke org.qimiaoscreen.controller android.permission.' + permission, quiet=True) current = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True) if any('android.permission.' + p + ': granted=true' in current for p in permissions): 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.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) 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 for font, rotation in (("1.0", "0"), ("1.3", "0"), ("1.3", "1")) if args.scenario == "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") 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), '-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.' + ('AnimationLibraryTest' if args.scenario == 'animation-library' else '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) if not passed(output): raise RuntimeError("Instrumentation did not pass") if layout_original: print("Layout passed: font=" + font + ", rotation=" + rotation) finally: errors = [] def cleanup(label, action): 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)) def verify_density(): value = re.search(r'Override density: (\d+)', root('wm density')) if (value[1] if value else 'reset') != layout_density: raise RuntimeError('density restore failed') cleanup('density verification', verify_density) for key, value in layout_original.items(): def restore(key=key, value=value): root('settings delete system ' + key if value == 'null' else 'settings put system ' + key + ' ' + value) if root('settings get system ' + key) != value: raise RuntimeError('settings restore failed') cleanup(key, restore) for permission, granted in permission_original.items(): def restore_permission(permission=permission, granted=granted): adb('shell', 'su', '-c', 'pm ' + ('grant' if granted else 'revoke') + ' org.qimiaoscreen.controller android.permission.' + permission, quiet=True) actual = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True) if ('android.permission.' + permission + ': granted=true' in actual) != granted: raise RuntimeError('Permission restore failed') cleanup('permission ' + permission, restore_permission) if args.toggle_bluetooth: cleanup('bluetooth', lambda: adb('shell', 'su', '-c', 'cmd bluetooth_manager enable', quiet=True)) for helper in ('qms-authorized-root.sh', 'qms-authorized-input.sh'): cleanup(helper, lambda helper=helper: adb('shell', 'rm', '-f', '/data/local/tmp/' + helper, quiet=True)) if errors: raise RuntimeError('Cleanup failed: ' + ', '.join(errors)) print('Cleanup verified', flush=True) if not passed(output): raise RuntimeError('BLE instrumentation did not pass') if __name__ == '__main__': main()