Skip to content
Open
18 changes: 18 additions & 0 deletions src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;

namespace Xamarin.Android.Tools;

/// <summary>
/// Options for booting an Android emulator.
/// </summary>
public class EmulatorBootOptions
{
public TimeSpan BootTimeout { get; set; } = TimeSpan.FromSeconds (300);
public IEnumerable<string>? AdditionalArgs { get; set; }
public bool ColdBoot { get; set; }
public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds (500);
}
14 changes: 14 additions & 0 deletions src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Xamarin.Android.Tools;

/// <summary>
/// Result of an emulator boot operation.
/// </summary>
public record EmulatorBootResult
{
public bool Success { get; init; }
public string? Serial { get; init; }
public string? ErrorMessage { get; init; }
Comment on lines +9 to +13
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

PR description’s API surface shows EmulatorBootResult properties as settable ({ get; set; }), but the implementation uses init-only properties. If consumers are expected to mutate results after creation, these should be set; otherwise, please update the PR description/API notes (and ensure this immutability is intentional).

Copilot uses AI. Check for mistakes.
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ Xamarin.Android.Tools.AdbDeviceType
Xamarin.Android.Tools.AdbDeviceType.Device = 0 -> Xamarin.Android.Tools.AdbDeviceType
Xamarin.Android.Tools.AdbDeviceType.Emulator = 1 -> Xamarin.Android.Tools.AdbDeviceType
Xamarin.Android.Tools.AdbRunner
Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary<string!, string!>? environmentVariables = null) -> void
Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AdbDeviceInfo!>!>!
Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary<string!, string!>? environmentVariables = null, System.Action<System.Diagnostics.TraceLevel, string!>? logger = null) -> void
*REMOVED*Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AdbDeviceInfo!>!>!
virtual Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AdbDeviceInfo!>!>!
Xamarin.Android.Tools.AdbRunner.StopEmulatorAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Xamarin.Android.Tools.AdbRunner.WaitForDeviceAsync(string? serial = null, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Xamarin.Android.Tools.ChecksumType
Expand Down Expand Up @@ -145,3 +146,28 @@ Xamarin.Android.Tools.AvdManagerRunner.AvdManagerRunner(string! avdManagerPath,
Xamarin.Android.Tools.AvdManagerRunner.GetOrCreateAvdAsync(string! name, string! systemImage, string? deviceProfile = null, bool force = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Xamarin.Android.Tools.AvdInfo!>!
Xamarin.Android.Tools.AvdManagerRunner.DeleteAvdAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Xamarin.Android.Tools.AvdManagerRunner.ListAvdsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AvdInfo!>!>!
virtual Xamarin.Android.Tools.AdbRunner.GetShellPropertyAsync(string! serial, string! propertyName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<string?>!
virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task<string?>!
virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, string![]! args, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<string?>!
Xamarin.Android.Tools.EmulatorBootOptions
Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.get -> System.Collections.Generic.IEnumerable<string!>?
Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.set -> void
Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.get -> System.TimeSpan
Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.set -> void
Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.get -> bool
Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.set -> void
Xamarin.Android.Tools.EmulatorBootOptions.EmulatorBootOptions() -> void
Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.get -> System.TimeSpan
Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.set -> void
Xamarin.Android.Tools.EmulatorBootResult
Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.get -> string?
Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.init -> void
Xamarin.Android.Tools.EmulatorBootResult.Serial.get -> string?
Xamarin.Android.Tools.EmulatorBootResult.Serial.init -> void
Xamarin.Android.Tools.EmulatorBootResult.Success.get -> bool
Xamarin.Android.Tools.EmulatorBootResult.Success.init -> void
Xamarin.Android.Tools.EmulatorRunner
Xamarin.Android.Tools.EmulatorRunner.BootEmulatorAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Xamarin.Android.Tools.EmulatorBootResult!>!
Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary<string!, string!>? environmentVariables = null, System.Action<System.Diagnostics.TraceLevel, string!>? logger = null) -> void
Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<string!>!>!
Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable<string!>? additionalArgs = null) -> System.Diagnostics.Process!
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ Xamarin.Android.Tools.AdbDeviceType
Xamarin.Android.Tools.AdbDeviceType.Device = 0 -> Xamarin.Android.Tools.AdbDeviceType
Xamarin.Android.Tools.AdbDeviceType.Emulator = 1 -> Xamarin.Android.Tools.AdbDeviceType
Xamarin.Android.Tools.AdbRunner
Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary<string!, string!>? environmentVariables = null) -> void
Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AdbDeviceInfo!>!>!
Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary<string!, string!>? environmentVariables = null, System.Action<System.Diagnostics.TraceLevel, string!>? logger = null) -> void
*REMOVED*Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AdbDeviceInfo!>!>!
virtual Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AdbDeviceInfo!>!>!
Xamarin.Android.Tools.AdbRunner.StopEmulatorAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Xamarin.Android.Tools.AdbRunner.WaitForDeviceAsync(string? serial = null, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Xamarin.Android.Tools.ChecksumType
Expand Down Expand Up @@ -145,3 +146,28 @@ Xamarin.Android.Tools.AvdManagerRunner.AvdManagerRunner(string! avdManagerPath,
Xamarin.Android.Tools.AvdManagerRunner.GetOrCreateAvdAsync(string! name, string! systemImage, string? deviceProfile = null, bool force = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Xamarin.Android.Tools.AvdInfo!>!
Xamarin.Android.Tools.AvdManagerRunner.DeleteAvdAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Xamarin.Android.Tools.AvdManagerRunner.ListAvdsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AvdInfo!>!>!
virtual Xamarin.Android.Tools.AdbRunner.GetShellPropertyAsync(string! serial, string! propertyName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<string?>!
virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task<string?>!
virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, string![]! args, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<string?>!
Xamarin.Android.Tools.EmulatorBootOptions
Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.get -> System.Collections.Generic.IEnumerable<string!>?
Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.set -> void
Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.get -> System.TimeSpan
Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.set -> void
Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.get -> bool
Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.set -> void
Xamarin.Android.Tools.EmulatorBootOptions.EmulatorBootOptions() -> void
Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.get -> System.TimeSpan
Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.set -> void
Xamarin.Android.Tools.EmulatorBootResult
Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.get -> string?
Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.init -> void
Xamarin.Android.Tools.EmulatorBootResult.Serial.get -> string?
Xamarin.Android.Tools.EmulatorBootResult.Serial.init -> void
Xamarin.Android.Tools.EmulatorBootResult.Success.get -> bool
Xamarin.Android.Tools.EmulatorBootResult.Success.init -> void
Xamarin.Android.Tools.EmulatorRunner
Xamarin.Android.Tools.EmulatorRunner.BootEmulatorAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Xamarin.Android.Tools.EmulatorBootResult!>!
Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary<string!, string!>? environmentVariables = null, System.Action<System.Diagnostics.TraceLevel, string!>? logger = null) -> void
Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<string!>!>!
Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, System.Collections.Generic.IEnumerable<string!>? additionalArgs = null) -> System.Diagnostics.Process!
93 changes: 91 additions & 2 deletions src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class AdbRunner
{
readonly string adbPath;
readonly IDictionary<string, string>? environmentVariables;
readonly Action<TraceLevel, string>? logger;

// Pattern to match device lines: <serial> <state> [key:value ...]
// Uses \s+ to match one or more whitespace characters (spaces or tabs) between fields.
Expand All @@ -35,19 +36,21 @@ public class AdbRunner
/// </summary>
/// <param name="adbPath">Full path to the adb executable (e.g., "/path/to/sdk/platform-tools/adb").</param>
/// <param name="environmentVariables">Optional environment variables to pass to adb processes.</param>
public AdbRunner (string adbPath, IDictionary<string, string>? environmentVariables = null)
/// <param name="logger">Optional logger callback for diagnostic messages.</param>
public AdbRunner (string adbPath, IDictionary<string, string>? environmentVariables = null, Action<TraceLevel, string>? logger = null)
{
if (string.IsNullOrWhiteSpace (adbPath))
throw new ArgumentException ("Path to adb must not be empty.", nameof (adbPath));
this.adbPath = adbPath;
this.environmentVariables = environmentVariables;
this.logger = logger;
}

/// <summary>
/// Lists connected devices using 'adb devices -l'.
/// For emulators, queries the AVD name using 'adb -s &lt;serial&gt; emu avd name'.
/// </summary>
public async Task<IReadOnlyList<AdbDeviceInfo>> ListDevicesAsync (CancellationToken cancellationToken = default)
public virtual async Task<IReadOnlyList<AdbDeviceInfo>> ListDevicesAsync (CancellationToken cancellationToken = default)
{
using var stdout = new StringWriter ();
using var stderr = new StringWriter ();
Expand Down Expand Up @@ -135,6 +138,92 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati
ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} emu kill", stderr);
}

/// <summary>
/// Gets a system property from a device via 'adb -s &lt;serial&gt; shell getprop &lt;property&gt;'.
/// Returns the property value (first non-empty line of stdout), or <c>null</c> on failure.
/// </summary>
public virtual async Task<string?> GetShellPropertyAsync (string serial, string propertyName, CancellationToken cancellationToken = default)
{
using var stdout = new StringWriter ();
using var stderr = new StringWriter ();
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "shell", "getprop", propertyName);
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
if (exitCode != 0) {
var stderrText = stderr.ToString ().Trim ();
if (stderrText.Length > 0)
logger?.Invoke (TraceLevel.Warning, $"adb shell getprop {propertyName} failed (exit {exitCode}): {stderrText}");
return null;
}
return FirstNonEmptyLine (stdout.ToString ());
}

/// <summary>
/// Runs a shell command on a device via 'adb -s &lt;serial&gt; shell &lt;command&gt;'.
/// Returns the full stdout output trimmed, or <c>null</c> on failure.
/// </summary>
/// <remarks>
/// The <paramref name="command"/> is passed as a single argument to <c>adb shell</c>,
/// which means the device's shell interprets it (shell expansion, pipes, semicolons are active).
/// Do not pass untrusted or user-supplied input without proper validation.
/// </remarks>
public virtual async Task<string?> RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken)
{
using var stdout = new StringWriter ();
using var stderr = new StringWriter ();
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "shell", command);
Copy link
Member

