diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceInfo.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceInfo.cs
new file mode 100644
index 00000000..a6be7d71
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceInfo.cs
@@ -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
+{
+ public class AdbDeviceInfo
+ {
+ public string Serial { get; set; } = string.Empty;
+ public string? State { get; set; }
+ public string? Model { get; set; }
+ public string? Device { get; set; }
+ public bool IsEmulator => Serial.StartsWith ("emulator-");
+ }
+}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs
new file mode 100644
index 00000000..4b598ac5
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs
@@ -0,0 +1,125 @@
+// 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;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Xamarin.Android.Tools
+{
+ ///
+ /// Runs Android Debug Bridge (adb) commands.
+ ///
+ public class AdbRunner
+ {
+ readonly Func getSdkPath;
+ static readonly Regex DetailsRegex = new Regex (@"(\w+):(\S+)", RegexOptions.Compiled);
+
+ public AdbRunner (Func getSdkPath)
+ {
+ this.getSdkPath = getSdkPath ?? throw new ArgumentNullException (nameof (getSdkPath));
+ }
+
+ public string? AdbPath {
+ get {
+ var sdkPath = getSdkPath ();
+ if (!string.IsNullOrEmpty (sdkPath)) {
+ var ext = OS.IsWindows ? ".exe" : "";
+ var sdkAdb = Path.Combine (sdkPath, "platform-tools", "adb" + ext);
+ if (File.Exists (sdkAdb))
+ return sdkAdb;
+ }
+ return ProcessUtils.FindExecutablesInPath ("adb").FirstOrDefault ();
+ }
+ }
+
+ public bool IsAvailable => AdbPath is not null;
+
+ string RequireAdb ()
+ {
+ return AdbPath ?? throw new InvalidOperationException ("ADB not found.");
+ }
+
+ ProcessStartInfo CreateAdbProcess (string adbPath, params string [] args)
+ {
+ var psi = new ProcessStartInfo {
+ FileName = adbPath,
+ Arguments = string.Join (" ", args),
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ var sdkPath = getSdkPath ();
+ if (!string.IsNullOrEmpty (sdkPath))
+ psi.EnvironmentVariables ["ANDROID_HOME"] = sdkPath;
+
+ return psi;
+ }
+
+ public async Task> ListDevicesAsync (CancellationToken cancellationToken = default)
+ {
+ var adb = RequireAdb ();
+ var stdout = new StringWriter ();
+ var psi = CreateAdbProcess (adb, "devices", "-l");
+ await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false);
+
+ var devices = new List ();
+ foreach (var line in stdout.ToString ().Split ('\n')) {
+ var trimmed = line.Trim ();
+ if (string.IsNullOrEmpty (trimmed) || trimmed.StartsWith ("List of", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ var parts = trimmed.Split (new [] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length < 2)
+ continue;
+
+ var device = new AdbDeviceInfo { Serial = parts [0], State = parts [1] };
+ foreach (Match match in DetailsRegex.Matches (trimmed)) {
+ switch (match.Groups [1].Value.ToLowerInvariant ()) {
+ case "model": device.Model = match.Groups [2].Value; break;
+ case "device": device.Device = match.Groups [2].Value; break;
+ }
+ }
+ devices.Add (device);
+ }
+ return devices;
+ }
+
+ public async Task WaitForDeviceAsync (string? serial = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
+ {
+ var adb = RequireAdb ();
+ var effectiveTimeout = timeout ?? TimeSpan.FromSeconds (60);
+
+ var args = string.IsNullOrEmpty (serial)
+ ? new [] { "wait-for-device" }
+ : new [] { "-s", serial, "wait-for-device" };
+
+ var psi = CreateAdbProcess (adb, args);
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken);
+ cts.CancelAfter (effectiveTimeout);
+
+ try {
+ await ProcessUtils.StartProcess (psi, null, null, cts.Token).ConfigureAwait (false);
+ } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) {
+ throw new TimeoutException ($"Timed out waiting for device after {effectiveTimeout.TotalSeconds}s.");
+ }
+ }
+
+ public async Task StopEmulatorAsync (string serial, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace (serial))
+ throw new ArgumentException ("Serial must not be empty.", nameof (serial));
+
+ var adb = RequireAdb ();
+ var psi = CreateAdbProcess (adb, "-s", serial, "emu", "kill");
+ await ProcessUtils.StartProcess (psi, null, null, cancellationToken).ConfigureAwait (false);
+ }
+ }
+}
+