C# and .NET

Everything about the .NET SDK: installing it, connecting, calling Async methods with named arguments, files, waiting, errors, parallel phones with Task.WhenAll, notifications, leases and the command log.

The .NET package is a thin client for droidline serve. The server and the phone do the waiting, retries and fallbacks; the package sends commands, returns the replies as .NET values and throws a DroidlineException when something fails. It targets .NET Standard 2.0 and .NET 8, so it runs on .NET 8 or later and on .NET Framework 4.6.2 or later.

Install

Run this in your project's folder:

dotnet add package Droidline

To start a new project, dotnet new console -o first creates a console app in the folder first. The examples on this page are written for that kind of app: top-level statements in Program.cs, with the implicit using directives the template turns on. Check that it works with the server running by putting this in Program.cs and running dotnet run:

using Droidline;

await using var d = await DroidlineClient.ConnectAsync();
Console.WriteLine(await d.InfoAsync());

Connect

using Droidline;

await using var d = await DroidlineClient.ConnectAsync();             // the only phone that is online
await using var e = await DroidlineClient.ConnectAsync("shelf-01");   // a phone by name or ID

ConnectAsync() returns a Device: an object whose methods send commands to one phone. It throws ServerNotRunningException right away if droidline serve is not running. With await using, the connection closes when the variable goes out of scope.

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

