diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs new file mode 100644 index 00000000..8765df77 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs @@ -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; + +namespace Xamarin.Android.Tools +{ + /// + /// Options for booting an Android emulator. + /// + public class EmulatorBootOptions + { + public TimeSpan BootTimeout { get; set; } = TimeSpan.FromSeconds (300); + public string? AdditionalArgs { get; set; } + public bool ColdBoot { get; set; } + public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds (500); + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs new file mode 100644 index 00000000..59281792 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs @@ -0,0 +1,15 @@ +// 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 +{ + /// + /// Result of an emulator boot operation. + /// + public class EmulatorBootResult + { + public bool Success { get; set; } + public string? Serial { get; set; } + public string? ErrorMessage { get; set; } + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index f321af6d..e743bf9c 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -47,7 +47,7 @@ public AdbRunner (string adbPath, IDictionary? environmentVariab /// Lists connected devices using 'adb devices -l'. /// For emulators, queries the AVD name using 'adb -s <serial> emu avd name'. /// - public async Task> ListDevicesAsync (CancellationToken cancellationToken = default) + public virtual async Task> ListDevicesAsync (CancellationToken cancellationToken = default) { using var stdout = new StringWriter (); using var stderr = new StringWriter (); @@ -135,6 +135,40 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} emu kill", stderr); } + /// + /// Gets a system property from a device via 'adb -s <serial> shell getprop <property>'. + /// + public virtual async Task 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); + return exitCode == 0 ? FirstNonEmptyLine (stdout.ToString ()) : null; + } + + /// + /// Runs a shell command on a device via 'adb -s <serial> shell <command>'. + /// + public virtual async Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default) + { + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "shell", command); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + return exitCode == 0 ? FirstNonEmptyLine (stdout.ToString ()) : 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; + } + /// /// Parses the output lines from 'adb devices -l'. /// Accepts an to avoid allocating a joined string. diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs index 51f6309d..3cf67207 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs @@ -37,4 +37,15 @@ internal static Dictionary GetEnvironmentVariables (string? sdkP return env; } + + /// + /// Applies Android SDK environment variables directly to a . + /// Used by runners that manage their own process lifecycle (e.g., EmulatorRunner). + /// + internal static void ConfigureEnvironment (System.Diagnostics.ProcessStartInfo psi, string? sdkPath, string? jdkPath) + { + var env = GetEnvironmentVariables (sdkPath, jdkPath); + foreach (var kvp in env) + psi.Environment [kvp.Key] = kvp.Value; + } } diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs new file mode 100644 index 00000000..837108f5 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -0,0 +1,237 @@ +// 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.Threading; +using System.Threading.Tasks; + +namespace Xamarin.Android.Tools; + +/// +/// Runs Android Emulator commands. +/// +public class EmulatorRunner +{ + readonly Func getSdkPath; + readonly Func? getJdkPath; + + public EmulatorRunner (Func getSdkPath) + : this (getSdkPath, null) + { + } + + public EmulatorRunner (Func getSdkPath, Func? getJdkPath) + { + this.getSdkPath = getSdkPath ?? throw new ArgumentNullException (nameof (getSdkPath)); + this.getJdkPath = getJdkPath; + } + + public string? EmulatorPath { + get { + var sdkPath = getSdkPath (); + if (string.IsNullOrEmpty (sdkPath)) + return null; + + var ext = OS.IsWindows ? ".exe" : ""; + var path = Path.Combine (sdkPath, "emulator", "emulator" + ext); + + return File.Exists (path) ? path : null; + } + } + + public bool IsAvailable => EmulatorPath is not null; + + string RequireEmulatorPath () + { + return EmulatorPath ?? throw new InvalidOperationException ("Android Emulator not found."); + } + + void ConfigureEnvironment (ProcessStartInfo psi) + { + AndroidEnvironmentHelper.ConfigureEnvironment (psi, getSdkPath (), getJdkPath?.Invoke ()); + } + + public Process StartAvd (string avdName, bool coldBoot = false, string? additionalArgs = null) + { + var emulatorPath = RequireEmulatorPath (); + + var args = new List { "-avd", avdName }; + if (coldBoot) + args.Add ("-no-snapshot-load"); + if (!string.IsNullOrEmpty (additionalArgs)) + args.Add (additionalArgs); + + var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, args.ToArray ()); + ConfigureEnvironment (psi); + + // Redirect stdout/stderr so the emulator process doesn't inherit the + // caller's pipes. Without this, parent processes (e.g. VS Code spawn) + // never see the 'close' event because the emulator holds the pipes open. + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + + var process = new Process { StartInfo = psi }; + process.Start (); + + return process; + } + + public async Task> ListAvdNamesAsync (CancellationToken cancellationToken = default) + { + var emulatorPath = RequireEmulatorPath (); + + using var stdout = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, "-list-avds"); + ConfigureEnvironment (psi); + + await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false); + + return ParseListAvdsOutput (stdout.ToString ()); + } + + internal static List ParseListAvdsOutput (string output) + { + var avds = new List (); + foreach (var line in output.Split ('\n')) { + var trimmed = line.Trim (); + if (!string.IsNullOrEmpty (trimmed)) + avds.Add (trimmed); + } + return avds; + } + + /// + /// Boots an emulator and waits for it to be fully booted. + /// Ported from dotnet/android BootAndroidEmulator MSBuild task. + /// + public async Task BootAndWaitAsync ( + string deviceOrAvdName, + AdbRunner adbRunner, + EmulatorBootOptions? options = null, + Action? logger = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (deviceOrAvdName)) + throw new ArgumentException ("Device or AVD name must not be empty.", nameof (deviceOrAvdName)); + if (adbRunner == null) + throw new ArgumentNullException (nameof (adbRunner)); + + options = options ?? new EmulatorBootOptions (); + void Log (TraceLevel level, string message) => logger?.Invoke (level, message); + + Log (TraceLevel.Info, $"Booting emulator for '{deviceOrAvdName}'..."); + + // Phase 1: Check if deviceOrAvdName is already an online ADB device by serial + var devices = await adbRunner.ListDevicesAsync (cancellationToken).ConfigureAwait (false); + var onlineDevice = devices.FirstOrDefault (d => + d.Status == AdbDeviceStatus.Online && + string.Equals (d.Serial, deviceOrAvdName, StringComparison.OrdinalIgnoreCase)); + + if (onlineDevice != null) { + Log (TraceLevel.Info, $"Device '{deviceOrAvdName}' is already online."); + return new EmulatorBootResult { Success = true, Serial = onlineDevice.Serial }; + } + + // Phase 2: Check if AVD is already running (possibly still booting) + var runningSerial = FindRunningAvdSerial (devices, deviceOrAvdName); + if (runningSerial != null) { + Log (TraceLevel.Info, $"AVD '{deviceOrAvdName}' is already running as '{runningSerial}', waiting for full boot..."); + return await WaitForFullBootAsync (adbRunner, runningSerial, options, logger, cancellationToken).ConfigureAwait (false); + } + + // Phase 3: Launch the emulator + if (EmulatorPath == null) { + return new EmulatorBootResult { + Success = false, + ErrorMessage = "Android Emulator not found. Ensure the Android SDK is installed and the emulator is available.", + }; + } + + Log (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'..."); + Process emulatorProcess; + try { + emulatorProcess = StartAvd (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs); + } catch (Exception ex) { + return new EmulatorBootResult { + Success = false, + ErrorMessage = $"Failed to launch emulator: {ex.Message}", + }; + } + + // Poll for the new emulator serial to appear + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); + timeoutCts.CancelAfter (options.BootTimeout); + + try { + string? newSerial = null; + while (newSerial == null) { + timeoutCts.Token.ThrowIfCancellationRequested (); + await Task.Delay (options.PollInterval, timeoutCts.Token).ConfigureAwait (false); + + devices = await adbRunner.ListDevicesAsync (timeoutCts.Token).ConfigureAwait (false); + newSerial = FindRunningAvdSerial (devices, deviceOrAvdName); + } + + Log (TraceLevel.Info, $"Emulator appeared as '{newSerial}', waiting for full boot..."); + return await WaitForFullBootAsync (adbRunner, newSerial, options, logger, timeoutCts.Token).ConfigureAwait (false); + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + return new EmulatorBootResult { + Success = false, + ErrorMessage = $"Timed out waiting for emulator '{deviceOrAvdName}' to boot within {options.BootTimeout.TotalSeconds}s.", + }; + } + } + + static string? FindRunningAvdSerial (IReadOnlyList devices, string avdName) + { + foreach (var d in devices) { + if (d.Type == AdbDeviceType.Emulator && + !string.IsNullOrEmpty (d.AvdName) && + string.Equals (d.AvdName, avdName, StringComparison.OrdinalIgnoreCase)) { + return d.Serial; + } + } + return null; + } + + async Task WaitForFullBootAsync ( + AdbRunner adbRunner, + string serial, + EmulatorBootOptions options, + Action? logger, + CancellationToken cancellationToken) + { + void Log (TraceLevel level, string message) => logger?.Invoke (level, message); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); + timeoutCts.CancelAfter (options.BootTimeout); + + try { + while (true) { + timeoutCts.Token.ThrowIfCancellationRequested (); + + var bootCompleted = await adbRunner.GetShellPropertyAsync (serial, "sys.boot_completed", timeoutCts.Token).ConfigureAwait (false); + if (string.Equals (bootCompleted, "1", StringComparison.Ordinal)) { + var pmResult = await adbRunner.RunShellCommandAsync (serial, "pm path android", timeoutCts.Token).ConfigureAwait (false); + if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.Ordinal)) { + Log (TraceLevel.Info, $"Emulator '{serial}' is fully booted."); + return new EmulatorBootResult { Success = true, Serial = serial }; + } + } + + await Task.Delay (options.PollInterval, timeoutCts.Token).ConfigureAwait (false); + } + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + return new EmulatorBootResult { + Success = false, + Serial = serial, + ErrorMessage = $"Timed out waiting for emulator '{serial}' to fully boot within {options.BootTimeout.TotalSeconds}s.", + }; + } + } +} + diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs new file mode 100644 index 00000000..490b18e7 --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -0,0 +1,326 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests; + +[TestFixture] +public class EmulatorRunnerTests +{ + [Test] + public void ParseListAvdsOutput_MultipleAvds () + { + var output = "Pixel_7_API_35\nMAUI_Emulator\nNexus_5X\n"; + + var avds = EmulatorRunner.ParseListAvdsOutput (output); + + Assert.AreEqual (3, avds.Count); + Assert.AreEqual ("Pixel_7_API_35", avds [0]); + Assert.AreEqual ("MAUI_Emulator", avds [1]); + Assert.AreEqual ("Nexus_5X", avds [2]); + } + + [Test] + public void ParseListAvdsOutput_EmptyOutput () + { + var avds = EmulatorRunner.ParseListAvdsOutput (""); + Assert.AreEqual (0, avds.Count); + } + + [Test] + public void ParseListAvdsOutput_WindowsNewlines () + { + var output = "Pixel_7_API_35\r\nMAUI_Emulator\r\n"; + + var avds = EmulatorRunner.ParseListAvdsOutput (output); + + Assert.AreEqual (2, avds.Count); + Assert.AreEqual ("Pixel_7_API_35", avds [0]); + Assert.AreEqual ("MAUI_Emulator", avds [1]); + } + + [Test] + public void ParseListAvdsOutput_BlankLines () + { + var output = "\nPixel_7_API_35\n\n\nMAUI_Emulator\n\n"; + + var avds = EmulatorRunner.ParseListAvdsOutput (output); + + Assert.AreEqual (2, avds.Count); + } + + [Test] + public void EmulatorPath_FindsInSdk () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"emu-test-{Path.GetRandomFileName ()}"); + var emulatorDir = Path.Combine (tempDir, "emulator"); + Directory.CreateDirectory (emulatorDir); + + try { + var emuName = OS.IsWindows ? "emulator.exe" : "emulator"; + File.WriteAllText (Path.Combine (emulatorDir, emuName), ""); + + var runner = new EmulatorRunner (() => tempDir); + + Assert.IsNotNull (runner.EmulatorPath); + Assert.IsTrue (runner.IsAvailable); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + public void EmulatorPath_MissingSdk_ReturnsNull () + { + var runner = new EmulatorRunner (() => "/nonexistent/path"); + Assert.IsNull (runner.EmulatorPath); + Assert.IsFalse (runner.IsAvailable); + } + + [Test] + public void EmulatorPath_NullSdk_ReturnsNull () + { + var runner = new EmulatorRunner (() => null); + Assert.IsNull (runner.EmulatorPath); + Assert.IsFalse (runner.IsAvailable); + } + + // --- BootAndWaitAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- + + [Test] + public async Task AlreadyOnlineDevice_PassesThrough () + { + var devices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }, + }; + + var mockAdb = new MockAdbRunner (devices); + var runner = new EmulatorRunner (() => null); + + var result = await runner.BootAndWaitAsync ("emulator-5554", mockAdb); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5554", result.Serial); + Assert.IsNull (result.ErrorMessage); + } + + [Test] + public async Task AvdAlreadyRunning_WaitsForFullBoot () + { + var devices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }, + }; + + var mockAdb = new MockAdbRunner (devices); + mockAdb.ShellProperties ["sys.boot_completed"] = "1"; + mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk"; + + var runner = new EmulatorRunner (() => null); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5554", result.Serial); + } + + [Test] + public async Task BootEmulator_AppearsAfterPolling () + { + var devices = new List (); + var mockAdb = new MockAdbRunner (devices); + mockAdb.ShellProperties ["sys.boot_completed"] = "1"; + mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk"; + + int pollCount = 0; + mockAdb.OnListDevices = () => { + pollCount++; + if (pollCount >= 2) { + devices.Add (new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }); + } + }; + + var tempDir = CreateFakeEmulatorSdk (); + try { + var runner = new EmulatorRunner (() => tempDir); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromSeconds (10), + PollInterval = TimeSpan.FromMilliseconds (50), + }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5554", result.Serial); + Assert.IsTrue (pollCount >= 2); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + public async Task LaunchFailure_ReturnsError () + { + var devices = new List (); + var mockAdb = new MockAdbRunner (devices); + + // No emulator path → EmulatorPath returns null → error + var runner = new EmulatorRunner (() => null); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (2) }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsFalse (result.Success); + Assert.IsNotNull (result.ErrorMessage); + Assert.IsTrue (result.ErrorMessage!.Contains ("not found"), $"Unexpected error: {result.ErrorMessage}"); + } + + [Test] + public async Task BootTimeout_BootCompletedNeverReaches1 () + { + var devices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }, + }; + + var mockAdb = new MockAdbRunner (devices); + // boot_completed never returns "1" + mockAdb.ShellProperties ["sys.boot_completed"] = "0"; + + var runner = new EmulatorRunner (() => null); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromMilliseconds (200), + PollInterval = TimeSpan.FromMilliseconds (50), + }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsFalse (result.Success); + Assert.IsNotNull (result.ErrorMessage); + Assert.IsTrue (result.ErrorMessage!.Contains ("Timed out"), $"Unexpected error: {result.ErrorMessage}"); + } + + [Test] + public async Task MultipleEmulators_FindsCorrectAvd () + { + var devices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_5_API_30", + }, + new AdbDeviceInfo { + Serial = "emulator-5556", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }, + new AdbDeviceInfo { + Serial = "emulator-5558", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Nexus_5X_API_28", + }, + }; + + var mockAdb = new MockAdbRunner (devices); + mockAdb.ShellProperties ["sys.boot_completed"] = "1"; + mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk"; + + var runner = new EmulatorRunner (() => null); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5556", result.Serial, "Should find the correct AVD among multiple emulators"); + } + + // --- Helpers --- + + static string CreateFakeEmulatorSdk () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"emu-boot-test-{Path.GetRandomFileName ()}"); + var emulatorDir = Path.Combine (tempDir, "emulator"); + Directory.CreateDirectory (emulatorDir); + + var emuName = OS.IsWindows ? "emulator.exe" : "emulator"; + var emuPath = Path.Combine (emulatorDir, emuName); + // Create a fake emulator script that just exits + if (OS.IsWindows) { + File.WriteAllText (emuPath, "@echo off"); + } else { + File.WriteAllText (emuPath, "#!/bin/sh\nsleep 60\n"); + // Make executable + var psi = new ProcessStartInfo ("chmod", $"+x \"{emuPath}\"") { UseShellExecute = false }; + Process.Start (psi)?.WaitForExit (); + } + + return tempDir; + } + + /// + /// Mock AdbRunner for testing BootAndWaitAsync without real adb commands. + /// + class MockAdbRunner : AdbRunner + { + readonly List devices; + + public Dictionary ShellProperties { get; } = new Dictionary (StringComparer.OrdinalIgnoreCase); + public Dictionary ShellCommands { get; } = new Dictionary (StringComparer.OrdinalIgnoreCase); + public Action? OnListDevices { get; set; } + + public MockAdbRunner (List devices) + : base ("/fake/adb") + { + this.devices = devices; + } + + public override Task> ListDevicesAsync (CancellationToken cancellationToken = default) + { + OnListDevices?.Invoke (); + return Task.FromResult> (devices); + } + + public override Task GetShellPropertyAsync (string serial, string propertyName, CancellationToken cancellationToken = default) + { + ShellProperties.TryGetValue (propertyName, out var value); + return Task.FromResult (value); + } + + public override Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default) + { + ShellCommands.TryGetValue (command, out var value); + return Task.FromResult (value); + } + } +}