Choose a reason for hiding this comment

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

🤖 💡 API design — \command\ is passed as a single argument to \�db shell, so the device shell interprets it (expansion, pipes, semicolons). Fine for current hardcoded callers, but since this is \public virtual, a future caller could pass unsanitized input.

Consider documenting the shell-interpretation behavior, or offering a structured overload where \�db shell\ receives multiple args and \�xec()\s directly.

Rule: Structured args, not string interpolation (Postmortem #49)

var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
if (exitCode != 0) {
var stderrText = stderr.ToString ().Trim ();
if (stderrText.Length > 0)
logger?.Invoke (TraceLevel.Warning, $"adb shell {command} failed (exit {exitCode}): {stderrText}");
return null;
}
var output = stdout.ToString ().Trim ();
return output.Length > 0 ? output : null;
}

/// <summary>
/// Runs a shell command on a device via <c>adb -s &lt;serial&gt; shell &lt;command&gt; &lt;args&gt;</c>.
/// Returns the full stdout output trimmed, or <c>null</c> on failure.
/// </summary>
/// <remarks>
/// When <c>adb shell</c> receives the command and arguments as separate tokens, it uses
/// <c>exec()</c> directly on the device — bypassing the device's shell interpreter.
/// This avoids shell expansion, pipes, and injection risks, making it safer for dynamic input.
/// </remarks>
public virtual async Task<string?> RunShellCommandAsync (string serial, string command, string[] args, CancellationToken cancellationToken = default)
{
using var stdout = new StringWriter ();
using var stderr = new StringWriter ();
// Build: adb -s <serial> shell <command> <arg1> <arg2> ...
var allArgs = new string [3 + 1 + args.Length];
allArgs [0] = "-s";
allArgs [1] = serial;
allArgs [2] = "shell";
allArgs [3] = command;
Array.Copy (args, 0, allArgs, 4, args.Length);
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, allArgs);
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
if (exitCode != 0) {
var stderrText = stderr.ToString ().Trim ();
if (stderrText.Length > 0)
logger?.Invoke (TraceLevel.Warning, $"adb shell {command} failed (exit {exitCode}): {stderrText}");
return null;
}
var output = stdout.ToString ().Trim ();
return output.Length > 0 ? output : null;
}

internal static string? FirstNonEmptyLine (string output)
{
foreach (var line in output.Split ('\n')) {
var trimmed = line.Trim ();
if (trimmed.Length > 0)
return trimmed;
}
return null;
}

/// <summary>
/// Parses the output lines from 'adb devices -l'.
/// Accepts an <see cref="IEnumerable{T}"/> to avoid allocating a joined string.
Expand Down
Loading