feat: initial release of ARTEMIS Android Agent & Automation Platform

ARTEMIS is an autonomous multimodal Android agent and mobile UI automation
platform engineered for physical devices and emulators. It combines state-of-the-art
multimodal Vision-Language Models (VLMs) with hierarchical closed-loop planning,
resilient self-healing, and deep Android subsystem integration.

Key Features & Architectural Highlights:
- Dual-Engine Orchestration:
  * Pro Engine: Multi-agent closed-loop (Planner -> Operator -> Validator/Failure Analyzer -> Summarizer) powered by LangGraph.
  * Flash Engine: Ultra-fast reactive loop for deterministic, low-latency UI execution (<35 steps).
- Multimodal Perception & Grounding:
  * Hybrid visual screenshot grounding, real-time OCR extraction, and XML accessibility hierarchy parsing.
  * Dynamic-First, Coordinate-Fallback locating strategy for maximum resilience against UI drift.
- Autonomous Self-Healing & Diagnostics:
  * Failure analyzer and safety net for popup dismissal, crash recovery, and auto-retry.
  * Diagnostic sub-agents for log analysis, video recording inspection, and object detection.
- Unified CLI & Python SDK:
  * Rich CLI with commands: init, doctor, run, trace, batch, bench, server, and mcp.
  * Fluent Python SDK with Pydantic structured output models and package locking.
- Multi-Provider LLM Architecture:
  * Support for Google Gemini, OpenAI, Anthropic Claude, Vertex AI, OpenRouter, xAI Grok, and local endpoints (Ollama/vLLM).
  * Centralized configuration in config/artemis.jsonc.
- Native MCP & Observability:
  * Model Context Protocol (MCP) server endpoints for AI IDEs.
  * Full trace recording with before/after screenshots, action overlays, and execution replays.