await using var d = await DroidlineClient.ConnectAsync("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 DroidlineClient and ask it for devices:

using var client = new DroidlineClient();          // same host, port and token arguments
foreach (var phone in await client.DevicesAsync())
    Console.WriteLine(phone);
var a = client.Device("shelf-01");
var b = client.Device("shelf-02");

The client connects on the first call and reconnects after a drop. One client can be shared by many tasks. Disposing the client closes the connection; devices from client.Device() share it, so disposing them does nothing.

Call commands

Every command in the command reference is a method named after the command in PascalCase with Async at the end: touch is TouchAsync, long_tap is LongTapAsync, get_text is GetTextAsync. Required parameters come first; optional ones are optional C# parameters, so pass them by position or by name:

await d.TouchAsync("text", "Log in");
await d.TouchAsync("text", "Log in", 0, 30);          // nth 0, timeout 30 by position
await d.TouchAsync("text", "Log in", timeout: 30);    // clearer: a named argument
await d.SwipeAsync("up");                             // a direction...
await d.SwipeAsync(540, 1600, 540, 400, 300);         // ...or coordinates and a duration
await d.Chrome.GoAsync("droidline.dev");              // dotted commands are properties

Shorthands exist for the most common selector fields: d.TouchByIdAsync("login"), d.TouchByTextAsync("OK"), d.TouchByDescAsync("Search").

Every method returns a Task, so remember await. Without it, the program moves on before the phone has done anything; the compiler warns about it with CS4014.

Every method also takes a CancellationToken as its last parameter, passed by name: cancellationToken: cts.Token. Cancelling stops waiting for the reply and throws OperationCanceledException; a command that was already sent may still run on the phone.

Parameters that take a list, such as the candidates of which and the steps of batch, take an object[]. Write a pair as a tuple, and a step as a tuple or an object[]:

var state = await d.WhichAsync(new object[] { ("text", "Log in"), ("id", "main_tab") });
await d.BatchAsync(new object[] { new object[] { "home" }, ("sleep", 500), new object[] { "recents" } });
await d.NotifyFilterAsync(new[] { "com.google.android.gm" });

When the steps of a batch turn off the phone's network, add cutsNetwork: true, wait: true. Concepts explains why.

What you get back

The command returnsThe task gives youExample
nothing interestinga result object with properties such as Via and Msawait d.TouchAsync(...) gives a TouchResult whose Via is "node"
one valuethe value itselfExistsAsync gives a bool, GetTextAsync a string
several fieldsa result object with a property per fieldBatteryAsync() gives a BatteryResult with Level, Charging and Temperature
nothing at alla plain TaskHomeAsync(), BackAsync()

Every result object is also a read-only dictionary of the fields the server sent. Get<T>("field") reads any field, including ones newer than this SDK, and ToString() gives the reply as one line of JSON:

var battery = await d.BatteryAsync();
Console.WriteLine(battery.Level);                         // 87
Console.WriteLine(battery.Get<double>("temperature"));    // 31.5
Console.WriteLine(battery);                               // {"charging":true,"level":87,"temperature":31.5}

Files: dumps and screenshots

await d.DumpAsync("screen.json");                         // writes the screen tree to a file on your PC
var screen = await d.DumpAsync();                         // or returns it: screen.Tree, screen.Package

await d.ScreenshotAsync("shot.png");                      // the extension picks PNG or JPEG
await d.ScreenshotAsync("small.jpg", scale: 0.5, quality: 70);
byte[] png = await d.ScreenshotAsync(format: "png");      // without a path: only the bytes

Paths are on your PC, relative to the folder you run the program from. ScreenshotAsync always returns the image bytes, and also saves them when you give a path.

Waiting and timeouts

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

await d.TouchAsync("text", "Next", timeout: 30);
await d.WaitAsync("text", "Upload complete", 120);   // only wait
await d.WaitGoneAsync("id", "progress");             // wait until something disappears

You do not need Task.Delay pauses before commands; they only make programs slower.

Conditions

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

if (await d.ExistsAsync("text", "Allow"))
    await d.TouchAsync("text", "Allow");

if (!await d.InAppAsync("com.android.chrome"))
    await d.LaunchAsync("com.android.chrome");

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

Errors

A failed command throws a subclass of DroidlineException. Each has Code, Retryable and Fields, the extra fields the server sent:

using Droidline;

await using var d = await DroidlineClient.ConnectAsync();
try
{
    await d.TouchAsync("text", "Log in", timeout: 5);
}
catch (NotFoundException e)
{
    Console.WriteLine($"not on screen: {e.Fields["screen"]}");   // the screen that was showing instead
}
catch (DeviceOfflineException)
{
    Console.WriteLine("the phone is offline");
}
catch (DroidlineException e)
{
    Console.WriteLine($"{e.Code} {e.Retryable} {e.Message}");
}
ExceptionCodeUsually means
NotFoundExceptionNOT_FOUNDThe element did not appear within the timeout
AmbiguousExceptionAMBIGUOUSSeveral elements matched and the command needs one
NotClickableExceptionNOT_CLICKABLEEvery way to tap the element failed
DroidlineTimeoutExceptionTIMEOUTThe command took too long on the phone
NoAccessibilityException, NoImeException, NoPermissionExceptionNO_ACCESSIBILITY, NO_IME, NO_PERMISSIONA permission is off on the phone
AppNotFoundExceptionAPP_NOT_FOUNDNo app with that package name
DeviceOfflineException, DeviceNotFoundException, DeviceAmbiguousExceptionDEVICE_*Which phone, or whether it is reachable
AgentRestartedExceptionAGENT_RESTARTEDThe app restarted; the command may or may not have run
BadArgsExceptionBAD_ARGSA parameter is missing or has the wrong type
ServerNotRunningExceptionSERVER_NOT_RUNNINGdroidline serve is not running
ConnectionLostExceptionCONNECTION_LOSTThe connection to the server dropped during a call; the next call reconnects

All of them are in the Droidline namespace. Error codes explains each code.

A simple retry for errors marked retryable:

static async Task<T> RetryAsync<T>(Func<Task<T>> action, int attempts = 3)
{
    for (var i = 0; ; i++)
    {
        try
        {
            return await action();
        }
        catch (DroidlineException e) when (e.Retryable && i < attempts - 1)
        {
            await Task.Delay(2000);
        }
    }
}

await RetryAsync(() => d.TouchAsync("text", "Refresh"));

Several phones in parallel

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

using Droidline;

using var client = new DroidlineClient();
var online = (await client.DevicesAsync()).Where(p => p.Get<bool>("online"));

var results = await Task.WhenAll(online.Select(async p =>
{
    var name = p.Get<string>("name")!;
    var d = client.Device(name);
    await d.WakeAsync();
    await d.LaunchAsync("com.android.chrome");
    await d.Chrome.GoAsync("droidline.dev");
    return (name, (await d.CurrentAsync()).Package);
}));
foreach (var (name, package) in results)
    Console.WriteLine($"{name} {package}");

If one phone fails, Task.WhenAll still waits for the others and then throws the first failure. To keep every phone's result, catch the exception inside the lambda. Many phones has more patterns.

Notifications and events

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

await d.NotifyFilterAsync(new[] { "com.google.android.gm" });

var n = await d.WaitNotificationAsync("textContains", "verification code", 60);
Console.WriteLine($"{n.Title} {n.Text}");

var stop = await d.OnNotificationAsync(x => Console.WriteLine($"{x.Title} {x.Text}"), package: "com.google.android.gm");
// ... later
stop.Dispose();

OnNotificationAsync returns an IDisposable; dispose it to stop the callback, or declare it with using var. Callbacks run one at a time on the SDK's event thread, so keep them short. Other events, such as phones going online or offline, arrive through the client:

using var client = new DroidlineClient();
await client.SubscribeAsync(new[] { "device" });
client.On("device", e => Console.WriteLine($"{e.Get<string>("name")} {e.Get<string>("state")}"));

On returns an IDisposable too. Notifications covers replies, clicks and webhooks.

Elements, leases and the command log

FindAsync returns an Element and FindAllAsync a list of them. Every method that takes a field and a value also takes a query object instead. On the main screen of the demo app from Your first script:

var chats = await d.FindAllAsync(new { textContains = "Chat", checkable = true });
foreach (var chat in chats)
    Console.WriteLine($"{chat.Text} {chat.Bounds}");
await chats[0].ClickAsync();
Chat 1 (60, 600, 1020, 740)
Chat 2 (60, 760, 1020, 900)
Chat 3 (60, 920, 1020, 1060)

A query can be an anonymous object, a dictionary or a Query. Anonymous objects keep their property names as they are, so write the keys the way the query reference spells them: textContains, long_clickable, and @class for class. These two lines tap the same button:

await d.TouchAsync(new { @class = "android.widget.Button", below = new { id = "auto_login" } });
await d.TouchAsync(new Query { Class = "android.widget.Button", Below = new Query { Id = "auto_login" } });

DroidlineClient.LeaseAsync() works like ConnectAsync() but borrows a free phone so no other script can use it; disposing the device gives it back. d.History lists the recent requests to the phone with their result and time, and Testing.FormatHistory turns them into text:

await using var d = await DroidlineClient.LeaseAsync(wait: 60);
await d.LaunchAsync("dev.droidline.demo");
await d.WaitAsync("text", "Sign in");
Console.WriteLine(Testing.FormatHistory(d.History));
04:43:42 launch {"package": "dev.droidline.demo"} -> ok (0 ms)
04:43:42 wait {"by": "text", "value": "Sign in"} -> ok (0 ms)

In a test, Testing.SaveFailureAsync saves screen.png, screen.json and the command log as commands.txt into a folder. It works with any test framework:

try
{
    await d.TouchByIdAsync("login");
}
catch
{
    await Testing.SaveFailureAsync(d, "artifacts/login");
    throw;
}

See Finding elements, Many phones and Test frameworks.

Commands the SDK does not know yet

If the server is newer than your package, call a command by name. Pass the parameters as an anonymous object or a dictionary; the reply comes back as a DroidlineObject whose fields you read with Get<T>:

var reply = await d.CallAsync("some_new_command", new { value = 1 });

client.CallAsync does the same for server commands.

A complete script

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

using Droidline;

try
{
    return await RunAsync();
}
catch (DroidlineException e)
{
    Console.Error.WriteLine($"{e.Code}: {e.Message}");
    return 2;
}

static async Task<int> RunAsync()
{
    await using var d = await DroidlineClient.ConnectAsync();
    await d.LaunchAsync("dev.droidline.demo");
    await d.WaitAsync("text", "Sign in");
    if (await d.ExistsAsync("text", "Close ad"))
        await d.TouchAsync("text", "Close ad");
    await d.InputAsync("id", "email", "knife");
    if (!await d.CheckedAsync("id", "auto_login"))
        await d.TouchAsync("id", "auto_login");
    await d.TouchAsync("text", "Log in");
    if (await d.WhichAsync(new object[] { ("text", "Log in"), ("id", "main_tab") }, timeout: 15) != 1)
    {
        await d.ScreenshotAsync("login-failed.png");
        return 1;
    }
    Console.WriteLine(await d.GetTextAsync("id", "greeting"));
    return 0;
}

Run it with dotnet run. Against droidline-fakephone it prints Welcome, knife and exits with 0.