gemini-computer-use 0.2.4 → 0.4.2
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.
- package/README.md +741 -24
- package/bin/cli.js +112 -17
- package/core/agent.py +503 -18
- package/core/agent_harness.py +9 -2
- package/core/code_cache.py +15 -3
- package/core/device.py +2400 -0
- package/core/domain.py +303 -0
- package/core/mcp_tools.py +1840 -112
- package/core/policies.py +35 -5
- package/core/server.py +78 -32
- package/core/termux.py +916 -0
- package/core/updater.py +1713 -0
- package/core/version.py +31 -0
- package/core/vitals.py +66 -2
- package/core/web_share.py +814 -0
- package/gateway.py +1469 -98
- package/install-gui.cmd +40 -0
- package/install-gui.ps1 +1882 -0
- package/install-gui.strings.json +211 -0
- package/install.ps1 +960 -92
- package/install.sh +1214 -81
- package/ops/doctor.ps1 +735 -0
- package/ops/mesh-domain.sh +86 -0
- package/ops/nginx/README.md +77 -0
- package/ops/nginx/antigravity-mesh-mcp.conf +172 -0
- package/ops/nginx/mesh-domain.vhost.template +58 -0
- package/ops/nginx/render-domain.sh +123 -0
- package/ops/update.ps1 +373 -0
- package/ops/windows/README.md +298 -0
- package/ops/windows/agent-watchdog.ps1 +575 -0
- package/ops/windows/run-hidden.vbs +64 -0
- package/package.json +8 -3
- package/skills/orchestrator.md +12 -0
package/core/termux.py
ADDED
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""core/termux.py - the one place in the node that runs ``termux-*`` commands.
|
|
4
|
+
|
|
5
|
+
Termux:API is a two-part install: the ``termux-api`` package supplies the shell
|
|
6
|
+
commands, and the Termux:API **app** answers them over an Android binder. Either
|
|
7
|
+
half missing produces the same silent nothing from the phone, so every
|
|
8
|
+
``termux-*`` call in this project goes through this module and every failure
|
|
9
|
+
comes back as a dict a model can act on instead of an exception nobody catches:
|
|
10
|
+
|
|
11
|
+
* ``missing`` - the command is not on ``PATH`` (the package or the app is absent);
|
|
12
|
+
* ``denied`` - Android refused the permission behind the call;
|
|
13
|
+
* ``timeout`` - Android froze the Termux:API app, which is the *normal* failure
|
|
14
|
+
mode once the screen has been off for a while;
|
|
15
|
+
* ``refused`` - the caller asked for something that is not a ``termux-*``
|
|
16
|
+
command, which must never reach an exec.
|
|
17
|
+
|
|
18
|
+
Contract:
|
|
19
|
+
|
|
20
|
+
* **nothing raises** - a public function always returns a dict;
|
|
21
|
+
* **no shell, ever** - every command is an argv list under a hard timeout, so a
|
|
22
|
+
string that arrived from a model can never become shell syntax;
|
|
23
|
+
* **cheap** - a missing command is answered from ``shutil.which`` without
|
|
24
|
+
starting a process, and the command inventory is cached for a minute;
|
|
25
|
+
* **one root cause, said once** - when every command hangs because the
|
|
26
|
+
Termux:API *app* is missing, that is remembered (for a short while) instead of
|
|
27
|
+
making each telemetry section report its own timeout;
|
|
28
|
+
* **silent** - no printing, no logging, no progress output.
|
|
29
|
+
|
|
30
|
+
The wake lock lives here for the same reason the rest does: Android freezes
|
|
31
|
+
background processes when the screen goes off, and the node's own boot script
|
|
32
|
+
takes the lock exactly once at boot with nobody left to release it.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import locale as _locale
|
|
39
|
+
import os
|
|
40
|
+
import shutil
|
|
41
|
+
import subprocess
|
|
42
|
+
import threading
|
|
43
|
+
import time
|
|
44
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
45
|
+
|
|
46
|
+
#: The one sentence a user needs when a ``termux-*`` command is missing. Both
|
|
47
|
+
#: halves matter: an app from a *different* store than the package fails to bind,
|
|
48
|
+
#: which looks exactly like "the command is not installed".
|
|
49
|
+
TERMUX_API_FIX = ("install the Termux:API app from the SAME store as Termux "
|
|
50
|
+
"(F-Droid or GitHub, never mixed) and run `pkg install termux-api`")
|
|
51
|
+
|
|
52
|
+
#: Told whenever a command exists but never answers. Android freezes the
|
|
53
|
+
#: Termux:API app in the background, and the binder call then waits forever.
|
|
54
|
+
TERMUX_TIMEOUT_FIX = ("open the Termux:API app once after installing it, and disable "
|
|
55
|
+
"battery optimisation for Termux and Termux:API")
|
|
56
|
+
|
|
57
|
+
#: Told when ``~/storage/shared`` is missing: that symlink is the only reason the
|
|
58
|
+
#: node can read or write the phone's own storage.
|
|
59
|
+
TERMUX_STORAGE_FIX = ("run `termux-setup-storage` in Termux and grant the storage "
|
|
60
|
+
"permission it asks for")
|
|
61
|
+
|
|
62
|
+
#: Every Termux:API command this project may use. Kept in one tuple so the
|
|
63
|
+
#: inventory a model receives and the allowlist the runner enforces cannot drift.
|
|
64
|
+
REQUIRED_COMMANDS: Tuple[str, ...] = (
|
|
65
|
+
"termux-battery-status",
|
|
66
|
+
"termux-brightness",
|
|
67
|
+
"termux-call-log",
|
|
68
|
+
"termux-camera-info",
|
|
69
|
+
"termux-camera-photo",
|
|
70
|
+
"termux-clipboard-get",
|
|
71
|
+
"termux-clipboard-set",
|
|
72
|
+
"termux-contact-list",
|
|
73
|
+
"termux-dialog",
|
|
74
|
+
"termux-download",
|
|
75
|
+
"termux-fingerprint",
|
|
76
|
+
"termux-infrared-frequencies",
|
|
77
|
+
"termux-infrared-transmit",
|
|
78
|
+
"termux-job-scheduler",
|
|
79
|
+
"termux-location",
|
|
80
|
+
"termux-media-player",
|
|
81
|
+
"termux-media-scan",
|
|
82
|
+
"termux-microphone-record",
|
|
83
|
+
"termux-notification",
|
|
84
|
+
"termux-notification-list",
|
|
85
|
+
"termux-notification-remove",
|
|
86
|
+
"termux-open",
|
|
87
|
+
"termux-open-url",
|
|
88
|
+
"termux-sensor",
|
|
89
|
+
"termux-share",
|
|
90
|
+
"termux-sms-list",
|
|
91
|
+
"termux-sms-send",
|
|
92
|
+
"termux-storage-get",
|
|
93
|
+
"termux-telephony-call",
|
|
94
|
+
"termux-telephony-cellinfo",
|
|
95
|
+
"termux-telephony-deviceinfo",
|
|
96
|
+
"termux-toast",
|
|
97
|
+
"termux-torch",
|
|
98
|
+
"termux-tts-engines",
|
|
99
|
+
"termux-tts-speak",
|
|
100
|
+
"termux-usb",
|
|
101
|
+
"termux-vibrate",
|
|
102
|
+
"termux-volume",
|
|
103
|
+
"termux-wallpaper",
|
|
104
|
+
"termux-wake-lock",
|
|
105
|
+
"termux-wake-unlock",
|
|
106
|
+
"termux-wifi-connectioninfo",
|
|
107
|
+
"termux-wifi-enable",
|
|
108
|
+
"termux-wifi-scaninfo",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
#: Without these three a phone cannot even be described: battery, clipboard and
|
|
112
|
+
#: the notification channel. Their absence is what "the API is not installed"
|
|
113
|
+
#: means to a user, so a partial install is reported as not installed.
|
|
114
|
+
MANDATORY_COMMANDS: Tuple[str, ...] = (
|
|
115
|
+
"termux-battery-status",
|
|
116
|
+
"termux-clipboard-get",
|
|
117
|
+
"termux-notification",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
#: The Android permission groups behind the commands above. They start as
|
|
121
|
+
#: "unknown" and are only ever changed by a real observation - probing the camera
|
|
122
|
+
#: or the microphone just to learn a state would open hardware the caller never
|
|
123
|
+
#: asked for, which is a side effect a status call must not have.
|
|
124
|
+
PERMISSION_APIS: Tuple[str, ...] = (
|
|
125
|
+
"biometric",
|
|
126
|
+
"call_log",
|
|
127
|
+
"camera",
|
|
128
|
+
"clipboard",
|
|
129
|
+
"contacts",
|
|
130
|
+
"location",
|
|
131
|
+
"microphone",
|
|
132
|
+
"notification",
|
|
133
|
+
"sensors",
|
|
134
|
+
"sms",
|
|
135
|
+
"storage",
|
|
136
|
+
"telephony",
|
|
137
|
+
"usb",
|
|
138
|
+
"wifi",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
#: Which permission a failing command was exercising. A command missing from this
|
|
142
|
+
#: map still reports ``denied``; it just cannot name the permission to remember.
|
|
143
|
+
_COMMAND_API: Dict[str, str] = {
|
|
144
|
+
"termux-call-log": "call_log",
|
|
145
|
+
"termux-camera-info": "camera",
|
|
146
|
+
"termux-camera-photo": "camera",
|
|
147
|
+
"termux-clipboard-get": "clipboard",
|
|
148
|
+
"termux-clipboard-set": "clipboard",
|
|
149
|
+
"termux-contact-list": "contacts",
|
|
150
|
+
"termux-fingerprint": "biometric",
|
|
151
|
+
"termux-location": "location",
|
|
152
|
+
"termux-microphone-record": "microphone",
|
|
153
|
+
"termux-notification": "notification",
|
|
154
|
+
"termux-notification-list": "notification",
|
|
155
|
+
"termux-notification-remove": "notification",
|
|
156
|
+
"termux-sensor": "sensors",
|
|
157
|
+
"termux-sms-list": "sms",
|
|
158
|
+
"termux-sms-send": "sms",
|
|
159
|
+
"termux-storage-get": "storage",
|
|
160
|
+
"termux-telephony-call": "telephony",
|
|
161
|
+
"termux-telephony-cellinfo": "telephony",
|
|
162
|
+
"termux-telephony-deviceinfo": "telephony",
|
|
163
|
+
"termux-usb": "usb",
|
|
164
|
+
"termux-wifi-connectioninfo": "wifi",
|
|
165
|
+
"termux-wifi-enable": "wifi",
|
|
166
|
+
"termux-wifi-scaninfo": "wifi",
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#: Failure text from the Termux:API app that always means "Android refused this
|
|
170
|
+
#: call", whichever API is behind it.
|
|
171
|
+
_PERMISSION_PHRASES: Tuple[str, ...] = (
|
|
172
|
+
"permission denied",
|
|
173
|
+
"not granted",
|
|
174
|
+
"requires permission",
|
|
175
|
+
"securityexception",
|
|
176
|
+
"location services",
|
|
177
|
+
"user denied",
|
|
178
|
+
"no such file or directory: /dev/video",
|
|
179
|
+
# The wording a real phone used (OPPO PHY110, Android 16), delivered as JSON on
|
|
180
|
+
# stdout with exit code 0: {"error": "Please grant the following permission to
|
|
181
|
+
# use this command: android.permission.ACCESS_COARSE_LOCATION"}.
|
|
182
|
+
"please grant",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
#: Words that turn a plain noun ("camera", "microphone") into a permission
|
|
186
|
+
#: failure. The nouns alone are not enough: "no camera found" is a hardware
|
|
187
|
+
#: answer, not a permission one, and must not be reported as "grant this".
|
|
188
|
+
_PERMISSION_CONTEXT: Tuple[str, ...] = (
|
|
189
|
+
"permission",
|
|
190
|
+
"denied",
|
|
191
|
+
"not granted",
|
|
192
|
+
"securityexception",
|
|
193
|
+
"security exception",
|
|
194
|
+
"not allowed",
|
|
195
|
+
"forbidden",
|
|
196
|
+
"unauthorized",
|
|
197
|
+
"requires",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
#: Where Termux really lives. ``$PREFIX`` is not trusted on its own - many build
|
|
201
|
+
#: systems export it - so the application data path is an independent signal.
|
|
202
|
+
_TERMUX_BIN_DIR = "/data/data/com.termux/files/usr/bin"
|
|
203
|
+
|
|
204
|
+
#: Default budget for one ``termux-*`` command, in seconds.
|
|
205
|
+
DEFAULT_TIMEOUT = 6.0
|
|
206
|
+
|
|
207
|
+
#: Wake-lock calls do not return data, so they get the same budget as anything else.
|
|
208
|
+
WAKE_LOCK_TIMEOUT = 6.0
|
|
209
|
+
|
|
210
|
+
#: The command inventory is a property of the install, not of the moment: a minute
|
|
211
|
+
#: of caching keeps a chatty model from running 44 PATH lookups per tool call.
|
|
212
|
+
_CACHE_TTL = 60.0
|
|
213
|
+
|
|
214
|
+
#: How long one timeout counts as evidence that the Termux:API **app** is
|
|
215
|
+
#: unreachable. Short on purpose: the operator may install the app, or Android may
|
|
216
|
+
#: unfreeze it, at any moment, and stale evidence is worse than no evidence.
|
|
217
|
+
APP_EVIDENCE_TTL = 120.0
|
|
218
|
+
|
|
219
|
+
_ACQUIRE = "termux-wake-lock"
|
|
220
|
+
_RELEASE = "termux-wake-unlock"
|
|
221
|
+
_WAKE_ACTIONS: Dict[str, str] = {"acquire": _ACQUIRE, "release": _RELEASE}
|
|
222
|
+
|
|
223
|
+
#: Windows: keep a console window from flashing for a phone command that cannot
|
|
224
|
+
#: exist there anyway (the flag is 0 everywhere else).
|
|
225
|
+
_POPEN_FLAGS = 0x08000000 if os.name == "nt" else 0
|
|
226
|
+
|
|
227
|
+
_CACHE: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
|
228
|
+
_CACHE_LOCK = threading.RLock()
|
|
229
|
+
|
|
230
|
+
#: ``api_installed``/``capabilities`` caches and the observation-backed permission
|
|
231
|
+
#: map are separate locks: a status call must not wait on a camera probe.
|
|
232
|
+
_PERMISSIONS: Dict[str, str] = {name: "unknown" for name in PERMISSION_APIS}
|
|
233
|
+
_PERMISSION_LOCK = threading.RLock()
|
|
234
|
+
|
|
235
|
+
#: Is the Termux:API *app* answering? The commands can all exist while the app is
|
|
236
|
+
#: missing, and then every one of them hangs: on a real phone that cost one
|
|
237
|
+
#: timeout per telemetry section, so the node has to remember the shared cause
|
|
238
|
+
#: instead of reporting five unrelated "timed out" blocks. Guarded by ``_CACHE_LOCK``
|
|
239
|
+
#: because it is capability evidence, exactly like the cached blocks beside it.
|
|
240
|
+
_APP_SUCCESS = False #: a call has been answered since the last reset
|
|
241
|
+
_APP_TIMEOUT_COMMAND = "" #: which command last hung (kept for diagnostics)
|
|
242
|
+
_APP_TIMEOUT_AT = 0.0 #: ``time.monotonic()`` of that hang
|
|
243
|
+
|
|
244
|
+
#: How many times *this process* has taken the wake lock. Held across the command
|
|
245
|
+
#: itself, because "exactly one acquire and one release" is the invariant Android
|
|
246
|
+
#: cares about and a race between two threads would break it.
|
|
247
|
+
_WAKE_COUNT = 0
|
|
248
|
+
_WAKE_LOCK = threading.RLock()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
# Seams: the two things tests replace, plus decoding
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
_DECODINGS: Optional[List[str]] = None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _windows_code_pages() -> List[str]:
|
|
259
|
+
"""Code pages a Windows child may write with: OEM console CP first, then ANSI."""
|
|
260
|
+
pages: List[str] = []
|
|
261
|
+
try:
|
|
262
|
+
import ctypes
|
|
263
|
+
|
|
264
|
+
for getter in ("GetOEMCP", "GetACP"):
|
|
265
|
+
try:
|
|
266
|
+
code_page = int(getattr(ctypes.windll.kernel32, getter)())
|
|
267
|
+
except Exception:
|
|
268
|
+
continue
|
|
269
|
+
if code_page > 0:
|
|
270
|
+
pages.append("cp%d" % code_page)
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
return pages
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _decodings() -> List[str]:
|
|
277
|
+
"""Encodings to try for captured child output, most likely first.
|
|
278
|
+
|
|
279
|
+
Order matters: every single-byte code page "succeeds" at decoding anything, so
|
|
280
|
+
UTF-8 (what Termux writes) has to be tried before the Windows OEM page (what
|
|
281
|
+
cmd.exe and the native console tools write) and before ANSI, the last resort.
|
|
282
|
+
"""
|
|
283
|
+
global _DECODINGS
|
|
284
|
+
if _DECODINGS is not None:
|
|
285
|
+
return _DECODINGS
|
|
286
|
+
candidates = ["utf-8"]
|
|
287
|
+
if os.name == "nt":
|
|
288
|
+
candidates.extend(_windows_code_pages())
|
|
289
|
+
else:
|
|
290
|
+
try:
|
|
291
|
+
candidates.append(_locale.getpreferredencoding(False))
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
candidates.append("cp1252")
|
|
295
|
+
unique: List[str] = []
|
|
296
|
+
seen = set()
|
|
297
|
+
for name in candidates:
|
|
298
|
+
if not name:
|
|
299
|
+
continue
|
|
300
|
+
key = name.lower()
|
|
301
|
+
if key in seen:
|
|
302
|
+
continue
|
|
303
|
+
seen.add(key)
|
|
304
|
+
unique.append(name)
|
|
305
|
+
_DECODINGS = unique
|
|
306
|
+
return unique
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _decode(data: Any) -> str:
|
|
310
|
+
"""Decode captured bytes without mojibake, with line endings normalised.
|
|
311
|
+
|
|
312
|
+
A stray ``\\r`` would end up inside a path or a JSON key on the way back to a
|
|
313
|
+
model, so ``\\r\\n`` and lone ``\\r`` both become ``\\n`` here rather than in
|
|
314
|
+
every reader.
|
|
315
|
+
"""
|
|
316
|
+
if data is None:
|
|
317
|
+
return ""
|
|
318
|
+
if isinstance(data, str):
|
|
319
|
+
return data.replace("\r\n", "\n").replace("\r", "\n").strip()
|
|
320
|
+
if isinstance(data, (bytes, bytearray)):
|
|
321
|
+
for encoding in _decodings():
|
|
322
|
+
try:
|
|
323
|
+
return (bytes(data).decode(encoding).replace("\r\n", "\n")
|
|
324
|
+
.replace("\r", "\n").strip())
|
|
325
|
+
except (UnicodeDecodeError, LookupError):
|
|
326
|
+
continue
|
|
327
|
+
return bytes(data).decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "\n").strip()
|
|
328
|
+
return str(data).strip()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _which(name: str) -> Optional[str]:
|
|
332
|
+
"""``shutil.which`` behind a seam, so tests never need a PATH shim."""
|
|
333
|
+
try:
|
|
334
|
+
return shutil.which(name)
|
|
335
|
+
except Exception:
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _run(argv: Sequence[str], timeout: float) -> Tuple[int, str]:
|
|
340
|
+
"""Run one argv list: no shell, no stdin, and a hard timeout.
|
|
341
|
+
|
|
342
|
+
``subprocess.TimeoutExpired`` is deliberately **not** swallowed here: this
|
|
343
|
+
module exists to explain a frozen Termux:API app, so the caller has to turn
|
|
344
|
+
that exception into a timeout answer instead of a silent empty result. Any
|
|
345
|
+
other failure to start the process is ``(-1, "")``, which the caller reports
|
|
346
|
+
as a reason.
|
|
347
|
+
"""
|
|
348
|
+
try:
|
|
349
|
+
proc = subprocess.run(
|
|
350
|
+
list(argv),
|
|
351
|
+
stdin=subprocess.DEVNULL,
|
|
352
|
+
stdout=subprocess.PIPE,
|
|
353
|
+
stderr=subprocess.PIPE,
|
|
354
|
+
timeout=max(0.2, float(timeout)),
|
|
355
|
+
creationflags=_POPEN_FLAGS,
|
|
356
|
+
)
|
|
357
|
+
except subprocess.TimeoutExpired:
|
|
358
|
+
raise
|
|
359
|
+
except Exception:
|
|
360
|
+
return -1, ""
|
|
361
|
+
out = _decode(proc.stdout)
|
|
362
|
+
if proc.returncode != 0:
|
|
363
|
+
# A failing Termux:API command explains itself on stderr. On success stdout
|
|
364
|
+
# is the answer - and an empty stdout is a legitimate empty clipboard, so
|
|
365
|
+
# stderr is never substituted there.
|
|
366
|
+
return int(proc.returncode), (_decode(proc.stderr) or out)
|
|
367
|
+
return int(proc.returncode), out
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
# Platform detection and the command inventory
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
def is_android() -> bool:
|
|
375
|
+
"""True only inside Termux on Android.
|
|
376
|
+
|
|
377
|
+
Three independent signals, any of which is enough: Termux exports
|
|
378
|
+
``TERMUX_VERSION``, its ``$PREFIX`` points inside ``com.termux``, and its
|
|
379
|
+
application data directory has a fixed path. A generic ``PREFIX`` such as
|
|
380
|
+
``/usr/local`` is not a signal - build systems export it everywhere.
|
|
381
|
+
"""
|
|
382
|
+
try:
|
|
383
|
+
if os.environ.get("TERMUX_VERSION"):
|
|
384
|
+
return True
|
|
385
|
+
if "com.termux" in os.environ.get("PREFIX", ""):
|
|
386
|
+
return True
|
|
387
|
+
return os.path.isdir(_TERMUX_BIN_DIR)
|
|
388
|
+
except Exception:
|
|
389
|
+
return False
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def api_installed(refresh: bool = False) -> Dict[str, Any]:
|
|
393
|
+
"""Which Termux:API commands this phone actually has, cached for a minute.
|
|
394
|
+
|
|
395
|
+
``installed`` answers the only question a user asks ("is the API working?"),
|
|
396
|
+
which needs the mandatory trio; ``commands`` answers the one a model asks
|
|
397
|
+
("may I try termux-sensor?"). ``refresh=True`` is for the moment right after
|
|
398
|
+
the installer ran ``pkg install termux-api``.
|
|
399
|
+
"""
|
|
400
|
+
now = time.monotonic()
|
|
401
|
+
if not refresh:
|
|
402
|
+
with _CACHE_LOCK:
|
|
403
|
+
hit = _CACHE.get("api_installed")
|
|
404
|
+
if hit and hit[0] > now:
|
|
405
|
+
return hit[1]
|
|
406
|
+
try:
|
|
407
|
+
commands = {name: bool(_which(name)) for name in REQUIRED_COMMANDS}
|
|
408
|
+
missing = [name for name in REQUIRED_COMMANDS if not commands[name]]
|
|
409
|
+
report: Dict[str, Any] = {
|
|
410
|
+
"installed": all(commands.get(name, False) for name in MANDATORY_COMMANDS),
|
|
411
|
+
"commands": commands,
|
|
412
|
+
"missing": missing,
|
|
413
|
+
"fix": TERMUX_API_FIX,
|
|
414
|
+
"source": "shutil.which",
|
|
415
|
+
}
|
|
416
|
+
except Exception as exc: # a probe must never escape
|
|
417
|
+
report = {
|
|
418
|
+
"installed": False,
|
|
419
|
+
"commands": {name: False for name in REQUIRED_COMMANDS},
|
|
420
|
+
"missing": list(REQUIRED_COMMANDS),
|
|
421
|
+
"fix": TERMUX_API_FIX,
|
|
422
|
+
"source": "shutil.which",
|
|
423
|
+
"error": "%s: %s" % (type(exc).__name__, exc),
|
|
424
|
+
}
|
|
425
|
+
with _CACHE_LOCK:
|
|
426
|
+
_CACHE["api_installed"] = (now + _CACHE_TTL, report)
|
|
427
|
+
return report
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _permission_snapshot() -> Dict[str, str]:
|
|
431
|
+
"""A copy of the permission map: a caller must not be able to edit our state."""
|
|
432
|
+
with _PERMISSION_LOCK:
|
|
433
|
+
return {name: _PERMISSIONS.get(name, "unknown") for name in sorted(_PERMISSIONS)}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _command_exists(name: str) -> bool:
|
|
437
|
+
try:
|
|
438
|
+
return bool(_which(name))
|
|
439
|
+
except Exception:
|
|
440
|
+
return False
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _wake_state() -> Dict[str, Any]:
|
|
444
|
+
"""The in-process half of the wake-lock answer (``capabilities`` needs it)."""
|
|
445
|
+
available = _command_exists(_ACQUIRE)
|
|
446
|
+
with _WAKE_LOCK:
|
|
447
|
+
return {"command": available,
|
|
448
|
+
# None while the command is missing: a phone without the API
|
|
449
|
+
# cannot tell us whether a lock is held, and guessing would be
|
|
450
|
+
# worse than saying "unknown".
|
|
451
|
+
"held": (bool(_WAKE_COUNT > 0) if available else None),
|
|
452
|
+
"count": int(_WAKE_COUNT)}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _record_app_timeout(command: str) -> None:
|
|
456
|
+
"""Remember that a ``termux-*`` call hung, which points at the app, not the package.
|
|
457
|
+
|
|
458
|
+
The cached capabilities block is dropped with it: a reader that still sees
|
|
459
|
+
``app_reachable: True`` (or "unknown") would hide the observation that was just
|
|
460
|
+
made, and telling the operator once is the whole point of remembering it.
|
|
461
|
+
"""
|
|
462
|
+
global _APP_TIMEOUT_AT, _APP_TIMEOUT_COMMAND
|
|
463
|
+
with _CACHE_LOCK:
|
|
464
|
+
_APP_TIMEOUT_COMMAND = str(command)
|
|
465
|
+
_APP_TIMEOUT_AT = time.monotonic()
|
|
466
|
+
_CACHE.pop("capabilities", None)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _record_app_success() -> None:
|
|
470
|
+
"""An answered call disproves every earlier timeout, so drop that evidence."""
|
|
471
|
+
global _APP_SUCCESS, _APP_TIMEOUT_AT, _APP_TIMEOUT_COMMAND
|
|
472
|
+
with _CACHE_LOCK:
|
|
473
|
+
changed = (not _APP_SUCCESS) or bool(_APP_TIMEOUT_COMMAND)
|
|
474
|
+
_APP_SUCCESS = True
|
|
475
|
+
_APP_TIMEOUT_COMMAND = ""
|
|
476
|
+
_APP_TIMEOUT_AT = 0.0
|
|
477
|
+
if changed:
|
|
478
|
+
# Only when the answer actually changes: a working phone must not
|
|
479
|
+
# rebuild the capabilities block on every single successful call.
|
|
480
|
+
_CACHE.pop("capabilities", None)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _app_reachable() -> Optional[bool]:
|
|
484
|
+
"""Is the Termux:API app answering? ``None`` means "no evidence either way".
|
|
485
|
+
|
|
486
|
+
A fresh node must not claim the app is broken, so the answer only becomes
|
|
487
|
+
``False`` when the newest evidence is a *recent* hang. A record older than
|
|
488
|
+
:data:`APP_EVIDENCE_TTL` is no evidence at all, because the operator may have
|
|
489
|
+
installed or opened the app in the meantime.
|
|
490
|
+
"""
|
|
491
|
+
now = time.monotonic()
|
|
492
|
+
with _CACHE_LOCK:
|
|
493
|
+
succeeded = _APP_SUCCESS
|
|
494
|
+
hung = bool(_APP_TIMEOUT_COMMAND)
|
|
495
|
+
hung_at = _APP_TIMEOUT_AT
|
|
496
|
+
if hung and (now - hung_at) <= APP_EVIDENCE_TTL:
|
|
497
|
+
return False
|
|
498
|
+
if succeeded:
|
|
499
|
+
return True
|
|
500
|
+
return None
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def capabilities(refresh: bool = False) -> Dict[str, Any]:
|
|
504
|
+
"""A mergeable block for :mod:`core.device`'s capabilities section.
|
|
505
|
+
|
|
506
|
+
It answers what a model needs before it tries something: is the API there,
|
|
507
|
+
can the node reach shared storage, is a wake lock held, which permission a
|
|
508
|
+
*previous* call proved to be missing, and - the one shared cause behind a
|
|
509
|
+
phone whose every command hangs - whether the Termux:API **app** is
|
|
510
|
+
answering at all (``app_reachable``: ``True``/``False``/``None`` for "no
|
|
511
|
+
evidence", with ``app_fix`` present only when it is ``False``). Permission
|
|
512
|
+
states are reported, never probed - opening the camera to learn whether the
|
|
513
|
+
camera is allowed would be an unwanted side effect of a status call.
|
|
514
|
+
"""
|
|
515
|
+
now = time.monotonic()
|
|
516
|
+
if not refresh:
|
|
517
|
+
with _CACHE_LOCK:
|
|
518
|
+
hit = _CACHE.get("capabilities")
|
|
519
|
+
if hit and hit[0] > now:
|
|
520
|
+
return hit[1]
|
|
521
|
+
value = _build_capabilities(refresh)
|
|
522
|
+
with _CACHE_LOCK:
|
|
523
|
+
_CACHE["capabilities"] = (now + _CACHE_TTL, value)
|
|
524
|
+
return value
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _build_capabilities(refresh: bool) -> Dict[str, Any]:
|
|
528
|
+
fixes: List[str] = []
|
|
529
|
+
try:
|
|
530
|
+
api = api_installed(refresh=refresh)
|
|
531
|
+
except Exception as exc:
|
|
532
|
+
api = {"installed": False, "commands": {}, "missing": list(REQUIRED_COMMANDS),
|
|
533
|
+
"fix": TERMUX_API_FIX, "source": "shutil.which",
|
|
534
|
+
"error": "%s: %s" % (type(exc).__name__, exc)}
|
|
535
|
+
if not api.get("installed"):
|
|
536
|
+
fixes.append(TERMUX_API_FIX)
|
|
537
|
+
|
|
538
|
+
try:
|
|
539
|
+
storage_permission = bool(os.path.isdir(os.path.expanduser("~/storage/shared")))
|
|
540
|
+
except Exception:
|
|
541
|
+
storage_permission = False
|
|
542
|
+
if not storage_permission:
|
|
543
|
+
fixes.append(TERMUX_STORAGE_FIX)
|
|
544
|
+
|
|
545
|
+
wake = _wake_state()
|
|
546
|
+
if wake.get("command") is not True:
|
|
547
|
+
fixes.append(TERMUX_API_FIX)
|
|
548
|
+
|
|
549
|
+
permissions = _permission_snapshot()
|
|
550
|
+
for api_name in permissions:
|
|
551
|
+
if permissions[api_name] == "denied":
|
|
552
|
+
fixes.append(_permission_fix(api_name))
|
|
553
|
+
|
|
554
|
+
unique: List[str] = []
|
|
555
|
+
for fix in fixes:
|
|
556
|
+
if fix and fix not in unique:
|
|
557
|
+
unique.append(fix)
|
|
558
|
+
app_reachable = _app_reachable()
|
|
559
|
+
block: Dict[str, Any] = {
|
|
560
|
+
"api": api,
|
|
561
|
+
"storage_permission": storage_permission,
|
|
562
|
+
"wake_lock": wake,
|
|
563
|
+
"permissions": permissions,
|
|
564
|
+
"fixes": unique,
|
|
565
|
+
"app_reachable": app_reachable,
|
|
566
|
+
}
|
|
567
|
+
if app_reachable is False:
|
|
568
|
+
# Deliberately a key of its own rather than another entry in "fixes": the
|
|
569
|
+
# timeout fix is about the app, and a consumer merging this block into a
|
|
570
|
+
# device report has to be able to tell the two kinds of fix apart.
|
|
571
|
+
block["app_fix"] = TERMUX_TIMEOUT_FIX
|
|
572
|
+
return block
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# ---------------------------------------------------------------------------
|
|
576
|
+
# Running one command
|
|
577
|
+
# ---------------------------------------------------------------------------
|
|
578
|
+
|
|
579
|
+
def _elapsed_ms(started: float) -> int:
|
|
580
|
+
return int(round((time.monotonic() - started) * 1000.0))
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _failure(command: str, argv: Sequence[str], started: float, reason: str,
|
|
584
|
+
**fields: Any) -> Dict[str, Any]:
|
|
585
|
+
result: Dict[str, Any] = {
|
|
586
|
+
"ok": False,
|
|
587
|
+
"reason": reason,
|
|
588
|
+
"command": command,
|
|
589
|
+
"argv": list(argv),
|
|
590
|
+
"duration_ms": _elapsed_ms(started),
|
|
591
|
+
}
|
|
592
|
+
result.update(fields)
|
|
593
|
+
return result
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _allowed(name: str) -> bool:
|
|
597
|
+
"""The security guard: only bare ``termux-*`` command names may be executed.
|
|
598
|
+
|
|
599
|
+
A caller must never be able to turn a tool argument into an arbitrary
|
|
600
|
+
execution, so the prefix is checked *and* a path separator is refused -
|
|
601
|
+
``termux-../../bin/sh`` satisfies the prefix while escaping the allowlist.
|
|
602
|
+
"""
|
|
603
|
+
if not name.startswith("termux-"):
|
|
604
|
+
return False
|
|
605
|
+
return not any(separator in name for separator in ("/", "\\"))
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _bounded(timeout: Any) -> float:
|
|
609
|
+
"""A timeout that is always usable, whatever the caller passed."""
|
|
610
|
+
try:
|
|
611
|
+
return max(0.2, float(timeout))
|
|
612
|
+
except Exception:
|
|
613
|
+
return DEFAULT_TIMEOUT
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _seconds(value: Any) -> float:
|
|
617
|
+
try:
|
|
618
|
+
return float(value)
|
|
619
|
+
except Exception:
|
|
620
|
+
return 0.0
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def run_json(command: str, args: Sequence[str] = (),
|
|
624
|
+
timeout: float = DEFAULT_TIMEOUT) -> Dict[str, Any]:
|
|
625
|
+
"""Run one ``termux-*`` command and return its JSON answer as ``value``.
|
|
626
|
+
|
|
627
|
+
Non-JSON stdout is **not** an error: several commands (``termux-open``,
|
|
628
|
+
``termux-toast``) answer with a sentence or with nothing at all, and a model
|
|
629
|
+
is better served by the text plus ``parsed: False`` than by a failure.
|
|
630
|
+
"""
|
|
631
|
+
return _execute(command, args, timeout, parse=True)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def run_text(command: str, args: Sequence[str] = (),
|
|
635
|
+
timeout: float = DEFAULT_TIMEOUT) -> Dict[str, Any]:
|
|
636
|
+
"""Run one ``termux-*`` command and return its stdout as ``value``."""
|
|
637
|
+
return _execute(command, args, timeout, parse=False)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _execute(command: str, args: Sequence[str], timeout: float, parse: bool) -> Dict[str, Any]:
|
|
641
|
+
started = time.monotonic()
|
|
642
|
+
name = str(command or "")
|
|
643
|
+
if isinstance(args, (str, bytes)):
|
|
644
|
+
# A bare string is one argument, never a split on spaces: splitting is
|
|
645
|
+
# exactly the step that would turn caller text into extra options.
|
|
646
|
+
extra = [args if isinstance(args, str) else _decode(args)]
|
|
647
|
+
else:
|
|
648
|
+
try:
|
|
649
|
+
extra = [str(item) for item in (args or ())]
|
|
650
|
+
except Exception:
|
|
651
|
+
extra = []
|
|
652
|
+
argv = [name] + extra
|
|
653
|
+
|
|
654
|
+
if not _allowed(name):
|
|
655
|
+
return _failure(name, argv, started,
|
|
656
|
+
"refused: %r is not a termux-* command" % name)
|
|
657
|
+
|
|
658
|
+
if not _command_exists(name):
|
|
659
|
+
# Nothing is executed when the command is absent: the failure itself is
|
|
660
|
+
# the answer, and running a "help" invocation would just be noise.
|
|
661
|
+
return _failure(name, argv, started,
|
|
662
|
+
"the %s command is not installed" % name,
|
|
663
|
+
missing=True, fix=TERMUX_API_FIX)
|
|
664
|
+
|
|
665
|
+
try:
|
|
666
|
+
rc, text = _run(argv, _bounded(timeout))
|
|
667
|
+
except subprocess.TimeoutExpired as exc:
|
|
668
|
+
# One hang is evidence about the app, not about this command: record it so
|
|
669
|
+
# the next capabilities() call can explain the whole phone at once.
|
|
670
|
+
_record_app_timeout(name)
|
|
671
|
+
fields: Dict[str, Any] = {"timeout": True, "fix": TERMUX_TIMEOUT_FIX}
|
|
672
|
+
partial = _decode(getattr(exc, "stdout", None) or getattr(exc, "stderr", None))
|
|
673
|
+
if partial:
|
|
674
|
+
fields["raw"] = partial
|
|
675
|
+
seconds = _seconds(getattr(exc, "timeout", None)) or _seconds(timeout)
|
|
676
|
+
return _failure(name, argv, started,
|
|
677
|
+
"%s timed out after %gs" % (name, seconds), **fields)
|
|
678
|
+
except Exception as exc:
|
|
679
|
+
return _failure(name, argv, started,
|
|
680
|
+
"%s could not be run: %s" % (name, exc))
|
|
681
|
+
|
|
682
|
+
# The seam may hand back text or bytes; normalising here keeps the contract of
|
|
683
|
+
# the result dict the same whichever way it arrived.
|
|
684
|
+
raw = _decode(text)
|
|
685
|
+
if rc != 0:
|
|
686
|
+
reason = raw.strip() or "%s exited with %d" % (name, rc)
|
|
687
|
+
fields = _diagnose(raw, name)
|
|
688
|
+
fields["raw"] = raw
|
|
689
|
+
return _failure(name, argv, started, reason, **fields)
|
|
690
|
+
|
|
691
|
+
value: Any = raw
|
|
692
|
+
extra_fields: Dict[str, Any] = {}
|
|
693
|
+
if parse:
|
|
694
|
+
try:
|
|
695
|
+
value = json.loads(raw)
|
|
696
|
+
except Exception:
|
|
697
|
+
value = raw
|
|
698
|
+
extra_fields["parsed"] = False
|
|
699
|
+
# A refused permission arrives as a SUCCESSFUL call: the app prints
|
|
700
|
+
# {"error": "Please grant the following permission to use this command: ..."}
|
|
701
|
+
# on stdout and exits 0. Reading only the exit code therefore turned "Android
|
|
702
|
+
# said no" into an empty answer - the model saw a phone with no cell data
|
|
703
|
+
# instead of a phone that needs one permission granted.
|
|
704
|
+
if isinstance(value, dict):
|
|
705
|
+
message = value.get("error")
|
|
706
|
+
if isinstance(message, str) and message.strip():
|
|
707
|
+
fields = _diagnose(message, name)
|
|
708
|
+
fields["raw"] = raw
|
|
709
|
+
return _failure(name, argv, started, message.strip(), **fields)
|
|
710
|
+
# Only an answered call counts. A non-zero exit is not proof that the app is
|
|
711
|
+
# alive - the honest reading of "the app is unreachable" is the one that
|
|
712
|
+
# requires positive evidence before it is cleared.
|
|
713
|
+
_record_app_success()
|
|
714
|
+
result: Dict[str, Any] = {
|
|
715
|
+
"ok": True,
|
|
716
|
+
"value": value,
|
|
717
|
+
"raw": raw,
|
|
718
|
+
"command": name,
|
|
719
|
+
"argv": argv,
|
|
720
|
+
"duration_ms": _elapsed_ms(started),
|
|
721
|
+
}
|
|
722
|
+
result.update(extra_fields)
|
|
723
|
+
return result
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def sensor_sample(name: str, timeout: float = 8.0) -> Dict[str, Any]:
|
|
727
|
+
"""One value from one named sensor.
|
|
728
|
+
|
|
729
|
+
``-n 1`` is built here and can never come from the caller: without it
|
|
730
|
+
``termux-sensor`` streams samples until it is killed, so a model asking for a
|
|
731
|
+
single reading would hold the tool open until its own timeout expires.
|
|
732
|
+
"""
|
|
733
|
+
sensor = str(name or "").strip()
|
|
734
|
+
return run_json("termux-sensor", ["-s", sensor, "-n", "1"], timeout)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
# ---------------------------------------------------------------------------
|
|
738
|
+
# Failure text -> a fix and a remembered permission
|
|
739
|
+
# ---------------------------------------------------------------------------
|
|
740
|
+
|
|
741
|
+
def _permission_fix(api: str) -> str:
|
|
742
|
+
return ("grant the %s permission to Termux:API in Android Settings > Apps > "
|
|
743
|
+
"Termux:API > Permissions" % api)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _api_name(api: str) -> str:
|
|
747
|
+
"""Normalise a permission name, accepting a command name as a shorthand."""
|
|
748
|
+
name = str(api or "").strip().lower()
|
|
749
|
+
if not name:
|
|
750
|
+
return ""
|
|
751
|
+
return _COMMAND_API.get(name, name)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def record_permission_state(api: str, state: str) -> None:
|
|
755
|
+
"""Remember what a real call proved about one Android permission.
|
|
756
|
+
|
|
757
|
+
Only an observed call may call this: asking Android up front would mean
|
|
758
|
+
opening the camera or the microphone to read a state, which is the side
|
|
759
|
+
effect this module refuses to have. The cached capabilities block is dropped
|
|
760
|
+
with it, so the next reader sees the new fact instead of a stale "unknown".
|
|
761
|
+
"""
|
|
762
|
+
name = _api_name(api)
|
|
763
|
+
wanted = str(state or "").strip().lower()
|
|
764
|
+
if not name or wanted not in ("granted", "denied", "unknown"):
|
|
765
|
+
return
|
|
766
|
+
with _PERMISSION_LOCK:
|
|
767
|
+
_PERMISSIONS[name] = wanted
|
|
768
|
+
with _CACHE_LOCK:
|
|
769
|
+
_CACHE.pop("capabilities", None)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _diagnose(text: str, command: str = "") -> Dict[str, Any]:
|
|
773
|
+
"""Map a failing command's output to ``denied``/``fix`` and remember it.
|
|
774
|
+
|
|
775
|
+
All of the "Android said no" vocabulary lives in this one function, so a new
|
|
776
|
+
permission-backed command only has to be added to ``_COMMAND_API`` to be
|
|
777
|
+
explained properly. An empty dict means "no permission story here" and lets
|
|
778
|
+
the caller report the raw text unchanged.
|
|
779
|
+
"""
|
|
780
|
+
lowered = (text or "").lower()
|
|
781
|
+
if not lowered:
|
|
782
|
+
return {}
|
|
783
|
+
denied = any(phrase in lowered for phrase in _PERMISSION_PHRASES)
|
|
784
|
+
if not denied and "camera" in lowered:
|
|
785
|
+
denied = any(word in lowered for word in _PERMISSION_CONTEXT)
|
|
786
|
+
if not denied and any(word in lowered for word in ("microphone", "audio record", "recording")):
|
|
787
|
+
denied = any(word in lowered for word in _PERMISSION_CONTEXT)
|
|
788
|
+
if not denied:
|
|
789
|
+
return {}
|
|
790
|
+
# The text is the more specific evidence, so it is read first: a command that
|
|
791
|
+
# names a permission in its own message is believed over the command map.
|
|
792
|
+
api = ""
|
|
793
|
+
if "camera" in lowered or "/dev/video" in lowered:
|
|
794
|
+
api = "camera"
|
|
795
|
+
elif any(word in lowered for word in ("microphone", "audio record", "recording")):
|
|
796
|
+
api = "microphone"
|
|
797
|
+
elif "location" in lowered:
|
|
798
|
+
api = "location"
|
|
799
|
+
if not api:
|
|
800
|
+
api = _COMMAND_API.get(str(command or ""), "")
|
|
801
|
+
fields: Dict[str, Any] = {"denied": True, "fix": _permission_fix(api or "requested")}
|
|
802
|
+
if api:
|
|
803
|
+
record_permission_state(api, "denied")
|
|
804
|
+
return fields
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
# ---------------------------------------------------------------------------
|
|
808
|
+
# Wake lock
|
|
809
|
+
# ---------------------------------------------------------------------------
|
|
810
|
+
|
|
811
|
+
def _wake_report(command: str, available: bool, reason: str = "",
|
|
812
|
+
**fields: Any) -> Dict[str, Any]:
|
|
813
|
+
report: Dict[str, Any] = {
|
|
814
|
+
"available": bool(available),
|
|
815
|
+
# ``held`` is None while the command is missing: the honest answer is
|
|
816
|
+
# "unknown", because a phone without the API cannot tell us either way.
|
|
817
|
+
"held": (bool(_WAKE_COUNT > 0) if available else None),
|
|
818
|
+
"count": int(_WAKE_COUNT),
|
|
819
|
+
"command": command,
|
|
820
|
+
}
|
|
821
|
+
if reason:
|
|
822
|
+
report["reason"] = reason
|
|
823
|
+
report.update(fields)
|
|
824
|
+
return report
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def wake_lock(action: str = "status") -> Dict[str, Any]:
|
|
828
|
+
"""Take, drop or report the Android wake lock, reference counted in process.
|
|
829
|
+
|
|
830
|
+
Android freezes a background process once the screen goes off, and the node's
|
|
831
|
+
install-time boot script takes the lock exactly once with nobody left to
|
|
832
|
+
release it. Counting the lock here means the system command runs only when the
|
|
833
|
+
lock really changes state - once on the way up (0 -> 1) and once on the way
|
|
834
|
+
down (1 -> 0) - so ``acquire`` twice and ``release`` twice still touch Android
|
|
835
|
+
once each, and the count can never go below zero. There is no "is a lock
|
|
836
|
+
held?" command in Termux:API, so ``held`` describes *this process's* count -
|
|
837
|
+
the only truth available without root.
|
|
838
|
+
"""
|
|
839
|
+
global _WAKE_COUNT
|
|
840
|
+
wanted = str(action or "status").strip().lower()
|
|
841
|
+
if wanted not in _WAKE_ACTIONS and wanted != "status":
|
|
842
|
+
with _WAKE_LOCK:
|
|
843
|
+
count = _WAKE_COUNT
|
|
844
|
+
return {"available": False, "held": None, "count": int(count), "command": "",
|
|
845
|
+
"reason": "refused: %r is not a wake-lock action" % (str(action),)}
|
|
846
|
+
|
|
847
|
+
with _WAKE_LOCK:
|
|
848
|
+
if wanted == "status":
|
|
849
|
+
# No status command exists, so nothing may be executed here - running
|
|
850
|
+
# the lock command to "check" would change the state it reports.
|
|
851
|
+
return _wake_report(_ACQUIRE, _command_exists(_ACQUIRE))
|
|
852
|
+
|
|
853
|
+
command = _WAKE_ACTIONS[wanted]
|
|
854
|
+
if wanted == "acquire" and _WAKE_COUNT > 0:
|
|
855
|
+
# Already held by this process: Android counts the lock, not the calls,
|
|
856
|
+
# so a second acquire only moves our own counter.
|
|
857
|
+
_WAKE_COUNT += 1
|
|
858
|
+
return _wake_report(command, True)
|
|
859
|
+
if wanted == "release":
|
|
860
|
+
if _WAKE_COUNT == 0:
|
|
861
|
+
# Nothing of ours is held: unlocking now would drop a lock some
|
|
862
|
+
# other app took, and the ref-count is the only thing that can know.
|
|
863
|
+
return _wake_report(command, _command_exists(command))
|
|
864
|
+
if _WAKE_COUNT > 1:
|
|
865
|
+
# Other holders remain, so the system lock must stay taken.
|
|
866
|
+
_WAKE_COUNT -= 1
|
|
867
|
+
return _wake_report(command, True)
|
|
868
|
+
|
|
869
|
+
if not _command_exists(command):
|
|
870
|
+
return _wake_report(command, False,
|
|
871
|
+
"the %s command is not installed" % command,
|
|
872
|
+
missing=True, fix=TERMUX_API_FIX)
|
|
873
|
+
|
|
874
|
+
try:
|
|
875
|
+
rc, text = _run([command], WAKE_LOCK_TIMEOUT)
|
|
876
|
+
except subprocess.TimeoutExpired as exc:
|
|
877
|
+
seconds = _seconds(getattr(exc, "timeout", None)) or WAKE_LOCK_TIMEOUT
|
|
878
|
+
return _wake_report(command, True,
|
|
879
|
+
"%s timed out after %gs" % (command, seconds),
|
|
880
|
+
timeout=True, fix=TERMUX_TIMEOUT_FIX)
|
|
881
|
+
except Exception as exc:
|
|
882
|
+
return _wake_report(command, True, "%s could not be run: %s" % (command, exc))
|
|
883
|
+
|
|
884
|
+
if rc != 0:
|
|
885
|
+
fields = _diagnose(text, command)
|
|
886
|
+
reason = (text or "").strip() or "%s exited with %d" % (command, rc)
|
|
887
|
+
return _wake_report(command, True, reason, **fields)
|
|
888
|
+
|
|
889
|
+
if wanted == "acquire":
|
|
890
|
+
_WAKE_COUNT += 1
|
|
891
|
+
else:
|
|
892
|
+
# Only a command that really ran gives the count back; a failed unlock
|
|
893
|
+
# leaves the lock held, which is what the phone still believes.
|
|
894
|
+
_WAKE_COUNT = max(0, _WAKE_COUNT - 1)
|
|
895
|
+
return _wake_report(command, True)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
# ---------------------------------------------------------------------------
|
|
899
|
+
# Test seam
|
|
900
|
+
# ---------------------------------------------------------------------------
|
|
901
|
+
|
|
902
|
+
def reset_state() -> None:
|
|
903
|
+
"""Drop every in-process cache: the inventory, capabilities, the lock count,
|
|
904
|
+
the permission map and the app evidence, as if this module had just been
|
|
905
|
+
imported."""
|
|
906
|
+
global _WAKE_COUNT, _APP_SUCCESS, _APP_TIMEOUT_AT, _APP_TIMEOUT_COMMAND
|
|
907
|
+
with _CACHE_LOCK:
|
|
908
|
+
_CACHE.clear()
|
|
909
|
+
_APP_SUCCESS = False
|
|
910
|
+
_APP_TIMEOUT_COMMAND = ""
|
|
911
|
+
_APP_TIMEOUT_AT = 0.0
|
|
912
|
+
with _PERMISSION_LOCK:
|
|
913
|
+
_PERMISSIONS.clear()
|
|
914
|
+
_PERMISSIONS.update({name: "unknown" for name in PERMISSION_APIS})
|
|
915
|
+
with _WAKE_LOCK:
|
|
916
|
+
_WAKE_COUNT = 0
|