android-midscene-automation 0.2.4 → 1.1.1

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 (525) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +159 -29
  3. package/artemis-main/.dockerignore +15 -0
  4. package/artemis-main/.env.example +34 -0
  5. package/artemis-main/CONTRIBUTING.md +35 -0
  6. package/artemis-main/Dockerfile +68 -0
  7. package/artemis-main/LICENSE +202 -0
  8. package/artemis-main/Makefile +115 -0
  9. package/artemis-main/README.md +322 -0
  10. package/artemis-main/README_CN.md +320 -0
  11. package/artemis-main/apps/__init__.py +15 -0
  12. package/artemis-main/apps/admin_console/__init__.py +19 -0
  13. package/artemis-main/apps/admin_console/core/__init__.py +13 -0
  14. package/artemis-main/apps/admin_console/core/config.py +43 -0
  15. package/artemis-main/apps/admin_console/core/security.py +178 -0
  16. package/artemis-main/apps/admin_console/core/state.py +243 -0
  17. package/artemis-main/apps/admin_console/database/__init__.py +13 -0
  18. package/artemis-main/apps/admin_console/database/connection.py +60 -0
  19. package/artemis-main/apps/admin_console/database/repositories/__init__.py +13 -0
  20. package/artemis-main/apps/admin_console/database/repositories/session_repository.py +564 -0
  21. package/artemis-main/apps/admin_console/database/repositories/step_repository.py +512 -0
  22. package/artemis-main/apps/admin_console/database/repositories/trace_repository.py +133 -0
  23. package/artemis-main/apps/admin_console/index.html +3787 -0
  24. package/artemis-main/apps/admin_console/replay_manager.py +2585 -0
  25. package/artemis-main/apps/admin_console/routers/__init__.py +13 -0
  26. package/artemis-main/apps/admin_console/routers/media.py +198 -0
  27. package/artemis-main/apps/admin_console/routers/replay.py +91 -0
  28. package/artemis-main/apps/admin_console/routers/sessions.py +239 -0
  29. package/artemis-main/apps/admin_console/routers/steps.py +109 -0
  30. package/artemis-main/apps/admin_console/routers/stream.py +56 -0
  31. package/artemis-main/apps/admin_console/routers/system.py +761 -0
  32. package/artemis-main/apps/admin_console/routers/tasks.py +517 -0
  33. package/artemis-main/apps/admin_console/schemas/__init__.py +13 -0
  34. package/artemis-main/apps/admin_console/schemas/task_schema.py +47 -0
  35. package/artemis-main/apps/admin_console/server.py +508 -0
  36. package/artemis-main/apps/admin_console/services/__init__.py +13 -0
  37. package/artemis-main/apps/admin_console/services/device_stream_service.py +137 -0
  38. package/artemis-main/apps/admin_console/services/ipc_service.py +262 -0
  39. package/artemis-main/apps/admin_console/services/media_service.py +623 -0
  40. package/artemis-main/apps/admin_console/services/model_service.py +148 -0
  41. package/artemis-main/apps/admin_console/services/task_preset_catalog.py +514 -0
  42. package/artemis-main/apps/admin_console/services/task_queue_service.py +1719 -0
  43. package/artemis-main/apps/admin_console/services/worker_process_io.py +144 -0
  44. package/artemis-main/apps/showcase_ui/README.md +31 -0
  45. package/artemis-main/apps/showcase_ui/angular.json +132 -0
  46. package/artemis-main/apps/showcase_ui/package-lock.json +9472 -0
  47. package/artemis-main/apps/showcase_ui/package.json +39 -0
  48. package/artemis-main/apps/showcase_ui/proxy.conf.json +23 -0
  49. package/artemis-main/apps/showcase_ui/public/favicon.ico +0 -0
  50. package/artemis-main/apps/showcase_ui/public/logo.png +0 -0
  51. package/artemis-main/apps/showcase_ui/public/logo.svg +56 -0
  52. package/artemis-main/apps/showcase_ui/src/app/app.component.html +24 -0
  53. package/artemis-main/apps/showcase_ui/src/app/app.component.scss +49 -0
  54. package/artemis-main/apps/showcase_ui/src/app/app.component.spec.ts +42 -0
  55. package/artemis-main/apps/showcase_ui/src/app/app.component.ts +32 -0
  56. package/artemis-main/apps/showcase_ui/src/app/app.config.ts +29 -0
  57. package/artemis-main/apps/showcase_ui/src/app/app.routes.ts +27 -0
  58. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts +108 -0
  59. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +1436 -0
  60. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss +3973 -0
  61. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +2235 -0
  62. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html +380 -0
  63. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.scss +1171 -0
  64. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts +270 -0
  65. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html +433 -0
  66. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss +987 -0
  67. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts +632 -0
  68. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.html +130 -0
  69. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.scss +406 -0
  70. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.ts +74 -0
  71. package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.scss +115 -0
  72. package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts +52 -0
  73. package/artemis-main/apps/showcase_ui/src/app/core/data/smart-tasks.data.ts +334 -0
  74. package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.spec.ts +55 -0
  75. package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.ts +61 -0
  76. package/artemis-main/apps/showcase_ui/src/app/core/i18n/translate.pipe.ts +11 -0
  77. package/artemis-main/apps/showcase_ui/src/app/core/i18n/zh-cn.ts +651 -0
  78. package/artemis-main/apps/showcase_ui/src/app/core/models/markdown.model.ts +43 -0
  79. package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.spec.ts +47 -0
  80. package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.ts +165 -0
  81. package/artemis-main/apps/showcase_ui/src/app/core/models/session.model.ts +85 -0
  82. package/artemis-main/apps/showcase_ui/src/app/core/models/stream.model.ts +246 -0
  83. package/artemis-main/apps/showcase_ui/src/app/core/models/system.model.ts +118 -0
  84. package/artemis-main/apps/showcase_ui/src/app/core/services/task-recommendation.service.ts +111 -0
  85. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.html +2130 -0
  86. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.scss +5204 -0
  87. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.ts +1189 -0
  88. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.html +39 -0
  89. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.scss +151 -0
  90. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.ts +58 -0
  91. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.html +201 -0
  92. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.scss +886 -0
  93. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts +324 -0
  94. package/artemis-main/apps/showcase_ui/src/app/services/agent.service.spec.ts +418 -0
  95. package/artemis-main/apps/showcase_ui/src/app/services/agent.service.ts +2012 -0
  96. package/artemis-main/apps/showcase_ui/src/app/services/system.service.spec.ts +173 -0
  97. package/artemis-main/apps/showcase_ui/src/app/services/system.service.ts +620 -0
  98. package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.spec.ts +513 -0
  99. package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.ts +1015 -0
  100. package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.spec.ts +85 -0
  101. package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.ts +662 -0
  102. package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts +70 -0
  103. package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.ts +383 -0
  104. package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.spec.ts +107 -0
  105. package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.ts +123 -0
  106. package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.spec.ts +92 -0
  107. package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.ts +116 -0
  108. package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts +640 -0
  109. package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts +1021 -0
  110. package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts +375 -0
  111. package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.ts +1419 -0
  112. package/artemis-main/apps/showcase_ui/src/index.html +37 -0
  113. package/artemis-main/apps/showcase_ui/src/main.ts +22 -0
  114. package/artemis-main/apps/showcase_ui/src/styles.scss +60 -0
  115. package/artemis-main/apps/showcase_ui/tsconfig.app.json +23 -0
  116. package/artemis-main/apps/showcase_ui/tsconfig.json +27 -0
  117. package/artemis-main/apps/showcase_ui/tsconfig.spec.json +23 -0
  118. package/artemis-main/artemis/__init__.py +44 -0
  119. package/artemis-main/artemis/__main__.py +18 -0
  120. package/artemis-main/artemis/_legacy_imports.py +94 -0
  121. package/artemis-main/artemis/_version.py +30 -0
  122. package/artemis-main/artemis/agents/__init__.py +17 -0
  123. package/artemis-main/artemis/agents/checker/checker.json +9 -0
  124. package/artemis-main/artemis/agents/checker/checker.py +859 -0
  125. package/artemis-main/artemis/agents/diagnoser/diagnoser.json +23 -0
  126. package/artemis-main/artemis/agents/diagnoser/diagnoser.py +608 -0
  127. package/artemis-main/artemis/agents/explorer/constants.py +52 -0
  128. package/artemis-main/artemis/agents/explorer/explorer.json +57 -0
  129. package/artemis-main/artemis/agents/explorer/explorer.py +473 -0
  130. package/artemis-main/artemis/agents/explorer/geometry.py +73 -0
  131. package/artemis-main/artemis/agents/explorer/native_runner.py +878 -0
  132. package/artemis-main/artemis/agents/explorer/perception_tools.py +632 -0
  133. package/artemis-main/artemis/agents/explorer/run_setup.py +403 -0
  134. package/artemis-main/artemis/agents/explorer/screen_index.py +266 -0
  135. package/artemis-main/artemis/agents/explorer/tiers.py +110 -0
  136. package/artemis-main/artemis/agents/explorer/tool_declarations.py +394 -0
  137. package/artemis-main/artemis/agents/explorer/universal_runner.py +317 -0
  138. package/artemis-main/artemis/agents/flash/context_compressor.py +452 -0
  139. package/artemis-main/artemis/agents/flash/flash_runner.md +51 -0
  140. package/artemis-main/artemis/agents/flash/flash_summarizer.md +53 -0
  141. package/artemis-main/artemis/agents/flash/flash_summarizer_single.md +54 -0
  142. package/artemis-main/artemis/agents/flash/runner.py +1167 -0
  143. package/artemis-main/artemis/agents/flash/summarizer.py +511 -0
  144. package/artemis-main/artemis/agents/history_analyzer/history_analyzer.md +8 -0
  145. package/artemis-main/artemis/agents/history_analyzer/history_analyzer.py +182 -0
  146. package/artemis-main/artemis/agents/image_processor/image_processor.md +74 -0
  147. package/artemis-main/artemis/agents/image_processor/image_processor.py +322 -0
  148. package/artemis-main/artemis/agents/log_analyzer/log_analyzer.md +15 -0
  149. package/artemis-main/artemis/agents/log_analyzer/log_analyzer.py +403 -0
  150. package/artemis-main/artemis/agents/log_analyzer/log_reader_sub_agent.md +8 -0
  151. package/artemis-main/artemis/agents/log_analyzer/output_analyzer.md +11 -0
  152. package/artemis-main/artemis/agents/log_analyzer/output_analyzer.py +163 -0
  153. package/artemis-main/artemis/agents/object_detector/object_detector.json +9 -0
  154. package/artemis-main/artemis/agents/object_detector/object_detector.py +211 -0
  155. package/artemis-main/artemis/agents/operator/operator.json +3 -0
  156. package/artemis-main/artemis/agents/operator/operator.py +1394 -0
  157. package/artemis-main/artemis/agents/operator/prompts.py +1064 -0
  158. package/artemis-main/artemis/agents/outputter/outputter.md +36 -0
  159. package/artemis-main/artemis/agents/outputter/outputter.py +552 -0
  160. package/artemis-main/artemis/agents/planner/planner.json +32 -0
  161. package/artemis-main/artemis/agents/planner/planner.py +436 -0
  162. package/artemis-main/artemis/agents/prompt_assembly.py +79 -0
  163. package/artemis-main/artemis/agents/summarizer/summarizer.py +190 -0
  164. package/artemis-main/artemis/agents/validator/action_execution.py +98 -0
  165. package/artemis-main/artemis/agents/validator/categories.py +33 -0
  166. package/artemis-main/artemis/agents/validator/execution_loop.py +488 -0
  167. package/artemis-main/artemis/agents/validator/incidents.py +120 -0
  168. package/artemis-main/artemis/agents/validator/pixel_safety_net.md +15 -0
  169. package/artemis-main/artemis/agents/validator/precondition_pixel.py +320 -0
  170. package/artemis-main/artemis/agents/validator/precondition_xml.py +672 -0
  171. package/artemis-main/artemis/agents/validator/tool_declarations.py +332 -0
  172. package/artemis-main/artemis/agents/validator/validator.py +188 -0
  173. package/artemis-main/artemis/agents/video_analyzer/__init__.py +15 -0
  174. package/artemis-main/artemis/agents/video_analyzer/audio_native.py +543 -0
  175. package/artemis-main/artemis/agents/video_analyzer/blackboard.py +821 -0
  176. package/artemis-main/artemis/agents/video_analyzer/chunk_agentic.py +455 -0
  177. package/artemis-main/artemis/agents/video_analyzer/chunk_conversation.py +491 -0
  178. package/artemis-main/artemis/agents/video_analyzer/chunk_native.py +437 -0
  179. package/artemis-main/artemis/agents/video_analyzer/conflict_resolution.py +137 -0
  180. package/artemis-main/artemis/agents/video_analyzer/gemini_files.py +162 -0
  181. package/artemis-main/artemis/agents/video_analyzer/native_coordinator.py +541 -0
  182. package/artemis-main/artemis/agents/video_analyzer/reliability.py +128 -0
  183. package/artemis-main/artemis/agents/video_analyzer/universal_engine.py +487 -0
  184. package/artemis-main/artemis/agents/video_analyzer/universal_tools.py +323 -0
  185. package/artemis-main/artemis/agents/video_analyzer/video_analyzer.md +51 -0
  186. package/artemis-main/artemis/agents/video_analyzer/video_analyzer.py +875 -0
  187. package/artemis-main/artemis/agents/video_analyzer/video_sub_agent.md +15 -0
  188. package/artemis-main/artemis/agents/video_analyzer/video_sub_agent_audio.md +12 -0
  189. package/artemis-main/artemis/clients/accessibility_client.py +438 -0
  190. package/artemis-main/artemis/clients/screen_client_factory.py +403 -0
  191. package/artemis-main/artemis/config/__init__.py +309 -0
  192. package/artemis-main/artemis/config/agent.py +1098 -0
  193. package/artemis-main/artemis/config/constants.py +151 -0
  194. package/artemis-main/artemis/config/core/__init__.py +24 -0
  195. package/artemis-main/artemis/config/core/engine.py +118 -0
  196. package/artemis-main/artemis/config/core/interpolator.py +78 -0
  197. package/artemis-main/artemis/config/llm.py +267 -0
  198. package/artemis-main/artemis/config/paths.py +244 -0
  199. package/artemis-main/artemis/config/runtime.py +197 -0
  200. package/artemis-main/artemis/config/settings.py +315 -0
  201. package/artemis-main/artemis/constants.py +47 -0
  202. package/artemis-main/artemis/context.py +289 -0
  203. package/artemis-main/artemis/controllers/__init__.py +41 -0
  204. package/artemis-main/artemis/controllers/controller_factory.py +41 -0
  205. package/artemis-main/artemis/controllers/device_controller.py +29 -0
  206. package/artemis-main/artemis/controllers/types.py +37 -0
  207. package/artemis-main/artemis/controllers/unified_controller.py +649 -0
  208. package/artemis-main/artemis/core/__init__.py +21 -0
  209. package/artemis-main/artemis/core/diagnostics/__init__.py +90 -0
  210. package/artemis-main/artemis/core/diagnostics/adb_keys.py +270 -0
  211. package/artemis-main/artemis/core/diagnostics/adb_server_connection.py +330 -0
  212. package/artemis-main/artemis/core/diagnostics/device_smoke.py +364 -0
  213. package/artemis-main/artemis/core/diagnostics/emulator_manager.py +501 -0
  214. package/artemis-main/artemis/core/diagnostics/engine.py +438 -0
  215. package/artemis-main/artemis/core/diagnostics/hierarchy_parity.py +216 -0
  216. package/artemis-main/artemis/core/diagnostics/probes/adb_probe.py +887 -0
  217. package/artemis-main/artemis/core/diagnostics/probes/base.py +45 -0
  218. package/artemis-main/artemis/core/diagnostics/probes/credentials_probe.py +296 -0
  219. package/artemis-main/artemis/core/diagnostics/probes/host_probe.py +427 -0
  220. package/artemis-main/artemis/core/diagnostics/probes/runtime_probe.py +169 -0
  221. package/artemis-main/artemis/core/diagnostics/probes/toolchain_probe.py +139 -0
  222. package/artemis-main/artemis/core/diagnostics/readiness.py +83 -0
  223. package/artemis-main/artemis/core/diagnostics/schema.py +118 -0
  224. package/artemis-main/artemis/core/tool_declaration.py +43 -0
  225. package/artemis-main/artemis/core/tool_failure.py +56 -0
  226. package/artemis-main/artemis/data_engine/context_vars.py +24 -0
  227. package/artemis-main/artemis/data_engine/engine.py +1615 -0
  228. package/artemis-main/artemis/data_engine/history_reader.py +142 -0
  229. package/artemis-main/artemis/data_engine/models.py +131 -0
  230. package/artemis-main/artemis/data_engine/storage.py +1245 -0
  231. package/artemis-main/artemis/data_engine/trace.py +727 -0
  232. package/artemis-main/artemis/drivers/__init__.py +48 -0
  233. package/artemis-main/artemis/drivers/android/adb_driver.py +507 -0
  234. package/artemis-main/artemis/drivers/android/hierarchy.py +64 -0
  235. package/artemis-main/artemis/drivers/android/input_ime.py +120 -0
  236. package/artemis-main/artemis/drivers/android/recorder.py +57 -0
  237. package/artemis-main/artemis/drivers/base.py +290 -0
  238. package/artemis-main/artemis/drivers/cloud/cloud_driver.py +117 -0
  239. package/artemis-main/artemis/drivers/factory.py +80 -0
  240. package/artemis-main/artemis/drivers/mock/mock_driver.py +198 -0
  241. package/artemis-main/artemis/drivers/types.py +67 -0
  242. package/artemis-main/artemis/graph/checkpoints.py +1243 -0
  243. package/artemis-main/artemis/graph/graph.py +1055 -0
  244. package/artemis-main/artemis/graph/perception.py +234 -0
  245. package/artemis-main/artemis/graph/state.py +151 -0
  246. package/artemis-main/artemis/graph/visibility.py +238 -0
  247. package/artemis-main/artemis/interfaces/cli/__init__.py +19 -0
  248. package/artemis-main/artemis/interfaces/cli/commands/__init__.py +15 -0
  249. package/artemis-main/artemis/interfaces/cli/commands/batch.py +278 -0
  250. package/artemis-main/artemis/interfaces/cli/commands/doctor.py +468 -0
  251. package/artemis-main/artemis/interfaces/cli/commands/helper.py +226 -0
  252. package/artemis-main/artemis/interfaces/cli/commands/init.py +193 -0
  253. package/artemis-main/artemis/interfaces/cli/commands/mcp.py +862 -0
  254. package/artemis-main/artemis/interfaces/cli/commands/run.py +463 -0
  255. package/artemis-main/artemis/interfaces/cli/commands/server.py +151 -0
  256. package/artemis-main/artemis/interfaces/cli/commands/server_lifecycle.py +328 -0
  257. package/artemis-main/artemis/interfaces/cli/commands/trace.py +111 -0
  258. package/artemis-main/artemis/interfaces/cli/commands/ui.py +404 -0
  259. package/artemis-main/artemis/interfaces/cli/main.py +109 -0
  260. package/artemis-main/artemis/interfaces/sdk/__init__.py +31 -0
  261. package/artemis-main/artemis/interfaces/sdk/client.py +19 -0
  262. package/artemis-main/artemis/interfaces/sdk/task.py +72 -0
  263. package/artemis-main/artemis/llm/__init__.py +25 -0
  264. package/artemis-main/artemis/llm/fake_model.py +179 -0
  265. package/artemis-main/artemis/llm/google/__init__.py +57 -0
  266. package/artemis-main/artemis/llm/google/provider.py +185 -0
  267. package/artemis-main/artemis/llm/google/usage.py +148 -0
  268. package/artemis-main/artemis/llm/parser.py +32 -0
  269. package/artemis-main/artemis/llm/reliability.py +267 -0
  270. package/artemis-main/artemis/llm/router.py +389 -0
  271. package/artemis-main/artemis/llm/schemas.py +36 -0
  272. package/artemis-main/artemis/llm/structured.py +196 -0
  273. package/artemis-main/artemis/main.py +58 -0
  274. package/artemis-main/artemis/mcp/__init__.py +20 -0
  275. package/artemis-main/artemis/mcp/action_executor.py +760 -0
  276. package/artemis-main/artemis/mcp/action_manifest.py +320 -0
  277. package/artemis-main/artemis/mcp/action_names.py +172 -0
  278. package/artemis-main/artemis/mcp/action_server.py +202 -0
  279. package/artemis-main/artemis/mcp/action_session.py +245 -0
  280. package/artemis-main/artemis/mcp/action_specs.py +868 -0
  281. package/artemis-main/artemis/mcp/action_types.py +86 -0
  282. package/artemis-main/artemis/mcp/actuators/__init__.py +21 -0
  283. package/artemis-main/artemis/mcp/actuators/adb.py +414 -0
  284. package/artemis-main/artemis/mcp/actuators/base.py +113 -0
  285. package/artemis-main/artemis/mcp/actuators/mock.py +70 -0
  286. package/artemis-main/artemis/mcp/adb_server.py +490 -0
  287. package/artemis-main/artemis/mcp/observation.py +133 -0
  288. package/artemis-main/artemis/mcp/xml_search_server.py +297 -0
  289. package/artemis-main/artemis/memory/__init__.py +90 -0
  290. package/artemis-main/artemis/memory/chunking.py +1843 -0
  291. package/artemis-main/artemis/memory/context_policy.py +226 -0
  292. package/artemis-main/artemis/memory/step_capsule.md +73 -0
  293. package/artemis-main/artemis/memory/step_memory.py +281 -0
  294. package/artemis-main/artemis/memory/transcript.py +798 -0
  295. package/artemis-main/artemis/platform/__init__.py +61 -0
  296. package/artemis-main/artemis/platform/base.py +104 -0
  297. package/artemis-main/artemis/platform/darwin.py +184 -0
  298. package/artemis-main/artemis/platform/linux.py +207 -0
  299. package/artemis-main/artemis/platform/windows.py +193 -0
  300. package/artemis-main/artemis/resources/__init__.py +46 -0
  301. package/artemis-main/artemis/runtime/__init__.py +108 -0
  302. package/artemis-main/artemis/runtime/adb_endpoint.py +173 -0
  303. package/artemis-main/artemis/runtime/awake_lease.py +283 -0
  304. package/artemis-main/artemis/runtime/awake_service.py +343 -0
  305. package/artemis-main/artemis/runtime/cancel_requests.py +254 -0
  306. package/artemis-main/artemis/runtime/daemon_client.py +422 -0
  307. package/artemis-main/artemis/runtime/device_lock.py +1013 -0
  308. package/artemis-main/artemis/runtime/device_pool.py +478 -0
  309. package/artemis-main/artemis/runtime/helper_manager.py +1030 -0
  310. package/artemis-main/artemis/runtime/process_probe.py +98 -0
  311. package/artemis-main/artemis/runtime/server_lifecycle.py +435 -0
  312. package/artemis-main/artemis/runtime/supervisor.py +121 -0
  313. package/artemis-main/artemis/runtime/trace_store.py +375 -0
  314. package/artemis-main/artemis/sdk/__init__.py +26 -0
  315. package/artemis-main/artemis/sdk/agent.py +485 -0
  316. package/artemis-main/artemis/sdk/builders/__init__.py +29 -0
  317. package/artemis-main/artemis/sdk/builders/agent_config_builder.py +474 -0
  318. package/artemis-main/artemis/sdk/constants.py +15 -0
  319. package/artemis-main/artemis/sdk/run_outcome.py +58 -0
  320. package/artemis-main/artemis/sdk/types/__init__.py +61 -0
  321. package/artemis-main/artemis/sdk/types/agent.py +89 -0
  322. package/artemis-main/artemis/sdk/utils.py +28 -0
  323. package/artemis-main/artemis/services/llm.py +1040 -0
  324. package/artemis-main/artemis/services/token_meter.py +267 -0
  325. package/artemis-main/artemis/telemetry/__init__.py +35 -0
  326. package/artemis-main/artemis/telemetry/models.py +64 -0
  327. package/artemis-main/artemis/telemetry/replay.py +34 -0
  328. package/artemis-main/artemis/telemetry/storage/json_storage.py +47 -0
  329. package/artemis-main/artemis/telemetry/tracer.py +85 -0
  330. package/artemis-main/artemis/toolchain/__init__.py +52 -0
  331. package/artemis-main/artemis/toolchain/descriptors.py +92 -0
  332. package/artemis-main/artemis/toolchain/resolver.py +185 -0
  333. package/artemis-main/artemis/tools/base.py +232 -0
  334. package/artemis-main/artemis/tools/command_tool.py +1240 -0
  335. package/artemis-main/artemis/tools/committee_prompts.json +5 -0
  336. package/artemis-main/artemis/tools/committee_tool.py +422 -0
  337. package/artemis-main/artemis/tools/diagnoser_submit_answer_tool.py +87 -0
  338. package/artemis-main/artemis/tools/diagnostic_tool.py +120 -0
  339. package/artemis-main/artemis/tools/explorer_tool.py +581 -0
  340. package/artemis-main/artemis/tools/history/__init__.py +398 -0
  341. package/artemis-main/artemis/tools/history/replay.py +120 -0
  342. package/artemis-main/artemis/tools/history/screenshot.py +139 -0
  343. package/artemis-main/artemis/tools/history/search.py +563 -0
  344. package/artemis-main/artemis/tools/image_processor_tool.py +124 -0
  345. package/artemis-main/artemis/tools/index.py +47 -0
  346. package/artemis-main/artemis/tools/log_tool.py +108 -0
  347. package/artemis-main/artemis/tools/mobile/launch_app.py +132 -0
  348. package/artemis-main/artemis/tools/mobile/log_utils.py +138 -0
  349. package/artemis-main/artemis/tools/mobile/ocr.py +202 -0
  350. package/artemis-main/artemis/tools/mobile/read_hierarchy.py +108 -0
  351. package/artemis-main/artemis/tools/mobile/read_logs.py +116 -0
  352. package/artemis-main/artemis/tools/mobile/search_logs.py +260 -0
  353. package/artemis-main/artemis/tools/object_detection_tool.py +244 -0
  354. package/artemis-main/artemis/tools/scratchpad.py +819 -0
  355. package/artemis-main/artemis/tools/system/adb_shell.py +31 -0
  356. package/artemis-main/artemis/tools/tool_wrapper.py +270 -0
  357. package/artemis-main/artemis/tools/video_tool.py +261 -0
  358. package/artemis-main/artemis/tools/wait_tool.py +97 -0
  359. package/artemis-main/artemis/utils/app_launch_utils.py +207 -0
  360. package/artemis-main/artemis/utils/coordinates.py +606 -0
  361. package/artemis-main/artemis/utils/credentials_validator.py +201 -0
  362. package/artemis-main/artemis/utils/cv_canvas.py +189 -0
  363. package/artemis-main/artemis/utils/cython_compat.py +45 -0
  364. package/artemis-main/artemis/utils/element_hit_test.py +80 -0
  365. package/artemis-main/artemis/utils/image_diff.py +335 -0
  366. package/artemis-main/artemis/utils/image_hash.py +80 -0
  367. package/artemis-main/artemis/utils/notes.py +470 -0
  368. package/artemis-main/artemis/utils/ocr_api.py +105 -0
  369. package/artemis-main/artemis/utils/ocr_xml_fusion.py +318 -0
  370. package/artemis-main/artemis/utils/plan_grammar.py +513 -0
  371. package/artemis-main/artemis/utils/python_executor.py +213 -0
  372. package/artemis-main/artemis/utils/startup_progress.py +60 -0
  373. package/artemis-main/artemis/utils/task_tree.py +1174 -0
  374. package/artemis-main/artemis/utils/text.py +37 -0
  375. package/artemis-main/artemis/utils/ui_filter.py +651 -0
  376. package/artemis-main/artemis/utils/video.py +685 -0
  377. package/artemis-main/artemis/utils/visualization.py +802 -0
  378. package/artemis-main/config/artemis.example.jsonc +12 -0
  379. package/artemis-main/mcp_server/README.md +111 -0
  380. package/artemis-main/mcp_server/__init__.py +21 -0
  381. package/artemis-main/mcp_server/__main__.py +20 -0
  382. package/artemis-main/mcp_server/background/__init__.py +15 -0
  383. package/artemis-main/mcp_server/background/task_runner.py +453 -0
  384. package/artemis-main/mcp_server/base.py +49 -0
  385. package/artemis-main/mcp_server/notifiers/__init__.py +74 -0
  386. package/artemis-main/mcp_server/notifiers/agentapi.py +289 -0
  387. package/artemis-main/mcp_server/notifiers/base.py +45 -0
  388. package/artemis-main/mcp_server/notifiers/composite.py +87 -0
  389. package/artemis-main/mcp_server/notifiers/desktop.py +108 -0
  390. package/artemis-main/mcp_server/notifiers/file.py +69 -0
  391. package/artemis-main/mcp_server/notifiers/script.py +90 -0
  392. package/artemis-main/mcp_server/notifiers/webhook.py +87 -0
  393. package/artemis-main/mcp_server/rules.md +68 -0
  394. package/artemis-main/mcp_server/server.py +106 -0
  395. package/artemis-main/mcp_server/tools/__init__.py +29 -0
  396. package/artemis-main/mcp_server/tools/device_state.py +107 -0
  397. package/artemis-main/mcp_server/tools/diagnose.py +1249 -0
  398. package/artemis-main/mcp_server/tools/inspect_trace.py +394 -0
  399. package/artemis-main/mcp_server/tools/task_manager.py +627 -0
  400. package/artemis-main/mcp_server/tools/task_runner.py +548 -0
  401. package/artemis-main/mcp_server/utils/__init__.py +15 -0
  402. package/artemis-main/mcp_server/utils/device_utils.py +180 -0
  403. package/artemis-main/mcp_server/utils/env_utils.py +65 -0
  404. package/artemis-main/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk +0 -0
  405. package/artemis-main/packages/artemis-accessibility-helper/app/build.gradle.kts +35 -0
  406. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml +49 -0
  407. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/A11yNode.java +274 -0
  408. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ArtemisAccessibilityService.java +227 -0
  409. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java +463 -0
  410. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/DisplayUtils.java +111 -0
  411. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java +232 -0
  412. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java +832 -0
  413. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenReceiver.java +36 -0
  414. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenStore.java +38 -0
  415. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/XmlUtils.java +102 -0
  416. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/values/strings.xml +4 -0
  417. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/xml/accessibility_service_config.xml +10 -0
  418. package/artemis-main/packages/artemis-accessibility-helper/build.gradle.kts +3 -0
  419. package/artemis-main/packages/artemis-accessibility-helper/build_apk.sh +215 -0
  420. package/artemis-main/packages/artemis-accessibility-helper/debug.keystore +0 -0
  421. package/artemis-main/packages/artemis-accessibility-helper/gradle.properties +2 -0
  422. package/artemis-main/packages/artemis-accessibility-helper/helper_manifest.json +7 -0
  423. package/artemis-main/packages/artemis-accessibility-helper/settings.gradle.kts +17 -0
  424. package/artemis-main/packages/artemis-client/DESIGN.md +89 -0
  425. package/artemis-main/packages/artemis-client/README.md +112 -0
  426. package/artemis-main/packages/artemis-client/examples/basic.py +30 -0
  427. package/artemis-main/packages/artemis-client/pyproject.toml +36 -0
  428. package/artemis-main/packages/artemis-client/src/artemis_client/__init__.py +45 -0
  429. package/artemis-main/packages/artemis-client/src/artemis_client/client.py +401 -0
  430. package/artemis-main/packages/artemis-client/src/artemis_client/errors.py +66 -0
  431. package/artemis-main/packages/artemis-client/src/artemis_client/models.py +212 -0
  432. package/artemis-main/packages/artemis-client/src/artemis_client/py.typed +1 -0
  433. package/artemis-main/packages/artemis-client/src/artemis_client/transport.py +134 -0
  434. package/artemis-main/packages/artemis-client/tests/test_client.py +289 -0
  435. package/artemis-main/packages/artemis-client/tests/test_transport.py +85 -0
  436. package/artemis-main/pyproject.toml +259 -0
  437. package/artemis-main/pyright-core.json +17 -0
  438. package/artemis-main/scripts/check_dependency_sources.py +68 -0
  439. package/artemis-main/scripts/install_deps.ps1 +520 -0
  440. package/artemis-main/scripts/install_deps.sh +533 -0
  441. package/artemis-main/scripts/quality_ratchet.py +97 -0
  442. package/artemis-main/scripts/real_device_video_acceptance.py +483 -0
  443. package/artemis-main/scripts/run_edge_case_part.py +327 -0
  444. package/artemis-main/scripts/run_ui.py +242 -0
  445. package/artemis-main/scripts/start.ps1 +500 -0
  446. package/artemis-main/scripts/test_all_edge_cases.py +269 -0
  447. package/artemis-main/scripts/verify_concurrency_and_devices.py +385 -0
  448. package/artemis-main/setup.py +164 -0
  449. package/artemis-main/start.bat +35 -0
  450. package/artemis-main/start.sh +423 -0
  451. package/artemis-main/third_party/mobile_use/LICENSE +190 -0
  452. package/artemis-main/third_party/mobile_use/METADATA +27 -0
  453. package/artemis-main/third_party/mobile_use/NOTICE +12 -0
  454. package/artemis-main/third_party/mobile_use/__init__.py +19 -0
  455. package/artemis-main/third_party/mobile_use/agents/__init__.py +13 -0
  456. package/artemis-main/third_party/mobile_use/agents/hopper/__init__.py +13 -0
  457. package/artemis-main/third_party/mobile_use/agents/hopper/hopper.md +22 -0
  458. package/artemis-main/third_party/mobile_use/agents/hopper/hopper.py +80 -0
  459. package/artemis-main/third_party/mobile_use/clients/__init__.py +13 -0
  460. package/artemis-main/third_party/mobile_use/clients/ui_automator_client.py +482 -0
  461. package/artemis-main/third_party/mobile_use/config/__init__.py +13 -0
  462. package/artemis-main/third_party/mobile_use/config/llm.py +183 -0
  463. package/artemis-main/third_party/mobile_use/config/output.py +184 -0
  464. package/artemis-main/third_party/mobile_use/context.py +119 -0
  465. package/artemis-main/third_party/mobile_use/controllers/__init__.py +13 -0
  466. package/artemis-main/third_party/mobile_use/controllers/platform_specific_commands_controller.py +151 -0
  467. package/artemis-main/third_party/mobile_use/controllers/types.py +92 -0
  468. package/artemis-main/third_party/mobile_use/controllers/unified_controller.py +183 -0
  469. package/artemis-main/third_party/mobile_use/graph.py +54 -0
  470. package/artemis-main/third_party/mobile_use/llm_service.py +193 -0
  471. package/artemis-main/third_party/mobile_use/main.py +130 -0
  472. package/artemis-main/third_party/mobile_use/sdk/__init__.py +13 -0
  473. package/artemis-main/third_party/mobile_use/sdk/agent.py +1060 -0
  474. package/artemis-main/third_party/mobile_use/sdk/builders/__init__.py +13 -0
  475. package/artemis-main/third_party/mobile_use/sdk/builders/agent_config_builder.py +225 -0
  476. package/artemis-main/third_party/mobile_use/sdk/builders/index.py +33 -0
  477. package/artemis-main/third_party/mobile_use/sdk/builders/task_request_builder.py +243 -0
  478. package/artemis-main/third_party/mobile_use/sdk/types/__init__.py +13 -0
  479. package/artemis-main/third_party/mobile_use/sdk/types/agent.py +98 -0
  480. package/artemis-main/third_party/mobile_use/sdk/types/exceptions.py +109 -0
  481. package/artemis-main/third_party/mobile_use/sdk/types/task.py +234 -0
  482. package/artemis-main/third_party/mobile_use/testing.py +59 -0
  483. package/artemis-main/third_party/mobile_use/tools/__init__.py +13 -0
  484. package/artemis-main/third_party/mobile_use/tools/index.py +44 -0
  485. package/artemis-main/third_party/mobile_use/tools/mobile/__init__.py +13 -0
  486. package/artemis-main/third_party/mobile_use/tools/mobile/launch_app.py +89 -0
  487. package/artemis-main/third_party/mobile_use/tools/tool_wrapper.py +52 -0
  488. package/artemis-main/third_party/mobile_use/utils/__init__.py +13 -0
  489. package/artemis-main/third_party/mobile_use/utils/app_launch_utils.py +217 -0
  490. package/artemis-main/third_party/mobile_use/utils/cli_helpers.py +60 -0
  491. package/artemis-main/third_party/mobile_use/utils/decorators.py +158 -0
  492. package/artemis-main/third_party/mobile_use/utils/file.py +31 -0
  493. package/artemis-main/third_party/mobile_use/utils/logger.py +216 -0
  494. package/artemis-main/third_party/mobile_use/utils/media.py +213 -0
  495. package/artemis-main/third_party/mobile_use/utils/shell_utils.py +39 -0
  496. package/artemis-main/third_party/mobile_use/utils/video.py +398 -0
  497. package/artemis-main/uv.lock +4908 -0
  498. package/bin/android-midscene-automation.js +1 -5
  499. package/bin/artemis-config.mjs +15 -0
  500. package/bin/artemis-embedded.py +56 -0
  501. package/bin/start-artemis.mjs +30 -0
  502. package/bin/start-dev.mjs +112 -0
  503. package/bin/test_artemis_embedded.py +44 -0
  504. package/package.json +4 -2
  505. package/server/artemis.ts +34 -0
  506. package/server/http-api.ts +12 -0
  507. package/src/App.vue +10 -5
  508. package/src/api.ts +5 -0
  509. package/src/appium-recorder/AppiumPage.vue +1 -1
  510. package/src/appium-recorder/components/ComponentInspector.vue +1 -1
  511. package/src/appium-recorder/components/FlowNodeSearch.vue +3 -3
  512. package/src/appium-recorder/components/ImageCheckDialog.vue +3 -3
  513. package/src/appium-recorder/components/RecorderWorkspace.vue +4 -4
  514. package/src/appium-recorder/flow-appearance.ts +4 -4
  515. package/src/components/AppSidebar.vue +8 -7
  516. package/src/components/MidsceneMaintenanceNotice.vue +17 -0
  517. package/src/components/analytics/AnalysisCharts.vue +4 -4
  518. package/src/components/analytics/ReportAnalysis.vue +1 -1
  519. package/src/components/generator/TestCaseFileUpload.vue +1 -1
  520. package/src/components/help/HelpCenter.vue +56 -37
  521. package/src/pages/AiGeneratorPage.vue +1 -1
  522. package/src/pages/ArtemisPage.vue +72 -0
  523. package/src/pages/ConfigPage.vue +18 -19
  524. package/src/style.css +340 -121
  525. package/src/types.ts +1 -1
