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,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
+ ]