From c6421d1d8d851cfb5644c43dfe87083fffef10fc Mon Sep 17 00:00:00 2001 From: yuan0818 Date: Wed, 30 Sep 2026 10:49:05 +0800 Subject: [PATCH 1/3] feat(android): add continuous multi-touch and long-press drag gestures --- artemis/agents/operator/operator.py | 14 + artemis/agents/operator/prompts.py | 1 + artemis/agents/validator/execution_loop.py | 4 +- artemis/agents/validator/tool_declarations.py | 3 + artemis/clients/accessibility_client.py | 45 +++ artemis/controllers/unified_controller.py | 10 + artemis/drivers/android/adb_driver.py | 24 ++ artemis/drivers/mock/mock_driver.py | 16 + artemis/mcp/action_executor.py | 14 + artemis/mcp/action_manifest.py | 1 + artemis/mcp/action_names.py | 6 + artemis/mcp/action_specs.py | 59 +++- artemis/mcp/actuators/adb.py | 26 ++ artemis/mcp/actuators/base.py | 2 + artemis/mcp/gestures.py | 113 +++++++ .../artemis-accessibility-helper/.gitignore | 1 + .../ArtemisAccessibilityHelper.apk | Bin 37353 -> 41449 bytes .../app/build.gradle.kts | 4 +- .../com/artemis/helper/CommandServer.java | 8 + .../com/artemis/helper/ContinuousGesture.java | 287 ++++++++++++++++++ .../com/artemis/helper/GestureController.java | 9 + .../com/artemis/helper/LongPressDrag.java | 62 ++++ .../helper_manifest.json | 8 +- 23 files changed, 709 insertions(+), 8 deletions(-) create mode 100644 artemis/mcp/gestures.py create mode 100644 packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ContinuousGesture.java create mode 100644 packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/LongPressDrag.java diff --git a/artemis/agents/operator/operator.py b/artemis/agents/operator/operator.py index d2453627..f8985209 100644 --- a/artemis/agents/operator/operator.py +++ b/artemis/agents/operator/operator.py @@ -1252,6 +1252,20 @@ def target_fields(el: dict) -> dict: ) return actions, None + elif tool_name == "perform_gesture": + from artemis.mcp.gestures import validate_phases + + try: + phases = validate_phases(args.get("phases")) + description = args.get("target_description") + if not isinstance(description, str) or not description.strip(): + raise ValueError("target_description is required for a gesture") + except (ValueError, TypeError) as exc: + return [], f"Invalid gesture: {exc}" + return [ + {"action": "perform_gesture", "phases": phases, "target_description": description} + ], None + elif tool_name == "swipe": kind, target, parsed_duration = parse_swipe_parameters(args, default_duration=None) duration = args.get("duration") diff --git a/artemis/agents/operator/prompts.py b/artemis/agents/operator/prompts.py index e800de23..19c08d50 100644 --- a/artemis/agents/operator/prompts.py +++ b/artemis/agents/operator/prompts.py @@ -83,6 +83,7 @@ def load_operator_prompts() -> dict[str, str]: _TURN_ENDING_ORDER = ( "click", "swipe", + "perform_gesture", "input_text", "long_press", "press_key", diff --git a/artemis/agents/validator/execution_loop.py b/artemis/agents/validator/execution_loop.py index cd3b7a4a..f868dfa5 100644 --- a/artemis/agents/validator/execution_loop.py +++ b/artemis/agents/validator/execution_loop.py @@ -205,7 +205,9 @@ async def _process_action( if burst: max_local_retries = 1 else: - max_local_retries = 1 if action_name == "launch_app" else 2 + # A timed-out gesture may already be dispatched; never replay it. + # Keep the existing retry policy for every other action. + max_local_retries = 1 if action_name in ("launch_app", "perform_gesture") else 2 outcome.success, outcome.error_msg = await _attempt_local_execution( node, session, diff --git a/artemis/agents/validator/tool_declarations.py b/artemis/agents/validator/tool_declarations.py index fba8dd75..a8aed2c9 100644 --- a/artemis/agents/validator/tool_declarations.py +++ b/artemis/agents/validator/tool_declarations.py @@ -238,6 +238,8 @@ def normalize_coordinate_target(target: Any) -> int | list[int] | Any: SWIPE_TOOL = tool_declaration("swipe") +PERFORM_GESTURE_TOOL = tool_declaration("perform_gesture") + PRESS_KEY_TOOL = tool_declaration("press_key") READ_NOTE_TOOL = ToolDeclaration( @@ -324,6 +326,7 @@ def normalize_coordinate_target(target: Any) -> int | list[int] | Any: LONG_PRESS_TOOL, INPUT_TEXT_TOOL, SWIPE_TOOL, + PERFORM_GESTURE_TOOL, PRESS_KEY_TOOL, READ_NOTE_TOOL, LIST_NOTES_TOOL, diff --git a/artemis/clients/accessibility_client.py b/artemis/clients/accessibility_client.py index f14b8ab3..7df5aa35 100644 --- a/artemis/clients/accessibility_client.py +++ b/artemis/clients/accessibility_client.py @@ -413,6 +413,51 @@ def press_key(self, key: str) -> bool: def tap(self, x: float, y: float) -> bool: return self._rpc("tap", {"x": x, "y": y}) + def perform_gesture(self, phases: list[dict], request_id: str) -> dict[str, Any]: + from artemis.mcp.gestures import gesture_duration_bound_ms, validate_phases + + phases = validate_phases(phases) + info = json.loads(self._http("/ping").decode("utf-8")) + if "perform_gesture" not in info.get("capabilities", []): + return { + "success": False, + "status": "unsupported", + "error": "Upgrade Accessibility Helper: perform_gesture is not supported", + } + long_press_drag = phases[0].get("kind") == "long_press_drag" + if long_press_drag and "gesture_long_press_drag" not in info.get("capabilities", []): + return { + "success": False, + "status": "unsupported", + "error": "Upgrade Accessibility Helper: long-press dragging is not supported", + } + if any( + "control_points" in phase + or any("control_points" in p for p in phase.get("pointers", [])) + for phase in phases + ) and ("gesture_cubic_bezier" not in info.get("capabilities", [])): + return { + "success": False, + "status": "unsupported", + "error": "Upgrade Accessibility Helper: native cubic Bezier is not supported", + } + if (len(phases) > 1 or long_press_drag) and not info.get("gesture_continuation", False): + return { + "success": False, + "status": "unsupported", + "error": "Continuous gestures require Android 8.0+", + } + timeout = gesture_duration_bound_ms(phases) / 1000 + 10 + payload = {"cmd": "perform_gesture", "request_id": request_id, "phases": phases} + return json.loads(self._http("/action", payload, timeout=timeout).decode("utf-8")) + + def cancel_gesture(self, request_id: str) -> dict[str, Any]: + return json.loads( + self._http("/action", {"cmd": "cancel_gesture", "request_id": request_id}).decode( + "utf-8" + ) + ) + def swipe(self, x1: float, y1: float, x2: float, y2: float, duration_ms: int = 300) -> bool: return self._rpc("swipe", {"x1": x1, "y1": y1, "x2": x2, "y2": y2, "duration": duration_ms}) diff --git a/artemis/controllers/unified_controller.py b/artemis/controllers/unified_controller.py index d0dc1d03..2c14d49f 100644 --- a/artemis/controllers/unified_controller.py +++ b/artemis/controllers/unified_controller.py @@ -56,6 +56,16 @@ def __init__(self, ctx: ArtemisContext): super().__init__(ctx, get_driver(ctx)) self._segment_cache: dict[tuple[str, int, float, float], VideoRecordingResult] = {} + async def perform_gesture(self, phases: list[dict]) -> dict: + method = getattr(self._driver, "perform_gesture", None) + if method is None: + return { + "success": False, + "status": "unsupported", + "error": "Driver has no continuous multi-touch support", + } + return await method(phases) + @staticmethod async def _spawn_scrcpy(command: list[str]) -> asyncio.subprocess.Process: kwargs: dict[str, Any] = { diff --git a/artemis/drivers/android/adb_driver.py b/artemis/drivers/android/adb_driver.py index 360aa486..a8ec1ee9 100644 --- a/artemis/drivers/android/adb_driver.py +++ b/artemis/drivers/android/adb_driver.py @@ -254,6 +254,30 @@ async def tap( 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 perform_gesture(self, phases: list[dict]) -> dict: + from artemis.clients.accessibility_client import AccessibilityClient + from artemis.mcp.gestures import validate_phases + from uuid import uuid4 + + phases = validate_phases(phases) + client = AccessibilityClient(self.device_id, provision_on_connect=False) + request_id = uuid4().hex + task = asyncio.create_task(asyncio.to_thread(client.perform_gesture, phases, request_id)) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + # The action request is never replayed. Ask the device to finish the current + # phase and lift its held contacts, then wait for the terminal reply. + try: + await asyncio.shield(asyncio.to_thread(client.cancel_gesture, request_id)) + await asyncio.wait_for(asyncio.shield(task), timeout=10.0) + except Exception: + logger.warning( + "Gesture cancellation could not be confirmed; device needs reconciliation" + ) + task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None) + raise + async def swipe( self, start_x: int, diff --git a/artemis/drivers/mock/mock_driver.py b/artemis/drivers/mock/mock_driver.py index c4b12c44..b46c3336 100644 --- a/artemis/drivers/mock/mock_driver.py +++ b/artemis/drivers/mock/mock_driver.py @@ -106,6 +106,22 @@ async def long_press(self, x: int, y: int, duration_ms: int = 1000) -> bool: ) return True + async def perform_gesture(self, phases: list[dict]) -> dict: + from artemis.mcp.gestures import validate_phases + + phases = validate_phases(phases) + self.action_history.append({"action": "perform_gesture", "phases": phases}) + return { + "success": True, + "status": "completed", + "release_confirmed": True, + "phases_completed": ( + 2 + int(phases[0].get("release_delay_ms", 0) > 0) + if phases[0].get("kind") == "long_press_drag" + else len(phases) + ), + } + async def swipe( self, start_x: int, diff --git a/artemis/mcp/action_executor.py b/artemis/mcp/action_executor.py index 79b399cd..502d56d0 100644 --- a/artemis/mcp/action_executor.py +++ b/artemis/mcp/action_executor.py @@ -351,6 +351,20 @@ def _translate( recorded, ) + if raw_name == "perform_gesture": + from artemis.mcp.gestures import validate_phases + + try: + phases = validate_phases(args.get("phases")) + except (ValueError, TypeError) as exc: + raise _ArgError(f"Invalid gesture: {exc}") from exc + return ( + "perform_gesture", + {"phases": phases}, + None, + self._require_description(args, raw_name), + ) + if raw_name == "swipe": return self._translate_swipe(args, state, index_elements=index_elements) diff --git a/artemis/mcp/action_manifest.py b/artemis/mcp/action_manifest.py index 735024a8..0e808fa6 100644 --- a/artemis/mcp/action_manifest.py +++ b/artemis/mcp/action_manifest.py @@ -79,6 +79,7 @@ "long_press", "input_text", "swipe", + "perform_gesture", "press_key", "manage_app", "wait_for_delay", diff --git a/artemis/mcp/action_names.py b/artemis/mcp/action_names.py index 78cef0a5..dcc76d6f 100644 --- a/artemis/mcp/action_names.py +++ b/artemis/mcp/action_names.py @@ -35,6 +35,7 @@ "tap": "click", "long_press_on": "long_press", "swipe": "swipe", + "perform_gesture": "perform_gesture", "focus_and_input_text": "input_text", "focus_and_clear_text": "focus_and_clear_text", "erase_one_char": "erase_one_char", @@ -106,6 +107,11 @@ def to_canonical_call( "duration_ms": action_item.get("duration", 1000), } + if verb == "perform_gesture": + from artemis.mcp.gestures import validate_phases + + return "perform_gesture", {"phases": validate_phases(action_item.get("phases"))} + if verb == "swipe": coords = action_item.get("normalized_coordinates") if not (isinstance(coords, (list, tuple)) and len(coords) == 4): diff --git a/artemis/mcp/action_specs.py b/artemis/mcp/action_specs.py index 1ea69a11..7011abe5 100644 --- a/artemis/mcp/action_specs.py +++ b/artemis/mcp/action_specs.py @@ -64,7 +64,9 @@ from langchain_core.tools import StructuredTool from mcp.types import CallToolResult -from pydantic import Field, create_model +from pydantic import BaseModel, Field, create_model + +from artemis.mcp.gestures import GestureInput, validate_phases from artemis.core.tool_declaration import ToolDeclaration from artemis.mcp.action_types import ActionResult @@ -86,6 +88,21 @@ ] +GESTURE_DESCRIPTION = ( + "[ACTION] Perform continuous touch gestures that a normal swipe cannot express: " + "simultaneous multi-finger input, continuous holds and drags, or delayed release. " + "Coordinates use 0-1000 screen space. In explicit phases, pointers move together; " + "a one-point path holds still. Keep the same pointer IDs and join endpoints across " + "phases to maintain contact until the final release. Optional control_points " + "define a cubic Bezier path with two endpoints and two controls. " + "Alternatively, provide one long_press_drag entry with start and end coordinates " + "and optional control_points. The endpoint is freely chosen, including inside the screen. " + "duration_ms is movement time; " + "release_delay_ms is the endpoint hold before lifting (zero releases immediately). " + "Observe the screen afterward to verify the intended effect." +) + + # --- Spec structure ------------------------------------------------------------------ @@ -206,6 +223,10 @@ async def _wire_input_text(actuator: Any, a: dict[str, Any]) -> ActionResult: return await actuator.input_text(a["text"], norm, clear_exist=a["clear_exist"]) +async def _wire_perform_gesture(actuator: Any, a: dict[str, Any]) -> ActionResult: + return await actuator.perform_gesture(validate_phases(a["phases"])) + + async def _wire_swipe(actuator: Any, a: dict[str, Any]) -> ActionResult: start, end = a["start"], a["end"] return await actuator.swipe( @@ -463,6 +484,27 @@ async def _wire_focus_and_clear_text(actuator: Any, a: dict[str, Any]) -> Action " the wire; required only for coordinate targets." ), ), + ActionSpec( + name="perform_gesture", + operator=OperatorDialect( + description=GESTURE_DESCRIPTION + + " target_description states the intended surface/objects and gesture purpose, not a claimed observation.", + params=( + ParamSpec( + "phases", + list[GestureInput], + "Explicit continuous phases or a single native long_press_drag object.", + ), + ParamSpec("target_description", str, "Target surface/objects and gesture purpose."), + ), + ), + wire=WireDialect( + description=GESTURE_DESCRIPTION, + params=(ParamSpec("phases", list[GestureInput]),), + bind=_wire_perform_gesture, + ), + differences="target_description is recorded by the agent and is not sent to the helper.", + ), ActionSpec( name="swipe", operator=OperatorDialect( @@ -686,6 +728,7 @@ async def _wire_focus_and_clear_text(actuator: Any, a: dict[str, Any]) -> Action "click", "input_text", "swipe", + "perform_gesture", "press_key", "manage_app", "wait_for_delay", @@ -765,6 +808,20 @@ def _json_schema(annotation: Any) -> dict[str, Any]: ``required`` flag says so); they never appear in the projected schema, so the declaration carries no ``null`` alternatives or ``default`` noise. """ + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + schema = annotation.model_json_schema() + definitions = schema.get("$defs", {}) + + def inline(value): + if isinstance(value, list): + return [inline(v) for v in value] + if not isinstance(value, dict): + return value + if "$ref" in value: + return inline(definitions[value["$ref"].split("/")[-1]]) + return {k: inline(v) for k, v in value.items() if k not in ("$defs", "title")} + + return inline(schema) origin = get_origin(annotation) if origin in (Union, types.UnionType): members = [a for a in get_args(annotation) if a is not type(None)] diff --git a/artemis/mcp/actuators/adb.py b/artemis/mcp/actuators/adb.py index 4580e436..85fe3f94 100644 --- a/artemis/mcp/actuators/adb.py +++ b/artemis/mcp/actuators/adb.py @@ -186,6 +186,32 @@ async def long_press(self, nx: int, ny: int, duration_ms: int = 1000) -> ActionR duration_ms=duration_ms, ) + async def perform_gesture(self, phases: list[dict]) -> ActionResult: + from artemis.mcp.gestures import validate_phases + + phases = validate_phases(phases) + result = await self.controller.perform_gesture(phases) + if not ( + result.get("success") is True + and result.get("status") == "completed" + and result.get("release_confirmed") is True + ): + return ActionResult.failure( + "perform_gesture", + f"Gesture {result.get('status', 'failed')}: {result.get('error', 'input not completed')}", + code={ + "unsupported": ActionCode.UNSUPPORTED, + "invalid_arguments": ActionCode.INVALID_ARGS, + "timeout": ActionCode.TIMEOUT, + }.get(result.get("status"), ActionCode.DEVICE_ERROR), + detail=str(result), + ) + return ActionResult.success( + "perform_gesture", + "Gesture completed. Observe the screen to verify the intended effect.", + detail=str(result), + ) + async def input_text( self, text: str, diff --git a/artemis/mcp/actuators/base.py b/artemis/mcp/actuators/base.py index 1dd49553..f2b03576 100644 --- a/artemis/mcp/actuators/base.py +++ b/artemis/mcp/actuators/base.py @@ -75,6 +75,8 @@ async def input_text( clear_exist: bool = True, ) -> ActionResult: ... + async def perform_gesture(self, phases: list[dict]) -> ActionResult: ... + async def swipe( self, start: tuple[int, int], diff --git a/artemis/mcp/gestures.py b/artemis/mcp/gestures.py new file mode 100644 index 00000000..eee09e93 --- /dev/null +++ b/artemis/mcp/gestures.py @@ -0,0 +1,113 @@ +# 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. + +"""Validated, executor-neutral continuous touch plans (0..1000 coordinates).""" + +from typing import Annotated, Any, Literal +from pydantic import BaseModel, ConfigDict, Field, StrictInt, model_validator + +Coordinate = Annotated[StrictInt, Field(ge=0, le=1000)] +Point = Annotated[list[Coordinate], Field(min_length=2, max_length=2)] + + +class GesturePointer(BaseModel): + model_config = ConfigDict(extra="forbid") + id: Annotated[StrictInt, Field(ge=0, le=9)] + path: Annotated[list[Point], Field(min_length=1, max_length=128)] + control_points: Annotated[list[Point], Field(min_length=2, max_length=2)] | None = Field( + default=None, + description="Two cubic Bezier control points. When supplied, path must contain only start and end; Android builds the curve natively.", + ) + + @model_validator(mode="after") + def curve_endpoints(self): + if self.control_points is not None and len(self.path) != 2: + raise ValueError("A cubic Bezier path requires exactly start and end") + return self + + +class GesturePhase(BaseModel): + model_config = ConfigDict(extra="forbid") + duration_ms: Annotated[StrictInt, Field(ge=1, le=5000)] + pointers: Annotated[list[GesturePointer], Field(min_length=1, max_length=10)] + + +class LongPressDrag(BaseModel): + """Hold, move to a caller-selected endpoint, optionally dwell, then release.""" + + model_config = ConfigDict(extra="forbid") + kind: Literal["long_press_drag"] + start: Point + end: Point + control_points: Annotated[list[Point], Field(min_length=2, max_length=2)] | None = Field( + default=None, + description="Two cubic Bezier controls between start and end. Omit for a straight path.", + ) + duration_ms: Annotated[StrictInt, Field(ge=1, le=5000)] = 800 + hold_ms: Annotated[StrictInt, Field(ge=1, le=5000)] | None = Field( + default=None, description="Omit to use the device long-press timeout plus 150ms." + ) + release_delay_ms: Annotated[StrictInt, Field(ge=0, le=5000)] = Field( + default=0, + description="Keep the same finger down at the endpoint for this long, then lift. Zero lifts immediately after moving; independent of movement duration.", + ) + + +GestureInput = GesturePhase | LongPressDrag + + +class GesturePlan(BaseModel): + model_config = ConfigDict(extra="forbid") + phases: Annotated[list[GestureInput], Field(min_length=1, max_length=32)] + + @model_validator(mode="after") + def continuous(self): + if any(isinstance(p, LongPressDrag) for p in self.phases): + if len(self.phases) != 1: + raise ValueError( + "long_press_drag must be the only entry; it includes hold, move and release" + ) + return self + if sum(p.duration_ms for p in self.phases) > 30000: + raise ValueError("Total gesture duration must not exceed 30000ms") + ends = None + for phase in self.phases: + ids = [p.id for p in phase.pointers] + if len(ids) != len(set(ids)): + raise ValueError("Pointer IDs must be unique within a phase") + if ends is not None: + if set(ids) != set(ends): + raise ValueError("Keep the same pointer IDs across all phases") + if any(p.path[0] != ends[p.id] for p in phase.pointers): + raise ValueError("Each continuation must start at the previous endpoint") + ends = {p.id: p.path[-1] for p in phase.pointers} + return self + + +def validate_phases(phases: Any) -> list[dict]: + """Validate before dispatch; never coerce strings/floats/bools into coordinates.""" + return GesturePlan(phases=phases).model_dump(exclude_none=True)["phases"] + + +def gesture_duration_bound_ms(phases: list[dict]) -> int: + """Bound transport timeout including APK-resolved hold and endpoint dwell.""" + return sum( + p["duration_ms"] + + ( + p.get("hold_ms", 5000) + p.get("release_delay_ms", 0) + if p.get("kind") == "long_press_drag" + else 0 + ) + for p in phases + ) diff --git a/packages/artemis-accessibility-helper/.gitignore b/packages/artemis-accessibility-helper/.gitignore index afc00f55..7e1f8575 100644 --- a/packages/artemis-accessibility-helper/.gitignore +++ b/packages/artemis-accessibility-helper/.gitignore @@ -3,3 +3,4 @@ build/ .gradle/ *.class .DS_Store +local.properties diff --git a/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk b/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk index 16f4480091f821f104423e2bb2a2a7e249f075af..be68bfbfb2ca95ee475c6083c9b2fb073033019d 100644 GIT binary patch delta 33131 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 delta 27931 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 diff --git a/packages/artemis-accessibility-helper/app/build.gradle.kts b/packages/artemis-accessibility-helper/app/build.gradle.kts index ad802d61..5babf999 100644 --- a/packages/artemis-accessibility-helper/app/build.gradle.kts +++ b/packages/artemis-accessibility-helper/app/build.gradle.kts @@ -10,8 +10,8 @@ android { applicationId = "com.artemis.helper" minSdk = 24 targetSdk = 35 - versionCode = 6 - versionName = "1.2.0" + versionCode = 10 + versionName = "1.3.3" } buildTypes { diff --git a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java index 328f7174..ed0fcfa0 100644 --- a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java +++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java @@ -338,6 +338,10 @@ private JSONObject executeCommand(String cmd, JSONObject params) { resp.put("success", GestureController.longPress(service, lpx, lpy, lpDuration, 2500L)); } break; + case "perform_gesture": + return ContinuousGesture.execute(service, params); + case "cancel_gesture": + return ContinuousGesture.cancel(params.optString("request_id", "")); case "swipe": float x1 = (float) params.optDouble("x1", -1.0); float y1 = (float) params.optDouble("y1", -1.0); @@ -389,6 +393,10 @@ private JSONObject buildPing(boolean authed) throws Exception { r.put("version_name", service.getVersionName()); r.put("protocol_version", ArtemisAccessibilityService.PROTOCOL_VERSION); r.put("port", port); + r.put("capabilities", new org.json.JSONArray().put("perform_gesture") + .put("gesture_cubic_bezier").put("gesture_long_press_drag")); + r.put("gesture_continuation", android.os.Build.VERSION.SDK_INT >= 26); + r.put("gesture_max_pointers", Math.min(10, android.accessibilityservice.GestureDescription.getMaxStrokeCount())); r.put("auth_required", true); r.put("token_set", TokenStore.isSet()); r.put("authenticated", authed); diff --git a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ContinuousGesture.java b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ContinuousGesture.java new file mode 100644 index 00000000..fcab9ffc --- /dev/null +++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ContinuousGesture.java @@ -0,0 +1,287 @@ +package com.artemis.helper; + +import android.accessibilityservice.AccessibilityService; +import android.accessibilityservice.GestureDescription; +import android.graphics.Path; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.util.DisplayMetrics; +import android.view.WindowManager; +import android.view.ViewConfiguration; +import org.json.JSONArray; +import org.json.JSONObject; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.TreeMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** One bounded transaction owns every contact until the final phase lifts it. */ +public final class ContinuousGesture { + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + private static final LinkedHashSet CANCELLED = new LinkedHashSet<>(); + private static ContinuousGesture active; + private final AccessibilityService service; + private final String requestId; + private final List phases; + private final CountDownLatch done = new CountDownLatch(1); + private volatile boolean cancelRequested; + private volatile JSONObject result; + private volatile int completed; + private boolean dispatched; + private boolean releasing; + + static final class Phase { + final long duration; + final TreeMap paths; + final TreeMap ends; + GestureDescription.StrokeDescription[] strokes; + GestureDescription gesture; + Phase(long duration, TreeMap paths, TreeMap ends) { + this.duration = duration; this.paths = paths; this.ends = ends; + } + } + + private ContinuousGesture(AccessibilityService service, String requestId, List phases) { + this.service = service; this.requestId = requestId; this.phases = phases; + } + + static JSONObject reply(String id, String status, int count, boolean released, String error) { + JSONObject result = new JSONObject(); + try { + result.put("request_id", id); + result.put("success", "completed".equals(status)); + result.put("status", status); + result.put("phases_completed", count); + result.put("release_confirmed", released); + if (error != null) result.put("error", error); + } catch (Exception impossible) { throw new IllegalStateException(impossible); } + return result; + } + + private static int integer(Object value, int min, int max) { + if (!(value instanceof Integer || value instanceof Long)) + throw new IllegalArgumentException("Expected an integer"); + long n = ((Number) value).longValue(); + if (n < min || n > max) throw new IllegalArgumentException("Integer outside allowed range"); + return (int) n; + } + + // Validate the ENTIRE plan before building or dispatching any input. + static List parse(JSONArray input, int width, int height) throws Exception { + if (width < 1 || height < 1 || input == null || input.length() < 1 || input.length() > 32) + throw new IllegalArgumentException("Invalid display or phase count"); + if ("long_press_drag".equals(input.getJSONObject(0).optString("kind"))) { + if (input.length() != 1) + throw new IllegalArgumentException("long_press_drag must be the only entry"); + input = LongPressDrag.expand(input.getJSONObject(0), + ViewConfiguration.getLongPressTimeout()); + } + if (input.length() > 1 && Build.VERSION.SDK_INT < 26) + throw new UnsupportedOperationException("Continuous gestures require Android 8.0+"); + List phases = new ArrayList<>(); + TreeMap previousEnds = null; + long total = 0; + for (int i = 0; i < input.length(); i++) { + JSONObject raw = input.getJSONObject(i); + if (raw.length() != 2) throw new IllegalArgumentException("Unknown phase fields"); + int duration = integer(raw.get("duration_ms"), 1, 5000); + total += duration; + if (total > 30000 || total > GestureDescription.getMaxGestureDuration()) + throw new IllegalArgumentException("Gesture exceeds duration limit"); + JSONArray pointers = raw.getJSONArray("pointers"); + if (pointers.length() < 1 || pointers.length() > Math.min(10, GestureDescription.getMaxStrokeCount())) + throw new IllegalArgumentException("Invalid pointer count"); + TreeMap paths = new TreeMap<>(); + TreeMap ends = new TreeMap<>(); + TreeMap normalizedEnds = new TreeMap<>(); + for (int j = 0; j < pointers.length(); j++) { + JSONObject pointer = pointers.getJSONObject(j); + boolean curved = pointer.has("control_points"); + if (pointer.length() != (curved ? 3 : 2)) + throw new IllegalArgumentException("Unknown pointer fields"); + int id = integer(pointer.get("id"), 0, 9); + if (paths.containsKey(id)) throw new IllegalArgumentException("Duplicate pointer ID"); + JSONArray points = pointer.getJSONArray("path"); + if (points.length() < 1 || points.length() > 128) + throw new IllegalArgumentException("Invalid path length"); + float[] controls = null; + if (curved) { + JSONArray rawControls = pointer.getJSONArray("control_points"); + if (points.length() != 2 || rawControls.length() != 2) + throw new IllegalArgumentException("Cubic Bezier requires two endpoints and two control points"); + controls = new float[4]; + for (int c = 0; c < 2; c++) { + JSONArray point = rawControls.getJSONArray(c); + if (point.length() != 2) throw new IllegalArgumentException("Expected control [x,y]"); + controls[c * 2] = integer(point.get(0), 0, 1000) * (width - 1) / 1000f; + controls[c * 2 + 1] = integer(point.get(1), 0, 1000) * (height - 1) / 1000f; + } + } + Path path = new Path(); + for (int k = 0; k < points.length(); k++) { + JSONArray point = points.getJSONArray(k); + if (point.length() != 2) throw new IllegalArgumentException("Expected [x,y]"); + int nx = integer(point.get(0), 0, 1000); + int ny = integer(point.get(1), 0, 1000); + String normalized = nx + "," + ny; + if (k == 0 && previousEnds != null && !normalized.equals(previousEnds.get(id))) + throw new IllegalArgumentException("Discontinuous pointer path"); + float x = nx * (width - 1) / 1000f, y = ny * (height - 1) / 1000f; + if (k == 0) path.moveTo(x, y); + else if (curved) path.cubicTo(controls[0], controls[1], controls[2], controls[3], x, y); + else path.lineTo(x, y); + ends.put(id, new float[]{x, y}); + normalizedEnds.put(id, normalized); + } + paths.put(id, path); + } + if (previousEnds != null && !previousEnds.keySet().equals(paths.keySet())) + throw new IllegalArgumentException("Keep the same pointer IDs across phases"); + previousEnds = normalizedEnds; + phases.add(new Phase(duration, paths, ends)); + } + GestureDescription.StrokeDescription[] prior = null; + for (int i = 0; i < phases.size(); i++) { + Phase phase = phases.get(i); + boolean more = i + 1 < phases.size(); + phase.strokes = new GestureDescription.StrokeDescription[phase.paths.size()]; + GestureDescription.Builder builder = new GestureDescription.Builder(); + int j = 0; + for (Path path : phase.paths.values()) { + GestureDescription.StrokeDescription stroke; + if (prior != null) stroke = prior[j].continueStroke(path, 0, phase.duration, more); + else if (Build.VERSION.SDK_INT >= 26) stroke = new GestureDescription.StrokeDescription(path, 0, phase.duration, more); + else stroke = new GestureDescription.StrokeDescription(path, 0, phase.duration); + phase.strokes[j++] = stroke; + builder.addStroke(stroke); + } + phase.gesture = builder.build(); + prior = phase.strokes; + } + return phases; + } + + public static JSONObject execute(AccessibilityService service, JSONObject params) { + String id = params.optString("request_id", ""); + if (!id.matches("[A-Za-z0-9_-]{1,64}")) + return reply(id, "invalid_arguments", 0, true, "A bounded request_id is required"); + if (Looper.myLooper() == Looper.getMainLooper()) + return reply(id, "rejected", 0, true, "Call from a worker thread"); + final ContinuousGesture run; + try { + DisplayMetrics metrics = new DisplayMetrics(); + ((WindowManager) service.getSystemService(AccessibilityService.WINDOW_SERVICE)) + .getDefaultDisplay().getRealMetrics(metrics); + run = new ContinuousGesture(service, id, + parse(params.getJSONArray("phases"), metrics.widthPixels, metrics.heightPixels)); + } catch (UnsupportedOperationException e) { + return reply(id, "unsupported", 0, true, e.getMessage()); + } catch (Exception e) { + return reply(id, "invalid_arguments", 0, true, e.getMessage()); + } + synchronized (ContinuousGesture.class) { + if (CANCELLED.remove(id)) return reply(id, "cancelled", 0, true, "Cancelled before dispatch"); + if (!GestureController.INPUT_BUSY.compareAndSet(false, true)) + return reply(id, "busy", 0, true, "Another gesture is active or needs recovery"); + active = run; + } + long total = 0; + for (Phase p : run.phases) total += p.duration; + MAIN.post(() -> run.next()); + try { + if (run.done.await(total + 5000, TimeUnit.MILLISECONDS)) return run.result; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + run.cancelRequested = true; + // Do not unlock or replay: late callbacks still own and release held contacts. + return reply(id, "timeout", run.completed, false, + "Gesture outcome unknown; cancellation requested. Reconcile device before reuse."); + } + + public static synchronized JSONObject cancel(String id) { + if (id == null || !id.matches("[A-Za-z0-9_-]{1,64}")) + return reply(id, "invalid_arguments", 0, true, "Invalid request_id"); + if (active != null && active.requestId.equals(id)) { + active.cancelRequested = true; + return reply(id, "cancelling", active.completed, false, "Will release after the current bounded phase"); + } + // Covers cancellation racing the host's in-flight capability check / request. + CANCELLED.add(id); + while (CANCELLED.size() > 128) CANCELLED.remove(CANCELLED.iterator().next()); + return reply(id, "cancelled", 0, true, "No matching active gesture"); + } + + private void finish(String status, boolean released, String error) { + if (result != null) return; + result = reply(requestId, status, completed, released, error); + synchronized (ContinuousGesture.class) { + if (active == this) active = null; + } + // Unknown held contacts fence all further injected gestures until recovery. + if (released || !dispatched) GestureController.INPUT_BUSY.set(false); + done.countDown(); + } + + private void next() { + if (result != null) return; + if (cancelRequested) { release("cancelled"); return; } + if (completed == phases.size()) { finish("completed", true, null); return; } + Phase phase = phases.get(completed); + final long phaseEndsAt = SystemClock.uptimeMillis() + phase.duration; + try { + boolean accepted = service.dispatchGesture(phase.gesture, + new AccessibilityService.GestureResultCallback() { + @Override public void onCompleted(GestureDescription ignored) { + completed++; + if (completed == phases.size()) + finish(cancelRequested ? "cancelled" : "completed", true, + cancelRequested ? "Cancellation requested" : null); + else { + // Android completes a stationary continued stroke after DOWN: + // duplicate MOVE events are omitted and there is no UP yet. + // Keep contact held until its requested time before continuing. + MAIN.postAtTime(() -> next(), phaseEndsAt); + } + } + @Override public void onCancelled(GestureDescription ignored) { + // Android cancels this stream. Conservatively do not certify release. + finish("cancelled", false, "System cancelled gesture; reconcile device"); + } + }, MAIN); + if (!accepted) release("rejected"); else dispatched = true; + } catch (Exception e) { release("rejected"); } + } + + private void release(final String status) { + if (completed == 0 || completed == phases.size()) { + finish(status, !dispatched || completed == phases.size(), "Gesture " + status); + return; + } + if (releasing) return; + releasing = true; + try { + Phase previous = phases.get(completed - 1); + GestureDescription.Builder builder = new GestureDescription.Builder(); + int j = 0; + for (float[] endpoint : previous.ends.values()) { + Path still = new Path(); still.moveTo(endpoint[0], endpoint[1]); + builder.addStroke(previous.strokes[j++].continueStroke(still, 0, 1, false)); + } + boolean accepted = service.dispatchGesture(builder.build(), + new AccessibilityService.GestureResultCallback() { + @Override public void onCompleted(GestureDescription ignored) { + finish(status, true, "Gesture " + status + "; held contacts released"); + } + @Override public void onCancelled(GestureDescription ignored) { + finish(status, false, "Release cancelled; reconcile device"); + } + }, MAIN); + if (!accepted) finish(status, false, "Release rejected; reconcile device"); + } catch (Exception e) { finish(status, false, "Release failed; reconcile device"); } + } +} diff --git a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java index 1714a634..c77ba05f 100644 --- a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java +++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java @@ -23,6 +23,9 @@ */ public final class GestureController { + // Owned only by ContinuousGesture; ordinary actions retain their dispatch semantics. + static final AtomicBoolean INPUT_BUSY = new AtomicBoolean(false); + private static final String TAG = "ArtemisGestureCtrl"; private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper()); @@ -162,12 +165,18 @@ private static boolean dispatchSynchronous( final GestureDescription gesture, long timeoutMs ) { + if (INPUT_BUSY.get()) return false; final CountDownLatch latch = new CountDownLatch(1); final AtomicBoolean result = new AtomicBoolean(false); MAIN_HANDLER.post(new Runnable() { @Override public void run() { + // A continuous gesture may have started while this input was queued. + if (INPUT_BUSY.get()) { + latch.countDown(); + return; + } try { service.dispatchGesture(gesture, new AccessibilityService.GestureResultCallback() { @Override diff --git a/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/LongPressDrag.java b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/LongPressDrag.java new file mode 100644 index 00000000..d877bea6 --- /dev/null +++ b/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/LongPressDrag.java @@ -0,0 +1,62 @@ +package com.artemis.helper; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; +import org.json.JSONArray; +import org.json.JSONObject; + +/** Expands a long-press drag; ContinuousGesture owns dispatch and release. */ +final class LongPressDrag { + private static final Set FIELDS = new HashSet<>(Arrays.asList( + "kind", "start", "end", "control_points", "duration_ms", "hold_ms", "release_delay_ms")); + + private static int integer(Object value, int min, int max) { + if (!(value instanceof Integer || value instanceof Long)) + throw new IllegalArgumentException("Expected an integer"); + long n = ((Number) value).longValue(); + if (n < min || n > max) throw new IllegalArgumentException("Integer outside allowed range"); + return (int) n; + } + + private static JSONArray point(JSONArray value) throws Exception { + if (value.length() != 2) throw new IllegalArgumentException("Expected [x,y]"); + return new JSONArray().put(integer(value.get(0), 0, 1000)) + .put(integer(value.get(1), 0, 1000)); + } + + private static JSONObject phase(int duration, JSONArray path, JSONArray controls) throws Exception { + JSONObject pointer = new JSONObject().put("id", 0).put("path", path); + if (controls != null) pointer.put("control_points", controls); + return new JSONObject().put("duration_ms", duration) + .put("pointers", new JSONArray().put(pointer)); + } + + static JSONArray expand(JSONObject args, int longPressMs) throws Exception { + for (Iterator keys = args.keys(); keys.hasNext();) + if (!FIELDS.contains(keys.next())) throw new IllegalArgumentException("Unknown long-press drag field"); + if (!"long_press_drag".equals(args.get("kind"))) + throw new IllegalArgumentException("Invalid long-press drag kind"); + JSONArray start = point(args.getJSONArray("start")); + JSONArray end = point(args.getJSONArray("end")); + JSONArray controls = null; + if (args.has("control_points")) { + JSONArray raw = args.getJSONArray("control_points"); + if (raw.length() != 2) + throw new IllegalArgumentException("Cubic Bezier requires two control points"); + controls = new JSONArray().put(point(raw.getJSONArray(0))).put(point(raw.getJSONArray(1))); + } + int duration = integer(args.has("duration_ms") ? args.get("duration_ms") : 800, 1, 5000); + // Do not silently shorten a device's configured long press to fit the budget. + int hold = integer(args.has("hold_ms") ? args.get("hold_ms") : (long) longPressMs + 150, 1, 5000); + int delay = integer(args.has("release_delay_ms") ? args.get("release_delay_ms") : 0, 0, 5000); + + JSONArray phases = new JSONArray() + .put(phase(hold, new JSONArray().put(start), null)) + .put(phase(duration, new JSONArray().put(start).put(end), controls)); + // Zero lifts on the movement stroke; a positive delay holds at the chosen endpoint. + if (delay > 0) phases.put(phase(delay, new JSONArray().put(end), null)); + return phases; + } +} diff --git a/packages/artemis-accessibility-helper/helper_manifest.json b/packages/artemis-accessibility-helper/helper_manifest.json index f8d979dd..65192926 100644 --- a/packages/artemis-accessibility-helper/helper_manifest.json +++ b/packages/artemis-accessibility-helper/helper_manifest.json @@ -1,7 +1,7 @@ { "package": "com.artemis.helper", - "version_code": 6, - "version_name": "1.2.0", - "sha256": "133e373fe03615c251cd74b03a38539c844d09a074954bca2d2dde6684561969", - "built_at": "2026-09-10T23:13:11+00:00" + "version_code": 10, + "version_name": "1.3.3", + "sha256": "dd625d467f16a432580022bb01b723aa78eac30647611b60c9937c7e8f352f74", + "built_at": "2026-09-29T11:19:03+00:00" } From 79e37107dd8cc4aa3f907f3246fadce8ce6fff10 Mon Sep 17 00:00:00 2001 From: yuan0818 Date: Wed, 30 Sep 2026 10:49:26 +0800 Subject: [PATCH 2/3] test(gesture): cover multi-touch, long-press drag, and cancellation --- .../app/build.gradle.kts | 7 +- .../artemis/helper/ContinuousGestureTest.java | 401 ++++++++++++++++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 46175 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + packages/artemis-accessibility-helper/gradlew | 248 ++++++++++ .../artemis-accessibility-helper/gradlew.bat | 93 ++++ .../action_server_manifest.json | 290 +++++++++++ .../action_surfaces/operator_shells.json | 204 ++++++++ .../action_surfaces/tool_declarations.json | 189 ++++++++ tests/tools/operator/test_operator_tools.py | 4 + tests/unit/agents/test_operator_transcript.py | 5 +- tests/unit/agents/test_prompt_assembly.py | 28 +- tests/unit/mcp/test_action_specs.py | 14 + tests/unit/mcp/test_gesture_compatibility.py | 201 ++++++++ tests/unit/mcp/test_gestures.py | 452 ++++++++++++++++++ 15 files changed, 2135 insertions(+), 8 deletions(-) create mode 100644 packages/artemis-accessibility-helper/app/src/test/java/com/artemis/helper/ContinuousGestureTest.java create mode 100644 packages/artemis-accessibility-helper/gradle/wrapper/gradle-wrapper.jar create mode 100644 packages/artemis-accessibility-helper/gradle/wrapper/gradle-wrapper.properties create mode 100755 packages/artemis-accessibility-helper/gradlew create mode 100644 packages/artemis-accessibility-helper/gradlew.bat create mode 100644 tests/unit/mcp/test_gesture_compatibility.py create mode 100644 tests/unit/mcp/test_gestures.py diff --git a/packages/artemis-accessibility-helper/app/build.gradle.kts b/packages/artemis-accessibility-helper/app/build.gradle.kts index 5babf999..3e0ff81a 100644 --- a/packages/artemis-accessibility-helper/app/build.gradle.kts +++ b/packages/artemis-accessibility-helper/app/build.gradle.kts @@ -23,6 +23,9 @@ android { ) } } + testOptions { + unitTests.isIncludeAndroidResources = true + } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 @@ -31,5 +34,7 @@ android { dependencies { // Pure standard Android SDK APIs (android.accessibilityservice, android.view.accessibility, org.json) - // Zero external dependencies to maximize stability, guarantee compatibility, and keep APK under 30KB. + // No runtime dependencies; the following dependencies are JVM test-only. + testImplementation("junit:junit:4.13.2") + testImplementation("org.robolectric:robolectric:4.14.1") } diff --git a/packages/artemis-accessibility-helper/app/src/test/java/com/artemis/helper/ContinuousGestureTest.java b/packages/artemis-accessibility-helper/app/src/test/java/com/artemis/helper/ContinuousGestureTest.java new file mode 100644 index 00000000..0b7d5e34 --- /dev/null +++ b/packages/artemis-accessibility-helper/app/src/test/java/com/artemis/helper/ContinuousGestureTest.java @@ -0,0 +1,401 @@ +package com.artemis.helper; + +import static org.junit.Assert.*; +import static org.robolectric.Shadows.shadowOf; + +import android.accessibilityservice.AccessibilityService; +import android.accessibilityservice.GestureDescription; +import android.graphics.PathMeasure; +import android.os.Handler; +import android.os.Looper; +import android.view.accessibility.AccessibilityEvent; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.annotation.LooperMode; +import org.robolectric.annotation.GraphicsMode; +import java.util.ArrayList; +import java.util.List; +import java.time.Duration; +import java.util.concurrent.*; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28, shadows = ContinuousGestureTest.InputShadow.class) +@LooperMode(LooperMode.Mode.PAUSED) +public class ContinuousGestureTest { + private ExecutorService worker; + private TestService service; + + public static class TestService extends AccessibilityService { + @Override public void onAccessibilityEvent(AccessibilityEvent event) {} + @Override public void onInterrupt() {} + } + + @Implements(AccessibilityService.class) + public static class InputShadow { + static final List inputs = new ArrayList<>(); + static final List callbacks = new ArrayList<>(); + static int rejectIndex = -1; + @Implementation + protected boolean dispatchGesture(GestureDescription gesture, + AccessibilityService.GestureResultCallback callback, Handler handler) { + inputs.add(gesture); callbacks.add(callback); + return inputs.size() - 1 != rejectIndex; + } + } + + @Before public void setup() { + InputShadow.inputs.clear(); InputShadow.callbacks.clear(); InputShadow.rejectIndex = -1; + GestureController.INPUT_BUSY.set(false); + service = Robolectric.buildService(TestService.class).create().get(); + worker = Executors.newSingleThreadExecutor(); + } + @After public void teardown() { worker.shutdownNow(); } + + private static JSONObject phase(int duration, int... points) throws Exception { + JSONArray path = new JSONArray(); + for (int i = 0; i < points.length; i += 2) + path.put(new JSONArray().put(points[i]).put(points[i + 1])); + return new JSONObject().put("duration_ms", duration).put("pointers", new JSONArray().put( + new JSONObject().put("id", 0).put("path", path))); + } + private static JSONArray drag() throws Exception { + return new JSONArray().put(phase(600, 300, 400)) + .put(phase(800, 300, 400, 980, 400)).put(phase(900, 980, 400)); + } + private Future execute(String id, JSONArray phases) throws Exception { + JSONObject args = new JSONObject().put("request_id", id).put("phases", phases); + return worker.submit(() -> ContinuousGesture.execute(service, args)); + } + private void awaitInputs(int count) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (InputShadow.inputs.size() < count && System.nanoTime() < deadline) { + shadowOf(Looper.getMainLooper()).idle(); Thread.sleep(5); + } + assertEquals(count, InputShadow.inputs.size()); + } + private void complete(int index) { + InputShadow.callbacks.get(index).onCompleted(InputShadow.inputs.get(index)); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis( + InputShadow.inputs.get(index).getStroke(0).getDuration())); + } + + @Test public void earlyDownCallbackCannotSkipLongPressTime() throws Exception { + Future result = execute("early-down", new JSONArray().put( + longPressDrag(0).put("hold_ms", 1500))); + awaitInputs(1); + InputShadow.callbacks.get(0).onCompleted(InputShadow.inputs.get(0)); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(1499)); + assertEquals("Do not move while the icon is still being long-pressed", 1, InputShadow.inputs.size()); + assertTrue(GestureController.INPUT_BUSY.get()); + assertFalse(result.isDone()); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(1)); + awaitInputs(2); + complete(1); + assertTrue(result.get(5, TimeUnit.SECONDS).getBoolean("release_confirmed")); + } + + @Test public void cancellationWhileWaitingForLongPressReleasesWithoutMoving() throws Exception { + Future result = execute("cancel-hold-wait", new JSONArray().put( + longPressDrag(0).put("hold_ms", 1500))); + awaitInputs(1); + InputShadow.callbacks.get(0).onCompleted(InputShadow.inputs.get(0)); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(500)); + ContinuousGesture.cancel("cancel-hold-wait"); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(1000)); + awaitInputs(2); + assertEquals(1, InputShadow.inputs.get(1).getStroke(0).getDuration()); + assertFalse(InputShadow.inputs.get(1).getStroke(0).willContinue()); + complete(1); + JSONObject receipt = result.get(5, TimeUnit.SECONDS); + assertEquals("cancelled", receipt.getString("status")); + assertTrue(receipt.getBoolean("release_confirmed")); + assertFalse(GestureController.INPUT_BUSY.get()); + } + + @Test public void allFingersAreSimultaneousAndLiftTogether() throws Exception { + JSONObject phase = phase(500, 100, 200, 100, 800); + JSONArray pointers = phase.getJSONArray("pointers"); + for (int id = 1; id < 3; id++) { + JSONObject p = new JSONObject(pointers.getJSONObject(0).toString()).put("id", id); + p.put("path", new JSONArray().put(new JSONArray().put(100 + id * 250).put(200)) + .put(new JSONArray().put(100 + id * 250).put(800))); + pointers.put(p); + } + GestureDescription gesture = ContinuousGesture.parse(new JSONArray().put(phase), 1080, 2400).get(0).gesture; + assertEquals(3, gesture.getStrokeCount()); + for (int i = 0; i < 3; i++) { + assertEquals(0, gesture.getStroke(i).getStartTime()); + assertEquals(500, gesture.getStroke(i).getDuration()); + assertFalse(gesture.getStroke(i).willContinue()); + } + } + + @Test public void dragKeepsContactUntilFinalPhase() throws Exception { + Future result = execute("drag", drag()); + for (int i = 0; i < 3; i++) { + awaitInputs(i + 1); + assertEquals(i < 2, InputShadow.inputs.get(i).getStroke(0).willContinue()); + assertTrue(GestureController.INPUT_BUSY.get()); + complete(i); + } + assertTrue(result.get(5, TimeUnit.SECONDS).getBoolean("success")); + assertFalse(GestureController.INPUT_BUSY.get()); + } + + private static JSONArray curvedDrag() throws Exception { + JSONArray phases = drag(); + phases.getJSONObject(1).getJSONArray("pointers").getJSONObject(0).put("control_points", + new JSONArray("[[450,200],[800,200]]")); + return phases; + } + + private static JSONObject longPressDrag(int delay) throws Exception { + return new JSONObject().put("kind", "long_press_drag").put("start", new JSONArray("[300,400]")) + .put("end", new JSONArray("[650,550]")).put("duration_ms", 800).put("release_delay_ms", delay); + } + + @Test public void zeroDelayLiftsOnMovementWithoutAnotherPhase() throws Exception { + Future result = execute("drag-immediate", new JSONArray().put(longPressDrag(0))); + awaitInputs(1); + assertTrue(InputShadow.inputs.get(0).getStroke(0).willContinue()); + complete(0); + awaitInputs(2); + GestureDescription.StrokeDescription movement = InputShadow.inputs.get(1).getStroke(0); + assertEquals(800, movement.getDuration()); + assertFalse(movement.willContinue()); + complete(1); + assertTrue(result.get(5, TimeUnit.SECONDS).getBoolean("release_confirmed")); + assertEquals(2, InputShadow.inputs.size()); + } + + @Test public void positiveDelayHoldsAtEndpointAndOnlyThenReleases() throws Exception { + Future result = execute("drag-delayed", new JSONArray().put(longPressDrag(1200))); + awaitInputs(1); complete(0); awaitInputs(2); + assertTrue(InputShadow.inputs.get(1).getStroke(0).willContinue()); + assertEquals(800, InputShadow.inputs.get(1).getStroke(0).getDuration()); + complete(1); awaitInputs(3); + GestureDescription.StrokeDescription dwell = InputShadow.inputs.get(2).getStroke(0); + assertEquals(1200, dwell.getDuration()); + assertFalse(dwell.willContinue()); + assertFalse(result.isDone()); + assertTrue(GestureController.INPUT_BUSY.get()); + complete(2); + assertTrue(result.get(5, TimeUnit.SECONDS).getBoolean("release_confirmed")); + assertFalse(GestureController.INPUT_BUSY.get()); + } + + @Test @GraphicsMode(GraphicsMode.Mode.NATIVE) + public void longPressDragPreservesChosenEndpointAndDeviceHold() throws Exception { + for (int[] endpoint : new int[][]{{650, 550}, {400, 200}, {20, 400}, {980, 400}}) { + for (int[] size : new int[][]{{1080, 2400}, {2400, 1080}}) { + JSONObject intent = longPressDrag(1200).put("end", new JSONArray(endpoint)) + .put("control_points", new JSONArray("[[400,250],[600,250]]")); + JSONArray raw = LongPressDrag.expand(intent, 1000); + assertEquals(1150, raw.getJSONObject(0).getInt("duration_ms")); + List parsed = ContinuousGesture.parse(raw, size[0], size[1]); + float[] end = parsed.get(1).ends.get(0); + int nx = endpoint[0], ny = endpoint[1]; + assertArrayEquals(new float[]{nx * (size[0] - 1) / 1000f, ny * (size[1] - 1) / 1000f}, end, 0.1f); + PathMeasure curve = new PathMeasure(parsed.get(1).paths.get(0), false); + float[] actualEnd = new float[2]; + assertTrue(curve.getPosTan(curve.getLength(), actualEnd, null)); + assertArrayEquals(end, actualEnd, 0.1f); + PathMeasure still = new PathMeasure(parsed.get(2).paths.get(0), false); + assertEquals(0f, still.getLength(), 0.01f); + assertArrayEquals(end, parsed.get(2).ends.get(0), 0.01f); + intent.put("hold_ms", 650); + assertEquals(650, LongPressDrag.expand(intent, 1000).getJSONObject(0).getInt("duration_ms")); + } + } + } + + @Test public void invalidReleaseDelayAndMixedPlansNeverTouchDevice() throws Exception { + for (Object value : new Object[]{-1, 5001, 1.5, true, "1000"}) { + JSONObject invalid = longPressDrag(0).put("release_delay_ms", value); + assertEquals("invalid_arguments", execute("bad-delay", new JSONArray().put(invalid)) + .get(5, TimeUnit.SECONDS).getString("status")); + } + assertEquals("invalid_arguments", execute("mixed", new JSONArray().put(longPressDrag(0)).put(phase(100, 300, 400))) + .get(5, TimeUnit.SECONDS).getString("status")); + assertTrue(InputShadow.inputs.isEmpty()); + } + + @Test public void invalidLongPressDragEndpointsAndControlsNeverTouchDevice() throws Exception { + JSONObject missingEnd = longPressDrag(0); + missingEnd.remove("end"); + JSONObject[] invalid = { + missingEnd, + longPressDrag(0).put("end", new JSONArray("[1001,500]")), + longPressDrag(0).put("end", new JSONArray("[500]")), + longPressDrag(0).put("end", new JSONArray("[true,500]")), + longPressDrag(0).put("control_points", new JSONArray("[[100,200]]")), + longPressDrag(0).put("control_points", new JSONArray("[[100,200],[300,1001]]")), + longPressDrag(0).put("direction", "left") + }; + for (int i = 0; i < invalid.length; i++) + assertEquals("invalid_arguments", execute("bad-drag-" + i, new JSONArray().put(invalid[i])) + .get(5, TimeUnit.SECONDS).getString("status")); + assertTrue(InputShadow.inputs.isEmpty()); + } + + @Test public void longPressDragWithoutControlsUsesAStraightPath() throws Exception { + JSONArray phases = LongPressDrag.expand(longPressDrag(0), 500); + JSONObject movement = phases.getJSONObject(1).getJSONArray("pointers").getJSONObject(0); + assertEquals("[[300,400],[650,550]]", movement.getJSONArray("path").toString()); + assertFalse(movement.has("control_points")); + } + + @Test public void cancelDuringEndpointDwellStillReleasesAndUnlocks() throws Exception { + Future result = execute("drag-cancel", new JSONArray().put(longPressDrag(1000))); + awaitInputs(1); complete(0); awaitInputs(2); complete(1); awaitInputs(3); + ContinuousGesture.cancel("drag-cancel"); + complete(2); + JSONObject receipt = result.get(5, TimeUnit.SECONDS); + assertEquals("cancelled", receipt.getString("status")); + assertTrue(receipt.getBoolean("release_confirmed")); + assertFalse(GestureController.INPUT_BUSY.get()); + } + + @Test @GraphicsMode(GraphicsMode.Mode.NATIVE) + public void cubicPathHasCurvatureAndScaledEndpointsWithoutLifting() throws Exception { + List phases = ContinuousGesture.parse(curvedDrag(), 1001, 2001); + PathMeasure measure = new PathMeasure(phases.get(1).paths.get(0), false); + assertTrue(measure.getLength() > 680); + float[] position = new float[2]; + assertTrue(measure.getPosTan(0, position, null)); + assertArrayEquals(new float[]{300, 800}, position, 0.1f); + assertTrue(measure.getPosTan(measure.getLength() / 2, position, null)); + assertTrue("Curve must bow above the straight horizontal path", position[1] < 700); + assertTrue(measure.getPosTan(measure.getLength(), position, null)); + assertArrayEquals(new float[]{980, 800}, position, 0.1f); + for (int i = 0; i < phases.size(); i++) + assertEquals(i < 2, phases.get(i).gesture.getStroke(0).willContinue()); + } + + @Test public void curvedDragCompletesHoldMoveDwellAsOneContact() throws Exception { + Future result = execute("curve", curvedDrag()); + for (int i = 0; i < 3; i++) { + awaitInputs(i + 1); + assertEquals(i < 2, InputShadow.inputs.get(i).getStroke(0).willContinue()); + complete(i); + } + JSONObject receipt = result.get(5, TimeUnit.SECONDS); + assertTrue(receipt.getBoolean("success")); + assertTrue(receipt.getBoolean("release_confirmed")); + } + + @Test public void malformedCurveInLaterPhaseNeverDispatchesHold() throws Exception { + String[] invalid = {"[]", "[[1,2]]", "[[1,2],[3,4],[5,6]]", "[[1001,2],[3,4]]", + "[[1.5,2],[3,4]]", "[[true,2],[3,4]]", "[[1,2,3],[4,5]]"}; + for (int i = 0; i < invalid.length; i++) { + JSONArray phases = curvedDrag(); + phases.getJSONObject(1).getJSONArray("pointers").getJSONObject(0) + .put("control_points", new JSONArray(invalid[i])); + assertEquals("invalid_arguments", execute("bad-curve-" + i, phases) + .get(5, TimeUnit.SECONDS).getString("status")); + } + JSONArray phases = curvedDrag(); + phases.getJSONObject(1).getJSONArray("pointers").getJSONObject(0) + .getJSONArray("path").put(new JSONArray("[980,400]")); + assertEquals("invalid_arguments", execute("extra-endpoint", phases) + .get(5, TimeUnit.SECONDS).getString("status")); + assertTrue(InputShadow.inputs.isEmpty()); + } + + @Test public void laterInvalidPhaseCannotCausePartialExecution() throws Exception { + JSONArray phases = drag(); + phases.getJSONObject(2).getJSONArray("pointers").getJSONObject(0) + .put("path", new JSONArray().put(new JSONArray().put(500).put(400))); + JSONObject result = execute("invalid", phases).get(5, TimeUnit.SECONDS); + assertEquals("invalid_arguments", result.getString("status")); + assertTrue(InputShadow.inputs.isEmpty()); + } + + @Test public void cancelHeldPhaseDispatchesOnlyReleaseThenUnlocks() throws Exception { + Future result = execute("cancel", drag()); + awaitInputs(1); + ContinuousGesture.cancel("cancel"); + complete(0); + awaitInputs(2); + GestureDescription release = InputShadow.inputs.get(1); + assertEquals(1, release.getStroke(0).getDuration()); + assertFalse(release.getStroke(0).willContinue()); + complete(1); + JSONObject reply = result.get(5, TimeUnit.SECONDS); + assertEquals("cancelled", reply.getString("status")); + assertTrue(reply.getBoolean("release_confirmed")); + assertFalse(GestureController.INPUT_BUSY.get()); + } + + @Test public void cancelBeforeRequestDoesNotTouchDevice() throws Exception { + ContinuousGesture.cancel("race"); + JSONObject result = execute("race", drag()).get(5, TimeUnit.SECONDS); + assertEquals("cancelled", result.getString("status")); + assertTrue(InputShadow.inputs.isEmpty()); + } + + @Test public void rejectedReleaseDoesNotAllowAnotherGesture() throws Exception { + Future result = execute("reject-release", drag()); + awaitInputs(1); + InputShadow.rejectIndex = 1; + ContinuousGesture.cancel("reject-release"); + complete(0); + JSONObject reply = result.get(5, TimeUnit.SECONDS); + assertFalse(reply.getBoolean("release_confirmed")); + assertTrue(GestureController.INPUT_BUSY.get()); + assertEquals("busy", execute("next", drag()).get(5, TimeUnit.SECONDS).getString("status")); + } + + @Test public void ordinaryTapRetainsSuccessAndCancellationResults() throws Exception { + Future tap = worker.submit(() -> GestureController.tap(service, 300, 400, 5000)); + awaitInputs(1); + assertFalse("Ordinary actions do not own continuous-input state", GestureController.INPUT_BUSY.get()); + complete(0); + assertTrue(tap.get(5, TimeUnit.SECONDS)); + + Future swipe = worker.submit(() -> GestureController.swipe(service, 300, 400, 650, 550, 800, 5000)); + awaitInputs(2); + InputShadow.callbacks.get(1).onCancelled(InputShadow.inputs.get(1)); + assertFalse(swipe.get(5, TimeUnit.SECONDS)); + assertFalse(GestureController.INPUT_BUSY.get()); + } + + @Test public void ordinaryCallbackCannotUnlockAnActiveContinuousGesture() throws Exception { + CompletableFuture tap = CompletableFuture.supplyAsync( + () -> GestureController.tap(service, 300, 400, 5000)); + awaitInputs(1); + Future gesture = execute("legacy-callback", drag()); + awaitInputs(2); + InputShadow.callbacks.get(0).onCompleted(InputShadow.inputs.get(0)); + assertTrue(tap.get(5, TimeUnit.SECONDS)); + assertTrue(GestureController.INPUT_BUSY.get()); + assertFalse(GestureController.tap(service, 600, 500, 100)); + assertFalse(GestureController.swipe(service, 300, 400, 650, 550, 800, 100)); + assertEquals(2, InputShadow.inputs.size()); + + ContinuousGesture.cancel("legacy-callback"); + complete(1); + awaitInputs(3); + complete(2); + assertTrue(gesture.get(5, TimeUnit.SECONDS).getBoolean("release_confirmed")); + assertFalse(GestureController.INPUT_BUSY.get()); + Future next = worker.submit(() -> GestureController.tap(service, 600, 500, 5000)); + awaitInputs(4); + complete(3); + assertTrue(next.get(5, TimeUnit.SECONDS)); + } + + @Test @Config(sdk = 24) public void android7RejectsContinuationButAllowsSinglePhase() throws Exception { + assertEquals(1, ContinuousGesture.parse(new JSONArray().put(phase(200, 100, 200)), 1080, 2400).size()); + assertThrows(UnsupportedOperationException.class, () -> ContinuousGesture.parse(drag(), 1080, 2400)); + } +} diff --git a/packages/artemis-accessibility-helper/gradle/wrapper/gradle-wrapper.jar b/packages/artemis-accessibility-helper/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..61285a659d17295f1de7c53e24fdf13ad755c379 GIT binary patch literal 46175 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

pVX@HRij*jw$g4?}Pn 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 literal 0 HcmV?d00001 diff --git a/packages/artemis-accessibility-helper/gradle/wrapper/gradle-wrapper.properties b/packages/artemis-accessibility-helper/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..19a6bdeb --- /dev/null +++ b/packages/artemis-accessibility-helper/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/artemis-accessibility-helper/gradlew b/packages/artemis-accessibility-helper/gradlew new file mode 100755 index 00000000..adff685a --- /dev/null +++ b/packages/artemis-accessibility-helper/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/packages/artemis-accessibility-helper/gradlew.bat b/packages/artemis-accessibility-helper/gradlew.bat new file mode 100644 index 00000000..e509b2dd --- /dev/null +++ b/packages/artemis-accessibility-helper/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/tests/fixtures/action_surfaces/action_server_manifest.json b/tests/fixtures/action_surfaces/action_server_manifest.json index 968aa5df..93649d44 100644 --- a/tests/fixtures/action_surfaces/action_server_manifest.json +++ b/tests/fixtures/action_surfaces/action_server_manifest.json @@ -1439,5 +1439,295 @@ "type": "object" }, "outputSchema": null + }, + "perform_gesture": { + "description": "[ACTION] Perform continuous touch gestures that a normal swipe cannot express: simultaneous multi-finger input, continuous holds and drags, or delayed release. Coordinates use 0-1000 screen space. In explicit phases, pointers move together; a one-point path holds still. Keep the same pointer IDs and join endpoints across phases to maintain contact until the final release. Optional control_points define a cubic Bezier path with two endpoints and two controls. Alternatively, provide one long_press_drag entry with start and end coordinates and optional control_points. The endpoint is freely chosen, including inside the screen. duration_ms is movement time; release_delay_ms is the endpoint hold before lifting (zero releases immediately). Observe the screen afterward to verify the intended effect.", + "inputSchema": { + "$defs": { + "GesturePhase": { + "additionalProperties": false, + "properties": { + "duration_ms": { + "maximum": 5000, + "minimum": 1, + "title": "Duration Ms", + "type": "integer" + }, + "pointers": { + "items": { + "$ref": "#/$defs/GesturePointer" + }, + "maxItems": 10, + "minItems": 1, + "title": "Pointers", + "type": "array" + } + }, + "required": [ + "duration_ms", + "pointers" + ], + "title": "GesturePhase", + "type": "object" + }, + "GesturePointer": { + "additionalProperties": false, + "properties": { + "id": { + "maximum": 9, + "minimum": 0, + "title": "Id", + "type": "integer" + }, + "path": { + "items": { + "items": { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "maxItems": 128, + "minItems": 1, + "title": "Path", + "type": "array" + }, + "control_points": { + "anyOf": [ + { + "items": { + "items": { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Two cubic Bezier control points. When supplied, path must contain only start and end; Android builds the curve natively.", + "title": "Control Points" + } + }, + "required": [ + "id", + "path" + ], + "title": "GesturePointer", + "type": "object" + }, + "LongPressDrag": { + "additionalProperties": false, + "description": "Hold, move to a caller-selected endpoint, optionally dwell, then release.", + "properties": { + "kind": { + "const": "long_press_drag", + "title": "Kind", + "type": "string" + }, + "start": { + "items": { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "title": "Start", + "type": "array" + }, + "end": { + "items": { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "title": "End", + "type": "array" + }, + "control_points": { + "anyOf": [ + { + "items": { + "items": { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Two cubic Bezier controls between start and end. Omit for a straight path.", + "title": "Control Points" + }, + "duration_ms": { + "default": 800, + "maximum": 5000, + "minimum": 1, + "title": "Duration Ms", + "type": "integer" + }, + "hold_ms": { + "anyOf": [ + { + "maximum": 5000, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Omit to use the device long-press timeout plus 150ms.", + "title": "Hold Ms" + }, + "release_delay_ms": { + "default": 0, + "description": "Keep the same finger down at the endpoint for this long, then lift. Zero lifts immediately after moving; independent of movement duration.", + "maximum": 5000, + "minimum": 0, + "title": "Release Delay Ms", + "type": "integer" + } + }, + "required": [ + "kind", + "start", + "end" + ], + "title": "LongPressDrag", + "type": "object" + } + }, + "properties": { + "phases": { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/GesturePhase" + }, + { + "$ref": "#/$defs/LongPressDrag" + } + ] + }, + "title": "Phases", + "type": "array" + } + }, + "required": [ + "phases" + ], + "title": "perform_gestureArguments", + "type": "object" + }, + "outputSchema": { + "$defs": { + "ActionCode": { + "description": "Machine-readable outcome classification for a device action.", + "enum": [ + "OK", + "INVALID_ARGS", + "TARGET_NOT_FOUND", + "DEVICE_ERROR", + "PACKAGE_NOT_FOUND", + "TIMEOUT", + "UNSUPPORTED" + ], + "title": "ActionCode", + "type": "string" + } + }, + "description": "Outcome of one device action, independent of any transport or agent.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "code": { + "$ref": "#/$defs/ActionCode" + }, + "action": { + "title": "Action", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Detail" + }, + "normalized_coordinates": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Normalized Coordinates" + }, + "duration_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Duration Ms" + } + }, + "required": [ + "ok", + "code", + "action", + "message" + ], + "title": "ActionResult", + "type": "object" + } } } diff --git a/tests/fixtures/action_surfaces/operator_shells.json b/tests/fixtures/action_surfaces/operator_shells.json index 6f3e6c81..3be0ba75 100644 --- a/tests/fixtures/action_surfaces/operator_shells.json +++ b/tests/fixtures/action_surfaces/operator_shells.json @@ -347,5 +347,209 @@ "type": "object" } } + }, + "perform_gesture": { + "type": "function", + "function": { + "name": "perform_gesture", + "description": "[ACTION] Perform continuous touch gestures that a normal swipe cannot express: simultaneous multi-finger input, continuous holds and drags, or delayed release. Coordinates use 0-1000 screen space. In explicit phases, pointers move together; a one-point path holds still. Keep the same pointer IDs and join endpoints across phases to maintain contact until the final release. Optional control_points define a cubic Bezier path with two endpoints and two controls. Alternatively, provide one long_press_drag entry with start and end coordinates and optional control_points. The endpoint is freely chosen, including inside the screen. duration_ms is movement time; release_delay_ms is the endpoint hold before lifting (zero releases immediately). Observe the screen afterward to verify the intended effect. target_description states the intended surface/objects and gesture purpose, not a claimed observation.", + "parameters": { + "properties": { + "phases": { + "description": "Explicit continuous phases or a single native long_press_drag object.", + "items": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "duration_ms": { + "maximum": 5000, + "minimum": 1, + "title": "Duration Ms", + "type": "integer" + }, + "pointers": { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "maximum": 9, + "minimum": 0, + "title": "Id", + "type": "integer" + }, + "path": { + "items": { + "items": { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "maxItems": 128, + "minItems": 1, + "title": "Path", + "type": "array" + }, + "control_points": { + "anyOf": [ + { + "items": { + "items": { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Two cubic Bezier control points. When supplied, path must contain only start and end; Android builds the curve natively.", + "title": "Control Points" + } + }, + "required": [ + "id", + "path" + ], + "title": "GesturePointer", + "type": "object" + }, + "maxItems": 10, + "minItems": 1, + "title": "Pointers", + "type": "array" + } + }, + "required": [ + "duration_ms", + "pointers" + ], + "title": "GesturePhase", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Hold, move to a caller-selected endpoint, optionally dwell, then release.", + "properties": { + "kind": { + "const": "long_press_drag", + "title": "Kind", + "type": "string" + }, + "start": { + "items": { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "title": "Start", + "type": "array" + }, + "end": { + "items": { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "title": "End", + "type": "array" + }, + "control_points": { + "anyOf": [ + { + "items": { + "items": { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Two cubic Bezier controls between start and end. Omit for a straight path.", + "title": "Control Points" + }, + "duration_ms": { + "default": 800, + "maximum": 5000, + "minimum": 1, + "title": "Duration Ms", + "type": "integer" + }, + "hold_ms": { + "anyOf": [ + { + "maximum": 5000, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Omit to use the device long-press timeout plus 150ms.", + "title": "Hold Ms" + }, + "release_delay_ms": { + "default": 0, + "description": "Keep the same finger down at the endpoint for this long, then lift. Zero lifts immediately after moving; independent of movement duration.", + "maximum": 5000, + "minimum": 0, + "title": "Release Delay Ms", + "type": "integer" + } + }, + "required": [ + "kind", + "start", + "end" + ], + "title": "LongPressDrag", + "type": "object" + } + ] + }, + "type": "array" + }, + "target_description": { + "description": "Target surface/objects and gesture purpose.", + "type": "string" + } + }, + "required": [ + "phases", + "target_description" + ], + "type": "object" + } + } } } diff --git a/tests/fixtures/action_surfaces/tool_declarations.json b/tests/fixtures/action_surfaces/tool_declarations.json index 33f5ac85..15a32693 100644 --- a/tests/fixtures/action_surfaces/tool_declarations.json +++ b/tests/fixtures/action_surfaces/tool_declarations.json @@ -310,5 +310,194 @@ ] } } + }, + "PERFORM_GESTURE_TOOL": { + "type": "function", + "function": { + "name": "perform_gesture", + "description": "[ACTION] Perform continuous touch gestures that a normal swipe cannot express: simultaneous multi-finger input, continuous holds and drags, or delayed release. Coordinates use 0-1000 screen space. In explicit phases, pointers move together; a one-point path holds still. Keep the same pointer IDs and join endpoints across phases to maintain contact until the final release. Optional control_points define a cubic Bezier path with two endpoints and two controls. Alternatively, provide one long_press_drag entry with start and end coordinates and optional control_points. The endpoint is freely chosen, including inside the screen. duration_ms is movement time; release_delay_ms is the endpoint hold before lifting (zero releases immediately). Observe the screen afterward to verify the intended effect. target_description states the intended surface/objects and gesture purpose, not a claimed observation.", + "parameters": { + "type": "object", + "properties": { + "phases": { + "type": "array", + "items": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "duration_ms": { + "maximum": 5000, + "minimum": 1, + "type": "integer" + }, + "pointers": { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "maximum": 9, + "minimum": 0, + "type": "integer" + }, + "path": { + "items": { + "items": { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "maxItems": 128, + "minItems": 1, + "type": "array" + }, + "control_points": { + "anyOf": [ + { + "items": { + "items": { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Two cubic Bezier control points. When supplied, path must contain only start and end; Android builds the curve natively." + } + }, + "required": [ + "id", + "path" + ], + "type": "object" + }, + "maxItems": 10, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "duration_ms", + "pointers" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Hold, move to a caller-selected endpoint, optionally dwell, then release.", + "properties": { + "kind": { + "const": "long_press_drag", + "type": "string" + }, + "start": { + "items": { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "end": { + "items": { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "control_points": { + "anyOf": [ + { + "items": { + "items": { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Two cubic Bezier controls between start and end. Omit for a straight path." + }, + "duration_ms": { + "default": 800, + "maximum": 5000, + "minimum": 1, + "type": "integer" + }, + "hold_ms": { + "anyOf": [ + { + "maximum": 5000, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Omit to use the device long-press timeout plus 150ms." + }, + "release_delay_ms": { + "default": 0, + "description": "Keep the same finger down at the endpoint for this long, then lift. Zero lifts immediately after moving; independent of movement duration.", + "maximum": 5000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "start", + "end" + ], + "type": "object" + } + ] + }, + "description": "Explicit continuous phases or a single native long_press_drag object." + }, + "target_description": { + "type": "string", + "description": "Target surface/objects and gesture purpose." + } + }, + "required": [ + "phases", + "target_description" + ] + } + } } } diff --git a/tests/tools/operator/test_operator_tools.py b/tests/tools/operator/test_operator_tools.py index 8fe40927..3f248125 100644 --- a/tests/tools/operator/test_operator_tools.py +++ b/tests/tools/operator/test_operator_tools.py @@ -25,6 +25,10 @@ async def test_operator_shell_tools(): "click": {"target": 1, "times": 1, "delay_ms": 100}, "input_text": {"text": "hello", "target": 1, "clear_exist": True}, "swipe": {"gesture": "up"}, + "perform_gesture": { + "phases": [{"kind": "long_press_drag", "start": [300, 400], "end": [650, 550]}], + "target_description": "move the selected object", + }, "press_key": {"key": "ENTER"}, "manage_app": {"action": "launch", "app_name": "Settings"}, "wait_for_delay": {"time_in_ms": 1000}, diff --git a/tests/unit/agents/test_operator_transcript.py b/tests/unit/agents/test_operator_transcript.py index 0fa9b5b7..076c3e11 100644 --- a/tests/unit/agents/test_operator_transcript.py +++ b/tests/unit/agents/test_operator_transcript.py @@ -53,9 +53,10 @@ ) # SHA-256 snapshots of the legacy system message with the fixed inputs below. +# Updated for perform_gesture in the physical/turn-ending action lists. # Update these when an intentional template change alters the rendered prompt. -GOLDEN_EMPTY_PLAN = "513e0341014b9a2342f608ad4dd173b7dcf5704ce53ee028ba302a9a1b696f96" -GOLDEN_SENTINEL_PLAN = "012fe1b08645266d5a7e518023e20fc6fbfa65d4f2d985847ebb06e0184c67ee" +GOLDEN_EMPTY_PLAN = "5ce954de33ba8c77aa14597b1bd78539a48e3e11009626c70fc04fb98e157715" +GOLDEN_SENTINEL_PLAN = "561f3e0717a6a5e09dee0bb9b79c9b28ed96afa9221258295f02222ac92d3370" SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8") diff --git a/tests/unit/agents/test_prompt_assembly.py b/tests/unit/agents/test_prompt_assembly.py index f924f3d9..f0acb4fa 100644 --- a/tests/unit/agents/test_prompt_assembly.py +++ b/tests/unit/agents/test_prompt_assembly.py @@ -33,6 +33,7 @@ "click", "input_text", "swipe", + "perform_gesture", "press_key", "manage_app", "wait_for_delay", @@ -131,23 +132,25 @@ def test_reduced_enums_stay_well_formed(): available_tools=OPERATOR_PROMPT_TOOLSET - {"manage_app", "wait_for_delay"}, ) assert ( - "Physical device actions (`click`, `input_text`, `swipe`, `press_key`, `long_press`)" in out + "Physical device actions (`click`, `input_text`, `swipe`, `perform_gesture`, `press_key`, `long_press`)" + in out ) assert ( - "Turn-Ending Action (`click`, `swipe`, `input_text`, `long_press`, or `press_key`)" in out + "Turn-Ending Action (`click`, `swipe`, `perform_gesture`, `input_text`, `long_press`, or `press_key`)" + in out ) def test_full_set_enum_slots_render_verbatim(): - """With every tool present the historical enumeration wording is reproduced.""" + """The new primitive appears alongside the existing physical actions.""" template = load_operator_prompts()["main_template"] out = apply_operator_prompt_contract(template) assert ( - "Physical device actions (`click`, `input_text`, `swipe`, `press_key`," + "Physical device actions (`click`, `input_text`, `swipe`, `perform_gesture`, `press_key`," " `manage_app`, `wait_for_delay`, `long_press`)" in out ) assert ( - "Turn-Ending Action (`click`, `swipe`, `input_text`, `long_press`," + "Turn-Ending Action (`click`, `swipe`, `perform_gesture`, `input_text`, `long_press`," " `press_key`, `manage_app`, or `wait_for_delay`)" in out ) assert "Helper/Subagent tools (`ask_explorer`, `ask_diagnoser`, `video_analyzer`)" in out @@ -162,6 +165,21 @@ def test_full_set_enum_slots_render_verbatim(): assert "analyze_task_output" not in out +def test_without_gesture_the_upstream_action_enumerations_are_unchanged(): + out = apply_operator_prompt_contract( + load_operator_prompts()["main_template"], + available_tools=OPERATOR_PROMPT_TOOLSET - {"perform_gesture"}, + ) + assert ( + "Physical device actions (`click`, `input_text`, `swipe`, `press_key`," + " `manage_app`, `wait_for_delay`, `long_press`)" in out + ) + assert ( + "Turn-Ending Action (`click`, `swipe`, `input_text`, `long_press`," + " `press_key`, `manage_app`, or `wait_for_delay`)" in out + ) + + # --- Flash prompt assembly ----------------------------------------------------------- diff --git a/tests/unit/mcp/test_action_specs.py b/tests/unit/mcp/test_action_specs.py index d89c2777..fb635d92 100644 --- a/tests/unit/mcp/test_action_specs.py +++ b/tests/unit/mcp/test_action_specs.py @@ -83,6 +83,7 @@ def test_tool_declarations_match_fixture(): "LONG_PRESS_TOOL": "long_press", "INPUT_TEXT_TOOL": "input_text", "SWIPE_TOOL": "swipe", + "PERFORM_GESTURE_TOOL": "perform_gesture", "PRESS_KEY_TOOL": "press_key", "MANAGE_APP_TOOL": "manage_app", "WAIT_FOR_DELAY_TOOL": "wait_for_delay", @@ -121,6 +122,7 @@ def test_validator_declaration_order_is_stable(): "long_press", "input_text", "swipe", + "perform_gesture", "press_key", "read_note", "list_notes", @@ -138,6 +140,18 @@ def _without_optional_noise(schema: dict) -> dict: ``default`` and the ``null`` alternative say "optional"; the declaration says that through ``required`` alone. """ + + # LangChain retains Pydantic titles inside array-item unions, while the + # declaration projector removes them. Compare semantics, retaining bounds, + # required fields, defaults inside models, and every union alternative. + def without_titles(value): + if isinstance(value, list): + return [without_titles(v) for v in value] + if isinstance(value, dict): + return {k: without_titles(v) for k, v in value.items() if k != "title"} + return value + + schema = without_titles(schema) out = {k: v for k, v in schema.items() if k not in ("anyOf", "default")} if "anyOf" in schema: members = [m for m in schema["anyOf"] if m.get("type") != "null"] diff --git a/tests/unit/mcp/test_gesture_compatibility.py b/tests/unit/mcp/test_gesture_compatibility.py new file mode 100644 index 00000000..b8fc3131 --- /dev/null +++ b/tests/unit/mcp/test_gesture_compatibility.py @@ -0,0 +1,201 @@ +# 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. + +"""Pin existing action contracts and execution behavior while adding gestures.""" + +from copy import deepcopy +import hashlib +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +from langchain_core.utils.function_calling import convert_to_openai_tool +import pytest + +from artemis.agents.validator.action_execution import exec_action +from artemis.agents.validator.execution_loop import _process_action +from artemis.mcp.action_server import build_action_server +from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool, tool_declaration +from artemis.mcp.action_types import ActionCode, ActionResult +from artemis.mcp.actuators import MockActuator +from artemis.utils.task_tree import format_action_clean, format_action_intent, format_result_clean + + +# Canonical JSON digests of the upstream 351ca84 fixtures, before perform_gesture. +# These pins are independent of the new gesture fixture and require no Git at test time. +LEGACY_SCHEMAS = { + "operator": "51882b1d081f24fa0dc8e79cd74a94ed13ee3eccda84cdc233bbbd0515cf8ad0", + "declarations": "ba06a25d8587769f9c518329706f6f1f15826a071a4db995f41c85a73c9d796f", + "wire": "ea41842f9a3440dd2eef2ccb5e156c8a136fb13319e5c7751f3d0653daacdd85", +} + + +def digest(value): + return hashlib.sha256( + json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + +@pytest.mark.asyncio +async def test_existing_action_schemas_and_descriptions_match_upstream(): + operator = { + name: convert_to_openai_tool(operator_shell_tool(name)) + for name in OPERATOR_SHELL_ORDER + if name != "perform_gesture" + } + declarations = { + name.upper() + "_TOOL": dict(tool_declaration(name)) + for name in ( + "click", + "click_sequence", + "long_press", + "input_text", + "swipe", + "press_key", + "manage_app", + "wait_for_delay", + ) + } + tools = await build_action_server(MockActuator()).list_tools() + wire = { + t.name: { + "description": t.description, + "inputSchema": t.inputSchema, + "outputSchema": t.outputSchema, + } + for t in tools + if t.name != "perform_gesture" + } + assert digest(operator) == LEGACY_SCHEMAS["operator"] + assert digest(declarations) == LEGACY_SCHEMAS["declarations"] + assert digest(wire) == LEGACY_SCHEMAS["wire"] + + +LEGACY_ACTIONS = [ + ( + {"action": "tap", "normalized_coordinates": [300, 400]}, + "click", + {"target": [300, 400], "times": 1, "delay_ms": 100}, + ), + ( + {"action": "swipe", "normalized_coordinates": [300, 400, 650, 550], "duration": 800}, + "swipe", + {"start": [300, 400], "end": [650, 550], "duration_ms": 800}, + ), + ( + {"action": "focus_and_input_text", "normalized_coordinates": [300, 400], "text": "hello"}, + "input_text", + {"target": [300, 400], "text": "hello", "clear_exist": False}, + ), + ( + {"action": "launch_app", "app_name": "settings"}, + "manage_app", + {"action": "launch", "app_name": "settings"}, + ), + ({"action": "press_key", "keycode": "KEYCODE_HOME"}, "press_key", {"key": "home"}), + ({"action": "back"}, "press_key", {"key": "back"}), +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action,name,wire", LEGACY_ACTIONS) +@pytest.mark.parametrize("ok", [True, False]) +async def test_existing_action_results_do_not_add_history_fields(action, name, wire, ok): + original = deepcopy(action) + result = ( + ActionResult.success(name, "completed") + if ok + else ActionResult.failure(name, "outcome unknown", code=ActionCode.TIMEOUT) + ) + session = Mock(call=AsyncMock(return_value=result)) + assert await exec_action(SimpleNamespace(device=None), session, action) == ( + (True, "") if ok else (False, "outcome unknown") + ) + session.call.assert_awaited_once_with(name, wire) + assert action == original + assert format_result_clean({"status": "dispatched", "execution": [action]}) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "action", ["tap", "swipe", "focus_and_input_text", "launch_app", "press_key", "perform_gesture"] +) +@pytest.mark.parametrize("burst", [False, True]) +@pytest.mark.parametrize("throws", [False, True]) +async def test_retry_change_is_scoped_to_perform_gesture(action, burst, throws): + execute = ( + AsyncMock(side_effect=TimeoutError("outcome unknown")) + if throws + else AsyncMock(return_value=(False, "outcome unknown")) + ) + node = SimpleNamespace(_exec_action=execute) + with ( + patch( + "artemis.agents.validator.execution_loop._run_precondition_gate", + AsyncMock(return_value=(True, None, "")), + ), + patch( + "artemis.agents.validator.execution_loop._capture_live_screenshot", + AsyncMock(return_value=None), + ), + patch("artemis.agents.validator.execution_loop.asyncio.sleep", AsyncMock()), + ): + outcome = await _process_action( + node, Mock(), Mock(), {"action": action}, action, "", burst=burst + ) + expected_attempts = 1 if burst or action in ("launch_app", "perform_gesture") else 2 + assert not outcome.success + assert execute.await_count == expected_attempts + assert outcome.action_item == { + "action": action, + "attempts": ["outcome unknown"] * expected_attempts, + } + + +@pytest.mark.parametrize( + "action,past,intent", + [ + ( + {"action": "click", "target": [300, 400], "target_description": "button"}, + "Tapped 'button' (self-described) at [300, 400]", + "tap 'button' (self-described) at [300, 400]", + ), + ( + {"action": "swipe", "coordinates": [300, 400, 650, 550], "duration": 800}, + "Swiped from [300, 400] to [650, 550] over 800ms", + "swipe from [300, 400] to [650, 550] over 800ms", + ), + ( + { + "action": "input_text", + "text": "hello", + "target": [300, 400], + "target_description": "search field", + }, + "Inputted 'hello' into 'search field' (self-described) at [300, 400]", + "type 'hello' into 'search field' (self-described) at [300, 400]", + ), + ({"action": "press_key", "key": "home"}, "Pressed key 'home'", "press key 'home'"), + ], +) +def test_existing_history_rendering_matches_upstream(action, past, intent): + assert format_action_clean(action) == past + assert format_action_intent(action) == intent + assert format_result_clean({"status": "dispatched", "execution": [action]}) is None + assert ( + format_result_clean( + {"status": "failed", "execution": [{**action, "attempts": ["timeout"]}]} + ) + == "Error: timeout" + ) diff --git a/tests/unit/mcp/test_gestures.py b/tests/unit/mcp/test_gestures.py new file mode 100644 index 00000000..21845442 --- /dev/null +++ b/tests/unit/mcp/test_gestures.py @@ -0,0 +1,452 @@ +# 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. + +"""Multi-touch semantics, model reachability, and no-replay transport regressions.""" + +import asyncio +import json +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from pydantic import ValidationError +from artemis.clients.accessibility_client import AccessibilityClient +from artemis.mcp.gestures import validate_phases +from artemis.mcp.action_names import to_canonical_call +from artemis.mcp.action_specs import operator_shell_tool, tool_declaration +from artemis.mcp.action_executor import McpActionExecutor, _ArgError +from artemis.mcp.action_session import ActionSession +from artemis.mcp.action_server import build_action_server +from artemis.mcp.actuators.mock import MockActuator +from artemis.mcp.action_types import ActionCode + + +def phase(points=None, duration=500): + return { + "duration_ms": duration, + "pointers": points + or [{"id": i, "path": [[x, 300], [x, 700]]} for i, x in enumerate([300, 500, 700])], + } + + +def drag(): + return [ + phase([{"id": 0, "path": [[300, 400]]}], 700), + phase([{"id": 0, "path": [[300, 400], [980, 400]]}], 800), + phase([{"id": 0, "path": [[980, 400]]}], 1000), + ] + + +def curved_drag(): + phases = drag() + phases[1]["pointers"][0]["control_points"] = [[450, 300], [800, 300]] + return phases + + +def long_press_drag(delay=0): + return [ + { + "kind": "long_press_drag", + "start": [300, 400], + "end": [650, 550], + "duration_ms": 800, + "release_delay_ms": delay, + } + ] + + +@pytest.mark.parametrize("delay", [0, 1200, 5000]) +def test_long_press_drag_keeps_endpoint_hold_distinct_from_movement(delay): + from artemis.mcp.gestures import gesture_duration_bound_ms + + plan = long_press_drag(delay) + assert validate_phases(plan) == plan + assert gesture_duration_bound_ms(plan) == 5000 + 800 + delay + plan[0]["hold_ms"] = 700 + assert gesture_duration_bound_ms(validate_phases(plan)) == 700 + 800 + delay + schema = json.dumps(tool_declaration("perform_gesture").parameters) + assert '"release_delay_ms"' in schema and '"long_press_drag"' in schema + + +@pytest.mark.parametrize( + "field,value", + [ + ("release_delay_ms", -1), + ("release_delay_ms", 5001), + ("release_delay_ms", True), + ("release_delay_ms", 0.5), + ("hold_ms", 0), + ("direction", "right"), + ("start", [1001, 0]), + ("end", [-1, 400]), + ("end", [400]), + ("duration_ms", 0), + ("control_points", [[100, 200]]), + ("control_points", [[100, 200], [300, 1001]]), + ], +) +def test_invalid_long_press_drag_rejected(field, value): + plan = long_press_drag() + plan[0][field] = value + with pytest.raises(ValidationError): + validate_phases(plan) + + +def test_long_press_drag_cannot_mix_with_manual_phases(): + with pytest.raises(ValidationError): + validate_phases(long_press_drag() + drag()) + with pytest.raises(ValidationError): + validate_phases(drag() + long_press_drag()) + + +@pytest.mark.parametrize("endpoint", [[650, 550], [400, 200], [20, 400], [980, 400]]) +def test_long_press_drag_keeps_caller_endpoint_and_bezier_controls(endpoint): + plan = long_press_drag(1200) + plan[0]["end"] = endpoint + plan[0]["control_points"] = [[400, 250], [600, 250]] + assert validate_phases(plan) == plan + executor = McpActionExecutor(Mock(), actuator=Mock()) + _, wire, _, _ = executor._translate( + "perform_gesture", {"phases": plan, "target_description": "drag selected object"}, Mock() + ) + assert wire["phases"] == plan + + +def test_long_press_drag_requires_an_explicit_endpoint(): + plan = long_press_drag() + del plan[0]["end"] + with pytest.raises(ValidationError): + validate_phases(plan) + + +def test_curve_controls_are_preserved_without_host_sampling(): + assert validate_phases(curved_drag()) == curved_drag() + schema = json.dumps(tool_declaration("perform_gesture").parameters) + assert "control_points" in schema + assert "Bezier" in operator_shell_tool("perform_gesture").description + + +@pytest.mark.parametrize( + "controls", + [ + [], + [[1, 2]], + [[1, 2]] * 3, + [[-1, 2], [3, 4]], + [[1.5, 2], [3, 4]], + [[True, 2], [3, 4]], + [[1, 2, 3], [3, 4]], + ], +) +def test_invalid_curve_controls_rejected(controls): + phases = curved_drag() + phases[1]["pointers"][0]["control_points"] = controls + with pytest.raises(ValidationError): + validate_phases(phases) + + +@pytest.mark.parametrize("path", [[[300, 400]], [[300, 400], [500, 400], [980, 400]]]) +def test_curve_requires_only_start_and_end(path): + phases = curved_drag() + phases[1]["pointers"][0]["path"] = path + with pytest.raises(ValidationError): + validate_phases(phases) + + +def test_three_fingers_and_continuous_drag_are_valid(): + assert len(validate_phases([phase()])[0]["pointers"]) == 3 + assert validate_phases(drag()) == drag() + + +@pytest.mark.parametrize( + "mutate", + [ + lambda p: p[0]["pointers"][0]["path"][0].__setitem__(0, True), + lambda p: p[0]["pointers"][0]["path"][0].__setitem__(0, 1.5), + lambda p: p[0]["pointers"][0]["path"][0].__setitem__(0, "30"), + lambda p: p[0]["pointers"][0]["path"][0].__setitem__(0, 1001), + lambda p: p[0]["pointers"][0].__setitem__("id", 1), + lambda p: p[0].__setitem__("duration_ms", 0), + lambda p: p[0].__setitem__("duration_ms", 5001), + lambda p: p[0].__setitem__("unexpected", True), + lambda p: p[0]["pointers"][0].__setitem__("path", []), + ], +) +def test_invalid_input_is_rejected(mutate): + p = [phase()] + mutate(p) + with pytest.raises(ValidationError): + validate_phases(p) + + +@pytest.mark.parametrize("change", ["jump", "ids", "budget"]) +def test_continuity_and_total_budget(change): + p = drag() + if change == "jump": + p[1]["pointers"][0]["path"][0][0] = 500 + elif change == "ids": + p[1]["pointers"][0]["id"] = 1 + else: + p = [phase([{"id": 0, "path": [[500, 500]]}], 5000)] * 7 + with pytest.raises(ValidationError): + validate_phases(p) + + +def test_model_schema_has_nested_paths_and_shared_semantics(): + shell = operator_shell_tool("perform_gesture") + args = shell.args_schema(phases=drag(), target_description="launcher icon") + assert validate_phases(args.phases) == drag() + declaration = tool_declaration("perform_gesture") + encoded = json.dumps(declaration.parameters) + assert "$ref" not in encoded and '"duration_ms"' in encoded and '"pointers"' in encoded + assert "maintain contact" in shell.description and "Observe the screen" in shell.description + + +@pytest.mark.parametrize("plan", [drag(), curved_drag(), long_press_drag(), long_press_drag(1200)]) +def test_pro_and_flash_lower_to_the_same_plan_without_early_execution(plan): + from artemis.agents.operator.operator import OperatorNode + + node = OperatorNode(Mock()) + arguments = {"phases": plan, "target_description": "launcher icon"} + decisions, error = node._translate_and_validate_tool( + {"name": "perform_gesture", "args": arguments}, Mock() + ) + assert error is None and decisions[0]["target_description"] == "launcher icon" + pro_name, pro_args = to_canonical_call(decisions[0]) + executor = McpActionExecutor(Mock(), actuator=Mock()) + name, wire, _, recorded = executor._translate("perform_gesture", arguments, Mock()) + assert (name, wire) == (pro_name, pro_args) + assert recorded["target_description"] == "launcher icon" + assert "target_description" not in wire + with pytest.raises(_ArgError): + executor._translate("perform_gesture", {"phases": drag()}, Mock()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("plan", [drag(), long_press_drag(), long_press_drag(1200)]) +async def test_mcp_executes_all_phases_as_one_action(plan): + actuator = MockActuator() + session = ActionSession(build_action_server(actuator)) + await session.start() + try: + result = await session.call("perform_gesture", {"phases": plan}) + assert result.ok, result + actions = [a for a in actuator.action_history if a["action"] == "perform_gesture"] + assert actions == [{"action": "perform_gesture", "phases": plan}] + finally: + await session.aclose() + + +@pytest.mark.asyncio +async def test_missing_or_incomplete_helper_does_not_succeed(): + actuator = MockActuator() + actuator.controller.perform_gesture = AsyncMock( + return_value={"success": False, "status": "unsupported", "error": "old helper"} + ) + assert (await actuator.perform_gesture([phase()])).code == ActionCode.UNSUPPORTED + actuator.controller.perform_gesture.return_value = { + "success": True, + "status": "completed", + "release_confirmed": False, + } + assert not (await actuator.perform_gesture([phase()])).ok + + +def client_with_capabilities(): + client = AccessibilityClient("test-device") + client._http = Mock( + side_effect=[ + json.dumps( + {"capabilities": ["perform_gesture"], "gesture_continuation": True} + ).encode(), + json.dumps( + {"success": True, "status": "completed", "release_confirmed": True} + ).encode(), + ] + ) + return client + + +def test_helper_receives_one_plan_with_duration_aware_timeout(): + client = client_with_capabilities() + assert client.perform_gesture(drag(), "request-1")["success"] + args, kwargs = client._http.call_args + assert args == ( + "/action", + {"cmd": "perform_gesture", "request_id": "request-1", "phases": drag()}, + ) + assert kwargs["timeout"] == 12.5 + + +def test_helper_probe_prevents_actions_on_old_apk(): + client = client_with_capabilities() + client._http.side_effect = [b"{}"] + assert client.perform_gesture(drag(), "request-1")["status"] == "unsupported" + assert client._http.call_count == 1 + + +def test_old_helper_rejects_curve_before_any_touch(): + client = client_with_capabilities() + assert client.perform_gesture(curved_drag(), "curve")["status"] == "unsupported" + assert client._http.call_count == 1 + + +def test_old_helper_rejects_long_press_drag_without_input(): + client = client_with_capabilities() + assert client.perform_gesture(long_press_drag(), "long-press-drag")["status"] == "unsupported" + assert client._http.call_count == 1 + + +def test_long_press_drag_transmitted_without_host_geometry_and_with_full_timeout(): + client = client_with_capabilities() + client._http.side_effect = [ + b'{"capabilities":["perform_gesture","gesture_long_press_drag"],"gesture_continuation":true}', + b'{"success":true,"status":"completed","release_confirmed":true}', + ] + assert client.perform_gesture(long_press_drag(1200), "long-press-drag")["success"] + assert client._http.call_args.args[1]["phases"] == long_press_drag(1200) + assert client._http.call_args.kwargs["timeout"] == 17 + + +def test_curved_long_press_drag_checks_capability_before_input(): + client = client_with_capabilities() + client._http.side_effect = [ + b'{"capabilities":["perform_gesture","gesture_long_press_drag"],"gesture_continuation":true}', + ] + plan = long_press_drag() + plan[0]["control_points"] = [[400, 250], [600, 250]] + assert client.perform_gesture(plan, "curve")["status"] == "unsupported" + assert client._http.call_count == 1 + + +def test_curved_long_press_drag_reaches_helper_with_interior_endpoint(): + client = client_with_capabilities() + client._http.side_effect = [ + b'{"capabilities":["perform_gesture","gesture_long_press_drag","gesture_cubic_bezier"],"gesture_continuation":true}', + b'{"success":true,"status":"completed","release_confirmed":true}', + ] + plan = long_press_drag(1000) + plan[0]["control_points"] = [[400, 250], [600, 250]] + assert client.perform_gesture(plan, "curve")["success"] + assert client._http.call_args.args[1]["phases"] == plan + + +@pytest.mark.parametrize("plan", [drag(), long_press_drag()]) +def test_helper_without_continuation_rejects_before_input(plan): + client = client_with_capabilities() + client._http.side_effect = [ + b'{"capabilities":["perform_gesture","gesture_long_press_drag"],"gesture_continuation":false}', + ] + assert client.perform_gesture(plan, "android7")["status"] == "unsupported" + assert client._http.call_count == 1 + + +def test_curve_controls_reach_native_helper_unchanged(): + client = client_with_capabilities() + client._http.side_effect = [ + b'{"capabilities":["perform_gesture","gesture_cubic_bezier"],"gesture_continuation":true}', + b'{"success":true,"status":"completed","release_confirmed":true}', + ] + assert client.perform_gesture(curved_drag(), "curve")["success"] + assert client._http.call_args.args[1]["phases"] == curved_drag() + + +def test_lost_action_response_is_not_replayed(): + client = client_with_capabilities() + client._http.side_effect = [ + b'{"capabilities":["perform_gesture"],"gesture_continuation":true}', + TimeoutError(), + ] + with pytest.raises(TimeoutError): + client.perform_gesture(drag(), "request-1") + assert client._http.call_count == 2 + + +@pytest.mark.asyncio +async def test_driver_cancel_targets_the_same_request_and_waits_for_release(): + import threading + from artemis.drivers.android.adb_driver import AndroidAdbDriver + + started, released = threading.Event(), threading.Event() + ids = [] + + def execute(phases, request_id): + ids.append(request_id) + started.set() + assert released.wait(5) + return {"success": False, "status": "cancelled", "release_confirmed": True} + + def cancel(request_id): + assert request_id == ids[0] + released.set() + return {"status": "cancelling"} + + client = Mock() + client.perform_gesture.side_effect = execute + client.cancel_gesture.side_effect = cancel + driver = AndroidAdbDriver("test-device", Mock()) + with patch("artemis.clients.accessibility_client.AccessibilityClient", return_value=client): + task = asyncio.create_task(driver.perform_gesture(drag())) + assert await asyncio.to_thread(started.wait, 5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert released.is_set() and client.perform_gesture.call_count == 1 + + +@pytest.mark.asyncio +async def test_gesture_uses_existing_result_without_mutating_history(): + from copy import deepcopy + from types import SimpleNamespace + from artemis.agents.validator.action_execution import exec_action + + actuator = MockActuator() + result = await actuator.perform_gesture(drag()) + assert result.ok and result.code == ActionCode.OK + assert result.message == "Gesture completed. Observe the screen to verify the intended effect." + assert "release_confirmed" in result.detail + assert "release_confirmed" not in result.message + session = Mock(call=AsyncMock(return_value=result)) + action = {"action": "perform_gesture", "phases": drag()} + original = deepcopy(action) + assert await exec_action(SimpleNamespace(device=None), session, action) == (True, "") + assert action == original + + +@pytest.mark.asyncio +async def test_continuous_gesture_is_not_automatically_retried_after_unknown_outcome(): + from types import SimpleNamespace + from artemis.agents.validator.execution_loop import _process_action + + node = SimpleNamespace(_exec_action=AsyncMock(return_value=(False, "outcome unknown"))) + with ( + patch( + "artemis.agents.validator.execution_loop._run_precondition_gate", + AsyncMock(return_value=(True, None, "")), + ), + patch( + "artemis.agents.validator.execution_loop._capture_live_screenshot", + AsyncMock(return_value=None), + ), + ): + outcome = await _process_action( + node, + Mock(), + Mock(), + {"action": "perform_gesture", "phases": drag()}, + "perform_gesture", + "", + burst=False, + ) + assert not outcome.success + assert node._exec_action.await_count == 1 From 1d6d28c5deb9ff4c92fdffbfd207eb7f71c8d802 Mon Sep 17 00:00:00 2001 From: yuan0818 Date: Wed, 30 Sep 2026 10:49:26 +0800 Subject: [PATCH 3/3] docs: document advanced gesture input --- docs/multi_touch.md | 124 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/multi_touch.md diff --git a/docs/multi_touch.md b/docs/multi_touch.md new file mode 100644 index 00000000..6580a4cd --- /dev/null +++ b/docs/multi_touch.md @@ -0,0 +1,124 @@ +# Advanced gesture input + +`perform_gesture` adds continuous and multi-finger input alongside the existing +`click` and `swipe` primitives. Use it when a normal swipe cannot express the +interaction. It uses the existing Operator, Validator, MCP and Android input path. + +Coordinates are integers from **0 to 1000**, relative to the current screen. Each +example below is a direct MCP call. Operator and Flash calls also require +`target_description`, describing the intended target and purpose. + +## Multi-touch + +All pointers in a phase start together. This example pinches inward; reverse the +paths to zoom outward. Add another pointer for a three-finger gesture. + +```json +{ + "action": "perform_gesture", + "phases": [{ + "duration_ms": 800, + "pointers": [ + {"id": 0, "path": [[300, 500], [450, 500]]}, + {"id": 1, "path": [[700, 500], [550, 500]]} + ] + }] +} +``` + +## Long press and drag + +Use `long_press_drag` to hold an object, move it to a chosen endpoint, optionally +hold there, then release. **The endpoint can be anywhere on screen.** + +```json +{ + "action": "perform_gesture", + "phases": [{ + "kind": "long_press_drag", + "start": [300, 400], + "end": [650, 550], + "hold_ms": 700, + "duration_ms": 800, + "release_delay_ms": 500 + }] +} +``` + +- `hold_ms`: initial long press, 1–5000 ms. If omitted, uses the device's configured + long-press timeout plus 150 ms. +- `duration_ms`: movement time, 1–5000 ms; default 800. +- `release_delay_ms`: time to stay pressed at `end`, 0–5000 ms; default 0. Zero + releases as soon as movement completes. A positive delay maintains contact + before release. + +For an edge dwell, choose an endpoint near the desired edge, such as +`"end": [980, 400]`, with a positive `release_delay_ms`. This is the same generic +drag operation. The target UI determines its edge activation region; inspect the +result afterward. + +### Curved drag + +Add two `control_points` for a cubic Bézier curve between the chosen `start` and +`end`. They shape the path; they do not replace or constrain its endpoint. + +```json +{ + "action": "perform_gesture", + "phases": [{ + "kind": "long_press_drag", + "start": [300, 400], + "end": [650, 550], + "control_points": [[400, 250], [600, 250]], + "duration_ms": 800, + "release_delay_ms": 500 + }] +} +``` + +Omit `control_points` for a straight path. The helper constructs the curve from +the supplied points. It never redirects the endpoint to an edge. + +## Explicit continuous phases + +For multiple pointers or intermediate waypoints, use explicit phases. Keep the +same pointer IDs across phases and start each path at its previous endpoint. +Contacts stay down between phases and lift after the final phase. A one-point +path holds still, allowing one pointer to hold while another moves. + +```json +{ + "action": "perform_gesture", + "phases": [ + {"duration_ms": 700, "pointers": [{"id": 0, "path": [[300, 400]]}]}, + {"duration_ms": 800, "pointers": [{"id": 0, "path": [[300, 400], [650, 550]]}]}, + {"duration_ms": 500, "pointers": [{"id": 0, "path": [[650, 550]]}]} + ] +} +``` + +An explicit pointer path may also contain `control_points: [[c1x,c1y],[c2x,c2y]]`; +then `path` must contain exactly the start and end. Do not mix a `long_press_drag` +entry with explicit phases in one call. + +Limits: 1–10 pointers, 1–32 phases, 1–128 path points per pointer, 1–5000 ms per +phase and at most 30000 ms total. Coordinates and controls must stay within +0–1000. Pointer IDs are unique integers from 0 to 9. Invalid plans are rejected +before input starts. + +## Compatibility and results + +The bundled helper is **v1.3.3 / version code 10**. Single-phase gestures support +Android 7+; continuous phases and `long_press_drag` require Android 8+. +Unsupported helpers return `UNSUPPORTED`. Multi-touch and continuous gestures +are never emulated with separate swipes. Ship the APK and its matching +`helper_manifest.json` together. + +Results use the existing `ActionResult` (`ok`, `code`, `message`). Success means +input completed; verify the intended UI effect afterward. A timeout does not +prove the input was never dispatched, so `perform_gesture` is not automatically +retried. Existing actions keep their retry and history behavior. + +Cancellation requests release of held contacts after the current bounded phase. +If release cannot be confirmed, the helper blocks further gesture injection until +recovery. This helper protection does not cover raw ADB input.