autool-dispatcher/apk_cloud/android_google_play_downloader.py
2026-06-17 19:50:39 +08:00

665 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import logging
import os
import re
import time
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from typing import Optional
from adb_client import ADBClient
from android_utils import get_current_package
logger = logging.getLogger(__name__)
_BOUNDS_RE = re.compile(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]")
STORE_GOOGLE_PLAY = "google_play"
STORE_AURORA = "aurora"
@dataclass(frozen=True)
class UiNode:
text: str
desc: str
package: str
enabled: bool
clickable: bool
bounds: tuple[int, int, int, int]
@property
def center(self) -> tuple[int, int]:
left, top, right, bottom = self.bounds
return ((left + right) // 2, (top + bottom) // 2)
def labels(self) -> tuple[str, ...]:
values = []
if self.text.strip():
values.append(self.text.strip())
if self.desc.strip():
values.append(self.desc.strip())
return tuple(values)
class GooglePlayDownloader:
GOOGLE_PLAY_PACKAGE = "com.android.vending"
def __init__(self, serial: Optional[str] = None, store_type: str = STORE_GOOGLE_PLAY):
self._adb = ADBClient(serial=serial)
self._serial = serial
self._store_type = store_type
if store_type == STORE_AURORA:
self._store_package = "com.aurora.store"
self._store_name = "Aurora Store"
self._build_url = self._build_aurora_url
else:
self._store_package = "com.android.vending"
self._store_name = "Google Play"
self._build_url = self._build_google_play_url
OPEN_TIMEOUT_SEC = 30
INSTALL_TIMEOUT_SEC = 200
POLL_INTERVAL_SEC = 2
# 页面无有效内容的最大等待时间Aurora 找不到包名会一直卡在加载界面)
LOADING_TIMEOUT_SEC = 30
# Aurora Store 点击 Install 后页面无变化的超时时间
# 正常情况点击 Install 后数秒内应出现系统安装弹窗或下载进度,
# 若 Install 按钮始终存在且无任何变化,说明 Aurora 未能触发下载
AURORA_INSTALL_STUCK_SEC = 8
INSTALL_BUTTONS = ("Install", "Update")
SUCCESS_BUTTONS = ("Open", "Play")
INSTALLED_STATE_BUTTONS = ("Open", "Play", "Uninstall")
DISMISS_BUTTONS = ("Continue", "Accept", "Allow", "Got it", "Skip", "No thanks", "Not now", "OK", "Done")
# Aurora Store 系统安装对话框的包名
_PACKAGE_INSTALLER = "com.android.packageinstaller"
ACCOUNT_BANNED_TEXTS = (
"Authentication is required. You need to sign in to your Google Account.",
)
REGION_BLOCKED_TEXTS = (
"This item isn't available in your country.",
)
NOT_FOUND_TEXTS = (
"Item not found",
)
INCOMPATIBLE_TEXTS = (
"This app is available only for your other devices",
"Your device isn't compatible with this version.",
)
PAGE_LOAD_FAILED_TEXTS = (
"Try again",
)
AURORA_FAILURE_TEXTS = (
"Session expired",
"Token expired",
"Too many requests",
"Rate limit",
"Not available",
"App not found",
)
# 系统安装失败弹窗中常见的错误文本
# 这类弹窗如果不关闭,会遮挡后续所有应用的安装界面
SYSTEM_INSTALL_ERROR_TEXTS = (
"App not installed",
"Installation failed",
"Can't install",
"Install failed",
"not installed",
"Couldn't install",
)
def start(self, package_name, max_retry=1, target_account=None, **kwargs):
del max_retry, target_account, kwargs
try:
# Aurora Store 使用 Compose UI需要关闭动画才能正常执行 uiautomator dump
if self._store_type == STORE_AURORA:
self._disable_animations()
# 清理上一次残留的弹窗/状态,避免遮挡当前任务的 UI
self._clear_screen()
if self.is_installed(package_name):
logger.info("%s download skipped, app already installed: %s",
self._store_name, package_name)
return True, "app already installed"
opened, message = self._open_store_page(package_name)
if not opened:
return False, message
return self._install_from_store(package_name)
except Exception as exc:
logger.error("%s download failed for %s: %s", self._store_name, package_name, exc)
return False, str(exc)
def stop(self):
return None
def is_installed(self, package_name: str) -> bool:
result = self._adb.run(["shell", "pm", "path", package_name], check=False, timeout=10)
if result.returncode != 0:
return False
return any(line.strip().startswith("package:") for line in (result.stdout or "").splitlines())
def _install_from_store(self, package_name: str) -> tuple[bool, str]:
install_clicked = False
system_install_clicked = False
deadline = time.time() + self.INSTALL_TIMEOUT_SEC
# 页面无进展计时:如果长时间没有出现 Install 按钮或安装状态,
# 说明页面可能卡在加载界面Aurora 找不到包名时的典型表现)
no_progress_since: float | None = None
# Aurora 点击 Install 后无反应计时:
# 点击 Install 后按钮始终存在且无系统安装弹窗,说明 Aurora 未能触发下载
install_stuck_since: float | None = None
while time.time() < deadline:
if self.is_installed(package_name):
return True, "install success" if install_clicked else "app already installed"
nodes = self._dump_ui_nodes()
if nodes:
# ---- 失败检测 ----
failure = self._detect_failure(nodes)
if failure is not None:
# 尝试关闭错误弹窗,避免遗留弹窗遮挡后续任务的 UI
self._dismiss_dialog(nodes)
return False, failure
# ---- 系统安装失败弹窗检测 ----
# 某些应用安装失败时,系统弹出 "App not installed" 等弹窗
# 如果不关闭,会遮挡后续所有应用的安装界面
sys_error = self._detect_system_install_error(nodes)
if sys_error is not None:
logger.warning("检测到系统安装失败弹窗: %s", sys_error)
self._dismiss_dialog(nodes)
return False, sys_error
# ---- Aurora Store: 系统安装对话框确认 ----
# 仅在点击 Aurora 的 Install 按钮后才检测系统安装弹窗,
# 避免识别到上一次下载遗留的弹窗导致 system_install_clicked 误置
if self._store_type == STORE_AURORA and install_clicked and not system_install_clicked:
pkg_installer_node = self._find_node_by_package(
nodes, self._PACKAGE_INSTALLER, ("Install",)
)
if pkg_installer_node is not None:
logger.info("Aurora: 检测到系统安装确认对话框,点击 INSTALL")
if self._tap_node(pkg_installer_node):
system_install_clicked = True
no_progress_since = None
time.sleep(5)
continue
# ---- 弹窗自动关闭 ----
if self._click_first(nodes, self.DISMISS_BUTTONS):
no_progress_since = None
time.sleep(2)
continue
# ---- 安装完成检测(双重校验) ----
if self._has_installed_state(nodes):
# 双重校验UI 显示已安装,再用 pm path 确认
# 防止 Aurora 安装过程中短暂出现 Open 按钮导致误判
if self.is_installed(package_name):
return True, "install success" if install_clicked else "app already installed"
logger.debug("UI 显示已安装但 pm path 未确认,继续等待: %s", package_name)
# ---- 点击 Install/Update 按钮 ----
# 仅首次点击,避免重复点击时 continue 跳过下方的卡住检测
install_button = self._find_first(nodes, self.INSTALL_BUTTONS)
if install_button is not None and not install_clicked:
if self._tap_node(install_button):
install_clicked = True
no_progress_since = None
logger.info("Clicked '%s' button in %s",
install_button.text or install_button.desc,
self._store_name)
time.sleep(3)
continue
# ---- Aurora 点击 Install 后无反应检测 ----
# 点击 Install 后按钮始终存在、无系统安装弹窗 → Aurora 未能触发下载
if (install_clicked and install_button is not None
and not system_install_clicked):
if install_stuck_since is None:
install_stuck_since = time.time()
elif time.time() - install_stuck_since > self.AURORA_INSTALL_STUCK_SEC:
logger.warning(
"Aurora Install 点击后 %ds 无变化(按钮仍为'%s'),下载未触发: %s",
self.AURORA_INSTALL_STUCK_SEC,
install_button.text or install_button.desc,
package_name,
)
return False, "aurora install not triggered"
else:
install_stuck_since = None
# ---- 加载超时检测 ----
# 判断是否有"有效进展":有 Install/Update 按钮、安装状态、或失败信息
has_progress = (
install_clicked
or install_button is not None
or system_install_clicked
)
if has_progress:
no_progress_since = None
else:
if no_progress_since is None:
no_progress_since = time.time()
elif time.time() - no_progress_since > self.LOADING_TIMEOUT_SEC:
logger.warning("%s 页面加载超时 (%ds),包名可能不存在: %s",
self._store_name, self.LOADING_TIMEOUT_SEC,
package_name)
return False, "page loading timeout - app may not exist"
time.sleep(self.POLL_INTERVAL_SEC)
return False, "install timeout"
def _open_store_page(self, package_name: str) -> tuple[bool, str]:
url = self._build_url(package_name)
# 构建 intent 命令,通过 -p 参数显式指定目标商店包名
# 避免 market:// 被其他应用拦截(如设备上同时存在 Play Store 和 Aurora Store
am_cmd = [
"shell",
"am",
"start",
"-a",
"android.intent.action.VIEW",
"-d",
url,
]
if self._store_package:
am_cmd.extend(["-p", self._store_package])
result = self._adb.run(am_cmd, check=False, timeout=15)
if result.returncode != 0:
error = (result.stderr or result.stdout or "jump failed").strip()
return False, error
deadline = time.time() + self.OPEN_TIMEOUT_SEC
while time.time() < deadline:
if self._is_store_in_foreground():
return True, "opened"
nodes = self._dump_ui_nodes()
if nodes and (
self._detect_failure(nodes) is not None
or self._find_first(nodes, self.INSTALL_BUTTONS) is not None
or self._has_installed_state(nodes)
):
return True, "opened"
time.sleep(2)
return False, "jump timeout"
def _is_store_in_foreground(self) -> bool:
current = get_current_package(lambda cmd: self._adb.shell(cmd, timeout=10))
return current == self._store_package
def _ensure_single_device(self) -> None:
if self._serial:
return
result = self._adb.run(["devices"], check=False, timeout=10)
lines = [line.strip() for line in (result.stdout or "").splitlines()]
online = [line for line in lines[1:] if line.endswith("\tdevice")]
if len(online) != 1:
raise RuntimeError(f"Expected exactly 1 online adb device, found {len(online)}.")
def _is_google_play_in_foreground(self) -> bool:
return self._is_store_in_foreground()
def _dump_ui_nodes(self, max_retries: int = 3) -> list[UiNode]:
"""执行 uiautomator dump 并解析 UI 节点树。
Aurora Store 使用 Compose UI偶尔会导致 dump 失败ERROR: could not
get idle state因此增加重试逻辑。
"""
for attempt in range(max_retries):
dump_result = self._adb.run(
["shell", "uiautomator", "dump", "/sdcard/uidump.xml"],
check=False,
timeout=15,
)
if dump_result.returncode != 0:
dump_err = (dump_result.stdout or "").strip()
if attempt < max_retries - 1:
logger.debug("uiautomator dump 失败 (%d/%d): %s",
attempt + 1, max_retries, dump_err)
time.sleep(1)
continue
return []
read_result = self._adb.run(
["exec-out", "cat", "/sdcard/uidump.xml"],
check=False,
timeout=15,
)
xml_text = (read_result.stdout or "").strip()
if read_result.returncode != 0 or not xml_text:
if attempt < max_retries - 1:
time.sleep(1)
continue
return []
try:
root = ET.fromstring(xml_text)
except ET.ParseError:
if attempt < max_retries - 1:
time.sleep(1)
continue
return []
nodes: list[UiNode] = []
for raw in root.iter("node"):
bounds = self._parse_bounds(raw.attrib.get("bounds", ""))
if bounds is None:
continue
nodes.append(
UiNode(
text=str(raw.attrib.get("text", "") or ""),
desc=str(raw.attrib.get("content-desc", "") or ""),
package=str(raw.attrib.get("package", "") or ""),
enabled=str(raw.attrib.get("enabled", "true")).lower() == "true",
clickable=str(raw.attrib.get("clickable", "false")).lower() == "true",
bounds=bounds,
)
)
return nodes
return []
def _detect_failure(self, nodes: list[UiNode]) -> str | None:
labels = [label.lower() for node in nodes for label in node.labels()]
if self._contains_text(labels, self.ACCOUNT_BANNED_TEXTS):
return "account banned"
if self._contains_text(labels, self.REGION_BLOCKED_TEXTS):
return "app not available"
if self._contains_text(labels, self.NOT_FOUND_TEXTS):
return "app not found"
if self._contains_text(labels, self.INCOMPATIBLE_TEXTS):
return "app incompatible"
if self._contains_text(labels, self.PAGE_LOAD_FAILED_TEXTS):
return "app page load failed"
if self._store_type == STORE_AURORA:
if self._contains_text(labels, self.AURORA_FAILURE_TEXTS):
return "aurora session/server error"
return None
def _detect_system_install_error(self, nodes: list[UiNode]) -> str | None:
"""检测系统安装失败弹窗。
某些应用安装失败时Android 系统会弹出 "App not installed" 等错误对话框。
这类弹窗如果不关闭,会遮挡后续所有应用的安装界面,导致脚本无法获取
Install 按钮而误判为无法下载。
"""
labels = [label.lower() for node in nodes for label in node.labels()]
if self._contains_text(labels, self.SYSTEM_INSTALL_ERROR_TEXTS):
return "system install error dialog"
return None
def _dismiss_dialog(self, nodes: list[UiNode] | None = None) -> None:
"""尝试关闭当前屏幕上的弹窗。
先尝试通过 UI 节点点击常见的关闭按钮OK/Close/Got it 等),
再按 Back 键作为兜底。
"""
if nodes:
dismiss_labels = ("OK", "Close", "Got it", "Done", "Cancel")
self._click_first(nodes, dismiss_labels)
time.sleep(1)
# Back 键兜底关闭弹窗
self._adb.run(["shell", "input", "keyevent", "KEYCODE_BACK"],
check=False, timeout=5)
time.sleep(0.5)
def _has_installed_state(self, nodes: list[UiNode]) -> bool:
"""判断应用是否已安装完成。
注意:点击 Install/Update 后Google Play 页面可能会短暂显示灰色的
Open 按钮,此时如果同时存在 Cancel 按钮,说明安装仍在进行中。
必须以 Uninstall 按钮出现,或 Open/Play 且没有 Cancel/Install/Update
作为真正完成的标志。
"""
# Uninstall 是最可靠的安装完成标志
if self._find_first(nodes, ("Uninstall",)) is not None:
return True
# 如果还有 Cancel 或 Install/Update 按钮,说明安装还在进行中
if self._find_first(nodes, ("Cancel", "Install", "Update")) is not None:
return False
# 只有 Open/Play 且没有上述安装相关按钮,算完成
if self._find_first(nodes, ("Open", "Play")) is not None:
return True
return False
def _click_first(self, nodes: list[UiNode], labels: tuple[str, ...]) -> bool:
node = self._find_first(nodes, labels)
if node is None:
return False
return self._tap_node(node)
def _find_first(self, nodes: list[UiNode], labels: tuple[str, ...]) -> UiNode | None:
wanted = {label.strip().lower() for label in labels}
matches: list[UiNode] = []
for node in nodes:
if not node.enabled:
continue
for label in node.labels():
if label.strip().lower() in wanted:
matches.append(node)
break
if not matches:
return None
return min(matches, key=lambda node: (node.center[1], node.center[0], not node.clickable))
def _find_node_by_package(
self,
nodes: list[UiNode],
package: str,
labels: tuple[str, ...],
) -> UiNode | None:
"""在指定包名的节点中查找匹配 label 的控件。
用于在 Aurora Store 场景中精确匹配系统安装对话框
(com.android.packageinstaller) 的按钮,避免与商店内同名按钮混淆。
"""
wanted = {label.strip().lower() for label in labels}
for node in nodes:
if not node.enabled:
continue
if node.package != package:
continue
for label in node.labels():
if label.strip().lower() in wanted:
return node
return None
def _disable_animations(self) -> None:
"""关闭设备动画。
Aurora Store 使用 Jetpack Compose 构建 UICompose 的动画/过渡效果
会导致 uiautomator dump 报 'ERROR: could not get idle state' 而失败。
关闭全局动画可以有效解决此问题。
"""
for setting in (
"window_animation_scale",
"transition_animation_scale",
"animator_duration_scale",
):
self._adb.run(
["shell", "settings", "put", "global", setting, "0"],
check=False,
timeout=10,
)
logger.debug("已关闭设备动画 (serial=%s)", self._serial)
def _clear_screen(self) -> None:
"""清理屏幕上可能残留的弹窗/遮挡。
在每次下载开始前调用,确保商店界面不被之前遗留的系统弹窗
(如安装失败提示)遮挡。
"""
# 先回到主屏幕
self._adb.run(["shell", "input", "keyevent", "KEYCODE_HOME"],
check=False, timeout=5)
# 关闭商店应用(清除残留状态)
self._adb.run(["shell", "am", "force-stop", self._store_package],
check=False, timeout=5)
# 关闭系统安装器(可能有残留的安装失败弹窗)
self._adb.run(["shell", "am", "force-stop", self._PACKAGE_INSTALLER],
check=False, timeout=5)
time.sleep(0.5)
def reset_state(self) -> None:
"""完成一个应用的下载/导出/卸载后重置设备状态。
在 us_download_worker 中每次处理完一个包后调用,确保:
1. 关闭残留的商店页面和弹窗
2. 回到主屏幕
3. 不影响下一个包的下载
"""
self._adb.run(["shell", "input", "keyevent", "KEYCODE_HOME"],
check=False, timeout=5)
self._adb.run(["shell", "am", "force-stop", self._store_package],
check=False, timeout=5)
self._adb.run(["shell", "am", "force-stop", self._PACKAGE_INSTALLER],
check=False, timeout=5)
# 也关闭可能弹出的权限管理器等系统组件
self._adb.run(["shell", "am", "force-stop", "com.google.android.permissioncontroller"],
check=False, timeout=5)
time.sleep(0.5)
logger.debug("设备状态已重置 (serial=%s)", self._serial)
def _tap_node(self, node: UiNode) -> bool:
x, y = node.center
if x <= 0 and y <= 0:
return False
result = self._adb.run(
["shell", "input", "tap", str(x), str(y)],
check=False,
timeout=10,
)
return result.returncode == 0
@staticmethod
def _contains_text(labels: list[str], expected_values: tuple[str, ...]) -> bool:
for expected in expected_values:
expected_lower = expected.lower()
if any(expected_lower in label for label in labels):
return True
return False
@staticmethod
def _parse_bounds(value: str) -> tuple[int, int, int, int] | None:
match = _BOUNDS_RE.fullmatch(str(value or "").strip())
if match is None:
return None
left, top, right, bottom = (int(part) for part in match.groups())
return left, top, right, bottom
def export_apk(self, package_name: str, output_dir: str) -> list:
os.makedirs(output_dir, exist_ok=True)
result = self._adb.run(
["shell", "pm", "path", package_name],
check=False,
timeout=10,
)
if result.returncode != 0:
logger.warning("pm path failed for %s: %s", package_name, (result.stderr or "").strip())
return []
apk_paths = []
for line in (result.stdout or "").splitlines():
line = line.strip()
if line.startswith("package:"):
apk_paths.append(line.split(":", 1)[1].strip())
if not apk_paths:
logger.warning("No APK paths found for %s", package_name)
return []
exported = []
for apk_path in apk_paths:
filename = os.path.basename(apk_path)
dest = os.path.join(output_dir, filename)
pull_result = self._adb.run(
["pull", apk_path, dest],
check=False,
timeout=60,
)
if pull_result.returncode != 0 or not os.path.isfile(dest):
logger.warning("adb pull failed for %s: %s", package_name, apk_path)
continue
exported.append(dest)
return exported
def get_apk_version(self, package_name: str) -> str:
result = self._adb.run(
["shell", "dumpsys", "package", package_name],
check=False,
timeout=15,
)
if result.returncode != 0:
return ""
for line in (result.stdout or "").splitlines():
if "versionName=" in line:
val = line.split("versionName=", 1)[1].strip()
if val:
return val
return ""
@staticmethod
def _build_google_play_url(package_name: str) -> str:
return f"https://play.google.com/store/apps/details?id={package_name}"
@staticmethod
def _build_aurora_url(package_name: str) -> str:
return f"market://details?id={package_name}"
def main() -> int:
import argparse
parser = argparse.ArgumentParser(description="Google Play / Aurora Store Downloader")
parser.add_argument("--package", default="com.google.android.youtube",
help="Package name to download")
parser.add_argument("--serial", default="",
help="Target device serial (optional)")
parser.add_argument("--aurora", action="store_true",
help="Use Aurora Store instead of Google Play")
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
store_type = STORE_AURORA if args.aurora else STORE_GOOGLE_PLAY
downloader = GooglePlayDownloader(
serial=args.serial or None,
store_type=store_type,
)
success, message = downloader.start(args.package)
print(f"success={success}")
print(f"message={message}")
return 0 if success else 1
if __name__ == "__main__":
raise SystemExit(main())