Conversation
- 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>
There was a problem hiding this comment.
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+ToolRunnerResultmodels for executing tools with captured output, timeouts, and cancellation. - Adds
AndroidEnvironmentHelperutilities for env var setup and some Android mapping helpers. - Adds
AdbRunnerfor 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.
| /// 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 | ||
| }; | ||
| } |
There was a problem hiding this comment.
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.
| var result = await AndroidToolRunner.RunAsync ( | ||
| AdbPath!, | ||
| $"-s {serial} emu kill", |
There was a problem hiding this comment.
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.
| 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", |
| 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); |
There was a problem hiding this comment.
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).
| var isEmulator = serial.StartsWith ("emulator"); | ||
| var state = MapAdbState (status); | ||
| var model = ExtractDeviceName (line); | ||
| var details = ParseDeviceDetails (line); |
There was a problem hiding this comment.
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).
| var details = ParseDeviceDetails (line); |
| "google_apis_playstore" => "Google API's, Play Store", | ||
| "google_apis" => playStoreEnabled ? "Google API's, Play Store" : "Google API's", |
There was a problem hiding this comment.
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").
| "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", |
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| 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 | ||
| }; | ||
| } |
There was a problem hiding this comment.
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.
| process.StartInfo = new ProcessStartInfo { | ||
| FileName = fileName, | ||
| Arguments = arguments, | ||
| WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory, | ||
| UseShellExecute = false, | ||
| RedirectStandardOutput = true, | ||
| RedirectStandardError = true, | ||
| CreateNoWindow = true | ||
| }; |
There was a problem hiding this comment.
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.
| public static ToolRunnerResult Run ( | ||
| string fileName, | ||
| string arguments = "", | ||
| string? workingDirectory = null, | ||
| Dictionary<string, string>? environmentVariables = null, | ||
| TimeSpan? timeout = null, | ||
| CancellationToken cancellationToken = default) |
There was a problem hiding this comment.
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.
| 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); | ||
| } |
There was a problem hiding this comment.
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).
|
I'd like to get the System.Diagnostics.Process code unified like mentioned here: |
Summary
Adds
AdbRunnerclass to wrapadbCLI operations for device management.Addresses #279
New API
Usage
Dependencies
Requires #281 (base tool runner infrastructure) to be merged first.
Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com