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,74 @@
|
|
|
1
|
+
"""Boundary DTOs for raw snapshot payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, StringConstraints
|
|
8
|
+
|
|
9
|
+
from androidctld.schema import ApiModel
|
|
10
|
+
|
|
11
|
+
TrimmedString = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
|
12
|
+
NonNegativeInt = Annotated[int, Field(ge=0)]
|
|
13
|
+
PositiveInt = Annotated[int, Field(ge=1)]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RawDisplayPayload(ApiModel):
|
|
17
|
+
width_px: PositiveInt
|
|
18
|
+
height_px: PositiveInt
|
|
19
|
+
density_dpi: PositiveInt
|
|
20
|
+
rotation: NonNegativeInt
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RawImePayload(ApiModel):
|
|
24
|
+
visible: bool
|
|
25
|
+
window_id: str | None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RawWindowPayload(ApiModel):
|
|
29
|
+
window_id: TrimmedString
|
|
30
|
+
type: TrimmedString
|
|
31
|
+
layer: int
|
|
32
|
+
package_name: TrimmedString | None
|
|
33
|
+
bounds: list[int]
|
|
34
|
+
root_rid: TrimmedString
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RawNodePayload(ApiModel):
|
|
38
|
+
rid: TrimmedString
|
|
39
|
+
window_id: TrimmedString
|
|
40
|
+
parent_rid: str | None
|
|
41
|
+
child_rids: list[str]
|
|
42
|
+
class_name: TrimmedString
|
|
43
|
+
resource_id: str | None
|
|
44
|
+
text: str | None
|
|
45
|
+
content_desc: str | None
|
|
46
|
+
hint_text: str | None
|
|
47
|
+
state_description: str | None
|
|
48
|
+
pane_title: str | None
|
|
49
|
+
package_name: TrimmedString | None
|
|
50
|
+
bounds: list[int]
|
|
51
|
+
visible_to_user: bool
|
|
52
|
+
important_for_accessibility: bool
|
|
53
|
+
clickable: bool
|
|
54
|
+
enabled: bool
|
|
55
|
+
editable: bool
|
|
56
|
+
focusable: bool
|
|
57
|
+
focused: bool
|
|
58
|
+
checkable: bool
|
|
59
|
+
checked: bool
|
|
60
|
+
selected: bool
|
|
61
|
+
scrollable: bool
|
|
62
|
+
password: bool
|
|
63
|
+
actions: list[str]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RawSnapshotPayload(ApiModel):
|
|
67
|
+
snapshot_id: NonNegativeInt
|
|
68
|
+
captured_at: TrimmedString
|
|
69
|
+
package_name: TrimmedString | None
|
|
70
|
+
activity_name: str | None
|
|
71
|
+
ime: RawImePayload
|
|
72
|
+
windows: list[RawWindowPayload]
|
|
73
|
+
nodes: list[RawNodePayload]
|
|
74
|
+
display: RawDisplayPayload
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Session-level snapshot retrieval."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from androidctld.device.bootstrap import DeviceBootstrapper
|
|
8
|
+
from androidctld.device.rpc import DeviceRpcClient
|
|
9
|
+
from androidctld.device.types import RuntimeTransport
|
|
10
|
+
from androidctld.errors import DaemonError, DaemonErrorCode
|
|
11
|
+
from androidctld.runtime import RuntimeKernel, RuntimeLifecycleLease
|
|
12
|
+
from androidctld.runtime.models import WorkspaceRuntime
|
|
13
|
+
from androidctld.runtime_policy import (
|
|
14
|
+
DEVICE_RPC_REQUEST_ID_SNAPSHOT,
|
|
15
|
+
TRANSIENT_INVALID_SNAPSHOT_RETRY_SECONDS,
|
|
16
|
+
)
|
|
17
|
+
from androidctld.snapshots.models import RawSnapshot
|
|
18
|
+
|
|
19
|
+
SleepFn = Callable[[float], None]
|
|
20
|
+
TimeFn = Callable[[], float]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SnapshotService:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
runtime_kernel: RuntimeKernel,
|
|
28
|
+
bootstrapper: DeviceBootstrapper | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._bootstrapper = bootstrapper or DeviceBootstrapper()
|
|
31
|
+
self._runtime_kernel = runtime_kernel
|
|
32
|
+
|
|
33
|
+
def fetch(
|
|
34
|
+
self,
|
|
35
|
+
session: WorkspaceRuntime,
|
|
36
|
+
force_refresh: bool,
|
|
37
|
+
*,
|
|
38
|
+
lifecycle_lease: RuntimeLifecycleLease | None = None,
|
|
39
|
+
) -> RawSnapshot:
|
|
40
|
+
if not force_refresh and session.latest_snapshot is not None:
|
|
41
|
+
_raise_if_stale_lifecycle_lease(session, lifecycle_lease)
|
|
42
|
+
return session.latest_snapshot
|
|
43
|
+
return self.device_client(
|
|
44
|
+
session,
|
|
45
|
+
lifecycle_lease=lifecycle_lease,
|
|
46
|
+
).snapshot_get(
|
|
47
|
+
request_id=DEVICE_RPC_REQUEST_ID_SNAPSHOT,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def device_client(
|
|
51
|
+
self,
|
|
52
|
+
session: WorkspaceRuntime,
|
|
53
|
+
*,
|
|
54
|
+
lifecycle_lease: RuntimeLifecycleLease | None = None,
|
|
55
|
+
) -> DeviceRpcClient:
|
|
56
|
+
if session.connection is None or not session.device_token:
|
|
57
|
+
raise DaemonError(
|
|
58
|
+
code=DaemonErrorCode.RUNTIME_NOT_CONNECTED,
|
|
59
|
+
message="runtime is not connected to a device",
|
|
60
|
+
retryable=False,
|
|
61
|
+
details={"workspaceRoot": session.workspace_root.as_posix()},
|
|
62
|
+
http_status=200,
|
|
63
|
+
)
|
|
64
|
+
transport = self.ensure_transport(
|
|
65
|
+
session,
|
|
66
|
+
lifecycle_lease=lifecycle_lease,
|
|
67
|
+
)
|
|
68
|
+
return DeviceRpcClient(endpoint=transport.endpoint, token=session.device_token)
|
|
69
|
+
|
|
70
|
+
def ensure_transport(
|
|
71
|
+
self,
|
|
72
|
+
session: WorkspaceRuntime,
|
|
73
|
+
*,
|
|
74
|
+
lifecycle_lease: RuntimeLifecycleLease | None = None,
|
|
75
|
+
) -> RuntimeTransport:
|
|
76
|
+
return self._runtime_kernel.rebootstrap_transport(
|
|
77
|
+
session,
|
|
78
|
+
bootstrap=self._bootstrapper.bootstrap,
|
|
79
|
+
lease=lifecycle_lease,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _raise_if_stale_lifecycle_lease(
|
|
84
|
+
session: WorkspaceRuntime,
|
|
85
|
+
lifecycle_lease: RuntimeLifecycleLease | None,
|
|
86
|
+
) -> None:
|
|
87
|
+
if lifecycle_lease is None:
|
|
88
|
+
return
|
|
89
|
+
with session.lock:
|
|
90
|
+
if lifecycle_lease.is_current(session):
|
|
91
|
+
return
|
|
92
|
+
raise DaemonError(
|
|
93
|
+
code=DaemonErrorCode.RUNTIME_NOT_CONNECTED,
|
|
94
|
+
message="runtime is not connected to a device",
|
|
95
|
+
retryable=False,
|
|
96
|
+
details={"workspaceRoot": session.workspace_root.as_posix()},
|
|
97
|
+
http_status=200,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_transient_invalid_package_snapshot(error: DaemonError) -> bool:
|
|
102
|
+
return (
|
|
103
|
+
error.code == DaemonErrorCode.DEVICE_RPC_FAILED
|
|
104
|
+
and error.details.get("reason") == "invalid_snapshot"
|
|
105
|
+
and error.details.get("field") == "result.packageName"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def fetch_with_transient_invalid_snapshot_retry(
|
|
110
|
+
snapshot_service: SnapshotService,
|
|
111
|
+
*,
|
|
112
|
+
session: WorkspaceRuntime,
|
|
113
|
+
force_refresh: bool,
|
|
114
|
+
lifecycle_lease: RuntimeLifecycleLease | None = None,
|
|
115
|
+
deadline_at: float,
|
|
116
|
+
max_retries: int,
|
|
117
|
+
sleep_fn: SleepFn,
|
|
118
|
+
time_fn: TimeFn,
|
|
119
|
+
) -> RawSnapshot:
|
|
120
|
+
attempts = 0
|
|
121
|
+
while True:
|
|
122
|
+
try:
|
|
123
|
+
return snapshot_service.fetch(
|
|
124
|
+
session,
|
|
125
|
+
force_refresh=force_refresh,
|
|
126
|
+
lifecycle_lease=lifecycle_lease,
|
|
127
|
+
)
|
|
128
|
+
except DaemonError as error:
|
|
129
|
+
if not is_transient_invalid_package_snapshot(error):
|
|
130
|
+
raise
|
|
131
|
+
now = time_fn()
|
|
132
|
+
if attempts >= max_retries or now >= deadline_at:
|
|
133
|
+
raise
|
|
134
|
+
attempts += 1
|
|
135
|
+
remaining = max(deadline_at - now, 0.0)
|
|
136
|
+
sleep_duration = min(remaining, TRANSIENT_INVALID_SNAPSHOT_RETRY_SECONDS)
|
|
137
|
+
if sleep_duration > 0.0:
|
|
138
|
+
sleep_fn(sleep_duration)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Shared internal text normalization and searchable-text helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from androidctld.snapshots.models import RawNode
|
|
10
|
+
|
|
11
|
+
_WHITESPACE_RE = re.compile(r"\s+")
|
|
12
|
+
_STATE_DESCRIPTION_SPLIT_RE = re.compile(r"[,;/]+")
|
|
13
|
+
_STATE_ONLY_TOKENS = {
|
|
14
|
+
"on",
|
|
15
|
+
"off",
|
|
16
|
+
"checked",
|
|
17
|
+
"unchecked",
|
|
18
|
+
"not checked",
|
|
19
|
+
"selected",
|
|
20
|
+
"disabled",
|
|
21
|
+
"focused",
|
|
22
|
+
"expanded",
|
|
23
|
+
"collapsed",
|
|
24
|
+
"password",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalized_text_surface(value: Any) -> str:
|
|
29
|
+
if value is None:
|
|
30
|
+
return ""
|
|
31
|
+
text = unicodedata.normalize("NFKC", str(value))
|
|
32
|
+
return _WHITESPACE_RE.sub(" ", text).strip()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def canonical_text_key(value: Any) -> str:
|
|
36
|
+
return normalized_text_surface(value).casefold()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def semantic_state_description_remainder(value: Any) -> str:
|
|
40
|
+
normalized = normalized_text_surface(value)
|
|
41
|
+
if not normalized:
|
|
42
|
+
return ""
|
|
43
|
+
parts = [
|
|
44
|
+
part.strip()
|
|
45
|
+
for part in _STATE_DESCRIPTION_SPLIT_RE.split(normalized)
|
|
46
|
+
if part.strip()
|
|
47
|
+
]
|
|
48
|
+
if not parts:
|
|
49
|
+
return normalized
|
|
50
|
+
semantic_parts = [
|
|
51
|
+
part for part in parts if canonical_text_key(part) not in _STATE_ONLY_TOKENS
|
|
52
|
+
]
|
|
53
|
+
return " ".join(semantic_parts)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def searchable_raw_node_texts(raw_node: RawNode) -> tuple[str, ...]:
|
|
57
|
+
candidates: list[str] = []
|
|
58
|
+
for value in (
|
|
59
|
+
raw_node.text,
|
|
60
|
+
raw_node.content_desc,
|
|
61
|
+
raw_node.hint_text,
|
|
62
|
+
semantic_state_description_remainder(raw_node.state_description),
|
|
63
|
+
):
|
|
64
|
+
normalized = normalized_text_surface(value)
|
|
65
|
+
if normalized:
|
|
66
|
+
candidates.append(normalized)
|
|
67
|
+
return tuple(candidates)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Wait helpers for androidctld."""
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Wait evaluation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, TypeAlias
|
|
7
|
+
|
|
8
|
+
from androidctld.app_targets import AppTargetMatch, match_app_target
|
|
9
|
+
from androidctld.commands.command_models import (
|
|
10
|
+
AppWaitPredicate,
|
|
11
|
+
GoneWaitPredicate,
|
|
12
|
+
IdleWaitPredicate,
|
|
13
|
+
ScreenChangeWaitPredicate,
|
|
14
|
+
TextWaitPredicate,
|
|
15
|
+
WaitCommand,
|
|
16
|
+
)
|
|
17
|
+
from androidctld.protocol import CommandKind
|
|
18
|
+
from androidctld.runtime_policy import WAIT_IDLE_STABLE_WINDOW_MS
|
|
19
|
+
from androidctld.semantics.compiler import CompiledScreen
|
|
20
|
+
from androidctld.semantics.public_models import (
|
|
21
|
+
PublicNode,
|
|
22
|
+
PublicScreen,
|
|
23
|
+
public_group_nodes,
|
|
24
|
+
)
|
|
25
|
+
from androidctld.snapshots.models import RawSnapshot
|
|
26
|
+
from androidctld.snapshots.refresh import compiled_screen_signature
|
|
27
|
+
from androidctld.waits.matcher import matches_text
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class WaitReadyContext:
|
|
32
|
+
snapshot: RawSnapshot
|
|
33
|
+
public_screen: PublicScreen
|
|
34
|
+
compiled_screen: CompiledScreen | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class WaitIdleEvaluationState:
|
|
39
|
+
idle_signature: tuple[Any, ...] | None = None
|
|
40
|
+
idle_stable_since: float | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class WaitMatchData:
|
|
45
|
+
snapshot: RawSnapshot
|
|
46
|
+
app_match: AppTargetMatch | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class WaitMatched:
|
|
51
|
+
match: WaitMatchData
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class WaitNoMatch:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class WaitIdleTracking:
|
|
61
|
+
idle_state: WaitIdleEvaluationState
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
WaitEvaluationOutcome: TypeAlias = WaitMatched | WaitNoMatch | WaitIdleTracking
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _evaluate_text_wait(
|
|
68
|
+
*,
|
|
69
|
+
text: str,
|
|
70
|
+
snapshot: RawSnapshot,
|
|
71
|
+
compiled_screen: CompiledScreen | None,
|
|
72
|
+
) -> WaitEvaluationOutcome:
|
|
73
|
+
if compiled_screen is None:
|
|
74
|
+
return WaitNoMatch()
|
|
75
|
+
if matches_text(snapshot, text):
|
|
76
|
+
return WaitMatched(WaitMatchData(snapshot=snapshot))
|
|
77
|
+
return WaitNoMatch()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _evaluate_screen_change_wait(
|
|
81
|
+
*,
|
|
82
|
+
source_screen_id: str,
|
|
83
|
+
snapshot: RawSnapshot,
|
|
84
|
+
public_screen: PublicScreen,
|
|
85
|
+
) -> WaitEvaluationOutcome:
|
|
86
|
+
if public_screen.screen_id != source_screen_id:
|
|
87
|
+
return WaitMatched(WaitMatchData(snapshot=snapshot))
|
|
88
|
+
return WaitNoMatch()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _screen_contains_ref(screen: PublicScreen, ref: str) -> bool:
|
|
92
|
+
nodes = (
|
|
93
|
+
*public_group_nodes(screen, "targets"),
|
|
94
|
+
*public_group_nodes(screen, "context"),
|
|
95
|
+
*public_group_nodes(screen, "dialog"),
|
|
96
|
+
*public_group_nodes(screen, "keyboard"),
|
|
97
|
+
*public_group_nodes(screen, "system"),
|
|
98
|
+
)
|
|
99
|
+
return any(_node_has_ref(node, ref) for node in nodes)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _node_has_ref(node: PublicNode, ref: str) -> bool:
|
|
103
|
+
return node.ref == ref
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _evaluate_gone_wait(
|
|
107
|
+
*,
|
|
108
|
+
ref: str,
|
|
109
|
+
snapshot: RawSnapshot,
|
|
110
|
+
public_screen: PublicScreen,
|
|
111
|
+
) -> WaitEvaluationOutcome:
|
|
112
|
+
if not _screen_contains_ref(public_screen, ref):
|
|
113
|
+
return WaitMatched(WaitMatchData(snapshot=snapshot))
|
|
114
|
+
return WaitNoMatch()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _evaluate_app_wait(
|
|
118
|
+
*,
|
|
119
|
+
package_name: str,
|
|
120
|
+
snapshot: RawSnapshot,
|
|
121
|
+
) -> WaitEvaluationOutcome:
|
|
122
|
+
app_match = match_app_target(package_name, snapshot.package_name)
|
|
123
|
+
if app_match is not None:
|
|
124
|
+
return WaitMatched(WaitMatchData(snapshot=snapshot, app_match=app_match))
|
|
125
|
+
return WaitNoMatch()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _evaluate_idle_wait(
|
|
129
|
+
*,
|
|
130
|
+
snapshot: RawSnapshot,
|
|
131
|
+
compiled_screen: CompiledScreen | None,
|
|
132
|
+
idle_state: WaitIdleEvaluationState,
|
|
133
|
+
now: float,
|
|
134
|
+
) -> WaitEvaluationOutcome:
|
|
135
|
+
if compiled_screen is None:
|
|
136
|
+
return WaitIdleTracking(WaitIdleEvaluationState())
|
|
137
|
+
current_signature = compiled_screen_signature(compiled_screen, snapshot)
|
|
138
|
+
if current_signature == idle_state.idle_signature:
|
|
139
|
+
stable_since = (
|
|
140
|
+
idle_state.idle_stable_since
|
|
141
|
+
if idle_state.idle_stable_since is not None
|
|
142
|
+
else now
|
|
143
|
+
)
|
|
144
|
+
if (now - stable_since) * 1000 >= WAIT_IDLE_STABLE_WINDOW_MS:
|
|
145
|
+
return WaitMatched(
|
|
146
|
+
WaitMatchData(
|
|
147
|
+
snapshot=snapshot,
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
return WaitIdleTracking(
|
|
151
|
+
WaitIdleEvaluationState(
|
|
152
|
+
idle_signature=current_signature,
|
|
153
|
+
idle_stable_since=stable_since,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
return WaitIdleTracking(
|
|
157
|
+
WaitIdleEvaluationState(
|
|
158
|
+
idle_signature=current_signature,
|
|
159
|
+
idle_stable_since=now,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def evaluate_ready_wait_match(
|
|
165
|
+
*,
|
|
166
|
+
command: WaitCommand,
|
|
167
|
+
ready: WaitReadyContext,
|
|
168
|
+
idle_state: WaitIdleEvaluationState,
|
|
169
|
+
now: float,
|
|
170
|
+
) -> WaitEvaluationOutcome:
|
|
171
|
+
if command.kind is not CommandKind.WAIT:
|
|
172
|
+
raise TypeError("wait evaluation kind must be canonical wait")
|
|
173
|
+
predicate = command.predicate
|
|
174
|
+
if isinstance(predicate, TextWaitPredicate):
|
|
175
|
+
return _evaluate_text_wait(
|
|
176
|
+
text=predicate.text,
|
|
177
|
+
snapshot=ready.snapshot,
|
|
178
|
+
compiled_screen=ready.compiled_screen,
|
|
179
|
+
)
|
|
180
|
+
if isinstance(predicate, ScreenChangeWaitPredicate):
|
|
181
|
+
return _evaluate_screen_change_wait(
|
|
182
|
+
source_screen_id=predicate.source_screen_id,
|
|
183
|
+
snapshot=ready.snapshot,
|
|
184
|
+
public_screen=ready.public_screen,
|
|
185
|
+
)
|
|
186
|
+
if isinstance(predicate, GoneWaitPredicate):
|
|
187
|
+
return _evaluate_gone_wait(
|
|
188
|
+
ref=predicate.ref,
|
|
189
|
+
snapshot=ready.snapshot,
|
|
190
|
+
public_screen=ready.public_screen,
|
|
191
|
+
)
|
|
192
|
+
if isinstance(predicate, AppWaitPredicate):
|
|
193
|
+
return _evaluate_app_wait(
|
|
194
|
+
package_name=predicate.package_name,
|
|
195
|
+
snapshot=ready.snapshot,
|
|
196
|
+
)
|
|
197
|
+
if isinstance(predicate, IdleWaitPredicate):
|
|
198
|
+
return _evaluate_idle_wait(
|
|
199
|
+
snapshot=ready.snapshot,
|
|
200
|
+
compiled_screen=ready.compiled_screen,
|
|
201
|
+
idle_state=idle_state,
|
|
202
|
+
now=now,
|
|
203
|
+
)
|
|
204
|
+
raise TypeError(f"unsupported wait evaluator kind: {command.wait_kind.value!r}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
__all__ = [
|
|
208
|
+
"WaitEvaluationOutcome",
|
|
209
|
+
"WaitIdleEvaluationState",
|
|
210
|
+
"WaitIdleTracking",
|
|
211
|
+
"WaitMatchData",
|
|
212
|
+
"WaitMatched",
|
|
213
|
+
"WaitNoMatch",
|
|
214
|
+
"WaitReadyContext",
|
|
215
|
+
"evaluate_ready_wait_match",
|
|
216
|
+
]
|