Files
Artemis-Eapil/artemis/core/diagnostics/emulator_manager.py
T
somew1nd 9a5aaf99b7 feat: introduce MCP server, cross-platform runtime, and Showcase UI reorganization
- Showcase UI Reorganization:
  * Move web UI dashboard from root directory into apps/showcase_ui/ for cleaner project structure
  * Add 'artemis ui' CLI command (artemis/interfaces/cli/commands/ui.py) and launcher scripts for the interactive showcase console
- Standalone MCP Server & Notifiers (mcp_server/):
  * Implement dedicated MCP server package with mobile automation tools (device_state, inspect_trace, task_manager, task_runner)
  * Add multi-channel notifiers (AgentAPI, Desktop notifications, Webhooks, File output, and Composite routing)
  * Introduce background task runner and trace store for autonomous mobile execution monitoring
- Cross-Platform Runtime & Toolchain Support:
  * Add artemis/platform/ with OS-specific implementations for Linux, macOS (Darwin), and Windows
  * Add artemis/runtime/supervisor.py for resilient process and agent runtime supervision
  * Add artemis/toolchain/ with dependency resolution and SDK descriptors
  * Add cross-platform launcher scripts (start.sh, start.bat) and installer scripts (scripts/install_deps.*)
- Core Enhancements & Diagnostics:
  * Enhance coordinate normalization, OCR API fallback, and perception pipeline
  * Add credential validation (credentials_validator.py) and system diagnostics
  * Update bilingual documentation (README.md, README_CN.md) and architecture diagrams
- Test Suite Expansion:
  * Add integration tests for cloud gateway, remote ADB, and Artemis login
  * Add unit tests covering core diagnostics, platform detection, toolchain, credentials validator, optional OCR, and MCP tools
2026-08-18 13:59:27 -07:00

