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.
@@ -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"
@@ -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
@@ -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 [])]