import html import json import os import re import subprocess import time from pathlib import Path from server.services.device import DeviceProfile PACKAGE = "com.leivideo.raptdrama" PREFS = f"/data/data/{PACKAGE}/shared_prefs/FlutterSharedPreferences.xml" REPO_ROOT = Path(__file__).resolve().parents[2] class Adb: def __init__(self, executable: str) -> None: self.executable = executable @classmethod def locate(cls, explicit: str = "") -> "Adb": if explicit: if not os.path.isfile(explicit): raise RuntimeError(f"找不到 adb:{explicit}") return cls(explicit) name = "adb.exe" if os.name == "nt" else "adb" candidates = [ REPO_ROOT / "tools" / "platform-tools" / name, REPO_ROOT / "vendor" / "platform-tools" / name, ] for path in candidates: if path.is_file(): return cls(str(path)) raise RuntimeError(f"项目内没有 adb:{candidates[0]}") def list_devices(self) -> list[dict]: proc = self._run(["devices", "-l"]) if proc.returncode not in (0, None): err = proc.stderr.decode("utf-8", "replace").strip() or "adb devices 失败" raise RuntimeError(err) items = [] for line in proc.stdout.decode("utf-8", "replace").splitlines()[1:]: parts = line.split() if len(parts) < 2 or parts[1] not in ("device", "unauthorized", "offline"): continue serial, state = parts[0], parts[1] model = "" for part in parts[2:]: if part.startswith("model:"): model = part.split(":", 1)[1].replace("_", " ") if state == "device" and not model: _, text, _ = self._shell(serial, ["getprop", "ro.product.model"], timeout=8) model = text.strip() items.append({ "serial": serial, "model": model or "未知型号", "rooted": self._rooted(serial) if state == "device" else False, "state": state, }) return items def probe_mumu(self) -> dict: ports = sorted(_listening_local_ports() & _mumu_ports()) if not ports: raise RuntimeError("没有发现正在运行的 MuMu。请先打开模拟器,再探测。") for port in ports: self._run(["connect", f"127.0.0.1:{port}"], timeout=8) return {"devices": self.list_devices(), "ports": ports} def read_profile(self, serial: str) -> DeviceProfile: serial = self._ready(serial) profile = DeviceProfile.builtin() brand = self._prop(serial, "ro.product.brand") or self._prop(serial, "ro.product.manufacturer") model = self._prop(serial, "ro.product.model") os_version = self._prop(serial, "ro.build.version.release") characteristics = self._prop(serial, "ro.build.characteristics") qemu = self._prop(serial, "ro.kernel.qemu") or self._prop(serial, "ro.boot.qemu") _, android_id, err = self._shell(serial, ["settings", "get", "secure", "android_id"], timeout=8) if err == "timeout": raise RuntimeError("读取设备信息超时,请确认设备仍连接后重试。") android_id = android_id.strip() version_name, version_code = self._app_version(serial) if brand: profile.brand = brand if model: profile.model = model if os_version: profile.os_version = os_version if version_name: profile.app_version = version_name if version_code: profile.app_build = version_code if android_id and android_id.lower() != "null": profile.device_id = f"rd_{android_id}" profile.device_type = "pad" if "tablet" in characteristics else "phone" profile.physical = not (qemu == "1" or serial.startswith("emulator-")) return profile def read_token(self, serial: str) -> str: serial = self._ready(serial) if not self._app_installed(serial): raise RuntimeError("这台设备还没有安装 RaptDrama。请先安装 App 并登录,然后再读取登录信息。") text = self._prefs(serial) match = re.search(r'name="flutter\.user_data"[^>]*>(.*?)', text, re.S) if not match: raise RuntimeError("登录信息里没有 user_data,请先在 App 里登录") user = json.loads(html.unescape(match.group(1))) token = user.get("accessToken") or "" if not token: raise RuntimeError("当前账号没有 accessToken,请先在 App 里登录") return token def _ready(self, serial: str) -> str: serial = (serial or "").strip() if ":" in serial: self._run(["connect", serial], timeout=15) ready = self._ready_serials() if serial not in ready: shown = "、".join(ready) or "无" raise RuntimeError(f"设备 {serial} 未就绪。当前已连接:{shown}") return serial def _ready_serials(self) -> list[str]: proc = self._run(["devices"]) serials = [] for line in proc.stdout.decode("utf-8", "replace").splitlines()[1:]: parts = line.split() if len(parts) >= 2 and parts[1] == "device": serials.append(parts[0]) return serials def _app_installed(self, serial: str) -> bool: _, text, err = self._shell(serial, ["pm", "path", PACKAGE], timeout=8) if err == "timeout": raise RuntimeError("检查 App 是否安装超时,请确认设备仍连接后重试。") return "package:" in text def _app_version(self, serial: str) -> tuple[str, str]: _, text, _ = self._shell(serial, ["dumpsys", "package", PACKAGE], timeout=15) version_name = "" version_code = "" for line in text.splitlines(): line = line.strip() if line.startswith("versionName="): version_name = line.split("=", 1)[1].strip() elif line.startswith("versionCode="): version_code = line.split("=", 1)[1].split()[0] return version_name, version_code def _prop(self, serial: str, name: str) -> str: _, text, err = self._shell(serial, ["getprop", name], timeout=8) if err == "timeout": raise RuntimeError("读取设备信息超时,请确认设备仍连接后重试。") return text.strip() def _rooted(self, serial: str) -> bool: _, text, _ = self._shell(serial, ["id"], timeout=5) if "uid=0" in text: return True for args in (["su", "-c", "id"], ["su", "0", "id"]): _, text, _ = self._shell(serial, args, timeout=4) if "uid=0" in text: return True return False def _prefs(self, serial: str) -> str: def usable(text: str) -> bool: return "flutter.user_data" in text _, text, _ = self._shell(serial, ["cat", PREFS]) if usable(text): return text self._run(["-s", serial, "root"], timeout=15) if ":" in serial: self._run(["connect", serial], timeout=15) for _ in range(10): time.sleep(0.5) _, text, _ = self._shell(serial, ["cat", PREFS]) if usable(text): return text for args in (["su", "-c", f"cat {PREFS}"], ["su", "0", "cat", PREFS]): _, text, _ = self._shell(serial, args, timeout=8) if usable(text): return text raise RuntimeError( "读不到应用私有目录。未 root 的设备无法获取登录信息。" "请使用已 root 的手机,或带 root 的模拟器。" ) def _shell(self, serial: str, args: list[str], timeout: int = 15) -> tuple[int, str, str]: try: proc = self._run(["-s", serial, "shell", *args], timeout=timeout) except subprocess.TimeoutExpired: return 1, "", "timeout" return ( proc.returncode, proc.stdout.decode("utf-8", "replace"), proc.stderr.decode("utf-8", "replace"), ) def _run(self, args: list[str], timeout: int = 20) -> subprocess.CompletedProcess: return subprocess.run([self.executable, *args], capture_output=True, timeout=timeout) def _mumu_ports() -> set[int]: ports = {7555} for index in range(8): base = 16384 + index * 32 ports.add(base) ports.add(base + 1) port = 5555 while port <= 5599: ports.add(port) port += 2 return ports def _listening_local_ports() -> set[int]: try: proc = subprocess.run( ["netstat", "-ano", "-p", "tcp"], capture_output=True, timeout=8, check=False, ) except (OSError, subprocess.TimeoutExpired): return set() text = proc.stdout.decode("utf-8", "replace") if "LISTENING" not in text.upper() and "监听" not in text: text = proc.stdout.decode("gbk", "replace") ports = set() for line in text.splitlines(): upper = line.upper() if "LISTENING" not in upper and "监听" not in line: continue parts = line.split() if len(parts) < 2 or ":" not in parts[1]: continue host, _, port_text = parts[1].rpartition(":") if host not in ("127.0.0.1", "0.0.0.0") or not port_text.isdigit(): continue ports.add(int(port_text)) return ports