382 lines
15 KiB
Python
382 lines
15 KiB
Python
"""adb 命令工具封装。
|
||
|
||
并发关键:adb_connect 绝不 kill-server(会断开其他 worker 正在用的设备),
|
||
用全局锁串行化连接阶段,connect 本身很快,串行不影响整体并发。
|
||
"""
|
||
import re
|
||
import time
|
||
import subprocess
|
||
import threading
|
||
|
||
from config import ADB_PATH
|
||
from core.logger import get_logger
|
||
|
||
_log = get_logger("core.adb")
|
||
|
||
_ADB_LOCK = threading.Lock()
|
||
# adb 命令超时(秒)。adb connect 到不可达主机(如 Tailscale IP 不通)会长时间挂起,
|
||
# 没有超时会让 worker 永久卡在 connecting。connect/disconnect 正常都应瞬时完成。
|
||
_ADB_TIMEOUT = 30
|
||
|
||
|
||
def _adb(*args):
|
||
"""执行 adb 命令,返回 (stdout+stderr) 文本。不用 shell=True,避免子进程环境问题。
|
||
|
||
用 bytes 模式自己解码(Windows 下 subprocess text=True 会用 GBK 读 adb 输出,
|
||
遇到非 GBK 字节会崩溃)。
|
||
带 30s 超时:adb connect 到不可达地址可能挂很久,超时返回空串由调用方处理。
|
||
"""
|
||
try:
|
||
r = subprocess.run([ADB_PATH, *args], capture_output=True, timeout=_ADB_TIMEOUT)
|
||
except subprocess.TimeoutExpired:
|
||
_log.warning(f"adb {' '.join(args)} 超时 {_ADB_TIMEOUT}s")
|
||
return ""
|
||
return _decode(r)
|
||
|
||
|
||
def _adb_remote(host, port, *args):
|
||
"""在远程 adb server(如 220)上执行 adb 命令,返回文本。
|
||
|
||
USB 设备插在 220,本机 adb server 看不到,必须 -H/-P 指向 220 的 adb server
|
||
(host 网络模式,5037 全接口监听,Tailscale 可达)。超时语义同 _adb。
|
||
"""
|
||
try:
|
||
r = subprocess.run([ADB_PATH, "-H", host, "-P", str(port), *args],
|
||
capture_output=True, timeout=_ADB_TIMEOUT)
|
||
except subprocess.TimeoutExpired:
|
||
_log.warning(f"adb -H {host} -P {port} {' '.join(args)} 超时 {_ADB_TIMEOUT}s")
|
||
return ""
|
||
return _decode(r)
|
||
|
||
|
||
def _decode(r):
|
||
"""subprocess 结果解码(Windows GBK 兼容)。"""
|
||
out = (r.stdout or b"") + (r.stderr or b"")
|
||
try:
|
||
return out.decode("utf-8", errors="replace").strip()
|
||
except Exception:
|
||
return out.decode("gbk", errors="replace").strip()
|
||
|
||
|
||
def get_foreground_app(remote_adb_url, timeout=3):
|
||
"""获取设备当前前台 App 包名。
|
||
|
||
通过 adb shell dumpsys window 解析 mCurrentFocus。
|
||
remote_adb_url: adb 连接地址(如 192.168.20.17:12345),设备需已 adb connect。
|
||
返回包名字符串(如 com.ss.android.ugc.aweme),失败/无设备返回 None。
|
||
"""
|
||
if not remote_adb_url:
|
||
return None
|
||
try:
|
||
r = subprocess.run(
|
||
[ADB_PATH, "-s", remote_adb_url, "shell",
|
||
"dumpsys window | grep mCurrentFocus"],
|
||
capture_output=True, timeout=timeout
|
||
)
|
||
out = (r.stdout or b"").decode("utf-8", errors="replace").strip()
|
||
if not out:
|
||
return None
|
||
low = out.lower()
|
||
if "device offline" in low or "device not found" in low or "error:" in low:
|
||
return None
|
||
# 解析 mCurrentFocus=Window{abc u0 com.xx/com.xx.MainActivity}
|
||
for line in out.split("\n"):
|
||
if "mCurrentFocus" not in line:
|
||
continue
|
||
# 提取 u0 后面的 token(包名/Activity 或包名)
|
||
m = re.search(r'\bu0\s+(\S+)', line)
|
||
if m:
|
||
token = m.group(1).rstrip("}")
|
||
if "/" in token:
|
||
return token.split("/")[0]
|
||
return token or None
|
||
return None
|
||
except subprocess.TimeoutExpired:
|
||
return None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def get_foreground_app_remote(host, port, serial, timeout=3):
|
||
"""远程 adb server(220)上设备的前台 App(USB 设备用)。
|
||
|
||
与 get_foreground_app 同逻辑,只是 adb 指向远程 server(-H/-P)。
|
||
"""
|
||
if not serial:
|
||
return None
|
||
try:
|
||
r = subprocess.run(
|
||
[ADB_PATH, "-H", host, "-P", str(port), "-s", serial, "shell",
|
||
"dumpsys window | grep mCurrentFocus"],
|
||
capture_output=True, timeout=timeout
|
||
)
|
||
out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace").strip()
|
||
if not out:
|
||
return None
|
||
low = out.lower()
|
||
if "device offline" in low or "device not found" in low or "error:" in low:
|
||
return None
|
||
for line in out.split("\n"):
|
||
if "mCurrentFocus" not in line:
|
||
continue
|
||
m = re.search(r'\bu0\s+(\S+)', line)
|
||
if m:
|
||
token = m.group(1).rstrip("}")
|
||
if "/" in token:
|
||
return token.split("/")[0]
|
||
return token or None
|
||
return None
|
||
except subprocess.TimeoutExpired:
|
||
return None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def adb_connect(remote_adb_url, retries=5):
|
||
"""adb connect(带重试)。返回 True/False。
|
||
|
||
首次常因认证握手未就绪报 failed to authenticate,第2次重试通常 already connected。
|
||
STF remoteConnect 启用后端口监听可能需要几秒,所以重试间隔递增。
|
||
|
||
**绝不 kill-server**:kill-server 会断开所有设备的 adb transport,
|
||
导致 STF provider 对全部设备误判离线并触发重连,影响所有运行中的任务。
|
||
连接失败就返回 False,由调用方处理(标记任务失败,不影响其他设备)。
|
||
"""
|
||
with _ADB_LOCK:
|
||
return _try_connect(remote_adb_url, retries)
|
||
|
||
|
||
def _try_connect(remote_adb_url, retries):
|
||
"""尝试连接,不 kill-server。返回 True/False。"""
|
||
for attempt in range(1, retries + 1):
|
||
out = _adb("connect", remote_adb_url)
|
||
_log.info(f"{remote_adb_url} 第 {attempt} 次尝试: {out}")
|
||
if "connected" in out.lower() and "failed" not in out.lower():
|
||
return True
|
||
time.sleep(1 + attempt)
|
||
return False
|
||
|
||
|
||
def adb_connect_light(remote_adb_url):
|
||
"""轻量 adb connect(扫描专用):单次尝试,不重试,绝不 kill-server。
|
||
|
||
与 adb_connect 的区别:
|
||
- 不重试:失败立即返回 False
|
||
- 不 kill-server:避免断开其他 worker 正在使用的 ADB 连接
|
||
用于前台 App 扫描等非关键路径,连不上就跳过,不影响正在运行的任务。
|
||
"""
|
||
with _ADB_LOCK:
|
||
out = _adb("connect", remote_adb_url)
|
||
if "connected" in out.lower() and "failed" not in out.lower():
|
||
return True
|
||
return False
|
||
|
||
|
||
def adb_disconnect(remote_adb_url):
|
||
"""adb disconnect。"""
|
||
try:
|
||
_adb("disconnect", remote_adb_url)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def screenshot(serial, timeout=5):
|
||
"""获取设备当前画面截图(PNG bytes)。
|
||
|
||
用 `adb exec-out -s <serial> shell screencap -p`,只读操作,
|
||
不抢占 u2 的 atx-agent 通道,任务运行中调用安全。
|
||
返回 (ok, png_bytes_or_errmsg)。
|
||
"""
|
||
if not serial:
|
||
return False, "缺少 serial"
|
||
# 先尝试连一下(任务运行中设备已 connect,这里是幂等的)
|
||
if not adb_connect_light(serial):
|
||
return False, "adb connect 失败"
|
||
try:
|
||
r = subprocess.run(
|
||
[ADB_PATH, "-s", serial, "exec-out", "screencap", "-p"],
|
||
capture_output=True, timeout=timeout,
|
||
)
|
||
# PNG 文件头:\x89PNG
|
||
if r.stdout and r.stdout[:4] == b"\x89PNG":
|
||
return True, r.stdout
|
||
# 失败时 stdout 可能为空,看 stderr
|
||
err = (r.stderr or b"").decode("utf-8", errors="replace").strip()
|
||
return False, err or "截图失败(设备可能离线)"
|
||
except subprocess.TimeoutExpired:
|
||
return False, "截图超时"
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
|
||
# 常见应用名映射:快速识别大/重要应用,避免拉取 APK 解析(慢)
|
||
COMMON_APPS = {
|
||
"com.ss.android.ugc.aweme": "抖音",
|
||
"com.ss.android.ugc.aweme.lite": "抖音极速版",
|
||
"com.smile.gifmaker": "快手",
|
||
"com.kuaishou.nebula": "快手极速版",
|
||
"com.tencent.mm": "微信",
|
||
"com.tencent.mobileqq": "QQ",
|
||
"com.tencent.qqlive": "腾讯视频",
|
||
"com.eg.android.AlipayGphone": "支付宝",
|
||
"com.taobao.taobao": "淘宝",
|
||
"com.tmall.wireless": "天猫",
|
||
"com.jingdong.app.mall": "京东",
|
||
"com.xunmeng.pinduoduo": "拼多多",
|
||
"com.ss.android.article.news": "今日头条",
|
||
"com.zhihu.android": "知乎",
|
||
"com.miui.home": "桌面",
|
||
"com.android.settings": "设置",
|
||
"com.android.systemui": "系统",
|
||
"com.github.uiautomator": "ATX",
|
||
"com.github.uiautomator.test": "ATX测试",
|
||
"jp.co.cyberagent.stf": "STF服务",
|
||
"moe.shizuku.privileged.api": "Shizuku",
|
||
"moe.shizuku.api": "Shizuku",
|
||
"io.tailscale.android": "Tailscale",
|
||
"com.immomo.momo": "陌陌",
|
||
"com.xingin.xhs": "小红书",
|
||
}
|
||
# 解析应用名时,超过该大小的 APK 不拉取(太慢),用映射或包名兜底
|
||
_LABEL_PARSE_MAX_BYTES = 5 * 1024 * 1024 # 5MB
|
||
# 应用名缓存:(serial, package) -> label
|
||
_APP_LABEL_CACHE = {}
|
||
|
||
|
||
def list_installed_apps(serial, timeout=40):
|
||
"""获取设备上已安装应用列表:应用名 + 包名 + versionCode + versionName + APK路径。
|
||
|
||
- `pm list packages --show-versioncode -f` 取包名/版本码/路径(快)
|
||
- `pm dump packages` 取人性化 versionName(实测 ~0.8s)
|
||
- 应用名 label:常见应用走映射;用户安装且较小(<=10MB)的未知应用流式拉 APK 用
|
||
pyaxmlparser 解析(带缓存),其余用包名兜底。
|
||
返回 (ok, apps) 或 (False, 错误消息)。
|
||
"""
|
||
if not serial:
|
||
return False, "缺少 serial"
|
||
try:
|
||
# 1. 包名 + versionCode + APK 路径
|
||
r1 = subprocess.run(
|
||
[ADB_PATH, "-s", serial, "shell", "pm", "list", "packages",
|
||
"--show-versioncode", "-f"],
|
||
capture_output=True, timeout=timeout)
|
||
apps = {}
|
||
for line in (r1.stdout or b"").decode("utf-8", errors="replace").splitlines():
|
||
line = line.strip()
|
||
m = re.match(r'package:(.+)=(\S+) versionCode:(\d+)', line)
|
||
if m:
|
||
apps[m.group(2)] = {"package": m.group(2), "path": m.group(1),
|
||
"version_code": int(m.group(3)), "version_name": "",
|
||
"label": COMMON_APPS.get(m.group(2), "")}
|
||
else:
|
||
m2 = re.match(r'package:(.+)=(\S+)', line)
|
||
if m2:
|
||
apps[m2.group(2)] = {"package": m2.group(2), "path": m2.group(1),
|
||
"version_code": 0, "version_name": "",
|
||
"label": COMMON_APPS.get(m2.group(2), "")}
|
||
# 2. 人性化版本号 versionName
|
||
try:
|
||
r2 = subprocess.run(
|
||
[ADB_PATH, "-s", serial, "shell", "pm", "dump", "packages"],
|
||
capture_output=True, timeout=timeout)
|
||
out2 = (r2.stdout or b"").decode("utf-8", errors="replace")
|
||
for m in re.finditer(r'Package \[([^\]]+)\]', out2):
|
||
pkg = m.group(1)
|
||
if pkg not in apps:
|
||
continue
|
||
nxt = out2.find("Package [", m.end())
|
||
seg = out2[m.end():nxt if nxt != -1 else len(out2)]
|
||
vm = re.search(r'versionName=(\S+)', seg)
|
||
if vm:
|
||
apps[pkg]["version_name"] = vm.group(1)
|
||
except Exception:
|
||
pass # versionName 拿不到不阻塞
|
||
# 3. 应用名 label:用户安装且未映射的小 APK,流式拉取解析真实名
|
||
_resolve_app_labels(serial, apps, timeout)
|
||
return True, sorted(apps.values(), key=lambda a: a["package"])
|
||
except subprocess.TimeoutExpired:
|
||
return False, "查询超时"
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
|
||
def _resolve_app_labels(serial, apps, timeout):
|
||
"""并行解析用户安装小应用的显示名 label(带缓存)。失败/超时静默跳过。"""
|
||
import io as _io
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
targets = [(pkg, a["path"]) for pkg, a in apps.items()
|
||
if a["path"].startswith("/data/") and not a["label"]
|
||
and (serial, pkg) not in _APP_LABEL_CACHE]
|
||
if not targets:
|
||
return
|
||
# 用 ls -l 一次拿所有候选 APK 大小,跳过过大的(流式拉大 APK 太慢)
|
||
sizes = {}
|
||
try:
|
||
rsz = subprocess.run(
|
||
[ADB_PATH, "-s", serial, "shell", "ls", "-l"] + [p for _, p in targets],
|
||
capture_output=True, timeout=min(timeout, 15))
|
||
for line in (rsz.stdout or b"").decode("utf-8", errors="replace").splitlines():
|
||
parts = line.split()
|
||
# ls -l(toybox): 权限 链接 属主 属组 大小 日期 时间 路径 = 8 字段
|
||
if len(parts) >= 6:
|
||
try:
|
||
sizes[parts[-1]] = int(parts[4])
|
||
except ValueError:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
small = [(pkg, path) for pkg, path in targets if sizes.get(path, 0) <= _LABEL_PARSE_MAX_BYTES]
|
||
|
||
def _fetch(pkg_path):
|
||
pkg, path = pkg_path
|
||
label = ""
|
||
try:
|
||
rp = subprocess.run(
|
||
[ADB_PATH, "-s", serial, "exec-out", "cat", path],
|
||
capture_output=True, timeout=min(timeout, 8))
|
||
from pyaxmlparser import APK as _APKParser
|
||
label = (_APKParser(_io.BytesIO(rp.stdout)).get_app_name() or "").strip()
|
||
except Exception:
|
||
label = ""
|
||
_APP_LABEL_CACHE[(serial, pkg)] = label
|
||
return pkg, label
|
||
|
||
if small:
|
||
try:
|
||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||
for pkg, label in pool.map(_fetch, small):
|
||
if pkg in apps:
|
||
apps[pkg]["label"] = label
|
||
except Exception:
|
||
pass # label 解析失败不影响主流程
|
||
|
||
|
||
def identify_device(serial, duration=3):
|
||
"""让设备响铃以供识别(直连 serial,不经过 STF)。
|
||
|
||
播放铃声 duration 秒后自动停止。
|
||
不 disconnect(避免断开 STF agent 共享的 adb 连接)。
|
||
返回 (ok, msg)。
|
||
"""
|
||
import subprocess
|
||
if not adb_connect_light(serial):
|
||
return False, "adb connect 失败"
|
||
try:
|
||
# 异步启动铃声播放(cmd audio play-ringtone 会阻塞,用 Popen)
|
||
p = subprocess.Popen(
|
||
[ADB_PATH, "-s", serial, "shell", "cmd", "audio", "play-ringtone"],
|
||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||
)
|
||
time.sleep(duration)
|
||
p.terminate()
|
||
# 停止铃声
|
||
subprocess.run(
|
||
[ADB_PATH, "-s", serial, "shell", "cmd", "audio", "stop-ringtone"],
|
||
capture_output=True, timeout=5,
|
||
)
|
||
_log.info(f"[{serial}] 识别响铃 {duration}s 完成")
|
||
return True, f"已响铃 {duration} 秒"
|
||
except Exception as e:
|
||
_log.warning(f"[{serial}] 识别响铃失败: {e}")
|
||
return False, str(e)
|