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"
path = REPO_ROOT / "tools" / "platform-tools" / name
if not path.is_file():
raise RuntimeError(f"项目内没有 adb:{path}")
return cls(str(path))
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 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)