androidctl 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.
- androidctl/__init__.py +5 -0
- androidctl/__main__.py +4 -0
- androidctl/_version.py +1 -0
- androidctl/app.py +73 -0
- androidctl/cli_options.py +27 -0
- androidctl/command_payloads.py +264 -0
- androidctl/command_views.py +157 -0
- androidctl/commands/__init__.py +1 -0
- androidctl/commands/actions.py +236 -0
- androidctl/commands/adb_wireless.py +157 -0
- androidctl/commands/close.py +30 -0
- androidctl/commands/connect.py +69 -0
- androidctl/commands/execute.py +179 -0
- androidctl/commands/list_apps.py +26 -0
- androidctl/commands/observe.py +26 -0
- androidctl/commands/open.py +41 -0
- androidctl/commands/plumbing.py +58 -0
- androidctl/commands/run_pipeline.py +307 -0
- androidctl/commands/screenshot.py +29 -0
- androidctl/commands/setup.py +301 -0
- androidctl/commands/wait.py +60 -0
- androidctl/daemon/__init__.py +1 -0
- androidctl/daemon/client.py +348 -0
- androidctl/daemon/discovery.py +190 -0
- androidctl/daemon/launcher.py +26 -0
- androidctl/daemon/owner.py +349 -0
- androidctl/errors/__init__.py +1 -0
- androidctl/errors/mapping.py +149 -0
- androidctl/errors/models.py +16 -0
- androidctl/exit_codes.py +8 -0
- androidctl/output.py +147 -0
- androidctl/parsing/__init__.py +1 -0
- androidctl/parsing/duration.py +17 -0
- androidctl/parsing/open_target.py +51 -0
- androidctl/parsing/refs.py +12 -0
- androidctl/parsing/screen_id.py +10 -0
- androidctl/parsing/wait.py +70 -0
- androidctl/renderers/__init__.py +110 -0
- androidctl/renderers/_paths.py +109 -0
- androidctl/renderers/xml.py +234 -0
- androidctl/renderers/xml_projection.py +732 -0
- androidctl/resources/__init__.py +1 -0
- androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
- androidctl/setup/__init__.py +1 -0
- androidctl/setup/accessibility.py +159 -0
- androidctl/setup/adb.py +586 -0
- androidctl/setup/apk_resource.py +29 -0
- androidctl/setup/pairing.py +70 -0
- androidctl/setup/verify.py +175 -0
- androidctl/workspace/__init__.py +3 -0
- androidctl/workspace/resolve.py +27 -0
- androidctl-0.1.0.dist-info/METADATA +217 -0
- androidctl-0.1.0.dist-info/RECORD +187 -0
- androidctl-0.1.0.dist-info/WHEEL +5 -0
- androidctl-0.1.0.dist-info/entry_points.txt +3 -0
- androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
- androidctl-0.1.0.dist-info/top_level.txt +3 -0
- androidctl_contracts/__init__.py +55 -0
- androidctl_contracts/_version.py +1 -0
- androidctl_contracts/_wire_helpers.py +31 -0
- androidctl_contracts/base.py +142 -0
- androidctl_contracts/command_catalog.py +414 -0
- androidctl_contracts/command_results.py +630 -0
- androidctl_contracts/daemon_api.py +335 -0
- androidctl_contracts/errors.py +44 -0
- androidctl_contracts/paths.py +5 -0
- androidctl_contracts/public_screen.py +579 -0
- androidctl_contracts/user_state.py +23 -0
- androidctl_contracts/vocabulary.py +82 -0
- androidctld/__init__.py +5 -0
- androidctld/__main__.py +63 -0
- androidctld/_version.py +1 -0
- androidctld/actions/__init__.py +1 -0
- androidctld/actions/action_target.py +142 -0
- androidctld/actions/capabilities.py +539 -0
- androidctld/actions/executor.py +894 -0
- androidctld/actions/focus_confirmation.py +177 -0
- androidctld/actions/focused_input_admissibility.py +120 -0
- androidctld/actions/fresh_current.py +176 -0
- androidctld/actions/postconditions.py +473 -0
- androidctld/actions/repair.py +101 -0
- androidctld/actions/request_builder.py +204 -0
- androidctld/actions/settle.py +146 -0
- androidctld/actions/submit_confirmation.py +211 -0
- androidctld/actions/submit_routing.py +311 -0
- androidctld/actions/type_confirmation.py +257 -0
- androidctld/app_targets.py +71 -0
- androidctld/artifacts/__init__.py +1 -0
- androidctld/artifacts/models.py +26 -0
- androidctld/artifacts/screen_lookup.py +241 -0
- androidctld/artifacts/screen_payloads.py +109 -0
- androidctld/artifacts/writer.py +286 -0
- androidctld/auth/__init__.py +1 -0
- androidctld/auth/active_registry.py +266 -0
- androidctld/auth/secret_files.py +52 -0
- androidctld/auth/token_store.py +59 -0
- androidctld/commands/__init__.py +1 -0
- androidctld/commands/assembly.py +231 -0
- androidctld/commands/command_models.py +254 -0
- androidctld/commands/dispatch.py +99 -0
- androidctld/commands/executor.py +31 -0
- androidctld/commands/from_boundary.py +175 -0
- androidctld/commands/handlers/__init__.py +15 -0
- androidctld/commands/handlers/action.py +439 -0
- androidctld/commands/handlers/connect.py +94 -0
- androidctld/commands/handlers/list_apps.py +215 -0
- androidctld/commands/handlers/observe.py +121 -0
- androidctld/commands/handlers/screenshot.py +105 -0
- androidctld/commands/handlers/wait.py +286 -0
- androidctld/commands/models.py +65 -0
- androidctld/commands/open_targets.py +56 -0
- androidctld/commands/orchestration.py +353 -0
- androidctld/commands/registry.py +116 -0
- androidctld/commands/result_builders.py +40 -0
- androidctld/commands/result_models.py +555 -0
- androidctld/commands/results.py +108 -0
- androidctld/commands/semantic_command_names.py +17 -0
- androidctld/commands/semantic_error_mapping.py +93 -0
- androidctld/commands/semantic_truth.py +135 -0
- androidctld/commands/service.py +67 -0
- androidctld/config.py +75 -0
- androidctld/daemon/__init__.py +1 -0
- androidctld/daemon/active_slot.py +326 -0
- androidctld/daemon/envelope.py +30 -0
- androidctld/daemon/http_host.py +123 -0
- androidctld/daemon/ingress.py +112 -0
- androidctld/daemon/ownership_probe.py +204 -0
- androidctld/daemon/server.py +286 -0
- androidctld/daemon/service.py +99 -0
- androidctld/device/__init__.py +1 -0
- androidctld/device/action_models.py +154 -0
- androidctld/device/action_serialization.py +121 -0
- androidctld/device/adapters.py +220 -0
- androidctld/device/bootstrap.py +153 -0
- androidctld/device/connectors.py +231 -0
- androidctld/device/errors.py +100 -0
- androidctld/device/interfaces.py +58 -0
- androidctld/device/parsing.py +320 -0
- androidctld/device/rpc.py +483 -0
- androidctld/device/schema.py +114 -0
- androidctld/device/types.py +161 -0
- androidctld/errors/__init__.py +94 -0
- androidctld/logging/__init__.py +22 -0
- androidctld/observation.py +98 -0
- androidctld/protocol.py +53 -0
- androidctld/refs/__init__.py +1 -0
- androidctld/refs/models.py +54 -0
- androidctld/refs/repair.py +284 -0
- androidctld/refs/service.py +422 -0
- androidctld/rendering/__init__.py +1 -0
- androidctld/rendering/screen_xml.py +256 -0
- androidctld/runtime/__init__.py +21 -0
- androidctld/runtime/kernel.py +548 -0
- androidctld/runtime/lifecycle.py +19 -0
- androidctld/runtime/models.py +48 -0
- androidctld/runtime/screen_state.py +117 -0
- androidctld/runtime/state_repo.py +70 -0
- androidctld/runtime/store.py +76 -0
- androidctld/runtime_policy.py +127 -0
- androidctld/schema/__init__.py +5 -0
- androidctld/schema/base.py +132 -0
- androidctld/schema/core.py +35 -0
- androidctld/schema/daemon_api.py +108 -0
- androidctld/schema/persistence.py +161 -0
- androidctld/schema/persistence_io.py +41 -0
- androidctld/schema/validation_errors.py +309 -0
- androidctld/semantics/__init__.py +1 -0
- androidctld/semantics/compiler.py +610 -0
- androidctld/semantics/continuity.py +107 -0
- androidctld/semantics/labels.py +252 -0
- androidctld/semantics/models.py +25 -0
- androidctld/semantics/policy.py +23 -0
- androidctld/semantics/public_models.py +123 -0
- androidctld/semantics/registries.py +13 -0
- androidctld/semantics/submit_refs.py +417 -0
- androidctld/semantics/surface.py +254 -0
- androidctld/semantics/targets.py +167 -0
- androidctld/snapshots/__init__.py +1 -0
- androidctld/snapshots/models.py +219 -0
- androidctld/snapshots/refresh.py +273 -0
- androidctld/snapshots/schema.py +74 -0
- androidctld/snapshots/service.py +138 -0
- androidctld/text_equivalence.py +67 -0
- androidctld/waits/__init__.py +1 -0
- androidctld/waits/evaluators.py +216 -0
- androidctld/waits/loop.py +305 -0
- androidctld/waits/matcher.py +41 -0
androidctl/setup/adb.py
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
ANDROIDCTL_PACKAGE = "com.rainng.androidctl"
|
|
10
|
+
ANDROIDCTL_MAIN_ACTIVITY = f"{ANDROIDCTL_PACKAGE}/.MainActivity"
|
|
11
|
+
ANDROIDCTL_SETUP_ACTIVITY = f"{ANDROIDCTL_PACKAGE}/.SetupActivity"
|
|
12
|
+
ANDROIDCTL_SETUP_ACTION = "com.rainng.androidctl.action.SETUP"
|
|
13
|
+
ANDROIDCTL_ACCESSIBILITY_SERVICE_CLASS = (
|
|
14
|
+
f"{ANDROIDCTL_PACKAGE}.agent.service.DeviceAccessibilityService"
|
|
15
|
+
)
|
|
16
|
+
ANDROIDCTL_ACCESSIBILITY_SERVICE = (
|
|
17
|
+
f"{ANDROIDCTL_PACKAGE}/{ANDROIDCTL_ACCESSIBILITY_SERVICE_CLASS}"
|
|
18
|
+
)
|
|
19
|
+
ANDROIDCTL_AGENT_PORT = 17171
|
|
20
|
+
|
|
21
|
+
_MAX_ERROR_OUTPUT_CHARS = 400
|
|
22
|
+
_TOKEN_PATTERNS = (
|
|
23
|
+
re.compile(r"(?i)\b(token\s*[=:]\s*)(\S+)"),
|
|
24
|
+
re.compile(r"(?i)\b(bearer\s+)(\S+)"),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class AdbDevice:
|
|
30
|
+
serial: str
|
|
31
|
+
state: str
|
|
32
|
+
details: tuple[str, ...] = ()
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_eligible(self) -> bool:
|
|
36
|
+
return self.state == "device"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SetupAdbError(RuntimeError):
|
|
40
|
+
def __init__(self, code: str, message: str) -> None:
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.code = code
|
|
43
|
+
self.message = message
|
|
44
|
+
self.layer = "ADB"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class AdbCommandResult:
|
|
49
|
+
stdout: str = ""
|
|
50
|
+
stderr: str = ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_adb_command(
|
|
54
|
+
args: Sequence[str],
|
|
55
|
+
*,
|
|
56
|
+
adb_path: str = "adb",
|
|
57
|
+
serial: str | None = None,
|
|
58
|
+
) -> list[str]:
|
|
59
|
+
command = [adb_path]
|
|
60
|
+
if serial:
|
|
61
|
+
command.extend(["-s", serial])
|
|
62
|
+
command.extend(args)
|
|
63
|
+
return command
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run_adb(
|
|
67
|
+
args: Sequence[str],
|
|
68
|
+
*,
|
|
69
|
+
adb_path: str = "adb",
|
|
70
|
+
serial: str | None = None,
|
|
71
|
+
timeout_s: float = 10.0,
|
|
72
|
+
operation: str = "adb command",
|
|
73
|
+
failure_code: str = "ADB_COMMAND_FAILED",
|
|
74
|
+
sensitive_values: Iterable[str] = (),
|
|
75
|
+
) -> AdbCommandResult:
|
|
76
|
+
result = _run_adb_process(
|
|
77
|
+
args,
|
|
78
|
+
adb_path=adb_path,
|
|
79
|
+
serial=serial,
|
|
80
|
+
timeout_s=timeout_s,
|
|
81
|
+
operation=operation,
|
|
82
|
+
)
|
|
83
|
+
_raise_for_nonzero(
|
|
84
|
+
result,
|
|
85
|
+
code=failure_code,
|
|
86
|
+
operation=operation,
|
|
87
|
+
sensitive_values=_sensitive_values(serial, sensitive_values),
|
|
88
|
+
)
|
|
89
|
+
return AdbCommandResult(stdout=result.stdout, stderr=result.stderr)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def list_adb_devices(
|
|
93
|
+
*,
|
|
94
|
+
adb_path: str = "adb",
|
|
95
|
+
timeout_s: float = 5.0,
|
|
96
|
+
) -> list[AdbDevice]:
|
|
97
|
+
result = run_adb(
|
|
98
|
+
["devices"],
|
|
99
|
+
adb_path=adb_path,
|
|
100
|
+
timeout_s=timeout_s,
|
|
101
|
+
operation="adb devices",
|
|
102
|
+
failure_code="ADB_COMMAND_FAILED",
|
|
103
|
+
)
|
|
104
|
+
return parse_adb_devices_output(result.stdout)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_adb_devices_output(output: str) -> list[AdbDevice]:
|
|
108
|
+
devices: list[AdbDevice] = []
|
|
109
|
+
for raw_line in output.splitlines():
|
|
110
|
+
line = raw_line.strip()
|
|
111
|
+
if not line or line == "List of devices attached" or line.startswith("* "):
|
|
112
|
+
continue
|
|
113
|
+
parts = line.split()
|
|
114
|
+
if len(parts) < 2:
|
|
115
|
+
continue
|
|
116
|
+
devices.append(
|
|
117
|
+
AdbDevice(
|
|
118
|
+
serial=parts[0],
|
|
119
|
+
state=parts[1],
|
|
120
|
+
details=tuple(parts[2:]),
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
return devices
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def select_eligible_device(
|
|
127
|
+
devices: list[AdbDevice],
|
|
128
|
+
*,
|
|
129
|
+
serial: str | None = None,
|
|
130
|
+
) -> AdbDevice:
|
|
131
|
+
if serial is not None:
|
|
132
|
+
for device in devices:
|
|
133
|
+
if device.serial != serial:
|
|
134
|
+
continue
|
|
135
|
+
if device.is_eligible:
|
|
136
|
+
return device
|
|
137
|
+
raise SetupAdbError(
|
|
138
|
+
"NO_ELIGIBLE_ADB_DEVICE",
|
|
139
|
+
f"requested ADB device is {device.state!r}, expected 'device'",
|
|
140
|
+
)
|
|
141
|
+
raise SetupAdbError(
|
|
142
|
+
"NO_ELIGIBLE_ADB_DEVICE",
|
|
143
|
+
"requested ADB device was not found",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
eligible_devices = [device for device in devices if device.is_eligible]
|
|
147
|
+
if not eligible_devices:
|
|
148
|
+
raise SetupAdbError(
|
|
149
|
+
"NO_ELIGIBLE_ADB_DEVICE",
|
|
150
|
+
"no authorized ADB device is in 'device' state",
|
|
151
|
+
)
|
|
152
|
+
if len(eligible_devices) > 1:
|
|
153
|
+
raise SetupAdbError(
|
|
154
|
+
"MULTIPLE_ADB_DEVICES",
|
|
155
|
+
"multiple authorized ADB devices found; pass --serial",
|
|
156
|
+
)
|
|
157
|
+
return eligible_devices[0]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def install_apk(
|
|
161
|
+
apk_path: Path,
|
|
162
|
+
*,
|
|
163
|
+
serial: str,
|
|
164
|
+
adb_path: str = "adb",
|
|
165
|
+
timeout_s: float = 120.0,
|
|
166
|
+
) -> AdbCommandResult:
|
|
167
|
+
if not apk_path.is_file():
|
|
168
|
+
raise SetupAdbError("APK_NOT_FOUND", "APK file was not found")
|
|
169
|
+
|
|
170
|
+
operation = "adb install"
|
|
171
|
+
result = _run_adb_process(
|
|
172
|
+
["install", "-r", str(apk_path)],
|
|
173
|
+
adb_path=adb_path,
|
|
174
|
+
serial=serial,
|
|
175
|
+
timeout_s=timeout_s,
|
|
176
|
+
operation=operation,
|
|
177
|
+
)
|
|
178
|
+
output = _combined_output(result)
|
|
179
|
+
if result.returncode != 0:
|
|
180
|
+
code = classify_install_failure(output)
|
|
181
|
+
message = _install_failure_message(code)
|
|
182
|
+
_raise_for_nonzero(
|
|
183
|
+
result,
|
|
184
|
+
code=code,
|
|
185
|
+
operation=operation,
|
|
186
|
+
sensitive_values=_sensitive_values(serial, (str(apk_path),)),
|
|
187
|
+
message_prefix=message,
|
|
188
|
+
)
|
|
189
|
+
return AdbCommandResult(stdout=result.stdout, stderr=result.stderr)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def classify_install_failure(output: str) -> str:
|
|
193
|
+
if "INSTALL_FAILED_VERSION_DOWNGRADE" in output:
|
|
194
|
+
return "ADB_INSTALL_DOWNGRADE"
|
|
195
|
+
if "INSTALL_FAILED_UPDATE_INCOMPATIBLE" in output:
|
|
196
|
+
return "ADB_INSTALL_SIGNATURE_MISMATCH"
|
|
197
|
+
if "INSTALL_FAILED_SHARED_USER_INCOMPATIBLE" in output:
|
|
198
|
+
return "ADB_INSTALL_SIGNATURE_MISMATCH"
|
|
199
|
+
return "ADB_INSTALL_FAILED"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def force_stop_app(
|
|
203
|
+
*,
|
|
204
|
+
serial: str,
|
|
205
|
+
adb_path: str = "adb",
|
|
206
|
+
package: str = ANDROIDCTL_PACKAGE,
|
|
207
|
+
timeout_s: float = 10.0,
|
|
208
|
+
) -> AdbCommandResult:
|
|
209
|
+
return run_adb(
|
|
210
|
+
["shell", "am", "force-stop", package],
|
|
211
|
+
adb_path=adb_path,
|
|
212
|
+
serial=serial,
|
|
213
|
+
timeout_s=timeout_s,
|
|
214
|
+
operation="adb force-stop app",
|
|
215
|
+
failure_code="ADB_FORCE_STOP_FAILED",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def start_setup_activity(
|
|
220
|
+
*,
|
|
221
|
+
serial: str,
|
|
222
|
+
adb_path: str = "adb",
|
|
223
|
+
component: str = ANDROIDCTL_SETUP_ACTIVITY,
|
|
224
|
+
action: str = ANDROIDCTL_SETUP_ACTION,
|
|
225
|
+
string_extras: Mapping[str, str] | None = None,
|
|
226
|
+
timeout_s: float = 10.0,
|
|
227
|
+
) -> AdbCommandResult:
|
|
228
|
+
args = ["shell", "am", "start", "-n", component, "-a", action]
|
|
229
|
+
sensitive_values: list[str] = []
|
|
230
|
+
for key, value in (string_extras or {}).items():
|
|
231
|
+
args.extend(["--es", key, value])
|
|
232
|
+
sensitive_values.append(value)
|
|
233
|
+
return run_adb(
|
|
234
|
+
args,
|
|
235
|
+
adb_path=adb_path,
|
|
236
|
+
serial=serial,
|
|
237
|
+
timeout_s=timeout_s,
|
|
238
|
+
operation="adb start setup activity",
|
|
239
|
+
failure_code="ADB_LAUNCH_FAILED",
|
|
240
|
+
sensitive_values=sensitive_values,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def forward_agent_port(
|
|
245
|
+
*,
|
|
246
|
+
serial: str,
|
|
247
|
+
adb_path: str = "adb",
|
|
248
|
+
local_port: int | None = None,
|
|
249
|
+
remote_port: int = ANDROIDCTL_AGENT_PORT,
|
|
250
|
+
timeout_s: float = 10.0,
|
|
251
|
+
) -> int:
|
|
252
|
+
dynamic_local_port = local_port is None or local_port == 0
|
|
253
|
+
if not dynamic_local_port:
|
|
254
|
+
if local_port is None:
|
|
255
|
+
raise SetupAdbError("ADB_INVALID_PORT", "local port must be provided")
|
|
256
|
+
_validate_tcp_port(local_port, label="local port")
|
|
257
|
+
_validate_tcp_port(remote_port, label="remote port")
|
|
258
|
+
|
|
259
|
+
local_spec = "tcp:0" if dynamic_local_port else f"tcp:{local_port}"
|
|
260
|
+
result = run_adb(
|
|
261
|
+
["forward", local_spec, f"tcp:{remote_port}"],
|
|
262
|
+
adb_path=adb_path,
|
|
263
|
+
serial=serial,
|
|
264
|
+
timeout_s=timeout_s,
|
|
265
|
+
operation="adb forward",
|
|
266
|
+
failure_code="ADB_FORWARD_FAILED",
|
|
267
|
+
)
|
|
268
|
+
if not dynamic_local_port:
|
|
269
|
+
if local_port is None:
|
|
270
|
+
raise SetupAdbError("ADB_INVALID_PORT", "local port must be provided")
|
|
271
|
+
return local_port
|
|
272
|
+
|
|
273
|
+
allocated_port = result.stdout.strip().splitlines()[0] if result.stdout else ""
|
|
274
|
+
try:
|
|
275
|
+
return int(allocated_port)
|
|
276
|
+
except ValueError as exc:
|
|
277
|
+
raise SetupAdbError(
|
|
278
|
+
"ADB_FORWARD_FAILED",
|
|
279
|
+
"adb forward did not report an allocated local port",
|
|
280
|
+
) from exc
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def get_secure_setting(
|
|
284
|
+
key: str,
|
|
285
|
+
*,
|
|
286
|
+
serial: str,
|
|
287
|
+
adb_path: str = "adb",
|
|
288
|
+
timeout_s: float = 10.0,
|
|
289
|
+
) -> str:
|
|
290
|
+
result = run_adb(
|
|
291
|
+
["shell", "settings", "get", "secure", key],
|
|
292
|
+
adb_path=adb_path,
|
|
293
|
+
serial=serial,
|
|
294
|
+
timeout_s=timeout_s,
|
|
295
|
+
operation="adb settings get secure",
|
|
296
|
+
failure_code="ADB_SETTINGS_FAILED",
|
|
297
|
+
)
|
|
298
|
+
return result.stdout.strip()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def put_secure_setting(
|
|
302
|
+
key: str,
|
|
303
|
+
value: str,
|
|
304
|
+
*,
|
|
305
|
+
serial: str,
|
|
306
|
+
adb_path: str = "adb",
|
|
307
|
+
timeout_s: float = 10.0,
|
|
308
|
+
) -> AdbCommandResult:
|
|
309
|
+
return run_adb(
|
|
310
|
+
["shell", "settings", "put", "secure", key, value],
|
|
311
|
+
adb_path=adb_path,
|
|
312
|
+
serial=serial,
|
|
313
|
+
timeout_s=timeout_s,
|
|
314
|
+
operation="adb settings put secure",
|
|
315
|
+
failure_code="ADB_SETTINGS_FAILED",
|
|
316
|
+
sensitive_values=(value,),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def get_package_dump(
|
|
321
|
+
package: str = ANDROIDCTL_PACKAGE,
|
|
322
|
+
*,
|
|
323
|
+
serial: str,
|
|
324
|
+
adb_path: str = "adb",
|
|
325
|
+
timeout_s: float = 10.0,
|
|
326
|
+
) -> str:
|
|
327
|
+
result = run_adb(
|
|
328
|
+
["shell", "dumpsys", "package", package],
|
|
329
|
+
adb_path=adb_path,
|
|
330
|
+
serial=serial,
|
|
331
|
+
timeout_s=timeout_s,
|
|
332
|
+
operation="adb dumpsys package",
|
|
333
|
+
failure_code="ADB_PACKAGE_QUERY_FAILED",
|
|
334
|
+
)
|
|
335
|
+
return result.stdout
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def get_package_paths(
|
|
339
|
+
package: str = ANDROIDCTL_PACKAGE,
|
|
340
|
+
*,
|
|
341
|
+
serial: str,
|
|
342
|
+
adb_path: str = "adb",
|
|
343
|
+
timeout_s: float = 10.0,
|
|
344
|
+
) -> tuple[str, ...]:
|
|
345
|
+
result = run_adb(
|
|
346
|
+
["shell", "pm", "path", package],
|
|
347
|
+
adb_path=adb_path,
|
|
348
|
+
serial=serial,
|
|
349
|
+
timeout_s=timeout_s,
|
|
350
|
+
operation="adb pm path",
|
|
351
|
+
failure_code="ADB_PACKAGE_QUERY_FAILED",
|
|
352
|
+
)
|
|
353
|
+
return parse_pm_path_output(result.stdout)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def parse_pm_path_output(output: str) -> tuple[str, ...]:
|
|
357
|
+
paths: list[str] = []
|
|
358
|
+
for raw_line in output.splitlines():
|
|
359
|
+
line = raw_line.strip()
|
|
360
|
+
if not line:
|
|
361
|
+
continue
|
|
362
|
+
if line.startswith("package:"):
|
|
363
|
+
paths.append(line.removeprefix("package:"))
|
|
364
|
+
else:
|
|
365
|
+
paths.append(line)
|
|
366
|
+
return tuple(paths)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def pair_wireless_device(
|
|
370
|
+
*,
|
|
371
|
+
pair_endpoint: str,
|
|
372
|
+
code: str,
|
|
373
|
+
adb_path: str = "adb",
|
|
374
|
+
timeout_s: float = 30.0,
|
|
375
|
+
) -> AdbCommandResult:
|
|
376
|
+
endpoint = validate_wireless_endpoint(pair_endpoint, label="pair endpoint")
|
|
377
|
+
normalized_code = code.strip()
|
|
378
|
+
if not normalized_code:
|
|
379
|
+
raise SetupAdbError(
|
|
380
|
+
"ADB_PAIR_CODE_REQUIRED",
|
|
381
|
+
"pairing code is required; open Android Wireless debugging to get it",
|
|
382
|
+
)
|
|
383
|
+
result: AdbCommandResult | None = None
|
|
384
|
+
adb_error: SetupAdbError | None = None
|
|
385
|
+
try:
|
|
386
|
+
result = run_adb(
|
|
387
|
+
["pair", endpoint, normalized_code],
|
|
388
|
+
adb_path=adb_path,
|
|
389
|
+
timeout_s=timeout_s,
|
|
390
|
+
operation="adb pair",
|
|
391
|
+
failure_code="ADB_PAIR_FAILED",
|
|
392
|
+
sensitive_values=(normalized_code,),
|
|
393
|
+
)
|
|
394
|
+
except SetupAdbError as error:
|
|
395
|
+
adb_error = SetupAdbError(error.code, error.message)
|
|
396
|
+
if adb_error is not None:
|
|
397
|
+
raise adb_error
|
|
398
|
+
if result is None:
|
|
399
|
+
raise SetupAdbError("ADB_PAIR_FAILED", "adb pair did not return a result")
|
|
400
|
+
if not parse_adb_pair_success(_combined_output_result(result)):
|
|
401
|
+
raise SetupAdbError("ADB_PAIR_FAILED", "adb pair did not report success")
|
|
402
|
+
return result
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def connect_wireless_device(
|
|
406
|
+
*,
|
|
407
|
+
connect_endpoint: str,
|
|
408
|
+
adb_path: str = "adb",
|
|
409
|
+
timeout_s: float = 30.0,
|
|
410
|
+
) -> AdbCommandResult:
|
|
411
|
+
endpoint = validate_wireless_endpoint(connect_endpoint, label="connect endpoint")
|
|
412
|
+
result = run_adb(
|
|
413
|
+
["connect", endpoint],
|
|
414
|
+
adb_path=adb_path,
|
|
415
|
+
timeout_s=timeout_s,
|
|
416
|
+
operation="adb connect",
|
|
417
|
+
failure_code="ADB_CONNECT_FAILED",
|
|
418
|
+
)
|
|
419
|
+
if not parse_adb_connect_success(
|
|
420
|
+
_combined_output_result(result),
|
|
421
|
+
endpoint=endpoint,
|
|
422
|
+
):
|
|
423
|
+
raise SetupAdbError("ADB_CONNECT_FAILED", "adb connect did not report success")
|
|
424
|
+
return result
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def validate_wireless_endpoint(endpoint: str, *, label: str) -> str:
|
|
428
|
+
normalized = endpoint.strip()
|
|
429
|
+
host, separator, port_text = normalized.rpartition(":")
|
|
430
|
+
if not normalized or not separator or not host or not port_text:
|
|
431
|
+
raise SetupAdbError(
|
|
432
|
+
"ADB_INVALID_WIRELESS_ENDPOINT",
|
|
433
|
+
f"{label} must be HOST:PORT",
|
|
434
|
+
)
|
|
435
|
+
try:
|
|
436
|
+
port = int(port_text)
|
|
437
|
+
except ValueError as exc:
|
|
438
|
+
raise SetupAdbError(
|
|
439
|
+
"ADB_INVALID_WIRELESS_ENDPOINT",
|
|
440
|
+
f"{label} port must be numeric",
|
|
441
|
+
) from exc
|
|
442
|
+
try:
|
|
443
|
+
_validate_tcp_port(port, label=f"{label} port")
|
|
444
|
+
except SetupAdbError as exc:
|
|
445
|
+
raise SetupAdbError(
|
|
446
|
+
"ADB_INVALID_WIRELESS_ENDPOINT",
|
|
447
|
+
f"{label} port must be between 1 and 65535",
|
|
448
|
+
) from exc
|
|
449
|
+
return normalized
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def parse_adb_pair_success(output: str) -> bool:
|
|
453
|
+
return "successfully paired" in output.lower()
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def parse_adb_connect_success(output: str, *, endpoint: str | None = None) -> bool:
|
|
457
|
+
normalized = output.lower()
|
|
458
|
+
success = "connected to " in normalized or "already connected to " in normalized
|
|
459
|
+
if not success or endpoint is None:
|
|
460
|
+
return success
|
|
461
|
+
return endpoint.lower() in normalized
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def redact_adb_output(
|
|
465
|
+
text: str,
|
|
466
|
+
*,
|
|
467
|
+
sensitive_values: Iterable[str] = (),
|
|
468
|
+
) -> str:
|
|
469
|
+
redacted = text
|
|
470
|
+
for value in sensitive_values:
|
|
471
|
+
if value:
|
|
472
|
+
redacted = redacted.replace(value, "<redacted>")
|
|
473
|
+
for pattern in _TOKEN_PATTERNS:
|
|
474
|
+
redacted = pattern.sub(r"\1<redacted>", redacted)
|
|
475
|
+
return redacted
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _run_adb_process(
|
|
479
|
+
args: Sequence[str],
|
|
480
|
+
*,
|
|
481
|
+
adb_path: str,
|
|
482
|
+
serial: str | None,
|
|
483
|
+
timeout_s: float,
|
|
484
|
+
operation: str,
|
|
485
|
+
) -> subprocess.CompletedProcess[str]:
|
|
486
|
+
try:
|
|
487
|
+
return subprocess.run(
|
|
488
|
+
build_adb_command(args, adb_path=adb_path, serial=serial),
|
|
489
|
+
capture_output=True,
|
|
490
|
+
text=True,
|
|
491
|
+
check=False,
|
|
492
|
+
timeout=timeout_s,
|
|
493
|
+
)
|
|
494
|
+
except FileNotFoundError as exc:
|
|
495
|
+
raise SetupAdbError(
|
|
496
|
+
"ADB_NOT_FOUND",
|
|
497
|
+
f"adb executable not found: {adb_path}",
|
|
498
|
+
) from exc
|
|
499
|
+
except subprocess.TimeoutExpired as exc:
|
|
500
|
+
raise SetupAdbError("ADB_TIMEOUT", f"{operation} timed out") from exc
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _raise_for_nonzero(
|
|
504
|
+
result: subprocess.CompletedProcess[str],
|
|
505
|
+
*,
|
|
506
|
+
code: str,
|
|
507
|
+
operation: str,
|
|
508
|
+
sensitive_values: Iterable[str],
|
|
509
|
+
message_prefix: str | None = None,
|
|
510
|
+
) -> None:
|
|
511
|
+
if result.returncode == 0:
|
|
512
|
+
return
|
|
513
|
+
|
|
514
|
+
output = _summarize_process_output(result, sensitive_values=sensitive_values)
|
|
515
|
+
message = message_prefix or f"{operation} failed"
|
|
516
|
+
if output:
|
|
517
|
+
message = f"{message}: {output}"
|
|
518
|
+
raise SetupAdbError(code, message)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _summarize_process_output(
|
|
522
|
+
result: subprocess.CompletedProcess[str],
|
|
523
|
+
*,
|
|
524
|
+
sensitive_values: Iterable[str],
|
|
525
|
+
) -> str:
|
|
526
|
+
parts: list[str] = []
|
|
527
|
+
stderr = _normalize_output(
|
|
528
|
+
redact_adb_output(result.stderr, sensitive_values=sensitive_values)
|
|
529
|
+
)
|
|
530
|
+
stdout = _normalize_output(
|
|
531
|
+
redact_adb_output(result.stdout, sensitive_values=sensitive_values)
|
|
532
|
+
)
|
|
533
|
+
if stderr:
|
|
534
|
+
parts.append(f"stderr={stderr}")
|
|
535
|
+
if stdout:
|
|
536
|
+
parts.append(f"stdout={stdout}")
|
|
537
|
+
return _clip_output("; ".join(parts))
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _combined_output(result: subprocess.CompletedProcess[str]) -> str:
|
|
541
|
+
return "\n".join((result.stdout, result.stderr))
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _combined_output_result(result: AdbCommandResult) -> str:
|
|
545
|
+
return "\n".join((result.stdout, result.stderr))
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _sensitive_values(
|
|
549
|
+
serial: str | None,
|
|
550
|
+
values: Iterable[str],
|
|
551
|
+
) -> tuple[str, ...]:
|
|
552
|
+
sensitive = [value for value in values if value]
|
|
553
|
+
if serial:
|
|
554
|
+
sensitive.append(serial)
|
|
555
|
+
return tuple(sensitive)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _normalize_output(output: str) -> str:
|
|
559
|
+
return "\n".join(line.rstrip() for line in output.splitlines()).strip()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _clip_output(output: str) -> str:
|
|
563
|
+
if len(output) <= _MAX_ERROR_OUTPUT_CHARS:
|
|
564
|
+
return output
|
|
565
|
+
return f"{output[: _MAX_ERROR_OUTPUT_CHARS - 3]}..."
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _validate_tcp_port(port: int, *, label: str) -> None:
|
|
569
|
+
if port < 1 or port > 65535:
|
|
570
|
+
raise SetupAdbError("ADB_INVALID_PORT", f"{label} must be between 1 and 65535")
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _install_failure_message(code: str) -> str:
|
|
574
|
+
if code == "ADB_INSTALL_SIGNATURE_MISMATCH":
|
|
575
|
+
return (
|
|
576
|
+
"adb install failed because an installed app appears to use a "
|
|
577
|
+
"different signing key; setup will not uninstall or clear app data "
|
|
578
|
+
"automatically"
|
|
579
|
+
)
|
|
580
|
+
if code == "ADB_INSTALL_DOWNGRADE":
|
|
581
|
+
return (
|
|
582
|
+
"adb install failed because the target APK is older than the "
|
|
583
|
+
"installed app; setup will not downgrade or clear app data "
|
|
584
|
+
"automatically"
|
|
585
|
+
)
|
|
586
|
+
return "adb install failed"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
AGENT_APK_RESOURCE_PACKAGE = "androidctl.resources"
|
|
10
|
+
AGENT_APK_NAME_TEMPLATE = "androidctl-agent-{version}-release.apk"
|
|
11
|
+
_VERSION_PATTERN = re.compile(r"\d+\.\d+\.\d+\Z")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def packaged_agent_apk_name(version: str) -> str:
|
|
15
|
+
if not _VERSION_PATTERN.fullmatch(version):
|
|
16
|
+
raise ValueError("version must be MAJOR.MINOR.PATCH")
|
|
17
|
+
return AGENT_APK_NAME_TEMPLATE.format(version=version)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@contextmanager
|
|
21
|
+
def packaged_agent_apk_path(version: str) -> Iterator[Path]:
|
|
22
|
+
apk_name = packaged_agent_apk_name(version)
|
|
23
|
+
resource = resources.files(AGENT_APK_RESOURCE_PACKAGE).joinpath(apk_name)
|
|
24
|
+
if not resource.is_file():
|
|
25
|
+
raise FileNotFoundError(
|
|
26
|
+
f"packaged Android Device Agent APK not found: {apk_name}"
|
|
27
|
+
)
|
|
28
|
+
with resources.as_file(resource) as apk_path:
|
|
29
|
+
yield apk_path
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import re
|
|
5
|
+
import secrets
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
SETUP_DEVICE_TOKEN_EXTRA = "androidctl.setup.deviceToken"
|
|
9
|
+
HOST_TOKEN_BYTES = 32
|
|
10
|
+
HOST_TOKEN_ENCODED_LENGTH = 43
|
|
11
|
+
|
|
12
|
+
_HOST_TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{43}$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SetupPairingError(ValueError):
|
|
16
|
+
def __init__(self, code: str, message: str) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.code = code
|
|
19
|
+
self.message = message
|
|
20
|
+
self.layer = "token"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def generate_host_token(
|
|
24
|
+
*,
|
|
25
|
+
token_bytes: Callable[[int], bytes] = secrets.token_bytes,
|
|
26
|
+
) -> str:
|
|
27
|
+
raw_token = token_bytes(HOST_TOKEN_BYTES)
|
|
28
|
+
if len(raw_token) != HOST_TOKEN_BYTES:
|
|
29
|
+
raise SetupPairingError(
|
|
30
|
+
"SETUP_TOKEN_GENERATION_FAILED",
|
|
31
|
+
"host token generator returned the wrong number of bytes",
|
|
32
|
+
)
|
|
33
|
+
return base64.urlsafe_b64encode(raw_token).decode("ascii").rstrip("=")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_host_token(token: str) -> str:
|
|
37
|
+
if not token:
|
|
38
|
+
raise SetupPairingError("SETUP_TOKEN_INVALID", "host token is required")
|
|
39
|
+
if not _HOST_TOKEN_PATTERN.fullmatch(token):
|
|
40
|
+
raise SetupPairingError(
|
|
41
|
+
"SETUP_TOKEN_INVALID",
|
|
42
|
+
"host token must be canonical base64url without padding",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
decoded = _decode_token(token)
|
|
46
|
+
if len(decoded) != HOST_TOKEN_BYTES:
|
|
47
|
+
raise SetupPairingError(
|
|
48
|
+
"SETUP_TOKEN_INVALID",
|
|
49
|
+
"host token must decode to 32 bytes",
|
|
50
|
+
)
|
|
51
|
+
if _encode_token(decoded) != token:
|
|
52
|
+
raise SetupPairingError(
|
|
53
|
+
"SETUP_TOKEN_INVALID",
|
|
54
|
+
"host token must be canonical base64url without padding",
|
|
55
|
+
)
|
|
56
|
+
return token
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _decode_token(token: str) -> bytes:
|
|
60
|
+
try:
|
|
61
|
+
return base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))
|
|
62
|
+
except ValueError as exc:
|
|
63
|
+
raise SetupPairingError(
|
|
64
|
+
"SETUP_TOKEN_INVALID",
|
|
65
|
+
"host token must be valid base64url",
|
|
66
|
+
) from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _encode_token(raw_token: bytes) -> str:
|
|
70
|
+
return base64.urlsafe_b64encode(raw_token).decode("ascii").rstrip("=")
|