nexus-android 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nexus_android/__init__.py +3 -0
- nexus_android/__main__.py +3 -0
- nexus_android/adb.py +323 -0
- nexus_android/awake.py +128 -0
- nexus_android/clip.py +152 -0
- nexus_android/extras.py +320 -0
- nexus_android/garde-fous.json +109 -0
- nexus_android/lock.py +83 -0
- nexus_android/messages.py +254 -0
- nexus_android/policy.py +153 -0
- nexus_android/py.typed +0 -0
- nexus_android/screen.py +287 -0
- nexus_android/server.py +1107 -0
- nexus_android-0.4.1.dist-info/METADATA +266 -0
- nexus_android-0.4.1.dist-info/RECORD +17 -0
- nexus_android-0.4.1.dist-info/WHEEL +4 -0
- nexus_android-0.4.1.dist-info/entry_points.txt +3 -0
nexus_android/adb.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Liaison ADB avec le téléphone : choix de l'appareil (USB d'abord, puis Wi-Fi jumelé), commandes shell, captures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PhoneError(RuntimeError):
|
|
18
|
+
"""Erreur à rapporter telle quelle à l'agent (message en français, sans pile)."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LinkError(PhoneError):
|
|
22
|
+
"""Liaison perdue en cours de commande (délai dépassé, téléphone parti) : la commande a pu s'exécuter ou non.
|
|
23
|
+
Un geste ainsi interrompu a un effet inconnu ; il se relit avant d'être refait, jamais répété à l'aveugle."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Erreurs d'adb sur l'appareil lui-même, pas sur la commande (« sh: foo: not found » n'en est pas une).
|
|
27
|
+
_LINK_LOST = re.compile(r"^(?:adb(?:\.exe)?: )?(?:error: )?(?:device .*not found|device offline|no devices|closed|"
|
|
28
|
+
r"protocol fault)", re.I | re.M)
|
|
29
|
+
|
|
30
|
+
def _find_adb() -> str:
|
|
31
|
+
explicit = os.environ.get("NEXUS_ANDROID_ADB")
|
|
32
|
+
if explicit and Path(explicit).is_file():
|
|
33
|
+
return explicit
|
|
34
|
+
found = shutil.which("adb")
|
|
35
|
+
if found:
|
|
36
|
+
return found
|
|
37
|
+
winget = Path(os.environ.get("LOCALAPPDATA", "")) / "Microsoft" / "WinGet" / "Packages"
|
|
38
|
+
for candidate in winget.glob("Google.PlatformTools_*/platform-tools/adb.exe"):
|
|
39
|
+
return str(candidate)
|
|
40
|
+
raise PhoneError("adb introuvable : installer les Platform Tools (winget install Google.PlatformTools).")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Device:
|
|
45
|
+
serial: str
|
|
46
|
+
state: str
|
|
47
|
+
model: str
|
|
48
|
+
transport: str # "usb" ou "wifi"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_devices(output: str) -> list[Device]:
|
|
52
|
+
devices = []
|
|
53
|
+
for line in output.splitlines()[1:]:
|
|
54
|
+
parts = line.split()
|
|
55
|
+
if len(parts) < 2:
|
|
56
|
+
continue
|
|
57
|
+
serial, state = parts[0], parts[1]
|
|
58
|
+
model = next((p.split(":", 1)[1] for p in parts[2:] if p.startswith("model:")), "")
|
|
59
|
+
wifi = ":" in serial or "._adb-tls-connect." in serial
|
|
60
|
+
devices.append(Device(serial, state, model.replace("_", "-"), "wifi" if wifi else "usb"))
|
|
61
|
+
return devices
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_mdns(output: str) -> list[str]:
|
|
65
|
+
"""Adresses ip:port des téléphones jumelés annoncés sur le Wi-Fi (débogage sans fil)."""
|
|
66
|
+
return re.findall(r"_adb-tls-connect\._tcp\.?\s+(\d+\.\d+\.\d+\.\d+:\d+)", output)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_adbd_port(proc_net: str) -> int | None:
|
|
70
|
+
"""Port TCP en écoute d'adbd (utilisateur shell, uid 2000) dans /proc/net/tcp6 et /proc/net/tcp."""
|
|
71
|
+
for line in proc_net.splitlines():
|
|
72
|
+
fields = line.split()
|
|
73
|
+
if len(fields) > 7 and fields[3] == "0A" and fields[7] == "2000":
|
|
74
|
+
port = int(fields[1].rsplit(":", 1)[1], 16)
|
|
75
|
+
if port != 5037:
|
|
76
|
+
return port
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
WIFI_STATE = Path.home() / ".nexus" / "nexus-android" / "wifi.json"
|
|
81
|
+
TLS_PORTS = range(30000, 50000) # plage des ports aléatoires du débogage sans fil
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def open_ports(host: str, ports: range = TLS_PORTS, timeout: float = 0.4) -> list[int]:
|
|
85
|
+
"""Ports TCP ouverts sur le téléphone : le port du débogage sans fil change quand adbd redémarre (câble
|
|
86
|
+
retiré, veille), et One UI ne l'annonce pas en mDNS."""
|
|
87
|
+
|
|
88
|
+
async def scan() -> list[int]:
|
|
89
|
+
gate = asyncio.Semaphore(800)
|
|
90
|
+
found: list[int] = []
|
|
91
|
+
|
|
92
|
+
async def probe(port: int) -> None:
|
|
93
|
+
async with gate:
|
|
94
|
+
try:
|
|
95
|
+
_, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout)
|
|
96
|
+
found.append(port)
|
|
97
|
+
writer.close()
|
|
98
|
+
except (OSError, TimeoutError):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
await asyncio.gather(*(probe(p) for p in ports))
|
|
102
|
+
return sorted(found)
|
|
103
|
+
|
|
104
|
+
return asyncio.run(scan())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Adb:
|
|
108
|
+
def __init__(self) -> None:
|
|
109
|
+
self.path = _find_adb()
|
|
110
|
+
self.forced: str | None = os.environ.get("NEXUS_ANDROID_SERIAL") or None # appareil imposé à l'installation
|
|
111
|
+
self.serial: str | None = self.forced
|
|
112
|
+
self._ids: dict[str, str] = {} # numéro adb → ro.serialno (identité réelle de l'appareil)
|
|
113
|
+
self.rebooted = False # le téléphone a redémarré depuis le dernier contact (à dire une fois, puis False)
|
|
114
|
+
|
|
115
|
+
def _raw(self, args: list[str], timeout: float = 30.0, serial: bool = True) -> subprocess.CompletedProcess[bytes]:
|
|
116
|
+
cmd = [self.path]
|
|
117
|
+
if serial and self.serial:
|
|
118
|
+
cmd += ["-s", self.serial]
|
|
119
|
+
try:
|
|
120
|
+
return subprocess.run(cmd + args, capture_output=True, timeout=timeout,
|
|
121
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
122
|
+
except subprocess.TimeoutExpired as e:
|
|
123
|
+
raise LinkError(f"adb {' '.join(args[:2])} : pas de réponse après {timeout:.0f} s.") from e
|
|
124
|
+
|
|
125
|
+
def devices(self) -> list[Device]:
|
|
126
|
+
return parse_devices(self._raw(["devices", "-l"], serial=False).stdout.decode("utf-8", "replace"))
|
|
127
|
+
|
|
128
|
+
def _online(self) -> list[Device]:
|
|
129
|
+
"""Appareils prêts, émulateurs exclus (jamais pris pour le téléphone de l'utilisateur)."""
|
|
130
|
+
return [d for d in self.devices() if d.state == "device" and not d.serial.startswith("emulator-")]
|
|
131
|
+
|
|
132
|
+
def _remembered(self) -> dict:
|
|
133
|
+
try:
|
|
134
|
+
return json.loads(WIFI_STATE.read_text(encoding="utf-8"))
|
|
135
|
+
except (OSError, ValueError):
|
|
136
|
+
return {}
|
|
137
|
+
|
|
138
|
+
def _save(self, state: dict) -> None:
|
|
139
|
+
WIFI_STATE.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
WIFI_STATE.write_text(json.dumps(state), encoding="utf-8")
|
|
141
|
+
|
|
142
|
+
def identity(self, serial: str) -> str:
|
|
143
|
+
"""Numéro de série matériel (ro.serialno) de l'appareil joint sous ce numéro adb ; "" s'il ne répond pas."""
|
|
144
|
+
if serial not in self._ids:
|
|
145
|
+
try:
|
|
146
|
+
out = self._raw(["-s", serial, "shell", "getprop", "ro.serialno"], timeout=8, serial=False)
|
|
147
|
+
except PhoneError:
|
|
148
|
+
return ""
|
|
149
|
+
ident = out.stdout.decode("utf-8", "replace").strip() if not out.returncode else ""
|
|
150
|
+
if not ident:
|
|
151
|
+
return ""
|
|
152
|
+
self._ids[serial] = ident
|
|
153
|
+
return self._ids[serial]
|
|
154
|
+
|
|
155
|
+
def _remember_wifi(self, serial: str, ident: str) -> None:
|
|
156
|
+
"""Branché en USB : note l'identité du téléphone, son adresse Wi-Fi et le port TLS d'adbd, que le téléphone
|
|
157
|
+
(One UI) n'annonce pas en mDNS. Le port ne change qu'au redémarrage du débogage sans fil (reboot, autre
|
|
158
|
+
réseau). Appelé seulement pour le téléphone de l'utilisateur, identité vérifiée."""
|
|
159
|
+
run = lambda *a: self._raw(["-s", serial, "shell", *a], serial=False).stdout.decode("utf-8", "replace") # noqa: E731
|
|
160
|
+
before = self._remembered()
|
|
161
|
+
state = {**before, "serialno": ident, "usb": serial}
|
|
162
|
+
ip = re.search(r"inet (\d+\.\d+\.\d+\.\d+)/", run("ip", "-4", "addr", "show", "wlan0"))
|
|
163
|
+
port = parse_adbd_port(run("cat", "/proc/net/tcp6", "/proc/net/tcp"))
|
|
164
|
+
if ip and port:
|
|
165
|
+
state["address"] = f"{ip.group(1)}:{port}"
|
|
166
|
+
if state != before:
|
|
167
|
+
self._save(state)
|
|
168
|
+
|
|
169
|
+
def _try_wifi(self, address: str, known: str) -> bool:
|
|
170
|
+
"""Se connecte à une adresse Wi-Fi ; garde la liaison seulement si c'est le téléphone de l'utilisateur."""
|
|
171
|
+
try:
|
|
172
|
+
out = self._raw(["connect", address], timeout=8, serial=False).stdout
|
|
173
|
+
except PhoneError: # adresse périmée (port changé) : ne pas empêcher d'essayer les suivantes
|
|
174
|
+
self._raw(["disconnect", address], timeout=5, serial=False)
|
|
175
|
+
return False
|
|
176
|
+
if b"connected to" not in out:
|
|
177
|
+
return False
|
|
178
|
+
if self.identity(address) == known:
|
|
179
|
+
return True
|
|
180
|
+
self._raw(["disconnect", address], timeout=5, serial=False) # autre appareil du réseau (box TV…)
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
def connect(self) -> Device:
|
|
184
|
+
"""Choisit le téléphone de l'utilisateur : celui imposé, sinon USB, sinon Wi-Fi ; tente les téléphones jumelés du
|
|
185
|
+
Wi-Fi (annonce mDNS, puis dernière adresse notée lors d'un branchement USB). Seul l'appareil dont le numéro
|
|
186
|
+
de série matériel est celui noté au premier branchement USB est accepté ; émulateurs et autres appareils
|
|
187
|
+
du réseau sont écartés, et wifi.json n'est jamais réécrit avec un autre appareil."""
|
|
188
|
+
if self.forced:
|
|
189
|
+
return self._connect_forced(self.forced)
|
|
190
|
+
remembered = self._remembered()
|
|
191
|
+
known = remembered.get("serialno") or remembered.get("usb") or "" # "usb" : fichier d'avant l'identité
|
|
192
|
+
mine = self._mine(self._online(), known)
|
|
193
|
+
if not mine and any(d.state == "offline" for d in self.devices()):
|
|
194
|
+
# Liaison Wi-Fi endormie (« device offline », vu sur le Fold) : la relancer avant de chercher ailleurs.
|
|
195
|
+
self._raw(["reconnect", "offline"], timeout=10, serial=False)
|
|
196
|
+
for _ in range(10):
|
|
197
|
+
time.sleep(0.5)
|
|
198
|
+
if mine := self._mine(self._online(), known):
|
|
199
|
+
break
|
|
200
|
+
if not mine and known:
|
|
201
|
+
mdns =self._raw(["mdns", "services"], timeout=10, serial=False).stdout.decode("utf-8", "replace")
|
|
202
|
+
for address in dict.fromkeys(parse_mdns(mdns) + ([remembered["address"]] if remembered.get("address") else [])):
|
|
203
|
+
if self._try_wifi(address, known):
|
|
204
|
+
break
|
|
205
|
+
mine = self._mine(self._online(), known)
|
|
206
|
+
if not mine and remembered.get("address"):
|
|
207
|
+
host = remembered["address"].rsplit(":", 1)[0]
|
|
208
|
+
for port in open_ports(host):
|
|
209
|
+
if self._try_wifi(f"{host}:{port}", known):
|
|
210
|
+
break
|
|
211
|
+
mine = self._mine(self._online(), known)
|
|
212
|
+
if not mine:
|
|
213
|
+
self.serial = None
|
|
214
|
+
raise PhoneError(self._missing(known))
|
|
215
|
+
chosen = sorted(mine, key=lambda d: d.transport != "usb")[0]
|
|
216
|
+
ident = self.identity(chosen.serial)
|
|
217
|
+
if chosen.transport == "usb":
|
|
218
|
+
try:
|
|
219
|
+
self._remember_wifi(chosen.serial, ident)
|
|
220
|
+
except (OSError, PhoneError):
|
|
221
|
+
pass
|
|
222
|
+
elif re.fullmatch(r"\d+\.\d+\.\d+\.\d+:\d+", chosen.serial) and chosen.serial != remembered.get("address"):
|
|
223
|
+
self._save({**remembered, "serialno": ident, "address": chosen.serial}) # port du débogage sans fil changé
|
|
224
|
+
self.serial = chosen.serial
|
|
225
|
+
self._note_boot()
|
|
226
|
+
return chosen
|
|
227
|
+
|
|
228
|
+
def _note_boot(self) -> None:
|
|
229
|
+
"""Redémarrage du téléphone depuis le dernier contact de ce PC : signalé une fois (rebooted)."""
|
|
230
|
+
try:
|
|
231
|
+
boot = self.boot_id()
|
|
232
|
+
except PhoneError:
|
|
233
|
+
return
|
|
234
|
+
state = self._remembered()
|
|
235
|
+
if not re.fullmatch(r"[0-9a-f-]{36}", boot) or boot == state.get("boot_id"):
|
|
236
|
+
return
|
|
237
|
+
self.rebooted = bool(state.get("boot_id"))
|
|
238
|
+
self._save({**state, "boot_id": boot})
|
|
239
|
+
|
|
240
|
+
def _mine(self, devices: list[Device], known: str) -> list[Device]:
|
|
241
|
+
if known:
|
|
242
|
+
return [d for d in devices if self.identity(d.serial) == known]
|
|
243
|
+
# Jamais branché : le téléphone en USB devient celui de l'utilisateur (le câble est un geste volontaire).
|
|
244
|
+
usb = [d for d in devices if d.transport == "usb" and self.identity(d.serial)]
|
|
245
|
+
if len(usb) > 1:
|
|
246
|
+
raise PhoneError("Plusieurs appareils en USB (" + ", ".join(d.model or d.serial for d in usb) + ") et aucun "
|
|
247
|
+
"téléphone encore enregistré : ne laisser branché que le téléphone de l'utilisateur.")
|
|
248
|
+
return usb
|
|
249
|
+
|
|
250
|
+
def _missing(self, known: str) -> str:
|
|
251
|
+
visible = self.devices()
|
|
252
|
+
if any(d.state == "unauthorized" for d in visible):
|
|
253
|
+
return "Téléphone branché mais non autorisé : accepter « Autoriser le débogage » sur l'écran."
|
|
254
|
+
others = [d.model or d.serial for d in visible if d.state == "device"]
|
|
255
|
+
ignored = f" Appareils visibles écartés (pas le téléphone de l'utilisateur) : {', '.join(others)}." if others else ""
|
|
256
|
+
if not known:
|
|
257
|
+
return ("Téléphone de l'utilisateur pas encore enregistré sur ce PC : le brancher une fois en USB (débogage USB "
|
|
258
|
+
"autorisé) ; il sera ensuite retrouvé aussi en Wi-Fi." + ignored)
|
|
259
|
+
return (f"Téléphone de l'utilisateur (n° de série {known}) introuvable : brancher le câble USB, ou activer le débogage "
|
|
260
|
+
"sans fil (téléphone jumelé à ce PC et sur le même Wi-Fi). Après un redémarrage du téléphone ou un "
|
|
261
|
+
"changement de réseau, Android coupe le débogage sans fil : le rallumer (Options de développement) "
|
|
262
|
+
"ou brancher le câble une fois." + ignored)
|
|
263
|
+
|
|
264
|
+
def _connect_forced(self, serial: str) -> Device:
|
|
265
|
+
"""NEXUS_ANDROID_SERIAL : appareil choisi explicitement à l'installation, pris tel quel."""
|
|
266
|
+
devices = [d for d in self.devices() if d.state == "device"]
|
|
267
|
+
if ":" in serial and all(d.serial != serial for d in devices):
|
|
268
|
+
self._raw(["connect", serial], timeout=8, serial=False) # adresse Wi-Fi imposée
|
|
269
|
+
devices = [d for d in self.devices() if d.state == "device"]
|
|
270
|
+
chosen = next((d for d in devices if d.serial == serial), None)
|
|
271
|
+
if not chosen:
|
|
272
|
+
raise PhoneError(f"Appareil imposé par NEXUS_ANDROID_SERIAL ({serial}) injoignable.")
|
|
273
|
+
self.serial = serial
|
|
274
|
+
return chosen
|
|
275
|
+
|
|
276
|
+
def run(self, *argv: str, timeout: float = 30.0) -> tuple[int, str, str]:
|
|
277
|
+
"""Commande shell Android : (code, sortie, erreurs). Chaque argument est protégé (texte libre, accents)."""
|
|
278
|
+
if not self.serial:
|
|
279
|
+
self.connect()
|
|
280
|
+
command = " ".join(shlex.quote(a) for a in argv)
|
|
281
|
+
result = self._raw(["shell", command], timeout=timeout)
|
|
282
|
+
out = result.stdout.decode("utf-8", "replace").replace("\r\n", "\n")
|
|
283
|
+
err = result.stderr.decode("utf-8", "replace").strip()
|
|
284
|
+
if result.returncode and _LINK_LOST.search(err):
|
|
285
|
+
self.serial = self.forced # téléphone perdu : le rechercher au prochain appel
|
|
286
|
+
raise LinkError(f"liaison perdue avec le téléphone ({err.splitlines()[0][:120]})")
|
|
287
|
+
return result.returncode, out, err
|
|
288
|
+
|
|
289
|
+
def host(self, *args: str, timeout: float = 60.0) -> str:
|
|
290
|
+
"""Commande adb côté PC sur le téléphone choisi (push, pull, forward) ; erreur → PhoneError."""
|
|
291
|
+
if not self.serial:
|
|
292
|
+
self.connect()
|
|
293
|
+
result = self._raw(list(args), timeout=timeout)
|
|
294
|
+
out = (result.stdout + result.stderr).decode("utf-8", "replace").strip()
|
|
295
|
+
if result.returncode:
|
|
296
|
+
if _LINK_LOST.search(out):
|
|
297
|
+
self.serial = self.forced
|
|
298
|
+
raise LinkError(f"liaison perdue avec le téléphone ({out.splitlines()[0][:120]})")
|
|
299
|
+
raise PhoneError(f"adb {args[0]} : {out[:300]}")
|
|
300
|
+
return out
|
|
301
|
+
|
|
302
|
+
def boot_id(self) -> str:
|
|
303
|
+
"""Identifiant du démarrage en cours du téléphone (change à chaque redémarrage)."""
|
|
304
|
+
return self.shell("cat", "/proc/sys/kernel/random/boot_id", check=False).strip()
|
|
305
|
+
|
|
306
|
+
def shell(self, *argv: str, timeout: float = 30.0, check: bool = True) -> str:
|
|
307
|
+
"""Commande shell Android ; chaque argument est protégé (texte libre, accents, retours à la ligne)."""
|
|
308
|
+
code, out, err = self.run(*argv, timeout=timeout)
|
|
309
|
+
if code and check:
|
|
310
|
+
raise PhoneError(f"{argv[0]} : {(err or out).strip()[:300]}")
|
|
311
|
+
return out
|
|
312
|
+
|
|
313
|
+
def exec_out(self, *argv: str, timeout: float = 30.0) -> bytes:
|
|
314
|
+
if not self.serial:
|
|
315
|
+
self.connect()
|
|
316
|
+
result = self._raw(["exec-out", " ".join(shlex.quote(a) for a in argv)], timeout=timeout)
|
|
317
|
+
if result.returncode:
|
|
318
|
+
err = result.stderr.decode("utf-8", "replace").strip()
|
|
319
|
+
if _LINK_LOST.search(err):
|
|
320
|
+
self.serial = self.forced
|
|
321
|
+
raise LinkError(f"liaison perdue avec le téléphone ({err.splitlines()[0][:120]})")
|
|
322
|
+
raise PhoneError(err[:300] or "exec-out a échoué")
|
|
323
|
+
return result.stdout
|
nexus_android/awake.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Écran gardé allumé pendant le pilotage : au premier geste sur un téléphone déverrouillé, la mise en veille
|
|
2
|
+
(screen_off_timeout) est montée à 30 min ; après IDLE secondes sans geste d'aucun agent, la valeur d'origine est
|
|
3
|
+
remise et l'écran éteint (ce qui reverrouille). Jamais de déverrouillage : c'est toujours l'utilisateur qui le fait.
|
|
4
|
+
|
|
5
|
+
L'état est commun aux serveurs des trois agents (AWAKE_STATE, hors des sources) et ne se lit ni ne s'écrit que sous
|
|
6
|
+
PhoneLock : une seule sauvegarde de la valeur d'origine, une seule restauration. Après un plantage, le prochain serveur
|
|
7
|
+
qui démarre restaure la valeur d'origine dès que la session a expiré."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .adb import Adb, PhoneError
|
|
18
|
+
|
|
19
|
+
AWAKE_STATE = Path.home() / ".nexus" / "nexus-android" / "awake.json"
|
|
20
|
+
RAISED = 30 * 60 * 1000 # ms : écran gardé allumé pendant le travail de l'agent
|
|
21
|
+
IDLE = float(os.environ.get("NEXUS_ANDROID_AWAKE_IDLE") or 120) # secondes sans geste avant de rendre la main
|
|
22
|
+
ENABLED = os.environ.get("NEXUS_ANDROID_KEEP_AWAKE", "1") != "0"
|
|
23
|
+
OWNER_MARGIN = 5.0 # s : activité plus récente que le dernier geste de l'agent = l'utilisateur utilise le téléphone
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load(path: Path) -> dict:
|
|
27
|
+
try:
|
|
28
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
29
|
+
except (OSError, ValueError):
|
|
30
|
+
return {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _save(path: Path, state: dict) -> None:
|
|
34
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
tmp = path.with_suffix(".tmp")
|
|
36
|
+
tmp.write_text(json.dumps(state), encoding="utf-8")
|
|
37
|
+
os.replace(tmp, path)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _timeout(adb: Adb) -> int | None:
|
|
41
|
+
value = adb.shell("settings", "get", "system", "screen_off_timeout", check=False).strip()
|
|
42
|
+
return int(value) if value.isdigit() else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def idle_on_phone(adb: Adb) -> float | None:
|
|
46
|
+
"""Secondes depuis la dernière activité sur le téléphone (toucher de l'utilisateur ou geste injecté), d'après dumpsys."""
|
|
47
|
+
match = re.search(r"^\s*lastUserActivityTime=\d+ \((\d+) ms ago\)", adb.shell("dumpsys", "power", check=False), re.M)
|
|
48
|
+
return int(match.group(1)) / 1000 if match else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class KeepAwake:
|
|
52
|
+
"""À appeler sous PhoneLock (état partagé entre processus)."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, adb: Adb, path: Path | None = None, idle: float | None = None,
|
|
55
|
+
clock=time.time) -> None:
|
|
56
|
+
self.adb = adb
|
|
57
|
+
self.path = path or AWAKE_STATE
|
|
58
|
+
self.idle = IDLE if idle is None else idle
|
|
59
|
+
self.clock = clock
|
|
60
|
+
|
|
61
|
+
def state(self) -> dict:
|
|
62
|
+
return _load(self.path)
|
|
63
|
+
|
|
64
|
+
def active_until(self) -> float | None:
|
|
65
|
+
"""Heure (epoch) à laquelle l'écran sera rendu, si une session est en cours."""
|
|
66
|
+
state = self.state()
|
|
67
|
+
return state["last"] + self.idle if state.get("active") else None
|
|
68
|
+
|
|
69
|
+
def _phone(self) -> str:
|
|
70
|
+
serial = getattr(self.adb, "serial", None)
|
|
71
|
+
return self.adb.identity(serial) if serial else ""
|
|
72
|
+
|
|
73
|
+
def touch(self) -> None:
|
|
74
|
+
"""Geste sur un téléphone déverrouillé : ouvre la session (sauvegarde puis montée) ou la prolonge."""
|
|
75
|
+
state = self.state()
|
|
76
|
+
now = self.clock()
|
|
77
|
+
if state.get("active"):
|
|
78
|
+
if _timeout(self.adb) != RAISED: # remise à zéro entre-temps (utilisateur, redémarrage) : la remonter
|
|
79
|
+
self.adb.shell("settings", "put", "system", "screen_off_timeout", str(RAISED))
|
|
80
|
+
_save(self.path, {**state, "last": now, "pid": os.getpid()})
|
|
81
|
+
return
|
|
82
|
+
current = _timeout(self.adb)
|
|
83
|
+
if current is None:
|
|
84
|
+
raise PhoneError("mise en veille illisible (settings) : écran non gardé allumé")
|
|
85
|
+
# Jamais la valeur montée prise pour l'origine (session perdue) : dernière origine connue, sinon 30 s.
|
|
86
|
+
original = current if current != RAISED else int(state.get("original") or 30000)
|
|
87
|
+
_save(self.path, {"active": True, "original": original, "phone": self._phone(), "start": now, "last": now,
|
|
88
|
+
"pid": os.getpid()})
|
|
89
|
+
self.adb.shell("settings", "put", "system", "screen_off_timeout", str(RAISED))
|
|
90
|
+
|
|
91
|
+
def extend(self) -> None:
|
|
92
|
+
"""Lecture de l'écran (look) pendant une session : la prolonge sans en ouvrir une."""
|
|
93
|
+
state = self.state()
|
|
94
|
+
if state.get("active"):
|
|
95
|
+
_save(self.path, {**state, "last": self.clock()})
|
|
96
|
+
|
|
97
|
+
def due(self) -> float | None:
|
|
98
|
+
"""Secondes avant la restauration (0 : à faire maintenant) ; None : aucune session."""
|
|
99
|
+
until = self.active_until()
|
|
100
|
+
return None if until is None else max(0.0, until - self.clock())
|
|
101
|
+
|
|
102
|
+
def restore(self, force: bool = False) -> str | None:
|
|
103
|
+
"""Session expirée (ou force) : remet la valeur d'origine puis éteint l'écran, sauf si l'utilisateur utilise le
|
|
104
|
+
téléphone. Renvoie ce qui a été fait, None si rien à faire."""
|
|
105
|
+
state = self.state()
|
|
106
|
+
if not state.get("active"):
|
|
107
|
+
return None
|
|
108
|
+
idle_for = self.clock() - state["last"]
|
|
109
|
+
if not force and idle_for < self.idle:
|
|
110
|
+
return None
|
|
111
|
+
if state.get("phone") and self._phone() and state["phone"] != self._phone():
|
|
112
|
+
return None # autre appareil joint : rien à toucher sur celui-ci
|
|
113
|
+
current = _timeout(self.adb)
|
|
114
|
+
done = []
|
|
115
|
+
if current == RAISED:
|
|
116
|
+
self.adb.shell("settings", "put", "system", "screen_off_timeout", str(state["original"]))
|
|
117
|
+
done.append(f"mise en veille remise à {state['original'] // 1000} s")
|
|
118
|
+
else:
|
|
119
|
+
done.append("mise en veille déjà changée sur le téléphone, laissée telle quelle")
|
|
120
|
+
_save(self.path, {"active": False, "original": state["original"], "phone": state.get("phone", ""),
|
|
121
|
+
"restored": self.clock()})
|
|
122
|
+
phone_idle = idle_on_phone(self.adb)
|
|
123
|
+
if current == RAISED and (phone_idle is None or phone_idle >= idle_for - OWNER_MARGIN):
|
|
124
|
+
self.adb.shell("input", "keyevent", "KEYCODE_SLEEP")
|
|
125
|
+
done.append("écran éteint")
|
|
126
|
+
else:
|
|
127
|
+
done.append("écran laissé à l'utilisateur (il s'en sert)")
|
|
128
|
+
return ", ".join(done)
|
nexus_android/clip.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Saisie de tout texte (accents, emoji) par le presse-papiers : `input text` d'Android n'accepte que l'ASCII.
|
|
2
|
+
|
|
3
|
+
Le serveur de scrcpy (Genymobile, Apache 2.0) tourne le temps de la saisie sous l'identité shell, sans vidéo ni son,
|
|
4
|
+
avec son seul canal de contrôle : il lit le presse-papiers de l'utilisateur, y met le texte et le colle dans le champ actif,
|
|
5
|
+
puis remet l'ancien contenu (vide s'il était vide). Méthode vérifiée sur un Galaxy Fold.
|
|
6
|
+
Le serveur s'arrête à la fin de chaque saisie : aucun programme d'aide ne reste sur le téléphone."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
import random
|
|
13
|
+
import socket
|
|
14
|
+
import struct
|
|
15
|
+
import subprocess
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .adb import Adb, LinkError, PhoneError
|
|
20
|
+
|
|
21
|
+
VERSION = "5.0.1"
|
|
22
|
+
SHA256 = "764eb6f79811d5211fe9df341120882ba9994c7a61b897d7bf3fb662e53bc536" # digest publié par GitHub pour la v5.0.1
|
|
23
|
+
LOCAL = Path.home() / ".nexus" / "nexus-android" / "tools" / f"scrcpy-server-v{VERSION}" # posé par install.ps1
|
|
24
|
+
REMOTE = "/data/local/tmp/nexus-scrcpy.jar"
|
|
25
|
+
GET_CLIPBOARD, SET_CLIPBOARD = 8, 9 # messages de contrôle (ControlMessage.java)
|
|
26
|
+
CLIPBOARD, ACK_CLIPBOARD = 0, 1 # messages du téléphone (DeviceMessage.java)
|
|
27
|
+
MAX_BYTES = 256 * 1024 - 14 # CLIPBOARD_TEXT_MAX_LENGTH du serveur
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def set_message(sequence: int, text: str, paste: bool) -> bytes:
|
|
31
|
+
data = text.encode("utf-8")
|
|
32
|
+
return struct.pack(">BQBI", SET_CLIPBOARD, sequence, int(paste), len(data)) + data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Channel:
|
|
36
|
+
"""Canal de contrôle d'un serveur scrcpy (socket déjà connecté)."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, sock: socket.socket) -> None:
|
|
39
|
+
self.sock = sock
|
|
40
|
+
|
|
41
|
+
def _read(self, n: int) -> bytes:
|
|
42
|
+
data = b""
|
|
43
|
+
while len(data) < n:
|
|
44
|
+
chunk = self.sock.recv(n - len(data))
|
|
45
|
+
if not chunk:
|
|
46
|
+
raise PhoneError("presse-papiers : le téléphone a fermé le canal")
|
|
47
|
+
data += chunk
|
|
48
|
+
return data
|
|
49
|
+
|
|
50
|
+
def get(self, wait: float = 1.5) -> str | None:
|
|
51
|
+
"""Texte du presse-papiers ; None s'il est vide (le serveur ne répond alors rien)."""
|
|
52
|
+
self.sock.sendall(bytes([GET_CLIPBOARD, 0]))
|
|
53
|
+
self.sock.settimeout(wait)
|
|
54
|
+
try:
|
|
55
|
+
kind = self._read(1)[0]
|
|
56
|
+
except TimeoutError:
|
|
57
|
+
return None
|
|
58
|
+
finally:
|
|
59
|
+
self.sock.settimeout(5)
|
|
60
|
+
if kind != CLIPBOARD:
|
|
61
|
+
raise PhoneError(f"presse-papiers : réponse inattendue ({kind})")
|
|
62
|
+
return self._read(struct.unpack(">I", self._read(4))[0]).decode("utf-8", "replace")
|
|
63
|
+
|
|
64
|
+
def set(self, sequence: int, text: str, paste: bool) -> None:
|
|
65
|
+
"""Met le texte dans le presse-papiers (et le colle si paste) ; attend l'accusé de réception."""
|
|
66
|
+
self.sock.sendall(set_message(sequence, text, paste))
|
|
67
|
+
while True:
|
|
68
|
+
kind = self._read(1)[0]
|
|
69
|
+
if kind == ACK_CLIPBOARD and struct.unpack(">Q", self._read(8))[0] == sequence:
|
|
70
|
+
return
|
|
71
|
+
if kind == CLIPBOARD: # copie faite entre-temps sur le téléphone : lue et ignorée
|
|
72
|
+
self._read(struct.unpack(">I", self._read(4))[0])
|
|
73
|
+
continue
|
|
74
|
+
raise PhoneError(f"presse-papiers : réponse inattendue ({kind})")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _ensure_server(adb: Adb) -> None:
|
|
78
|
+
if not LOCAL.is_file() or hashlib.sha256(LOCAL.read_bytes()).hexdigest() != SHA256:
|
|
79
|
+
raise PhoneError("Saisie avec accents indisponible : serveur scrcpy absent du PC ou altéré (lancer "
|
|
80
|
+
"install.ps1). Pour un message : draft(to, text).")
|
|
81
|
+
size = adb.shell("stat", "-c", "%s", REMOTE, check=False).strip()
|
|
82
|
+
if size != str(LOCAL.stat().st_size):
|
|
83
|
+
adb.host("push", str(LOCAL), REMOTE)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def paste(adb: Adb, text: str) -> bool:
|
|
87
|
+
"""Colle `text` dans le champ qui a le focus, puis rend à l'utilisateur son presse-papiers d'avant. Renvoie False si le
|
|
88
|
+
collage est fait mais l'ancien contenu n'a pas pu être remis (liaison coupée juste après)."""
|
|
89
|
+
if len(text.encode("utf-8")) > MAX_BYTES:
|
|
90
|
+
raise PhoneError("texte trop long pour une saisie (256 Ko au plus)")
|
|
91
|
+
_ensure_server(adb)
|
|
92
|
+
scid = f"{random.randrange(1, 0x7FFFFFFF):08x}"
|
|
93
|
+
port = adb.host("forward", "tcp:0", f"localabstract:scrcpy_{scid}").strip()
|
|
94
|
+
server: subprocess.Popen[bytes] | None = None
|
|
95
|
+
sock = None
|
|
96
|
+
try:
|
|
97
|
+
if not port.isdigit():
|
|
98
|
+
raise PhoneError(f"presse-papiers : redirection impossible ({port[:100]})")
|
|
99
|
+
cmd = [adb.path, "-s", adb.serial or "", "shell",
|
|
100
|
+
f"CLASSPATH={REMOTE} app_process / com.genymobile.scrcpy.Server {VERSION} scid={scid} log_level=warn "
|
|
101
|
+
"tunnel_forward=true video=false audio=false control=true send_device_meta=false "
|
|
102
|
+
"send_dummy_byte=true clipboard_autosync=false cleanup=false"]
|
|
103
|
+
server = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
|
104
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
105
|
+
deadline = time.monotonic() + 8
|
|
106
|
+
while sock is None:
|
|
107
|
+
if server.poll() is not None:
|
|
108
|
+
err = (server.stderr.read() if server.stderr else b"").decode("utf-8", "replace").strip()
|
|
109
|
+
raise PhoneError(f"presse-papiers : serveur scrcpy arrêté ({err[:200] or 'sans message'})")
|
|
110
|
+
try:
|
|
111
|
+
candidate = socket.create_connection(("127.0.0.1", int(port)), timeout=2)
|
|
112
|
+
candidate.settimeout(2)
|
|
113
|
+
if candidate.recv(1) == b"\x00": # octet d'accueil : le serveur écoute vraiment
|
|
114
|
+
sock = candidate
|
|
115
|
+
break
|
|
116
|
+
candidate.close()
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
if time.monotonic() > deadline:
|
|
120
|
+
raise LinkError("presse-papiers : serveur scrcpy injoignable après 8 s ; rien n'a été collé")
|
|
121
|
+
time.sleep(0.1)
|
|
122
|
+
sock.settimeout(5)
|
|
123
|
+
channel = Channel(sock)
|
|
124
|
+
before = channel.get()
|
|
125
|
+
sequence = int.from_bytes(os.urandom(4)) or 1
|
|
126
|
+
try:
|
|
127
|
+
channel.set(sequence, text, paste=True)
|
|
128
|
+
except (OSError, PhoneError) as e:
|
|
129
|
+
raise LinkError(f"collage interrompu ({e}) : effet inconnu") from None
|
|
130
|
+
time.sleep(0.3) # le collage part avant la remise de l'ancien contenu
|
|
131
|
+
try:
|
|
132
|
+
channel.set(sequence + 1, before or "", paste=False)
|
|
133
|
+
except (OSError, PhoneError):
|
|
134
|
+
return False # texte collé : ne pas faire croire à un échec qui pousserait à recommencer
|
|
135
|
+
return True
|
|
136
|
+
finally:
|
|
137
|
+
if sock:
|
|
138
|
+
sock.close()
|
|
139
|
+
if server:
|
|
140
|
+
try:
|
|
141
|
+
server.wait(timeout=3)
|
|
142
|
+
except subprocess.TimeoutExpired:
|
|
143
|
+
server.kill()
|
|
144
|
+
try: # arrêter aussi le serveur côté téléphone : aucun programme d'aide ne doit y rester
|
|
145
|
+
adb.shell("pkill", "-f", f"scid=[{scid[0]}]{scid[1:]}", check=False, timeout=10) # [x] : pas soi
|
|
146
|
+
except PhoneError:
|
|
147
|
+
pass
|
|
148
|
+
if port.isdigit():
|
|
149
|
+
try:
|
|
150
|
+
adb.host("forward", "--remove", f"tcp:{port}", timeout=10)
|
|
151
|
+
except PhoneError:
|
|
152
|
+
pass
|