From 3f768267285d3c21269e25e7ceed4777971ebf3b Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 13 Mar 2026 09:19:43 +0000 Subject: [PATCH 1/2] Add ADB reverse port forwarding support to AdbRunner Add ReversePortAsync, RemoveReversePortAsync, RemoveAllReversePortsAsync, and ListReversePortsAsync methods to AdbRunner for managing reverse port forwarding rules. These APIs enable the MAUI DevTools CLI to manage hot-reload tunnels without going through ServiceHub. New type AdbReversePortRule represents entries from 'adb reverse --list'. Internal ParseReverseListOutput handles parsing the output format. Includes 14 new tests covering parsing and parameter validation. Closes #303 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 10 ++ .../netstandard2.0/PublicAPI.Unshipped.txt | 10 ++ .../Runners/AdbReversePortRule.cs | 20 +++ .../Runners/AdbRunner.cs | 102 +++++++++++ .../AdbRunnerTests.cs | 167 ++++++++++++++++++ 5 files changed, 309 insertions(+) create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbReversePortRule.cs 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..5c1d7b67 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,13 @@ 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() -> 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.ReversePortAsync(string! serial, int remotePort, int localPort, 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..5c1d7b67 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,13 @@ 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() -> 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.ReversePortAsync(string! serial, int remotePort, int localPort, 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..d1503a00 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbReversePortRule.cs @@ -0,0 +1,20 @@ +// 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'. +/// +public class AdbReversePortRule +{ + /// + /// The remote (device-side) socket spec, e.g. "tcp:5000". + /// + public string Remote { get; init; } = string.Empty; + + /// + /// The local (host-side) socket spec, e.g. "tcp:5000". + /// + public string Local { get; init; } = string.Empty; +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index 24a62cec..46be7956 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -135,6 +135,108 @@ 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 tcp:<remotePort> tcp:<localPort>'. + /// This allows the device to connect to a service on the host machine through the specified port. + /// + public virtual async Task ReversePortAsync (string serial, int remotePort, int localPort, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (serial)) + throw new ArgumentException ("Serial must not be empty.", nameof (serial)); + if (remotePort <= 0 || remotePort > 65535) + throw new ArgumentOutOfRangeException (nameof (remotePort), remotePort, "Port must be between 1 and 65535."); + if (localPort <= 0 || localPort > 65535) + throw new ArgumentOutOfRangeException (nameof (localPort), localPort, "Port must be between 1 and 65535."); + + var remoteSpec = $"tcp:{remotePort}"; + var localSpec = $"tcp:{localPort}"; + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "reverse", remoteSpec, localSpec); + using var stderr = new StringWriter (); + var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} reverse {remoteSpec} {localSpec}", stderr); + } + + /// + /// Removes a specific reverse port forwarding rule via + /// 'adb -s <serial> reverse --remove tcp:<remotePort>'. + /// + public virtual async Task RemoveReversePortAsync (string serial, int remotePort, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (serial)) + throw new ArgumentException ("Serial must not be empty.", nameof (serial)); + if (remotePort <= 0 || remotePort > 65535) + throw new ArgumentOutOfRangeException (nameof (remotePort), remotePort, "Port must be between 1 and 65535."); + + var remoteSpec = $"tcp:{remotePort}"; + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "reverse", "--remove", remoteSpec); + 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 {remoteSpec}", stderr); + } + + /// + /// 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; + } + /// /// 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..ed0bc008 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs @@ -655,4 +655,171 @@ 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); + } + + // --- 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)); + } + + // --- 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)); + } + + // --- 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 ("")); + } } From 7874c05fb7c024d7f5f7786227397694593309bd Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Mon, 16 Mar 2026 14:32:42 +0000 Subject: [PATCH 2/2] Improve ADB reverse port API: record type, string overloads, more tests Based on multi-model code review (GPT-5.1 + Gemini-3-Pro): - Convert AdbReversePortRule from class to positional record for value equality and concise construction - Add string socket-spec overloads for ReversePortAsync and RemoveReversePortAsync to support non-TCP protocols (e.g., localabstract:, localfilesystem:) matching existing patterns in ClientTools.Platform and vscode-maui - Extract ValidatePort helper for consistent validation - Int overloads now delegate to string overloads as convenience wrappers - Add 8 new tests: NonTcpSpecs, WindowsLineEndings, ValueEquality, Deconstruct, ToString, and string overload validation tests (total: 25 reverse-port tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 4 +- .../netstandard2.0/PublicAPI.Unshipped.txt | 4 +- .../Runners/AdbReversePortRule.cs | 16 +-- .../Runners/AdbRunner.cs | 79 +++++++++---- .../AdbRunnerTests.cs | 110 ++++++++++++++++++ 5 files changed, 176 insertions(+), 37 deletions(-) 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 5c1d7b67..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 @@ -146,7 +146,7 @@ Xamarin.Android.Tools.AvdManagerRunner.GetOrCreateAvdAsync(string! name, string! 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() -> void +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! @@ -154,4 +154,6 @@ 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 5c1d7b67..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 @@ -146,7 +146,7 @@ Xamarin.Android.Tools.AvdManagerRunner.GetOrCreateAvdAsync(string! name, string! 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() -> void +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! @@ -154,4 +154,6 @@ 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 index d1503a00..cff0719a 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbReversePortRule.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbReversePortRule.cs @@ -5,16 +5,8 @@ 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(). /// -public class AdbReversePortRule -{ - /// - /// The remote (device-side) socket spec, e.g. "tcp:5000". - /// - public string Remote { get; init; } = string.Empty; - - /// - /// The local (host-side) socket spec, e.g. "tcp:5000". - /// - public string Local { get; init; } = string.Empty; -} +/// 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 46be7956..5526fc9c 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -137,42 +137,69 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati /// /// Sets up reverse port forwarding from the device to the host via - /// 'adb -s <serial> reverse tcp:<remotePort> tcp:<localPort>'. - /// This allows the device to connect to a service on the host machine through the specified port. + /// '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. /// - public virtual async Task ReversePortAsync (string serial, int remotePort, int localPort, CancellationToken cancellationToken = default) + /// 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 (remotePort <= 0 || remotePort > 65535) - throw new ArgumentOutOfRangeException (nameof (remotePort), remotePort, "Port must be between 1 and 65535."); - if (localPort <= 0 || localPort > 65535) - throw new ArgumentOutOfRangeException (nameof (localPort), localPort, "Port must be between 1 and 65535."); - - var remoteSpec = $"tcp:{remotePort}"; - var localSpec = $"tcp:{localPort}"; - var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "reverse", remoteSpec, localSpec); + 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 {remoteSpec} {localSpec}", stderr); + 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 tcp:<remotePort>'. + /// 'adb -s <serial> reverse --remove <remote>'. + /// Supports any socket spec accepted by adb. /// - public virtual async Task RemoveReversePortAsync (string serial, int remotePort, CancellationToken cancellationToken = default) + /// 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 (remotePort <= 0 || remotePort > 65535) - throw new ArgumentOutOfRangeException (nameof (remotePort), remotePort, "Port must be between 1 and 65535."); + if (string.IsNullOrWhiteSpace (remote)) + throw new ArgumentException ("Remote socket spec must not be empty.", nameof (remote)); - var remoteSpec = $"tcp:{remotePort}"; - var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "reverse", "--remove", remoteSpec); + 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 {remoteSpec}", stderr); + 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); } /// @@ -227,16 +254,22 @@ internal static IReadOnlyList ParseReverseListOutput (IEnume 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], - }); + 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 ed0bc008..9eba1b47 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs @@ -751,6 +751,72 @@ public void ParseReverseListOutput_DifferentRemoteAndLocalPorts () 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] @@ -785,6 +851,32 @@ public void ReversePortAsync_PortAbove65535_ThrowsArgumentOutOfRange () 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] @@ -803,6 +895,24 @@ public void RemoveReversePortAsync_ZeroPort_ThrowsArgumentOutOfRange () 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]