Skip to content

Commit 3a1af94

Browse files
committed
Add PII screenshot redaction + uiautomator2 Android widget tree + iOS XCUITest backend
1 parent 1ca504e commit 3a1af94

19 files changed

Lines changed: 2008 additions & 1 deletion

File tree

je_auto_control/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@
5555
HealEvent, HealEventLog, HealOutcome, SelfHealError,
5656
default_heal_log, self_heal_click, self_heal_locate,
5757
)
58+
# Screenshot PII redaction (blur regions before VLM upload / audit log).
59+
from je_auto_control.utils.redaction import (
60+
POLICY_MODERATE, POLICY_OFF, POLICY_STRICT,
61+
RedactionEngine, RedactionPolicy, RedactionResult,
62+
default_policy as default_redaction_policy,
63+
policy_from_name as redaction_policy_from_name,
64+
redact_png_bytes,
65+
)
5866
# WebRunner bridge (headless: optional je_web_runner dependency)
5967
from je_auto_control.utils.webrunner_bridge import (
6068
WebRunnerBridgeError, is_webrunner_available, list_webrunner_commands,
@@ -475,6 +483,11 @@ def start_autocontrol_gui(*args, **kwargs):
475483
# Self-healing locator (image → VLM fallback)
476484
"HealEvent", "HealEventLog", "HealOutcome", "SelfHealError",
477485
"default_heal_log", "self_heal_click", "self_heal_locate",
486+
# Screenshot redaction (PII blur)
487+
"POLICY_MODERATE", "POLICY_OFF", "POLICY_STRICT",
488+
"RedactionEngine", "RedactionPolicy", "RedactionResult",
489+
"default_redaction_policy", "redaction_policy_from_name",
490+
"redact_png_bytes",
478491
# WebRunner bridge (browser automation via je_web_runner)
479492
"WebRunnerBridgeError", "is_webrunner_available",
480493
"list_webrunner_commands", "run_webrunner_action",

je_auto_control/android/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,18 @@
3232
from je_auto_control.android.adb_client import (
3333
AdbClient, AdbError, AdbNotAvailable, AndroidDevice,
3434
)
35+
from je_auto_control.android.client import (
36+
UIAutomatorDevice, UIAutomatorUnavailableError,
37+
default_ui_device, reset_default_ui_device,
38+
)
39+
from je_auto_control.android.find import (
40+
ElementNotFoundError, click_element, dump_hierarchy, find_element,
41+
)
3542

3643
__all__ = [
3744
"AdbClient", "AdbError", "AdbNotAvailable", "AndroidDevice",
45+
"ElementNotFoundError",
46+
"UIAutomatorDevice", "UIAutomatorUnavailableError",
47+
"click_element", "default_ui_device", "dump_hierarchy",
48+
"find_element", "reset_default_ui_device",
3849
]

je_auto_control/android/client.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Thin lazy wrapper around ``uiautomator2.Device``.
2+
3+
The ADB-based path in :mod:`adb_client` handles tap / swipe / text /
4+
screenshot via raw ``adb shell`` commands. ``uiautomator2`` adds what
5+
``adb shell`` cannot: a live widget tree, blocking ``wait`` for an
6+
element, and bounding-rect introspection. We keep it in a separate
7+
class so the cheap adb-only path stays available when the daemon
8+
isn't installed.
9+
"""
10+
from __future__ import annotations
11+
12+
import threading
13+
from typing import Any, Optional
14+
15+
16+
class UIAutomatorUnavailableError(RuntimeError):
17+
"""Raised when the ``uiautomator2`` SDK or a target device is missing."""
18+
19+
20+
class UIAutomatorDevice:
21+
"""Adapter around ``uiautomator2.Device`` with lazy connection.
22+
23+
Construct with an optional ``serial`` (the adb device serial as
24+
reported by ``adb devices``). When omitted, ``uiautomator2``
25+
selects the first attached device. The underlying
26+
``uiautomator2.Device`` is built on first attribute access so
27+
importing this module never triggers an adb scan.
28+
"""
29+
30+
def __init__(self, serial: Optional[str] = None,
31+
handle: Optional[Any] = None) -> None:
32+
self._serial = serial
33+
self._handle = handle
34+
self._lock = threading.Lock()
35+
36+
@property
37+
def serial(self) -> Optional[str]:
38+
return self._serial
39+
40+
@property
41+
def handle(self) -> Any:
42+
"""Return the underlying ``uiautomator2.Device`` instance.
43+
44+
Lazily connects on first call. Subsequent calls reuse the
45+
handle so the daemon-side session survives across operations.
46+
"""
47+
return self._resolve_handle()
48+
49+
def _resolve_handle(self) -> Any:
50+
with self._lock:
51+
if self._handle is not None:
52+
return self._handle
53+
try:
54+
import uiautomator2 as u2
55+
except ImportError as error:
56+
raise UIAutomatorUnavailableError(
57+
"uiautomator2 not installed. "
58+
"`pip install uiautomator2` and ensure adb sees the "
59+
"device (`adb devices`).",
60+
) from error
61+
try:
62+
self._handle = u2.connect(self._serial)
63+
except (OSError, RuntimeError, ValueError) as error:
64+
raise UIAutomatorUnavailableError(
65+
f"could not connect to Android device "
66+
f"{self._serial or '(default)'}: {error}",
67+
) from error
68+
return self._handle
69+
70+
71+
_DEFAULT_DEVICE: Optional[UIAutomatorDevice] = None
72+
73+
74+
def default_ui_device() -> UIAutomatorDevice:
75+
"""Process-wide default :class:`UIAutomatorDevice` (lazy-built)."""
76+
global _DEFAULT_DEVICE
77+
if _DEFAULT_DEVICE is None:
78+
_DEFAULT_DEVICE = UIAutomatorDevice()
79+
return _DEFAULT_DEVICE
80+
81+
82+
def reset_default_ui_device() -> None:
83+
"""Clear the process-wide default — used by tests between cases."""
84+
global _DEFAULT_DEVICE
85+
_DEFAULT_DEVICE = None
86+
87+
88+
__all__ = [
89+
"UIAutomatorDevice", "UIAutomatorUnavailableError",
90+
"default_ui_device", "reset_default_ui_device",
91+
]

je_auto_control/android/find.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Element lookup over the uiautomator2 widget tree.
2+
3+
The Android equivalent of the macOS accessibility-tree locator:
4+
callers describe a widget by ``text`` / ``resource_id`` /
5+
``description`` / ``class_name``, and the helper returns the
6+
bounding rect or taps it. The thin :func:`dump_hierarchy` is
7+
exposed so test code can snapshot the live UI tree.
8+
"""
9+
from __future__ import annotations
10+
11+
from typing import Any, Dict, Optional, Tuple
12+
13+
from je_auto_control.android.client import (
14+
UIAutomatorDevice, default_ui_device,
15+
)
16+
17+
18+
class ElementNotFoundError(LookupError):
19+
"""Raised when no widget on screen matches the supplied selector."""
20+
21+
22+
def _build_query(handle: Any,
23+
text: Optional[str],
24+
resource_id: Optional[str],
25+
description: Optional[str],
26+
class_name: Optional[str]) -> Any:
27+
"""Translate the public kwargs into uiautomator2's chained selector."""
28+
selectors: Dict[str, Any] = {}
29+
if text is not None:
30+
selectors["text"] = text
31+
if resource_id is not None:
32+
selectors["resourceId"] = resource_id
33+
if description is not None:
34+
selectors["description"] = description
35+
if class_name is not None:
36+
selectors["className"] = class_name
37+
if not selectors:
38+
raise ValueError(
39+
"at least one of text/resource_id/description/class_name "
40+
"is required",
41+
)
42+
return handle(**selectors)
43+
44+
45+
def find_element(text: Optional[str] = None,
46+
resource_id: Optional[str] = None,
47+
description: Optional[str] = None,
48+
class_name: Optional[str] = None,
49+
*, timeout_s: float = 5.0,
50+
device: Optional[UIAutomatorDevice] = None,
51+
) -> Tuple[int, int, int, int]:
52+
"""Return the matched widget's bounding rect ``(x1, y1, x2, y2)``."""
53+
handle = (device or default_ui_device()).handle
54+
query = _build_query(handle, text, resource_id, description, class_name)
55+
if not query.wait(timeout=float(timeout_s)):
56+
raise ElementNotFoundError(
57+
f"no widget matched selectors text={text!r} "
58+
f"resource_id={resource_id!r} description={description!r} "
59+
f"class_name={class_name!r}",
60+
)
61+
info = query.info
62+
bounds = info.get("bounds") or {}
63+
return (
64+
int(bounds.get("left", 0)),
65+
int(bounds.get("top", 0)),
66+
int(bounds.get("right", 0)),
67+
int(bounds.get("bottom", 0)),
68+
)
69+
70+
71+
def click_element(text: Optional[str] = None,
72+
resource_id: Optional[str] = None,
73+
description: Optional[str] = None,
74+
class_name: Optional[str] = None,
75+
*, timeout_s: float = 5.0,
76+
device: Optional[UIAutomatorDevice] = None,
77+
) -> Tuple[int, int]:
78+
"""Tap the matched widget; return the click-centre ``(x, y)``.
79+
80+
Uses the uiautomator2 handle for the tap rather than ``adb shell
81+
input tap`` so the daemon notices the press synchronously and
82+
can update its event queue.
83+
"""
84+
bounds = find_element(
85+
text=text, resource_id=resource_id, description=description,
86+
class_name=class_name, timeout_s=timeout_s, device=device,
87+
)
88+
cx = (bounds[0] + bounds[2]) // 2
89+
cy = (bounds[1] + bounds[3]) // 2
90+
handle = (device or default_ui_device()).handle
91+
handle.click(int(cx), int(cy))
92+
return (int(cx), int(cy))
93+
94+
95+
def dump_hierarchy(*, device: Optional[UIAutomatorDevice] = None) -> str:
96+
"""Return the device's current widget tree as an XML string."""
97+
handle = (device or default_ui_device()).handle
98+
return str(handle.dump_hierarchy())
99+
100+
101+
__all__ = [
102+
"ElementNotFoundError", "click_element", "dump_hierarchy",
103+
"find_element",
104+
]

je_auto_control/ios/__init__.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""iOS device backend (WebDriverAgent / facebook-wda).
2+
3+
Typical usage::
4+
5+
from je_auto_control.ios import tap, swipe, find_element, screenshot
6+
7+
tap(180, 800)
8+
swipe(180, 1000, 180, 200)
9+
find_element(name="Sign in")
10+
screenshot("/tmp/phone.png")
11+
12+
WebDriverAgent must be running on the device — see the Facebook
13+
WDA README for installation. ``facebook-wda`` is an optional pip
14+
dependency that loads lazily so importing this module on a non-Mac
15+
host does not fail.
16+
"""
17+
from je_auto_control.ios.client import (
18+
IOSDevice, IOSUnavailableError,
19+
default_ios_device, reset_default_ios_device,
20+
)
21+
from je_auto_control.ios.find import (
22+
ElementNotFoundError, click_element, dump_source, find_element,
23+
)
24+
from je_auto_control.ios.input import (
25+
long_press, press_key, swipe, tap, type_text,
26+
)
27+
from je_auto_control.ios.screen import screen_size, screenshot
28+
29+
30+
__all__ = [
31+
"ElementNotFoundError", "IOSDevice", "IOSUnavailableError",
32+
"click_element", "default_ios_device", "dump_source",
33+
"find_element", "long_press", "press_key",
34+
"reset_default_ios_device", "screen_size", "screenshot", "swipe",
35+
"tap", "type_text",
36+
]

je_auto_control/ios/client.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Thin wrapper around ``facebook-wda`` (WebDriverAgent client).
2+
3+
WebDriverAgent (WDA) is the iOS automation server that Appium and
4+
``facebook-wda`` both talk to. The Python client connects over HTTP
5+
to a WDA instance running on a real device or the iOS Simulator.
6+
We import it lazily so the package stays usable on Windows / Linux
7+
hosts where iOS automation isn't possible.
8+
9+
Setup outside this module:
10+
11+
* Install WDA on the device (``xcodebuild test`` from Facebook's
12+
WebDriverAgent project, then run ``iproxy`` to forward 8100).
13+
* ``pip install facebook-wda``.
14+
* Point ``IOSDevice(url=...)`` at ``http://127.0.0.1:8100`` (or the
15+
WDA hub URL when going through Appium / Sauce Labs).
16+
"""
17+
from __future__ import annotations
18+
19+
import threading
20+
from typing import Any, Optional
21+
22+
23+
class IOSUnavailableError(RuntimeError):
24+
"""Raised when the ``wda`` SDK is missing or the device can't be reached."""
25+
26+
27+
class IOSDevice:
28+
"""Adapter around ``wda.Client`` with a lazy connection.
29+
30+
``url`` is the WebDriverAgent HTTP endpoint
31+
(default ``http://localhost:8100``). ``handle`` lets tests inject
32+
a fake client without ever loading the real SDK.
33+
"""
34+
35+
DEFAULT_URL = "http://localhost:8100"
36+
37+
def __init__(self, url: Optional[str] = None,
38+
handle: Optional[Any] = None) -> None:
39+
self._url = url or self.DEFAULT_URL
40+
self._handle = handle
41+
self._lock = threading.Lock()
42+
43+
@property
44+
def url(self) -> str:
45+
return self._url
46+
47+
@property
48+
def handle(self) -> Any:
49+
"""Return the underlying ``wda.Client`` instance (lazy)."""
50+
return self._resolve_handle()
51+
52+
def _resolve_handle(self) -> Any:
53+
with self._lock:
54+
if self._handle is not None:
55+
return self._handle
56+
try:
57+
import wda
58+
except ImportError as error:
59+
raise IOSUnavailableError(
60+
"facebook-wda not installed. "
61+
"`pip install facebook-wda` and run WebDriverAgent "
62+
"on the target device (see the Facebook WDA "
63+
"project README).",
64+
) from error
65+
try:
66+
self._handle = wda.Client(self._url)
67+
except (OSError, RuntimeError, ValueError) as error:
68+
raise IOSUnavailableError(
69+
f"could not reach WebDriverAgent at {self._url}: {error}",
70+
) from error
71+
return self._handle
72+
73+
74+
_DEFAULT_DEVICE: Optional[IOSDevice] = None
75+
76+
77+
def default_ios_device() -> IOSDevice:
78+
"""Process-wide default :class:`IOSDevice` (lazy-built)."""
79+
global _DEFAULT_DEVICE
80+
if _DEFAULT_DEVICE is None:
81+
_DEFAULT_DEVICE = IOSDevice()
82+
return _DEFAULT_DEVICE
83+
84+
85+
def reset_default_ios_device() -> None:
86+
"""Clear the process-wide default — used by tests between cases."""
87+
global _DEFAULT_DEVICE
88+
_DEFAULT_DEVICE = None
89+
90+
91+
__all__ = [
92+
"IOSDevice", "IOSUnavailableError",
93+
"default_ios_device", "reset_default_ios_device",
94+
]

0 commit comments

Comments
 (0)