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.
Files changed (187) hide show
  1. androidctl/__init__.py +5 -0
  2. androidctl/__main__.py +4 -0
  3. androidctl/_version.py +1 -0
  4. androidctl/app.py +73 -0
  5. androidctl/cli_options.py +27 -0
  6. androidctl/command_payloads.py +264 -0
  7. androidctl/command_views.py +157 -0
  8. androidctl/commands/__init__.py +1 -0
  9. androidctl/commands/actions.py +236 -0
  10. androidctl/commands/adb_wireless.py +157 -0
  11. androidctl/commands/close.py +30 -0
  12. androidctl/commands/connect.py +69 -0
  13. androidctl/commands/execute.py +179 -0
  14. androidctl/commands/list_apps.py +26 -0
  15. androidctl/commands/observe.py +26 -0
  16. androidctl/commands/open.py +41 -0
  17. androidctl/commands/plumbing.py +58 -0
  18. androidctl/commands/run_pipeline.py +307 -0
  19. androidctl/commands/screenshot.py +29 -0
  20. androidctl/commands/setup.py +301 -0
  21. androidctl/commands/wait.py +60 -0
  22. androidctl/daemon/__init__.py +1 -0
  23. androidctl/daemon/client.py +348 -0
  24. androidctl/daemon/discovery.py +190 -0
  25. androidctl/daemon/launcher.py +26 -0
  26. androidctl/daemon/owner.py +349 -0
  27. androidctl/errors/__init__.py +1 -0
  28. androidctl/errors/mapping.py +149 -0
  29. androidctl/errors/models.py +16 -0
  30. androidctl/exit_codes.py +8 -0
  31. androidctl/output.py +147 -0
  32. androidctl/parsing/__init__.py +1 -0
  33. androidctl/parsing/duration.py +17 -0
  34. androidctl/parsing/open_target.py +51 -0
  35. androidctl/parsing/refs.py +12 -0
  36. androidctl/parsing/screen_id.py +10 -0
  37. androidctl/parsing/wait.py +70 -0
  38. androidctl/renderers/__init__.py +110 -0
  39. androidctl/renderers/_paths.py +109 -0
  40. androidctl/renderers/xml.py +234 -0
  41. androidctl/renderers/xml_projection.py +732 -0
  42. androidctl/resources/__init__.py +1 -0
  43. androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
  44. androidctl/setup/__init__.py +1 -0
  45. androidctl/setup/accessibility.py +159 -0
  46. androidctl/setup/adb.py +586 -0
  47. androidctl/setup/apk_resource.py +29 -0
  48. androidctl/setup/pairing.py +70 -0
  49. androidctl/setup/verify.py +175 -0
  50. androidctl/workspace/__init__.py +3 -0
  51. androidctl/workspace/resolve.py +27 -0
  52. androidctl-0.1.0.dist-info/METADATA +217 -0
  53. androidctl-0.1.0.dist-info/RECORD +187 -0
  54. androidctl-0.1.0.dist-info/WHEEL +5 -0
  55. androidctl-0.1.0.dist-info/entry_points.txt +3 -0
  56. androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
  57. androidctl-0.1.0.dist-info/top_level.txt +3 -0
  58. androidctl_contracts/__init__.py +55 -0
  59. androidctl_contracts/_version.py +1 -0
  60. androidctl_contracts/_wire_helpers.py +31 -0
  61. androidctl_contracts/base.py +142 -0
  62. androidctl_contracts/command_catalog.py +414 -0
  63. androidctl_contracts/command_results.py +630 -0
  64. androidctl_contracts/daemon_api.py +335 -0
  65. androidctl_contracts/errors.py +44 -0
  66. androidctl_contracts/paths.py +5 -0
  67. androidctl_contracts/public_screen.py +579 -0
  68. androidctl_contracts/user_state.py +23 -0
  69. androidctl_contracts/vocabulary.py +82 -0
  70. androidctld/__init__.py +5 -0
  71. androidctld/__main__.py +63 -0
  72. androidctld/_version.py +1 -0
  73. androidctld/actions/__init__.py +1 -0
  74. androidctld/actions/action_target.py +142 -0
  75. androidctld/actions/capabilities.py +539 -0
  76. androidctld/actions/executor.py +894 -0
  77. androidctld/actions/focus_confirmation.py +177 -0
  78. androidctld/actions/focused_input_admissibility.py +120 -0
  79. androidctld/actions/fresh_current.py +176 -0
  80. androidctld/actions/postconditions.py +473 -0
  81. androidctld/actions/repair.py +101 -0
  82. androidctld/actions/request_builder.py +204 -0
  83. androidctld/actions/settle.py +146 -0
  84. androidctld/actions/submit_confirmation.py +211 -0
  85. androidctld/actions/submit_routing.py +311 -0
  86. androidctld/actions/type_confirmation.py +257 -0
  87. androidctld/app_targets.py +71 -0
  88. androidctld/artifacts/__init__.py +1 -0
  89. androidctld/artifacts/models.py +26 -0
  90. androidctld/artifacts/screen_lookup.py +241 -0
  91. androidctld/artifacts/screen_payloads.py +109 -0
  92. androidctld/artifacts/writer.py +286 -0
  93. androidctld/auth/__init__.py +1 -0
  94. androidctld/auth/active_registry.py +266 -0
  95. androidctld/auth/secret_files.py +52 -0
  96. androidctld/auth/token_store.py +59 -0
  97. androidctld/commands/__init__.py +1 -0
  98. androidctld/commands/assembly.py +231 -0
  99. androidctld/commands/command_models.py +254 -0
  100. androidctld/commands/dispatch.py +99 -0
  101. androidctld/commands/executor.py +31 -0
  102. androidctld/commands/from_boundary.py +175 -0
  103. androidctld/commands/handlers/__init__.py +15 -0
  104. androidctld/commands/handlers/action.py +439 -0
  105. androidctld/commands/handlers/connect.py +94 -0
  106. androidctld/commands/handlers/list_apps.py +215 -0
  107. androidctld/commands/handlers/observe.py +121 -0
  108. androidctld/commands/handlers/screenshot.py +105 -0
  109. androidctld/commands/handlers/wait.py +286 -0
  110. androidctld/commands/models.py +65 -0
  111. androidctld/commands/open_targets.py +56 -0
  112. androidctld/commands/orchestration.py +353 -0
  113. androidctld/commands/registry.py +116 -0
  114. androidctld/commands/result_builders.py +40 -0
  115. androidctld/commands/result_models.py +555 -0
  116. androidctld/commands/results.py +108 -0
  117. androidctld/commands/semantic_command_names.py +17 -0
  118. androidctld/commands/semantic_error_mapping.py +93 -0
  119. androidctld/commands/semantic_truth.py +135 -0
  120. androidctld/commands/service.py +67 -0
  121. androidctld/config.py +75 -0
  122. androidctld/daemon/__init__.py +1 -0
  123. androidctld/daemon/active_slot.py +326 -0
  124. androidctld/daemon/envelope.py +30 -0
  125. androidctld/daemon/http_host.py +123 -0
  126. androidctld/daemon/ingress.py +112 -0
  127. androidctld/daemon/ownership_probe.py +204 -0
  128. androidctld/daemon/server.py +286 -0
  129. androidctld/daemon/service.py +99 -0
  130. androidctld/device/__init__.py +1 -0
  131. androidctld/device/action_models.py +154 -0
  132. androidctld/device/action_serialization.py +121 -0
  133. androidctld/device/adapters.py +220 -0
  134. androidctld/device/bootstrap.py +153 -0
  135. androidctld/device/connectors.py +231 -0
  136. androidctld/device/errors.py +100 -0
  137. androidctld/device/interfaces.py +58 -0
  138. androidctld/device/parsing.py +320 -0
  139. androidctld/device/rpc.py +483 -0
  140. androidctld/device/schema.py +114 -0
  141. androidctld/device/types.py +161 -0
  142. androidctld/errors/__init__.py +94 -0
  143. androidctld/logging/__init__.py +22 -0
  144. androidctld/observation.py +98 -0
  145. androidctld/protocol.py +53 -0
  146. androidctld/refs/__init__.py +1 -0
  147. androidctld/refs/models.py +54 -0
  148. androidctld/refs/repair.py +284 -0
  149. androidctld/refs/service.py +422 -0
  150. androidctld/rendering/__init__.py +1 -0
  151. androidctld/rendering/screen_xml.py +256 -0
  152. androidctld/runtime/__init__.py +21 -0
  153. androidctld/runtime/kernel.py +548 -0
  154. androidctld/runtime/lifecycle.py +19 -0
  155. androidctld/runtime/models.py +48 -0
  156. androidctld/runtime/screen_state.py +117 -0
  157. androidctld/runtime/state_repo.py +70 -0
  158. androidctld/runtime/store.py +76 -0
  159. androidctld/runtime_policy.py +127 -0
  160. androidctld/schema/__init__.py +5 -0
  161. androidctld/schema/base.py +132 -0
  162. androidctld/schema/core.py +35 -0
  163. androidctld/schema/daemon_api.py +108 -0
  164. androidctld/schema/persistence.py +161 -0
  165. androidctld/schema/persistence_io.py +41 -0
  166. androidctld/schema/validation_errors.py +309 -0
  167. androidctld/semantics/__init__.py +1 -0
  168. androidctld/semantics/compiler.py +610 -0
  169. androidctld/semantics/continuity.py +107 -0
  170. androidctld/semantics/labels.py +252 -0
  171. androidctld/semantics/models.py +25 -0
  172. androidctld/semantics/policy.py +23 -0
  173. androidctld/semantics/public_models.py +123 -0
  174. androidctld/semantics/registries.py +13 -0
  175. androidctld/semantics/submit_refs.py +417 -0
  176. androidctld/semantics/surface.py +254 -0
  177. androidctld/semantics/targets.py +167 -0
  178. androidctld/snapshots/__init__.py +1 -0
  179. androidctld/snapshots/models.py +219 -0
  180. androidctld/snapshots/refresh.py +273 -0
  181. androidctld/snapshots/schema.py +74 -0
  182. androidctld/snapshots/service.py +138 -0
  183. androidctld/text_equivalence.py +67 -0
  184. androidctld/waits/__init__.py +1 -0
  185. androidctld/waits/evaluators.py +216 -0
  186. androidctld/waits/loop.py +305 -0
  187. androidctld/waits/matcher.py +41 -0
@@ -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("=")