Skip to content

Comments

Add AdbRunner for adb CLI operations#283

Open
rmarinho wants to merge 2 commits intomainfrom
feature/adb-runner
Open

Add AdbRunner for adb CLI operations#283
rmarinho wants to merge 2 commits intomainfrom
feature/adb-runner

Conversation

@rmarinho
Copy link
Member

Summary

Adds AdbRunner class to wrap adb CLI operations for device management.

Addresses #279

New API

public class AdbRunner
{
    // List connected devices and emulators
    Task<IReadOnlyList<AndroidDeviceInfo>> GetDevicesAsync(CancellationToken ct = default);
    
    // Start ADB server
    Task<ToolRunnerResult> StartServerAsync(CancellationToken ct = default);
    
    // Stop ADB server
    Task<ToolRunnerResult> KillServerAsync(CancellationToken ct = default);
    
    // Wait for device to be ready
    Task<ToolRunnerResult> WaitForDeviceAsync(string serial, CancellationToken ct = default);
    
    // Query device property
    Task<string?> GetDevicePropertyAsync(string serial, string property, CancellationToken ct = default);
}

public record AndroidDeviceInfo(string Serial, DeviceState State, string? Model, string? Product, TransportType Transport);

public enum DeviceState { Unknown, Offline, Device, NoDevice, Recovery, Sideload, Unauthorized }
public enum TransportType { Unknown, Usb, Emulator }

Usage

var adb = new AdbRunner(androidSdkPath);

// List devices
var devices = await adb.GetDevicesAsync();
foreach (var device in devices)
{
    Console.WriteLine($""{device.Serial}: {device.State} ({device.Transport})"");
}

// Wait for specific device
await adb.WaitForDeviceAsync(""emulator-5554"");

// Get device property
var model = await adb.GetDevicePropertyAsync(""emulator-5554"", ""ro.product.model"");

Dependencies

Requires #281 (base tool runner infrastructure) to be merged first.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

rmarinho and others added 2 commits February 23, 2026 17:15
- AndroidToolRunner: Base class for running Android SDK CLI tools
- AndroidEnvironmentHelper: Environment variable setup, ABI mapping
- ToolRunnerResult: Generic result model for tool execution

This provides the foundation for specific tool runners.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- AdbRunner: Device management, server control
- AndroidDeviceInfo: Model for device metadata with state/transport enums

API:
- GetDevicesAsync(): List connected devices and emulators
- StartServerAsync(): Start ADB server
- KillServerAsync(): Stop ADB server
- WaitForDeviceAsync(): Wait for device to be ready
- GetDevicePropertyAsync(): Query device properties

Addresses #279

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds new “runner” infrastructure and an AdbRunner wrapper intended to simplify invoking Android SDK command-line tools (notably adb) from Xamarin.Android.Tools.AndroidSdk.

Changes:

  • Introduces AndroidToolRunner + ToolRunnerResult models for executing tools with captured output, timeouts, and cancellation.
  • Adds AndroidEnvironmentHelper utilities for env var setup and some Android mapping helpers.
  • Adds AdbRunner for listing devices, waiting for boot completion, emulator control, and property enrichment.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidToolRunner.cs New process execution helper (sync/async/timeout/background).
