feat: add managed accessibility helper with UIAutomator fallback

This commit is contained in:
somew1nd committed 2026-09-10 19:40:26 -07:00
1 parent b86c37127d
commit 52749c7f23
53 files changed
+5935 -536

No files matched your search

+64
View File
@@ -27,6 +27,7 @@ from dataclasses import dataclass
import json
import platform as sys_platform
import shutil
import subprocess
from typing import Any
from rich.console import Console
@@ -194,6 +195,66 @@ def _showcase_row() -> ExtraRow:
)
def _helper_row(results: list[ProbeResult]) -> ExtraRow | None:
"""State of the Artemis accessibility helper on the one ready, idle device.
Tasks install the helper themselves when they take a device; this row lets a
person pre-install it so the first task does not pay the install delay, and
tells them the helper is a thing they can remove.
"""
from artemis.clients.screen_client_factory import resolve_backend
from artemis.runtime.helper_manager import helper_manager
if resolve_backend().value == "uiautomator":
return None
adb = next((r for r in results if r.id == "android_adb"), None)
devices = (adb.metadata.get("devices") if adb else None) or []
ready = [str(d.get("serial")) for d in devices if d.get("state") == "device"]
if len(ready) != 1:
return None
serial = ready[0]
try:
status = helper_manager.status(serial)
except (OSError, ValueError, subprocess.SubprocessError) as exc:
return ExtraRow(
key="accessibility_helper",
title="Artemis Accessibility Helper",
status="missing",
status_markup="[dim]⚪ Unknown[/dim]",
summary="Could not read",
detail=f"{serial}: {exc}",
)
healthy = status["installed"] and status["enabled"] and not status["outdated"]
if healthy and status["reachable"]:
version = status.get("installed_version")
note = " (newer than the bundled build)" if status.get("newer_than_bundled") else ""
return ExtraRow(
key="accessibility_helper",
title="Artemis Accessibility Helper",
status="pass",
status_markup="[bold green]✔ Ready[/bold green]",
summary=f"v{version} on {serial}{note}",
detail=f"Remove any time with: artemis helper uninstall --serial {serial}",
)
if not status["installed"]:
summary, why = "Not installed", "the first task on this device will install it (about 3 s)"
elif status["outdated"]:
summary = f"Outdated (v{status['installed_version']} < v{status['bundled_version']})"
why = "the next task will upgrade it"
elif not status["enabled"]:
summary, why = "Service disabled", "the next task will try to enable it"
else:
summary, why = "Not answering", "unlock the phone or reinstall with --force"
return ExtraRow(
key="accessibility_helper",
title="Artemis Accessibility Helper",
status="missing",
status_markup="[bold yellow]○ Pending[/bold yellow]",
summary=summary,
detail=f"{why}; pre-install now: artemis helper install --serial {serial}",
)
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
@@ -373,6 +434,9 @@ def doctor_command(
"""Run diagnostics to inspect system dependencies, device connectivity, and configuration."""
results, fixes = asyncio.run(_diagnose(fix))
extras = [_npm_row(), _showcase_row()]
helper_row = _helper_row(results)
if helper_row is not None:
extras.append(helper_row)
verdict = base_verdict(results)
if json_output:
+226
View File
@@ -0,0 +1,226 @@
# 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.
"""``artemis helper``: manage the Accessibility Helper APK on attached devices.
Tasks provision the helper themselves when they take a device, so these
commands exist for the cases the automatic path deliberately leaves alone:
inspecting a device, forcing a reinstall, and removing the helper (which is
never done automatically).
"""
from __future__ import annotations
import json
from typing import Annotated
from rich.console import Console
from rich.table import Table
import typer
from artemis.runtime.device_pool import device_pool
from artemis.runtime.helper_manager import helper_manager
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
helper_app = typer.Typer(help="Install, inspect, or remove the Artemis Accessibility Helper APK.")
SerialOption = Annotated[
str | None,
typer.Option("--serial", "-s", help="Device serial. Defaults to the only ready device."),
]
def _resolve_serial(serial: str | None) -> str:
if serial:
return serial
ready = [d.serial for d in device_pool.get_ready_devices()]
if len(ready) == 1:
return ready[0]
if not ready:
typer.secho("No authorized device is attached.", fg=typer.colors.RED)
else:
typer.secho(
"Several devices are ready; pass --serial, or --all to target every idle one: "
+ ", ".join(ready),
fg=typer.colors.RED,
)
raise typer.Exit(code=1)
def _idle_ready_serials() -> list[str]:
"""Every authorized device that no Artemis task currently holds."""
return [d.serial for d in device_pool.get_ready_devices() if not d.is_busy]
def _bool(value: object) -> str:
return "[green]yes[/green]" if value else "[red]no[/red]"
@helper_app.command("status")
def helper_status(
serial: SerialOption = None,
as_json: Annotated[bool, typer.Option("--json", help="Print raw JSON.")] = False,
) -> None:
"""Show install, version, service and tunnel state of the helper on a device."""
target = _resolve_serial(serial)
status = helper_manager.status(target)
if as_json:
typer.echo(json.dumps(status, indent=2))
return
table = Table(title=f"Accessibility helper on {target}")
table.add_column("Field", style="cyan")
table.add_column("Value")
table.add_row("Installed", _bool(status["installed"]))
version = str(status["installed_version"])
if status.get("newer_than_bundled"):
version += f" [yellow](newer than the bundled v{status['bundled_version']}; not downgraded)[/yellow]"
elif status.get("outdated"):
version += f" [yellow](bundled is v{status['bundled_version']}; the next task upgrades it)[/yellow]"
table.add_row("Installed version", version)
table.add_row("Bundled version", str(status["bundled_version"]))
table.add_row("Service enabled", _bool(status["enabled"]))
tunnel = {
"session": f"this process, host port {status['forward_port']}",
"shared": f"another Artemis process, host port {status['forward_port']}",
"probe": "none (probed through a temporary forward)",
None: "none",
}[status.get("tunnel")]
table.add_row("Tunnel", tunnel)
table.add_row("Service answering", _bool(status["reachable"]))
if status.get("reachable"):
protocol = status.get("protocol_version")
proto_text = str(protocol)
if not status.get("protocol_supported"):
proto_text += " [red](too old for this host; reinstall with --force)[/red]"
table.add_row("Protocol", proto_text)
table.add_row("Session token pushed", _bool(status.get("token_set")))
table.add_row("Auto-install by tasks", _bool(status.get("auto_install", True)))
table.add_row("Transport id", str(status["transport_id"]))
Console().print(table)
def _install_one(target: str, force: bool) -> bool:
def on_event(event: str, details: dict) -> None:
if event == "installing":
typer.echo(f"{target}: installing v{details.get('version_name')}...")
elif event == "upgrading":
typer.echo(
f"{target}: upgrading v{details.get('from_version')} -> "
f"v{details.get('to_version')}..."
)
result = helper_manager.provision(target, force=force, on_event=on_event)
if result.ok:
typer.secho(
f"{target}: {result.action} (version {result.installed_version}, service enabled).",
fg=typer.colors.GREEN,
)
return True
typer.secho(f"{target}: {result.action}: {result.error}", fg=typer.colors.RED)
return False
@helper_app.command("install")
def helper_install(
serial: SerialOption = None,
force: Annotated[bool, typer.Option("--force", help="Reinstall even when up to date.")] = False,
all_devices: Annotated[
bool, typer.Option("--all", help="Every authorized device that no task is using.")
] = False,
) -> None:
"""Install or upgrade the helper to the bundled version and enable its service."""
if all_devices:
targets = _idle_ready_serials()
if not targets:
typer.secho("No idle, authorized device is attached.", fg=typer.colors.RED)
raise typer.Exit(code=1)
else:
targets = [_resolve_serial(serial)]
outcomes = [_install_one(target, force) for target in targets]
if not all(outcomes):
raise typer.Exit(code=1)
@helper_app.command("uninstall")
def helper_uninstall(
serial: SerialOption = None,
yes: Annotated[bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")] = False,
) -> None:
"""Disable the service and remove the helper package from a device."""
target = _resolve_serial(serial)
if not yes and not typer.confirm(f"Remove the Artemis accessibility helper from {target}?"):
raise typer.Exit(code=0)
if helper_manager.uninstall(target):
typer.secho(f"{target}: helper removed.", fg=typer.colors.GREEN)
return
typer.secho(f"{target}: uninstall failed (is the package installed?).", fg=typer.colors.RED)
raise typer.Exit(code=1)
@helper_app.command("parity")
def helper_parity(
serial: SerialOption = None,
rounds: Annotated[int, typer.Option("--rounds", help="Timed dumps per backend.")] = 3,
as_json: Annotated[bool, typer.Option("--json", help="Print raw JSON.")] = False,
) -> None:
"""Dump the current screen with the helper and with UIAutomator2 and compare them.
The two backends must describe the same visible screen: every labelled
UIAutomator element should have a helper element with the same label at
the same place, the helper must emit no off-screen or negative bounds, and
its dump should not be slower. Run it on a busy screen (a scrolled list, a
dialog over an app) to catch regressions the unit tests cannot see.
"""
from artemis.core.diagnostics.hierarchy_parity import compare_backends
target = _resolve_serial(serial)
report = compare_backends(target, rounds=max(1, rounds))
if as_json:
typer.echo(json.dumps(report, indent=2, ensure_ascii=False))
raise typer.Exit(code=0 if report["ok"] else 1)
table = Table(title=f"Hierarchy parity on {target}")
table.add_column("Metric", style="cyan")
table.add_column("Helper")
table.add_column("UIAutomator2")
h, u = report["helper"], report["uiautomator"]
table.add_row("Dump time, median", f"{h['median_ms']:.0f} ms", f"{u['median_ms']:.0f} ms")
table.add_row("Nodes", str(h["nodes"]), str(u["nodes"]))
table.add_row("Labelled nodes (text / desc)", str(h["labelled"]), str(u["labelled"]))
table.add_row("Negative bounds", str(h["negative_bounds"]), str(u["negative_bounds"]))
table.add_row("Off-screen bounds", str(h["offscreen_bounds"]), str(u["offscreen_bounds"]))
table.add_row("Foreground package", str(h["package"]), str(u["package"]))
Console().print(table)
m = report["match"]
typer.echo(
f"UIAutomator labels found in helper dump: {m['uiautomator_in_helper']}/{m['uiautomator_labelled']}"
f" ({m['recall']:.0%}); helper labels found in UIAutomator dump: "
f"{m['helper_in_uiautomator']}/{m['helper_labelled']} ({m['precision']:.0%})"
)
for label in m["missing_in_helper"][:10]:
typer.echo(f" helper lacks: {label}")
for label in m["extra_in_helper"][:10]:
typer.echo(f" helper adds: {label}")
for problem in report["problems"]:
typer.secho(f" ! {problem}", fg=typer.colors.RED)
typer.secho(
"PARITY OK" if report["ok"] else "PARITY FAILED",
fg=typer.colors.GREEN if report["ok"] else typer.colors.RED,
)
raise typer.Exit(code=0 if report["ok"] else 1)
__all__ = ["helper_app"]
+4
View File
@@ -19,6 +19,7 @@ from typing import Annotated
from artemis._version import __version__
from artemis.interfaces.cli.commands.batch import batch_command
from artemis.interfaces.cli.commands.doctor import doctor_command
from artemis.interfaces.cli.commands.helper import helper_app
from artemis.interfaces.cli.commands.init import init_command
from artemis.interfaces.cli.commands.mcp import mcp_command
from artemis.interfaces.cli.commands.run import run_command
@@ -63,6 +64,9 @@ app.command(name="batch", help="Execute a batch sequence of automation tasks.")(
app.command(name="mcp", help="Start the Artemis Model Context Protocol (MCP) server.")(mcp_command)
app.add_typer(server_app, name="server", help="Cloud Run proxy and web dashboard server.")
app.add_typer(trace_app, name="trace", help="Inspect and query execution traces.")
app.add_typer(
helper_app, name="helper", help="Manage the Accessibility Helper APK on attached devices."
)
def version_callback(value: bool):