diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 03a3352e..7d711363 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -145,3 +145,15 @@ 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.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!>! +Xamarin.Android.Tools.AdbReversePortRule +Xamarin.Android.Tools.AdbReversePortRule.AdbReversePortRule(string! Remote, string! Local) -> void +Xamarin.Android.Tools.AdbReversePortRule.Local.get -> string! +Xamarin.Android.Tools.AdbReversePortRule.Local.init -> void +Xamarin.Android.Tools.AdbReversePortRule.Remote.get -> string! +Xamarin.Android.Tools.AdbReversePortRule.Remote.init -> void +virtual Xamarin.Android.Tools.AdbRunner.ListReversePortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual Xamarin.Android.Tools.AdbRunner.RemoveAllReversePortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RemoveReversePortAsync(string! serial, int remotePort, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RemoveReversePortAsync(string! serial, string! remote, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.ReversePortAsync(string! serial, int remotePort, int localPort, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.ReversePortAsync(string! serial, string! remote, string! local, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 03a3352e..7d711363 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -145,3 +145,15 @@ 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.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!>! +Xamarin.Android.Tools.AdbReversePortRule +Xamarin.Android.Tools.AdbReversePortRule.AdbReversePortRule(string! Remote, string! Local) -> void +Xamarin.Android.Tools.AdbReversePortRule.Local.get -> string! +Xamarin.Android.Tools.AdbReversePortRule.Local.init -> void +Xamarin.Android.Tools.AdbReversePortRule.Remote.get -> string! +Xamarin.Android.Tools.AdbReversePortRule.Remote.init -> void +virtual Xamarin.Android.Tools.AdbRunner.ListReversePortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual Xamarin.Android.Tools.AdbRunner.RemoveAllReversePortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RemoveReversePortAsync(string! serial, int remotePort, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RemoveReversePortAsync(string! serial, string! remote, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.ReversePortAsync(string! serial, int remotePort, int localPort, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.ReversePortAsync(string! serial, string! remote, string! local, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbReversePortRule.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbReversePortRule.cs new file mode 100644 index 00000000..cff0719a --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbReversePortRule.cs @@ -0,0 +1,12 @@ +// 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; + +/// +/// Represents a single ADB reverse port forwarding rule as reported by 'adb reverse --list'. +/// Uses positional record for value equality and built-in ToString(). +/// +/// The remote (device-side) socket spec, e.g. "tcp:5000". +/// The local (host-side) socket spec, e.g. "tcp:5000". +public record AdbReversePortRule (string Remote, string Local); diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index 24a62cec..5526fc9c 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -135,6 +135,141 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} emu kill", stderr); } + /// + /// Sets up reverse port forwarding from the device to the host via + /// 'adb -s <serial> reverse <remote> <local>'. + /// Supports any socket spec accepted by adb (tcp:PORT, localabstract:NAME, etc.). + /// This is the core overload; the (int, int) convenience overload delegates here. + /// + /// Device serial number. + /// Remote (device-side) socket spec, e.g. "tcp:5000" or "localabstract:foo". + /// Local (host-side) socket spec, e.g. "tcp:5000". + /// Cancellation token. + public virtual async Task ReversePortAsync (string serial, string remote, string local, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (serial)) + throw new ArgumentException ("Serial must not be empty.", nameof (serial)); + if (string.IsNullOrWhiteSpace (remote)) + throw new ArgumentException ("Remote socket spec must not be empty.", nameof (remote)); + if (string.IsNullOrWhiteSpace (local)) + throw new ArgumentException ("Local socket spec must not be empty.", nameof (local)); + + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "reverse", remote, local); + using var stderr = new StringWriter (); + var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} reverse {remote} {local}", stderr); + } + + /// + /// TCP convenience overload: sets up reverse port forwarding via + /// 'adb -s <serial> reverse tcp:<remotePort> tcp:<localPort>'. + /// + public virtual Task ReversePortAsync (string serial, int remotePort, int localPort, CancellationToken cancellationToken = default) + { + ValidatePort (remotePort, nameof (remotePort)); + ValidatePort (localPort, nameof (localPort)); + return ReversePortAsync (serial, $"tcp:{remotePort}", $"tcp:{localPort}", cancellationToken); + } + + /// + /// Removes a specific reverse port forwarding rule via + /// 'adb -s <serial> reverse --remove <remote>'. + /// Supports any socket spec accepted by adb. + /// + /// Device serial number. + /// Remote (device-side) socket spec to remove, e.g. "tcp:5000". + /// Cancellation token. + public virtual async Task RemoveReversePortAsync (string serial, string remote, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (serial)) + throw new ArgumentException ("Serial must not be empty.", nameof (serial)); + if (string.IsNullOrWhiteSpace (remote)) + throw new ArgumentException ("Remote socket spec must not be empty.", nameof (remote)); + + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "reverse", "--remove", remote); + using var stderr = new StringWriter (); + var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} reverse --remove {remote}", stderr); + } + + /// + /// TCP convenience overload: removes a specific reverse port forwarding rule via + /// 'adb -s <serial> reverse --remove tcp:<remotePort>'. + /// + public virtual Task RemoveReversePortAsync (string serial, int remotePort, CancellationToken cancellationToken = default) + { + ValidatePort (remotePort, nameof (remotePort)); + return RemoveReversePortAsync (serial, $"tcp:{remotePort}", cancellationToken); + } + + /// + /// Removes all reverse port forwarding rules via + /// 'adb -s <serial> reverse --remove-all'. + /// + public virtual async Task RemoveAllReversePortsAsync (string serial, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (serial)) + throw new ArgumentException ("Serial must not be empty.", nameof (serial)); + + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "reverse", "--remove-all"); + using var stderr = new StringWriter (); + var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} reverse --remove-all", stderr); + } + + /// + /// Lists all active reverse port forwarding rules via + /// 'adb -s <serial> reverse --list'. + /// + public virtual async Task> ListReversePortsAsync (string serial, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (serial)) + throw new ArgumentException ("Serial must not be empty.", nameof (serial)); + + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "reverse", "--list"); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} reverse --list", stderr); + + return ParseReverseListOutput (stdout.ToString ().Split ('\n')); + } + + /// + /// Parses the output of 'adb reverse --list'. + /// Each line is "(reverse) <remote> <local>", e.g. "(reverse) tcp:5000 tcp:5000". + /// + internal static IReadOnlyList ParseReverseListOutput (IEnumerable lines) + { + var rules = new List (); + + foreach (var line in lines) { + var trimmed = line.Trim (); + if (string.IsNullOrEmpty (trimmed)) + continue; + + // Expected format: "(reverse) tcp:5000 tcp:5000" + if (!trimmed.StartsWith ("(reverse)", StringComparison.Ordinal)) + continue; + + var parts = trimmed.Substring ("(reverse)".Length).Trim ().Split (new [] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2) { + rules.Add (new AdbReversePortRule ( + Remote: parts [0], + Local: parts [1] + )); + } + } + + return rules; + } + + static void ValidatePort (int port, string paramName) + { + if (port <= 0 || port > 65535) + throw new ArgumentOutOfRangeException (paramName, port, "Port must be between 1 and 65535."); + } + /// /// Parses the output lines from 'adb devices -l'. /// Accepts an to avoid allocating a joined string. diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs index 1e9a232a..9eba1b47 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs @@ -655,4 +655,281 @@ public void WaitForDeviceAsync_ZeroTimeout_ThrowsArgumentOutOfRange () Assert.ThrowsAsync ( async () => await runner.WaitForDeviceAsync (timeout: System.TimeSpan.Zero)); } + + // --- ParseReverseListOutput tests --- + // Consumer: MAUI DevTools (via ListReversePortsAsync), vscode-maui ServiceHub replacement + + [Test] + public void ParseReverseListOutput_SingleRule () + { + var output = new [] { + "(reverse) tcp:5000 tcp:5000", + }; + + var rules = AdbRunner.ParseReverseListOutput (output); + + Assert.AreEqual (1, rules.Count); + Assert.AreEqual ("tcp:5000", rules [0].Remote); + Assert.AreEqual ("tcp:5000", rules [0].Local); + } + + [Test] + public void ParseReverseListOutput_MultipleRules () + { + var output = new [] { + "(reverse) tcp:5000 tcp:5000", + "(reverse) tcp:8081 tcp:8081", + "(reverse) tcp:19000 tcp:19001", + }; + + var rules = AdbRunner.ParseReverseListOutput (output); + + Assert.AreEqual (3, rules.Count); + Assert.AreEqual ("tcp:5000", rules [0].Remote); + Assert.AreEqual ("tcp:5000", rules [0].Local); + Assert.AreEqual ("tcp:8081", rules [1].Remote); + Assert.AreEqual ("tcp:8081", rules [1].Local); + Assert.AreEqual ("tcp:19000", rules [2].Remote); + Assert.AreEqual ("tcp:19001", rules [2].Local); + } + + [Test] + public void ParseReverseListOutput_EmptyOutput () + { + var output = new [] { "", " " }; + var rules = AdbRunner.ParseReverseListOutput (output); + Assert.AreEqual (0, rules.Count); + } + + [Test] + public void ParseReverseListOutput_NoLines () + { + var rules = AdbRunner.ParseReverseListOutput (Array.Empty ()); + Assert.AreEqual (0, rules.Count); + } + + [Test] + public void ParseReverseListOutput_IgnoresNonReverseLines () + { + var output = new [] { + "some random header", + "(reverse) tcp:5000 tcp:5000", + "* daemon started successfully", + "(reverse) tcp:8081 tcp:8081", + "", + }; + + var rules = AdbRunner.ParseReverseListOutput (output); + + Assert.AreEqual (2, rules.Count); + Assert.AreEqual ("tcp:5000", rules [0].Remote); + Assert.AreEqual ("tcp:8081", rules [1].Remote); + } + + [Test] + public void ParseReverseListOutput_MalformedLine_InsufficientParts () + { + var output = new [] { + "(reverse) tcp:5000", // missing local spec + }; + + var rules = AdbRunner.ParseReverseListOutput (output); + Assert.AreEqual (0, rules.Count); + } + + [Test] + public void ParseReverseListOutput_DifferentRemoteAndLocalPorts () + { + var output = new [] { + "(reverse) tcp:8080 tcp:3000", + }; + + var rules = AdbRunner.ParseReverseListOutput (output); + + Assert.AreEqual (1, rules.Count); + Assert.AreEqual ("tcp:8080", rules [0].Remote); + Assert.AreEqual ("tcp:3000", rules [0].Local); + } + + [Test] + public void ParseReverseListOutput_NonTcpSpecs () + { + var output = new [] { + "(reverse) localabstract:chrome_devtools_remote tcp:9222", + "(reverse) tcp:5000 tcp:5000", + }; + + var rules = AdbRunner.ParseReverseListOutput (output); + + Assert.AreEqual (2, rules.Count); + Assert.AreEqual ("localabstract:chrome_devtools_remote", rules [0].Remote); + Assert.AreEqual ("tcp:9222", rules [0].Local); + Assert.AreEqual ("tcp:5000", rules [1].Remote); + Assert.AreEqual ("tcp:5000", rules [1].Local); + } + + [Test] + public void ParseReverseListOutput_WindowsLineEndings () + { + // Simulate \r\n line endings (split on \n leaves trailing \r) + var output = new [] { + "(reverse) tcp:5000 tcp:5000\r", + "(reverse) tcp:8081 tcp:8081\r", + }; + + var rules = AdbRunner.ParseReverseListOutput (output); + + Assert.AreEqual (2, rules.Count); + Assert.AreEqual ("tcp:5000", rules [0].Remote); + Assert.AreEqual ("tcp:8081", rules [1].Remote); + } + + [Test] + public void AdbReversePortRule_ValueEquality () + { + var rule1 = new AdbReversePortRule ("tcp:5000", "tcp:5000"); + var rule2 = new AdbReversePortRule ("tcp:5000", "tcp:5000"); + var rule3 = new AdbReversePortRule ("tcp:5000", "tcp:3000"); + + Assert.AreEqual (rule1, rule2); + Assert.AreNotEqual (rule1, rule3); + Assert.IsTrue (rule1 == rule2); + Assert.IsFalse (rule1 == rule3); + } + + [Test] + public void AdbReversePortRule_Deconstruct () + { + var rule = new AdbReversePortRule ("tcp:5000", "tcp:3000"); + var (remote, local) = rule; + + Assert.AreEqual ("tcp:5000", remote); + Assert.AreEqual ("tcp:3000", local); + } + + [Test] + public void AdbReversePortRule_ToString () + { + var rule = new AdbReversePortRule ("tcp:5000", "tcp:3000"); + var str = rule.ToString (); + + Assert.That (str, Does.Contain ("tcp:5000")); + Assert.That (str, Does.Contain ("tcp:3000")); + } + + // --- ReversePortAsync parameter validation tests --- + + [Test] + public void ReversePortAsync_EmptySerial_ThrowsArgumentException () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.ReversePortAsync ("", 5000, 5000)); + } + + [Test] + public void ReversePortAsync_ZeroRemotePort_ThrowsArgumentOutOfRange () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.ReversePortAsync ("emulator-5554", 0, 5000)); + } + + [Test] + public void ReversePortAsync_NegativeLocalPort_ThrowsArgumentOutOfRange () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.ReversePortAsync ("emulator-5554", 5000, -1)); + } + + [Test] + public void ReversePortAsync_PortAbove65535_ThrowsArgumentOutOfRange () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.ReversePortAsync ("emulator-5554", 70000, 5000)); + } + + // --- ReversePortAsync string overload validation tests --- + + [Test] + public void ReversePortAsync_String_EmptySerial_ThrowsArgumentException () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.ReversePortAsync ("", "tcp:5000", "tcp:5000")); + } + + [Test] + public void ReversePortAsync_String_EmptyRemote_ThrowsArgumentException () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.ReversePortAsync ("emulator-5554", "", "tcp:5000")); + } + + [Test] + public void ReversePortAsync_String_EmptyLocal_ThrowsArgumentException () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.ReversePortAsync ("emulator-5554", "tcp:5000", "")); + } + + // --- RemoveReversePortAsync parameter validation tests --- + + [Test] + public void RemoveReversePortAsync_EmptySerial_ThrowsArgumentException () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.RemoveReversePortAsync ("", 5000)); + } + + [Test] + public void RemoveReversePortAsync_ZeroPort_ThrowsArgumentOutOfRange () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.RemoveReversePortAsync ("emulator-5554", 0)); + } + + // --- RemoveReversePortAsync string overload validation tests --- + + [Test] + public void RemoveReversePortAsync_String_EmptySerial_ThrowsArgumentException () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.RemoveReversePortAsync ("", "tcp:5000")); + } + + [Test] + public void RemoveReversePortAsync_String_EmptyRemote_ThrowsArgumentException () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.RemoveReversePortAsync ("emulator-5554", "")); + } + + // --- RemoveAllReversePortsAsync parameter validation tests --- + + [Test] + public void RemoveAllReversePortsAsync_EmptySerial_ThrowsArgumentException () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.RemoveAllReversePortsAsync ("")); + } + + // --- ListReversePortsAsync parameter validation tests --- + + [Test] + public void ListReversePortsAsync_EmptySerial_ThrowsArgumentException () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.ListReversePortsAsync ("")); + } }