src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs New helper for tool env vars and mapping helpers.
src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs New adb wrapper for device/emulator operations and parsing.
src/Xamarin.Android.Tools.AndroidSdk/Models/ToolRunnerResult.cs New execution result model (+ generic variant).
src/Xamarin.Android.Tools.AndroidSdk/Models/AndroidDeviceInfo.cs New device model + enums used by AdbRunner.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +60 to +109
/// Lists connected devices and running emulators.
/// </summary>
public async Task<ToolRunnerResult<List<AndroidDeviceInfo>>> ListDevicesAsync (
bool enrichProperties = true,
CancellationToken cancellationToken = default)
{
if (!IsAvailable) {
return new ToolRunnerResult<List<AndroidDeviceInfo>> {
ExitCode = -1,
ErrorMessage = "ADB not found"
};
}

var result = await AndroidToolRunner.RunAsync (
AdbPath!,
"devices -l",
timeout: TimeSpan.FromSeconds (10),
cancellationToken: cancellationToken
).ConfigureAwait (false);

if (!result.Success) {
return new ToolRunnerResult<List<AndroidDeviceInfo>> {
ExitCode = result.ExitCode,
StandardOutput = result.StandardOutput,
StandardError = result.StandardError,
Duration = result.Duration,
ErrorMessage = result.ErrorMessage ?? result.StandardError
};
}

var devices = ParseDeviceList (result.StandardOutput);

// Enrich devices with additional properties
if (enrichProperties) {
for (int i = 0; i < devices.Count; i++) {
if (devices [i].IsOnline) {
devices [i] = await EnrichDevicePropertiesAsync (devices [i], cancellationToken)
.ConfigureAwait (false);
}
}
}

return new ToolRunnerResult<List<AndroidDeviceInfo>> {
ExitCode = result.ExitCode,
StandardOutput = result.StandardOutput,
StandardError = result.StandardError,
Duration = result.Duration,
Data = devices
};
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description/issue proposes AdbRunner APIs like GetDevicesAsync/StartServerAsync/KillServerAsync/GetDevicePropertyAsync returning simple types, but this implementation exposes ListDevicesAsync returning ToolRunnerResult<List> and adds unrelated methods (StopEmulatorAsync/TakeScreenshotAsync) while omitting StartServer/KillServer/GetDeviceProperty. Please align the implementation with the stated API (or update the PR description/issue) to avoid breaking expectations for downstream consumers.

Copilot uses AI. Check for mistakes.
Comment on lines +125 to +127
var result = await AndroidToolRunner.RunAsync (
AdbPath!,
$"-s {serial} emu kill",
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Command arguments are built via string interpolation without quoting/escaping the serial. If a caller passes a serial containing spaces or leading '-' it can break argument parsing or be interpreted as additional adb options. Consider a shared quoting/escaping helper (or ProcessStartInfo.ArgumentList where available) and apply it consistently to all '-s {serial}' usages.

Suggested change
var result = await AndroidToolRunner.RunAsync (
AdbPath!,
$"-s {serial} emu kill",
var safeSerial = serial?.Replace ("\"", "\\\"") ?? string.Empty;
var result = await AndroidToolRunner.RunAsync (
AdbPath!,
$"-s \"{safeSerial}\" emu kill",

Copilot uses AI. Check for mistakes.
Comment on lines +211 to +235
var args = serial != null ? $"-s {serial} wait-for-device" : "wait-for-device";
var effectiveTimeout = timeout ?? TimeSpan.FromMinutes (2);

var result = await AndroidToolRunner.RunAsync (
AdbPath!,
args,
timeout: effectiveTimeout,
cancellationToken: cancellationToken
).ConfigureAwait (false);

if (!result.Success) {
return result;
}

// Wait for boot completion
for (int i = 0; i < 60; i++) {
cancellationToken.ThrowIfCancellationRequested ();

var bootArgs = serial != null ? $"-s {serial} shell getprop sys.boot_completed" : "shell getprop sys.boot_completed";
var bootResult = await AndroidToolRunner.RunAsync (
AdbPath!,
bootArgs,
timeout: TimeSpan.FromSeconds (5),
cancellationToken: cancellationToken
).ConfigureAwait (false);
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WaitForDeviceAsync allows serial to be null (“wait for any device”), but the subsequent adb shell getprop ... call without -s will fail with "more than one device/emulator" if multiple devices are connected. Either require a serial for the boot-completion polling, or handle the multi-device case (e.g., pick the single connected device from adb devices or return a clear error).

Copilot uses AI. Check for mistakes.
var isEmulator = serial.StartsWith ("emulator");
var state = MapAdbState (status);
var model = ExtractDeviceName (line);
var details = ParseDeviceDetails (line);
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

var details = ParseDeviceDetails(line); is computed but never used, which adds parsing cost and noise. Remove it or use it to populate AndroidDeviceInfo fields (e.g., product/transport info from adb devices -l).

Suggested change
var details = ParseDeviceDetails (line);

Copilot uses AI. Check for mistakes.
Comment on lines +113 to +114
"google_apis_playstore" => "Google API's, Play Store",
"google_apis" => playStoreEnabled ? "Google API's, Play Store" : "Google API's",
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Display strings use "Google API's" (apostrophe) which is grammatically incorrect and user-visible. Consider changing these to "Google APIs" (and "Google APIs, Play Store").

Suggested change
"google_apis_playstore" => "Google API's, Play Store",
"google_apis" => playStoreEnabled ? "Google API's, Play Store" : "Google API's",
"google_apis_playstore" => "Google APIs, Play Store",
"google_apis" => playStoreEnabled ? "Google APIs, Play Store" : "Google APIs",

Copilot uses AI. Check for mistakes.
Comment on lines +328 to +365
List<AndroidDeviceInfo> ParseDeviceList (string output)
{
var devices = new List<AndroidDeviceInfo> ();
var lines = output.Split ('\n');

foreach (var line in lines) {
if (string.IsNullOrWhiteSpace (line) || line.StartsWith ("List of devices"))
continue;

var parts = line.Split (new [] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
continue;

var serial = parts [0];
var status = parts [1];

// Skip header or invalid lines
if (serial == "attached" || !IsValidSerial (serial))
continue;

var isEmulator = serial.StartsWith ("emulator");
var state = MapAdbState (status);
var model = ExtractDeviceName (line);
var details = ParseDeviceDetails (line);

var device = new AndroidDeviceInfo {
Serial = serial,
Name = model ?? serial,
Model = model,
Type = isEmulator ? AndroidDeviceType.Emulator : AndroidDeviceType.Physical,
State = state
};

devices.Add (device);
}

return devices;
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New parsing logic (ParseDeviceList/ParseGetProp/MapAdbState) is non-trivial and the repo has an existing test project for Xamarin.Android.Tools.AndroidSdk. Please add unit tests covering representative adb devices -l outputs (incl. offline/unauthorized/emulator), plus getprop parsing, so regressions in device discovery are caught.

Copilot uses AI. Check for mistakes.
Comment on lines +80 to +88
if (!result.Success) {
return new ToolRunnerResult<List<AndroidDeviceInfo>> {
ExitCode = result.ExitCode,
StandardOutput = result.StandardOutput,
StandardError = result.StandardError,
Duration = result.Duration,
ErrorMessage = result.ErrorMessage ?? result.StandardError
};
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When propagating a failed AndroidToolRunner.RunAsync result, ListDevicesAsync copies ExitCode/output/error/duration but drops flags like TimedOut. Consider copying TimedOut (and potentially other fields) so callers can distinguish timeouts from other failures.

Copilot uses AI. Check for mistakes.
Comment on lines +282 to +290
process.StartInfo = new ProcessStartInfo {
FileName = fileName,
Arguments = arguments,
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StartBackground redirects stdout/stderr but never reads from either stream. If the child process writes enough output, it can block on a full pipe buffer and hang (especially likely for long-running background processes like emulators). Consider not redirecting output/error for background processes, or start async readers to drain the streams.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +40
public static ToolRunnerResult Run (
string fileName,
string arguments = "",
string? workingDirectory = null,
Dictionary<string, string>? environmentVariables = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AndroidToolRunner.Run exposes a CancellationToken parameter but never observes it. Either remove the parameter or implement cancellation (e.g., register to kill the process and return an "Operation cancelled" result) so callers don’t assume cancellation works when it doesn’t.

Copilot uses AI. Check for mistakes.
Comment on lines +211 to +245
var args = serial != null ? $"-s {serial} wait-for-device" : "wait-for-device";
var effectiveTimeout = timeout ?? TimeSpan.FromMinutes (2);

var result = await AndroidToolRunner.RunAsync (
AdbPath!,
args,
timeout: effectiveTimeout,
cancellationToken: cancellationToken
).ConfigureAwait (false);

if (!result.Success) {
return result;
}

// Wait for boot completion
for (int i = 0; i < 60; i++) {
cancellationToken.ThrowIfCancellationRequested ();

var bootArgs = serial != null ? $"-s {serial} shell getprop sys.boot_completed" : "shell getprop sys.boot_completed";
var bootResult = await AndroidToolRunner.RunAsync (
AdbPath!,
bootArgs,
timeout: TimeSpan.FromSeconds (5),
cancellationToken: cancellationToken
).ConfigureAwait (false);

if (bootResult.Success && bootResult.StandardOutput.Trim () == "1") {
return new ToolRunnerResult {
ExitCode = 0,
StandardOutput = "Device booted successfully"
};
}

await Task.Delay (1000, cancellationToken).ConfigureAwait (false);
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timeout is only applied to the initial wait-for-device command; the subsequent boot-completion polling loop always waits up to ~60s (plus the initial wait), so the overall method can exceed the requested timeout. Consider enforcing the timeout across the entire WaitForDeviceAsync operation (e.g., linked CTS around both steps or compute remaining time for the polling loop).

Copilot uses AI. Check for mistakes.
@rmarinho rmarinho added the copilot `copilot-cli` or other AIs were used to author this label Feb 23, 2026
@jonathanpeppers
Copy link
Member

I'd like to get the System.Diagnostics.Process code unified like mentioned here:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

copilot `copilot-cli` or other AIs were used to author this

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants