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,117 @@
|
|
|
1
|
+
"""Shared accessors for current runtime screen state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from androidctld.artifacts.models import ScreenArtifacts
|
|
9
|
+
from androidctld.protocol import RuntimeStatus
|
|
10
|
+
from androidctld.runtime.models import WorkspaceRuntime
|
|
11
|
+
from androidctld.semantics.compiler import CompiledScreen
|
|
12
|
+
from androidctld.semantics.public_models import (
|
|
13
|
+
PublicScreen,
|
|
14
|
+
iter_public_nodes,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class AuthoritativeCurrentBasis:
|
|
20
|
+
screen_id: str
|
|
21
|
+
screen_sequence: int
|
|
22
|
+
snapshot_id: int
|
|
23
|
+
captured_at: str
|
|
24
|
+
package_name: str | None
|
|
25
|
+
activity_name: str | None
|
|
26
|
+
public_screen: PublicScreen
|
|
27
|
+
compiled_screen: CompiledScreen
|
|
28
|
+
artifacts: ScreenArtifacts | None
|
|
29
|
+
public_refs: frozenset[str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_authoritative_current_basis(
|
|
33
|
+
runtime: WorkspaceRuntime,
|
|
34
|
+
) -> AuthoritativeCurrentBasis | None:
|
|
35
|
+
with runtime.lock:
|
|
36
|
+
if runtime.status is not RuntimeStatus.READY:
|
|
37
|
+
return None
|
|
38
|
+
current_screen_id = runtime.current_screen_id
|
|
39
|
+
if not isinstance(current_screen_id, str) or not current_screen_id:
|
|
40
|
+
return None
|
|
41
|
+
latest_snapshot = runtime.latest_snapshot
|
|
42
|
+
screen_state = runtime.screen_state
|
|
43
|
+
if latest_snapshot is None or screen_state is None:
|
|
44
|
+
return None
|
|
45
|
+
public_screen = screen_state.public_screen
|
|
46
|
+
compiled_screen = screen_state.compiled_screen
|
|
47
|
+
if public_screen is None or compiled_screen is None:
|
|
48
|
+
return None
|
|
49
|
+
if public_screen.screen_id != current_screen_id:
|
|
50
|
+
return None
|
|
51
|
+
if compiled_screen.screen_id != current_screen_id:
|
|
52
|
+
return None
|
|
53
|
+
if compiled_screen.source_snapshot_id != latest_snapshot.snapshot_id:
|
|
54
|
+
return None
|
|
55
|
+
if compiled_screen.sequence != runtime.screen_sequence:
|
|
56
|
+
return None
|
|
57
|
+
if compiled_screen.captured_at != latest_snapshot.captured_at:
|
|
58
|
+
return None
|
|
59
|
+
if compiled_screen.package_name != latest_snapshot.package_name:
|
|
60
|
+
return None
|
|
61
|
+
if compiled_screen.activity_name != latest_snapshot.activity_name:
|
|
62
|
+
return None
|
|
63
|
+
if public_screen.app.package_name != latest_snapshot.package_name:
|
|
64
|
+
return None
|
|
65
|
+
if public_screen.app.activity_name != latest_snapshot.activity_name:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
public_screen_copy = deepcopy(public_screen)
|
|
69
|
+
compiled_screen_copy = deepcopy(compiled_screen)
|
|
70
|
+
artifacts_copy = deepcopy(screen_state.artifacts)
|
|
71
|
+
return AuthoritativeCurrentBasis(
|
|
72
|
+
screen_id=current_screen_id,
|
|
73
|
+
screen_sequence=runtime.screen_sequence,
|
|
74
|
+
snapshot_id=latest_snapshot.snapshot_id,
|
|
75
|
+
captured_at=latest_snapshot.captured_at,
|
|
76
|
+
package_name=latest_snapshot.package_name,
|
|
77
|
+
activity_name=latest_snapshot.activity_name,
|
|
78
|
+
public_screen=public_screen_copy,
|
|
79
|
+
compiled_screen=compiled_screen_copy,
|
|
80
|
+
artifacts=artifacts_copy,
|
|
81
|
+
public_refs=frozenset(
|
|
82
|
+
node.ref
|
|
83
|
+
for group in public_screen_copy.groups
|
|
84
|
+
for node in iter_public_nodes(group.nodes)
|
|
85
|
+
if node.ref
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def current_public_screen(
|
|
91
|
+
runtime: WorkspaceRuntime, *, copy_value: bool = True
|
|
92
|
+
) -> PublicScreen | None:
|
|
93
|
+
if runtime.screen_state is None or runtime.screen_state.public_screen is None:
|
|
94
|
+
return None
|
|
95
|
+
if copy_value:
|
|
96
|
+
return deepcopy(runtime.screen_state.public_screen)
|
|
97
|
+
return runtime.screen_state.public_screen
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def current_compiled_screen(
|
|
101
|
+
runtime: WorkspaceRuntime, *, copy_value: bool = True
|
|
102
|
+
) -> CompiledScreen | None:
|
|
103
|
+
if runtime.screen_state is None or runtime.screen_state.compiled_screen is None:
|
|
104
|
+
return None
|
|
105
|
+
if copy_value:
|
|
106
|
+
return deepcopy(runtime.screen_state.compiled_screen)
|
|
107
|
+
return runtime.screen_state.compiled_screen
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def current_artifacts(
|
|
111
|
+
runtime: WorkspaceRuntime, *, copy_value: bool = True
|
|
112
|
+
) -> ScreenArtifacts | None:
|
|
113
|
+
if runtime.screen_state is None or runtime.screen_state.artifacts is None:
|
|
114
|
+
return None
|
|
115
|
+
if copy_value:
|
|
116
|
+
return deepcopy(runtime.screen_state.artifacts)
|
|
117
|
+
return runtime.screen_state.artifacts
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Runtime state persistence repository."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from androidctld.protocol import RuntimeStatus
|
|
9
|
+
from androidctld.runtime.models import WorkspaceRuntime
|
|
10
|
+
from androidctld.schema.persistence import (
|
|
11
|
+
RUNTIME_STATE_SCHEMA_VERSION,
|
|
12
|
+
RuntimeStateFile,
|
|
13
|
+
build_persistence_model,
|
|
14
|
+
validate_persistence_payload,
|
|
15
|
+
)
|
|
16
|
+
from androidctld.schema.persistence_io import atomic_write_json, load_json_object
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RuntimeStateRepository:
|
|
20
|
+
def load(self, runtime_path: Path) -> WorkspaceRuntime | None:
|
|
21
|
+
if not runtime_path.exists():
|
|
22
|
+
return None
|
|
23
|
+
state_payload = load_json_object(runtime_path)
|
|
24
|
+
parsed = validate_persistence_payload(
|
|
25
|
+
RuntimeStateFile,
|
|
26
|
+
state_payload,
|
|
27
|
+
field_name="runtime",
|
|
28
|
+
schema_version=RUNTIME_STATE_SCHEMA_VERSION,
|
|
29
|
+
)
|
|
30
|
+
artifact_root = runtime_path.parent.resolve()
|
|
31
|
+
workspace_root = artifact_root.parent.resolve()
|
|
32
|
+
return WorkspaceRuntime(
|
|
33
|
+
workspace_root=workspace_root,
|
|
34
|
+
artifact_root=artifact_root,
|
|
35
|
+
runtime_path=runtime_path.resolve(),
|
|
36
|
+
status=parsed.status,
|
|
37
|
+
screen_sequence=parsed.screen_sequence,
|
|
38
|
+
current_screen_id=None,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def persist(
|
|
42
|
+
self,
|
|
43
|
+
runtime: WorkspaceRuntime,
|
|
44
|
+
*,
|
|
45
|
+
runtime_path: Path | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
target_runtime_path = runtime_path or runtime.runtime_path
|
|
48
|
+
state_payload = build_persistence_model(
|
|
49
|
+
RuntimeStateFile,
|
|
50
|
+
status=_status_for_persisted_runtime(runtime.status),
|
|
51
|
+
screen_sequence=runtime.screen_sequence,
|
|
52
|
+
updated_at=now_isoformat(),
|
|
53
|
+
).model_dump(by_alias=True, mode="json")
|
|
54
|
+
state_payload["schemaVersion"] = RUNTIME_STATE_SCHEMA_VERSION
|
|
55
|
+
atomic_write_json(target_runtime_path, state_payload)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def now_isoformat() -> str:
|
|
59
|
+
return (
|
|
60
|
+
datetime.now(timezone.utc)
|
|
61
|
+
.replace(microsecond=0)
|
|
62
|
+
.isoformat()
|
|
63
|
+
.replace("+00:00", "Z")
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _status_for_persisted_runtime(status: RuntimeStatus) -> RuntimeStatus:
|
|
68
|
+
if status is RuntimeStatus.READY:
|
|
69
|
+
return RuntimeStatus.BROKEN
|
|
70
|
+
return status
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Workspace runtime store and persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
|
|
9
|
+
from androidctld.config import DaemonConfig
|
|
10
|
+
from androidctld.protocol import RuntimeStatus
|
|
11
|
+
from androidctld.runtime.models import WorkspaceRuntime
|
|
12
|
+
from androidctld.runtime.state_repo import RuntimeStateRepository
|
|
13
|
+
from androidctld.schema.persistence import RUNTIME_STATE_FILE_NAME
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RuntimeSerialCommandBusyError(RuntimeError):
|
|
17
|
+
"""Raised when a public runtime command is already active."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RuntimeStore:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
config: DaemonConfig,
|
|
24
|
+
state_repo: RuntimeStateRepository | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._config = config
|
|
27
|
+
self._state_repo = state_repo or RuntimeStateRepository()
|
|
28
|
+
self._lock = threading.RLock()
|
|
29
|
+
self._runtime: WorkspaceRuntime | None = None
|
|
30
|
+
self._active_command: str | None = None
|
|
31
|
+
|
|
32
|
+
def get_runtime(self) -> WorkspaceRuntime:
|
|
33
|
+
with self._lock:
|
|
34
|
+
if self._runtime is None:
|
|
35
|
+
runtime_path = (
|
|
36
|
+
self._config.workspace_root
|
|
37
|
+
/ ".androidctl"
|
|
38
|
+
/ RUNTIME_STATE_FILE_NAME
|
|
39
|
+
)
|
|
40
|
+
loaded = self._state_repo.load(runtime_path)
|
|
41
|
+
if loaded is None:
|
|
42
|
+
artifact_root = runtime_path.parent
|
|
43
|
+
self._runtime = WorkspaceRuntime(
|
|
44
|
+
workspace_root=self._config.workspace_root,
|
|
45
|
+
artifact_root=artifact_root,
|
|
46
|
+
runtime_path=runtime_path,
|
|
47
|
+
status=RuntimeStatus.NEW,
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
self._runtime = loaded
|
|
51
|
+
return self._runtime
|
|
52
|
+
|
|
53
|
+
def persist_runtime(self, runtime: WorkspaceRuntime) -> None:
|
|
54
|
+
artifact_root = self._config.workspace_root / ".androidctl"
|
|
55
|
+
self._state_repo.persist(
|
|
56
|
+
runtime,
|
|
57
|
+
runtime_path=artifact_root / RUNTIME_STATE_FILE_NAME,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def ensure_runtime(self) -> WorkspaceRuntime:
|
|
61
|
+
return self.get_runtime()
|
|
62
|
+
|
|
63
|
+
@contextmanager
|
|
64
|
+
def begin_serial_command(self, command_name: str) -> Iterator[None]:
|
|
65
|
+
with self._lock:
|
|
66
|
+
if self._active_command is not None:
|
|
67
|
+
raise RuntimeSerialCommandBusyError(
|
|
68
|
+
"overlapping control requests are not allowed"
|
|
69
|
+
)
|
|
70
|
+
self._active_command = command_name
|
|
71
|
+
try:
|
|
72
|
+
yield
|
|
73
|
+
finally:
|
|
74
|
+
with self._lock:
|
|
75
|
+
if self._active_command == command_name:
|
|
76
|
+
self._active_command = None
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Runtime policy constants shared across androidctld subsystems."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Final
|
|
6
|
+
|
|
7
|
+
from androidctld.protocol import CommandKind
|
|
8
|
+
|
|
9
|
+
DEFAULT_DEVICE_PORT: Final[int] = 17171
|
|
10
|
+
DEFAULT_DEVICE_RPC_TIMEOUT_SECONDS: Final[float] = 5.0
|
|
11
|
+
ANDROID_SCREENSHOT_METHOD_TIMEOUT_SECONDS: Final[float] = 11.0
|
|
12
|
+
SCREENSHOT_DEVICE_RPC_TRANSPORT_MARGIN_SECONDS: Final[float] = 1.0
|
|
13
|
+
SCREENSHOT_DEVICE_RPC_TIMEOUT_SECONDS: Final[float] = (
|
|
14
|
+
ANDROID_SCREENSHOT_METHOD_TIMEOUT_SECONDS
|
|
15
|
+
+ SCREENSHOT_DEVICE_RPC_TRANSPORT_MARGIN_SECONDS
|
|
16
|
+
)
|
|
17
|
+
ADB_COMMAND_TIMEOUT_SECONDS: Final[float] = 10.0
|
|
18
|
+
DAEMON_HTTP_MAX_REQUEST_BODY_BYTES: Final[int] = 1 * 1024 * 1024
|
|
19
|
+
DAEMON_HTTP_SOCKET_TIMEOUT_SECONDS: Final[float] = 5.0
|
|
20
|
+
DEVICE_RPC_MAX_RESPONSE_BYTES: Final[int] = 4 * 1024 * 1024
|
|
21
|
+
SCREENSHOT_MAX_BINARY_BYTES: Final[int] = 32 * 1024 * 1024
|
|
22
|
+
SCREENSHOT_MAX_BASE64_CHARS: Final[int] = ((SCREENSHOT_MAX_BINARY_BYTES + 2) // 3) * 4
|
|
23
|
+
SCREENSHOT_MAX_RPC_RESPONSE_BYTES: Final[int] = 48 * 1024 * 1024
|
|
24
|
+
SCREENSHOT_MAX_OUTPUT_PIXELS: Final[int] = 16_777_216
|
|
25
|
+
MAIN_LOOP_SLEEP_SECONDS: Final[float] = 0.1
|
|
26
|
+
NON_NUMERIC_REF_SORT_BUCKET: Final[int] = 1_000_000_000
|
|
27
|
+
|
|
28
|
+
DEVICE_RPC_REQUEST_ID_BOOTSTRAP: Final[str] = "androidctld-bootstrap"
|
|
29
|
+
DEVICE_RPC_REQUEST_ID_SNAPSHOT: Final[str] = "androidctld-snapshot"
|
|
30
|
+
DEVICE_RPC_REQUEST_ID_SETTLE: Final[str] = "androidctld-settle"
|
|
31
|
+
DEVICE_RPC_REQUEST_ID_ACTION: Final[str] = "androidctld-action"
|
|
32
|
+
DEVICE_RPC_REQUEST_ID_ACTION_REPAIRED: Final[str] = "androidctld-action-repaired"
|
|
33
|
+
DEVICE_RPC_REQUEST_ID_WAIT: Final[str] = "androidctld-wait"
|
|
34
|
+
DEVICE_RPC_REQUEST_ID_SCREENSHOT: Final[str] = "androidctld-screenshot"
|
|
35
|
+
DEVICE_RPC_REQUEST_ID_LIST_APPS: Final[str] = "androidctld-list-apps"
|
|
36
|
+
|
|
37
|
+
DEFAULT_SNAPSHOT_INCLUDE_INVISIBLE: Final[bool] = True
|
|
38
|
+
DEFAULT_SNAPSHOT_INCLUDE_SYSTEM_WINDOWS: Final[bool] = True
|
|
39
|
+
DEFAULT_SCREENSHOT_FORMAT: Final[str] = "png"
|
|
40
|
+
DEFAULT_SCREENSHOT_SCALE: Final[float] = 1.0
|
|
41
|
+
|
|
42
|
+
DEFAULT_SETTLE_MIN_GRACE_MS: Final[int] = 200
|
|
43
|
+
SETTLE_MIN_GRACE_MS_BY_COMMAND: Final[dict[CommandKind, int]] = {
|
|
44
|
+
CommandKind.OPEN: 500,
|
|
45
|
+
}
|
|
46
|
+
DEFAULT_SETTLE_MAX_TOTAL_MS: Final[int] = 1200
|
|
47
|
+
SETTLE_MAX_TOTAL_MS_BY_COMMAND: Final[dict[CommandKind, int]] = {
|
|
48
|
+
CommandKind.OPEN: 4000,
|
|
49
|
+
}
|
|
50
|
+
DEFAULT_SETTLE_STABLE_WINDOW_MS: Final[int] = 300
|
|
51
|
+
SETTLE_STABLE_WINDOW_MS_BY_COMMAND: Final[dict[CommandKind, int]] = {
|
|
52
|
+
CommandKind.OPEN: 500,
|
|
53
|
+
}
|
|
54
|
+
DEFAULT_SETTLE_SNAPSHOT_MAX_INTERVAL_MS: Final[int] = 250
|
|
55
|
+
SETTLE_SNAPSHOT_MAX_INTERVAL_MS_BY_COMMAND: Final[dict[CommandKind, int]] = {
|
|
56
|
+
CommandKind.OPEN: 500,
|
|
57
|
+
}
|
|
58
|
+
SETTLE_POLL_SLICE_MS: Final[int] = 100
|
|
59
|
+
|
|
60
|
+
WAIT_TIMEOUT_MS_BY_KIND: Final[dict[str, int]] = {
|
|
61
|
+
"text": 3000,
|
|
62
|
+
"screen-change": 3000,
|
|
63
|
+
"gone": 3000,
|
|
64
|
+
"app": 3000,
|
|
65
|
+
"idle": 3000,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ACTION_TIMEOUT_MS_BY_COMMAND: Final[dict[CommandKind, int]] = {
|
|
69
|
+
CommandKind.OPEN: 5000,
|
|
70
|
+
CommandKind.TAP: 5000,
|
|
71
|
+
CommandKind.LONG_TAP: 5000,
|
|
72
|
+
CommandKind.TYPE: 8000,
|
|
73
|
+
CommandKind.GLOBAL: 5000,
|
|
74
|
+
CommandKind.SCROLL: 5000,
|
|
75
|
+
CommandKind.FOCUS: 5000,
|
|
76
|
+
CommandKind.SUBMIT: 5000,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
WAIT_EVENT_POLL_SLICE_MS: Final[int] = 250
|
|
80
|
+
WAIT_SNAPSHOT_MAX_INTERVAL_MS: Final[int] = 500
|
|
81
|
+
WAIT_LOOP_SLEEP_SECONDS: Final[float] = 0.05
|
|
82
|
+
TRANSIENT_INVALID_SNAPSHOT_RETRY_SECONDS: Final[float] = 0.05
|
|
83
|
+
TRANSIENT_INVALID_SNAPSHOT_MAX_RETRIES: Final[int] = 2
|
|
84
|
+
WAIT_IDLE_STABLE_WINDOW_MS: Final[int] = 500
|
|
85
|
+
QUERY_PROGRESS_WAIT_SECONDS: Final[float] = 0.2
|
|
86
|
+
QUERY_PROGRESS_POLL_SECONDS: Final[float] = 0.02
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def settle_min_grace_ms(kind: CommandKind) -> int:
|
|
90
|
+
return SETTLE_MIN_GRACE_MS_BY_COMMAND.get(kind, DEFAULT_SETTLE_MIN_GRACE_MS)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def settle_max_total_ms(kind: CommandKind) -> int:
|
|
94
|
+
return SETTLE_MAX_TOTAL_MS_BY_COMMAND.get(kind, DEFAULT_SETTLE_MAX_TOTAL_MS)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def settle_stable_window_ms(kind: CommandKind) -> int:
|
|
98
|
+
return SETTLE_STABLE_WINDOW_MS_BY_COMMAND.get(kind, DEFAULT_SETTLE_STABLE_WINDOW_MS)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def settle_snapshot_max_interval_ms(kind: CommandKind) -> int:
|
|
102
|
+
return SETTLE_SNAPSHOT_MAX_INTERVAL_MS_BY_COMMAND.get(
|
|
103
|
+
kind, DEFAULT_SETTLE_SNAPSHOT_MAX_INTERVAL_MS
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def default_wait_timeout_ms(wait_kind: object) -> int:
|
|
108
|
+
normalized = str(getattr(wait_kind, "value", wait_kind))
|
|
109
|
+
return WAIT_TIMEOUT_MS_BY_KIND[normalized]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def action_timeout_ms(kind: CommandKind) -> int:
|
|
113
|
+
return ACTION_TIMEOUT_MS_BY_COMMAND[kind]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def default_snapshot_params() -> dict[str, bool]:
|
|
117
|
+
return {
|
|
118
|
+
"includeInvisible": DEFAULT_SNAPSHOT_INCLUDE_INVISIBLE,
|
|
119
|
+
"includeSystemWindows": DEFAULT_SNAPSHOT_INCLUDE_SYSTEM_WINDOWS,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def default_screenshot_params() -> dict[str, Any]:
|
|
124
|
+
return {
|
|
125
|
+
"format": DEFAULT_SCREENSHOT_FORMAT,
|
|
126
|
+
"scale": DEFAULT_SCREENSHOT_SCALE,
|
|
127
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Shared boundary-model conventions for androidctld schemas."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
9
|
+
from pydantic_core import InitErrorDetails
|
|
10
|
+
from typing_extensions import Self
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pydantic._internal._model_construction import ModelMetaclass as _ModelMetaclass
|
|
14
|
+
else:
|
|
15
|
+
_ModelMetaclass = type(BaseModel)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def to_camel(name: str) -> str:
|
|
19
|
+
parts = name.split("_")
|
|
20
|
+
if len(parts) == 1:
|
|
21
|
+
return name
|
|
22
|
+
head, *tail = parts
|
|
23
|
+
return head + "".join(
|
|
24
|
+
segment[:1].upper() + segment[1:] for segment in tail if segment
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _ApiModelMeta(_ModelMetaclass):
|
|
29
|
+
def __call__(cls, *args: Any, **kwargs: Any) -> Any:
|
|
30
|
+
model_cls = cast(type[ApiModel], cls)
|
|
31
|
+
if kwargs and model_cls._contains_alias_kwargs(kwargs):
|
|
32
|
+
raise ValidationError.from_exception_data(
|
|
33
|
+
model_cls.__name__,
|
|
34
|
+
model_cls._alias_init_errors(kwargs),
|
|
35
|
+
)
|
|
36
|
+
return super().__call__(*args, **kwargs)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ApiModel(BaseModel, metaclass=_ApiModelMeta):
|
|
40
|
+
model_config = ConfigDict(
|
|
41
|
+
strict=True,
|
|
42
|
+
extra="forbid",
|
|
43
|
+
alias_generator=to_camel,
|
|
44
|
+
validate_by_alias=True,
|
|
45
|
+
validate_by_name=True,
|
|
46
|
+
use_enum_values=False,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def model_validate(
|
|
51
|
+
cls,
|
|
52
|
+
obj: Any,
|
|
53
|
+
*,
|
|
54
|
+
strict: bool | None = None,
|
|
55
|
+
extra: Literal["allow", "ignore", "forbid"] | None = None,
|
|
56
|
+
from_attributes: bool | None = None,
|
|
57
|
+
context: Any | None = None,
|
|
58
|
+
by_alias: bool | None = None,
|
|
59
|
+
by_name: bool | None = None,
|
|
60
|
+
) -> Self:
|
|
61
|
+
# External payload parsing stays alias-only by default, while internal
|
|
62
|
+
# code can still use normal snake_case keyword construction.
|
|
63
|
+
return super().model_validate(
|
|
64
|
+
obj,
|
|
65
|
+
strict=strict,
|
|
66
|
+
extra=extra,
|
|
67
|
+
from_attributes=from_attributes,
|
|
68
|
+
context=context,
|
|
69
|
+
by_alias=True if by_alias is None else by_alias,
|
|
70
|
+
by_name=False if by_name is None else by_name,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def model_validate_json(
|
|
75
|
+
cls,
|
|
76
|
+
json_data: str | bytes | bytearray,
|
|
77
|
+
*,
|
|
78
|
+
strict: bool | None = None,
|
|
79
|
+
extra: Literal["allow", "ignore", "forbid"] | None = None,
|
|
80
|
+
context: Any | None = None,
|
|
81
|
+
by_alias: bool | None = None,
|
|
82
|
+
by_name: bool | None = None,
|
|
83
|
+
) -> Self:
|
|
84
|
+
return super().model_validate_json(
|
|
85
|
+
json_data,
|
|
86
|
+
strict=strict,
|
|
87
|
+
extra=extra,
|
|
88
|
+
context=context,
|
|
89
|
+
by_alias=True if by_alias is None else by_alias,
|
|
90
|
+
by_name=False if by_name is None else by_name,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def model_copy(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
update: Mapping[str, Any] | None = None,
|
|
97
|
+
deep: bool = False,
|
|
98
|
+
) -> Self:
|
|
99
|
+
model_type = type(self)
|
|
100
|
+
if update and model_type._contains_alias_kwargs(update):
|
|
101
|
+
raise ValidationError.from_exception_data(
|
|
102
|
+
model_type.__name__,
|
|
103
|
+
model_type._alias_init_errors(update),
|
|
104
|
+
)
|
|
105
|
+
return super().model_copy(update=update, deep=deep)
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def _contains_alias_kwargs(cls, data: Mapping[str, Any]) -> bool:
|
|
109
|
+
return any(
|
|
110
|
+
field.alias is not None
|
|
111
|
+
and field.alias != field_name
|
|
112
|
+
and field.alias in data
|
|
113
|
+
for field_name, field in cls.model_fields.items()
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def _alias_init_errors(cls, data: Mapping[str, Any]) -> list[InitErrorDetails]:
|
|
118
|
+
return [
|
|
119
|
+
InitErrorDetails(
|
|
120
|
+
type="extra_forbidden",
|
|
121
|
+
loc=(field.alias,),
|
|
122
|
+
input=data[field.alias],
|
|
123
|
+
)
|
|
124
|
+
for field_name, field in cls.model_fields.items()
|
|
125
|
+
if field.alias is not None
|
|
126
|
+
and field.alias != field_name
|
|
127
|
+
and field.alias in data
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def dump_api_model(model: ApiModel) -> dict[str, Any]:
|
|
132
|
+
return model.model_dump(by_alias=True, mode="json")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Shared schema decoding helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class SchemaDecodeError(ValueError):
|
|
11
|
+
field: str
|
|
12
|
+
problem: str
|
|
13
|
+
|
|
14
|
+
def __str__(self) -> str:
|
|
15
|
+
return f"{self.field} {self.problem}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def expect_field(payload: dict[str, Any], key: str, field_name: str) -> object:
|
|
19
|
+
if key not in payload:
|
|
20
|
+
raise SchemaDecodeError(field_name, "is required")
|
|
21
|
+
return payload[key]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def expect_object(value: object, field_name: str) -> dict[str, Any]:
|
|
25
|
+
if not isinstance(value, dict):
|
|
26
|
+
raise SchemaDecodeError(field_name, "must be a JSON object")
|
|
27
|
+
return dict(value)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def expect_int(value: object, field_name: str, minimum: int | None = None) -> int:
|
|
31
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
32
|
+
raise SchemaDecodeError(field_name, "must be an integer")
|
|
33
|
+
if minimum is not None and value < minimum:
|
|
34
|
+
raise SchemaDecodeError(field_name, f"must be an integer >= {minimum}")
|
|
35
|
+
return value
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Typed daemon ingress parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from androidctl_contracts import daemon_api as wire_api
|
|
11
|
+
from androidctl_contracts.command_catalog import is_daemon_command_kind
|
|
12
|
+
from androidctl_contracts.daemon_api import HealthResult
|
|
13
|
+
from androidctld.commands.command_models import InternalCommand
|
|
14
|
+
from androidctld.commands.from_boundary import (
|
|
15
|
+
compile_connect_command,
|
|
16
|
+
compile_global_action_command,
|
|
17
|
+
compile_list_apps_command,
|
|
18
|
+
compile_observe_command,
|
|
19
|
+
compile_open_command,
|
|
20
|
+
compile_ref_action_command,
|
|
21
|
+
compile_screenshot_command,
|
|
22
|
+
compile_service_wait_command,
|
|
23
|
+
)
|
|
24
|
+
from androidctld.errors import bad_request
|
|
25
|
+
from androidctld.schema.validation_errors import validation_error_to_bad_request
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"HealthResult",
|
|
29
|
+
"ParsedCommandRun",
|
|
30
|
+
"parse_command_run_request",
|
|
31
|
+
"require_empty_payload",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ParsedCommandRun:
|
|
37
|
+
command: InternalCommand
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def require_empty_payload(payload: dict[str, Any], route: str) -> None:
|
|
41
|
+
if payload:
|
|
42
|
+
raise bad_request(f"{route} does not accept request payload")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_command_run_request(payload: dict[str, Any]) -> ParsedCommandRun:
|
|
46
|
+
_validate_command_run_payload_shape(payload)
|
|
47
|
+
try:
|
|
48
|
+
boundary = wire_api.CommandRunRequest.model_validate(
|
|
49
|
+
payload,
|
|
50
|
+
strict=True,
|
|
51
|
+
)
|
|
52
|
+
except ValidationError as error:
|
|
53
|
+
raise validation_error_to_bad_request(error, field_name=None) from error
|
|
54
|
+
return ParsedCommandRun(
|
|
55
|
+
command=_adapt_wire_command_payload(boundary.command),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _validate_command_run_payload_shape(payload: dict[str, Any]) -> None:
|
|
60
|
+
if "command" not in payload:
|
|
61
|
+
raise bad_request("command is required", {"field": "command"})
|
|
62
|
+
|
|
63
|
+
command = payload["command"]
|
|
64
|
+
if not isinstance(command, dict):
|
|
65
|
+
raise bad_request("command must be a JSON object", {"field": "command"})
|
|
66
|
+
|
|
67
|
+
kind_raw = command.get("kind")
|
|
68
|
+
if not isinstance(kind_raw, str):
|
|
69
|
+
raise bad_request("command.kind must be a string", {"field": "command.kind"})
|
|
70
|
+
|
|
71
|
+
kind = kind_raw.strip()
|
|
72
|
+
if not kind:
|
|
73
|
+
raise bad_request(
|
|
74
|
+
"command.kind must be a non-empty string",
|
|
75
|
+
{"field": "command.kind"},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if not is_daemon_command_kind(kind):
|
|
79
|
+
raise bad_request(
|
|
80
|
+
"unsupported command kind",
|
|
81
|
+
{"field": "command.kind", "kind": kind},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _adapt_wire_command_payload(
|
|
86
|
+
payload: wire_api.DaemonCommandPayload,
|
|
87
|
+
) -> InternalCommand:
|
|
88
|
+
if isinstance(payload, wire_api.ConnectCommandPayload):
|
|
89
|
+
return compile_connect_command(payload)
|
|
90
|
+
if isinstance(payload, wire_api.ObserveCommandPayload):
|
|
91
|
+
return compile_observe_command(payload)
|
|
92
|
+
if isinstance(payload, wire_api.ListAppsCommandPayload):
|
|
93
|
+
return compile_list_apps_command(payload)
|
|
94
|
+
if isinstance(payload, wire_api.OpenCommandPayload):
|
|
95
|
+
return compile_open_command(payload)
|
|
96
|
+
if isinstance(payload, wire_api.RefActionCommandPayload):
|
|
97
|
+
return compile_ref_action_command(payload)
|
|
98
|
+
if isinstance(payload, wire_api.TypeCommandPayload):
|
|
99
|
+
return compile_ref_action_command(payload)
|
|
100
|
+
if isinstance(payload, wire_api.ScrollCommandPayload):
|
|
101
|
+
return compile_ref_action_command(payload)
|
|
102
|
+
if isinstance(payload, wire_api.GlobalActionCommandPayload):
|
|
103
|
+
return compile_global_action_command(payload)
|
|
104
|
+
if isinstance(payload, wire_api.WaitCommandPayload):
|
|
105
|
+
return compile_service_wait_command(payload)
|
|
106
|
+
if isinstance(payload, wire_api.ScreenshotCommandPayload):
|
|
107
|
+
return compile_screenshot_command(payload)
|
|
108
|
+
raise TypeError(f"unsupported wire command payload: {type(payload)!r}")
|