210 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""USB/ADB control helpers for the physical release gate.
Device selectors are accepted only as ephemeral command inputs. They are never
printed or written to artifacts; all output uses the operator-assigned alias.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Callable
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
if str(REPOSITORY_ROOT) not in sys.path:
sys.path.insert(0, str(REPOSITORY_ROOT))
from tools.release_gate.release_gate import (
GateError,
SAFE_ID_RE,
append_trace_event,
)
APPLICATION_ID = "com.bitchat.droid"
def find_adb() -> str:
direct = shutil.which("adb")
if direct:
return direct
android_home = os.environ.get("ANDROID_HOME")
if android_home:
candidate = Path(android_home) / "platform-tools" / "adb"
if candidate.is_file():
return str(candidate)
raise GateError("adb was not found; set ANDROID_HOME or add adb to PATH")
def run_adb(
serial: str,
arguments: list[str],
*,
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
) -> str:
result = runner(
[find_adb(), "-s", serial, *arguments],
check=False,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
raise GateError("ADB command failed for the selected logical device")
return result.stdout.strip()
def count_connected_devices(
*,
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
) -> int:
result = runner(
[find_adb(), "devices"],
check=False,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
raise GateError("could not enumerate ADB devices")
return sum(
1
for line in result.stdout.splitlines()[1:]
if line.strip().endswith("\tdevice")
)
def probe_device(serial: str, alias: str) -> dict[str, object]:
if not SAFE_ID_RE.fullmatch(alias):
raise GateError("device alias must be a lowercase logical identifier")
api_text = run_adb(serial, ["shell", "getprop", "ro.build.version.sdk"])
if not api_text.isdigit():
raise GateError("selected device returned an invalid API level")
features = run_adb(serial, ["shell", "pm", "list", "features"])
manufacturer = run_adb(
serial, ["shell", "getprop", "ro.product.manufacturer"]
).strip().lower()
model = run_adb(serial, ["shell", "getprop", "ro.product.model"]).strip()
capabilities = ["ble-central", "ble-peripheral"]
if "android.hardware.wifi.aware" in features:
capabilities.append("wifi-aware")
return {
"alias": alias,
"platform": "android",
"model": model,
"manufacturer_class": re.sub(r"[^a-z0-9._-]", "-", manufacturer)[:64],
"api_level": int(api_text),
"physical": True,
"capabilities": capabilities,
}
def prepare_disposable_device(serial: str, confirmed: bool) -> None:
if not confirmed:
raise GateError("prepare requires --confirm-disposable-app-data")
run_adb(serial, ["shell", "am", "force-stop", APPLICATION_ID])
output = run_adb(serial, ["shell", "pm", "clear", APPLICATION_ID])
if "Success" not in output:
raise GateError("could not clear disposable app data")
def collect_resource_snapshot(serial: str) -> dict[str, int | bool]:
pid_text = run_adb(serial, ["shell", "pidof", APPLICATION_ID])
pid = pid_text.split()[0] if pid_text else ""
metrics: dict[str, int | bool] = {"process-running": bool(pid)}
if not pid.isdigit():
return metrics
meminfo = run_adb(serial, ["shell", "dumpsys", "meminfo", APPLICATION_ID])
total_match = re.search(r"TOTAL\s+(\d+)", meminfo)
metrics["total-pss-kb"] = int(total_match.group(1)) if total_match else -1
thread_text = run_adb(
serial,
["shell", "sh", "-c", f"find /proc/{pid}/task -mindepth 1 -maxdepth 1 | wc -l"],
)
fd_text = run_adb(
serial,
["shell", "sh", "-c", f"find /proc/{pid}/fd -mindepth 1 -maxdepth 1 | wc -l"],
)
metrics["thread-count"] = int(thread_text) if thread_text.isdigit() else -1
metrics["fd-count"] = int(fd_text) if fd_text.isdigit() else -1
power = run_adb(serial, ["shell", "dumpsys", "power"])
metrics["app-wakelock-count"] = sum(
1
for line in power.splitlines()
if APPLICATION_ID in line and "WakeLock" in line
)
battery = run_adb(serial, ["shell", "dumpsys", "battery"])
battery_level = re.search(r"^\s*level:\s*(\d+)", battery, re.MULTILINE)
metrics["battery-level-percent"] = (
int(battery_level.group(1)) if battery_level else -1
)
return metrics
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("count")
probe = commands.add_parser("probe")
probe.add_argument("--serial", required=True, help=argparse.SUPPRESS)
probe.add_argument("--alias", required=True)
prepare = commands.add_parser("prepare")
prepare.add_argument("--serial", required=True, help=argparse.SUPPRESS)
prepare.add_argument("--confirm-disposable-app-data", action="store_true")
cleanup = commands.add_parser("cleanup")
cleanup.add_argument("--serial", required=True, help=argparse.SUPPRESS)
cleanup.add_argument("--confirm-disposable-app-data", action="store_true")
snapshot = commands.add_parser("snapshot")
snapshot.add_argument("--serial", required=True, help=argparse.SUPPRESS)
snapshot.add_argument("--alias", required=True)
snapshot.add_argument("--run", type=Path, required=True)
snapshot.add_argument("--scenario", required=True)
snapshot.add_argument("--event", default="resource-snapshot")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.command == "count":
print(json.dumps({"authorized-device-count": count_connected_devices()}))
elif args.command == "probe":
print(json.dumps(probe_device(args.serial, args.alias), sort_keys=True))
elif args.command in {"prepare", "cleanup"}:
prepare_disposable_device(
args.serial, args.confirm_disposable_app_data
)
print(json.dumps({"status": "clean", "application": APPLICATION_ID}))
elif args.command == "snapshot":
metrics = collect_resource_snapshot(args.serial)
append_trace_event(
args.run,
args.scenario,
args.alias,
args.event,
"observed",
None,
metrics,
)
print(json.dumps({"source_alias": args.alias, "metrics": metrics}, sort_keys=True))
return 0
except (GateError, OSError, subprocess.SubprocessError) as error:
print(f"android lab error: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())