Revert "refactor: clean up legacy code, rename hopper to entity_extractor, and add third-party notices"
This reverts commit 54fcef9887.
Please do not submit unrelated docs and codes in the same commit. I am reverting this commit so that the related files can be submitted in better-organized future commits.
This commit is contained in:
1 parent
54fcef9887
commit
1d82d59ec8
83 files changed
+3677
-2013
No files matched your search
@@ -11,9 +11,6 @@
|
||||
# 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.
|
||||
#
|
||||
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
|
||||
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
|
||||
|
||||
"""Utilities for handling app locking and initial app launch logic."""
|
||||
|
||||
@@ -219,7 +216,7 @@ async def _poll_for_app_ready(
|
||||
Only fails if we get a different (non-null) package or timeout.
|
||||
|
||||
Args:
|
||||
ctx: ArtemisContext
|
||||
ctx: Mobile use context
|
||||
app_package: Expected package name
|
||||
max_poll_seconds: Maximum time to poll (default: 15s)
|
||||
poll_interval: Time between polls (default: 1s)
|
||||
@@ -275,7 +272,7 @@ async def launch_app_with_retries(
|
||||
"""Launch an app with retry logic and smart polling.
|
||||
|
||||
Args:
|
||||
ctx: ArtemisContext
|
||||
ctx: Mobile use context
|
||||
app_package: Package name (Android) to launch
|
||||
max_retries: Maximum number of launch attempts (default: 3)
|
||||
max_poll_seconds: Maximum time to wait for app to load per attempt
|
||||
@@ -348,7 +345,7 @@ async def _handle_initial_app_launch(
|
||||
3. Return status with success/error information
|
||||
|
||||
Args:
|
||||
ctx: ArtemisContext
|
||||
ctx: Mobile use context
|
||||
locked_app_package: Package name (Android) to lock to
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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.
|
||||
|
||||
import sys
|
||||
|
||||
from adbutils import AdbClient
|
||||
from rich.console import Console
|
||||
|
||||
|
||||
def display_device_status(console: Console, adb_client: AdbClient | None = None):
|
||||
"""Checks for connected devices and displays the status."""
|
||||
console.print("\n[bold]📱 Device Status[/bold]")
|
||||
devices = None
|
||||
if adb_client is not None:
|
||||
devices = adb_client.device_list()
|
||||
if devices:
|
||||
console.print("✅ [bold green]Android device(s) connected:[/bold green]")
|
||||
for device in devices:
|
||||
console.print(f" - {device.serial}")
|
||||
else:
|
||||
console.print("❌ [bold red]No Android device found.[/bold red]")
|
||||
command = "emulator -avd <avd_name>"
|
||||
if sys.platform not in ["win32", "darwin"]:
|
||||
command = f"./{command}"
|
||||
console.print(
|
||||
f"You can start an emulator using a command like: [bold]'{command}'[/bold]"
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
# 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.
|
||||
|
||||
import sys
|
||||
|
||||
import inquirer
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
def select_provider_and_model(
|
||||
console: Console,
|
||||
available_providers: list[str],
|
||||
available_models: dict,
|
||||
default_provider: str,
|
||||
default_model: str,
|
||||
provider: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Interactive selection of LLM provider and model with arrow-key dropdowns when available.
|
||||
|
||||
Args:
|
||||
console: Rich console for output
|
||||
available_providers: List of available provider names
|
||||
available_models: Dict mapping providers to their available models
|
||||
default_provider: Default provider to use
|
||||
default_model: Default model to use
|
||||
provider: Pre-selected provider (optional)
|
||||
model: Pre-selected model (optional)
|
||||
|
||||
Returns:
|
||||
Tuple of (selected_provider, selected_model)
|
||||
"""
|
||||
final_provider = provider
|
||||
final_model = model
|
||||
|
||||
# Interactive provider selection
|
||||
if not final_provider:
|
||||
console.print("\n🤖 [bold cyan]LLM Provider Selection[/bold cyan]")
|
||||
final_provider = _select_from_list(
|
||||
console=console,
|
||||
item_type="provider",
|
||||
choices=available_providers,
|
||||
default=default_provider,
|
||||
message="Select LLM provider",
|
||||
)
|
||||
|
||||
# Interactive model selection
|
||||
if not final_model:
|
||||
console.print(f"\n🎯 [bold green]Model Selection for {final_provider}[/bold green]")
|
||||
available_model_list = (
|
||||
available_models[final_provider]
|
||||
if final_provider
|
||||
else available_models[default_provider]
|
||||
)
|
||||
|
||||
default_model_for_provider = (
|
||||
default_model if default_model in available_model_list else available_model_list[0]
|
||||
)
|
||||
|
||||
final_model = _select_from_list(
|
||||
console=console,
|
||||
item_type="model",
|
||||
choices=available_model_list,
|
||||
default=default_model_for_provider,
|
||||
message=f"Select model for {final_provider}",
|
||||
)
|
||||
|
||||
return final_provider, final_model
|
||||
|
||||
|
||||
def _select_from_list(
|
||||
console: Console,
|
||||
item_type: str,
|
||||
choices: list[str],
|
||||
default: str,
|
||||
message: str,
|
||||
) -> str:
|
||||
"""Select an item from a list using arrow keys when available, fallback to numbered selection.
|
||||
|
||||
Args:
|
||||
console: Rich console for output
|
||||
item_type: Type of item being selected (for error messages)
|
||||
choices: List of choices to select from
|
||||
default: Default choice
|
||||
message: Message to display in dropdown
|
||||
|
||||
Returns:
|
||||
Selected choice
|
||||
"""
|
||||
# Try arrow-key dropdown if TTY is available, fallback to numbered selection
|
||||
if sys.stdin.isatty():
|
||||
try:
|
||||
questions = [
|
||||
inquirer.List(
|
||||
"selection",
|
||||
message=f"{message} (use arrow keys)",
|
||||
choices=choices,
|
||||
default=default,
|
||||
),
|
||||
]
|
||||
answers = inquirer.prompt(questions)
|
||||
return answers["selection"] if answers else default
|
||||
except Exception:
|
||||
# Fallback to numbered selection
|
||||
return _numbered_selection(console, item_type, choices, default)
|
||||
else:
|
||||
return _numbered_selection(console, item_type, choices, default)
|
||||
|
||||
|
||||
def _numbered_selection(console: Console, item_type: str, choices: list[str], default: str) -> str:
|
||||
"""Fallback numbered selection when arrow keys aren't available."""
|
||||
choices_text = "\n".join([f" {i + 1}. {choice}" for i, choice in enumerate(choices)])
|
||||
console.print(f"Available {item_type}s:\n{choices_text}")
|
||||
|
||||
default_idx = choices.index(default) + 1 if default in choices else 1
|
||||
|
||||
while True:
|
||||
choice = Prompt.ask(
|
||||
f"Select {item_type} (1-{len(choices)}) or press Enter for default",
|
||||
default=str(default_idx),
|
||||
)
|
||||
try:
|
||||
choice_idx = int(choice) - 1
|
||||
if 0 <= choice_idx < len(choices):
|
||||
return choices[choice_idx]
|
||||
else:
|
||||
console.print("[red]Invalid choice. Please try again.[/red]")
|
||||
except ValueError:
|
||||
console.print("[red]Please enter a number.[/red]")
|
||||
|
||||
|
||||
def display_llm_config(console: Console, provider: str, model: str) -> None:
|
||||
"""Display the selected LLM configuration with colors."""
|
||||
config_text = Text()
|
||||
config_text.append("🤖 LLM Configuration: ", style="bold white")
|
||||
config_text.append("Provider: ", style="white")
|
||||
config_text.append(f"{provider}", style="bold cyan")
|
||||
config_text.append(" | Model: ", style="white")
|
||||
config_text.append(f"{model}", style="bold green")
|
||||
|
||||
console.print(config_text)
|
||||
@@ -0,0 +1,45 @@
|
||||
# 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.
|
||||
|
||||
from typing import TypeGuard
|
||||
|
||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage
|
||||
|
||||
|
||||
def is_ai_message(message: BaseMessage) -> TypeGuard[AIMessage]:
|
||||
return isinstance(message, AIMessage)
|
||||
|
||||
|
||||
def is_human_message(message: BaseMessage) -> TypeGuard[HumanMessage]:
|
||||
return isinstance(message, HumanMessage)
|
||||
|
||||
|
||||
def is_tool_message(message: BaseMessage) -> TypeGuard[ToolMessage]:
|
||||
return isinstance(message, ToolMessage)
|
||||
|
||||
|
||||
def is_tool_for_name(tool_message: ToolMessage, name: str) -> bool:
|
||||
return tool_message.name == name
|
||||
|
||||
|
||||
def get_screenshot_message_for_llm(screenshot_base64: str):
|
||||
prefix = "" if screenshot_base64.startswith("data:image") else "data:image/jpeg;base64,"
|
||||
return HumanMessage(
|
||||
content=[
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"{prefix}{screenshot_base64}"},
|
||||
}
|
||||
]
|
||||
)
|
||||
+113
-74
@@ -12,89 +12,128 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Lifecycle callback and execution telemetry decorators for ARTEMIS.
|
||||
|
||||
Provides function interception hooks for pre-execution logging, success verification,
|
||||
and error capture across synchronous and asynchronous agent invocations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from functools import wraps
|
||||
import inspect
|
||||
from typing import Any, Callable
|
||||
from typing import Any, TypeVar, cast, overload
|
||||
|
||||
R = TypeVar("R")
|
||||
|
||||
|
||||
def wrap_with_callbacks_sync(
|
||||
fn: Callable[..., R],
|
||||
*,
|
||||
before: Callable[..., None] | None = None,
|
||||
on_success: Callable[[R], None] | None = None,
|
||||
on_failure: Callable[[Exception], None] | None = None,
|
||||
suppress_exceptions: bool = False,
|
||||
) -> Callable[..., R]:
|
||||
@wraps(fn)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> R:
|
||||
if before:
|
||||
before()
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
if on_success:
|
||||
on_success(result)
|
||||
return result
|
||||
except Exception as e:
|
||||
if on_failure:
|
||||
on_failure(e)
|
||||
if suppress_exceptions:
|
||||
return None # type: ignore
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def wrap_with_callbacks_async(
|
||||
fn: Callable[..., Awaitable[R]],
|
||||
*,
|
||||
before: Callable[..., None] | None = None,
|
||||
on_success: Callable[[R], None] | None = None,
|
||||
on_failure: Callable[[Exception], None] | None = None,
|
||||
suppress_exceptions: bool = False,
|
||||
) -> Callable[..., Awaitable[R]]:
|
||||
@wraps(fn)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> R:
|
||||
if before:
|
||||
before()
|
||||
try:
|
||||
result = await fn(*args, **kwargs)
|
||||
if on_success:
|
||||
on_success(result)
|
||||
return result
|
||||
except Exception as e:
|
||||
if on_failure:
|
||||
on_failure(e)
|
||||
if suppress_exceptions:
|
||||
return None # type: ignore
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@overload
|
||||
def wrap_with_callbacks(
|
||||
fn: Callable[..., Awaitable[R]],
|
||||
*,
|
||||
before: Callable[[], None] | None = ...,
|
||||
on_success: Callable[[R], None] | None = ...,
|
||||
on_failure: Callable[[Exception], None] | None = ...,
|
||||
suppress_exceptions: bool = ...,
|
||||
) -> Callable[..., Awaitable[R]]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def wrap_with_callbacks(
|
||||
*,
|
||||
before: Callable[..., None] | None = ...,
|
||||
on_success: Callable[[Any], None] | None = ...,
|
||||
on_failure: Callable[[Exception], None] | None = ...,
|
||||
suppress_exceptions: bool = ...,
|
||||
) -> Callable[[Callable[..., R]], Callable[..., R]]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def wrap_with_callbacks(
|
||||
fn: Callable[..., R],
|
||||
*,
|
||||
before: Callable[[], None] | None = ...,
|
||||
on_success: Callable[[R], None] | None = ...,
|
||||
on_failure: Callable[[Exception], None] | None = ...,
|
||||
suppress_exceptions: bool = ...,
|
||||
) -> Callable[..., R]: ...
|
||||
|
||||
|
||||
def wrap_with_callbacks(
|
||||
fn: Callable[..., Any] | None = None,
|
||||
*,
|
||||
before: Callable[..., None] | None = None,
|
||||
before: Callable[[], None] | None = None,
|
||||
on_success: Callable[[Any], None] | None = None,
|
||||
on_failure: Callable[[Exception], None] | None = None,
|
||||
suppress_exceptions: bool = False,
|
||||
) -> Any:
|
||||
"""Wrap a callable with pre-invocation, success, and error callback handlers.
|
||||
def decorator(func: Callable[..., Any]) -> Any:
|
||||
if inspect.iscoroutinefunction(func):
|
||||
return wrap_with_callbacks_async(
|
||||
cast(Callable[..., Awaitable[Any]], func),
|
||||
before=before,
|
||||
on_success=on_success,
|
||||
on_failure=on_failure,
|
||||
suppress_exceptions=suppress_exceptions,
|
||||
)
|
||||
else:
|
||||
return wrap_with_callbacks_sync(
|
||||
cast(Callable[..., Any], func),
|
||||
before=before,
|
||||
on_success=on_success,
|
||||
on_failure=on_failure,
|
||||
suppress_exceptions=suppress_exceptions,
|
||||
)
|
||||
|
||||
Supports decorating both coroutines and regular synchronous functions.
|
||||
"""
|
||||
|
||||
def _decorator(target_fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||
if inspect.iscoroutinefunction(target_fn):
|
||||
|
||||
@wraps(target_fn)
|
||||
async def _async_wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||
if before is not None:
|
||||
try:
|
||||
before()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
result = await target_fn(*args, **kwargs)
|
||||
if on_success is not None:
|
||||
try:
|
||||
on_success(result)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
except Exception as exc:
|
||||
if on_failure is not None:
|
||||
try:
|
||||
on_failure(exc)
|
||||
except Exception:
|
||||
pass
|
||||
if suppress_exceptions:
|
||||
return None
|
||||
raise
|
||||
|
||||
return _async_wrapped
|
||||
|
||||
@wraps(target_fn)
|
||||
def _sync_wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||
if before is not None:
|
||||
try:
|
||||
before()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
result = target_fn(*args, **kwargs)
|
||||
if on_success is not None:
|
||||
try:
|
||||
on_success(result)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
except Exception as exc:
|
||||
if on_failure is not None:
|
||||
try:
|
||||
on_failure(exc)
|
||||
except Exception:
|
||||
pass
|
||||
if suppress_exceptions:
|
||||
return None
|
||||
raise
|
||||
|
||||
return _sync_wrapped
|
||||
|
||||
if fn is not None:
|
||||
return _decorator(fn)
|
||||
return _decorator
|
||||
if fn is None:
|
||||
return decorator
|
||||
else:
|
||||
return decorator(fn)
|
||||
+6
-33
@@ -12,43 +12,16 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""File and configuration loading utilities for ARTEMIS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import IO, Any
|
||||
from typing import IO
|
||||
|
||||
|
||||
def strip_json_comments(text: str) -> str:
|
||||
"""Removes single-line (//) and multi-line (/* ... */) comments from JSONC text.
|
||||
|
||||
Args:
|
||||
text: Raw JSON with comments string.
|
||||
|
||||
Returns:
|
||||
Clean JSON string with comments stripped.
|
||||
"""
|
||||
pattern = r"//.*?$|/\*.*?\*/"
|
||||
return re.sub(pattern, "", text, flags=re.MULTILINE | re.DOTALL)
|
||||
text = re.sub(r"//.*?$", "", text, flags=re.MULTILINE)
|
||||
text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
|
||||
return text
|
||||
|
||||
|
||||
def load_jsonc(source: str | Path | IO[str] | IO[bytes]) -> dict[str, Any]:
|
||||
"""Loads and parses a JSONC (JSON with Comments) document.
|
||||
|
||||
Args:
|
||||
source: File-like object, Path, or string path to parse.
|
||||
|
||||
Returns:
|
||||
Parsed dictionary.
|
||||
"""
|
||||
if isinstance(source, (str, Path)):
|
||||
raw_content = Path(source).read_text(encoding="utf-8")
|
||||
else:
|
||||
content = source.read()
|
||||
raw_content = content.decode("utf-8") if isinstance(content, bytes) else content
|
||||
|
||||
cleaned = strip_json_comments(raw_content)
|
||||
return json.loads(cleaned)
|
||||
def load_jsonc(file: IO) -> dict:
|
||||
return json.loads(strip_json_comments(file.read()))
|
||||
@@ -11,9 +11,6 @@
|
||||
# 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.
|
||||
#
|
||||
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
|
||||
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
|
||||
|
||||
from enum import Enum
|
||||
import logging
|
||||
|
||||
+115
-117
@@ -11,33 +11,23 @@
|
||||
# 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.
|
||||
#
|
||||
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
|
||||
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
|
||||
|
||||
"""Media synthesis and trace asset management for ARTEMIS.
|
||||
|
||||
Handles chronological trace GIF generation, frame quantization, and step snapshot
|
||||
compilation while preserving active session recording manifests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from artemis.utils.logger import get_logger
|
||||
from PIL import Image
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_PREFER_FFMPEG = os.environ.get("USE_FFMPEG_GIF", "").strip().lower() in ("1", "true", "yes")
|
||||
USE_FFMPEG_GIF = os.environ.get("USE_FFMPEG_GIF", "").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
def quantize_and_save_gif_from_paths(
|
||||
@@ -46,68 +36,81 @@ def quantize_and_save_gif_from_paths(
|
||||
colors: int = 128,
|
||||
duration: int = 100,
|
||||
) -> None:
|
||||
"""Compile ordered image snapshots into a quantized animation asset."""
|
||||
"""Create an optimized GIF from image file paths.
|
||||
|
||||
By default uses PIL (loads all frames into memory).
|
||||
Set USE_FFMPEG_GIF=1 env var to use ffmpeg for memory-efficient streaming.
|
||||
|
||||
Args:
|
||||
image_paths: List of paths to image files (must be sorted in desired
|
||||
order)
|
||||
output_path: Path where the GIF will be saved
|
||||
colors: Number of colors to use in quantization (lower = smaller file)
|
||||
duration: Duration of each frame in milliseconds
|
||||
|
||||
Raises:
|
||||
ValueError: If image_paths list is empty
|
||||
RuntimeError: If ffmpeg fails (when USE_FFMPEG_GIF is enabled)
|
||||
"""
|
||||
if not image_paths:
|
||||
raise ValueError("Cannot assemble animation from an empty image sequence.")
|
||||
raise ValueError("image_paths must not be empty")
|
||||
|
||||
if _PREFER_FFMPEG and shutil.which("ffmpeg"):
|
||||
try:
|
||||
_render_gif_with_ffmpeg(image_paths, output_path, colors=colors, frame_delay_ms=duration)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.warning(f"FFmpeg GIF rendering encountered an error ({exc}); falling back to Pillow.")
|
||||
|
||||
_render_gif_with_pillow(image_paths, output_path, colors=colors, frame_delay_ms=duration)
|
||||
if USE_FFMPEG_GIF:
|
||||
_save_gif_ffmpeg(image_paths, output_path, colors, duration)
|
||||
else:
|
||||
_save_gif_pillow(image_paths, output_path, colors, duration)
|
||||
|
||||
|
||||
def _render_gif_with_pillow(
|
||||
paths: list[Path],
|
||||
def _save_gif_pillow(
|
||||
image_paths: list[Path],
|
||||
output_path: Path,
|
||||
colors: int,
|
||||
frame_delay_ms: int,
|
||||
duration: int,
|
||||
) -> None:
|
||||
"""Internal Pillow quantization loop."""
|
||||
frames: list[Image.Image] = []
|
||||
for p in paths:
|
||||
try:
|
||||
with Image.open(p) as img:
|
||||
rgb_img = img.convert("RGB") if img.mode != "RGB" else img.copy()
|
||||
frames.append(rgb_img.quantize(colors=colors, method=Image.Quantize.MEDIANCUT))
|
||||
except Exception as exc:
|
||||
logger.debug(f"Skipping damaged frame {p}: {exc}")
|
||||
"""Create GIF using PIL (loads all frames into memory)."""
|
||||
|
||||
if not frames:
|
||||
return
|
||||
def frame_generator():
|
||||
for path in image_paths:
|
||||
with Image.open(path) as img:
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
yield img.quantize(colors=colors, method=2)
|
||||
|
||||
first_frame = frames[0]
|
||||
frames = frame_generator()
|
||||
first_frame = next(frames)
|
||||
first_frame.save(
|
||||
output_path,
|
||||
save_all=True,
|
||||
append_images=frames[1:],
|
||||
append_images=frames,
|
||||
loop=0,
|
||||
optimize=True,
|
||||
duration=frame_delay_ms,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
|
||||
def _render_gif_with_ffmpeg(
|
||||
paths: list[Path],
|
||||
def _save_gif_ffmpeg(
|
||||
image_paths: list[Path],
|
||||
output_path: Path,
|
||||
colors: int,
|
||||
frame_delay_ms: int,
|
||||
duration: int,
|
||||
) -> None:
|
||||
"""Streamlined two-pass palette-generated GIF rendering via FFmpeg."""
|
||||
fps = max(1.0, 1000.0 / max(1, frame_delay_ms))
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
list_file = Path(tmpdir) / "frames.txt"
|
||||
palette = Path(tmpdir) / "palette.png"
|
||||
"""Create GIF using ffmpeg (memory-efficient streaming)."""
|
||||
fps = 1000 / duration
|
||||
|
||||
lines = [f"file '{p.resolve()}'\nduration {frame_delay_ms / 1000.0}" for p in paths]
|
||||
if paths:
|
||||
lines.append(f"file '{paths[-1].resolve()}'")
|
||||
list_file.write_text("\n".join(lines), encoding="utf-8")
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
for i, path in enumerate(image_paths):
|
||||
f.write(f"file '{path.absolute()}'\n")
|
||||
# Last file needs no duration (ffmpeg concat demuxer uses it as the final frame)
|
||||
if i < len(image_paths) - 1:
|
||||
f.write(f"duration {duration / 1000}\n")
|
||||
# Repeat last file to ensure it's included (ffmpeg concat demuxer quirk)
|
||||
f.write(f"file '{image_paths[-1].absolute()}'\n")
|
||||
concat_file = Path(f.name)
|
||||
|
||||
subprocess.run(
|
||||
palette_path = output_path.with_suffix(".palette.png")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
@@ -116,16 +119,18 @@ def _render_gif_with_ffmpeg(
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(list_file),
|
||||
str(concat_file),
|
||||
"-vf",
|
||||
f"palettegen=max_colors={min(colors, 256)}",
|
||||
str(palette),
|
||||
f"palettegen=max_colors={min(colors, 256)}:stats_mode=diff",
|
||||
str(palette_path),
|
||||
],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg palette generation failed: {result.stderr}")
|
||||
|
||||
subprocess.run(
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
@@ -134,78 +139,71 @@ def _render_gif_with_ffmpeg(
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(list_file),
|
||||
str(concat_file),
|
||||
"-i",
|
||||
str(palette),
|
||||
str(palette_path),
|
||||
"-lavfi",
|
||||
f"fps={fps},paletteuse",
|
||||
f"fps={fps},paletteuse=dither=bayer:bayer_scale=5",
|
||||
"-loop",
|
||||
"0",
|
||||
str(output_path),
|
||||
],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg GIF creation failed: {result.stderr}")
|
||||
|
||||
finally:
|
||||
concat_file.unlink(missing_ok=True)
|
||||
if palette_path.exists():
|
||||
palette_path.unlink()
|
||||
|
||||
|
||||
def create_gif_from_trace_folder(trace_folder_path: Path) -> None:
|
||||
"""Scan and synthesize sequential JPEG frames in a trace directory to trace.gif."""
|
||||
if not trace_folder_path.is_dir():
|
||||
def create_gif_from_trace_folder(trace_folder_path: Path):
|
||||
image_files: list[Path] = []
|
||||
|
||||
for file in trace_folder_path.iterdir():
|
||||
if file.suffix == ".jpeg":
|
||||
image_files.append(file)
|
||||
|
||||
image_files.sort(key=lambda f: int(f.stem))
|
||||
|
||||
logger.info(f"Found {len(image_files)} images to compile")
|
||||
|
||||
if not image_files:
|
||||
return
|
||||
|
||||
frames = sorted(
|
||||
[f for f in trace_folder_path.iterdir() if f.suffix.lower() in (".jpeg", ".jpg") and f.stem.isdigit()],
|
||||
key=lambda x: int(x.stem),
|
||||
)
|
||||
if not frames:
|
||||
return
|
||||
|
||||
target_gif = trace_folder_path / "trace.gif"
|
||||
quantize_and_save_gif_from_paths(frames, target_gif)
|
||||
logger.info(f"Assembled trace animation: {target_gif}")
|
||||
gif_path = trace_folder_path / "trace.gif"
|
||||
quantize_and_save_gif_from_paths(image_files, gif_path)
|
||||
logger.info(f"GIF created at {gif_path}")
|
||||
|
||||
|
||||
def remove_images_from_trace_folder(trace_folder_path: Path) -> None:
|
||||
"""Purge temporary individual JPEG frame captures once compiled."""
|
||||
if not trace_folder_path.is_dir():
|
||||
return
|
||||
for item in trace_folder_path.iterdir():
|
||||
if item.suffix.lower() in (".jpeg", ".jpg") and item.stem.isdigit():
|
||||
try:
|
||||
item.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
def remove_images_from_trace_folder(trace_folder_path: Path):
|
||||
for file in trace_folder_path.iterdir():
|
||||
if file.suffix == ".jpeg":
|
||||
file.unlink()
|
||||
|
||||
|
||||
def create_steps_json_from_trace_folder(trace_folder_path: Path) -> None:
|
||||
"""Collate discrete numeric step payload dumps into steps.json preserving metadata manifests."""
|
||||
if not trace_folder_path.is_dir():
|
||||
return
|
||||
def create_steps_json_from_trace_folder(trace_folder_path: Path):
|
||||
"""Compile legacy timestamp-named step files without consuming manifests."""
|
||||
steps = []
|
||||
for file in trace_folder_path.iterdir():
|
||||
if file.suffix == ".json" and file.stem.isdigit():
|
||||
with open(file, encoding="utf-8", errors="ignore") as f:
|
||||
json_content = f.read()
|
||||
steps.append({"timestamp": int(file.stem), "data": json_content})
|
||||
|
||||
numeric_step_files = sorted(
|
||||
[f for f in trace_folder_path.iterdir() if f.suffix == ".json" and f.stem.isdigit()],
|
||||
key=lambda x: int(x.stem),
|
||||
)
|
||||
steps.sort(key=lambda f: f["timestamp"])
|
||||
|
||||
records: list[dict[str, Any]] = []
|
||||
for step_file in numeric_step_files:
|
||||
try:
|
||||
records.append({
|
||||
"timestamp": int(step_file.stem),
|
||||
"data": step_file.read_text(encoding="utf-8", errors="ignore"),
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.debug(f"Unable to read step file {step_file}: {exc}")
|
||||
logger.info(f"Found {len(steps)} steps to compile")
|
||||
|
||||
output_file = trace_folder_path / "steps.json"
|
||||
output_file.write_text(json.dumps(records, ensure_ascii=False), encoding="utf-8")
|
||||
with open(trace_folder_path / "steps.json", "w", encoding="utf-8", errors="ignore") as f:
|
||||
f.write(json.dumps(steps))
|
||||
|
||||
|
||||
def remove_steps_json_from_trace_folder(trace_folder_path: Path) -> None:
|
||||
"""Clean up standalone numeric step files, leaving steps.json and recording manifests untouched."""
|
||||
if not trace_folder_path.is_dir():
|
||||
return
|
||||
for item in trace_folder_path.iterdir():
|
||||
if item.suffix == ".json" and item.stem.isdigit():
|
||||
try:
|
||||
item.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
def remove_steps_json_from_trace_folder(trace_folder_path: Path):
|
||||
"""Remove only legacy timestamp-named step files, preserving manifests."""
|
||||
for file in trace_folder_path.iterdir():
|
||||
if file.suffix == ".json" and file.stem.isdigit():
|
||||
file.unlink()
|
||||
@@ -0,0 +1,60 @@
|
||||
# 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.
|
||||
|
||||
import base64
|
||||
import time
|
||||
|
||||
from langchain_core.messages import BaseMessage
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.controllers.controller_factory import create_device_controller
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def record_interaction(ctx: ArtemisContext, response: BaseMessage):
|
||||
if not ctx.execution_setup:
|
||||
raise ValueError("No execution setup found")
|
||||
if not ctx.execution_setup.traces_path or not ctx.execution_setup.trace_name:
|
||||
raise ValueError("No traces path or trace name found")
|
||||
|
||||
logger.info("Recording interaction")
|
||||
controller = create_device_controller(ctx)
|
||||
screenshot_base64 = await controller.screenshot()
|
||||
logger.info("Screenshot taken")
|
||||
try:
|
||||
controller = create_device_controller(ctx)
|
||||
compressed_screenshot_base64 = controller.get_compressed_b64_screenshot(screenshot_base64)
|
||||
except Exception as e:
|
||||
logger.error(f"Error compressing screenshot: {e}")
|
||||
return "Could not record this interaction"
|
||||
timestamp = time.time()
|
||||
folder = ctx.execution_setup.traces_path.joinpath(ctx.execution_setup.trace_name).resolve()
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with open(
|
||||
folder.joinpath(f"{int(timestamp)}.jpeg").resolve(),
|
||||
"wb",
|
||||
) as f:
|
||||
f.write(base64.b64decode(compressed_screenshot_base64))
|
||||
|
||||
with open(
|
||||
folder.joinpath(f"{int(timestamp)}.json").resolve(),
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
f.write(response.model_dump_json())
|
||||
except Exception as e:
|
||||
logger.error(f"Error recording interaction: {e}")
|
||||
return "Screenshot recorded successfully"
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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.
|
||||
|
||||
from artemis.utils.logger import get_logger
|
||||
import requests
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def curl_from_request(req: requests.PreparedRequest) -> str:
|
||||
"""Converts a requests.PreparedRequest object to a valid cURL command string."""
|
||||
command = ["curl", f"-X {req.method}"]
|
||||
|
||||
for key, value in req.headers.items():
|
||||
command.append(f'-H "{key}: {value}"')
|
||||
|
||||
if req.body:
|
||||
body = req.body
|
||||
if isinstance(body, bytes):
|
||||
body = body.decode("utf-8")
|
||||
# Escape single quotes in the body for shell safety
|
||||
body = body.replace("'", "'\\''")
|
||||
command.append(f"-d '{body}'")
|
||||
|
||||
command.append(f"'{req.url}'")
|
||||
|
||||
return " ".join(command)
|
||||
|
||||
|
||||
def logging_hook(response, *args, **kwargs):
|
||||
"""Hook to log the request as a cURL command."""
|
||||
curl_command = curl_from_request(response.request)
|
||||
logger.debug(f"\n--- cURL Command ---\n{curl_command}\n--------------------")
|
||||
|
||||
|
||||
def get_session_with_curl_logging() -> requests.Session:
|
||||
"""Returns a requests.Session with cURL logging enabled."""
|
||||
session = requests.Session()
|
||||
session.hooks["response"] = [logging_hook]
|
||||
return session
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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.
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def run_shell_command_on_host(command: str) -> str:
|
||||
"""Helper to run a shell command on the host and return the output."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
shell=True,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Log the error and stderr for better debugging
|
||||
error_message = (
|
||||
f"Command '{command}' failed with exit code {e.returncode}.\nStderr: {e.stderr.strip()}"
|
||||
)
|
||||
raise RuntimeError(error_message) from e
|
||||
@@ -739,7 +739,7 @@ def _render_step_detailed(
|
||||
INTERNAL_SYSTEM_TOOLS = {
|
||||
"safety_net_validation",
|
||||
"safety_net_pixel_validation",
|
||||
"entity_extractor",
|
||||
"hopper",
|
||||
}
|
||||
|
||||
if interleaved:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def convert_timestamp_to_str(ts: float) -> str:
|
||||
dt = datetime.fromtimestamp(ts)
|
||||
return dt.strftime("%Y-%m-%dT%H-%M-%S")
|
||||
+132
-128
@@ -12,151 +12,155 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""UI hierarchy traversal and element geometry primitives for ARTEMIS.
|
||||
|
||||
Provides structured spatial coordinates, bounding box metrics, and recursive
|
||||
node querying across Android accessibility hierarchies and UIAutomator tree dumps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from artemis.utils.logger import get_logger
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def __find_element_by_resource_id_in_rich_hierarchy(
|
||||
hierarchy: list[dict], resource_id: str
|
||||
) -> dict | None:
|
||||
"""Retrieves all the sibling elements for a given resource ID from a nested dictionary.
|
||||
|
||||
Args:
|
||||
hierarchy (dict): The nested dictionary representing the UI hierarchy.
|
||||
resource_id (str): The resource-id to find.
|
||||
|
||||
Returns:
|
||||
list: A list of the sibling elements, or None if the resource_id is not
|
||||
found.
|
||||
"""
|
||||
if not hierarchy:
|
||||
return None
|
||||
|
||||
for child in hierarchy:
|
||||
if child.get("attributes", {}).get("resource-id") == resource_id:
|
||||
return child.get("attributes", {})
|
||||
|
||||
for child in hierarchy:
|
||||
result = __find_element_by_resource_id_in_rich_hierarchy(
|
||||
child.get("children", []), resource_id
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def text_input_is_empty(text: str | None, hint_text: str | None) -> bool:
|
||||
return not text or text == hint_text
|
||||
|
||||
|
||||
def find_element_by_resource_id(
|
||||
ui_hierarchy: list[dict],
|
||||
resource_id: str,
|
||||
index: int | None = None,
|
||||
is_rich_hierarchy: bool = False,
|
||||
) -> dict | None:
|
||||
"""Find a UI element by its resource-id in the UI hierarchy.
|
||||
|
||||
Args:
|
||||
ui_hierarchy: List of UI element dictionaries
|
||||
resource_id: The resource-id to search for (e.g.,
|
||||
"com.google.android.settings.intelligence:id/open_search_view_edit_text")
|
||||
|
||||
Returns:
|
||||
The complete UI element dictionary if found, None otherwise
|
||||
"""
|
||||
if is_rich_hierarchy:
|
||||
return __find_element_by_resource_id_in_rich_hierarchy(ui_hierarchy, resource_id)
|
||||
|
||||
target_idx = index or 0
|
||||
|
||||
def search_recursive(elements: list[dict]) -> dict | None:
|
||||
nonlocal target_idx
|
||||
for element in elements:
|
||||
if isinstance(element, dict):
|
||||
if element.get("resourceId") == resource_id:
|
||||
if target_idx == 0:
|
||||
return element
|
||||
target_idx -= 1
|
||||
continue
|
||||
|
||||
children = element.get("children", [])
|
||||
if children:
|
||||
result = search_recursive(children)
|
||||
if result:
|
||||
return result
|
||||
return None
|
||||
|
||||
return search_recursive(ui_hierarchy)
|
||||
|
||||
|
||||
def is_element_focused(element: dict) -> bool:
|
||||
return element.get("focused", None) == "true"
|
||||
|
||||
|
||||
def get_element_text(element: dict, hint_text: bool = False) -> str | None:
|
||||
if hint_text:
|
||||
return element.get("hintText", None)
|
||||
return element.get("text", None)
|
||||
|
||||
|
||||
class _CyFunctionDetectorMeta(type):
|
||||
def __instancecheck__(self, instance):
|
||||
name = type(instance).__name__
|
||||
return (
|
||||
name
|
||||
in (
|
||||
"cyfunction",
|
||||
"cython_function_or_method",
|
||||
"builtin_function_or_method",
|
||||
)
|
||||
or "cyfunction" in name.lower()
|
||||
)
|
||||
|
||||
|
||||
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
|
||||
pass
|
||||
|
||||
|
||||
class Point(BaseModel):
|
||||
"""Discrete pixel coordinate on screen."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
model_config = {"ignored_types": (CyFunctionDetector,)}
|
||||
x: int
|
||||
y: int
|
||||
|
||||
|
||||
class ElementBounds(BaseModel):
|
||||
"""Axis-aligned rectangular boundary defining an on-screen element."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
x: int = Field(description="Horizontal offset of the top-left corner in pixels.")
|
||||
y: int = Field(description="Vertical offset of the top-left corner in pixels.")
|
||||
width: int = Field(description="Horizontal extent of the bounding box.")
|
||||
height: int = Field(description="Vertical extent of the bounding box.")
|
||||
model_config = {"ignored_types": (CyFunctionDetector,)}
|
||||
x: int = Field(description="The x coordinate of the top-left corner of the element.")
|
||||
y: int = Field(description="The y coordinate of the top-left corner of the element.")
|
||||
width: int = Field(description="The width of the element.")
|
||||
height: int = Field(description="The height of the element.")
|
||||
|
||||
def get_center(self) -> Point:
|
||||
"""Compute the centroid of the element boundary."""
|
||||
return Point(x=self.x + (self.width // 2), y=self.y + (self.height // 2))
|
||||
return Point(x=self.x + self.width // 2, y=self.y + self.height // 2)
|
||||
|
||||
def get_relative_point(self, x_percent: float, y_percent: float) -> Point:
|
||||
"""Compute an internal pixel coordinate by fractional offsets [0.0, 1.0]."""
|
||||
clamped_x = max(0.0, min(1.0, float(x_percent)))
|
||||
clamped_y = max(0.0, min(1.0, float(y_percent)))
|
||||
"""Returns the coordinates of the point at x_percent of the width and y_percent
|
||||
|
||||
of the height of the element.
|
||||
|
||||
Ex if x_percent = 0.95 and y_percent = 0.95,
|
||||
the point is at the bottom right of the element:
|
||||
<------>
|
||||
| |
|
||||
| x|
|
||||
<------>
|
||||
"""
|
||||
return Point(
|
||||
x=int(self.x + (self.width * clamped_x)),
|
||||
y=int(self.y + (self.height * clamped_y)),
|
||||
x=int(self.x + self.width * x_percent),
|
||||
y=int(self.y + self.height * y_percent),
|
||||
)
|
||||
|
||||
|
||||
def get_bounds_for_element(element: dict[str, Any]) -> ElementBounds | None:
|
||||
"""Extract and validate the bounding box from a node descriptor."""
|
||||
raw_bounds = element.get("bounds")
|
||||
if not isinstance(raw_bounds, dict):
|
||||
return None
|
||||
|
||||
try:
|
||||
return ElementBounds(
|
||||
x=int(raw_bounds["x"]),
|
||||
y=int(raw_bounds["y"]),
|
||||
width=int(raw_bounds["width"]),
|
||||
height=int(raw_bounds["height"]),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
logger.error(f"Failed to validate element bounds ({raw_bounds}): {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def text_input_is_empty(text: str | None, hint_text: str | None) -> bool:
|
||||
"""Determine whether an editable field contains user-entered text."""
|
||||
if not text:
|
||||
return True
|
||||
return text == hint_text
|
||||
|
||||
|
||||
def is_element_focused(element: dict[str, Any]) -> bool:
|
||||
"""Check if the given node currently holds system focus."""
|
||||
focused_val = element.get("focused")
|
||||
if isinstance(focused_val, bool):
|
||||
return focused_val
|
||||
return str(focused_val).strip().lower() == "true"
|
||||
|
||||
|
||||
def get_element_text(element: dict[str, Any], hint_text: bool = False) -> str | None:
|
||||
"""Retrieve textual label or placeholder from an element node."""
|
||||
if hint_text:
|
||||
return element.get("hintText")
|
||||
return element.get("text")
|
||||
|
||||
|
||||
def find_element_by_resource_id(
|
||||
ui_hierarchy: list[dict[str, Any]],
|
||||
resource_id: str,
|
||||
index: int | None = None,
|
||||
is_rich_hierarchy: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Locate an element in the hierarchy tree matching the specified resource ID.
|
||||
|
||||
Supports both flat attribute mappings and rich accessibility tree hierarchies.
|
||||
"""
|
||||
if not ui_hierarchy or not resource_id:
|
||||
return None
|
||||
|
||||
if is_rich_hierarchy:
|
||||
return _search_rich_tree(ui_hierarchy, resource_id)
|
||||
|
||||
target_index = max(0, index or 0)
|
||||
current_match = 0
|
||||
|
||||
def _walk(nodes: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
nonlocal current_match
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
if node.get("resourceId") == resource_id:
|
||||
if current_match == target_index:
|
||||
return node
|
||||
current_match += 1
|
||||
|
||||
children = node.get("children")
|
||||
if isinstance(children, list) and children:
|
||||
found = _walk(children)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
return _walk(ui_hierarchy)
|
||||
|
||||
|
||||
def _search_rich_tree(
|
||||
nodes: list[dict[str, Any]], resource_id: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""Recursively traverse a rich XML attribute tree for resource ID match."""
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
attributes = node.get("attributes")
|
||||
if isinstance(attributes, dict) and attributes.get("resource-id") == resource_id:
|
||||
return attributes
|
||||
|
||||
children = node.get("children")
|
||||
if isinstance(children, list) and children:
|
||||
sub = _search_rich_tree(children, resource_id)
|
||||
if sub is not None:
|
||||
return sub
|
||||
|
||||
def get_bounds_for_element(element: dict) -> ElementBounds | None:
|
||||
bounds = element.get("bounds", None)
|
||||
if bounds:
|
||||
try:
|
||||
return ElementBounds(**bounds)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to validate bounds: {e}")
|
||||
return None
|
||||
return None
|
||||
+18
-3
@@ -11,9 +11,6 @@
|
||||
# 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.
|
||||
#
|
||||
# Portions of this file are derived from mobile-use (https://github.com/minitap-ai/mobile-use)
|
||||
# Copyright 2025-2026 Minitap, Inc. Licensed under the Apache License 2.0.
|
||||
|
||||
"""Video recording utilities for mobile devices.
|
||||
|
||||
@@ -126,6 +123,24 @@ async def await_scrcpy_first_frame(
|
||||
return max(spawned_at, time.time())
|
||||
|
||||
|
||||
class _CyFunctionDetectorMeta(type):
|
||||
def __instancecheck__(self, instance):
|
||||
name = type(instance).__name__
|
||||
return (
|
||||
name
|
||||
in (
|
||||
"cyfunction",
|
||||
"cython_function_or_method",
|
||||
"builtin_function_or_method",
|
||||
)
|
||||
or "cyfunction" in name.lower()
|
||||
)
|
||||
|
||||
|
||||
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
|
||||
pass
|
||||
|
||||
|
||||
class RecordingSession(BaseModel):
|
||||
"""Tracks an active video recording session."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user