Node.js and TypeScript

Everything about the Node.js SDK: installing it, connecting, calling commands with options objects, files, waiting, errors, parallel phones with Promise.all, notifications and TypeScript.

The Node.js package is a thin client for droidline serve. The server and the phone do the waiting, retries and fallbacks; the package sends commands, resolves with the reply and rejects with a DroidlineError when something fails. It needs Node.js 18 or later, is published as ES modules and includes TypeScript types.

Install

npm install droidline

Your script must be an ES module: name it something.mjs, or set "type": "module" in your package.json. Check that it works with the server running:

node -e "import('droidline').then(async ({ connect }) => console.log(await (await connect()).info()))"

Connect

import { connect } from "droidline";

const d = await connect();              // the only phone that is online
const e = await connect("shelf-01");    // a phone by name or ID

connect() resolves with a Device: an object whose methods send commands to one phone. It rejects at once with code SERVER_NOT_RUNNING if droidline serve is not running.

To reach a server on another machine, pass options, or set DROIDLINE_HOST, DROIDLINE_PORT, DROIDLINE_TOKEN and DROIDLINE_DEVICE:

const d = await connect({ device: "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:

import { Droidline } from "droidline";

const dl = new Droidline();          // same host, port and token options
console.log(await dl.devices());
const a = dl.device("shelf-01");
const b = dl.device("shelf-02");

The process exits on its own once nothing is pending, so scripts need no close(). Long-running programs can call dl.close() when they are done.

Call commands

Every command in the command reference is a method. Required parameters come first; optional ones can follow by position or go into an options object as the last argument:

await d.touch("text", "Log in");
await d.touch("text", "Log in", 0, 30);          // nth=0, timeout=30 by position
await d.touch("text", "Log in", { timeout: 30 }); // clearer: an options object
await d.swipe("up");                             // a direction...
await d.swipe(540, 1600, 540, 400, 300);         // ...or coordinates and a duration
await d.chrome.go("droidline.dev");              // dotted commands are properties

Multi-word names exist in both styles, get_text and getText, scroll_to and scrollTo; this guide uses camelCase. Shorthands exist for the most common selector fields: d.touchById("login"), d.touchByText("OK"), d.touchByDesc("Search").

Every method returns a promise, so remember await. Forgetting it is the most common mistake: the script moves on before the phone has done anything.

What you get back

The command returnsThe promise resolves withExample
nothing interestingan object with details such as via and msawait d.touch(...) gives { via: "node", ms: 208 }
one valuethe value itselfawait d.exists(...) gives true, await d.getText(...) a string
several fieldsan objectawait d.battery() gives { level: 84, charging: false, temperature: 31.5 }

Files: dumps and screenshots

await d.dump("screen.json");                           // writes the screen tree to a file on your PC
const tree = await d.dump();                           // or resolves with it

await d.screenshot("shot.png");                        // the extension picks PNG or JPEG
await d.screenshot("small.jpg", { scale: 0.5, quality: 70 });
const png = await d.screenshot({ format: "png" });     // without a path: a Buffer

Paths are on your PC, relative to the folder you run the script from.

Waiting and timeouts

Commands that act on an element wait for it, 10 seconds by default:

await d.touch("text", "Next", { timeout: 30 });
await d.wait("text", "Upload complete", 120);   // only wait
await d.waitGone("id", "progress");             // wait until something disappears

You do not need setTimeout pauses before commands; they only make scripts slower.

Conditions

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

if (await d.exists("text", "Allow")) await d.touch("text", "Allow");

if (!(await d.inApp("com.android.chrome"))) await d.launch("com.android.chrome");

const state = await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 });  // 0, 1 or -1

Errors

Every failure is a DroidlineError with code, retryable and data, the extra fields the server sent:

import { connect, DroidlineError } from "droidline";

const d = await connect();
try {
  await d.touch("text", "Log in", { timeout: 5 });
} catch (e) {
  if (!(e instanceof DroidlineError)) throw e;
  switch (e.code) {
    case "NOT_FOUND":
      console.log("not on screen:", e.data.screen);
      break;
    case "DEVICE_OFFLINE":
      console.log("the phone is offline");
      break;
    default:
      console.log(e.code, e.retryable, e.message);
  }
}

Besides the codes in Error codes, the SDK uses SERVER_NOT_RUNNING (nothing listens on the port) and CONNECTION_LOST (the connection dropped during a call; the command may or may not have run).

A simple retry for errors marked retryable:

async function retry(fn, attempts = 3) {
  for (let i = 0; ; i++) {
    try {
      return await fn();
    } catch (e) {
      if (!(e instanceof DroidlineError) || !e.retryable || i >= attempts - 1) throw e;
      await new Promise((r) => setTimeout(r, 2000));
    }
  }
}

await retry(() => d.touch("text", "Refresh"));

Several phones in parallel

Commands to one phone run in order; commands to different phones run at the same time. Promise.all drives a whole shelf at once:

import { Droidline } from "droidline";

const dl = new Droidline();
const online = (await dl.devices()).filter((p) => p.online);

const results = await Promise.all(online.map(async (p) => {
  const d = dl.device(p.name);
  await d.wake();
  await d.launch("com.android.chrome");
  await d.chrome.go("droidline.dev");
  return [p.name, (await d.current()).package];
}));
console.log(results);

Use Promise.allSettled instead if one phone failing should not stop the others. Many phones has more patterns.

Notifications and events

Choose which apps the phone forwards first, with notifyFilter or on the app's Status tab. Then wait for one notification, or get a callback for each:

await d.notifyFilter(["com.google.android.gm"]);

const n = await d.waitNotification("textContains", "verification code", 60);
console.log(n.title, n.text);

const stop = await d.onNotification({ package: "com.google.android.gm" }, (n) => console.log(n.title, n.text));
// ... later
stop();

While a notification listener is active, the process keeps running; call stop() to let it exit. 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:

await d.call("some_new_command", { value: 1 });

TypeScript

The package ships its own types. Parameter names, option objects and results are all typed:

import { connect, DroidlineError, type Device } from "droidline";

async function login(d: Device, email: string): Promise<boolean> {
  await d.input("id", "email", email);
  await d.touchById("login");
  return (await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 })) === 1;
}

const d = await connect();
console.log(await login(d, "knife"));

Run TypeScript directly with npx tsx script.ts, or compile it with tsc as usual.

A complete script

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

import { connect, DroidlineError } from "droidline";

async function main() {
  const d = await connect();
  await d.launch("dev.droidline.demo");
  await d.wait("text", "Sign in");
  if (await d.exists("text", "Close ad")) await d.touch("text", "Close ad");
  await d.input("id", "email", "knife");
  if (!(await d.checked("id", "auto_login"))) await d.touch("id", "auto_login");
  await d.touch("text", "Log in");
  if ((await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 })) !== 1) {
    await d.screenshot("login-failed.png");
    return 1;
  }
  console.log(await d.getText("id", "greeting"));
  return 0;
}

try {
  process.exitCode = await main();
} catch (e) {
  if (!(e instanceof DroidlineError)) throw e;
  console.error(`${e.code}: ${e.message}`);
  process.exitCode = 2;
}