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
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Thin list-apps command handler backed by wrapper-backed apps.list RPC."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, NoReturn, Protocol
|
|
7
|
+
|
|
8
|
+
from androidctl_contracts.command_results import ListAppsResult
|
|
9
|
+
from androidctld.commands.command_models import ListAppsCommand
|
|
10
|
+
from androidctld.device.bootstrap import DeviceBootstrapper
|
|
11
|
+
from androidctld.device.rpc import DeviceRpcClient
|
|
12
|
+
from androidctld.device.types import DeviceEndpoint
|
|
13
|
+
from androidctld.errors import DaemonError, DaemonErrorCode
|
|
14
|
+
from androidctld.runtime import RuntimeKernel, RuntimeLifecycleLease
|
|
15
|
+
from androidctld.runtime.models import WorkspaceRuntime
|
|
16
|
+
from androidctld.runtime_policy import DEVICE_RPC_REQUEST_ID_LIST_APPS
|
|
17
|
+
|
|
18
|
+
ANDROID_APPS_LIST_METHOD = "apps.list"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ListAppsRpcClient(Protocol):
|
|
22
|
+
def call_result_payload(
|
|
23
|
+
self,
|
|
24
|
+
method: str,
|
|
25
|
+
params: dict[str, Any] | None = None,
|
|
26
|
+
*,
|
|
27
|
+
request_id: str,
|
|
28
|
+
) -> object: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
ListAppsRpcClientFactory = Callable[[DeviceEndpoint, str], ListAppsRpcClient]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ListAppsCommandHandler:
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
runtime_kernel: RuntimeKernel,
|
|
39
|
+
bootstrapper: DeviceBootstrapper,
|
|
40
|
+
rpc_client_factory: ListAppsRpcClientFactory | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._runtime_kernel = runtime_kernel
|
|
43
|
+
self._bootstrapper = bootstrapper
|
|
44
|
+
self._rpc_client_factory = rpc_client_factory or DeviceRpcClient
|
|
45
|
+
|
|
46
|
+
def handle(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
command: ListAppsCommand,
|
|
50
|
+
) -> dict[str, object]:
|
|
51
|
+
del command
|
|
52
|
+
runtime = self._runtime_kernel.ensure_runtime()
|
|
53
|
+
query_lane_acquired = False
|
|
54
|
+
try:
|
|
55
|
+
lifecycle_lease = self._runtime_kernel.capture_lifecycle_lease(runtime)
|
|
56
|
+
self._runtime_kernel.acquire_query_lane(runtime)
|
|
57
|
+
query_lane_acquired = True
|
|
58
|
+
client = self._client_without_readiness(
|
|
59
|
+
runtime,
|
|
60
|
+
lifecycle_lease=lifecycle_lease,
|
|
61
|
+
)
|
|
62
|
+
payload = client.call_result_payload(
|
|
63
|
+
ANDROID_APPS_LIST_METHOD,
|
|
64
|
+
{},
|
|
65
|
+
request_id=DEVICE_RPC_REQUEST_ID_LIST_APPS,
|
|
66
|
+
)
|
|
67
|
+
result = build_list_apps_result(payload)
|
|
68
|
+
_raise_runtime_not_connected_if_stale(runtime, lifecycle_lease)
|
|
69
|
+
return result.model_dump(by_alias=True, mode="json")
|
|
70
|
+
finally:
|
|
71
|
+
if query_lane_acquired:
|
|
72
|
+
self._runtime_kernel.release_progress_lane(runtime)
|
|
73
|
+
|
|
74
|
+
def _client_without_readiness(
|
|
75
|
+
self,
|
|
76
|
+
runtime: WorkspaceRuntime,
|
|
77
|
+
*,
|
|
78
|
+
lifecycle_lease: RuntimeLifecycleLease,
|
|
79
|
+
) -> ListAppsRpcClient:
|
|
80
|
+
with runtime.lock:
|
|
81
|
+
if not lifecycle_lease.is_current(runtime):
|
|
82
|
+
raise _runtime_not_connected_error(runtime)
|
|
83
|
+
if runtime.connection is None:
|
|
84
|
+
raise _runtime_not_connected_error(runtime)
|
|
85
|
+
token = _require_device_token(runtime)
|
|
86
|
+
if runtime.transport is not None:
|
|
87
|
+
endpoint = runtime.transport.endpoint
|
|
88
|
+
return self._rpc_client_factory(endpoint, token)
|
|
89
|
+
|
|
90
|
+
rebuilt_transport = self._runtime_kernel.rebootstrap_transport(
|
|
91
|
+
runtime,
|
|
92
|
+
bootstrap=self._bootstrapper.bootstrap_rpc_only,
|
|
93
|
+
lease=lifecycle_lease,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
with runtime.lock:
|
|
97
|
+
if not lifecycle_lease.is_current(runtime):
|
|
98
|
+
raise _runtime_not_connected_error(runtime)
|
|
99
|
+
transport = runtime.transport or rebuilt_transport
|
|
100
|
+
token = _require_device_token(runtime)
|
|
101
|
+
return self._rpc_client_factory(
|
|
102
|
+
transport.endpoint,
|
|
103
|
+
token,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_list_apps_result(payload: object) -> ListAppsResult:
|
|
108
|
+
if not isinstance(payload, dict):
|
|
109
|
+
_raise_malformed_payload("result")
|
|
110
|
+
raw_apps = payload.get("apps", _MISSING)
|
|
111
|
+
if not isinstance(raw_apps, list):
|
|
112
|
+
_raise_malformed_payload("result.apps")
|
|
113
|
+
|
|
114
|
+
apps: list[dict[str, str]] = []
|
|
115
|
+
for index, raw_app in enumerate(raw_apps):
|
|
116
|
+
field_prefix = f"result.apps[{index}]"
|
|
117
|
+
if not isinstance(raw_app, dict):
|
|
118
|
+
_raise_malformed_payload(field_prefix)
|
|
119
|
+
package_name = _required_non_empty_string(
|
|
120
|
+
raw_app.get("packageName", _MISSING),
|
|
121
|
+
field=f"{field_prefix}.packageName",
|
|
122
|
+
)
|
|
123
|
+
app_label = _required_non_empty_string(
|
|
124
|
+
raw_app.get("appLabel", _MISSING),
|
|
125
|
+
field=f"{field_prefix}.appLabel",
|
|
126
|
+
)
|
|
127
|
+
if raw_app.get("launchable", _MISSING) is not True:
|
|
128
|
+
_raise_malformed_payload(f"{field_prefix}.launchable")
|
|
129
|
+
apps.append({"packageName": package_name, "appLabel": app_label})
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
return ListAppsResult.model_validate(
|
|
133
|
+
{"ok": True, "command": "list-apps", "apps": apps},
|
|
134
|
+
strict=True,
|
|
135
|
+
)
|
|
136
|
+
except ValueError as error:
|
|
137
|
+
raise _malformed_payload_error("result") from error
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _required_non_empty_string(value: object, *, field: str) -> str:
|
|
141
|
+
if type(value) is not str:
|
|
142
|
+
_raise_malformed_payload(field)
|
|
143
|
+
normalized = value.strip()
|
|
144
|
+
if not normalized:
|
|
145
|
+
_raise_malformed_payload(field)
|
|
146
|
+
return normalized
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _raise_runtime_not_connected_if_stale(
|
|
150
|
+
runtime: WorkspaceRuntime,
|
|
151
|
+
lifecycle_lease: RuntimeLifecycleLease,
|
|
152
|
+
) -> None:
|
|
153
|
+
with runtime.lock:
|
|
154
|
+
if lifecycle_lease.is_current(runtime):
|
|
155
|
+
return
|
|
156
|
+
raise _runtime_not_connected_error(
|
|
157
|
+
runtime,
|
|
158
|
+
details={"reason": "runtime_lifecycle_changed"},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _require_device_token(runtime: WorkspaceRuntime) -> str:
|
|
163
|
+
token = runtime.device_token
|
|
164
|
+
if not token:
|
|
165
|
+
raise _runtime_not_connected_error(runtime)
|
|
166
|
+
return token
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _runtime_not_connected_error(
|
|
170
|
+
runtime: WorkspaceRuntime,
|
|
171
|
+
*,
|
|
172
|
+
details: dict[str, object] | None = None,
|
|
173
|
+
) -> DaemonError:
|
|
174
|
+
error_details: dict[str, object] = {
|
|
175
|
+
"workspaceRoot": runtime.workspace_root.as_posix()
|
|
176
|
+
}
|
|
177
|
+
if details is not None:
|
|
178
|
+
error_details.update(details)
|
|
179
|
+
return DaemonError(
|
|
180
|
+
code=DaemonErrorCode.RUNTIME_NOT_CONNECTED,
|
|
181
|
+
message="runtime is not connected to a device",
|
|
182
|
+
retryable=False,
|
|
183
|
+
details=error_details,
|
|
184
|
+
http_status=200,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _raise_malformed_payload(field: str) -> NoReturn:
|
|
189
|
+
raise _malformed_payload_error(field)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _malformed_payload_error(field: str) -> DaemonError:
|
|
193
|
+
return DaemonError(
|
|
194
|
+
code=DaemonErrorCode.DEVICE_RPC_FAILED,
|
|
195
|
+
message="apps.list returned malformed payload",
|
|
196
|
+
retryable=False,
|
|
197
|
+
details={"field": field, "reason": "invalid_payload"},
|
|
198
|
+
http_status=200,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class _Missing:
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
_MISSING = _Missing()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
__all__ = [
|
|
210
|
+
"ANDROID_APPS_LIST_METHOD",
|
|
211
|
+
"ListAppsCommandHandler",
|
|
212
|
+
"ListAppsRpcClient",
|
|
213
|
+
"ListAppsRpcClientFactory",
|
|
214
|
+
"build_list_apps_result",
|
|
215
|
+
]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Semantic observe command handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from androidctl_contracts.vocabulary import SemanticResultCode
|
|
6
|
+
from androidctld.commands.command_models import ObserveCommand
|
|
7
|
+
from androidctld.commands.result_models import (
|
|
8
|
+
build_semantic_failure_result,
|
|
9
|
+
build_semantic_success_result,
|
|
10
|
+
)
|
|
11
|
+
from androidctld.commands.semantic_error_mapping import (
|
|
12
|
+
map_daemon_error_to_semantic_failure,
|
|
13
|
+
)
|
|
14
|
+
from androidctld.commands.semantic_truth import resolve_screen_continuity
|
|
15
|
+
from androidctld.errors import DaemonError
|
|
16
|
+
from androidctld.runtime import RuntimeKernel
|
|
17
|
+
from androidctld.runtime.screen_state import (
|
|
18
|
+
current_compiled_screen,
|
|
19
|
+
get_authoritative_current_basis,
|
|
20
|
+
)
|
|
21
|
+
from androidctld.snapshots.refresh import ScreenRefreshService
|
|
22
|
+
from androidctld.snapshots.service import SnapshotService
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ObserveCommandHandler:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
runtime_kernel: RuntimeKernel,
|
|
30
|
+
snapshot_service: SnapshotService,
|
|
31
|
+
screen_refresh: ScreenRefreshService,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._runtime_kernel = runtime_kernel
|
|
34
|
+
self._snapshot_service = snapshot_service
|
|
35
|
+
self._screen_refresh = screen_refresh
|
|
36
|
+
|
|
37
|
+
def handle(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
command: ObserveCommand,
|
|
41
|
+
) -> dict[str, object]:
|
|
42
|
+
runtime = self._runtime_kernel.ensure_runtime()
|
|
43
|
+
source_basis = get_authoritative_current_basis(runtime)
|
|
44
|
+
if runtime.connection is None or runtime.device_token is None:
|
|
45
|
+
return build_semantic_failure_result(
|
|
46
|
+
command="observe",
|
|
47
|
+
category="observe",
|
|
48
|
+
code=SemanticResultCode.DEVICE_UNAVAILABLE,
|
|
49
|
+
message="No current device observation is available.",
|
|
50
|
+
source_screen_id=None,
|
|
51
|
+
current_screen=(
|
|
52
|
+
None if source_basis is None else source_basis.public_screen
|
|
53
|
+
),
|
|
54
|
+
artifacts=None if source_basis is None else source_basis.artifacts,
|
|
55
|
+
).model_dump(by_alias=True, mode="json")
|
|
56
|
+
lifecycle_lease = self._runtime_kernel.capture_lifecycle_lease(runtime)
|
|
57
|
+
force_refresh = source_basis is None
|
|
58
|
+
self._runtime_kernel.acquire_progress_lane(
|
|
59
|
+
runtime,
|
|
60
|
+
occupant_kind=command.kind.value,
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
snapshot = self._snapshot_service.fetch(
|
|
64
|
+
runtime,
|
|
65
|
+
force_refresh=force_refresh,
|
|
66
|
+
lifecycle_lease=lifecycle_lease,
|
|
67
|
+
)
|
|
68
|
+
previous_screen = (
|
|
69
|
+
None if source_basis is None else source_basis.public_screen
|
|
70
|
+
)
|
|
71
|
+
previous_compiled = (
|
|
72
|
+
None if source_basis is None else source_basis.compiled_screen
|
|
73
|
+
)
|
|
74
|
+
snapshot, public_screen, artifacts = self._screen_refresh.refresh(
|
|
75
|
+
runtime,
|
|
76
|
+
snapshot,
|
|
77
|
+
lifecycle_lease=lifecycle_lease,
|
|
78
|
+
command_kind=command.kind,
|
|
79
|
+
)
|
|
80
|
+
source_screen_id = (
|
|
81
|
+
None if previous_screen is None else previous_screen.screen_id
|
|
82
|
+
)
|
|
83
|
+
continuity = resolve_screen_continuity(
|
|
84
|
+
source_screen_id=source_screen_id,
|
|
85
|
+
source_compiled_screen=previous_compiled,
|
|
86
|
+
current_screen=public_screen,
|
|
87
|
+
candidate_compiled_screen=current_compiled_screen(
|
|
88
|
+
runtime,
|
|
89
|
+
copy_value=False,
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
return build_semantic_success_result(
|
|
93
|
+
command="observe",
|
|
94
|
+
category="observe",
|
|
95
|
+
source_screen_id=source_screen_id,
|
|
96
|
+
next_screen=public_screen,
|
|
97
|
+
artifacts=artifacts,
|
|
98
|
+
continuity_status=continuity.continuity_status,
|
|
99
|
+
execution_outcome="notApplicable",
|
|
100
|
+
changed=continuity.changed,
|
|
101
|
+
).model_dump(by_alias=True, mode="json")
|
|
102
|
+
except DaemonError as error:
|
|
103
|
+
mapped = map_daemon_error_to_semantic_failure(
|
|
104
|
+
command_name="observe",
|
|
105
|
+
error=error,
|
|
106
|
+
)
|
|
107
|
+
if mapped is None:
|
|
108
|
+
raise
|
|
109
|
+
return build_semantic_failure_result(
|
|
110
|
+
command="observe",
|
|
111
|
+
category="observe",
|
|
112
|
+
code=mapped.code,
|
|
113
|
+
message=mapped.message,
|
|
114
|
+
source_screen_id=None,
|
|
115
|
+
current_screen=(
|
|
116
|
+
None if source_basis is None else source_basis.public_screen
|
|
117
|
+
),
|
|
118
|
+
artifacts=None if source_basis is None else source_basis.artifacts,
|
|
119
|
+
).model_dump(by_alias=True, mode="json")
|
|
120
|
+
finally:
|
|
121
|
+
self._runtime_kernel.release_progress_lane(runtime)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Thin screenshot command handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
|
|
7
|
+
from androidctld.actions.capabilities import ensure_command_supported
|
|
8
|
+
from androidctld.artifacts.writer import ArtifactWriter
|
|
9
|
+
from androidctld.commands.command_models import ScreenshotCommand
|
|
10
|
+
from androidctld.commands.result_models import (
|
|
11
|
+
build_projected_retained_failure_result_for_error,
|
|
12
|
+
build_retained_success_result,
|
|
13
|
+
)
|
|
14
|
+
from androidctld.device.interfaces import DeviceClientFactory
|
|
15
|
+
from androidctld.device.parsing import (
|
|
16
|
+
decode_screenshot_body_base64,
|
|
17
|
+
validate_screenshot_png_bytes,
|
|
18
|
+
)
|
|
19
|
+
from androidctld.errors import DaemonError
|
|
20
|
+
from androidctld.runtime import RuntimeKernel
|
|
21
|
+
from androidctld.runtime_policy import DEVICE_RPC_REQUEST_ID_SCREENSHOT
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ScreenshotCommandHandler:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
runtime_kernel: RuntimeKernel,
|
|
29
|
+
device_client_factory: DeviceClientFactory,
|
|
30
|
+
artifact_writer: ArtifactWriter,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._runtime_kernel = runtime_kernel
|
|
33
|
+
self._device_client_factory = device_client_factory
|
|
34
|
+
self._artifact_writer = artifact_writer
|
|
35
|
+
|
|
36
|
+
def handle(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
command: ScreenshotCommand,
|
|
40
|
+
) -> dict[str, object]:
|
|
41
|
+
runtime = self._runtime_kernel.ensure_runtime()
|
|
42
|
+
query_lane_acquired = False
|
|
43
|
+
try:
|
|
44
|
+
lifecycle_lease = self._runtime_kernel.capture_lifecycle_lease(runtime)
|
|
45
|
+
self._runtime_kernel.acquire_query_lane(runtime)
|
|
46
|
+
query_lane_acquired = True
|
|
47
|
+
client = None
|
|
48
|
+
if (
|
|
49
|
+
runtime.connection is not None
|
|
50
|
+
and runtime.device_token is not None
|
|
51
|
+
and runtime.transport is None
|
|
52
|
+
):
|
|
53
|
+
client = self._device_client_factory(
|
|
54
|
+
runtime,
|
|
55
|
+
lifecycle_lease=lifecycle_lease,
|
|
56
|
+
)
|
|
57
|
+
ensure_command_supported(runtime, command)
|
|
58
|
+
if client is None:
|
|
59
|
+
client = self._device_client_factory(
|
|
60
|
+
runtime,
|
|
61
|
+
lifecycle_lease=lifecycle_lease,
|
|
62
|
+
)
|
|
63
|
+
payload = client.screenshot_capture(
|
|
64
|
+
request_id=DEVICE_RPC_REQUEST_ID_SCREENSHOT,
|
|
65
|
+
)
|
|
66
|
+
with runtime.lock:
|
|
67
|
+
if not lifecycle_lease.is_current(runtime):
|
|
68
|
+
raise RuntimeError("runtime lifecycle changed during screenshot")
|
|
69
|
+
decoded_body = decode_screenshot_body_base64(
|
|
70
|
+
payload.body_base64,
|
|
71
|
+
field_name="result.bodyBase64",
|
|
72
|
+
)
|
|
73
|
+
validate_screenshot_png_bytes(
|
|
74
|
+
decoded_body,
|
|
75
|
+
field_name="result.bodyBase64",
|
|
76
|
+
expected_width_px=payload.width_px,
|
|
77
|
+
expected_height_px=payload.height_px,
|
|
78
|
+
)
|
|
79
|
+
output_path = self._artifact_writer.write_screenshot_png(
|
|
80
|
+
runtime,
|
|
81
|
+
decoded_body,
|
|
82
|
+
)
|
|
83
|
+
attachment = self._runtime_kernel.attach_screenshot_artifact(
|
|
84
|
+
runtime,
|
|
85
|
+
lifecycle_lease,
|
|
86
|
+
screenshot_png=output_path.as_posix(),
|
|
87
|
+
)
|
|
88
|
+
if attachment is None:
|
|
89
|
+
with suppress(OSError):
|
|
90
|
+
output_path.unlink()
|
|
91
|
+
raise RuntimeError("runtime lifecycle changed during screenshot")
|
|
92
|
+
artifacts = attachment.artifacts
|
|
93
|
+
return build_retained_success_result(
|
|
94
|
+
command="screenshot",
|
|
95
|
+
artifacts=artifacts,
|
|
96
|
+
).model_dump(by_alias=True, mode="json")
|
|
97
|
+
except DaemonError as error:
|
|
98
|
+
return build_projected_retained_failure_result_for_error(
|
|
99
|
+
command="screenshot",
|
|
100
|
+
error=error,
|
|
101
|
+
artifacts=None,
|
|
102
|
+
).model_dump(by_alias=True, mode="json")
|
|
103
|
+
finally:
|
|
104
|
+
if query_lane_acquired:
|
|
105
|
+
self._runtime_kernel.release_progress_lane(runtime)
|