mobilerun-core 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mobilerun_core/__init__.py +14 -0
- mobilerun_core/_a11y.py +197 -0
- mobilerun_core/_utils.py +12 -0
- mobilerun_core/connection/__init__.py +107 -0
- mobilerun_core/connection/_unsupported.py +141 -0
- mobilerun_core/connection/cloud.py +194 -0
- mobilerun_core/connection/framework.py +96 -0
- mobilerun_core/detect.py +89 -0
- mobilerun_core/device.py +447 -0
- mobilerun_core/hitl.py +25 -0
- mobilerun_core/mobilerun.py +217 -0
- mobilerun_core/sync.py +168 -0
- mobilerun_core-0.3.0.dist-info/METADATA +239 -0
- mobilerun_core-0.3.0.dist-info/RECORD +15 -0
- mobilerun_core-0.3.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from mobilerun_core.connection import UnsupportedOperation
|
|
2
|
+
from mobilerun_core.device import Device, DeviceHandle
|
|
3
|
+
from mobilerun_core.hitl import HitlDenied, HitlGate
|
|
4
|
+
from mobilerun_core.mobilerun import Mobilerun
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Mobilerun",
|
|
8
|
+
"Device",
|
|
9
|
+
"DeviceHandle",
|
|
10
|
+
"HitlGate",
|
|
11
|
+
"HitlDenied",
|
|
12
|
+
"UnsupportedOperation",
|
|
13
|
+
]
|
|
14
|
+
__version__ = "0.3.0"
|
mobilerun_core/_a11y.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""A11y-tree walking and geometry helpers. Pure functions; no I/O.
|
|
2
|
+
|
|
3
|
+
Split out of `device.py` so the public `Device` class stays focused on
|
|
4
|
+
the device-control verbs. Imported by `Device.find_nodes`, `tap_node`,
|
|
5
|
+
`screen_size`, `wait_for_idle`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Iterator
|
|
11
|
+
|
|
12
|
+
from mobilerun_core._utils import to_dict
|
|
13
|
+
|
|
14
|
+
_TEXT_FIELDS = ("text",)
|
|
15
|
+
_DESC_FIELDS = ("contentDescription", "content_description")
|
|
16
|
+
_RID_FIELDS = ("resourceId", "resource_id", "accessibilityIdentifier")
|
|
17
|
+
_CLASS_FIELDS = ("className", "class_name")
|
|
18
|
+
_ANY_TEXT_FIELDS = _TEXT_FIELDS + _DESC_FIELDS + _RID_FIELDS
|
|
19
|
+
|
|
20
|
+
_BOUNDS_FIELDS = (
|
|
21
|
+
"bounds",
|
|
22
|
+
"boundsInScreen",
|
|
23
|
+
"bounds_in_screen",
|
|
24
|
+
"rect",
|
|
25
|
+
"frame",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_tree(tree: Any) -> Any:
|
|
30
|
+
"""Strip timestamp-ish fields so wait_for_idle doesn't see false diffs."""
|
|
31
|
+
d = to_dict(tree)
|
|
32
|
+
if isinstance(d, dict):
|
|
33
|
+
return {k: v for k, v in d.items() if k not in ("timestamp", "capturedAt")}
|
|
34
|
+
return d
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def walk_a11y(tree: Any) -> Iterator[dict[str, Any]]:
|
|
38
|
+
"""Yield every node in the a11y tree. Accepts either a full UI response
|
|
39
|
+
(descends into `a11y_tree`) or a raw subtree. Tolerates top-level
|
|
40
|
+
`tree` / `root` / `nodes` containers, alternate response shapes."""
|
|
41
|
+
root = to_dict(tree)
|
|
42
|
+
if isinstance(root, dict) and "a11y_tree" in root:
|
|
43
|
+
root = to_dict(root["a11y_tree"])
|
|
44
|
+
if isinstance(root, dict):
|
|
45
|
+
for container in ("tree", "root", "nodes"):
|
|
46
|
+
v = root.get(container)
|
|
47
|
+
if isinstance(v, list):
|
|
48
|
+
yield from _visit_nodes(v)
|
|
49
|
+
return
|
|
50
|
+
yield from _visit_nodes(root)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _visit_nodes(node: Any) -> Iterator[dict[str, Any]]:
|
|
54
|
+
node = to_dict(node)
|
|
55
|
+
if isinstance(node, list):
|
|
56
|
+
for n in node:
|
|
57
|
+
yield from _visit_nodes(n)
|
|
58
|
+
return
|
|
59
|
+
if not isinstance(node, dict):
|
|
60
|
+
return
|
|
61
|
+
yield node
|
|
62
|
+
for child_field in ("children", "nodes", "subviews"):
|
|
63
|
+
children = node.get(child_field)
|
|
64
|
+
if children:
|
|
65
|
+
yield from _visit_nodes(children)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def find_nodes(
|
|
69
|
+
tree: Any,
|
|
70
|
+
*,
|
|
71
|
+
text: str | None,
|
|
72
|
+
desc: str | None,
|
|
73
|
+
resource_id: str | None,
|
|
74
|
+
class_name: str | None,
|
|
75
|
+
text_contains: str | None,
|
|
76
|
+
desc_contains: str | None,
|
|
77
|
+
any_contains: str | None,
|
|
78
|
+
) -> list[dict[str, Any]]:
|
|
79
|
+
results: list[dict[str, Any]] = []
|
|
80
|
+
for node in walk_a11y(tree):
|
|
81
|
+
if not _matches(node, _TEXT_FIELDS, text, text_contains):
|
|
82
|
+
continue
|
|
83
|
+
if not _matches(node, _DESC_FIELDS, desc, desc_contains):
|
|
84
|
+
continue
|
|
85
|
+
if not _matches(node, _RID_FIELDS, resource_id, None):
|
|
86
|
+
continue
|
|
87
|
+
if not _matches(node, _CLASS_FIELDS, class_name, None):
|
|
88
|
+
continue
|
|
89
|
+
if not _matches(node, _ANY_TEXT_FIELDS, None, any_contains):
|
|
90
|
+
continue
|
|
91
|
+
annotated = dict(node)
|
|
92
|
+
center = bounds_center(node_bounds(node))
|
|
93
|
+
if center is not None:
|
|
94
|
+
annotated["center"] = center
|
|
95
|
+
results.append(annotated)
|
|
96
|
+
return results
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _node_field(node: dict[str, Any], *names: str) -> str | None:
|
|
100
|
+
for n in names:
|
|
101
|
+
v = node.get(n)
|
|
102
|
+
if isinstance(v, str):
|
|
103
|
+
return v
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _matches(
|
|
108
|
+
node: dict[str, Any],
|
|
109
|
+
fields: tuple[str, ...],
|
|
110
|
+
exact: str | None,
|
|
111
|
+
contains: str | None,
|
|
112
|
+
) -> bool:
|
|
113
|
+
if exact is None and contains is None:
|
|
114
|
+
return True
|
|
115
|
+
value = _node_field(node, *fields)
|
|
116
|
+
if exact is not None and value != exact:
|
|
117
|
+
return False
|
|
118
|
+
if contains is not None and (
|
|
119
|
+
value is None or contains.lower() not in value.lower()
|
|
120
|
+
):
|
|
121
|
+
return False
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def node_bounds(node: dict[str, Any]) -> Any:
|
|
126
|
+
for name in _BOUNDS_FIELDS:
|
|
127
|
+
v = node.get(name)
|
|
128
|
+
if v:
|
|
129
|
+
return v
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def bounds_center(bounds: Any) -> tuple[int, int] | None:
|
|
134
|
+
bounds = to_dict(bounds)
|
|
135
|
+
if not isinstance(bounds, dict):
|
|
136
|
+
return None
|
|
137
|
+
if "width" in bounds and "x" in bounds:
|
|
138
|
+
return (
|
|
139
|
+
int(bounds["x"]) + int(bounds["width"]) // 2,
|
|
140
|
+
int(bounds["y"]) + int(bounds["height"]) // 2,
|
|
141
|
+
)
|
|
142
|
+
if "right" in bounds and "left" in bounds:
|
|
143
|
+
return (
|
|
144
|
+
(int(bounds["left"]) + int(bounds["right"])) // 2,
|
|
145
|
+
(int(bounds["top"]) + int(bounds["bottom"])) // 2,
|
|
146
|
+
)
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _rect_dims(rect: Any) -> tuple[int, int] | None:
|
|
151
|
+
rect = to_dict(rect)
|
|
152
|
+
if not isinstance(rect, dict):
|
|
153
|
+
return None
|
|
154
|
+
if "width" in rect and "height" in rect:
|
|
155
|
+
w, h = int(rect["width"]), int(rect["height"])
|
|
156
|
+
return (w, h) if w > 0 and h > 0 else None
|
|
157
|
+
if all(k in rect for k in ("left", "right", "top", "bottom")):
|
|
158
|
+
w = int(rect["right"]) - int(rect["left"])
|
|
159
|
+
h = int(rect["bottom"]) - int(rect["top"])
|
|
160
|
+
return (w, h) if w > 0 and h > 0 else None
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def probe_screen_dims(ui_response: Any) -> tuple[int, int] | None:
|
|
165
|
+
"""Walk the UI response for usable (width, height). Tries
|
|
166
|
+
display_metrics first (canonical), then screen_bounds / screen_size,
|
|
167
|
+
and finally the a11y root's boundsInScreen as last resort."""
|
|
168
|
+
resp = to_dict(ui_response)
|
|
169
|
+
if not isinstance(resp, dict):
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
ctx = resp.get("device_context") or {}
|
|
173
|
+
dm = ctx.get("display_metrics") or {}
|
|
174
|
+
w = _safe_int(dm.get("widthPixels") or dm.get("width_pixels"))
|
|
175
|
+
h = _safe_int(dm.get("heightPixels") or dm.get("height_pixels"))
|
|
176
|
+
if w and h:
|
|
177
|
+
return (w, h)
|
|
178
|
+
|
|
179
|
+
for key in ("screen_bounds", "screenBounds", "screen_size", "screenSize"):
|
|
180
|
+
dims = _rect_dims(ctx.get(key))
|
|
181
|
+
if dims:
|
|
182
|
+
return dims
|
|
183
|
+
|
|
184
|
+
a11y = resp.get("a11y_tree")
|
|
185
|
+
if isinstance(a11y, dict):
|
|
186
|
+
dims = _rect_dims(node_bounds(a11y))
|
|
187
|
+
if dims:
|
|
188
|
+
return dims
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _safe_int(v: Any) -> int | None:
|
|
193
|
+
try:
|
|
194
|
+
n = int(v)
|
|
195
|
+
except (TypeError, ValueError):
|
|
196
|
+
return None
|
|
197
|
+
return n if n > 0 else None
|
mobilerun_core/_utils.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Internal utilities shared across modules. Not part of the public API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def to_dict(obj: Any) -> Any:
|
|
9
|
+
"""Coerce a Pydantic-ish model into a plain dict; pass dicts through."""
|
|
10
|
+
if hasattr(obj, "model_dump"):
|
|
11
|
+
return obj.model_dump()
|
|
12
|
+
return obj
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal, Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from mobilerun_core.connection._unsupported import UnsupportedOperation, unsupported_on
|
|
6
|
+
|
|
7
|
+
ConnectionKind = Literal["cloud", "framework"]
|
|
8
|
+
|
|
9
|
+
DEFAULT_SWIPE_MS = 300
|
|
10
|
+
|
|
11
|
+
# Android KeyEvent codes. Shared between Device (string→int mapping) and
|
|
12
|
+
# the cloud connection (forwards the int to the SDK). Local connections
|
|
13
|
+
# will pass the same int to ADB shell `input keyevent`.
|
|
14
|
+
# https://developer.android.com/reference/android/view/KeyEvent
|
|
15
|
+
KEY_CODES: dict[str, int] = {
|
|
16
|
+
"back": 4,
|
|
17
|
+
"home": 3,
|
|
18
|
+
"menu": 82,
|
|
19
|
+
"enter": 66,
|
|
20
|
+
"delete": 67,
|
|
21
|
+
"escape": 111,
|
|
22
|
+
"tab": 61,
|
|
23
|
+
"space": 62,
|
|
24
|
+
"search": 84,
|
|
25
|
+
"page_up": 92,
|
|
26
|
+
"page_down": 93,
|
|
27
|
+
"volume_up": 24,
|
|
28
|
+
"volume_down": 25,
|
|
29
|
+
"power": 26,
|
|
30
|
+
"media_play_pause": 85,
|
|
31
|
+
"media_next": 87,
|
|
32
|
+
"media_prev": 88,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@runtime_checkable
|
|
37
|
+
class Connection(Protocol):
|
|
38
|
+
"""Sync per-device contract.
|
|
39
|
+
|
|
40
|
+
One Connection instance == one device. The two implementations live
|
|
41
|
+
next to this Protocol: `MobilerunCloud` (wraps mobilerun-sdk) and
|
|
42
|
+
`MobilerunFramework` (wraps mobilerun-core-cli). Helpers on `Device`
|
|
43
|
+
consume this Protocol exclusively — they never touch SDK/CLI types.
|
|
44
|
+
|
|
45
|
+
Return-shape normalization is the Connection's job:
|
|
46
|
+
- `ui()` returns a plain dict (not a Pydantic model)
|
|
47
|
+
- `screenshot()` returns a base64-encoded PNG string
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
device_id: str
|
|
51
|
+
|
|
52
|
+
# -- state / observation ------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def ui(self, *, filter: bool = True) -> dict[str, Any]: ...
|
|
55
|
+
def screenshot(self, *, hide_overlay: bool = False) -> str: ...
|
|
56
|
+
def time(self) -> str: ...
|
|
57
|
+
|
|
58
|
+
# -- input actions ------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def tap(self, x: int, y: int, *, stealth: bool = True) -> None: ...
|
|
61
|
+
def swipe(
|
|
62
|
+
self,
|
|
63
|
+
x1: int,
|
|
64
|
+
y1: int,
|
|
65
|
+
x2: int,
|
|
66
|
+
y2: int,
|
|
67
|
+
ms: int = DEFAULT_SWIPE_MS,
|
|
68
|
+
) -> None: ...
|
|
69
|
+
def type(
|
|
70
|
+
self,
|
|
71
|
+
text: str,
|
|
72
|
+
*,
|
|
73
|
+
clear: bool = False,
|
|
74
|
+
wpm: int | None = None,
|
|
75
|
+
stealth: bool = True,
|
|
76
|
+
) -> None: ...
|
|
77
|
+
def key(self, name_or_code: str | int) -> None: ...
|
|
78
|
+
def clear_input(self) -> None: ...
|
|
79
|
+
|
|
80
|
+
# -- app management -----------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def app_start(self, package: str, *, activity: str | None = None) -> None: ...
|
|
83
|
+
def app_stop(self, package: str) -> None: ...
|
|
84
|
+
def app_install_apk(
|
|
85
|
+
self,
|
|
86
|
+
apk_path: str,
|
|
87
|
+
*,
|
|
88
|
+
replace: bool = False,
|
|
89
|
+
grant_permissions: bool = True,
|
|
90
|
+
) -> None: ...
|
|
91
|
+
def app_uninstall(self, package: str) -> None: ...
|
|
92
|
+
def app_list(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
include_system_apps: bool = False,
|
|
96
|
+
include_protected_apps: bool = False,
|
|
97
|
+
) -> list[dict[str, Any]]: ...
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"Connection",
|
|
102
|
+
"ConnectionKind",
|
|
103
|
+
"DEFAULT_SWIPE_MS",
|
|
104
|
+
"KEY_CODES",
|
|
105
|
+
"UnsupportedOperation",
|
|
106
|
+
"unsupported_on",
|
|
107
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Agent-friendly unsupported-operation signaling.
|
|
2
|
+
|
|
3
|
+
The `Connection` Protocol is one surface, but each backend supports only
|
|
4
|
+
a subset of verbs by design (cloud has store-installs but no APK paths;
|
|
5
|
+
framework has APK paths but no store). Instead of raw
|
|
6
|
+
`NotImplementedError("nope")`, unsupported verbs raise a typed
|
|
7
|
+
`UnsupportedOperation` carrying structured fields an agent can inspect
|
|
8
|
+
to recover automatically — verb, backend, alternative verb, and hint.
|
|
9
|
+
|
|
10
|
+
Usage on a backend method:
|
|
11
|
+
|
|
12
|
+
class MobilerunCloud:
|
|
13
|
+
@unsupported_on(
|
|
14
|
+
backend="cloud",
|
|
15
|
+
alternative="app_install_store",
|
|
16
|
+
reason="Cloud devices can only install from the store.",
|
|
17
|
+
hint="Pass a package_name or bundle_id to app_install_store.",
|
|
18
|
+
)
|
|
19
|
+
def app_install_apk(self, apk_path, *, replace=False,
|
|
20
|
+
grant_permissions=True):
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
The body is never executed — the decorator replaces it with a raise. The
|
|
24
|
+
original signature, annotations, and docstring are preserved so
|
|
25
|
+
introspection (help, IDEs, schema generators) still works.
|
|
26
|
+
|
|
27
|
+
Inspect supported/unsupported verbs at runtime:
|
|
28
|
+
|
|
29
|
+
UnsupportedOperation.is_supported(MobilerunCloud, "app_install_apk")
|
|
30
|
+
UnsupportedOperation.describe(MobilerunCloud, "app_install_apk")
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from functools import wraps
|
|
36
|
+
from typing import Any, Callable
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UnsupportedOperation(NotImplementedError):
|
|
40
|
+
"""Raised when a backend doesn't support a verb defined on the
|
|
41
|
+
`Connection` Protocol. Subclasses `NotImplementedError` so existing
|
|
42
|
+
`except NotImplementedError:` callers keep working.
|
|
43
|
+
|
|
44
|
+
Fields:
|
|
45
|
+
verb the Connection verb that was called
|
|
46
|
+
backend "cloud" | "framework" (or other backend tag)
|
|
47
|
+
reason short human-readable explanation
|
|
48
|
+
alternative the verb to use instead, if any
|
|
49
|
+
hint longer agent-targeted guidance, if any
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
verb: str,
|
|
56
|
+
backend: str,
|
|
57
|
+
reason: str,
|
|
58
|
+
alternative: str | None = None,
|
|
59
|
+
hint: str | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.verb = verb
|
|
62
|
+
self.backend = backend
|
|
63
|
+
self.reason = reason
|
|
64
|
+
self.alternative = alternative
|
|
65
|
+
self.hint = hint
|
|
66
|
+
super().__init__(self._format())
|
|
67
|
+
|
|
68
|
+
def _format(self) -> str:
|
|
69
|
+
parts = [f"{self.verb!r} is not supported on backend={self.backend!r}: {self.reason}"]
|
|
70
|
+
if self.alternative:
|
|
71
|
+
parts.append(f"Use {self.alternative!r} instead.")
|
|
72
|
+
if self.hint:
|
|
73
|
+
parts.append(self.hint)
|
|
74
|
+
return " ".join(parts)
|
|
75
|
+
|
|
76
|
+
def as_dict(self) -> dict[str, Any]:
|
|
77
|
+
"""Structured payload an agent can parse to pick a recovery path."""
|
|
78
|
+
return {
|
|
79
|
+
"error": "unsupported_operation",
|
|
80
|
+
"verb": self.verb,
|
|
81
|
+
"backend": self.backend,
|
|
82
|
+
"reason": self.reason,
|
|
83
|
+
"alternative": self.alternative,
|
|
84
|
+
"hint": self.hint,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def is_supported(cls_or_instance: Any, verb: str) -> bool:
|
|
89
|
+
"""True iff `verb` exists on the target and is NOT marked unsupported."""
|
|
90
|
+
fn = getattr(cls_or_instance, verb, None)
|
|
91
|
+
if fn is None:
|
|
92
|
+
return False
|
|
93
|
+
return not getattr(fn, "__unsupported__", False)
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def describe(cls_or_instance: Any, verb: str) -> dict[str, Any] | None:
|
|
97
|
+
"""Return the unsupported-annotation dict for `verb`, or None
|
|
98
|
+
if the verb is supported (or doesn't exist)."""
|
|
99
|
+
fn = getattr(cls_or_instance, verb, None)
|
|
100
|
+
if fn is None or not getattr(fn, "__unsupported__", False):
|
|
101
|
+
return None
|
|
102
|
+
return dict(fn.__unsupported_meta__)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_DEFAULT_REASON = "this backend does not support this operation"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def unsupported_on(
|
|
109
|
+
backend: str,
|
|
110
|
+
*,
|
|
111
|
+
reason: str | None = None,
|
|
112
|
+
alternative: str | None = None,
|
|
113
|
+
hint: str | None = None,
|
|
114
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
115
|
+
"""Decorator marking a backend method as design-time unsupported.
|
|
116
|
+
|
|
117
|
+
The decorated function never runs — calls raise `UnsupportedOperation`
|
|
118
|
+
carrying the annotation. Only `backend` is required; everything else
|
|
119
|
+
falls back to sensible defaults. The original signature is preserved
|
|
120
|
+
via `functools.wraps`.
|
|
121
|
+
"""
|
|
122
|
+
meta = {
|
|
123
|
+
"backend": backend,
|
|
124
|
+
"reason": reason or _DEFAULT_REASON,
|
|
125
|
+
"alternative": alternative,
|
|
126
|
+
"hint": hint,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
def decorate(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
130
|
+
@wraps(fn)
|
|
131
|
+
def wrapper(*_args: Any, **_kwargs: Any) -> Any:
|
|
132
|
+
raise UnsupportedOperation(verb=fn.__name__, **meta)
|
|
133
|
+
|
|
134
|
+
wrapper.__unsupported__ = True # type: ignore[attr-defined]
|
|
135
|
+
wrapper.__unsupported_meta__ = {"verb": fn.__name__, **meta} # type: ignore[attr-defined]
|
|
136
|
+
return wrapper
|
|
137
|
+
|
|
138
|
+
return decorate
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
__all__ = ["UnsupportedOperation", "unsupported_on"]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Cloud connection over `mobilerun-sdk`.
|
|
2
|
+
|
|
3
|
+
Self-contained: bearer holder and SDK factory live in this module as
|
|
4
|
+
private helpers. There is no separate `bearer.py` or `_sdk.py` — auth is
|
|
5
|
+
a cloud-only concern and stays where it's used.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import threading
|
|
12
|
+
import time as _time
|
|
13
|
+
from functools import wraps
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from mobilerun_core._utils import to_dict
|
|
17
|
+
from mobilerun_core.connection import DEFAULT_SWIPE_MS, KEY_CODES, unsupported_on
|
|
18
|
+
|
|
19
|
+
_READ_RETRY_DELAYS = (0.5, 1.5)
|
|
20
|
+
|
|
21
|
+
# Cloud-auth env vars. Public so detect.py can name them in error
|
|
22
|
+
# messages and `cloud_creds_present()` checks.
|
|
23
|
+
BEARER_ENV_VAR = "MOBILERUN_CLOUD_API_KEY"
|
|
24
|
+
BASE_URL_ENV_VAR = "MOBILERUN_API_BASE_URL"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Bearer:
|
|
28
|
+
"""Lock-protected holder for the upstream cloud bearer."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, initial: str) -> None:
|
|
31
|
+
if not initial:
|
|
32
|
+
raise ValueError("Bearer requires a non-empty initial value")
|
|
33
|
+
self._value = initial
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_env(cls) -> "Bearer":
|
|
38
|
+
val = os.environ.get(BEARER_ENV_VAR)
|
|
39
|
+
if not val:
|
|
40
|
+
raise RuntimeError(f"no bearer in env (${BEARER_ENV_VAR})")
|
|
41
|
+
return cls(val)
|
|
42
|
+
|
|
43
|
+
def get(self) -> str:
|
|
44
|
+
with self._lock:
|
|
45
|
+
return self._value
|
|
46
|
+
|
|
47
|
+
def set(self, new_value: str) -> None:
|
|
48
|
+
if not new_value:
|
|
49
|
+
raise ValueError("refusing to set empty bearer")
|
|
50
|
+
with self._lock:
|
|
51
|
+
self._value = new_value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def make_sdk_client(bearer: Bearer | str, base_url: str) -> Any:
|
|
55
|
+
"""Build the upstream `mobilerun-sdk` client. SDK turns `api_key`
|
|
56
|
+
into `Authorization: Bearer <token>`."""
|
|
57
|
+
from mobilerun_sdk import Mobilerun as _SDK
|
|
58
|
+
token = bearer.get() if isinstance(bearer, Bearer) else bearer
|
|
59
|
+
if not token:
|
|
60
|
+
raise ValueError("make_sdk_client: bearer is empty")
|
|
61
|
+
return _SDK(api_key=token, base_url=base_url)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _transient_read_excs() -> tuple[type[BaseException], ...]:
|
|
65
|
+
from mobilerun_sdk import APIConnectionError, APITimeoutError, InternalServerError
|
|
66
|
+
return (InternalServerError, APIConnectionError, APITimeoutError)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _retry_transient_read(fn):
|
|
70
|
+
@wraps(fn)
|
|
71
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
72
|
+
excs = _transient_read_excs()
|
|
73
|
+
for delay in (*_READ_RETRY_DELAYS, None):
|
|
74
|
+
try:
|
|
75
|
+
return fn(*args, **kwargs)
|
|
76
|
+
except excs:
|
|
77
|
+
if delay is None:
|
|
78
|
+
raise
|
|
79
|
+
_time.sleep(delay)
|
|
80
|
+
return wrapper
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class MobilerunCloud:
|
|
84
|
+
"""Cloud-backed `Connection`. One instance == one cloud device."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, sdk_client: Any, device_id: str) -> None:
|
|
87
|
+
self.device_id = device_id
|
|
88
|
+
self._sdk = sdk_client
|
|
89
|
+
|
|
90
|
+
# -- state / observation ------------------------------------------------
|
|
91
|
+
|
|
92
|
+
@_retry_transient_read
|
|
93
|
+
def ui(self, *, filter: bool = True) -> dict[str, Any]:
|
|
94
|
+
resp = self._sdk.devices.state.ui(self.device_id, filter=filter)
|
|
95
|
+
out = to_dict(resp)
|
|
96
|
+
return out if isinstance(out, dict) else {}
|
|
97
|
+
|
|
98
|
+
@_retry_transient_read
|
|
99
|
+
def screenshot(self, *, hide_overlay: bool = False) -> str:
|
|
100
|
+
return self._sdk.devices.state.screenshot(
|
|
101
|
+
self.device_id, hide_overlay=hide_overlay
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@_retry_transient_read
|
|
105
|
+
def time(self) -> str:
|
|
106
|
+
return self._sdk.devices.state.time(self.device_id)
|
|
107
|
+
|
|
108
|
+
# -- input actions ------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def tap(self, x: int, y: int, *, stealth: bool = True) -> None:
|
|
111
|
+
self._sdk.devices.actions.tap(self.device_id, x=x, y=y, stealth=stealth)
|
|
112
|
+
|
|
113
|
+
def swipe(
|
|
114
|
+
self,
|
|
115
|
+
x1: int,
|
|
116
|
+
y1: int,
|
|
117
|
+
x2: int,
|
|
118
|
+
y2: int,
|
|
119
|
+
ms: int = DEFAULT_SWIPE_MS,
|
|
120
|
+
) -> None:
|
|
121
|
+
self._sdk.devices.actions.swipe(
|
|
122
|
+
self.device_id,
|
|
123
|
+
start_x=x1,
|
|
124
|
+
start_y=y1,
|
|
125
|
+
end_x=x2,
|
|
126
|
+
end_y=y2,
|
|
127
|
+
duration=ms,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def type(
|
|
131
|
+
self,
|
|
132
|
+
text: str,
|
|
133
|
+
*,
|
|
134
|
+
clear: bool = False,
|
|
135
|
+
wpm: int | None = None,
|
|
136
|
+
stealth: bool = True,
|
|
137
|
+
) -> None:
|
|
138
|
+
kwargs: dict[str, Any] = {"text": text, "clear": clear, "stealth": stealth}
|
|
139
|
+
if wpm is not None:
|
|
140
|
+
kwargs["wpm"] = wpm
|
|
141
|
+
self._sdk.devices.keyboard.write(self.device_id, **kwargs)
|
|
142
|
+
|
|
143
|
+
def key(self, name_or_code: str | int) -> None:
|
|
144
|
+
if isinstance(name_or_code, int):
|
|
145
|
+
code = name_or_code
|
|
146
|
+
else:
|
|
147
|
+
code = KEY_CODES.get(name_or_code.lower())
|
|
148
|
+
if code is None:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"key: unknown name {name_or_code!r}; pass an int "
|
|
151
|
+
f"KeyEvent code or one of {sorted(KEY_CODES)}"
|
|
152
|
+
)
|
|
153
|
+
self._sdk.devices.keyboard.key(self.device_id, key=code)
|
|
154
|
+
|
|
155
|
+
def clear_input(self) -> None:
|
|
156
|
+
self._sdk.devices.keyboard.clear(self.device_id)
|
|
157
|
+
|
|
158
|
+
# -- app management -----------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def app_start(self, package: str, *, activity: str | None = None) -> None:
|
|
161
|
+
kwargs: dict[str, Any] = {"device_id": self.device_id}
|
|
162
|
+
if activity is not None:
|
|
163
|
+
kwargs["activity"] = activity
|
|
164
|
+
self._sdk.devices.apps.start(package, **kwargs)
|
|
165
|
+
|
|
166
|
+
def app_stop(self, package: str) -> None:
|
|
167
|
+
self._sdk.devices.apps.update(package, device_id=self.device_id)
|
|
168
|
+
|
|
169
|
+
@unsupported_on(
|
|
170
|
+
backend="cloud"
|
|
171
|
+
)
|
|
172
|
+
def app_install_apk(
|
|
173
|
+
self,
|
|
174
|
+
apk_path: str,
|
|
175
|
+
*,
|
|
176
|
+
replace: bool = False,
|
|
177
|
+
grant_permissions: bool = True,
|
|
178
|
+
) -> None: ...
|
|
179
|
+
|
|
180
|
+
def app_uninstall(self, package: str) -> None:
|
|
181
|
+
self._sdk.devices.apps.delete(package, device_id=self.device_id)
|
|
182
|
+
|
|
183
|
+
def app_list(
|
|
184
|
+
self,
|
|
185
|
+
*,
|
|
186
|
+
include_system_apps: bool = False,
|
|
187
|
+
include_protected_apps: bool = False,
|
|
188
|
+
) -> list[dict[str, Any]]:
|
|
189
|
+
resp = self._sdk.devices.apps.list(
|
|
190
|
+
self.device_id,
|
|
191
|
+
include_system_apps=include_system_apps,
|
|
192
|
+
include_protected_apps=include_protected_apps,
|
|
193
|
+
)
|
|
194
|
+
return [to_dict(item) for item in (resp or [])]
|