feat: add managed accessibility helper with UIAutomator fallback
This commit is contained in:
1 parent
b86c37127d
commit
52749c7f23
53 files changed
+5935
-536
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user