droidline 0.1.2__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.
droidline/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Control Android phones through a local Droidline server, without ADB.
2
+
3
+ from droidline import connect
4
+
5
+ d = connect()
6
+ d.touch("id", "com.kakao.talk:id/login")
7
+
8
+ Docs: https://droidline.dev/docs
9
+ """
10
+
11
+ from . import _generated
12
+ from ._client import Device, Droidline, LeasedDevice, connect, lease
13
+ from ._element import Element
14
+ from ._errors import ConnectionLostError, DroidlineError, ServerNotRunningError
15
+ from ._generated import * # noqa: F401,F403
16
+
17
+ __version__ = "0.1.2"
18
+
19
+ __all__ = [
20
+ "connect",
21
+ "lease",
22
+ "Droidline",
23
+ "Device",
24
+ "LeasedDevice",
25
+ "Element",
26
+ "DroidlineError",
27
+ "ServerNotRunningError",
28
+ "ConnectionLostError",
29
+ ]
30
+ __all__ += _generated.__all__
droidline/_client.py ADDED
@@ -0,0 +1,567 @@
1
+ """Connection to the Droidline PC server over its client API (spec/PROTOCOL.md section 3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import collections
7
+ import itertools
8
+ import json
9
+ import os
10
+ import queue
11
+ import socket
12
+ import threading
13
+ import time
14
+ import traceback
15
+ from typing import Any, Callable, Dict, List, Optional, Set, Union
16
+
17
+ from ._element import Element
18
+ from ._errors import ConnectionLostError, DroidlineError, ServerNotRunningError
19
+ from ._generated import ERRORS, ClientCommands, DeviceCommands, Notification
20
+
21
+ DEFAULT_HOST = "127.0.0.1"
22
+ DEFAULT_PORT = 8780
23
+
24
+ EventCallback = Callable[[Dict[str, Any]], Any]
25
+
26
+
27
+ def _error_from(resp: Dict[str, Any]) -> DroidlineError:
28
+ code = resp.get("error") or "INTERNAL"
29
+ cls = ERRORS.get(code, DroidlineError)
30
+ data = {k: v for k, v in resp.items() if k not in ("id", "ok", "error", "msg", "retryable")}
31
+ retryable = resp.get("retryable")
32
+ if not isinstance(retryable, bool):
33
+ retryable = cls.retryable
34
+ return cls(resp.get("msg") or code, code=code, retryable=retryable, data=data)
35
+
36
+
37
+ def _fields(resp: Dict[str, Any]) -> Dict[str, Any]:
38
+ return {k: v for k, v in resp.items() if k not in ("id", "ok")}
39
+
40
+
41
+ def _message(
42
+ cmd: str, device: Optional[str], required: Dict[str, Any], optional: Dict[str, Any], lease: Optional[str] = None
43
+ ) -> Dict[str, Any]:
44
+ msg: Dict[str, Any] = {"cmd": cmd}
45
+ if device is not None:
46
+ msg["device"] = device
47
+ if lease is not None:
48
+ msg["lease"] = lease
49
+ msg.update(required)
50
+ msg.update((k, v) for k, v in optional.items() if v is not None)
51
+ return msg
52
+
53
+
54
+ def _result(kind: str, resp: Dict[str, Any]) -> Any:
55
+ if kind == "value":
56
+ return resp.get("value")
57
+ if kind == "fields":
58
+ return _fields(resp)
59
+ return None
60
+
61
+
62
+ class _Slot:
63
+ __slots__ = ("done", "resp", "error")
64
+
65
+ def __init__(self) -> None:
66
+ self.done = threading.Event()
67
+ self.resp: Optional[Dict[str, Any]] = None
68
+ self.error: Optional[BaseException] = None
69
+
70
+
71
+ class _Link:
72
+ """One TCP connection and the requests still waiting for a reply on it."""
73
+
74
+ def __init__(self, sock: socket.socket) -> None:
75
+ self.sock = sock
76
+ self.alive = True
77
+ self.pending: Dict[Any, _Slot] = {}
78
+
79
+
80
+ class _Connection:
81
+ """Pipelines NDJSON requests over one socket and matches replies by id.
82
+
83
+ A reader thread per socket routes replies to waiting callers and hands events to a
84
+ separate dispatcher thread, so a slow event callback never delays a reply.
85
+ """
86
+
87
+ def __init__(self, host: str, port: int, token: Optional[str]) -> None:
88
+ self.host = host
89
+ self.port = port
90
+ self.token = token
91
+ self._lock = threading.Lock()
92
+ self._connect_lock = threading.Lock()
93
+ self._send_lock = threading.Lock()
94
+ self._ids = itertools.count(1)
95
+ self._link: Optional[_Link] = None
96
+ self._subscriptions: List[Dict[str, Any]] = []
97
+ self._listeners: Dict[str, List[EventCallback]] = {}
98
+ self._queue: Optional["queue.Queue[Optional[Dict[str, Any]]]"] = None
99
+ self.history: "collections.deque[Dict[str, Any]]" = collections.deque(maxlen=200)
100
+
101
+ def request(self, msg: Dict[str, Any]) -> Dict[str, Any]:
102
+ start = time.monotonic()
103
+ try:
104
+ resp = self._exchange(self.ensure(), msg)
105
+ except DroidlineError as e:
106
+ self._note(msg, start, e.code)
107
+ raise
108
+ self._note(msg, start, None)
109
+ if msg.get("cmd") == "subscribe":
110
+ with self._lock:
111
+ if msg not in self._subscriptions:
112
+ self._subscriptions.append(dict(msg))
113
+ elif msg.get("cmd") == "auth":
114
+ self.token = msg.get("token")
115
+ return resp
116
+
117
+ def _note(self, msg: Dict[str, Any], start: float, error: Optional[str]) -> None:
118
+ if msg.get("cmd") == "auth":
119
+ return
120
+ params = {}
121
+ for k, v in msg.items():
122
+ if k in ("id", "cmd", "device", "lease", "token"):
123
+ continue
124
+ if isinstance(v, str) and len(v) > 200:
125
+ v = f"<{len(v)} characters>"
126
+ params[k] = v
127
+ self.history.append({
128
+ "time": time.time(), "device": msg.get("device"), "cmd": msg.get("cmd"), "params": params,
129
+ "ok": error is None, "error": error, "ms": int((time.monotonic() - start) * 1000),
130
+ })
131
+
132
+ def subscribe_once(self, msg: Dict[str, Any]) -> None:
133
+ with self._lock:
134
+ known = msg in self._subscriptions
135
+ if not known:
136
+ self.request(msg)
137
+
138
+ def ensure(self) -> _Link:
139
+ with self._connect_lock:
140
+ link = self._link
141
+ if link is not None and link.alive:
142
+ return link
143
+ link = self._open()
144
+ try:
145
+ # Auth must be the first line, and subscriptions belong to the socket, so a
146
+ # reconnect replays both before any other request goes out.
147
+ if self.token:
148
+ self._exchange(link, {"cmd": "auth", "token": self.token})
149
+ with self._lock:
150
+ subscriptions = list(self._subscriptions)
151
+ for sub in subscriptions:
152
+ self._exchange(link, sub)
153
+ except BaseException:
154
+ self._drop(link)
155
+ raise
156
+ self._link = link
157
+ return link
158
+
159
+ def _open(self) -> _Link:
160
+ try:
161
+ sock = socket.create_connection((self.host, self.port), timeout=5)
162
+ except OSError as e:
163
+ raise ServerNotRunningError(
164
+ f"Cannot reach the Droidline server at {self.host}:{self.port} ({e.strerror or e}). "
165
+ "Start it with `droidline serve`."
166
+ ) from None
167
+ sock.settimeout(None)
168
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
169
+ link = _Link(sock)
170
+ threading.Thread(target=self._read_loop, args=(link,), name="droidline-reader", daemon=True).start()
171
+ return link
172
+
173
+ def _exchange(self, link: _Link, msg: Dict[str, Any]) -> Dict[str, Any]:
174
+ slot = _Slot()
175
+ with self._lock:
176
+ if not link.alive:
177
+ raise ConnectionLostError("The connection to the Droidline server dropped. Retry the call.")
178
+ msg_id = next(self._ids)
179
+ link.pending[msg_id] = slot
180
+ line = json.dumps({"id": msg_id, **msg}, ensure_ascii=False, separators=(",", ":")) + "\n"
181
+ try:
182
+ with self._send_lock:
183
+ link.sock.sendall(line.encode("utf-8"))
184
+ except OSError:
185
+ self._drop(link)
186
+ # Short waits keep Ctrl+C working on Windows, where an untimed wait cannot be interrupted.
187
+ while not slot.done.wait(0.5):
188
+ pass
189
+ if slot.error is not None:
190
+ raise slot.error
191
+ resp = slot.resp or {}
192
+ if resp.get("ok") is False:
193
+ raise _error_from(resp)
194
+ return resp
195
+
196
+ def _read_loop(self, link: _Link) -> None:
197
+ try:
198
+ with link.sock.makefile("rb") as stream:
199
+ for raw in stream:
200
+ try:
201
+ msg = json.loads(raw)
202
+ except ValueError:
203
+ continue
204
+ if not isinstance(msg, dict):
205
+ continue
206
+ # Late results carry the request id too, so the event check comes first.
207
+ if "event" in msg:
208
+ self._emit(msg)
209
+ continue
210
+ msg_id = msg.get("id")
211
+ if not isinstance(msg_id, (int, str)):
212
+ continue
213
+ with self._lock:
214
+ slot = link.pending.pop(msg_id, None)
215
+ if slot is not None:
216
+ slot.resp = msg
217
+ slot.done.set()
218
+ except (OSError, ValueError):
219
+ pass
220
+ finally:
221
+ self._drop(link)
222
+
223
+ def _drop(self, link: _Link) -> None:
224
+ with self._lock:
225
+ was_alive = link.alive
226
+ link.alive = False
227
+ pending = list(link.pending.values())
228
+ link.pending.clear()
229
+ if self._link is link:
230
+ self._link = None
231
+ if was_alive:
232
+ # shutdown() wakes a reader blocked in recv(); close() alone does not on Linux.
233
+ try:
234
+ link.sock.shutdown(socket.SHUT_RDWR)
235
+ except OSError:
236
+ pass
237
+ link.sock.close()
238
+ for slot in pending:
239
+ slot.error = ConnectionLostError(
240
+ "The connection to the Droidline server dropped before the reply arrived. "
241
+ "The command may or may not have run."
242
+ )
243
+ slot.done.set()
244
+
245
+ def add_listener(self, kind: str, callback: EventCallback) -> None:
246
+ with self._lock:
247
+ self._listeners.setdefault(kind, []).append(callback)
248
+
249
+ def remove_listener(self, kind: str, callback: EventCallback) -> None:
250
+ with self._lock:
251
+ callbacks = self._listeners.get(kind, [])
252
+ if callback in callbacks:
253
+ callbacks.remove(callback)
254
+
255
+ def _emit(self, event: Dict[str, Any]) -> None:
256
+ with self._lock:
257
+ if self._queue is None:
258
+ self._queue = queue.Queue()
259
+ threading.Thread(
260
+ target=self._dispatch_loop, args=(self._queue,), name="droidline-events", daemon=True
261
+ ).start()
262
+ q = self._queue
263
+ q.put(event)
264
+
265
+ def _dispatch_loop(self, q: "queue.Queue[Optional[Dict[str, Any]]]") -> None:
266
+ while True:
267
+ event = q.get()
268
+ if event is None:
269
+ return
270
+ with self._lock:
271
+ callbacks = list(self._listeners.get(str(event.get("event")), ())) + list(self._listeners.get("*", ()))
272
+ for callback in callbacks:
273
+ try:
274
+ callback(event)
275
+ except Exception:
276
+ traceback.print_exc()
277
+
278
+ @property
279
+ def connected(self) -> bool:
280
+ link = self._link
281
+ return link is not None and link.alive
282
+
283
+ def close(self) -> None:
284
+ with self._lock:
285
+ q, self._queue = self._queue, None
286
+ link = self._link
287
+ if q is not None:
288
+ q.put(None)
289
+ if link is not None:
290
+ self._drop(link)
291
+
292
+
293
+ class Droidline(ClientCommands):
294
+ """Client for the local Droidline server. Commands for one phone go through `device()`.
295
+
296
+ Arguments fall back to DROIDLINE_HOST, DROIDLINE_PORT and DROIDLINE_TOKEN, then to
297
+ 127.0.0.1:8780 without a token. The socket opens on the first call and reopens after a drop.
298
+ """
299
+
300
+ def __init__(self, host: Optional[str] = None, port: Optional[int] = None, token: Optional[str] = None) -> None:
301
+ self.host = host or os.environ.get("DROIDLINE_HOST") or DEFAULT_HOST
302
+ self.port = int(port or os.environ.get("DROIDLINE_PORT") or DEFAULT_PORT)
303
+ self._conn = _Connection(self.host, self.port, token or os.environ.get("DROIDLINE_TOKEN") or None)
304
+
305
+ @property
306
+ def connected(self) -> bool:
307
+ """True while the socket to the server is open."""
308
+ return self._conn.connected
309
+
310
+ def device(self, id_or_name: Optional[str] = None) -> Device:
311
+ """A handle for one phone. Without an argument it uses DROIDLINE_DEVICE, else the only online phone."""
312
+ return Device(self, id_or_name or os.environ.get("DROIDLINE_DEVICE") or None)
313
+
314
+ def call(self, cmd: str, /, **params: Any) -> Dict[str, Any]:
315
+ """Send any command, including ones newer than this SDK. Returns the reply without id and ok."""
316
+ return _fields(self._conn.request({"cmd": cmd, **params}))
317
+
318
+ def lease(
319
+ self,
320
+ device: Optional[str] = None,
321
+ *,
322
+ wait: Optional[float] = None,
323
+ ttl: Optional[float] = None,
324
+ min_sdk: Optional[int] = None,
325
+ model: Optional[str] = None,
326
+ ) -> LeasedDevice:
327
+ """Borrow a free phone so no other script can use it until release().
328
+
329
+ Optional: phones nobody leased keep working as before. Waits up to `wait` seconds
330
+ (default 30) for a match, else raises NoFreeDeviceError. The lease also ends after
331
+ `ttl` seconds (default 300) without a command. Use it as a context manager to release
332
+ it at the end of the block.
333
+ """
334
+ optional = {"device": device, "wait": wait, "ttl": ttl, "min_sdk": min_sdk, "model": model}
335
+ reply = _fields(self._conn.request(_message("lease", None, {}, optional)))
336
+ return LeasedDevice(self, reply["device"], reply["lease"], reply)
337
+
338
+ def release(self, lease: Optional[str] = None, *, device: Optional[str] = None) -> None:
339
+ """Give a leased phone back. Releasing twice is harmless.
340
+
341
+ Pass device instead of lease to free a phone whose script crashed while holding it.
342
+ """
343
+ self._conn.request(_message("release", None, {}, {"lease": lease, "device": device}))
344
+
345
+ @property
346
+ def history(self) -> List[Dict[str, Any]]:
347
+ """The last 200 requests on this connection, oldest first: cmd, params, ok, error, ms."""
348
+ return list(self._conn.history)
349
+
350
+ def on(self, kind: str, callback: EventCallback) -> EventCallback:
351
+ """Call `callback(event)` for each event of this kind (device, notification, screen, toast,
352
+ result) or every event with "*". Only result events arrive without subscribe()."""
353
+ self._conn.add_listener(kind, callback)
354
+ return callback
355
+
356
+ def off(self, kind: str, callback: EventCallback) -> None:
357
+ self._conn.remove_listener(kind, callback)
358
+
359
+ def close(self) -> None:
360
+ self._conn.close()
361
+
362
+ def __enter__(self) -> Droidline:
363
+ return self
364
+
365
+ def __exit__(self, *exc: Any) -> None:
366
+ self.close()
367
+
368
+ def __repr__(self) -> str:
369
+ return f"Droidline({self.host!r}, {self.port!r})"
370
+
371
+ def _run(self, cmd: str, kind: str, required: Dict[str, Any], /, **optional: Any) -> Any:
372
+ return _result(kind, self._conn.request(_message(cmd, None, required, optional)))
373
+
374
+
375
+ class Device(DeviceCommands):
376
+ """One phone. Every generated command method sends a request with this device's ID or name."""
377
+
378
+ lease_id: Optional[str] = None
379
+
380
+ def __init__(self, client: Droidline, device: Optional[str] = None) -> None:
381
+ self.client = client
382
+ self.device = device
383
+ self._owns_client = False
384
+
385
+ def call(self, cmd: str, /, **params: Any) -> Dict[str, Any]:
386
+ """Send any command to this phone, including ones newer than this SDK."""
387
+ return _fields(self.client._conn.request(_message(cmd, self.device, params, {}, self.lease_id)))
388
+
389
+ @property
390
+ def history(self) -> List[Dict[str, Any]]:
391
+ """Recent requests to this phone, oldest first: cmd, params, ok, error, ms."""
392
+ return [e for e in self.client.history if self.device is None or e["device"] == self.device]
393
+
394
+ def on_notification(
395
+ self,
396
+ package: Optional[str] = None,
397
+ textContains: Optional[str] = None,
398
+ callback: Optional[Callable[[Notification], Any]] = None,
399
+ ) -> Callable[[], None]:
400
+ """Call `callback(notification)` for every matching notification from this phone.
401
+
402
+ Args:
403
+ package: Only from this app.
404
+ textContains: Only if title or text contains this.
405
+ callback: Called with the notification event on the SDK's event thread.
406
+
407
+ The phone forwards only the apps allowed with notify_filter(). Returns a function
408
+ that stops the callback.
409
+ """
410
+ if callback is None:
411
+ raise TypeError("on_notification() needs callback=")
412
+ ids = self._identities()
413
+
414
+ def handler(event: Dict[str, Any]) -> None:
415
+ if ids and event.get("device") not in ids and event.get("name") not in ids:
416
+ return
417
+ if package is not None and event.get("package") != package:
418
+ return
419
+ if textContains is not None and not any(
420
+ textContains in str(event.get(k) or "") for k in ("title", "text")
421
+ ):
422
+ return
423
+ callback(event) # type: ignore[arg-type]
424
+
425
+ conn = self.client._conn
426
+ conn.add_listener("notification", handler)
427
+ try:
428
+ conn.subscribe_once({"cmd": "subscribe", "events": ["notification"]})
429
+ except BaseException:
430
+ conn.remove_listener("notification", handler)
431
+ raise
432
+ return lambda: conn.remove_listener("notification", handler)
433
+
434
+ def _identities(self) -> Set[str]:
435
+ # Events name the device by ID; resolve a name so both match. Empty means any device.
436
+ if self.device is None:
437
+ return set()
438
+ ids = {self.device}
439
+ try:
440
+ for info in self.client.devices():
441
+ if self.device in (info.get("id"), info.get("name")):
442
+ ids.update(str(v) for v in (info.get("id"), info.get("name")) if v)
443
+ except DroidlineError:
444
+ pass
445
+ return ids
446
+
447
+ def close(self) -> None:
448
+ """Close the connection if connect() opened it for this device; otherwise do nothing."""
449
+ if self._owns_client:
450
+ self.client.close()
451
+
452
+ def __enter__(self) -> Device:
453
+ return self
454
+
455
+ def __exit__(self, *exc: Any) -> None:
456
+ self.close()
457
+
458
+ def __repr__(self) -> str:
459
+ return f"Device({self.device!r})"
460
+
461
+ def _run(self, cmd: str, kind: str, required: Dict[str, Any], /, **optional: Any) -> Any:
462
+ return _result(kind, self.client._conn.request(_message(cmd, self.device, required, optional, self.lease_id)))
463
+
464
+ def _load_image(self, image: Union[str, bytes]) -> str:
465
+ # A file path or the bytes of a PNG or JPEG; the wire carries base64.
466
+ if isinstance(image, (bytes, bytearray)):
467
+ return base64.b64encode(bytes(image)).decode("ascii")
468
+ with open(image, "rb") as f:
469
+ return base64.b64encode(f.read()).decode("ascii")
470
+
471
+ def _run_element(self, cmd: str, kind: str, required: Dict[str, Any], /, **optional: Any) -> Any:
472
+ found = self._run(cmd, kind, required, **optional)
473
+ if isinstance(found, list):
474
+ return [Element(self, n) for n in found]
475
+ return Element(self, found)
476
+
477
+ def _save_image(self, cmd: str, path: Optional[str], required: Dict[str, Any], /, **optional: Any) -> Any:
478
+ if path is not None and optional.get("format") is None:
479
+ ext = os.path.splitext(path)[1].lower()
480
+ optional["format"] = {".png": "png", ".jpg": "jpeg", ".jpeg": "jpeg"}.get(ext)
481
+ result = self._run(cmd, "fields", required, **optional)
482
+ image = base64.b64decode(result["data"])
483
+ if path is None:
484
+ return image
485
+ with open(path, "wb") as f:
486
+ f.write(image)
487
+ return path
488
+
489
+ def _save_json(self, cmd: str, path: Optional[str], required: Dict[str, Any], /, **optional: Any) -> Any:
490
+ result = self._run(cmd, "fields", required, **optional)
491
+ if path is not None:
492
+ with open(path, "w", encoding="utf-8") as f:
493
+ json.dump(result, f, ensure_ascii=False, indent=2)
494
+ return result
495
+
496
+
497
+ class LeasedDevice(Device):
498
+ """A phone borrowed with lease(). Every request carries the lease; release() gives it back."""
499
+
500
+ def __init__(self, client: Droidline, device: str, lease_id: str, info: Dict[str, Any]) -> None:
501
+ super().__init__(client, device)
502
+ self.lease_id = lease_id
503
+ self.name: str = info.get("name") or device
504
+ self.ttl: float = float(info.get("ttl") or 0)
505
+
506
+ def release(self) -> None:
507
+ """Give the phone back."""
508
+ lease_id, self.lease_id = self.lease_id, None
509
+ if lease_id is not None:
510
+ self.client.release(lease_id)
511
+
512
+ def close(self) -> None:
513
+ """Release the phone, then close the connection if lease() opened it."""
514
+ try:
515
+ self.release()
516
+ finally:
517
+ super().close()
518
+
519
+ def __enter__(self) -> LeasedDevice:
520
+ return self
521
+
522
+ def __repr__(self) -> str:
523
+ return f"LeasedDevice({self.name!r}, lease={self.lease_id!r})"
524
+
525
+
526
+ def lease(
527
+ device: Optional[str] = None,
528
+ *,
529
+ wait: Optional[float] = None,
530
+ ttl: Optional[float] = None,
531
+ min_sdk: Optional[int] = None,
532
+ model: Optional[str] = None,
533
+ host: Optional[str] = None,
534
+ port: Optional[int] = None,
535
+ token: Optional[str] = None,
536
+ ) -> LeasedDevice:
537
+ """Like connect(), but borrows a free phone so no other script can use it.
538
+
539
+ with lease() as d:
540
+ d.launch("com.android.settings")
541
+ """
542
+ client = Droidline(host, port, token)
543
+ try:
544
+ d = client.lease(device, wait=wait, ttl=ttl, min_sdk=min_sdk, model=model)
545
+ except BaseException:
546
+ client.close()
547
+ raise
548
+ d._owns_client = True
549
+ return d
550
+
551
+
552
+ def connect(
553
+ device: Optional[str] = None,
554
+ host: Optional[str] = None,
555
+ port: Optional[int] = None,
556
+ token: Optional[str] = None,
557
+ ) -> Device:
558
+ """Connect to the local Droidline server and return a Device.
559
+
560
+ `device` is a device ID or name; without it DROIDLINE_DEVICE is used, else the only online
561
+ phone. Raises ServerNotRunningError right away if the server is not running.
562
+ """
563
+ client = Droidline(host, port, token)
564
+ client._conn.ensure()
565
+ d = client.device(device)
566
+ d._owns_client = True
567
+ return d