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())