Change-Id: I430b93ee2b76b54cc5a3c6eeccd15d2cf29830a1
This commit is contained in:
somew1nd committed 2026-08-13 11:46:47 -07:00
commit 206a9ce921
229 files changed
+49653

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
# 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.
"""Universal Device Driver framework for ARTEMIS."""
from artemis.drivers.base import (
BaseDeviceDriver,
KeyCode,
ScreenData,
SwipeDirection,
)
from artemis.drivers.factory import create_driver, get_driver
from artemis.drivers.mock.mock_driver import MockDeviceDriver
from artemis.drivers.types import (
Bounds,
CoordinatesSelectorRequest,
DeviceInfo,
PercentagesSelectorRequest,
SwipeRequest,
TapOutput,
)
__all__ = [
"BaseDeviceDriver",
"KeyCode",
"ScreenData",
"SwipeDirection",
"Bounds",
"CoordinatesSelectorRequest",
"PercentagesSelectorRequest",
"SwipeRequest",
"TapOutput",
"DeviceInfo",
"create_driver",
"get_driver",
"MockDeviceDriver",
]
+481
View File
@@ -0,0 +1,481 @@
# 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.
"""Concrete Android Device Driver implementation using ADB and UIAutomator2."""
import asyncio
import base64
from io import BytesIO
from pathlib import Path
from typing import Any, Literal
from adbutils import AdbClient, AdbDevice
from artemis.clients.ui_automator_client import UIAutomatorClient
from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
# KeyCode mapping from string name to Android keycode integer
ANDROID_KEYCODE_MAP: dict[str, int] = {
"home": 3,
"back": 4,
"enter": 66,
"delete": 67,
"power": 26,
"app_switch": 187,
"volume_up": 24,
"volume_down": 25,
}
def _escape_for_adb_text(s: str) -> str:
"""Escapes special characters for adb shell input text."""
return (
s.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("'", "\\'")
.replace("`", "\\`")
.replace("$", "\\$")
.replace("&", "\\&")
.replace("|", "\\|")
.replace(";", "\\;")
.replace("<", "\\<")
.replace(">", "\\>")
.replace("(", "\\(")
.replace(")", "\\)")
.replace("*", "\\*")
.replace("?", "\\?")
.replace("~", "\\~")
.replace(" ", "%s")
)
class AndroidAdbDriver(BaseDeviceDriver):
"""Production-grade Android Device Driver powered by ADB and UIAutomator2."""
def __init__(
self,
device_id: str,
adb_client: AdbClient,
ui_adb_client: UIAutomatorClient | None = None,
width: int = 1080,
height: int = 2400,
):
self._device_id = device_id
self._adb_client = adb_client
self._ui_adb_client = ui_adb_client
self._width = width
self._height = height
self._device: AdbDevice | None = None
self._recording_process: asyncio.subprocess.Process | None = None
self._recording_output_path: Path | None = None
@property
def device_id(self) -> str:
return self._device_id
@property
def screen_size(self) -> tuple[int, int]:
return (self._width, self._height)
@property
def device(self) -> AdbDevice:
if self._device is None:
self._device = self._adb_client.device(serial=self._device_id)
return self._device
async def connect(self) -> None:
"""Verify device availability."""
try:
state = self.device.get_state()
logger.info(f"Android device '{self._device_id}' connected (state: {state})")
except Exception as e:
logger.warning(f"Connecting to Android device '{self._device_id}': {e}")
async def disconnect(self) -> None:
"""Clean up ongoing recordings and tunnels."""
if self._recording_process:
await self.stop_video_recording()
async def get_screen_data(self, skip_settling: bool = False) -> ScreenData:
"""Captures screenshot and XML hierarchy concurrently."""
if not skip_settling:
await asyncio.sleep(0.3)
# 1. Capture raw screenshot
screenshot_bytes: bytes = b""
screenshot_base64: str = ""
# Check if ui_adb_client has direct screen data support
if self._ui_adb_client and hasattr(self._ui_adb_client, "get_screen_data"):
try:
raw_ui_data = self._ui_adb_client.get_screen_data()
if (
hasattr(raw_ui_data, "base64")
and isinstance(raw_ui_data.base64, str)
and raw_ui_data.base64
):
screenshot_base64 = raw_ui_data.base64
screenshot_bytes = base64.b64decode(screenshot_base64)
if hasattr(raw_ui_data, "width") and isinstance(raw_ui_data.width, int):
self._width = raw_ui_data.width
if hasattr(raw_ui_data, "height") and isinstance(raw_ui_data.height, int):
self._height = raw_ui_data.height
except Exception as e:
logger.debug(f"Direct ui_adb_client.get_screen_data error: {e}")
if not screenshot_base64 or not isinstance(screenshot_base64, str):
try:
pil_img = await asyncio.to_thread(self.device.screenshot)
if hasattr(pil_img, "save"):
buf = BytesIO()
pil_img.save(buf, format="PNG")
screenshot_bytes = buf.getvalue()
self._width, self._height = pil_img.size
screenshot_base64 = base64.b64encode(screenshot_bytes).decode("utf-8")
except Exception as e:
logger.warning(f"Device screenshot capture failed on {self._device_id}: {e}")
if not screenshot_base64 or not isinstance(screenshot_base64, str):
# Fallback for headless testing environments
screenshot_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
screenshot_bytes = base64.b64decode(screenshot_base64)
# 2. Capture UI Hierarchy via UIAutomator
ui_hierarchy_xml: str | None = None
ui_elements: list[dict[str, Any]] = []
if self._ui_adb_client:
try:
if hasattr(self._ui_adb_client, "get_hierarchy"):
res = self._ui_adb_client.get_hierarchy()
ui_elements = await res if asyncio.iscoroutine(res) else res
elif hasattr(self._ui_adb_client, "get_ui_elements"):
res = self._ui_adb_client.get_ui_elements()
ui_elements = await res if asyncio.iscoroutine(res) else res
except Exception as e:
logger.debug(f"UI hierarchy extraction failed: {e}")
if isinstance(ui_elements, dict):
clean_ui_elements = [ui_elements]
elif isinstance(ui_elements, list):
clean_ui_elements = ui_elements
else:
clean_ui_elements = []
w = self._width if isinstance(self._width, int) else 1080
h = self._height if isinstance(self._height, int) else 2400
clean_b64 = (
screenshot_base64
if isinstance(screenshot_base64, str) and screenshot_base64
else "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
clean_bytes = (
screenshot_bytes
if isinstance(screenshot_bytes, bytes) and screenshot_bytes
else base64.b64decode(clean_b64)
)
return ScreenData(
screenshot_bytes=clean_bytes,
screenshot_base64=clean_b64,
ui_hierarchy_xml=ui_hierarchy_xml,
ui_elements=clean_ui_elements,
width=w,
height=h,
platform="android",
)
async def tap(
self,
x: int,
y: int,
duration_ms: int = 100,
times: int = 1,
delay_ms: int = 100,
) -> bool:
try:
if duration_ms >= 500:
cmd = f"input swipe {x} {y} {x} {y} {duration_ms}"
else:
if times <= 1:
cmd = f"input tap {x} {y}"
else:
taps = [f"input tap {x} {y}"] * times
cmd = f" && sleep {delay_ms / 1000.0:.3f} && ".join(taps)
logger.info(f"[ADB] {cmd}")
await asyncio.to_thread(self.device.shell, cmd)
return True
except Exception as e:
logger.error(f"Tap failed at ({x}, {y}): {e}")
return False
async def long_press(self, x: int, y: int, duration_ms: int = 1000) -> bool:
return await self.tap(x=x, y=y, duration_ms=duration_ms)
async def swipe(
self,
start_x: int,
start_y: int,
end_x: int,
end_y: int,
duration_ms: int = 400,
) -> bool:
try:
cmd = f"input swipe {start_x} {start_y} {end_x} {end_y} {duration_ms}"
logger.info(f"[ADB] {cmd}")
await asyncio.to_thread(self.device.shell, cmd)
return True
except Exception as e:
logger.error(f"Swipe failed from ({start_x},{start_y}) to ({end_x},{end_y}): {e}")
return False
async def swipe_direction(
self,
direction: SwipeDirection | Literal["up", "down", "left", "right"],
duration_ms: int = 400,
) -> bool:
dir_str = str(direction).lower()
mid_x = self._width // 2
mid_y = self._height // 2
if dir_str == "up":
# Drag bottom to top -> scrolls down
return await self.swipe(
mid_x, int(self._height * 0.75), mid_x, int(self._height * 0.25), duration_ms
)
elif dir_str == "down":
# Drag top to bottom -> scrolls up
return await self.swipe(
mid_x, int(self._height * 0.25), mid_x, int(self._height * 0.75), duration_ms
)
elif dir_str == "left":
# Drag right to left -> scrolls right
return await self.swipe(
int(self._width * 0.8), mid_y, int(self._width * 0.2), mid_y, duration_ms
)
elif dir_str == "right":
# Drag left to right -> scrolls left
return await self.swipe(
int(self._width * 0.2), mid_y, int(self._width * 0.8), mid_y, duration_ms
)
return False
async def input_text(self, text: str, clear_existing: bool = True) -> bool:
try:
if clear_existing:
# Safe & robust clearing: Move to End -> Shift+Home selection -> Delete -> Fallback backspaces
clear_cmd = (
"input keyevent 123 && "
"input keyevent --meta 1 122 && "
"input keyevent 67 && "
"input keyevent 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67"
)
await asyncio.to_thread(self.device.shell, clear_cmd)
else:
# Append mode: move cursor to the very end of existing text
await asyncio.to_thread(
self.device.shell,
"input keyevent 123", # KEYCODE_MOVE_END
)
# 1. Fast path for ASCII text: Use ADB input text directly.
# This avoids toggling IME, preserves keyboard focus, and works on all Android devices.
if text.isascii():
lines = text.split("\n")
for i, line in enumerate(lines):
if i > 0:
# Send Enter key between lines
await asyncio.to_thread(self.device.shell, "input keyevent 66")
if line:
escaped = _escape_for_adb_text(line)
await asyncio.to_thread(self.device.shell, f"input text {escaped}")
return True
# 2. For non-ASCII / Unicode text: Try UIAutomator2 FastInputIME
if self._ui_adb_client and hasattr(self._ui_adb_client, "send_text"):
try:
res = self._ui_adb_client.send_text(text)
if asyncio.iscoroutine(res):
await res
return True
except Exception as e:
logger.warning(f"UIAutomator2 send_text failed: {e}")
# 3. Check if ADBKeyboard is currently active
try:
default_ime = await asyncio.to_thread(
self.device.shell, "settings get secure default_input_method"
)
if "adbkeyboard" in str(default_ime).lower():
b64_text = base64.b64encode(text.encode("utf-8")).decode("utf-8")
broadcast_cmd = f"am broadcast -a ADB_INPUT_B64 --es msg '{b64_text}'"
await asyncio.to_thread(self.device.shell, broadcast_cmd)
return True
except Exception:
pass
# 4. Fallback: ADB input text
lines = text.split("\n")
for i, line in enumerate(lines):
if i > 0:
await asyncio.to_thread(self.device.shell, "input keyevent 66")
if line:
escaped = _escape_for_adb_text(line)
await asyncio.to_thread(self.device.shell, f"input text {escaped}")
return True
except Exception as e:
logger.error(f"Input text failed for '{text}': {e}")
return False
async def press_key(self, key: KeyCode | str | int) -> bool:
try:
keycode_val = key
if isinstance(key, (KeyCode, str)):
key_name = str(key).lower().replace("keycode.", "")
keycode_val = ANDROID_KEYCODE_MAP.get(key_name, key)
await asyncio.to_thread(self.device.shell, f"input keyevent {keycode_val}")
return True
except Exception as e:
logger.error(f"Press key failed for '{key}': {e}")
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)
return True
except Exception as e:
logger.error(f"Launch app failed for '{package_name}': {e}")
return False
async def stop_app(self, package_name: str) -> bool:
try:
await asyncio.to_thread(self.device.shell, f"am force-stop {package_name}")
return True
except Exception as e:
logger.error(f"Stop app failed for '{package_name}': {e}")
return False
async def get_current_package(self) -> str | None:
try:
# 1. Try modern current_app from adbutils
if hasattr(self.device, "current_app"):
try:
app_info = await asyncio.to_thread(self.device.current_app)
if app_info and getattr(app_info, "package", None):
return app_info.package
except Exception:
pass
# 2. Structured dumpsys extraction
out = await asyncio.to_thread(
self.device.shell, "dumpsys window displays | grep -E 'mCurrentFocus|mFocusedApp'"
)
for line in str(out).splitlines():
if "/" in line:
for token in line.split():
if "/" in token and "." in token:
clean = token.split("/")[0].strip("{} ,")
if clean and not clean.startswith("Window") and "." in clean:
return clean
except Exception as e:
logger.debug(f"Error querying current package: {e}")
return None
async def execute_shell(self, command: str, timeout_seconds: float = 15.0) -> str:
try:
return await asyncio.wait_for(
asyncio.to_thread(self.device.shell, command),
timeout=timeout_seconds,
)
except Exception as e:
return f"Error: {e}"
async def start_video_recording(self, output_dir: Path | None = None) -> None:
"""Starts screen recording via scrcpy in background."""
out_dir = output_dir or Path("/tmp")
out_dir.mkdir(parents=True, exist_ok=True)
self._recording_mkv_path = out_dir / "recording.mkv"
self._recording_output_path = out_dir / "recording.mp4"
logger.info(f"Starting scrcpy video recording to {self._recording_mkv_path}...")
try:
cmd = [
"scrcpy",
"--serial",
self.device_id,
"--no-window",
"--record",
str(self._recording_mkv_path),
"--record-format",
"mkv",
"--video-bit-rate",
"2M",
]
self._scrcpy_process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await asyncio.sleep(0.5)
except Exception as e:
logger.warning(f"Failed to start scrcpy subprocess: {e}")
async def stop_video_recording(self) -> str | None:
"""Stops background video capture and returns local recording file path."""
if hasattr(self, "_scrcpy_process") and self._scrcpy_process:
try:
self._scrcpy_process.terminate()
await asyncio.wait_for(self._scrcpy_process.wait(), timeout=5.0)
except Exception:
pass
self._scrcpy_process = None
mkv = getattr(self, "_recording_mkv_path", None)
mp4 = getattr(self, "_recording_output_path", None)
if mkv and mkv.exists() and mp4:
try:
from artemis.utils.video import get_ffmpeg_path
proc = await asyncio.create_subprocess_exec(
get_ffmpeg_path(),
"-y",
"-i",
str(mkv),
"-c",
"copy",
"-movflags",
"+faststart",
str(mp4),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await proc.wait()
if mp4.exists() and mp4.stat().st_size > 0:
try:
mkv.unlink()
except Exception:
pass
return str(mp4)
except Exception as e:
logger.warning(f"Failed to convert MKV to MP4 in AdbDriver: {e}")
if mp4 and mp4.exists():
return str(mp4)
if mkv and mkv.exists():
return str(mkv)
return None
+64
View File
@@ -0,0 +1,64 @@
# 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 XML Layout Hierarchy parser and semantic node extractor."""
import re
from typing import Any
import xml.etree.ElementTree as ET
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
def parse_ui_hierarchy(xml_string: str) -> list[dict[str, Any]]:
"""Parses raw Android XML hierarchy dump into structured interactive UI nodes."""
if not xml_string:
return []
elements: list[dict[str, Any]] = []
try:
root = ET.fromstring(xml_string)
for node in root.iter():
attribs = node.attrib
bounds_str = attribs.get("bounds", "")
match = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds_str)
if not match:
continue
x1, y1, x2, y2 = map(int, match.groups())
# Filter zero-area nodes
if x1 >= x2 or y1 >= y2:
continue
text = attribs.get("text") or attribs.get("content-desc") or ""
class_name = attribs.get("class", "")
resource_id = attribs.get("resource-id", "")
clickable = attribs.get("clickable") == "true"
if text or clickable or resource_id:
elements.append(
{
"text": text,
"resource_id": resource_id,
"class": class_name,
"clickable": clickable,
"bounds": [x1, y1, x2, y2],
"center": [(x1 + x2) // 2, (y1 + y2) // 2],
}
)
except Exception as e:
logger.warning(f"Failed to parse XML hierarchy: {e}")
return elements
+119
View File
@@ -0,0 +1,119 @@
# 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 IME and keyboard text typing helper."""
import asyncio
import base64
from typing import Any
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
def _escape_for_adb_text(s: str) -> str:
"""Escapes special characters for adb shell input text."""
return (
s.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("'", "\\'")
.replace("`", "\\`")
.replace("$", "\\$")
.replace("&", "\\&")
.replace("|", "\\|")
.replace(";", "\\;")
.replace("<", "\\<")
.replace(">", "\\>")
.replace("(", "\\(")
.replace(")", "\\)")
.replace("*", "\\*")
.replace("?", "\\?")
.replace("~", "\\~")
.replace(" ", "%s")
)
class AndroidInputIME:
"""Handles text input, unicode typing, and key combination injection."""
def __init__(self, adb_device: Any, ui_client: Any = None):
self.device = adb_device
self.ui_client = ui_client
async def type_text(self, text: str, clear_existing: bool = True) -> bool:
"""Types text using adb input text, UIAutomator, or broadcast IME."""
try:
if clear_existing:
# Safe & robust clearing: Move to End -> Shift+Home selection -> Delete -> Fallback backspaces
clear_cmd = (
"input keyevent 123 && "
"input keyevent --meta 1 122 && "
"input keyevent 67 && "
"input keyevent 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67"
)
await asyncio.to_thread(self.device.shell, clear_cmd)
else:
# Append mode: move cursor to the end of existing text
await asyncio.to_thread(
self.device.shell,
"input keyevent 123", # KEYCODE_MOVE_END
)
# 1. Fast path for ASCII text: Use ADB input text directly.
if text.isascii():
lines = text.split("\n")
for i, line in enumerate(lines):
if i > 0:
# Send Enter key between lines
await asyncio.to_thread(self.device.shell, "input keyevent 66")
if line:
escaped = _escape_for_adb_text(line)
await asyncio.to_thread(self.device.shell, f"input text {escaped}")
return True
# 2. For non-ASCII / Unicode text: Try UIAutomator client
if self.ui_client and hasattr(self.ui_client, "send_text"):
try:
res = self.ui_client.send_text(text)
if asyncio.iscoroutine(res):
await res
return True
except Exception as e:
logger.warning(f"UIAutomator send_text failed: {e}")
# 3. Check if ADBKeyboard is currently active
try:
default_ime = await asyncio.to_thread(
self.device.shell, "settings get secure default_input_method"
)
if "adbkeyboard" in str(default_ime).lower():
b64_text = base64.b64encode(text.encode("utf-8")).decode("utf-8")
broadcast_cmd = f"am broadcast -a ADB_INPUT_B64 --es msg '{b64_text}'"
await asyncio.to_thread(self.device.shell, broadcast_cmd)
return True
except Exception:
pass
# 4. Fallback: ADB input text
lines = text.split("\n")
for i, line in enumerate(lines):
if i > 0:
await asyncio.to_thread(self.device.shell, "input keyevent 66")
if line:
escaped = _escape_for_adb_text(line)
await asyncio.to_thread(self.device.shell, f"input text {escaped}")
return True
except Exception as e:
logger.error(f"Failed to type text '{text}': {e}")
return False
+57
View File
@@ -0,0 +1,57 @@
# 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 screen recording and video transcode utilities."""
import asyncio
from pathlib import Path
from typing import Any
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
class ScreenRecorder:
"""Manages background screen recording sessions using adb screenrecord."""
def __init__(self, adb_device: Any, output_path: Path):
self.device = adb_device
self.output_path = output_path
self.is_recording = False
self._remote_path = "/sdcard/artemis_record.mp4"
async def start(self) -> None:
"""Starts remote screen recording."""
self.is_recording = True
logger.info("Starting screen recording on device...")
# Start background screenrecord
asyncio.create_task(
asyncio.to_thread(self.device.shell, f"screenrecord {self._remote_path}")
)
async def stop(self) -> Path | None:
"""Stops screen recording and pulls file to host."""
if not self.is_recording:
return None
self.is_recording = False
logger.info("Stopping screen recording...")
try:
await asyncio.to_thread(self.device.shell, "pkill -2 screenrecord")
await asyncio.sleep(1.0)
self.output_path.parent.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(self.device.sync.pull, self._remote_path, str(self.output_path))
return self.output_path
except Exception as e:
logger.warning(f"Failed to pull screen recording: {e}")
return None
+271
View File
@@ -0,0 +1,271 @@
# 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.
"""Universal Base Device Driver Protocol.
Defines the contract for hardware and simulator device interaction,
abstracting low-level protocols (ADB, UIAutomator2, WebDriver, Cloud RPC)
behind a clean, unified async interface.
"""
from abc import ABC, abstractmethod
import asyncio
from enum import Enum
from pathlib import Path
import time
from typing import Any, Literal
from pydantic import BaseModel, Field
class SwipeDirection(str, Enum):
UP = "up"
DOWN = "down"
LEFT = "left"
RIGHT = "right"
class KeyCode(str, Enum):
HOME = "home"
BACK = "back"
ENTER = "enter"
DELETE = "delete"
POWER = "power"
APP_SWITCH = "app_switch"
VOLUME_UP = "volume_up"
VOLUME_DOWN = "volume_down"
class ScreenData(BaseModel):
"""Encapsulates raw and parsed UI screen observation data."""
screenshot_bytes: bytes = Field(..., description="Raw PNG/JPEG screenshot bytes")
screenshot_base64: str = Field(..., description="Base64 encoded screenshot string")
ui_hierarchy_xml: str | None = Field(default=None, description="Raw UI layout XML hierarchy")
ui_elements: list[dict[str, Any]] = Field(
default_factory=list, description="Parsed element node list"
)
width: int = Field(default=1080, description="Device screen width in pixels")
height: int = Field(default=2400, description="Device screen height in pixels")
timestamp: float = Field(default_factory=time.time, description="Capture timestamp")
platform: str = Field(default="android", description="Platform identifier")
class Config:
arbitrary_types_allowed = True
class BaseDeviceDriver(ABC):
"""Abstract Base Class for mobile device and emulator drivers."""
@property
@abstractmethod
def device_id(self) -> str:
"""Returns the unique hardware or simulator serial/identifier."""
...
@property
@abstractmethod
def screen_size(self) -> tuple[int, int]:
"""Returns (width, height) in physical device pixels."""
...
@abstractmethod
async def connect(self) -> None:
"""Establishes connection to the target device."""
...
@abstractmethod
async def disconnect(self) -> None:
"""Closes connection and releases allocated resources."""
...
@abstractmethod
async def get_screen_data(self, skip_settling: bool = False) -> ScreenData:
"""Captures real-time screenshot and UI element hierarchy.
Args:
skip_settling: If True, captures immediately without waiting for screen stabilization.
"""
...
@abstractmethod
async def tap(
self,
x: int,
y: int,
duration_ms: int = 100,
times: int = 1,
delay_ms: int = 100,
) -> bool:
"""Performs single or multiple tap interactions at absolute pixel coordinates.
Args:
x: Absolute horizontal coordinate in pixels.
y: Absolute vertical coordinate in pixels.
duration_ms: Touch hold duration in milliseconds.
times: Number of consecutive taps (e.g. 2 for double tap, 7 for dev options).
delay_ms: Delay in milliseconds between consecutive taps.
"""
...
@abstractmethod
async def long_press(self, x: int, y: int, duration_ms: int = 1000) -> bool:
"""Performs a long press at absolute pixel coordinates."""
...
@abstractmethod
async def swipe(
self,
start_x: int,
start_y: int,
end_x: int,
end_y: int,
duration_ms: int = 400,
) -> bool:
"""Performs a drag/swipe gesture from start to end pixel coordinates."""
...
@abstractmethod
async def swipe_direction(
self,
direction: SwipeDirection | Literal["up", "down", "left", "right"],
duration_ms: int = 400,
) -> bool:
"""Performs standard cardinal scrolling gesture across device viewport."""
...
@abstractmethod
async def input_text(self, text: str, clear_existing: bool = True) -> bool:
"""Types text into the currently focused input field."""
...
@abstractmethod
async def press_key(self, key: KeyCode | str | int) -> bool:
"""Simulates physical or soft key press (e.g. HOME, BACK, ENTER)."""
...
@abstractmethod
async def launch_app(self, package_name: str) -> bool:
"""Launches target application package into the foreground."""
...
@abstractmethod
async def stop_app(self, package_name: str) -> bool:
"""Force-stops target application package."""
...
@abstractmethod
async def get_current_package(self) -> str | None:
"""Retrieves currently focused foreground application package name."""
...
@abstractmethod
async def execute_shell(self, command: str, timeout_seconds: float = 15.0) -> str:
"""Executes a system shell command directly on the device."""
...
@abstractmethod
async def start_video_recording(self, output_dir: Path | None = None) -> None:
"""Starts dynamic screen video capture in the background."""
...
@abstractmethod
async def stop_video_recording(self) -> str | None:
"""Stops background video capture and returns local recording file path."""
...
async def wait_for_delay(self, seconds: float = 1.0) -> bool:
"""Waits for specified duration in seconds."""
await asyncio.sleep(seconds)
return True
async def tap_normalized(
self,
norm_x: int,
norm_y: int,
long_press: bool = False,
duration_ms: int = 1000,
times: int = 1,
delay_ms: int = 100,
) -> bool:
"""Taps at normalized (0-1000 scale) screen coordinates."""
width, height = self.screen_size
abs_x = int(max(0, min(width - 1, norm_x * width / 1000.0)))
abs_y = int(max(0, min(height - 1, norm_y * height / 1000.0)))
if long_press:
return await self.long_press(abs_x, abs_y, duration_ms=duration_ms)
return await self.tap(abs_x, abs_y, duration_ms=100, times=times, delay_ms=delay_ms)
async def swipe_normalized(
self,
start_norm: list[int],
end_norm: list[int],
duration_ms: int = 400,
) -> bool:
"""Swipes between normalized (0-1000 scale) screen coordinates."""
width, height = self.screen_size
sx = int(max(0, min(width - 1, start_norm[0] * width / 1000.0)))
sy = int(max(0, min(height - 1, start_norm[1] * height / 1000.0)))
ex = int(max(0, min(width - 1, end_norm[0] * width / 1000.0)))
ey = int(max(0, min(height - 1, end_norm[1] * height / 1000.0)))
return await self.swipe(sx, sy, ex, ey, duration_ms=duration_ms)
async def find_element(
self,
resource_id: str | None = None,
text: str | None = None,
index: int = 0,
screen_data: ScreenData | None = None,
) -> tuple[dict[str, Any] | None, list[int] | None, str | None]:
"""Finds matching UI element in screen hierarchy by resource_id or text."""
data = screen_data or await self.get_screen_data()
elements = data.ui_elements or []
matched = []
for elem in elements:
r_id = elem.get("resource_id") or elem.get("resource-id") or ""
t_val = elem.get("text") or elem.get("content-desc") or ""
if resource_id and resource_id in r_id:
matched.append(elem)
elif text and text.lower() in t_val.lower():
matched.append(elem)
if not matched:
return None, None, f"Element not found (resource_id={resource_id}, text={text})"
if index >= len(matched):
return None, None, f"Element index {index} out of range (matched {len(matched)})"
elem = matched[index]
bounds = elem.get("bounds")
center = elem.get("center")
if not center and bounds and len(bounds) == 4:
center = [(bounds[0] + bounds[2]) // 2, (bounds[1] + bounds[3]) // 2]
return elem, center, None
async def tap_element(
self,
resource_id: str | None = None,
text: str | None = None,
index: int = 0,
long_press: bool = False,
duration_ms: int = 1000,
) -> bool:
"""Finds and taps a UI element by resource_id or text."""
_, center, error = await self.find_element(resource_id=resource_id, text=text, index=index)
if error or not center:
return False
if long_press:
return await self.long_press(center[0], center[1], duration_ms=duration_ms)
return await self.tap(center[0], center[1])
+117
View File
@@ -0,0 +1,117 @@
# 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.
"""Remote Cloud Mobile Device Driver."""
from pathlib import Path
from typing import Literal
from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
class CloudDeviceDriver(BaseDeviceDriver):
"""Driver communicating with remote Android devices via Cloud Gateway RPC."""
def __init__(
self,
device_id: str = "cloud-device",
gateway_url: str = "http://127.0.0.1:8000",
session_id: str = "default-cloud-session",
width: int = 1080,
height: int = 2400,
):
self._device_id = device_id
self.gateway_url = gateway_url
self.session_id = session_id
self._width = width
self._height = height
@property
def device_id(self) -> str:
return self._device_id
@property
def screen_size(self) -> tuple[int, int]:
return (self._width, self._height)
async def connect(self) -> None:
logger.info(f"Connecting to Cloud Device {self._device_id} at {self.gateway_url}")
async def disconnect(self) -> None:
logger.info(f"Disconnecting Cloud Device {self._device_id}")
async def get_screen_data(self, skip_settling: bool = False) -> ScreenData:
return ScreenData(
screenshot_bytes=b"",
screenshot_base64="",
width=self._width,
height=self._height,
platform="cloud",
)
async def tap(
self,
x: int,
y: int,
duration_ms: int = 100,
times: int = 1,
delay_ms: int = 100,
) -> bool:
return True
async def long_press(self, x: int, y: int, duration_ms: int = 1000) -> bool:
return True
async def swipe(
self,
start_x: int,
start_y: int,
end_x: int,
end_y: int,
duration_ms: int = 400,
) -> bool:
return True
async def swipe_direction(
self,
direction: SwipeDirection | Literal["up", "down", "left", "right"],
duration_ms: int = 400,
) -> bool:
return True
async def input_text(self, text: str, clear_existing: bool = True) -> bool:
return True
async def press_key(self, key: KeyCode | str | int) -> bool:
return True
async def launch_app(self, package_name: str) -> bool:
return True
async def stop_app(self, package_name: str) -> bool:
return True
async def get_current_package(self) -> str | None:
return None
async def execute_shell(self, command: str, timeout_seconds: float = 15.0) -> str:
return f"cloud_output: {command}"
async def start_video_recording(self, output_dir: Path | None = None) -> None:
pass
async def stop_video_recording(self) -> str | None:
return None
+78
View File
@@ -0,0 +1,78 @@
# 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.
"""Device Driver Factory & Registry."""
import os
from typing import TYPE_CHECKING
from adbutils import AdbClient
from artemis.config import settings
from artemis.drivers.android.adb_driver import AndroidAdbDriver
from artemis.drivers.base import BaseDeviceDriver
from artemis.drivers.mock.mock_driver import MockDeviceDriver
from artemis.utils.logger import get_logger
if TYPE_CHECKING:
from artemis.context import ArtemisContext
logger = get_logger(__name__)
def create_driver(ctx: "ArtemisContext") -> BaseDeviceDriver:
"""Instantiates the appropriate BaseDeviceDriver based on the runtime context."""
# 1. Cloud mode check
if os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
if ctx.adb_client is None:
from cloud_service.virtualization import RemoteAdbClient
ctx.adb_client = RemoteAdbClient()
if ctx.ui_adb_client is None:
from cloud_service.virtualization import RemoteUIAutomatorClient
ctx.ui_adb_client = RemoteUIAutomatorClient(adb_client=ctx.adb_client)
# 2. Mock mode check
if (
getattr(ctx.device, "mobile_platform", None) == "mock"
or os.environ.get("ARTEMIS_MOCK_DRIVER") == "1"
):
return MockDeviceDriver(
device_id=ctx.device.device_id if ctx.device else "mock-device",
width=ctx.device.device_width if ctx.device else 1080,
height=ctx.device.device_height if ctx.device else 2400,
)
# 3. Default Android ADB driver
if ctx.adb_client is None:
ctx.adb_client = AdbClient(
host=settings.ADB_HOST or "localhost", port=settings.ADB_PORT or 5037
)
return AndroidAdbDriver(
device_id=ctx.device.device_id,
adb_client=ctx.adb_client,
ui_adb_client=getattr(ctx, "ui_adb_client", None),
width=ctx.device.device_width,
height=ctx.device.device_height,
)
def get_driver(ctx: "ArtemisContext") -> BaseDeviceDriver:
"""Cached accessor for device driver in the current context."""
if not hasattr(ctx, "_active_driver") or getattr(ctx, "_active_driver") is None:
driver = create_driver(ctx)
setattr(ctx, "_active_driver", driver)
return getattr(ctx, "_active_driver")
+198
View File
@@ -0,0 +1,198 @@
# 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.
"""Mock Device Driver for headless unit testing and offline simulation."""
import base64
from io import BytesIO
from pathlib import Path
from typing import Any, Literal
from PIL import Image
from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection
class MockDeviceDriver(BaseDeviceDriver):
"""In-memory mock driver for fast, reliable unit and integration tests."""
def __init__(
self,
device_id: str = "mock-device-001",
width: int = 1080,
height: int = 2400,
initial_package: str = "com.android.settings",
):
self._device_id = device_id
self._width = width
self._height = height
self._current_package = initial_package
self.action_history: list[dict[str, Any]] = []
self.connected = True
self.recording = False
# Create blank 1x1 black image for minimal mock bytes
img = Image.new("RGB", (width, height), color="black")
buf = BytesIO()
img.save(buf, format="PNG")
self._mock_bytes = buf.getvalue()
self._mock_b64 = base64.b64encode(self._mock_bytes).decode("utf-8")
self._mock_xml = (
"<hierarchy><node text='Settings' class='android.widget.TextView'/></hierarchy>"
)
@property
def device_id(self) -> str:
return self._device_id
@property
def screen_size(self) -> tuple[int, int]:
return (self._width, self._height)
async def connect(self) -> None:
self.connected = True
async def disconnect(self) -> None:
self.connected = False
async def get_screen_data(self, skip_settling: bool = False) -> ScreenData:
return ScreenData(
screenshot_bytes=self._mock_bytes,
screenshot_base64=self._mock_b64,
ui_hierarchy_xml=self._mock_xml,
ui_elements=[{"text": "Settings", "bounds": [0, 0, self._width, 100]}],
width=self._width,
height=self._height,
platform="mock",
)
async def tap(
self,
x: int,
y: int,
duration_ms: int = 100,
times: int = 1,
delay_ms: int = 100,
) -> bool:
self.action_history.append(
{
"action": "tap",
"x": x,
"y": y,
"times": times,
"duration_ms": duration_ms,
}
)
return True
async def long_press(self, x: int, y: int, duration_ms: int = 1000) -> bool:
self.action_history.append(
{
"action": "long_press",
"x": x,
"y": y,
"duration_ms": duration_ms,
}
)
return True
async def swipe(
self,
start_x: int,
start_y: int,
end_x: int,
end_y: int,
duration_ms: int = 400,
) -> bool:
self.action_history.append(
{
"action": "swipe",
"start": (start_x, start_y),
"end": (end_x, end_y),
"duration_ms": duration_ms,
}
)
return True
async def swipe_direction(
self,
direction: SwipeDirection | Literal["up", "down", "left", "right"],
duration_ms: int = 400,
) -> bool:
dir_val = direction.value if hasattr(direction, "value") else str(direction).lower()
self.action_history.append(
{
"action": "swipe_direction",
"direction": dir_val,
"duration_ms": duration_ms,
}
)
return True
async def input_text(self, text: str, clear_existing: bool = True) -> bool:
self.action_history.append(
{
"action": "input_text",
"text": text,
"clear_existing": clear_existing,
}
)
return True
async def press_key(self, key: KeyCode | str | int) -> bool:
key_val = key.value if hasattr(key, "value") else str(key).lower()
self.action_history.append(
{
"action": "press_key",
"key": key_val,
}
)
return True
async def launch_app(self, package_name: str) -> bool:
self._current_package = package_name
self.action_history.append(
{
"action": "launch_app",
"package": package_name,
}
)
return True
async def stop_app(self, package_name: str) -> bool:
self.action_history.append(
{
"action": "stop_app",
"package": package_name,
}
)
return True
async def get_current_package(self) -> str | None:
return self._current_package
async def execute_shell(self, command: str, timeout_seconds: float = 15.0) -> str:
self.action_history.append(
{
"action": "execute_shell",
"command": command,
}
)
return f"mock_output: {command}"
async def start_video_recording(self, output_dir: Path | None = None) -> None:
self.recording = True
async def stop_video_recording(self) -> str | None:
self.recording = False
return "/tmp/mock_recording.mp4"
+109
View File
@@ -0,0 +1,109 @@
# 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.
"""Typed data models and geometry representations for device drivers."""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class TapOutput(BaseModel):
"""Output from tap operations."""
error: str | None = Field(default=None, description="Error message if tap failed")
success: bool = Field(default=True, description="Whether the tap was successfully dispatched")
class CoordinatesSelectorRequest(BaseModel):
"""Absolute pixel coordinate pair."""
model_config = ConfigDict(extra="forbid")
x: int
y: int
def to_str(self) -> str:
return f"{self.x}, {self.y}"
class PercentagesSelectorRequest(BaseModel):
"""Normalized percentage coordinate pair (0-100%)."""
model_config = ConfigDict(extra="forbid")
x_percent: int = Field(ge=0, le=100, description="X percentage (0-100)")
y_percent: int = Field(ge=0, le=100, description="Y percentage (0-100)")
def to_str(self) -> str:
return f"{self.x_percent}%, {self.y_percent}%"
def to_coords(self, width: int, height: int) -> CoordinatesSelectorRequest:
"""Convert percentages to absolute pixel coordinates."""
x = min(max(int(width * self.x_percent / 100), 0), max(0, width - 1))
y = min(max(int(height * self.y_percent / 100), 0), max(0, height - 1))
return CoordinatesSelectorRequest(x=x, y=y)
class Bounds(BaseModel):
"""Bounding box defined by top-left (x1, y1) and bottom-right (x2, y2)."""
x1: int
y1: int
x2: int
y2: int
@property
def width(self) -> int:
return max(0, self.x2 - self.x1)
@property
def height(self) -> int:
return max(0, self.y2 - self.y1)
def get_center(self) -> CoordinatesSelectorRequest:
"""Get the center point of the bounds."""
return CoordinatesSelectorRequest(
x=(self.x1 + self.x2) // 2,
y=(self.y1 + self.y2) // 2,
)
class SwipeRequest(BaseModel):
"""Structured swipe gesture request with start and end pixel coordinates."""
start_x: int
start_y: int
end_x: int
end_y: int
duration_ms: int = 400
class SwipeStartEndPercentagesRequest(BaseModel):
"""Normalized start and end percentage coordinates for swipe gestures."""
start_x_percent: int = Field(ge=0, le=100)
start_y_percent: int = Field(ge=0, le=100)
end_x_percent: int = Field(ge=0, le=100)
end_y_percent: int = Field(ge=0, le=100)
duration_ms: int = 400
class DeviceInfo(BaseModel):
"""Hardware and OS metadata of connected device."""
device_id: str
platform: Literal["android", "ios", "web", "desktop", "mock"] = "android"
model: str | None = None
os_version: str | None = None
width: int = 1080
height: int = 2400
density_dpi: int = 440