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 +30 -0
- droidline/_client.py +567 -0
- droidline/_element.py +111 -0
- droidline/_errors.py +52 -0
- droidline/_generated.py +1905 -0
- droidline/py.typed +0 -0
- droidline/pytest_plugin.py +72 -0
- droidline/testing.py +52 -0
- droidline-0.1.2.dist-info/METADATA +59 -0
- droidline-0.1.2.dist-info/RECORD +11 -0
- droidline-0.1.2.dist-info/WHEEL +4 -0
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
|