Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AdbPortRule!>!>!
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!
Original file line number Diff line number Diff line change
Expand Up @@ -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<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AdbPortRule!>!>!
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!
120 changes: 120 additions & 0 deletions src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,126 @@ internal static IReadOnlyList<AdbPortRule> ParseReverseListOutput (IEnumerable<s
return rules;
}

/// <summary>
/// Sets up forward port forwarding via 'adb -s &lt;serial&gt; forward &lt;local&gt; &lt;remote&gt;'.
/// The host-side &lt;local&gt; socket is forwarded to the device-side &lt;remote&gt; socket,
/// the symmetric pair to <see cref="ReversePortAsync"/>.
/// </summary>
/// <param name="serial">Device serial number.</param>
/// <param name="local">Local (host-side) port spec.</param>
/// <param name="remote">Remote (device-side) port spec.</param>
/// <param name="cancellationToken">Cancellation token.</param>
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);
}

/// <summary>
/// Removes a specific forward port forwarding rule via
/// 'adb -s &lt;serial&gt; forward --remove &lt;local&gt;'.
/// </summary>
/// <param name="serial">Device serial number.</param>
/// <param name="local">Local (host-side) port spec to remove.</param>
/// <param name="cancellationToken">Cancellation token.</param>
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);
}

/// <summary>
/// Removes all forward port forwarding rules for the device via
/// 'adb -s &lt;serial&gt; forward --remove-all'.
/// Note that the underlying adb command operates globally, but we scope it via -s.
/// </summary>
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);
}

/// <summary>
/// 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 <paramref name="serial"/>.
/// </summary>
public virtual async Task<IReadOnlyList<AdbPortRule>> 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);
}

/// <summary>
/// Parses the output of 'adb forward --list'.
/// Each line is "&lt;serial&gt; &lt;local&gt; &lt;remote&gt;", e.g. "emulator-5554 tcp:5000 tcp:6000".
/// Only rules matching <paramref name="serial"/> are returned. Lines with
/// unparseable socket specs are skipped.
/// </summary>
internal static IReadOnlyList<AdbPortRule> ParseForwardListOutput (IEnumerable<string> lines, string serial)
{
var rules = new List<AdbPortRule> ();
if (string.IsNullOrEmpty (serial))
return rules;

foreach (var line in lines) {
var trimmed = line.Trim ();
if (string.IsNullOrEmpty (trimmed))
continue;

// Expected format: "<serial> <local> <remote>"
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;
}

/// <summary>
/// Parses the output lines from 'adb devices -l'.
/// Accepts an <see cref="IEnumerable{T}"/> to avoid allocating a joined string.
Expand Down
226 changes: 226 additions & 0 deletions tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) <remote> <local>" → "<serial> <local> <remote>".

[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<string> (), "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]
Expand Down Expand Up @@ -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<System.ArgumentException> (
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<System.ArgumentNullException> (
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<System.ArgumentNullException> (
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<System.ArgumentException> (
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<System.ArgumentNullException> (
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<System.ArgumentException> (
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<System.ArgumentException> (
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.
Expand Down