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,236 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Collection
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Annotated, Literal, cast
7
+
8
+ import typer
9
+
10
+ from androidctl.command_payloads import (
11
+ SCROLL_DIRECTIONS,
12
+ build_global_action_command,
13
+ build_ref_action_command,
14
+ build_scroll_command,
15
+ build_type_command,
16
+ )
17
+ from androidctl.commands import run_pipeline
18
+ from androidctl.commands.execute import emit_usage_error
19
+ from androidctl.commands.plumbing import build_and_run_command
20
+ from androidctl.parsing.refs import parse_ref
21
+ from androidctl.parsing.screen_id import parse_screen_id_override
22
+ from androidctl_contracts.command_catalog import daemon_kind_for_public_command
23
+
24
+ RefActionKind = Literal["tap", "longTap", "focus", "submit"]
25
+ GlobalActionKind = Literal["back", "home", "recents", "notifications"]
26
+ ActionCommandKind = Literal[
27
+ "tap",
28
+ "longTap",
29
+ "focus",
30
+ "submit",
31
+ "type",
32
+ "scroll",
33
+ "back",
34
+ "home",
35
+ "recents",
36
+ "notifications",
37
+ ]
38
+ ActionCommandSignature = Literal["ref", "ref_text", "ref_direction", "screen"]
39
+ ScrollDirection = Literal["up", "down", "left", "right", "backward"]
40
+ TargetRefArgument = Annotated[
41
+ str,
42
+ typer.Argument(help="Target ref, for example n3."),
43
+ ]
44
+ ScrollableRefArgument = Annotated[
45
+ str,
46
+ typer.Argument(help="Scrollable ref, for example n8."),
47
+ ]
48
+ TypeTextArgument = Annotated[
49
+ str,
50
+ typer.Argument(help="Text to replace the current value with."),
51
+ ]
52
+ ScrollDirectionArgument = Annotated[
53
+ str,
54
+ typer.Argument(help="Scroll direction: up/down/left/right/backward."),
55
+ ]
56
+ ScreenIdOption = Annotated[
57
+ str | None,
58
+ typer.Option("--screen-id", help="Override the source screen id for this command."),
59
+ ]
60
+ WorkspaceRootOption = Annotated[Path | None, typer.Option("--workspace-root")]
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class _ActionCommandSpec:
65
+ public_command: str
66
+ signature: ActionCommandSignature
67
+
68
+ @property
69
+ def kind(self) -> ActionCommandKind:
70
+ daemon_kind = daemon_kind_for_public_command(self.public_command)
71
+ if daemon_kind is None:
72
+ raise RuntimeError(
73
+ f"missing daemon kind for action command {self.public_command!r}"
74
+ )
75
+ return cast(ActionCommandKind, daemon_kind)
76
+
77
+
78
+ _ACTION_COMMAND_SPECS = (
79
+ _ActionCommandSpec(public_command="tap", signature="ref"),
80
+ _ActionCommandSpec(public_command="long-tap", signature="ref"),
81
+ _ActionCommandSpec(public_command="focus", signature="ref"),
82
+ _ActionCommandSpec(public_command="submit", signature="ref"),
83
+ _ActionCommandSpec(public_command="type", signature="ref_text"),
84
+ _ActionCommandSpec(
85
+ public_command="scroll",
86
+ signature="ref_direction",
87
+ ),
88
+ _ActionCommandSpec(public_command="back", signature="screen"),
89
+ _ActionCommandSpec(public_command="home", signature="screen"),
90
+ _ActionCommandSpec(public_command="recents", signature="screen"),
91
+ _ActionCommandSpec(
92
+ public_command="notifications",
93
+ signature="screen",
94
+ ),
95
+ )
96
+
97
+
98
+ def register(app: typer.Typer) -> None:
99
+ for spec in _ACTION_COMMAND_SPECS:
100
+ if spec.signature == "ref":
101
+ _register_ref_command(app, spec)
102
+ continue
103
+ if spec.signature == "ref_text":
104
+ _register_ref_text_command(app, spec)
105
+ continue
106
+ if spec.signature == "ref_direction":
107
+ _register_ref_direction_command(app, spec)
108
+ continue
109
+ _register_screen_command(app, spec)
110
+
111
+
112
+ def _register_ref_command(app: typer.Typer, spec: _ActionCommandSpec) -> None:
113
+ @app.command(spec.public_command)
114
+ def action_command(
115
+ ctx: typer.Context,
116
+ ref: TargetRefArgument,
117
+ screen_id: ScreenIdOption = None,
118
+ workspace_root: WorkspaceRootOption = None,
119
+ ) -> None:
120
+ build_and_run_command(
121
+ ctx=ctx,
122
+ workspace_root=workspace_root,
123
+ build_request=lambda options: run_pipeline.CliCommandRequest(
124
+ public_command=spec.public_command,
125
+ command=build_ref_action_command(
126
+ kind=cast(RefActionKind, spec.kind),
127
+ ref=_parse_ref_or_fail(ref),
128
+ source_screen_id=_parse_screen_id_or_fail(screen_id),
129
+ ),
130
+ workspace_root=options.workspace_root,
131
+ ),
132
+ public_command=spec.public_command,
133
+ )
134
+
135
+
136
+ def _register_ref_text_command(app: typer.Typer, spec: _ActionCommandSpec) -> None:
137
+ @app.command(spec.public_command)
138
+ def action_command(
139
+ ctx: typer.Context,
140
+ ref: TargetRefArgument,
141
+ text: TypeTextArgument,
142
+ screen_id: ScreenIdOption = None,
143
+ workspace_root: WorkspaceRootOption = None,
144
+ ) -> None:
145
+ build_and_run_command(
146
+ ctx=ctx,
147
+ workspace_root=workspace_root,
148
+ build_request=lambda options: run_pipeline.CliCommandRequest(
149
+ public_command=spec.public_command,
150
+ command=build_type_command(
151
+ ref=_parse_ref_or_fail(ref),
152
+ text=text,
153
+ source_screen_id=_parse_screen_id_or_fail(screen_id),
154
+ ),
155
+ workspace_root=options.workspace_root,
156
+ ),
157
+ public_command=spec.public_command,
158
+ )
159
+
160
+
161
+ def _register_ref_direction_command(
162
+ app: typer.Typer,
163
+ spec: _ActionCommandSpec,
164
+ ) -> None:
165
+ @app.command(spec.public_command)
166
+ def action_command(
167
+ ctx: typer.Context,
168
+ ref: ScrollableRefArgument,
169
+ direction: ScrollDirectionArgument,
170
+ screen_id: ScreenIdOption = None,
171
+ workspace_root: WorkspaceRootOption = None,
172
+ ) -> None:
173
+ build_and_run_command(
174
+ ctx=ctx,
175
+ workspace_root=workspace_root,
176
+ build_request=lambda options: run_pipeline.CliCommandRequest(
177
+ public_command=spec.public_command,
178
+ command=build_scroll_command(
179
+ ref=_parse_ref_or_fail(ref),
180
+ direction=_parse_direction_or_fail(
181
+ direction,
182
+ allowed=SCROLL_DIRECTIONS,
183
+ ),
184
+ source_screen_id=_parse_screen_id_or_fail(screen_id),
185
+ ),
186
+ workspace_root=options.workspace_root,
187
+ ),
188
+ public_command=spec.public_command,
189
+ )
190
+
191
+
192
+ def _register_screen_command(app: typer.Typer, spec: _ActionCommandSpec) -> None:
193
+ @app.command(spec.public_command)
194
+ def action_command(
195
+ ctx: typer.Context,
196
+ screen_id: ScreenIdOption = None,
197
+ workspace_root: WorkspaceRootOption = None,
198
+ ) -> None:
199
+ build_and_run_command(
200
+ ctx=ctx,
201
+ workspace_root=workspace_root,
202
+ build_request=lambda options: run_pipeline.CliCommandRequest(
203
+ public_command=spec.public_command,
204
+ command=build_global_action_command(
205
+ kind=cast(GlobalActionKind, spec.kind),
206
+ source_screen_id=_parse_screen_id_or_fail(screen_id),
207
+ ),
208
+ workspace_root=options.workspace_root,
209
+ ),
210
+ public_command=spec.public_command,
211
+ )
212
+
213
+
214
+ def _parse_ref_or_fail(raw_ref: str) -> str:
215
+ try:
216
+ return parse_ref(raw_ref)
217
+ except ValueError as error:
218
+ emit_usage_error(str(error))
219
+
220
+
221
+ def _parse_direction_or_fail(
222
+ raw_direction: str,
223
+ *,
224
+ allowed: Collection[str],
225
+ ) -> ScrollDirection:
226
+ normalized = raw_direction.strip().lower()
227
+ if normalized not in allowed:
228
+ emit_usage_error(f"direction must be one of: {', '.join(sorted(allowed))}")
229
+ return cast(ScrollDirection, normalized)
230
+
231
+
232
+ def _parse_screen_id_or_fail(raw_screen_id: str | None) -> str | None:
233
+ try:
234
+ return parse_screen_id_override(raw_screen_id)
235
+ except ValueError as error:
236
+ emit_usage_error(str(error))
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from androidctl.exit_codes import ExitCode
9
+ from androidctl.setup import adb as setup_adb
10
+
11
+ _WORKSPACE_ROOT_HELP = "Accepted for CLI consistency; ignored by wireless ADB helpers."
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class WirelessAdbCommandError(RuntimeError):
16
+ code: str
17
+ message: str
18
+ exit_code: ExitCode = ExitCode.ENVIRONMENT
19
+
20
+ def __str__(self) -> str:
21
+ return self.message
22
+
23
+
24
+ def register(app: typer.Typer) -> None:
25
+ @app.command(
26
+ "adb-pair",
27
+ help="Auxiliary helper for Android wireless debugging pairing.",
28
+ )
29
+ def adb_pair(
30
+ pair: str | None = typer.Option(
31
+ None,
32
+ "--pair",
33
+ help="Wireless debugging pair endpoint as HOST:PORT.",
34
+ ),
35
+ code: str | None = typer.Option(
36
+ None,
37
+ "--code",
38
+ help="Pairing code shown on the Android device.",
39
+ ),
40
+ workspace_root: Path | None = typer.Option(
41
+ None,
42
+ "--workspace-root",
43
+ help=_WORKSPACE_ROOT_HELP,
44
+ ),
45
+ ) -> None:
46
+ _ignore_workspace_root(workspace_root)
47
+ try:
48
+ _run_adb_pair(pair=pair, code=code)
49
+ except WirelessAdbCommandError as error:
50
+ _emit_failure("adb-pair", error)
51
+
52
+ @app.command(
53
+ "adb-connect",
54
+ help="Auxiliary helper for connecting an already paired wireless ADB device.",
55
+ )
56
+ def adb_connect(
57
+ endpoint: str = typer.Argument(
58
+ ...,
59
+ help="Wireless debugging connect endpoint as HOST:PORT.",
60
+ ),
61
+ workspace_root: Path | None = typer.Option(
62
+ None,
63
+ "--workspace-root",
64
+ help=_WORKSPACE_ROOT_HELP,
65
+ ),
66
+ ) -> None:
67
+ _ignore_workspace_root(workspace_root)
68
+ try:
69
+ _run_adb_connect(endpoint=endpoint)
70
+ except WirelessAdbCommandError as error:
71
+ _emit_failure("adb-connect", error)
72
+
73
+
74
+ def _run_adb_pair(*, pair: str | None, code: str | None) -> None:
75
+ pair_endpoint = _required_value(
76
+ pair,
77
+ code="ADB_PAIR_ENDPOINT_REQUIRED",
78
+ message="pair endpoint is required; pass --pair HOST:PAIR_PORT",
79
+ )
80
+ pairing_code = _required_value(
81
+ code,
82
+ code="ADB_PAIR_CODE_REQUIRED",
83
+ message=(
84
+ "pairing code is required; open Android Wireless debugging and pass "
85
+ "--code"
86
+ ),
87
+ )
88
+ wireless_error: WirelessAdbCommandError | None = None
89
+ try:
90
+ setup_adb.pair_wireless_device(
91
+ pair_endpoint=pair_endpoint,
92
+ code=pairing_code,
93
+ )
94
+ except setup_adb.SetupAdbError as error:
95
+ wireless_error = _wireless_error_from_adb(error)
96
+ if wireless_error is not None:
97
+ raise wireless_error
98
+ _emit_progress("wireless ADB: paired device")
99
+
100
+
101
+ def _run_adb_connect(*, endpoint: str) -> None:
102
+ connect_endpoint: str | None = None
103
+ wireless_error: WirelessAdbCommandError | None = None
104
+ try:
105
+ connect_endpoint = setup_adb.validate_wireless_endpoint(
106
+ endpoint,
107
+ label="connect endpoint",
108
+ )
109
+ setup_adb.connect_wireless_device(connect_endpoint=connect_endpoint)
110
+ except setup_adb.SetupAdbError as error:
111
+ wireless_error = _wireless_error_from_adb(error)
112
+ if wireless_error is not None:
113
+ raise wireless_error
114
+ if connect_endpoint is None:
115
+ raise WirelessAdbCommandError(
116
+ "ADB_CONNECT_FAILED",
117
+ "adb connect endpoint was not validated",
118
+ )
119
+ _emit_progress("wireless ADB: connected device")
120
+ _emit_progress(f"wireless ADB: run setup with --serial {connect_endpoint}")
121
+
122
+
123
+ def _required_value(value: str | None, *, code: str, message: str) -> str:
124
+ normalized = value.strip() if value is not None else ""
125
+ if not normalized:
126
+ raise WirelessAdbCommandError(
127
+ code=code,
128
+ message=message,
129
+ exit_code=ExitCode.USAGE,
130
+ )
131
+ return normalized
132
+
133
+
134
+ def _wireless_error_from_adb(error: setup_adb.SetupAdbError) -> WirelessAdbCommandError:
135
+ exit_code = (
136
+ ExitCode.USAGE
137
+ if error.code in {"ADB_PAIR_CODE_REQUIRED", "ADB_INVALID_WIRELESS_ENDPOINT"}
138
+ else ExitCode.ENVIRONMENT
139
+ )
140
+ return WirelessAdbCommandError(
141
+ code=error.code,
142
+ message=error.message,
143
+ exit_code=exit_code,
144
+ )
145
+
146
+
147
+ def _ignore_workspace_root(workspace_root: Path | None) -> None:
148
+ del workspace_root
149
+
150
+
151
+ def _emit_progress(message: str) -> None:
152
+ typer.echo(message, err=True)
153
+
154
+
155
+ def _emit_failure(command: str, error: WirelessAdbCommandError) -> None:
156
+ typer.echo(f"androidctl {command} failed [{error.code}]: {error.message}", err=True)
157
+ raise typer.Exit(code=int(error.exit_code))
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from androidctl.cli_options import command_cli_options
8
+ from androidctl.commands import execute, run_pipeline
9
+
10
+
11
+ def register(app: typer.Typer) -> None:
12
+ @app.command("close", help="Retained support route for runtime lifecycle shutdown.")
13
+ def close(
14
+ ctx: typer.Context,
15
+ workspace_root: Path | None = typer.Option(None, "--workspace-root"),
16
+ ) -> None:
17
+ options = command_cli_options(ctx, workspace_root=workspace_root)
18
+ try:
19
+ outcome = run_pipeline.run_close_command(
20
+ run_pipeline.build_context(),
21
+ options.workspace_root,
22
+ )
23
+ except typer.Exit:
24
+ raise
25
+ except Exception as error:
26
+ execute.render_exception(error, command="close")
27
+ execute.render_command_outcome(
28
+ outcome=outcome,
29
+ public_command="close",
30
+ )
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from androidctl.cli_options import CliOptions
8
+ from androidctl.commands import run_pipeline
9
+ from androidctl.commands.execute import emit_usage_error
10
+ from androidctl.commands.plumbing import build_and_run_command
11
+ from androidctl_contracts.daemon_api import ConnectCommandPayload, ConnectionPayload
12
+
13
+
14
+ def register(app: typer.Typer) -> None:
15
+ @app.command(
16
+ "connect",
17
+ help="Retained support route for starting a device runtime.",
18
+ )
19
+ def connect(
20
+ ctx: typer.Context,
21
+ adb: bool = typer.Option(False, "--adb", help="Use ADB transport."),
22
+ token: str = typer.Option(..., "--token", help="Device agent token."),
23
+ serial: str | None = typer.Option(None, "--serial", help="ADB serial."),
24
+ host: str | None = typer.Option(None, "--host", help="Device host."),
25
+ port: int | None = typer.Option(None, "--port", help="Device port."),
26
+ workspace_root: Path | None = typer.Option(None, "--workspace-root"),
27
+ ) -> None:
28
+ def build_request(options: CliOptions) -> run_pipeline.CliCommandRequest:
29
+ if adb and host:
30
+ emit_usage_error("--adb cannot be used with --host")
31
+ if adb and port is not None:
32
+ emit_usage_error("--adb cannot be used with --port")
33
+ if not adb and host is None:
34
+ emit_usage_error("choose --adb or provide --host and --port")
35
+ if not adb and port is None:
36
+ emit_usage_error("choose --adb or provide --host and --port")
37
+ if not adb and serial:
38
+ emit_usage_error("--serial can only be used with --adb")
39
+ normalized_token = token.strip()
40
+ if not normalized_token:
41
+ emit_usage_error("--token cannot be empty")
42
+
43
+ if adb:
44
+ connection = ConnectionPayload(
45
+ mode="adb",
46
+ token=normalized_token,
47
+ serial=serial,
48
+ )
49
+ else:
50
+ if host is None or port is None:
51
+ emit_usage_error("choose --adb or provide --host and --port")
52
+ connection = ConnectionPayload(
53
+ mode="lan",
54
+ token=normalized_token,
55
+ host=host,
56
+ port=port,
57
+ )
58
+
59
+ return run_pipeline.CliCommandRequest(
60
+ public_command="connect",
61
+ command=ConnectCommandPayload(kind="connect", connection=connection),
62
+ workspace_root=options.workspace_root,
63
+ )
64
+
65
+ build_and_run_command(
66
+ ctx=ctx,
67
+ workspace_root=workspace_root,
68
+ build_request=build_request,
69
+ )
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import suppress
4
+ from typing import NoReturn
5
+
6
+ import click
7
+ import typer
8
+
9
+ from androidctl.command_views import pre_dispatch_execution_outcome_for_public_command
10
+ from androidctl.commands import run_pipeline
11
+ from androidctl.errors.mapping import map_exception
12
+ from androidctl.errors.models import ErrorTier
13
+ from androidctl.exit_codes import ExitCode
14
+ from androidctl.output import (
15
+ CLI_OUTPUT_FAILED,
16
+ CLI_RENDER_FAILED,
17
+ CliOutputError,
18
+ static_cli_failure_xml_bytes,
19
+ write_stderr_bytes,
20
+ write_stderr_xml,
21
+ write_stdout_xml,
22
+ )
23
+ from androidctl.renderers.xml import render_error_text, render_success_text
24
+
25
+ _RETAINED_ENVIRONMENT_FAILURE_CODES = frozenset(
26
+ {
27
+ "WORKSPACE_STATE_UNWRITABLE",
28
+ "DEVICE_AGENT_UNAUTHORIZED",
29
+ "DEVICE_AGENT_VERSION_MISMATCH",
30
+ }
31
+ )
32
+
33
+
34
+ def run_and_render(
35
+ cli_request: run_pipeline.CliCommandRequest,
36
+ *,
37
+ public_command: str | None = None,
38
+ ) -> None:
39
+ render_command = public_command or cli_request.public_command
40
+ try:
41
+ outcome = run_pipeline.run_command(cli_request, run_pipeline.build_context())
42
+ except typer.Exit:
43
+ raise
44
+ except Exception as error:
45
+ render_exception(error, command=render_command)
46
+ render_command_outcome(
47
+ outcome=outcome,
48
+ public_command=render_command,
49
+ )
50
+
51
+
52
+ def render_command_outcome(
53
+ *,
54
+ outcome: run_pipeline.CommandOutcome,
55
+ public_command: str,
56
+ ) -> None:
57
+ try:
58
+ xml_text = render_outcome(payload=outcome.payload)
59
+ except Exception:
60
+ _emit_cli_render_failed(command=public_command)
61
+ try:
62
+ write_stdout_xml(xml_text)
63
+ except CliOutputError:
64
+ _emit_cli_output_failed(command=public_command)
65
+ _exit_for_command_failure(outcome.payload)
66
+
67
+
68
+ def render_outcome(
69
+ *,
70
+ payload: dict[str, object],
71
+ ) -> str:
72
+ return render_success_text(payload=payload)
73
+
74
+
75
+ def render_exception(
76
+ error: Exception,
77
+ *,
78
+ command: str | None = None,
79
+ execution_outcome: str | None = None,
80
+ ) -> NoReturn:
81
+ resolved_command = command or _current_public_command()
82
+ mapped_error = error
83
+ if isinstance(error, run_pipeline.PreDispatchCommandError):
84
+ mapped_error = error.cause
85
+ if execution_outcome is None:
86
+ execution_outcome = error.execution_outcome or (
87
+ pre_dispatch_execution_outcome_for_public_command(resolved_command)
88
+ )
89
+
90
+ public_error = map_exception(mapped_error)
91
+ tier = _error_tier(
92
+ wrapper=error,
93
+ mapped_error=mapped_error,
94
+ )
95
+ try:
96
+ xml_text = render_error_text(
97
+ public_error,
98
+ command=resolved_command,
99
+ tier=tier,
100
+ execution_outcome=execution_outcome,
101
+ )
102
+ except Exception:
103
+ _emit_cli_render_failed(command=resolved_command)
104
+ try:
105
+ write_stderr_xml(xml_text)
106
+ except CliOutputError:
107
+ _emit_cli_output_failed(command=resolved_command)
108
+ raise typer.Exit(code=int(public_error.exit_code)) from error
109
+
110
+
111
+ def _emit_cli_render_failed(*, command: str | None) -> NoReturn:
112
+ _emit_static_cli_failure(command=command, code=CLI_RENDER_FAILED)
113
+
114
+
115
+ def _emit_cli_output_failed(*, command: str | None) -> NoReturn:
116
+ _emit_static_cli_failure(command=command, code=CLI_OUTPUT_FAILED)
117
+
118
+
119
+ def _emit_static_cli_failure(*, command: str | None, code: str) -> NoReturn:
120
+ with suppress(CliOutputError):
121
+ write_stderr_bytes(static_cli_failure_xml_bytes(command=command, code=code))
122
+ raise typer.Exit(code=int(ExitCode.ENVIRONMENT))
123
+
124
+
125
+ def _exit_for_command_failure(payload: dict[str, object]) -> None:
126
+ if payload.get("ok") is False:
127
+ raise typer.Exit(code=int(_failure_exit_code(payload)))
128
+
129
+
130
+ def _failure_exit_code(payload: dict[str, object]) -> ExitCode:
131
+ if "envelope" in payload:
132
+ return _retained_failure_exit_code(payload)
133
+ return ExitCode.ERROR
134
+
135
+
136
+ def _retained_failure_exit_code(payload: dict[str, object]) -> ExitCode:
137
+ code = payload.get("code")
138
+ if isinstance(code, str) and code in _RETAINED_ENVIRONMENT_FAILURE_CODES:
139
+ return ExitCode.ENVIRONMENT
140
+ return ExitCode.ERROR
141
+
142
+
143
+ def emit_usage_error(
144
+ message: str,
145
+ *,
146
+ command: str | None = None,
147
+ execution_outcome: str | None = None,
148
+ ) -> NoReturn:
149
+ resolved_command = command or _current_public_command()
150
+ render_exception(
151
+ click.UsageError(message),
152
+ command=resolved_command,
153
+ execution_outcome=execution_outcome
154
+ or pre_dispatch_execution_outcome_for_public_command(resolved_command),
155
+ )
156
+
157
+
158
+ def _error_tier(
159
+ *,
160
+ wrapper: Exception,
161
+ mapped_error: Exception,
162
+ ) -> ErrorTier:
163
+ if isinstance(mapped_error, click.UsageError):
164
+ return "usage"
165
+ if not isinstance(wrapper, run_pipeline.PreDispatchCommandError):
166
+ return "outer"
167
+ if wrapper.error_tier == "preDispatch":
168
+ return "preDispatch"
169
+ return "outer"
170
+
171
+
172
+ def _current_public_command() -> str | None:
173
+ context = click.get_current_context(silent=True)
174
+ if context is None:
175
+ return None
176
+ command_name = context.info_name
177
+ if isinstance(command_name, str) and command_name:
178
+ return command_name
179
+ return None
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from androidctl.commands import run_pipeline
8
+ from androidctl.commands.plumbing import build_and_run_command
9
+ from androidctl_contracts.daemon_api import ListAppsCommandPayload
10
+
11
+
12
+ def register(app: typer.Typer) -> None:
13
+ @app.command("list-apps")
14
+ def list_apps(
15
+ ctx: typer.Context,
16
+ workspace_root: Path | None = typer.Option(None, "--workspace-root"),
17
+ ) -> None:
18
+ build_and_run_command(
19
+ ctx=ctx,
20
+ workspace_root=workspace_root,
21
+ build_request=lambda options: run_pipeline.CliCommandRequest(
22
+ public_command="list-apps",
23
+ command=ListAppsCommandPayload(kind="listApps"),
24
+ workspace_root=options.workspace_root,
25
+ ),
26
+ )