Python

Everything about the Python SDK: installing it, connecting, calling commands, files, waiting, errors, several phones and threads, notifications, events and testing with pytest.

The Python package is a thin client for droidline serve. The server and the phone do the waiting, retries and fallbacks; the package sends commands, turns replies into Python values and turns failures into exceptions. It needs Python 3.9 or later and has no dependencies.

Install

pip install droidline

Installing into a virtual environment keeps it apart from your other projects; Installation shows how on each system. Check that it works with the server running:

python -c "from droidline import connect; print(connect().info())"

Connect

from droidline import connect

d = connect()                 # the only phone that is online
d = connect("shelf-01")       # a phone by name or ID

connect() returns a Device: an object whose methods send commands to one phone. It fails right away with ServerNotRunningError if droidline serve is not running.

To reach a server on another machine, pass its address and a client token, or set DROIDLINE_HOST, DROIDLINE_PORT, DROIDLINE_TOKEN and DROIDLINE_DEVICE:

d = connect("shelf-01", host="192.168.0.12", port=8780, token="dlc_...")

For several phones, or for server commands such as devices and pair, create a Droidline client and ask it for devices:

from droidline import Droidline

dl = Droidline()                    # same host, port and token arguments as connect()
print(dl.devices())
a = dl.device("shelf-01")
b = dl.device("shelf-02")

Both objects can be used with with, which closes the connection at the end:

with Droidline() as dl:
    dl.device("shelf-01").home()

Call commands

Every command in the command reference is a method with the same name. Required parameters come first and can be passed by position; optional ones can be passed by position or by keyword:

d.touch("text", "Log in")
d.touch("text", "Log in", 0, 30)          # nth=0, timeout=30 by position
d.touch("text", "Log in", timeout=30)     # clearer: by keyword
d.swipe("up")                             # swipe takes a direction...
d.swipe(540, 1600, 540, 400, 300)         # ...or coordinates and a duration
d.chrome.go("droidline.dev")              # dotted commands are attributes

Shorthands exist for the most common selector fields: d.touchById("login"), d.touchByText("OK"), d.touchByDesc("Search").

What you get back

The command returnsYou getExample
nothing interestinga dict with details such as via and msd.touch(...) returns {"via": "node", "ms": 208}
one valuethe value itselfd.exists(...) returns True, d.get_text(...) returns a str
several fieldsa dictd.battery() returns {"level": 84, "charging": False, "temperature": 31.5}

The methods have type hints, so your editor completes parameter names and shows the shape of each result.

Files: dumps and screenshots

d.dump("screen.json")                     # writes the screen tree to a file on your PC
tree = d.dump()                           # or returns it as a dict

d.screenshot("shot.png")                  # the extension picks PNG or JPEG
d.screenshot("small.jpg", scale=0.5, quality=70)
png = d.screenshot(format="png")          # without a path: the image as bytes

Paths are on your PC, relative to the folder you run the script from. To work with the image in memory, open the bytes with Pillow: Image.open(io.BytesIO(png)).

Waiting and timeouts

Commands that act on an element wait for it, 10 seconds by default. Change that per call:

d.touch("text", "Next", timeout=30)
d.wait("text", "Upload complete", 120)    # only wait
d.wait_gone("id", "progress")             # wait until something disappears

Avoid time.sleep() before a command; it only makes scripts slower. Use sleep when you need a pause for a reason the screen does not show, such as giving a server time to send a message.

Conditions

Conditions answer at once and never raise for a missing element:

if d.exists("text", "Allow"):
    d.touch("text", "Allow")

if not d.in_app("com.android.chrome"):
    d.launch("com.android.chrome")

state = d.which([("text", "Log in"), ("id", "main_tab")], timeout=15)   # 0, 1 or -1

Errors

A failed command raises a subclass of DroidlineError. Each has code, retryable and data, the extra fields the server sent; those fields can also be read as attributes:

from droidline import connect, DroidlineError, NotFoundError, DeviceOfflineError

d = connect()
try:
    d.touch("text", "Log in", timeout=5)
except NotFoundError as e:
    print("not on screen:", e.screen)      # the screen that was showing instead
except DeviceOfflineError:
    print("the phone is offline")
except DroidlineError as e:
    print(e.code, e.retryable, e)
