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 61fc0e0b..4322e5e0 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 @@ -199,3 +199,7 @@ virtual Xamarin.Android.Tools.AdbRunner.ListReversePortsAsync(string! serial, Sy 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, Xamarin.Android.Tools.AdbPortSpec! remote, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Xamarin.Android.Tools.AdbRunner.ReversePortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! remote, Xamarin.Android.Tools.AdbPortSpec! local, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.ForwardPortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! local, Xamarin.Android.Tools.AdbPortSpec! remote, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.ListForwardPortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual Xamarin.Android.Tools.AdbRunner.RemoveAllForwardPortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RemoveForwardPortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! 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 61fc0e0b..4322e5e0 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 @@ -199,3 +199,7 @@ virtual Xamarin.Android.Tools.AdbRunner.ListReversePortsAsync(string! serial, Sy 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, Xamarin.Android.Tools.AdbPortSpec! remote, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Xamarin.Android.Tools.AdbRunner.ReversePortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! remote, Xamarin.Android.Tools.AdbPortSpec! local, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.ForwardPortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! local, Xamarin.Android.Tools.AdbPortSpec! remote, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.ListForwardPortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual Xamarin.Android.Tools.AdbRunner.RemoveAllForwardPortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RemoveForwardPortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! local, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index 0eadc70e..ad5e8c9b 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -355,6 +355,126 @@ internal static IReadOnlyList ParseReverseListOutput (IEnumerable + /// Sets up forward port forwarding via 'adb -s <serial> forward <local> <remote>'. + /// The host-side <local> socket is forwarded to the device-side <remote> socket, + /// the symmetric pair to . + /// + /// Device serial number. + /// Local (host-side) port spec. + /// Remote (device-side) port spec. + /// Cancellation token. + public virtual async Task ForwardPortAsync (string serial, AdbPortSpec local, AdbPortSpec remote, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (serial)) + throw new ArgumentException ("Serial must not be empty.", nameof (serial)); + if (local is null) + throw new ArgumentNullException (nameof (local)); + if (remote is null) + throw new ArgumentNullException (nameof (remote)); + if (local.Port <= 0 || local.Port > 65535) + throw new ArgumentOutOfRangeException (nameof (local), local.Port, "Port must be between 1 and 65535."); + if (remote.Port <= 0 || remote.Port > 65535) + throw new ArgumentOutOfRangeException (nameof (remote), remote.Port, "Port must be between 1 and 65535."); + + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "forward", local.ToSocketSpec (), remote.ToSocketSpec ()); + using var stderr = new StringWriter (); + var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} forward {local} {remote}", stderr); + } + + /// + /// Removes a specific forward port forwarding rule via + /// 'adb -s <serial> forward --remove <local>'. + /// + /// Device serial number. + /// Local (host-side) port spec to remove. + /// Cancellation token. + public virtual async Task RemoveForwardPortAsync (string serial, AdbPortSpec local, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (serial)) + throw new ArgumentException ("Serial must not be empty.", nameof (serial)); + if (local is null) + throw new ArgumentNullException (nameof (local)); + if (local.Port <= 0 || local.Port > 65535) + throw new ArgumentOutOfRangeException (nameof (local), local.Port, "Port must be between 1 and 65535."); + + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "forward", "--remove", local.ToSocketSpec ()); + using var stderr = new StringWriter (); + var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} forward --remove {local}", stderr); + } + + /// + /// Removes all forward port forwarding rules for the device via + /// 'adb -s <serial> forward --remove-all'. + /// Note that the underlying adb command operates globally, but we scope it via -s. + /// + public virtual async Task RemoveAllForwardPortsAsync (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, "forward", "--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} forward --remove-all", stderr); + } + + /// + /// Lists active forward port forwarding rules for the specified device via + /// 'adb forward --list'. + /// The underlying command always lists rules across all devices, so the + /// result is filtered to entries matching . + /// + public virtual async Task> ListForwardPortsAsync (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, "forward", "--list"); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + ProcessUtils.ThrowIfFailed (exitCode, $"adb forward --list", stderr, stdout); + + return ParseForwardListOutput (stdout.ToString ().Split ('\n'), serial); + } + + /// + /// Parses the output of 'adb forward --list'. + /// Each line is "<serial> <local> <remote>", e.g. "emulator-5554 tcp:5000 tcp:6000". + /// Only rules matching are returned. Lines with + /// unparseable socket specs are skipped. + /// + internal static IReadOnlyList ParseForwardListOutput (IEnumerable lines, string serial) + { + var rules = new List (); + if (string.IsNullOrEmpty (serial)) + return rules; + + foreach (var line in lines) { + var trimmed = line.Trim (); + if (string.IsNullOrEmpty (trimmed)) + continue; + + // Expected format: " " + var parts = trimmed.Split ((char[]?) null, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 3) + continue; + + if (!string.Equals (parts [0], serial, StringComparison.Ordinal)) + continue; + + var local = AdbPortSpec.TryParse (parts [1]); + var remote = AdbPortSpec.TryParse (parts [2]); + if (local is { } l && remote is { } r) + rules.Add (new AdbPortRule (r, l)); + } + + 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 00b5ccc6..9fe488dc 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs @@ -863,6 +863,168 @@ public void ParseReverseListOutput_TabSeparated () Assert.AreEqual (8081, rules [1].Remote.Port); } + // --- ParseForwardListOutput tests --- + // Consumer: MAUI DevTools (via ListForwardPortsAsync — host→device tunnel for JDWP debugger + // attach, perf endpoints, host-side DevFlow agent connect), vscode-maui ServiceHub replacement. + // Output format differs from reverse: "(reverse) " → " ". + + [Test] + public void ParseForwardListOutput_SingleRule () + { + var output = new [] { + "emulator-5554 tcp:5000 tcp:6000", + }; + + var rules = AdbRunner.ParseForwardListOutput (output, "emulator-5554"); + + Assert.AreEqual (1, rules.Count); + Assert.AreEqual (AdbProtocol.Tcp, rules [0].Local.Protocol); + Assert.AreEqual (5000, rules [0].Local.Port); + Assert.AreEqual (AdbProtocol.Tcp, rules [0].Remote.Protocol); + Assert.AreEqual (6000, rules [0].Remote.Port); + } + + [Test] + public void ParseForwardListOutput_MultipleRulesSameDevice () + { + var output = new [] { + "emulator-5554 tcp:5000 tcp:6000", + "emulator-5554 tcp:8081 tcp:8081", + "emulator-5554 tcp:9222 tcp:9223", + }; + + var rules = AdbRunner.ParseForwardListOutput (output, "emulator-5554"); + + Assert.AreEqual (3, rules.Count); + Assert.AreEqual (5000, rules [0].Local.Port); + Assert.AreEqual (6000, rules [0].Remote.Port); + Assert.AreEqual (8081, rules [1].Local.Port); + Assert.AreEqual (8081, rules [1].Remote.Port); + Assert.AreEqual (9222, rules [2].Local.Port); + Assert.AreEqual (9223, rules [2].Remote.Port); + } + + [Test] + public void ParseForwardListOutput_FiltersByDeviceSerial () + { + // adb forward --list returns rules across ALL devices; we must filter. + var output = new [] { + "emulator-5554 tcp:5000 tcp:5000", + "emulator-5556 tcp:5001 tcp:5001", + "emulator-5554 tcp:8081 tcp:8081", + "abcd1234device tcp:9000 tcp:9000", + }; + + var rules = AdbRunner.ParseForwardListOutput (output, "emulator-5554"); + + Assert.AreEqual (2, rules.Count); + Assert.AreEqual (5000, rules [0].Local.Port); + Assert.AreEqual (8081, rules [1].Local.Port); + } + + [Test] + public void ParseForwardListOutput_EmptyOutput () + { + var output = new [] { "", " " }; + var rules = AdbRunner.ParseForwardListOutput (output, "emulator-5554"); + Assert.AreEqual (0, rules.Count); + } + + [Test] + public void ParseForwardListOutput_NoLines () + { + var rules = AdbRunner.ParseForwardListOutput (Array.Empty (), "emulator-5554"); + Assert.AreEqual (0, rules.Count); + } + + [Test] + public void ParseForwardListOutput_EmptySerial_ReturnsEmpty () + { + var output = new [] { "emulator-5554 tcp:5000 tcp:5000" }; + var rules = AdbRunner.ParseForwardListOutput (output, ""); + Assert.AreEqual (0, rules.Count); + } + + [Test] + public void ParseForwardListOutput_NoMatchingDevice () + { + var output = new [] { + "emulator-5554 tcp:5000 tcp:5000", + "emulator-5556 tcp:5001 tcp:5001", + }; + var rules = AdbRunner.ParseForwardListOutput (output, "missing-device"); + Assert.AreEqual (0, rules.Count); + } + + [Test] + public void ParseForwardListOutput_MalformedLine_InsufficientParts () + { + var output = new [] { + "emulator-5554 tcp:5000", // missing remote spec + }; + + var rules = AdbRunner.ParseForwardListOutput (output, "emulator-5554"); + Assert.AreEqual (0, rules.Count); + } + + [Test] + public void ParseForwardListOutput_NonTcpSpecs_SkipsUnparseable () + { + var output = new [] { + "emulator-5554 tcp:9222 localabstract:chrome_devtools_remote", + "emulator-5554 tcp:5000 tcp:5000", + }; + + var rules = AdbRunner.ParseForwardListOutput (output, "emulator-5554"); + + // localabstract:chrome_devtools_remote has a non-numeric port, so it is skipped + Assert.AreEqual (1, rules.Count); + Assert.AreEqual (5000, rules [0].Local.Port); + Assert.AreEqual (5000, rules [0].Remote.Port); + } + + [Test] + public void ParseForwardListOutput_WindowsLineEndings () + { + var output = new [] { + "emulator-5554 tcp:5000 tcp:6000\r", + "emulator-5554 tcp:8081 tcp:8081\r", + }; + + var rules = AdbRunner.ParseForwardListOutput (output, "emulator-5554"); + + Assert.AreEqual (2, rules.Count); + Assert.AreEqual (5000, rules [0].Local.Port); + Assert.AreEqual (6000, rules [0].Remote.Port); + Assert.AreEqual (8081, rules [1].Local.Port); + } + + [Test] + public void ParseForwardListOutput_TabSeparated () + { + var output = new [] { + "emulator-5554\ttcp:5000\ttcp:6000", + }; + + var rules = AdbRunner.ParseForwardListOutput (output, "emulator-5554"); + + Assert.AreEqual (1, rules.Count); + Assert.AreEqual (5000, rules [0].Local.Port); + Assert.AreEqual (6000, rules [0].Remote.Port); + } + + [Test] + public void ParseForwardListOutput_SerialMatch_IsCaseSensitive () + { + // adb device serials are case-sensitive. + var output = new [] { + "emulator-5554 tcp:5000 tcp:5000", + }; + + var rules = AdbRunner.ParseForwardListOutput (output, "EMULATOR-5554"); + Assert.AreEqual (0, rules.Count); + } + // --- AdbPortSpec tests --- [Test] @@ -1069,6 +1231,70 @@ public void ListReversePortsAsync_EmptySerial_ThrowsArgumentException () async () => await runner.ListReversePortsAsync ("")); } + // --- ForwardPortAsync parameter validation tests --- + + [Test] + public void ForwardPortAsync_EmptySerial_ThrowsArgumentException () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.ForwardPortAsync ("", new AdbPortSpec (AdbProtocol.Tcp, 5000), new AdbPortSpec (AdbProtocol.Tcp, 5000))); + } + + [Test] + public void ForwardPortAsync_NullLocal_ThrowsArgumentNull () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.ForwardPortAsync ("emulator-5554", (AdbPortSpec) null!, new AdbPortSpec (AdbProtocol.Tcp, 5000))); + } + + [Test] + public void ForwardPortAsync_NullRemote_ThrowsArgumentNull () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.ForwardPortAsync ("emulator-5554", new AdbPortSpec (AdbProtocol.Tcp, 5000), (AdbPortSpec) null!)); + } + + // --- RemoveForwardPortAsync parameter validation tests --- + + [Test] + public void RemoveForwardPortAsync_EmptySerial_ThrowsArgumentException () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.RemoveForwardPortAsync ("", new AdbPortSpec (AdbProtocol.Tcp, 5000))); + } + + [Test] + public void RemoveForwardPortAsync_NullLocal_ThrowsArgumentNull () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.RemoveForwardPortAsync ("emulator-5554", (AdbPortSpec) null!)); + } + + // --- RemoveAllForwardPortsAsync parameter validation tests --- + + [Test] + public void RemoveAllForwardPortsAsync_EmptySerial_ThrowsArgumentException () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.RemoveAllForwardPortsAsync ("")); + } + + // --- ListForwardPortsAsync parameter validation tests --- + + [Test] + public void ListForwardPortsAsync_EmptySerial_ThrowsArgumentException () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.ListForwardPortsAsync ("")); + } + // --- GetEmulatorAvdNameAsync + ListDevicesAsync tests --- // These tests use a fake 'adb' script to control process output, // verifying AVD detection order and offline emulator handling.