@@ -0,0 +1,1030 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Lifecycle of the Artemis Accessibility Helper APK on each Android device.
16
+
17
+ The helper (``packages/artemis-accessibility-helper``) is an accessibility
18
+ service that serves the UI hierarchy over a loopback HTTP port on the phone.
19
+ This module owns everything *around* that HTTP API, split by lifetime:
20
+
21
+ * **Provision** (persistent on the device, survives reboots): the APK is
22
+ installed at the bundled version and the service is enabled in secure
23
+ settings. Only callers that hold the device (a task inside its
24
+ ``DeviceExecutionLock`` boundary, ``artemis helper install``,
25
+ ``mobile_diagnose(attempt_fix=True)``) provision; read-only observers never
26
+ install anything. A cross-process file mutex serialises concurrent installs.
27
+ * **Attach** (one process, one device, one session): an ``adb forward`` from a
28
+ host port that adb allocates (``tcp:0``) to the fixed device port. Existing
29
+ forwards for the same serial are reused so two Artemis processes share one
30
+ tunnel; the local port is the identity of the tunnel, never a fixed number,
31
+ which is what keeps several devices on one host apart.
32
+ * **Serve** (per request): handled by :class:`AccessibilityClient`; on a
33
+ transport error it calls :meth:`AccessibilityHelperManager.reattach` once.
34
+ * **Detach / evict**: forwards this process created are removed on detach.
35
+ A physical unplug needs no host work at all: adb drops the forward with the
36
+ transport and the on-device service keeps running. The ``transport_id`` that
37
+ ``adb devices -l`` reports changes on every replug, so a session records it
38
+ and :meth:`attach` treats a changed id as "stale, rebuild the tunnel".
39
+ * **Token**: the loopback port is reachable by every app on the phone, so the
40
+ helper serves nothing (except ``/ping``) without the session token this host
41
+ pushed. The token is one random secret per host, kept in a file under the
42
+ Artemis temp directory so every Artemis process on the machine presents the
43
+ same one, and delivered with an ``am broadcast`` that only the adb shell user
44
+ can send (the receiver is guarded by ``WRITE_SECURE_SETTINGS``). It is pushed
45
+ on every attach and again whenever the helper answers 401 (a re-bound service
46
+ starts without one).
47
+
48
+ Locking is per device: provisioning one phone (up to a minute when the install
49
+ is slow) never blocks attaching to another.
50
+
51
+ **UiAutomation suppresses the helper.** Android unbinds every accessibility
52
+ service while a ``UiAutomation`` connection created without
53
+ ``FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES`` is alive. uiautomator2's device
54
+ server (``app_process ... com.wetest.uia2.Main``) and Appium's UiAutomator2
55
+ server both do that by default, so the helper is silent for as long as one of
56
+ them runs. When the helper does not answer, :meth:`attach` looks for such a
57
+ holder: uiautomator2's server (Artemis's own fallback, or one left behind by a
58
+ previous run) is stopped so the system re-binds the helper within a second;
59
+ Appium's is never touched, the error names it instead.
60
+ """
61
+
62
+ from __future__ import annotations
63
+
64
+ from collections.abc import Callable
65
+ from contextlib import contextmanager
66
+ from dataclasses import asdict, dataclass, field
67
+ import hashlib
68
+ import json
69
+ import os
70
+ from pathlib import Path
71
+ import re
72
+ import secrets
73
+ import subprocess
74
+ import threading
75
+ import time
76
+ from typing import Any
77
+ import urllib.error
78
+ import urllib.request
79
+
80
+ from artemis.config.constants import ENV_ARTEMIS_HELPER_AUTO_INSTALL
81
+ from artemis.config.paths import get_temp_dir
82
+ from artemis.runtime.adb_endpoint import adb_command
83
+ from third_party.mobile_use.utils.logger import get_logger
84
+
85
+ logger = get_logger(__name__)
86
+
87
+ PACKAGE_NAME = "com.artemis.helper"
88
+ SERVICE_NAME = f"{PACKAGE_NAME}/.ArtemisAccessibilityService"
89
+ TOKEN_RECEIVER = f"{PACKAGE_NAME}/.TokenReceiver"
90
+ TOKEN_ACTION = f"{PACKAGE_NAME}.SET_TOKEN"
91
+ #: Loopback port the service binds on the device. Never used as a host port.
92
+ DEVICE_PORT = 18888
93
+ #: Oldest helper HTTP contract this host can talk to (see PROTOCOL_VERSION in the APK).
94
+ MIN_PROTOCOL_VERSION = 2
95
+ TOKEN_FILE_NAME = "session.token"
96
+
97
+ HELPER_PACKAGE_DIR = (
98
+ Path(__file__).resolve().parent.parent.parent / "packages" / "artemis-accessibility-helper"
99
+ )
100
+ BUNDLED_APK_PATH = HELPER_PACKAGE_DIR / "ArtemisAccessibilityHelper.apk"
101
+ BUNDLED_MANIFEST_PATH = HELPER_PACKAGE_DIR / "helper_manifest.json"
102
+
103
+ _PING_ATTEMPTS_AFTER_PROVISION = 12
104
+ _PING_ATTEMPTS_LAZY = 3
105
+ _PING_INTERVAL_SECONDS = 0.5
106
+ _UIAUTOMATION_RELEASE_SETTLE_SECONDS = 1.0
107
+
108
+ #: Process command-line markers of UiAutomation holders. The first group is
109
+ #: uiautomator2 (openatx), which Artemis itself uses as the fallback backend and
110
+ #: may stop; the second is Appium's server, which belongs to someone else.
111
+ _UIAUTOMATOR2_MARKERS = ("com.wetest.uia2.Main", "com.github.uiautomator")
112
+ _APPIUM_MARKERS = ("io.appium.uiautomator2",)
113
+ _PROVISION_MUTEX_TIMEOUT_SECONDS = 90.0
114
+ _PROVISION_MUTEX_STALE_SECONDS = 180.0
115
+ _ENABLE_ATTEMPTS = 5
116
+ _ENABLE_SETTLE_SECONDS = 0.4
117
+ _REVIVE_SETTLE_SECONDS = 0.3
118
+
119
+ AdbRunner = Callable[[list[str]], subprocess.CompletedProcess]
120
+ Pinger = Callable[[int], dict[str, Any] | None]
121
+ #: Progress callback: ``(event, details)`` with events ``installing`` / ``upgrading`` /
122
+ #: ``enabling`` fired *before* the slow step so a UI can show what is happening.
123
+ ProvisionEvent = Callable[[str, dict[str, Any]], None]
124
+
125
+ #: Where a person enables the service by hand when secure settings are rejected
126
+ #: (some OEM ROMs and Android 13+ restricted settings for sideloaded apps).
127
+ MANUAL_ENABLE_PATH = (
128
+ "Settings > Accessibility > Installed apps (or Downloaded apps) > "
129
+ "Artemis Accessibility Helper > turn it on"
130
+ )
131
+ ACCESSIBILITY_SETTINGS_INTENT = "android.settings.ACCESSIBILITY_SETTINGS"
132
+
133
+
134
+ class HelperUnavailable(RuntimeError):
135
+ """The helper could not be reached on a device (not installed, disabled, or dead)."""
136
+
137
+
138
+ def auto_install_allowed() -> bool:
139
+ """Whether a task may install / upgrade the helper on a device it holds.
140
+
141
+ ``ARTEMIS_HELPER_AUTO_INSTALL`` (environment, then settings); default true.
142
+ """
143
+ raw = os.environ.get(ENV_ARTEMIS_HELPER_AUTO_INSTALL)
144
+ if raw is None:
145
+ try:
146
+ from artemis.config import settings
147
+
148
+ value = getattr(settings, "ARTEMIS_HELPER_AUTO_INSTALL", True)
149
+ return (
150
+ bool(value)
151
+ if not isinstance(value, str)
152
+ else value.strip().lower()
153
+ not in (
154
+ "0",
155
+ "false",
156
+ "no",
157
+ "off",
158
+ )
159
+ )
160
+ except (ImportError, ValueError):
161
+ return True
162
+ return raw.strip().lower() not in ("0", "false", "no", "off")
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class BundledHelper:
167
+ """The APK shipped in the repository, described by ``helper_manifest.json``."""
168
+
169
+ apk_path: Path
170
+ version_code: int
171
+ version_name: str
172
+ sha256: str
173
+
174
+ def to_dict(self) -> dict[str, Any]:
175
+ return {
176
+ "apk_path": str(self.apk_path),
177
+ "version_code": self.version_code,
178
+ "version_name": self.version_name,
179
+ "sha256": self.sha256,
180
+ }
181
+
182
+
183
+ def load_bundled_helper(
184
+ apk_path: Path = BUNDLED_APK_PATH, manifest_path: Path = BUNDLED_MANIFEST_PATH
185
+ ) -> BundledHelper | None:
186
+ """Read the bundled APK's manifest; ``None`` when the artifact is not shipped."""
187
+ if not apk_path.is_file() or not manifest_path.is_file():
188
+ return None
189
+ try:
190
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
191
+ return BundledHelper(
192
+ apk_path=apk_path,
193
+ version_code=int(data["version_code"]),
194
+ version_name=str(data.get("version_name", "")),
195
+ sha256=str(data.get("sha256", "")),
196
+ )
197
+ except (OSError, ValueError, KeyError, TypeError) as exc:
198
+ logger.warning(f"Bundled accessibility helper manifest is unreadable: {exc}")
199
+ return None
200
+
201
+
202
+ @dataclass
203
+ class HelperSession:
204
+ """One process's live tunnel to the helper on one device."""
205
+
206
+ serial: str
207
+ local_port: int
208
+ transport_id: str | None
209
+ version_code: int | None
210
+ version_name: str
211
+ owns_forward: bool
212
+ attached_at: float = field(default_factory=time.time)
213
+ #: Session token the helper expects in ``X-Artemis-Token``; None for pre-token helpers.
214
+ token: str | None = field(default=None, repr=False)
215
+ protocol_version: int = 1
216
+
217
+ @property
218
+ def base_url(self) -> str:
219
+ return f"http://127.0.0.1:{self.local_port}"
220
+
221
+ def to_dict(self) -> dict[str, Any]:
222
+ data = asdict(self)
223
+ data.pop("token", None)
224
+ data["base_url"] = self.base_url
225
+ return data
226
+
227
+
228
+ @dataclass
229
+ class ProvisionResult:
230
+ ok: bool
231
+ action: str # "up_to_date" | "installed" | "upgraded" | "skipped" | "failed"
232
+ installed_version: int | None
233
+ bundled_version: int | None
234
+ enabled: bool
235
+ error: str | None = None
236
+
237
+ def to_dict(self) -> dict[str, Any]:
238
+ return asdict(self)
239
+
240
+
241
+ def _default_run_adb(args: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess:
242
+ return subprocess.run(
243
+ adb_command(args),
244
+ stdin=subprocess.DEVNULL,
245
+ capture_output=True,
246
+ text=True,
247
+ timeout=timeout,
248
+ check=False,
249
+ )
250
+
251
+
252
+ def _default_ping(local_port: int, timeout: float = 2.0) -> dict[str, Any] | None:
253
+ try:
254
+ with urllib.request.urlopen(f"http://127.0.0.1:{local_port}/ping", timeout=timeout) as resp:
255
+ data = json.loads(resp.read().decode("utf-8"))
256
+ except (urllib.error.URLError, OSError, ValueError, TimeoutError):
257
+ return None
258
+ if not isinstance(data, dict) or not data.get("success"):
259
+ return None
260
+ return data
261
+
262
+
263
+ class AccessibilityHelperManager:
264
+ """Process-wide registry of helper sessions, keyed by device serial."""
265
+
266
+ def __init__(
267
+ self,
268
+ run_adb: AdbRunner | None = None,
269
+ ping: Pinger | None = None,
270
+ bundled: BundledHelper | None | Callable[[], BundledHelper | None] = load_bundled_helper,
271
+ sleep: Callable[[float], None] = time.sleep,
272
+ token_path: Path | None = None,
273
+ auto_install: Callable[[], bool] = auto_install_allowed,
274
+ ) -> None:
275
+ self._run_adb = run_adb or _default_run_adb
276
+ self._ping = ping or _default_ping
277
+ self._bundled_source = bundled
278
+ self._sleep = sleep
279
+ self._token_path = token_path
280
+ self._auto_install = auto_install
281
+ self._token: str | None = None
282
+ self._sessions: dict[str, HelperSession] = {}
283
+ #: Guards the session registry and the per-device lock table only.
284
+ self._registry_lock = threading.Lock()
285
+ self._device_locks: dict[str, threading.RLock] = {}
286
+
287
+ def _device_lock(self, serial: str) -> threading.RLock:
288
+ """One re-entrant lock per device, so devices provision and attach in parallel."""
289
+ with self._registry_lock:
290
+ lock = self._device_locks.get(serial)
291
+ if lock is None:
292
+ lock = self._device_locks[serial] = threading.RLock()
293
+ return lock
294
+
295
+ # ------------------------------------------------------------------ #
296
+ # Bundled artifact
297
+ # ------------------------------------------------------------------ #
298
+
299
+ @property
300
+ def bundled(self) -> BundledHelper | None:
301
+ source = self._bundled_source
302
+ return source() if callable(source) else source
303
+
304
+ # ------------------------------------------------------------------ #
305
+ # Session token
306
+ # ------------------------------------------------------------------ #
307
+
308
+ def host_token(self) -> str:
309
+ """The per-host secret every Artemis process presents to the helper.
310
+
311
+ Created on first use (48 hex chars) in the Artemis temp directory with
312
+ owner-only permissions; shared by the MCP daemon, the CLI and the SDK.
313
+ """
314
+ if self._token:
315
+ return self._token
316
+ path = self._token_path or (get_temp_dir("helper-token") / TOKEN_FILE_NAME)
317
+ with self._provision_mutex(f"host-token:{path.resolve()}"):
318
+ return self._load_or_create_token(path)
319
+
320
+ def _load_or_create_token(self, path: Path) -> str:
321
+ try:
322
+ text = path.read_text(encoding="utf-8").strip()
323
+ if re.fullmatch(r"[0-9a-f]{32,128}", text):
324
+ self._token = text
325
+ return text
326
+ except OSError:
327
+ pass
328
+ token = secrets.token_hex(24)
329
+ try:
330
+ path.parent.mkdir(parents=True, exist_ok=True)
331
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
332
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
333
+ fh.write(token)
334
+ except OSError as exc:
335
+ # No persistent file (read-only temp?): a process-local token still
336
+ # protects the helper; other processes will simply push their own.
337
+ logger.warning(f"Could not persist the helper session token at {path}: {exc}")
338
+ self._token = token
339
+ return token
340
+
341
+ def push_token(self, serial: str) -> bool:
342
+ """Deliver the host token to the helper on ``serial`` (idempotent).
343
+
344
+ The broadcast is explicit (component named) so it reaches the manifest
345
+ receiver on every Android version, and it is sent by the adb shell user,
346
+ the only sender the receiver's permission guard admits.
347
+ """
348
+ result = self._adb(
349
+ serial,
350
+ "shell",
351
+ "am",
352
+ "broadcast",
353
+ "-n",
354
+ TOKEN_RECEIVER,
355
+ "-a",
356
+ TOKEN_ACTION,
357
+ "--es",
358
+ "token",
359
+ self.host_token(),
360
+ )
361
+ output = f"{result.stdout or ''}\n{result.stderr or ''}"
362
+ ok = result.returncode == 0 and "Broadcast completed" in output
363
+ if not ok:
364
+ logger.warning(
365
+ f"Pushing the session token to the accessibility helper on {serial} failed: "
366
+ f"adb exited with code {result.returncode}."
367
+ )
368
+ return ok
369
+
370
+ # ------------------------------------------------------------------ #
371
+ # Device queries (no side effects)
372
+ # ------------------------------------------------------------------ #
373
+
374
+ def _adb(self, serial: str, *args: str) -> subprocess.CompletedProcess:
375
+ return self._run_adb(["-s", serial, *args])
376
+
377
+ def transport_id(self, serial: str) -> str | None:
378
+ """The adb transport id of ``serial``; changes every time the device reconnects."""
379
+ try:
380
+ result = self._run_adb(["devices", "-l"])
381
+ except (OSError, subprocess.SubprocessError) as exc:
382
+ logger.debug(f"adb devices -l failed while reading transport id: {exc}")
383
+ return None
384
+ for line in (result.stdout or "").splitlines():
385
+ parts = line.split()
386
+ if len(parts) < 2 or parts[0] != serial:
387
+ continue
388
+ for token in parts[2:]:
389
+ if token.startswith("transport_id:"):
390
+ return token.split(":", 1)[1]
391
+ return None
392
+ return None
393
+
394
+ def installed_version(self, serial: str) -> int | None:
395
+ """versionCode of the helper on the device, or ``None`` when it is not installed."""
396
+ result = self._adb(serial, "shell", "dumpsys", "package", PACKAGE_NAME)
397
+ output = result.stdout or ""
398
+ if result.returncode != 0 or "Unable to find package" in output:
399
+ return None
400
+ match = re.search(r"versionCode=(\d+)", output)
401
+ if match is None:
402
+ # dumpsys answered but without a version block: the package is absent.
403
+ return None
404
+ return int(match.group(1))
405
+
406
+ def is_service_enabled(self, serial: str) -> bool:
407
+ result = self._adb(
408
+ serial, "shell", "settings", "get", "secure", "enabled_accessibility_services"
409
+ )
410
+ enabled = (result.stdout or "").strip()
411
+ return SERVICE_NAME in enabled.split(":") if enabled and enabled != "null" else False
412
+
413
+ def existing_forward(self, serial: str) -> int | None:
414
+ """Host port of an adb forward that already targets the helper on ``serial``."""
415
+ result = self._run_adb(["forward", "--list"])
416
+ for line in (result.stdout or "").splitlines():
417
+ parts = line.split()
418
+ if len(parts) != 3 or parts[0] != serial:
419
+ continue
420
+ local, remote = parts[1], parts[2]
421
+ if remote == f"tcp:{DEVICE_PORT}" and local.startswith("tcp:"):
422
+ try:
423
+ return int(local[4:])
424
+ except ValueError:
425
+ continue
426
+ return None
427
+
428
+ def ping(self, local_port: int) -> dict[str, Any] | None:
429
+ return self._ping(local_port)
430
+
431
+ def uiautomation_holders(self, serial: str) -> dict[str, list[int]]:
432
+ """Processes on ``serial`` holding a UiAutomation connection that mutes the helper.
433
+
434
+ Returns ``{"uiautomator2": [pids], "appium": [pids]}``.
435
+ """
436
+ holders: dict[str, list[int]] = {"uiautomator2": [], "appium": []}
437
+ try:
438
+ result = self._adb(serial, "shell", "ps", "-A", "-o", "PID,ARGS")
439
+ except (OSError, subprocess.SubprocessError) as exc:
440
+ logger.debug(f"ps on {serial} failed while looking for UiAutomation holders: {exc}")
441
+ return holders
442
+ for line in (result.stdout or "").splitlines():
443
+ parts = line.split(None, 1)
444
+ if len(parts) != 2 or not parts[0].isdigit():
445
+ continue
446
+ pid, args = int(parts[0]), parts[1]
447
+ if args.lstrip().startswith("sh "):
448
+ continue # the `sh -c` wrapper around app_process, not the holder itself
449
+ if any(marker in args for marker in _UIAUTOMATOR2_MARKERS):
450
+ holders["uiautomator2"].append(pid)
451
+ elif any(marker in args for marker in _APPIUM_MARKERS):
452
+ holders["appium"].append(pid)
453
+ return holders
454
+
455
+ def stop_uiautomator2_server(self, serial: str, pids: list[int] | None = None) -> bool:
456
+ """Kill uiautomator2's device server so Android re-binds the helper.
457
+
458
+ Only ever aimed at openatx's server (``u2.jar`` / ``com.github.uiautomator``),
459
+ which Artemis owns as its fallback backend. Returns whether anything was stopped.
460
+ """
461
+ if pids is None:
462
+ pids = self.uiautomation_holders(serial)["uiautomator2"]
463
+ stopped = False
464
+ for pid in pids:
465
+ result = self._adb(serial, "shell", "kill", str(pid))
466
+ stopped = stopped or result.returncode == 0
467
+ result = self._adb(serial, "shell", "am", "force-stop", "com.github.uiautomator")
468
+ stopped = stopped or (result.returncode == 0 and bool(pids))
469
+ if stopped:
470
+ logger.info(
471
+ f"Stopped uiautomator2's device server on {serial} so the accessibility helper "
472
+ "can bind again (UiAutomation suppresses other accessibility services)."
473
+ )
474
+ self._sleep(_UIAUTOMATION_RELEASE_SETTLE_SECONDS)
475
+ return stopped
476
+
477
+ def session(self, serial: str) -> HelperSession | None:
478
+ with self._registry_lock:
479
+ return self._sessions.get(serial)
480
+
481
+ def status(self, serial: str) -> dict[str, Any]:
482
+ """Everything a diagnostic wants to know, without changing the device.
483
+
484
+ ``reachable`` answers "is the service alive on the phone", not "does
485
+ this process have a tunnel": when no forward exists yet a temporary one
486
+ is created for the probe and removed again. ``tunnel`` says which it was.
487
+ """
488
+ bundled = self.bundled
489
+ installed = self.installed_version(serial)
490
+ enabled = self.is_service_enabled(serial) if installed is not None else False
491
+ session = self.session(serial)
492
+ tunnel: str | None
493
+ forward_port: int | None
494
+ if session is not None:
495
+ forward_port, tunnel = session.local_port, "session"
496
+ else:
497
+ forward_port = self.existing_forward(serial)
498
+ tunnel = "shared" if forward_port is not None else None
499
+ ping = None
500
+ if forward_port is not None:
501
+ ping = self.ping(forward_port)
502
+ elif installed is not None and enabled:
503
+ ping = self._probe_with_temporary_forward(serial)
504
+ tunnel = "probe"
505
+ return {
506
+ "package": PACKAGE_NAME,
507
+ "installed": installed is not None,
508
+ "installed_version": installed,
509
+ "bundled_version": bundled.version_code if bundled else None,
510
+ "bundled_apk_present": bundled is not None,
511
+ "outdated": bool(
512
+ bundled and installed is not None and installed < bundled.version_code
513
+ ),
514
+ "newer_than_bundled": bool(
515
+ bundled and installed is not None and installed > bundled.version_code
516
+ ),
517
+ "enabled": enabled,
518
+ "forward_port": forward_port,
519
+ "tunnel": tunnel,
520
+ "reachable": ping is not None,
521
+ "reported_version": ping.get("version_code") if ping else None,
522
+ "protocol_version": ping.get("protocol_version", 1) if ping else None,
523
+ "protocol_supported": bool(
524
+ ping and int(ping.get("protocol_version", 1)) >= MIN_PROTOCOL_VERSION
525
+ ),
526
+ "token_set": bool(ping.get("token_set")) if ping else None,
527
+ "auto_install": self._auto_install(),
528
+ "transport_id": self.transport_id(serial),
529
+ "session": session.to_dict() if session else None,
530
+ }
531
+
532
+ def _probe_with_temporary_forward(self, serial: str) -> dict[str, Any] | None:
533
+ try:
534
+ port = self._create_forward(serial)
535
+ except HelperUnavailable:
536
+ return None
537
+ try:
538
+ return self.ping(port)
539
+ finally:
540
+ self._remove_forward(serial, port)
541
+
542
+ # ------------------------------------------------------------------ #
543
+ # Provision: install / upgrade / enable (persistent device state)
544
+ # ------------------------------------------------------------------ #
545
+
546
+ @contextmanager
547
+ def _provision_mutex(self, serial: str):
548
+ """Serialise installs across Artemis processes sharing one device."""
549
+ root = get_temp_dir("helper-provision")
550
+ root.mkdir(parents=True, exist_ok=True)
551
+ digest = hashlib.sha256(serial.encode("utf-8")).hexdigest()[:16]
552
+ path = root / f"{digest}.mutex"
553
+ started = time.monotonic()
554
+ while True:
555
+ try:
556
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
557
+ except (FileExistsError, PermissionError) as exc:
558
+ # Windows may report access denied while a lock file is being deleted.
559
+ if isinstance(exc, PermissionError) and os.name != "nt":
560
+ raise
561
+ if time.monotonic() - started > _PROVISION_MUTEX_TIMEOUT_SECONDS:
562
+ raise TimeoutError(
563
+ f"Timed out waiting for the accessibility helper lock on {serial}."
564
+ ) from exc
565
+ try:
566
+ age = time.time() - path.stat().st_mtime
567
+ except OSError:
568
+ self._sleep(0.1)
569
+ continue
570
+ if age > _PROVISION_MUTEX_STALE_SECONDS:
571
+ path.unlink(missing_ok=True)
572
+ continue
573
+ if time.monotonic() - started > _PROVISION_MUTEX_TIMEOUT_SECONDS:
574
+ raise TimeoutError(
575
+ f"Timed out waiting for another process to finish installing the "
576
+ f"accessibility helper on {serial}."
577
+ )
578
+ self._sleep(0.1)
579
+ else:
580
+ try:
581
+ os.write(fd, str(os.getpid()).encode("ascii"))
582
+ finally:
583
+ os.close(fd)
584
+ break
585
+ try:
586
+ yield
587
+ finally:
588
+ path.unlink(missing_ok=True)
589
+
590
+ def _enable_service(self, serial: str) -> bool:
591
+ """Add the service to secure settings and confirm the write stuck.
592
+
593
+ Right after ``pm install`` (and right after boot) AccessibilityManager
594
+ may still not resolve the component and prunes it from the setting
595
+ again, so a single write followed by an immediate read can report
596
+ success for a value that is gone a moment later. Re-read after a short
597
+ delay and retry a few times before giving up.
598
+ """
599
+ for attempt in range(_ENABLE_ATTEMPTS):
600
+ result = self._adb(
601
+ serial, "shell", "settings", "get", "secure", "enabled_accessibility_services"
602
+ )
603
+ current = (result.stdout or "").strip()
604
+ services = [s for s in current.split(":") if s and s != "null"] if current else []
605
+ if SERVICE_NAME in services:
606
+ # Already enabled (the common, up-to-date case): no settle wait.
607
+ self._adb(
608
+ serial, "shell", "settings", "put", "secure", "accessibility_enabled", "1"
609
+ )
610
+ return True
611
+ services.append(SERVICE_NAME)
612
+ self._adb(
613
+ serial,
614
+ "shell",
615
+ "settings",
616
+ "put",
617
+ "secure",
618
+ "enabled_accessibility_services",
619
+ ":".join(services),
620
+ )
621
+ self._adb(serial, "shell", "settings", "put", "secure", "accessibility_enabled", "1")
622
+ self._sleep(_ENABLE_SETTLE_SECONDS)
623
+ if self.is_service_enabled(serial):
624
+ return True
625
+ logger.debug(
626
+ f"Accessibility service setting on {serial} did not stick "
627
+ f"(attempt {attempt + 1}/{_ENABLE_ATTEMPTS}); retrying."
628
+ )
629
+ return False
630
+
631
+ def _revive_service(self, serial: str) -> None:
632
+ """Make AccessibilityManager rebind a service that stopped answering.
633
+
634
+ A force-stopped (or ROM-killed) accessibility service stays dead even
635
+ though it is still listed as enabled; removing and re-adding it in the
636
+ secure setting is what makes the system bind it again. Persistent state
637
+ ends up exactly as before, so this is a repair, not provisioning.
638
+ """
639
+ result = self._adb(
640
+ serial, "shell", "settings", "get", "secure", "enabled_accessibility_services"
641
+ )
642
+ current = (result.stdout or "").strip()
643
+ others = [s for s in current.split(":") if s and s != "null" and s != SERVICE_NAME]
644
+ self._adb(
645
+ serial,
646
+ "shell",
647
+ "settings",
648
+ "put",
649
+ "secure",
650
+ "enabled_accessibility_services",
651
+ ":".join(others) if others else "null",
652
+ )
653
+ self._sleep(_REVIVE_SETTLE_SECONDS)
654
+ self._adb(
655
+ serial,
656
+ "shell",
657
+ "settings",
658
+ "put",
659
+ "secure",
660
+ "enabled_accessibility_services",
661
+ ":".join([*others, SERVICE_NAME]),
662
+ )
663
+ self._adb(serial, "shell", "settings", "put", "secure", "accessibility_enabled", "1")
664
+ logger.info(f"Re-bound the accessibility helper service on {serial}.")
665
+
666
+ def provision(
667
+ self,
668
+ serial: str,
669
+ *,
670
+ force: bool = False,
671
+ on_event: ProvisionEvent | None = None,
672
+ install: bool = True,
673
+ ) -> ProvisionResult:
674
+ """Install or upgrade the helper to the bundled version and enable the service.
675
+
676
+ Idempotent: an up-to-date, enabled helper costs two ``adb shell`` calls.
677
+ A device running a *newer* helper than the bundle is left alone.
678
+ ``on_event`` is told ``installing`` / ``upgrading`` before the install.
679
+ ``install=False`` (auto-install disabled) never runs ``adb install``: a
680
+ missing helper is reported as a failure that names the manual command,
681
+ an outdated one is used as is.
682
+ """
683
+ bundled = self.bundled
684
+
685
+ def emit(event: str, **details: Any) -> None:
686
+ if on_event is None:
687
+ return
688
+ try:
689
+ on_event(event, {"serial": serial, **details})
690
+ except Exception as exc: # pylint: disable=broad-exception-caught
691
+ logger.debug(f"Provision event callback failed: {exc}")
692
+
693
+ with self._provision_mutex(serial):
694
+ installed = self.installed_version(serial)
695
+ action = "up_to_date"
696
+ needs_install = (
697
+ force
698
+ or installed is None
699
+ or (bundled is not None and installed < bundled.version_code)
700
+ )
701
+ if needs_install and not install:
702
+ if installed is None:
703
+ return ProvisionResult(
704
+ ok=False,
705
+ action="failed",
706
+ installed_version=None,
707
+ bundled_version=bundled.version_code if bundled else None,
708
+ enabled=False,
709
+ error=(
710
+ "The accessibility helper is not installed on this device and "
711
+ f"{ENV_ARTEMIS_HELPER_AUTO_INSTALL}=false forbids installing it "
712
+ f"from a task. Install it once with: artemis helper install "
713
+ f"--serial {serial}"
714
+ ),
715
+ )
716
+ logger.info(
717
+ f"Accessibility helper on {serial} is v{installed} (bundled "
718
+ f"v{bundled.version_code if bundled else '?'}); not upgrading because "
719
+ f"{ENV_ARTEMIS_HELPER_AUTO_INSTALL}=false."
720
+ )
721
+ action = "skipped"
722
+ needs_install = False
723
+ if needs_install:
724
+ if bundled is None:
725
+ if installed is None:
726
+ return ProvisionResult(
727
+ ok=False,
728
+ action="failed",
729
+ installed_version=None,
730
+ bundled_version=None,
731
+ enabled=False,
732
+ error=(
733
+ "The accessibility helper is not installed and no bundled APK "
734
+ f"was found at {BUNDLED_APK_PATH}."
735
+ ),
736
+ )
737
+ action = "skipped"
738
+ else:
739
+ # This is the one moment Artemis changes what is installed on the
740
+ # phone; say so in a way that survives in every log.
741
+ logger.warning(
742
+ f"Installing the Artemis accessibility helper v{bundled.version_name} "
743
+ f"(code {bundled.version_code}) on {serial}. It is an accessibility "
744
+ "service that reads the screen layout for automated tests, answers only "
745
+ "on the phone's loopback port to a host holding the session token, and "
746
+ "stays installed until removed with `artemis helper uninstall`. Set "
747
+ f"{ENV_ARTEMIS_HELPER_AUTO_INSTALL}=false to install it by hand only."
748
+ )
749
+ emit(
750
+ "installing" if installed is None else "upgrading",
751
+ from_version=installed,
752
+ to_version=bundled.version_code,
753
+ version_name=bundled.version_name,
754
+ )
755
+ result = self._adb(serial, "install", "-r", "-g", str(bundled.apk_path))
756
+ output = f"{result.stdout or ''}\n{result.stderr or ''}"
757
+ if result.returncode != 0 or "Success" not in output:
758
+ return ProvisionResult(
759
+ ok=False,
760
+ action="failed",
761
+ installed_version=installed,
762
+ bundled_version=bundled.version_code,
763
+ enabled=False,
764
+ error=f"adb install failed: {output.strip()[-400:]}",
765
+ )
766
+ action = "installed" if installed is None else "upgraded"
767
+ installed = self.installed_version(serial)
768
+ enabled = self._enable_service(serial)
769
+ if not enabled:
770
+ # Secure settings were rejected (OEM ROM or restricted settings):
771
+ # open the Accessibility settings screen so the person only has to
772
+ # flip the switch, and say exactly where it is.
773
+ self._adb(serial, "shell", "am", "start", "-a", ACCESSIBILITY_SETTINGS_INTENT)
774
+ return ProvisionResult(
775
+ ok=enabled,
776
+ action=action,
777
+ installed_version=installed,
778
+ bundled_version=bundled.version_code if bundled else None,
779
+ enabled=enabled,
780
+ error=None
781
+ if enabled
782
+ else (
783
+ "This device rejected enabling the accessibility service from adb. "
784
+ f"The Accessibility settings screen was opened on {serial}; enable it by hand: "
785
+ f"{MANUAL_ENABLE_PATH}."
786
+ ),
787
+ )
788
+
789
+ def uninstall(self, serial: str) -> bool:
790
+ """Remove the helper from the device (never done automatically)."""
791
+ self.detach(serial)
792
+ result = self._adb(
793
+ serial, "shell", "settings", "get", "secure", "enabled_accessibility_services"
794
+ )
795
+ current = (result.stdout or "").strip()
796
+ services = [s for s in current.split(":") if s and s != "null" and s != SERVICE_NAME]
797
+ self._adb(
798
+ serial,
799
+ "shell",
800
+ "settings",
801
+ "put",
802
+ "secure",
803
+ "enabled_accessibility_services",
804
+ ":".join(services) if services else "null",
805
+ )
806
+ result = self._adb(serial, "uninstall", PACKAGE_NAME)
807
+ return result.returncode == 0 and "Success" in (result.stdout or "")
808
+
809
+ # ------------------------------------------------------------------ #
810
+ # Attach / detach (host-side tunnel)
811
+ # ------------------------------------------------------------------ #
812
+
813
+ def _create_forward(self, serial: str) -> int:
814
+ result = self._adb(serial, "forward", "--no-rebind", "tcp:0", f"tcp:{DEVICE_PORT}")
815
+ if result.returncode != 0:
816
+ raise HelperUnavailable(
817
+ f"adb forward to the accessibility helper on {serial} failed: "
818
+ f"{(result.stderr or result.stdout or '').strip()}"
819
+ )
820
+ text = (result.stdout or "").strip()
821
+ match = re.search(r"\b(\d{2,5})\b", text)
822
+ if match is not None:
823
+ return int(match.group(1))
824
+ existing = self.existing_forward(serial)
825
+ if existing is None:
826
+ raise HelperUnavailable(
827
+ f"adb forward on {serial} did not report the allocated host port (output: {text!r})."
828
+ )
829
+ return existing
830
+
831
+ def _remove_forward(self, serial: str, local_port: int) -> None:
832
+ try:
833
+ self._adb(serial, "forward", "--remove", f"tcp:{local_port}")
834
+ except (OSError, subprocess.SubprocessError) as exc:
835
+ logger.debug(f"Removing adb forward tcp:{local_port} on {serial} failed: {exc}")
836
+
837
+ def _wait_for_ping(self, local_port: int, attempts: int) -> dict[str, Any] | None:
838
+ for attempt in range(attempts):
839
+ info = self.ping(local_port)
840
+ if info is not None:
841
+ return info
842
+ if attempt + 1 < attempts:
843
+ self._sleep(_PING_INTERVAL_SECONDS)
844
+ return None
845
+
846
+ def attach(
847
+ self,
848
+ serial: str,
849
+ *,
850
+ provision: bool = True,
851
+ on_event: ProvisionEvent | None = None,
852
+ revive: bool | None = None,
853
+ ) -> HelperSession:
854
+ """Return a live session for ``serial``, building the tunnel when needed.
855
+
856
+ ``provision=True`` (task path) installs / upgrades / enables first.
857
+ ``provision=False`` (observers) only connects to a helper that is
858
+ already running and raises :class:`HelperUnavailable` otherwise.
859
+ ``revive`` (defaults to ``provision``) allows re-binding a dead but
860
+ enabled service; :meth:`reattach` always does, because the caller
861
+ already owned a working session on this device.
862
+ """
863
+ if revive is None:
864
+ revive = provision
865
+ with self._device_lock(serial):
866
+ transport = self.transport_id(serial)
867
+ existing = self.session(serial)
868
+ if existing is not None:
869
+ if existing.transport_id == transport and self.ping(existing.local_port):
870
+ return existing
871
+ logger.info(
872
+ f"Accessibility helper session on {serial} is stale "
873
+ f"(transport {existing.transport_id} -> {transport}); rebuilding the tunnel."
874
+ )
875
+ self._drop_session(serial, remove_forward=existing.transport_id == transport)
876
+
877
+ if provision:
878
+ result = self.provision(serial, on_event=on_event, install=self._auto_install())
879
+ if not result.ok:
880
+ raise HelperUnavailable(result.error or "Provisioning the helper failed.")
881
+
882
+ reused_port = self.existing_forward(serial)
883
+ owns_forward = reused_port is None
884
+ local_port = reused_port if reused_port is not None else self._create_forward(serial)
885
+
886
+ attempts = _PING_ATTEMPTS_AFTER_PROVISION if provision else _PING_ATTEMPTS_LAZY
887
+ info = self._wait_for_ping(local_port, attempts)
888
+ suppressed_by: str | None = None
889
+ if info is None and self.installed_version(serial) is not None:
890
+ # Silent although installed. The usual cause is a UiAutomation
891
+ # holder (uiautomator2 / Appium) that made Android unbind every
892
+ # accessibility service; only then a killed service.
893
+ holders = self.uiautomation_holders(serial)
894
+ if holders["uiautomator2"] and revive:
895
+ self.stop_uiautomator2_server(serial, holders["uiautomator2"])
896
+ info = self._wait_for_ping(local_port, _PING_ATTEMPTS_AFTER_PROVISION)
897
+ elif holders["uiautomator2"]:
898
+ suppressed_by = "uiautomator2's device server"
899
+ if info is None and holders["appium"]:
900
+ suppressed_by = "Appium's UiAutomator2 server"
901
+ if info is None and revive and suppressed_by is None:
902
+ # Installed and enabled but silent: the service was killed
903
+ # (force-stop, ROM task killer) or has not bound yet after an
904
+ # upgrade. Re-binding it is cheap and needs no install.
905
+ if self.is_service_enabled(serial):
906
+ self._revive_service(serial)
907
+ else:
908
+ self._enable_service(serial)
909
+ info = self._wait_for_ping(local_port, _PING_ATTEMPTS_AFTER_PROVISION)
910
+ if info is None:
911
+ if owns_forward:
912
+ self._remove_forward(serial, local_port)
913
+ if suppressed_by:
914
+ raise HelperUnavailable(
915
+ f"The accessibility helper on {serial} is suppressed by {suppressed_by}: "
916
+ "Android unbinds accessibility services while a UiAutomation connection "
917
+ "without FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES is alive. Stop that "
918
+ "server (for Appium, set the disableSuppressAccessibilityServices "
919
+ "capability) or run with ARTEMIS_HIERARCHY_BACKEND=uiautomator."
920
+ )
921
+ raise HelperUnavailable(
922
+ f"The accessibility helper on {serial} is not answering on "
923
+ f"127.0.0.1:{local_port} (device port {DEVICE_PORT})."
924
+ )
925
+
926
+ protocol = _protocol_of(info)
927
+ if protocol < MIN_PROTOCOL_VERSION:
928
+ # A helper older than this host can talk to (a dev build with a
929
+ # newer versionCode but the old contract, or a downgrade). Reinstall
930
+ # the bundle when this caller may, otherwise say what to run.
931
+ bundled = self.bundled
932
+ if provision and self._auto_install() and bundled is not None:
933
+ logger.warning(
934
+ f"Accessibility helper on {serial} speaks protocol {protocol} "
935
+ f"(host needs >= {MIN_PROTOCOL_VERSION}); reinstalling the bundled "
936
+ f"v{bundled.version_name}."
937
+ )
938
+ result = self.provision(serial, force=True, on_event=on_event)
939
+ if not result.ok:
940
+ raise HelperUnavailable(result.error or "Reinstalling the helper failed.")
941
+ info = self._wait_for_ping(local_port, _PING_ATTEMPTS_AFTER_PROVISION)
942
+ protocol = _protocol_of(info) if info else 0
943
+ if protocol < MIN_PROTOCOL_VERSION:
944
+ if owns_forward:
945
+ self._remove_forward(serial, local_port)
946
+ raise HelperUnavailable(
947
+ f"The accessibility helper on {serial} speaks protocol {protocol}, "
948
+ f"this host needs {MIN_PROTOCOL_VERSION} or newer. Reinstall it with: "
949
+ f"artemis helper install --serial {serial} --force"
950
+ )
951
+
952
+ token: str | None = None
953
+ if info.get("auth_required", protocol >= 2):
954
+ token = self.host_token()
955
+ self.push_token(serial)
956
+
957
+ version = info.get("version_code")
958
+ session = HelperSession(
959
+ serial=serial,
960
+ local_port=local_port,
961
+ transport_id=transport,
962
+ version_code=int(version) if isinstance(version, (int, float)) else None,
963
+ version_name=str(info.get("version_name") or ""),
964
+ owns_forward=owns_forward,
965
+ token=token,
966
+ protocol_version=protocol,
967
+ )
968
+ with self._registry_lock:
969
+ self._sessions[serial] = session
970
+ logger.info(
971
+ f"Accessibility helper attached on {serial}: 127.0.0.1:{local_port} "
972
+ f"(v{session.version_name or '?'}, protocol {protocol}, transport {transport})"
973
+ )
974
+ return session
975
+
976
+ def reattach(self, serial: str) -> HelperSession:
977
+ """Rebuild the tunnel after a request failed; never installs anything.
978
+
979
+ The caller had a working session, so a dead service is re-bound.
980
+ """
981
+ with self._device_lock(serial):
982
+ self._drop_session(serial, remove_forward=True)
983
+ return self.attach(serial, provision=False, revive=True)
984
+
985
+ def detach(self, serial: str) -> None:
986
+ with self._device_lock(serial):
987
+ self._drop_session(serial, remove_forward=True)
988
+
989
+ def _drop_session(self, serial: str, *, remove_forward: bool) -> None:
990
+ with self._registry_lock:
991
+ session = self._sessions.pop(serial, None)
992
+ if session is None:
993
+ return
994
+ if remove_forward and session.owns_forward:
995
+ self._remove_forward(serial, session.local_port)
996
+
997
+ def detach_all(self) -> None:
998
+ with self._registry_lock:
999
+ serials = list(self._sessions)
1000
+ for serial in serials:
1001
+ self.detach(serial)
1002
+
1003
+
1004
+ def _protocol_of(info: dict[str, Any] | None) -> int:
1005
+ try:
1006
+ return int((info or {}).get("protocol_version", 1))
1007
+ except (TypeError, ValueError):
1008
+ return 1
1009
+
1010
+
1011
+ helper_manager = AccessibilityHelperManager()
1012
+
1013
+ __all__ = [
1014
+ "AccessibilityHelperManager",
1015
+ "BundledHelper",
1016
+ "DEVICE_PORT",
1017
+ "HelperSession",
1018
+ "HelperUnavailable",
1019
+ "MANUAL_ENABLE_PATH",
1020
+ "MIN_PROTOCOL_VERSION",
1021
+ "PACKAGE_NAME",
1022
+ "ProvisionEvent",
1023
+ "ProvisionResult",
1024
+ "SERVICE_NAME",
1025
+ "TOKEN_ACTION",
1026
+ "TOKEN_RECEIVER",
1027
+ "auto_install_allowed",
1028
+ "helper_manager",
1029
+ "load_bundled_helper",
1030
+ ]