fix(android): launch apps with checked activity manager commands
This commit is contained in:
1 parent
ef7af9988e
commit
f86db1ced6
8 files changed
+334
-15
No files matched your search
@@ -30,6 +30,7 @@ from artemis.config.agent import load_agent_config
|
||||
from artemis.config.settings import settings
|
||||
from artemis.config.paths import get_temp_dir
|
||||
from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection
|
||||
from artemis.drivers.android.app_launch import launch_android_app
|
||||
from artemis.toolchain import find_ffmpeg, find_scrcpy
|
||||
from artemis.utils.video import build_scrcpy_record_command
|
||||
from artemis.utils.ui_filter import filter_ui_hierarchy
|
||||
@@ -450,15 +451,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
return False
|
||||
|
||||
async def launch_app(self, package_name: str) -> bool:
|
||||
try:
|
||||
cmd = f"monkey -p {package_name} -c android.intent.category.LAUNCHER 1"
|
||||
await asyncio.to_thread(
|
||||
self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Launch app failed for '{package_name}': {e}")
|
||||
return False
|
||||
return await launch_android_app(
|
||||
self.device, package_name, settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
|
||||
)
|
||||
|
||||
async def stop_app(self, package_name: str) -> bool:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Launch Android activities through Activity Manager, without requiring Monkey."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from adbutils import AdbDevice
|
||||
|
||||
from artemis.drivers.errors import AppLaunchError
|
||||
|
||||
_PACKAGE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*")
|
||||
_COMPONENT = re.compile(r"([\w.]+)/([\w.$]+)", re.ASCII)
|
||||
_ERROR = re.compile(
|
||||
r"^\s*(?:error\b|exception\b|securityexception\b|permission denial\b|"
|
||||
r"[\w.]+Exception\b)",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_TRANSIENT = re.compile(r"timed? out|timeout|deadobjectexception|can't find service", re.IGNORECASE)
|
||||
_UNSUPPORTED = re.compile(
|
||||
r"unknown (?:command|option)|unrecognized (?:command|option)", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _failure(package: str, stage: str, code: int, output: str, **kwargs: bool) -> AppLaunchError:
|
||||
return AppLaunchError(
|
||||
f"应用 {package} {stage}失败(退出码 {code}):{output.strip()[:1500] or '命令没有返回结果'}",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
async def _shell(
|
||||
device: AdbDevice, command: list[str], package: str, stage: str, timeout: float
|
||||
) -> tuple[int, str]:
|
||||
try:
|
||||
result = await asyncio.to_thread(device.shell2, command, timeout=timeout)
|
||||
return result.returncode, result.output
|
||||
except Exception as exc:
|
||||
# A transport timeout can happen after Android has already received start.
|
||||
# The caller must inspect the foreground before issuing another launch.
|
||||
raise AppLaunchError(
|
||||
f"应用 {package} {stage}时 ADB 请求失败:{str(exc)[:1500] or type(exc).__name__}",
|
||||
retryable=True,
|
||||
may_have_started=stage == "启动",
|
||||
) from exc
|
||||
|
||||
|
||||
async def launch_android_app(device: AdbDevice, package: str, timeout: float) -> bool:
|
||||
"""Resolve the current user's launcher and request a normal launcher-style start.
|
||||
|
||||
True means Activity Manager accepted the command; the shared launch workflow
|
||||
still verifies the foreground. Deterministic failures raise AppLaunchError so
|
||||
they do not turn into repeated foreground timeouts.
|
||||
"""
|
||||
if not _PACKAGE.fullmatch(package):
|
||||
raise AppLaunchError(f"无效的 Android 应用包名:{package!r}")
|
||||
|
||||
intent = ["-a", "android.intent.action.MAIN", "-c", "android.intent.category.LAUNCHER"]
|
||||
resolve = ["resolve-activity", "--brief", "--user", "current", *intent, "-p", package]
|
||||
component = None
|
||||
# pm is the compatibility path for systems without cmd package. If neither
|
||||
# exposes resolution, let Activity Manager resolve the package-scoped intent.
|
||||
for prefix in (["cmd", "package"], ["pm"]):
|
||||
code, output = await _shell(device, [*prefix, *resolve], package, "查询启动入口", timeout)
|
||||
if code == 127 or _UNSUPPORTED.search(output):
|
||||
continue
|
||||
if code != 0 or _ERROR.search(output):
|
||||
raise _failure(
|
||||
package, "查询启动入口", code, output, retryable=bool(_TRANSIENT.search(output))
|
||||
)
|
||||
candidates = [
|
||||
match.group(0)
|
||||
for line in output.splitlines()
|
||||
if (match := _COMPONENT.fullmatch(line.strip())) and match.group(1) == package
|
||||
]
|
||||
if len(candidates) != 1:
|
||||
raise _failure(
|
||||
package,
|
||||
"查询启动入口",
|
||||
code,
|
||||
f"未找到唯一可启动入口;应用可能未安装、已停用或没有桌面入口。{output.strip()}",
|
||||
)
|
||||
component = candidates[0]
|
||||
break
|
||||
|
||||
target = ["-n", component] if component else ["-p", package]
|
||||
# Match a launcher tap: NEW_TASK | RESET_TASK_IF_NEEDED, preserving warm tasks.
|
||||
command = ["am", "start", "-W", "--user", "current", *intent, "-f", "0x10200000", *target]
|
||||
code, output = await _shell(device, command, package, "启动", timeout)
|
||||
status = re.search(r"^\s*Status:\s*(\S+)", output, re.IGNORECASE | re.MULTILINE)
|
||||
if code != 0 or _ERROR.search(output) or (status and status[1].lower() != "ok"):
|
||||
transient = bool(_TRANSIENT.search(output))
|
||||
raise _failure(
|
||||
package, "启动", code, output, retryable=transient, may_have_started=transient
|
||||
)
|
||||
if not status and not re.search(r"Starting:\s*Intent|Warning:\s*Activity not started", output):
|
||||
raise _failure(package, "启动", code, output, retryable=True, may_have_started=True)
|
||||
return True
|
||||
@@ -157,7 +157,10 @@ class BaseDeviceDriver(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def launch_app(self, package_name: str) -> bool:
|
||||
"""Launches target application package into the foreground."""
|
||||
"""Requests launch; may raise AppLaunchError with actionable failure details.
|
||||
|
||||
Callers must independently verify that the application reached the foreground.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Action failures that the shared controller can classify without parsing text."""
|
||||
|
||||
|
||||
class AppLaunchError(RuntimeError):
|
||||
"""A launch failure with bounded-retry and uncertain-execution semantics."""
|
||||
|
||||
def __init__(self, message: str, *, retryable: bool = False, may_have_started: bool = False):
|
||||
super().__init__(message)
|
||||
self.retryable = retryable
|
||||
self.may_have_started = may_have_started
|
||||
@@ -28,6 +28,7 @@ from artemis.controllers.platform_specific_commands_controller import (
|
||||
)
|
||||
from artemis.controllers.unified_controller import UnifiedMobileController
|
||||
from artemis.data_engine.trace import TraceSpan
|
||||
from artemis.drivers.errors import AppLaunchError
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -285,6 +286,7 @@ async def launch_app_with_retries(
|
||||
Tuple of (success: bool, error_message: str | None)
|
||||
"""
|
||||
|
||||
last_error: str | None = None
|
||||
for attempt in range(1, max_retries + 1):
|
||||
logger.info(f"Launch attempt {attempt}/{max_retries} for app {app_package}")
|
||||
|
||||
@@ -304,13 +306,32 @@ async def launch_app_with_retries(
|
||||
await controller.terminate_app(app_package)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
launch_success = await controller.launch_app(app_package)
|
||||
launch_error: AppLaunchError | None = None
|
||||
try:
|
||||
launch_success = await controller.launch_app(app_package)
|
||||
except AppLaunchError as exc:
|
||||
launch_success = False
|
||||
launch_error = exc
|
||||
span.payload["retryable"] = exc.retryable
|
||||
span.payload["launch_error"] = str(exc)
|
||||
if exc.may_have_started:
|
||||
logger.warning(f"{exc};先核对应用是否已进入前台,避免重复启动。")
|
||||
ready, _ = await _poll_for_app_ready(ctx, app_package, max_poll_seconds)
|
||||
if ready:
|
||||
span.status = "success"
|
||||
span.result = "App is ready (launch acknowledgement was unavailable)"
|
||||
return True, None
|
||||
if not launch_success:
|
||||
error_msg = f"Failed to execute launch command for {app_package}"
|
||||
error_msg = (
|
||||
str(launch_error)
|
||||
if launch_error
|
||||
else (f"Failed to execute launch command for {app_package}")
|
||||
)
|
||||
last_error = error_msg
|
||||
logger.error(error_msg)
|
||||
span.status = "failed"
|
||||
span.error = error_msg
|
||||
if attempt == max_retries:
|
||||
if attempt == max_retries or (launch_error and not launch_error.retryable):
|
||||
return False, error_msg
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
@@ -326,12 +347,15 @@ async def launch_app_with_retries(
|
||||
|
||||
span.status = "failed"
|
||||
span.error = error_msg
|
||||
last_error = error_msg
|
||||
|
||||
if attempt < max_retries:
|
||||
logger.warning(f"Attempt {attempt} failed: {error_msg}. Retrying...")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
error_msg = f"Failed to launch {app_package} after {max_retries} attempts"
|
||||
if last_error:
|
||||
error_msg += f": {last_error}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
|
||||
Reference in new issue
Block a user