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.
@@ -0,0 +1,3 @@
1
+ """nexus-android : computer use du téléphone Android de l'utilisateur, partagé par ses agents."""
2
+
3
+ __version__ = "0.4.1"
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
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