ExceptionCodeUsually means
NotFoundErrorNOT_FOUNDThe element did not appear within the timeout
AmbiguousErrorAMBIGUOUSSeveral elements matched and the command needs one
NotClickableErrorNOT_CLICKABLEEvery way to tap the element failed
DroidlineTimeoutErrorTIMEOUTThe command took too long on the phone
NoAccessibilityError, NoImeError, NoPermissionErrorNO_ACCESSIBILITY, NO_IME, NO_PERMISSIONA permission is off on the phone
AppNotFoundErrorAPP_NOT_FOUNDNo app with that package name
MacroFailedErrorMACRO_FAILEDA Settings shortcut such as clear_data could not finish
DeviceOfflineError, DeviceNotFoundError, DeviceAmbiguousErrorDEVICE_*Which phone, or whether it is reachable
AgentRestartedErrorAGENT_RESTARTEDThe app restarted; the command may or may not have run
BadArgsErrorBAD_ARGSA parameter is missing or has the wrong type
ServerNotRunningErrorSERVER_NOT_RUNNINGdroidline serve is not running
ConnectionLostErrorCONNECTION_LOSTThe connection to the server dropped during a call

All of them can be imported from droidline. Error codes explains each code.

A simple retry for errors marked retryable:

import time
from droidline import DroidlineError

def retry(fn, attempts=3):
    for i in range(attempts):
        try:
            return fn()
        except DroidlineError as e:
            if not e.retryable or i == attempts - 1:
                raise
            time.sleep(2)

retry(lambda: d.touch("text", "Refresh"))

Several phones and threads

One Droidline client can drive many phones from many threads. Commands to one phone run in order; commands to different phones run at the same time:

from concurrent.futures import ThreadPoolExecutor
from droidline import Droidline

dl = Droidline()

def morning(name):
    d = dl.device(name)
    d.wake()
    d.launch("com.android.chrome")
    d.chrome.go("droidline.dev")
    return name, d.current()["package"]

names = [p["name"] for p in dl.devices() if p["online"]]
with ThreadPoolExecutor(max_workers=len(names) or 1) as pool:
    for name, pkg in pool.map(morning, names):
        print(name, pkg)

Many phones has more patterns for a shelf of phones.

Notifications and events

Wait for one notification, or get a callback for every matching one. Choose which apps the phone forwards first, with notify_filter or on the app's Status tab:

d.notify_filter(["com.google.android.gm"])

n = d.wait_notification("textContains", "verification code", 60)
print(n["title"], n["text"])

stop = d.on_notification(package="com.google.android.gm",
                         callback=lambda n: print(n["title"], n["text"]))
# ... later
stop()

Callbacks run on the SDK's event thread, so keep them short or hand the work to a queue. Other events, such as phones going online or offline, arrive through the client:

dl = Droidline()
dl.subscribe(["device"])
dl.on("device", lambda e: print(e["name"], e["state"]))

Notifications covers replies, clicks and webhooks.

Commands the SDK does not know yet

If the server is newer than your package, call a command by name:

d.call("some_new_command", value=1)

Testing with pytest

A fixture gives each test a phone, starting from the home screen:

# conftest.py
import pytest
from droidline import connect

@pytest.fixture
def phone():
    d = connect()
    d.home()
    yield d
    d.screenshot("last-test.png")
# test_login.py
def test_login(phone):
    phone.launch("dev.droidline.demo")
    phone.input("id", "email", "knife")
    phone.touchById("login")
    assert phone.which([("text", "Log in"), ("id", "main_tab")]) == 1
    assert phone.get_text("id", "greeting") == "Welcome, knife"

The test runs against droidline-fakephone in CI and against a real phone on your desk without changes.

A complete script

The script from Your first script, with a retry and a clean exit code:

import sys
from droidline import connect, DroidlineError

def main() -> int:
    d = connect()
    d.launch("dev.droidline.demo")
    d.wait("text", "Sign in")
    if d.exists("text", "Close ad"):
        d.touch("text", "Close ad")
    d.input("id", "email", "knife")
    if not d.checked("id", "auto_login"):
        d.touch("id", "auto_login")
    d.touch("text", "Log in")
    if d.which([("text", "Log in"), ("id", "main_tab")], timeout=15) != 1:
        d.screenshot("login-failed.png")
        return 1
    print(d.get_text("id", "greeting"))
    return 0

if __name__ == "__main__":
    try:
        sys.exit(main())
    except DroidlineError as e:
        print(f"{e.code}: {e}", file=sys.stderr)
        sys.exit(2)