mobilerun-core-cli 0.1.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_cli/__init__.py +34 -0
- mobilerun_core_cli/driver/__init__.py +4 -0
- mobilerun_core_cli/driver/android.py +194 -0
- mobilerun_core_cli/driver/base.py +140 -0
- mobilerun_core_cli/portal.py +798 -0
- mobilerun_core_cli/transport/__init__.py +3 -0
- mobilerun_core_cli/transport/portal_client.py +744 -0
- mobilerun_core_cli-0.1.0.dist-info/METADATA +154 -0
- mobilerun_core_cli-0.1.0.dist-info/RECORD +11 -0
- mobilerun_core_cli-0.1.0.dist-info/WHEEL +4 -0
- mobilerun_core_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Portal APK management and device communication utilities.
|
|
3
|
+
|
|
4
|
+
This module handles downloading, installing, and managing the Mobilerun Portal app
|
|
5
|
+
on Android devices. It also provides utilities for checking accessibility service
|
|
6
|
+
status and managing device communication modes (TCP and content provider).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
from async_adbutils import AdbDevice, adb
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
from mobilerun_core_cli import __version__
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("mobilerun_core_cli")
|
|
24
|
+
|
|
25
|
+
REPO = "droidrun/mobilerun-portal"
|
|
26
|
+
ASSET_NAME = "mobilerun-portal"
|
|
27
|
+
DOWNLOAD_BASE = f"https://github.com/{REPO}/releases/download"
|
|
28
|
+
GITHUB_API_HOSTS = ["https://api.github.com", "https://ungh.cc"]
|
|
29
|
+
|
|
30
|
+
VERSION_MAP_GIST_URL = "https://raw.githubusercontent.com/droidrun/gists/refs/heads/main/version_map_android.json"
|
|
31
|
+
|
|
32
|
+
PORTAL_PACKAGE_NAME = "com.mobilerun.portal"
|
|
33
|
+
PORTAL_APK_ASSET_PREFIXES = (
|
|
34
|
+
PORTAL_PACKAGE_NAME,
|
|
35
|
+
"mobilerun-portal-internal",
|
|
36
|
+
ASSET_NAME,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# ── Centralized portal identity resolution ──
|
|
40
|
+
# ALL portal identifiers (package, a11y service, IME, content URIs) MUST be
|
|
41
|
+
# resolved through these helpers. No file should hard-code these strings.
|
|
42
|
+
|
|
43
|
+
_PORTAL_META = {
|
|
44
|
+
PORTAL_PACKAGE_NAME: {
|
|
45
|
+
"a11y": f"{PORTAL_PACKAGE_NAME}/{PORTAL_PACKAGE_NAME}.service.MobilerunAccessibilityService",
|
|
46
|
+
"ime": f"{PORTAL_PACKAGE_NAME}/.input.MobilerunKeyboardIME",
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Artifact channels — mobilerun-portal-internal is a repo/artifact convention,
|
|
51
|
+
# not an Android package name.
|
|
52
|
+
_ARTIFACT_CHANNELS = {
|
|
53
|
+
PORTAL_PACKAGE_NAME: {
|
|
54
|
+
"repo": "droidrun/mobilerun-portal",
|
|
55
|
+
"asset_name": "mobilerun-portal",
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
A11Y_SERVICE_NAME = _PORTAL_META[PORTAL_PACKAGE_NAME]["a11y"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def portal_content_uri(pkg: str, path: str) -> str:
|
|
63
|
+
"""Build a content URI for the given portal package."""
|
|
64
|
+
return f"content://{pkg}/{path}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def portal_a11y_service(pkg: str) -> str:
|
|
68
|
+
"""Return the accessibility service component name."""
|
|
69
|
+
return _PORTAL_META[pkg]["a11y"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def portal_ime_id(pkg: str) -> str:
|
|
73
|
+
"""Return the IME component name."""
|
|
74
|
+
return _PORTAL_META[pkg]["ime"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_portal_artifact_source(target_package: str) -> dict:
|
|
78
|
+
"""Return repo/asset_name for the given portal package."""
|
|
79
|
+
return _ARTIFACT_CHANNELS[target_package]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_version_mapping(debug: bool = False) -> dict | None:
|
|
83
|
+
try:
|
|
84
|
+
response = requests.get(VERSION_MAP_GIST_URL, timeout=10)
|
|
85
|
+
response.raise_for_status()
|
|
86
|
+
return response.json()
|
|
87
|
+
except Exception as e:
|
|
88
|
+
if debug:
|
|
89
|
+
print(f"Failed to fetch version mapping: {e}")
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _version_in_range(version: str, range_str: str) -> bool:
|
|
94
|
+
if "-" not in range_str:
|
|
95
|
+
return False
|
|
96
|
+
try:
|
|
97
|
+
start, end = range_str.split("-", 1)
|
|
98
|
+
v_parts = [int(x) for x in version.split(".")]
|
|
99
|
+
s_parts = [int(x) for x in start.split(".")]
|
|
100
|
+
e_parts = [int(x) for x in end.split(".")]
|
|
101
|
+
return s_parts <= v_parts <= e_parts
|
|
102
|
+
except (ValueError, AttributeError):
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_compatible_portal_version(
|
|
107
|
+
mobilerun_version: str, debug: bool = False
|
|
108
|
+
) -> tuple[str | None, str, bool]:
|
|
109
|
+
mapping = get_version_mapping(debug)
|
|
110
|
+
if mapping is None:
|
|
111
|
+
return (None, "", False)
|
|
112
|
+
|
|
113
|
+
mappings = mapping.get("mappings", {})
|
|
114
|
+
download_base = _normalize_download_base(
|
|
115
|
+
mapping.get("download_base", DOWNLOAD_BASE)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Try exact match first
|
|
119
|
+
if mobilerun_version in mappings:
|
|
120
|
+
return (mappings[mobilerun_version], download_base, True)
|
|
121
|
+
|
|
122
|
+
# Try range match (e.g., "0.4.0-0.4.14": "1.0.0")
|
|
123
|
+
for key, portal_version in mappings.items():
|
|
124
|
+
if "-" in key and _version_in_range(mobilerun_version, key):
|
|
125
|
+
return (portal_version, download_base, True)
|
|
126
|
+
|
|
127
|
+
return (None, download_base, True)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _normalize_download_base(download_base: str | None) -> str:
|
|
131
|
+
if not download_base:
|
|
132
|
+
return DOWNLOAD_BASE
|
|
133
|
+
return download_base.replace(
|
|
134
|
+
"droidrun/droidrun-portal", "droidrun/mobilerun-portal"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _normalize_portal_release_tag(version: str) -> str:
|
|
139
|
+
version = version.strip()
|
|
140
|
+
return version if version.startswith("v") else f"v{version}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _extract_release_assets(release: dict) -> list[dict]:
|
|
144
|
+
if "release" in release:
|
|
145
|
+
return release["release"].get("assets", [])
|
|
146
|
+
return release.get("assets", [])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _asset_download_url(asset: dict) -> str | None:
|
|
150
|
+
return asset.get("browser_download_url") or asset.get("downloadUrl")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _asset_file_name(asset: dict) -> str:
|
|
154
|
+
name = asset.get("name")
|
|
155
|
+
if name:
|
|
156
|
+
return name
|
|
157
|
+
|
|
158
|
+
asset_url = _asset_download_url(asset)
|
|
159
|
+
if not asset_url:
|
|
160
|
+
return ""
|
|
161
|
+
|
|
162
|
+
return os.path.basename(urlparse(asset_url).path)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _is_portal_apk_asset_name(asset_name: str) -> bool:
|
|
166
|
+
lower_name = asset_name.lower()
|
|
167
|
+
if not lower_name.endswith(".apk"):
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
return any(
|
|
171
|
+
lower_name.startswith(prefix.lower()) for prefix in PORTAL_APK_ASSET_PREFIXES
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _portal_apk_asset_priority(asset_name: str) -> tuple[int, str]:
|
|
176
|
+
lower_name = asset_name.lower()
|
|
177
|
+
if "unsigned" in lower_name:
|
|
178
|
+
return (3, lower_name)
|
|
179
|
+
if "debug" in lower_name:
|
|
180
|
+
return (2, lower_name)
|
|
181
|
+
if "release" in lower_name or "stable" in lower_name:
|
|
182
|
+
return (1, lower_name)
|
|
183
|
+
return (0, lower_name)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _portal_apk_fallback_name(version: str) -> str:
|
|
187
|
+
return f"{PORTAL_PACKAGE_NAME}-{version}.apk"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _portal_apk_fallback_url(
|
|
191
|
+
version: str, download_base: str, tag: str
|
|
192
|
+
) -> tuple[str, str]:
|
|
193
|
+
asset_name = _portal_apk_fallback_name(version)
|
|
194
|
+
base = _normalize_download_base(download_base).rstrip("/")
|
|
195
|
+
return f"{base}/{tag}/{asset_name}", asset_name
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _parse_portal_asset_version(asset_name: str) -> str | None:
|
|
199
|
+
stem = os.path.basename(asset_name).removesuffix(".apk")
|
|
200
|
+
lower_stem = stem.lower()
|
|
201
|
+
|
|
202
|
+
for suffix in (
|
|
203
|
+
"-release-unsigned",
|
|
204
|
+
"-release-signed",
|
|
205
|
+
"-unsigned",
|
|
206
|
+
"-release",
|
|
207
|
+
"-debug",
|
|
208
|
+
"-stable",
|
|
209
|
+
):
|
|
210
|
+
if lower_stem.endswith(suffix):
|
|
211
|
+
stem = stem[: -len(suffix)]
|
|
212
|
+
lower_stem = lower_stem[: -len(suffix)]
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
for prefix in PORTAL_APK_ASSET_PREFIXES:
|
|
216
|
+
marker = f"{prefix}-"
|
|
217
|
+
if lower_stem.startswith(marker.lower()):
|
|
218
|
+
version = stem[len(marker) :]
|
|
219
|
+
return version.removeprefix("v") or None
|
|
220
|
+
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _format_asset_names(assets: list[dict]) -> str:
|
|
225
|
+
names = [_asset_file_name(asset) or "<unnamed>" for asset in assets]
|
|
226
|
+
return ", ".join(names) if names else "none"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _select_portal_apk_asset(assets: list[dict]) -> tuple[str, str, str | None]:
|
|
230
|
+
candidates: list[tuple[tuple[int, str], str, str]] = []
|
|
231
|
+
|
|
232
|
+
for asset in assets:
|
|
233
|
+
asset_name = _asset_file_name(asset)
|
|
234
|
+
asset_url = _asset_download_url(asset)
|
|
235
|
+
if not asset_name or not asset_url:
|
|
236
|
+
continue
|
|
237
|
+
if not _is_portal_apk_asset_name(asset_name):
|
|
238
|
+
continue
|
|
239
|
+
candidates.append(
|
|
240
|
+
(_portal_apk_asset_priority(asset_name), asset_name, asset_url)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if not candidates:
|
|
244
|
+
raise Exception(
|
|
245
|
+
"Portal APK asset not found in release. "
|
|
246
|
+
f"Saw assets: {_format_asset_names(assets)}"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
_, asset_name, asset_url = min(candidates, key=lambda candidate: candidate[0])
|
|
250
|
+
return asset_url, asset_name, _parse_portal_asset_version(asset_name)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _fetch_release_json(release_path: str, debug: bool = False) -> dict:
|
|
254
|
+
path = release_path.lstrip("/")
|
|
255
|
+
response = None
|
|
256
|
+
last_request_error: requests.RequestException | None = None
|
|
257
|
+
|
|
258
|
+
for host in GITHUB_API_HOSTS:
|
|
259
|
+
url = f"{host}/repos/{REPO}/{path}"
|
|
260
|
+
try:
|
|
261
|
+
response = requests.get(url)
|
|
262
|
+
except requests.RequestException as e:
|
|
263
|
+
last_request_error = e
|
|
264
|
+
if debug:
|
|
265
|
+
print(f"Failed to fetch release from {host}: {e}")
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
if response.status_code == 200:
|
|
269
|
+
if debug:
|
|
270
|
+
print(f"Using GitHub release on {host}")
|
|
271
|
+
return response.json()
|
|
272
|
+
|
|
273
|
+
if response is not None:
|
|
274
|
+
response.raise_for_status()
|
|
275
|
+
|
|
276
|
+
if last_request_error is not None:
|
|
277
|
+
raise Exception(
|
|
278
|
+
"Failed to fetch Portal release metadata from all configured hosts"
|
|
279
|
+
) from last_request_error
|
|
280
|
+
|
|
281
|
+
raise Exception("No GitHub API hosts configured")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _get_release_assets_by_tag(version: str, debug: bool = False) -> list[dict]:
|
|
285
|
+
tag = _normalize_portal_release_tag(version)
|
|
286
|
+
release = _fetch_release_json(f"releases/tags/{tag}", debug)
|
|
287
|
+
return _extract_release_assets(release)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _resolve_versioned_portal_apk_asset(
|
|
291
|
+
version: str, download_base: str, debug: bool = False
|
|
292
|
+
) -> tuple[str, str, str]:
|
|
293
|
+
tag = _normalize_portal_release_tag(version)
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
assets = _get_release_assets_by_tag(tag, debug)
|
|
297
|
+
asset_url, asset_name, asset_version = _select_portal_apk_asset(assets)
|
|
298
|
+
return asset_url, asset_version or tag.removeprefix("v"), asset_name
|
|
299
|
+
except Exception as e:
|
|
300
|
+
if debug:
|
|
301
|
+
print(
|
|
302
|
+
f"Failed to resolve release assets for {tag}, using fallback URL: {e}"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
asset_version = tag.removeprefix("v")
|
|
306
|
+
asset_url, asset_name = _portal_apk_fallback_url(asset_version, download_base, tag)
|
|
307
|
+
return asset_url, asset_version, asset_name
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _resolve_latest_portal_apk_asset(debug: bool = False) -> tuple[str, str, str]:
|
|
311
|
+
assets = get_latest_release_assets(debug)
|
|
312
|
+
asset_url, asset_name, asset_version = _select_portal_apk_asset(assets)
|
|
313
|
+
return asset_url, asset_version or "unknown", asset_name
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@contextlib.contextmanager
|
|
317
|
+
def download_versioned_portal_apk(
|
|
318
|
+
version: str, download_base: str, debug: bool = False
|
|
319
|
+
):
|
|
320
|
+
"""Download a specific Portal APK version."""
|
|
321
|
+
console = Console()
|
|
322
|
+
asset_url, asset_version, _ = _resolve_versioned_portal_apk_asset(
|
|
323
|
+
version, download_base, debug
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
console.print(f"Downloading Portal APK [bold]{asset_version}[/bold]")
|
|
327
|
+
if debug:
|
|
328
|
+
console.print(f"Asset URL: {asset_url}")
|
|
329
|
+
|
|
330
|
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".apk")
|
|
331
|
+
try:
|
|
332
|
+
r = requests.get(asset_url, stream=True)
|
|
333
|
+
r.raise_for_status()
|
|
334
|
+
for chunk in r.iter_content(chunk_size=8192):
|
|
335
|
+
if chunk:
|
|
336
|
+
tmp.write(chunk)
|
|
337
|
+
tmp.close()
|
|
338
|
+
yield tmp.name
|
|
339
|
+
finally:
|
|
340
|
+
if os.path.exists(tmp.name):
|
|
341
|
+
os.unlink(tmp.name)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def get_latest_release_assets(debug: bool = False):
|
|
345
|
+
"""
|
|
346
|
+
Fetch the latest Portal APK release assets from GitHub.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
debug: Enable debug logging
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
List of asset dictionaries from the latest GitHub release
|
|
353
|
+
|
|
354
|
+
Raises:
|
|
355
|
+
requests.HTTPError: If the GitHub API request fails
|
|
356
|
+
"""
|
|
357
|
+
latest_release = _fetch_release_json("releases/latest", debug)
|
|
358
|
+
return _extract_release_assets(latest_release)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@contextlib.contextmanager
|
|
362
|
+
def download_portal_apk(debug: bool = False):
|
|
363
|
+
"""
|
|
364
|
+
Download the latest Portal APK from GitHub releases.
|
|
365
|
+
|
|
366
|
+
This context manager downloads the APK to a temporary file and yields
|
|
367
|
+
the file path. The file is automatically deleted when the context exits.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
debug: Enable debug logging
|
|
371
|
+
|
|
372
|
+
Yields:
|
|
373
|
+
str: Path to the downloaded APK file
|
|
374
|
+
|
|
375
|
+
Raises:
|
|
376
|
+
Exception: If the Portal APK asset is not found in the release
|
|
377
|
+
requests.HTTPError: If the download fails
|
|
378
|
+
"""
|
|
379
|
+
console = Console()
|
|
380
|
+
asset_url, asset_version, _ = _resolve_latest_portal_apk_asset(debug)
|
|
381
|
+
|
|
382
|
+
console.print(f"Found Portal APK [bold]{asset_version}[/bold]")
|
|
383
|
+
if debug:
|
|
384
|
+
console.print(f"Asset URL: {asset_url}")
|
|
385
|
+
|
|
386
|
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".apk")
|
|
387
|
+
try:
|
|
388
|
+
r = requests.get(asset_url, stream=True)
|
|
389
|
+
r.raise_for_status()
|
|
390
|
+
for chunk in r.iter_content(chunk_size=8192):
|
|
391
|
+
if chunk:
|
|
392
|
+
tmp.write(chunk)
|
|
393
|
+
tmp.close()
|
|
394
|
+
yield tmp.name
|
|
395
|
+
finally:
|
|
396
|
+
if os.path.exists(tmp.name):
|
|
397
|
+
os.unlink(tmp.name)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
async def enable_portal_accessibility(
|
|
401
|
+
device: AdbDevice, service_name: str = A11Y_SERVICE_NAME
|
|
402
|
+
):
|
|
403
|
+
"""
|
|
404
|
+
Enable the Portal accessibility service on the device.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
device: ADB device connection
|
|
408
|
+
service_name: Full accessibility service name (default: Portal service)
|
|
409
|
+
|
|
410
|
+
Note:
|
|
411
|
+
This may fail on some devices due to security restrictions.
|
|
412
|
+
Manual enablement may be required.
|
|
413
|
+
"""
|
|
414
|
+
await device.shell(
|
|
415
|
+
f"settings put secure enabled_accessibility_services {service_name}"
|
|
416
|
+
)
|
|
417
|
+
await device.shell("settings put secure accessibility_enabled 1")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
async def check_portal_accessibility(
|
|
421
|
+
device: AdbDevice, service_name: str = A11Y_SERVICE_NAME, debug: bool = False
|
|
422
|
+
) -> bool:
|
|
423
|
+
"""
|
|
424
|
+
Check if the Portal accessibility service is enabled.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
device: ADB device connection
|
|
428
|
+
service_name: Full accessibility service name to check
|
|
429
|
+
debug: Enable debug logging
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
True if the accessibility service is enabled, False otherwise
|
|
433
|
+
"""
|
|
434
|
+
a11y_services = await device.shell(
|
|
435
|
+
"settings get secure enabled_accessibility_services"
|
|
436
|
+
)
|
|
437
|
+
if service_name not in a11y_services:
|
|
438
|
+
if debug:
|
|
439
|
+
print(a11y_services)
|
|
440
|
+
return False
|
|
441
|
+
|
|
442
|
+
a11y_enabled = await device.shell("settings get secure accessibility_enabled")
|
|
443
|
+
if a11y_enabled != "1":
|
|
444
|
+
if debug:
|
|
445
|
+
print(a11y_enabled)
|
|
446
|
+
return False
|
|
447
|
+
|
|
448
|
+
return True
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
async def ping_portal(device: AdbDevice, debug: bool = False):
|
|
452
|
+
"""
|
|
453
|
+
Ping the Mobilerun Portal to check if it is installed and accessible.
|
|
454
|
+
"""
|
|
455
|
+
try:
|
|
456
|
+
packages = await device.list_packages()
|
|
457
|
+
except Exception as e:
|
|
458
|
+
raise Exception("Failed to list packages") from e
|
|
459
|
+
|
|
460
|
+
if PORTAL_PACKAGE_NAME not in packages:
|
|
461
|
+
if debug:
|
|
462
|
+
print(packages)
|
|
463
|
+
raise Exception("Portal is not installed on the device")
|
|
464
|
+
|
|
465
|
+
if not await check_portal_accessibility(device, debug=debug):
|
|
466
|
+
await device.shell("am start -a android.settings.ACCESSIBILITY_SETTINGS")
|
|
467
|
+
raise Exception(
|
|
468
|
+
"Mobilerun Portal is not enabled as an accessibility service on the device"
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
async def ping_portal_content(device: AdbDevice, debug: bool = False):
|
|
473
|
+
"""
|
|
474
|
+
Test Portal accessibility via content provider.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
device: ADB device connection
|
|
478
|
+
debug: Enable debug logging
|
|
479
|
+
|
|
480
|
+
Raises:
|
|
481
|
+
Exception: If Portal is not reachable via content provider
|
|
482
|
+
"""
|
|
483
|
+
try:
|
|
484
|
+
uri = portal_content_uri(PORTAL_PACKAGE_NAME, "state")
|
|
485
|
+
state = await device.shell(f"content query --uri {uri}")
|
|
486
|
+
if "Row: 0 result=" not in state:
|
|
487
|
+
raise Exception("Failed to get state from Mobilerun Portal")
|
|
488
|
+
except Exception as e:
|
|
489
|
+
raise Exception("Mobilerun Portal is not reachable") from e
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
async def ping_portal_tcp(device: AdbDevice, debug: bool = False):
|
|
493
|
+
"""
|
|
494
|
+
Test Portal accessibility via TCP mode.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
device: ADB device connection
|
|
498
|
+
debug: Enable debug logging
|
|
499
|
+
|
|
500
|
+
Raises:
|
|
501
|
+
Exception: If Portal is not reachable via TCP or port forwarding fails
|
|
502
|
+
"""
|
|
503
|
+
from mobilerun_core_cli.driver.android import AndroidDriver
|
|
504
|
+
|
|
505
|
+
try:
|
|
506
|
+
driver = AndroidDriver(serial=device.serial, use_tcp=True)
|
|
507
|
+
await driver.connect()
|
|
508
|
+
except Exception as e:
|
|
509
|
+
raise Exception("Failed to setup TCP forwarding") from e
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
async def set_overlay_offset(device: AdbDevice, offset: int):
|
|
513
|
+
"""
|
|
514
|
+
Set the overlay offset using the /overlay_offset portal content provider endpoint.
|
|
515
|
+
"""
|
|
516
|
+
try:
|
|
517
|
+
uri = portal_content_uri(PORTAL_PACKAGE_NAME, "overlay_offset")
|
|
518
|
+
cmd = f'content insert --uri "{uri}" --bind offset:i:{offset}'
|
|
519
|
+
await device.shell(cmd)
|
|
520
|
+
except Exception as e:
|
|
521
|
+
raise Exception("Error setting overlay offset") from e
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
async def toggle_overlay(device: AdbDevice, visible: bool):
|
|
525
|
+
"""Toggle the overlay visibility.
|
|
526
|
+
|
|
527
|
+
Args:
|
|
528
|
+
device: Device to toggle the overlay on
|
|
529
|
+
visible: Whether to show the overlay
|
|
530
|
+
|
|
531
|
+
throws:
|
|
532
|
+
Exception: If the overlay toggle fails
|
|
533
|
+
"""
|
|
534
|
+
try:
|
|
535
|
+
visible_str = "true" if visible else "false"
|
|
536
|
+
uri = portal_content_uri(PORTAL_PACKAGE_NAME, "overlay_visible")
|
|
537
|
+
cmd = f'content insert --uri "{uri}" --bind visible:b:{visible_str}'
|
|
538
|
+
await device.shell(cmd)
|
|
539
|
+
except Exception as e:
|
|
540
|
+
raise Exception("Failed to toggle overlay") from e
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
async def setup_keyboard(device: AdbDevice):
|
|
544
|
+
"""
|
|
545
|
+
Set up the Mobilerun keyboard as the default input method.
|
|
546
|
+
Simple setup that just switches to Mobilerun keyboard without saving/restoring.
|
|
547
|
+
|
|
548
|
+
throws:
|
|
549
|
+
Exception: If the keyboard setup fails
|
|
550
|
+
"""
|
|
551
|
+
try:
|
|
552
|
+
ime = portal_ime_id(PORTAL_PACKAGE_NAME)
|
|
553
|
+
await device.shell(f"ime enable {ime}")
|
|
554
|
+
await device.shell(f"ime set {ime}")
|
|
555
|
+
except Exception as e:
|
|
556
|
+
raise Exception("Error setting up keyboard") from e
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
async def disable_keyboard(
|
|
560
|
+
device: AdbDevice,
|
|
561
|
+
target_ime: str | None = None,
|
|
562
|
+
):
|
|
563
|
+
"""
|
|
564
|
+
Disable a specific IME (keyboard) and optionally switch to another.
|
|
565
|
+
By default, disables the Mobilerun keyboard.
|
|
566
|
+
|
|
567
|
+
Args:
|
|
568
|
+
target_ime: The IME package/activity to disable (default: Mobilerun keyboard)
|
|
569
|
+
|
|
570
|
+
Returns:
|
|
571
|
+
bool: True if disabled successfully, False otherwise
|
|
572
|
+
"""
|
|
573
|
+
if target_ime is None:
|
|
574
|
+
target_ime = portal_ime_id(PORTAL_PACKAGE_NAME)
|
|
575
|
+
try:
|
|
576
|
+
await device.shell(f"ime disable {target_ime}")
|
|
577
|
+
return True
|
|
578
|
+
except Exception as e:
|
|
579
|
+
raise Exception("Error disabling keyboard") from e
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
async def setup_portal(
|
|
583
|
+
device: AdbDevice,
|
|
584
|
+
debug: bool = False,
|
|
585
|
+
) -> bool:
|
|
586
|
+
"""Download, install, and enable the Portal APK on a device.
|
|
587
|
+
|
|
588
|
+
Uses version mapping to find the compatible Portal version for the
|
|
589
|
+
current mobilerun SDK version. Falls back to the latest release if
|
|
590
|
+
the mapping is unavailable.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
device: ADB device connection.
|
|
594
|
+
debug: Enable debug logging.
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
True if setup completed successfully, False otherwise.
|
|
598
|
+
"""
|
|
599
|
+
try:
|
|
600
|
+
portal_version, download_base, mapping_fetched = get_compatible_portal_version(
|
|
601
|
+
__version__, debug
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
if portal_version:
|
|
605
|
+
apk_context = download_versioned_portal_apk(
|
|
606
|
+
portal_version, download_base, debug
|
|
607
|
+
)
|
|
608
|
+
else:
|
|
609
|
+
if not mapping_fetched:
|
|
610
|
+
logger.warning(
|
|
611
|
+
"Could not fetch version mapping, falling back to latest portal"
|
|
612
|
+
)
|
|
613
|
+
apk_context = download_portal_apk(debug)
|
|
614
|
+
|
|
615
|
+
with apk_context as apk_path:
|
|
616
|
+
if not os.path.exists(apk_path):
|
|
617
|
+
logger.error(f"APK file not found at {apk_path}")
|
|
618
|
+
return False
|
|
619
|
+
|
|
620
|
+
logger.info("Installing Portal APK...")
|
|
621
|
+
try:
|
|
622
|
+
await device.install(
|
|
623
|
+
apk_path, uninstall=True, flags=["-g"], silent=not debug
|
|
624
|
+
)
|
|
625
|
+
except Exception as e:
|
|
626
|
+
logger.error(f"Portal installation failed: {e}")
|
|
627
|
+
return False
|
|
628
|
+
|
|
629
|
+
logger.info("Portal APK installed")
|
|
630
|
+
|
|
631
|
+
try:
|
|
632
|
+
await enable_portal_accessibility(device)
|
|
633
|
+
# Wait for the service to become responsive
|
|
634
|
+
await _wait_for_portal_service(device)
|
|
635
|
+
logger.info("Accessibility service enabled")
|
|
636
|
+
except Exception as e:
|
|
637
|
+
logger.warning(f"Could not auto-enable accessibility service: {e}")
|
|
638
|
+
try:
|
|
639
|
+
await device.shell(
|
|
640
|
+
"am start -a android.settings.ACCESSIBILITY_SETTINGS"
|
|
641
|
+
)
|
|
642
|
+
except Exception:
|
|
643
|
+
pass
|
|
644
|
+
return False
|
|
645
|
+
|
|
646
|
+
return True
|
|
647
|
+
|
|
648
|
+
except Exception as e:
|
|
649
|
+
logger.error(f"Portal setup failed: {e}")
|
|
650
|
+
if debug:
|
|
651
|
+
import traceback
|
|
652
|
+
|
|
653
|
+
logger.debug(traceback.format_exc())
|
|
654
|
+
return False
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
async def _wait_for_portal_service(
|
|
658
|
+
device: AdbDevice, timeout: float = 10.0, interval: float = 1.0
|
|
659
|
+
) -> None:
|
|
660
|
+
"""Poll the content provider until the accessibility service is responsive.
|
|
661
|
+
|
|
662
|
+
Uses the simple ``/state`` endpoint which responds as soon as the
|
|
663
|
+
service process is alive, without requiring an active window.
|
|
664
|
+
"""
|
|
665
|
+
deadline = asyncio.get_event_loop().time() + timeout
|
|
666
|
+
while asyncio.get_event_loop().time() < deadline:
|
|
667
|
+
try:
|
|
668
|
+
uri = portal_content_uri(PORTAL_PACKAGE_NAME, "state")
|
|
669
|
+
state = await device.shell(f"content query --uri {uri}")
|
|
670
|
+
if '"status":"success"' in state:
|
|
671
|
+
return
|
|
672
|
+
except Exception:
|
|
673
|
+
pass
|
|
674
|
+
await asyncio.sleep(interval)
|
|
675
|
+
logger.warning("Portal service did not become responsive within timeout")
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _parse_portal_version(raw_output: str) -> str | None:
|
|
679
|
+
"""Extract portal version string from content provider output."""
|
|
680
|
+
try:
|
|
681
|
+
if "result=" in raw_output:
|
|
682
|
+
json_str = raw_output.split("result=", 1)[1].strip()
|
|
683
|
+
data = json.loads(json_str)
|
|
684
|
+
if data.get("status") == "success":
|
|
685
|
+
return data.get("result") or data.get("data")
|
|
686
|
+
except Exception:
|
|
687
|
+
pass
|
|
688
|
+
return None
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
async def ensure_portal_ready(
|
|
692
|
+
device: AdbDevice,
|
|
693
|
+
debug: bool = False,
|
|
694
|
+
) -> None:
|
|
695
|
+
"""Run parallel health checks and auto-fix portal issues.
|
|
696
|
+
|
|
697
|
+
Performs three checks concurrently:
|
|
698
|
+
1. Is the Portal APK installed?
|
|
699
|
+
2. Is the installed version compatible?
|
|
700
|
+
3. Is the accessibility service enabled?
|
|
701
|
+
|
|
702
|
+
If any check fails, attempts to fix automatically (install/upgrade
|
|
703
|
+
APK, enable accessibility). Raises on unrecoverable failure.
|
|
704
|
+
|
|
705
|
+
Args:
|
|
706
|
+
device: ADB device connection.
|
|
707
|
+
debug: Enable debug logging.
|
|
708
|
+
|
|
709
|
+
Raises:
|
|
710
|
+
RuntimeError: If portal cannot be made ready after auto-fix.
|
|
711
|
+
"""
|
|
712
|
+
# ── parallel checks ──────────────────────────────────────────
|
|
713
|
+
packages_task = device.list_packages()
|
|
714
|
+
version_task = device.shell(
|
|
715
|
+
f"content query --uri {portal_content_uri(PORTAL_PACKAGE_NAME, 'version')}"
|
|
716
|
+
)
|
|
717
|
+
a11y_task = device.shell("settings get secure enabled_accessibility_services")
|
|
718
|
+
|
|
719
|
+
packages, version_raw, a11y_services = await asyncio.gather(
|
|
720
|
+
packages_task, version_task, a11y_task, return_exceptions=True
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
# If all checks failed, the device is likely unreachable — skip
|
|
724
|
+
# auto-setup and let AndroidDriver.connect() surface the real error.
|
|
725
|
+
if (
|
|
726
|
+
isinstance(packages, Exception)
|
|
727
|
+
and isinstance(version_raw, Exception)
|
|
728
|
+
and isinstance(a11y_services, Exception)
|
|
729
|
+
):
|
|
730
|
+
logger.debug(f"Portal health check skipped (device unreachable): {packages}")
|
|
731
|
+
return
|
|
732
|
+
|
|
733
|
+
# ── evaluate results ─────────────────────────────────────────
|
|
734
|
+
is_installed = isinstance(packages, list) and PORTAL_PACKAGE_NAME in packages
|
|
735
|
+
|
|
736
|
+
installed_version = (
|
|
737
|
+
_parse_portal_version(version_raw) if isinstance(version_raw, str) else None
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
a11y_enabled = isinstance(a11y_services, str) and A11Y_SERVICE_NAME in a11y_services
|
|
741
|
+
|
|
742
|
+
# Check version compatibility
|
|
743
|
+
needs_upgrade = False
|
|
744
|
+
if is_installed and installed_version:
|
|
745
|
+
expected_version, _, mapping_fetched = get_compatible_portal_version(
|
|
746
|
+
__version__, debug
|
|
747
|
+
)
|
|
748
|
+
if expected_version and mapping_fetched:
|
|
749
|
+
needs_upgrade = installed_version != expected_version.lstrip("v")
|
|
750
|
+
if needs_upgrade:
|
|
751
|
+
logger.info(
|
|
752
|
+
f"Portal version mismatch: installed={installed_version}, "
|
|
753
|
+
f"expected={expected_version}"
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
# ── fix if needed ────────────────────────────────────────────
|
|
757
|
+
if not is_installed or needs_upgrade:
|
|
758
|
+
reason = "not installed" if not is_installed else "outdated"
|
|
759
|
+
logger.info(f"Portal {reason}, running auto-setup...")
|
|
760
|
+
success = await setup_portal(device, debug)
|
|
761
|
+
if not success:
|
|
762
|
+
raise RuntimeError(
|
|
763
|
+
f"Portal auto-setup failed ({reason}). "
|
|
764
|
+
"Run 'mobilerun doctor' for diagnostics."
|
|
765
|
+
)
|
|
766
|
+
# After install, accessibility is already enabled by setup_portal
|
|
767
|
+
return
|
|
768
|
+
|
|
769
|
+
if not a11y_enabled:
|
|
770
|
+
logger.info("Portal accessibility service not enabled, enabling...")
|
|
771
|
+
try:
|
|
772
|
+
await enable_portal_accessibility(device)
|
|
773
|
+
# Verify settings were applied
|
|
774
|
+
if not await check_portal_accessibility(device, debug=debug):
|
|
775
|
+
raise RuntimeError(
|
|
776
|
+
"Could not enable Portal accessibility service. "
|
|
777
|
+
"Please enable it manually in device settings, "
|
|
778
|
+
"or run 'mobilerun setup'."
|
|
779
|
+
)
|
|
780
|
+
# Wait for the service process to start and become responsive
|
|
781
|
+
await _wait_for_portal_service(device)
|
|
782
|
+
logger.info("Accessibility service enabled")
|
|
783
|
+
except RuntimeError:
|
|
784
|
+
raise
|
|
785
|
+
except Exception as e:
|
|
786
|
+
raise RuntimeError(
|
|
787
|
+
f"Failed to enable accessibility service: {e}. "
|
|
788
|
+
"Run 'mobilerun doctor' for diagnostics."
|
|
789
|
+
) from e
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
async def test():
|
|
793
|
+
device = await adb.device()
|
|
794
|
+
await ping_portal(device, debug=False)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
if __name__ == "__main__":
|
|
798
|
+
asyncio.run(test())
|