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