481 lines
20 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Android Emulator Launch & Boot Progress Lifecycle Manager."""
import asyncio
from collections import deque
from enum import Enum
import os
from pathlib import Path
import shutil
import subprocess
import threading
import time
from typing import Any
from pydantic import BaseModel, Field
from artemis.platform import OSType, platform
from artemis.toolchain import toolchain
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
class EmulatorLaunchStage(str, Enum):
"""Lifecycle stages of launching an Android Virtual Device."""
IDLE = "idle"
STARTING = "starting" # Process spawned, verifying PID
WAITING_FOR_ADB = "waiting_for_adb" # Process alive, waiting for emulator to appear in ADB
BOOTING = "booting" # Visible in ADB, waiting for sys.boot_completed=1
READY = "ready" # Boot complete and ready for automation
FAILED = "failed" # Process crashed, lock error, or timeout
STOPPED = "stopped" # Stopped by user
class EmulatorLaunchState(BaseModel):
"""State schema for real-time emulator launch tracking."""
avd_name: str | None = None
status: EmulatorLaunchStage = EmulatorLaunchStage.IDLE
pid: int | None = None
serial: str | None = None
stage_message: str = "Ready to launch"
progress_percent: int = 0
started_at: float | None = None
elapsed_seconds: int = 0
error: str | None = None
logs: list[str] = Field(default_factory=list)
can_retry: bool = True
class EmulatorManager:
"""Manages background emulator execution, stdout/stderr stream capture, and boot completion polling."""
def __init__(self):
self._current_state = EmulatorLaunchState()
self._proc: subprocess.Popen | None = None
self._log_buffer: deque[str] = deque(maxlen=100)
self._track_task: asyncio.Task | None = None
self._lock = asyncio.Lock()
self._reader_thread: threading.Thread | None = None
def _locate_emulator(self) -> str | None:
"""Find the emulator binary path from PATH or standard SDK environments."""
resolved = toolchain.resolve("emulator")
if resolved:
return resolved
emu_path = shutil.which("emulator")
if emu_path:
return emu_path
sdk_candidates = [
os.environ.get("ANDROID_HOME"),
os.environ.get("ANDROID_SDK_ROOT"),
str(Path.home() / "Android" / "Sdk"),
str(Path.home() / "Android" / "sdk"),
str(Path.home() / "Library" / "Android" / "sdk"),
os.getenv("LOCALAPPDATA", "") + "/Android/Sdk" if os.getenv("LOCALAPPDATA") else None,
"/usr/lib/android-sdk",
"/opt/android-sdk",
]
for base in sdk_candidates:
if base:
cand = (
Path(base)
/ "emulator"
/ ("emulator.exe" if platform.os_type == OSType.WINDOWS else "emulator")
)
if cand.is_file():
return str(cand)
return None
def _locate_adb(self) -> str:
"""Find the adb binary path."""
resolved = toolchain.resolve("adb")
if resolved:
return resolved
return shutil.which("adb") or "adb"
def get_status(self) -> EmulatorLaunchState:
"""Return current launch progress state snapshot."""
state = self._current_state.model_copy()
if state.started_at and state.status in (
EmulatorLaunchStage.STARTING,
EmulatorLaunchStage.WAITING_FOR_ADB,
EmulatorLaunchStage.BOOTING,
):
state.elapsed_seconds = int(time.time() - state.started_at)
state.logs = list(self._log_buffer)
return state
def _stream_logs(self, proc: subprocess.Popen):
"""Continuously read process stdout/stderr in a background thread."""
try:
if proc.stdout:
for line in iter(proc.stdout.readline, ""):
if not line:
break
clean_line = line.strip()
if clean_line:
self._log_buffer.append(clean_line)
except Exception as e:
logger.debug(f"Log streaming finished: {e}")
finally:
if proc.stdout:
proc.stdout.close()
async def launch(self, avd_name: str) -> EmulatorLaunchState:
"""Initiate background emulator launch and start asynchronous boot monitoring."""
clean_avd = avd_name.strip()
if not clean_avd:
self._current_state = EmulatorLaunchState(
status=EmulatorLaunchStage.FAILED,
error="AVD name cannot be empty.",
stage_message="Launch failed: AVD name cannot be empty.",
)
return self.get_status()
async with self._lock:
# If already launching the same AVD and process is alive
if (
self._current_state.status
in (
EmulatorLaunchStage.STARTING,
EmulatorLaunchStage.WAITING_FOR_ADB,
EmulatorLaunchStage.BOOTING,
)
and self._current_state.avd_name == clean_avd
and self._proc is not None
and self._proc.poll() is None
):
logger.info(f"AVD '{clean_avd}' is already in progress of launching.")
return self.get_status()
emu_path = self._locate_emulator()
if not emu_path:
self._current_state = EmulatorLaunchState(
avd_name=clean_avd,
status=EmulatorLaunchStage.FAILED,
error="Android emulator executable not found in PATH or Android SDK. Please install Android SDK platform tools and emulator.",
stage_message="Emulator binary not found.",
)
return self.get_status()
self._log_buffer.clear()
self._log_buffer.append(f"Starting emulator '{clean_avd}' using binary: {emu_path}")
try:
# Cancel previous tracker if any
if self._track_task and not self._track_task.done():
self._track_task.cancel()
# Spawn emulator process capturing stdout & stderr
proc = subprocess.Popen(
[emu_path, "-avd", clean_avd],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
start_new_session=True,
)
self._proc = proc
now = time.time()
# Start reader thread for stdout/stderr
self._reader_thread = threading.Thread(
target=self._stream_logs, args=(proc,), daemon=True
)
self._reader_thread.start()
self._current_state = EmulatorLaunchState(
avd_name=clean_avd,
status=EmulatorLaunchStage.STARTING,
pid=proc.pid,
stage_message=f"Process spawned (PID: {proc.pid}). Initializing QEMU virtualization...",
progress_percent=15,
started_at=now,
elapsed_seconds=0,
error=None,
logs=list(self._log_buffer),
)
# Launch async boot tracker task
self._track_task = asyncio.create_task(
self._track_boot_lifecycle(clean_avd, proc, now)
)
return self.get_status()
except Exception as e:
logger.error(f"Failed to spawn emulator process: {e}")
self._current_state = EmulatorLaunchState(
avd_name=clean_avd,
status=EmulatorLaunchStage.FAILED,
error=f"Failed to spawn emulator process: {e}",
stage_message="Failed to spawn emulator process.",
logs=list(self._log_buffer),
)
return self.get_status()
async def _track_boot_lifecycle(self, avd_name: str, proc: subprocess.Popen, started_at: float):
"""Monitor emulator lifecycle from process execution to ADB connection and OS boot completion."""
adb_path = self._locate_adb()
detected_serial: str | None = None
max_wait_seconds = 180 # 3 minutes maximum boot timeout
try:
# Phase 1: Early crash detection (first 5 seconds)
for _ in range(5):
await asyncio.sleep(1)
poll_res = proc.poll()
if poll_res is not None:
logs_str = "\n".join(list(self._log_buffer)[-10:])
error_msg = f"Emulator process exited immediately with code {poll_res}."
if "already running" in logs_str.lower() or "lock" in logs_str.lower():
error_msg = f"AVD '{avd_name}' is locked by another running emulator instance. Try stopping existing instances or restart ADB."
elif "panic" in logs_str.lower():
error_msg = f"Emulator panic: {logs_str}"
logger.error(f"[EmulatorManager] Early crash: {error_msg}")
self._current_state = EmulatorLaunchState(
avd_name=avd_name,
status=EmulatorLaunchStage.FAILED,
pid=proc.pid,
error=error_msg,
stage_message=f"Process exited prematurely (Exit code {poll_res})",
started_at=started_at,
elapsed_seconds=int(time.time() - started_at),
logs=list(self._log_buffer),
)
return
# Phase 2: Waiting for ADB handshake
self._current_state.status = EmulatorLaunchStage.WAITING_FOR_ADB
self._current_state.progress_percent = 35
self._current_state.stage_message = (
"QEMU hypervisor started. Waiting for ADB connection..."
)
while time.time() - started_at < max_wait_seconds:
await asyncio.sleep(1.5)
# Check if process died
poll_res = proc.poll()
if poll_res is not None:
logs_str = "\n".join(list(self._log_buffer)[-10:])
self._current_state = EmulatorLaunchState(
avd_name=avd_name,
status=EmulatorLaunchStage.FAILED,
pid=proc.pid,
error=f"Emulator process terminated unexpectedly (exit code {poll_res}): {logs_str}",
stage_message="Process terminated unexpectedly",
started_at=started_at,
elapsed_seconds=int(time.time() - started_at),
logs=list(self._log_buffer),
)
return
# Check adb devices for emulator serial
try:
p = await asyncio.create_subprocess_exec(
adb_path,
"devices",
"-l",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = await p.communicate()
out_text = stdout.decode(errors="replace")
# Scan for emulator-*
for line in out_text.splitlines():
line = line.strip()
if line.startswith("emulator-"):
parts = line.split()
if parts:
detected_serial = parts[0]
break
except Exception as e:
logger.debug(f"ADB query error during boot tracking: {e}")
if detected_serial:
break
if not detected_serial:
self._current_state = EmulatorLaunchState(
avd_name=avd_name,
status=EmulatorLaunchStage.FAILED,
pid=proc.pid,
error="Timeout waiting for emulator to connect to ADB.",
stage_message="ADB handshake timed out.",
started_at=started_at,
elapsed_seconds=int(time.time() - started_at),
logs=list(self._log_buffer),
)
return
# Phase 3: Android OS Booting (Polling sys.boot_completed)
self._current_state.status = EmulatorLaunchStage.BOOTING
self._current_state.serial = detected_serial
self._current_state.progress_percent = 65
self._current_state.stage_message = (
f"Connected to ADB ({detected_serial}). Android OS is booting up..."
)
while time.time() - started_at < max_wait_seconds:
await asyncio.sleep(2)
# Check if process died
poll_res = proc.poll()
if poll_res is not None:
self._current_state = EmulatorLaunchState(
avd_name=avd_name,
status=EmulatorLaunchStage.FAILED,
pid=proc.pid,
error=f"Emulator process crashed during OS boot (exit code {poll_res}).",
stage_message="Process crashed during OS boot",
started_at=started_at,
elapsed_seconds=int(time.time() - started_at),
logs=list(self._log_buffer),
)
return
# Check sys.boot_completed
try:
p = await asyncio.create_subprocess_exec(
adb_path,
"-s",
detected_serial,
"shell",
"getprop sys.boot_completed",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = await p.communicate()
boot_completed = stdout.decode(errors="replace").strip()
if boot_completed == "1":
logger.info(
f"[EmulatorManager] Android system boot completed for {detected_serial} ({avd_name})"
)
self._current_state = EmulatorLaunchState(
avd_name=avd_name,
status=EmulatorLaunchStage.READY,
pid=proc.pid,
serial=detected_serial,
stage_message=f"Android emulator '{avd_name}' is fully booted and ready!",
progress_percent=100,
started_at=started_at,
elapsed_seconds=int(time.time() - started_at),
error=None,
logs=list(self._log_buffer),
)
try:
from artemis.core.diagnostics.engine import readiness_engine
readiness_engine.set_active_device_serial(detected_serial)
except Exception as e:
logger.debug(f"Failed to auto-select device serial: {e}")
return
else:
# Gradual progress visual indicator
elapsed = time.time() - started_at
calc_progress = min(95, int(65 + (elapsed / 60) * 30))
self._current_state.progress_percent = calc_progress
self._current_state.stage_message = f"Android OS booting ({int(elapsed)}s)... Initializing system services..."
except Exception as e:
logger.debug(f"Error querying boot_completed: {e}")
# Timeout after max_wait_seconds
self._current_state = EmulatorLaunchState(
avd_name=avd_name,
status=EmulatorLaunchStage.FAILED,
pid=proc.pid,
serial=detected_serial,
error=f"Android OS boot did not finish within {max_wait_seconds}s timeout.",
stage_message="Boot process timed out.",
started_at=started_at,
elapsed_seconds=int(time.time() - started_at),
logs=list(self._log_buffer),
)
except asyncio.CancelledError:
logger.info(f"[EmulatorManager] Boot tracker cancelled for '{avd_name}'")
except Exception as exc:
logger.error(f"[EmulatorManager] Unexpected exception in boot tracker: {exc}")
self._current_state = EmulatorLaunchState(
avd_name=avd_name,
status=EmulatorLaunchStage.FAILED,
pid=proc.pid if proc else None,
error=f"Unexpected error: {exc}",
stage_message="Unexpected error during boot tracking.",
started_at=started_at,
elapsed_seconds=int(time.time() - started_at),
logs=list(self._log_buffer),
)
async def stop(self) -> dict[str, Any]:
"""Terminate the active emulator process."""
async with self._lock:
if self._track_task and not self._track_task.done():
self._track_task.cancel()
serial = self._current_state.serial
pid = self._current_state.pid
avd = self._current_state.avd_name
if serial:
try:
adb_path = self._locate_adb()
subprocess.run(
[adb_path, "-s", serial, "emu", "kill"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
)
except Exception:
pass
if self._proc and self._proc.poll() is None:
try:
self._proc.terminate()
try:
self._proc.wait(timeout=3)
except subprocess.TimeoutExpired:
self._proc.kill()
except Exception as e:
logger.error(f"Error terminating emulator process {pid}: {e}")
self._current_state = EmulatorLaunchState(
avd_name=avd,
status=EmulatorLaunchStage.STOPPED,
stage_message="Emulator process stopped by user.",
logs=list(self._log_buffer),
)
return {"success": True, "message": f"Emulator '{avd}' stopped"}
def dismiss(self) -> dict[str, Any]:
"""Reset emulator launch state back to idle."""
self._current_state = EmulatorLaunchState()
self._log_buffer.clear()
return {"success": True, "message": "State reset"}
# Global singleton instance
emulator_manager = EmulatorManager()