Skip to content

Commit 53ee762

Browse files
rmarinhoCopilotjonathanpeppers
authored
Add ADB reverse port forwarding support (#305)
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> Co-authored-by: Jonathan Peppers <jonathan.peppers@microsoft.com>
1 parent d679f2b commit 53ee762

7 files changed

Lines changed: 581 additions & 0 deletions

File tree

src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,25 @@ Xamarin.Android.Tools.EmulatorRunner.BootEmulatorAsync(string! deviceOrAvdName,
177177
Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary<string!, string!>? environmentVariables = null, System.Action<System.Diagnostics.TraceLevel, string!>? logger = null) -> void
178178
Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<string!>!>!
179179
Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, System.Collections.Generic.List<string!>? additionalArgs = null) -> System.Diagnostics.Process!
180+
Xamarin.Android.Tools.AdbPortRule
181+
Xamarin.Android.Tools.AdbPortRule.AdbPortRule(Xamarin.Android.Tools.AdbPortSpec! Remote, Xamarin.Android.Tools.AdbPortSpec! Local) -> void
182+
Xamarin.Android.Tools.AdbPortRule.Local.get -> Xamarin.Android.Tools.AdbPortSpec!
183+
Xamarin.Android.Tools.AdbPortRule.Local.init -> void
184+
Xamarin.Android.Tools.AdbPortRule.Remote.get -> Xamarin.Android.Tools.AdbPortSpec!
185+
Xamarin.Android.Tools.AdbPortRule.Remote.init -> void
186+
Xamarin.Android.Tools.AdbPortSpec
187+
Xamarin.Android.Tools.AdbPortSpec.AdbPortSpec(Xamarin.Android.Tools.AdbProtocol Protocol, int Port) -> void
188+
Xamarin.Android.Tools.AdbPortSpec.Port.get -> int
189+
Xamarin.Android.Tools.AdbPortSpec.Port.init -> void
190+
Xamarin.Android.Tools.AdbPortSpec.Protocol.get -> Xamarin.Android.Tools.AdbProtocol
191+
Xamarin.Android.Tools.AdbPortSpec.Protocol.init -> void
192+
Xamarin.Android.Tools.AdbPortSpec.ToSocketSpec() -> string!
193+
Xamarin.Android.Tools.AdbProtocol
194+
195+
Xamarin.Android.Tools.AdbProtocol.Tcp = 0 -> Xamarin.Android.Tools.AdbProtocol
196+
override Xamarin.Android.Tools.AdbPortSpec.ToString() -> string!
197+
static Xamarin.Android.Tools.AdbPortSpec.TryParse(string? socketSpec) -> Xamarin.Android.Tools.AdbPortSpec?
198+
virtual Xamarin.Android.Tools.AdbRunner.ListReversePortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AdbPortRule!>!>!
199+
virtual Xamarin.Android.Tools.AdbRunner.RemoveAllReversePortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
200+
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!
201+
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!

src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,25 @@ Xamarin.Android.Tools.EmulatorRunner.BootEmulatorAsync(string! deviceOrAvdName,
177177
Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary<string!, string!>? environmentVariables = null, System.Action<System.Diagnostics.TraceLevel, string!>? logger = null) -> void
178178
Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<string!>!>!
179179
Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, System.Collections.Generic.List<string!>? additionalArgs = null) -> System.Diagnostics.Process!
180+
Xamarin.Android.Tools.AdbPortRule
181+
Xamarin.Android.Tools.AdbPortRule.AdbPortRule(Xamarin.Android.Tools.AdbPortSpec! Remote, Xamarin.Android.Tools.AdbPortSpec! Local) -> void
182+
Xamarin.Android.Tools.AdbPortRule.Local.get -> Xamarin.Android.Tools.AdbPortSpec!
183+
Xamarin.Android.Tools.AdbPortRule.Local.init -> void
184+
Xamarin.Android.Tools.AdbPortRule.Remote.get -> Xamarin.Android.Tools.AdbPortSpec!
185+
Xamarin.Android.Tools.AdbPortRule.Remote.init -> void
186+
Xamarin.Android.Tools.AdbPortSpec
187+
Xamarin.Android.Tools.AdbPortSpec.AdbPortSpec(Xamarin.Android.Tools.AdbProtocol Protocol, int Port) -> void
188+
Xamarin.Android.Tools.AdbPortSpec.Port.get -> int
189+
Xamarin.Android.Tools.AdbPortSpec.Port.init -> void
190+
Xamarin.Android.Tools.AdbPortSpec.Protocol.get -> Xamarin.Android.Tools.AdbProtocol
191+
Xamarin.Android.Tools.AdbPortSpec.Protocol.init -> void
192+
Xamarin.Android.Tools.AdbPortSpec.ToSocketSpec() -> string!
193+
Xamarin.Android.Tools.AdbProtocol
194+
195+
Xamarin.Android.Tools.AdbProtocol.Tcp = 0 -> Xamarin.Android.Tools.AdbProtocol
196+
override Xamarin.Android.Tools.AdbPortSpec.ToString() -> string!
197+
static Xamarin.Android.Tools.AdbPortSpec.TryParse(string? socketSpec) -> Xamarin.Android.Tools.AdbPortSpec?
198+
virtual Xamarin.Android.Tools.AdbRunner.ListReversePortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AdbPortRule!>!>!
199+
virtual Xamarin.Android.Tools.AdbRunner.RemoveAllReversePortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
200+
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!
201+
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!
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Xamarin.Android.Tools;
5+
6+
/// <summary>
7+
/// Represents an adb port forwarding rule as reported by 'adb reverse --list' or 'adb forward --list'.
8+
/// </summary>
9+
public record AdbPortRule (AdbPortSpec Remote, AdbPortSpec Local);
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
6+
namespace Xamarin.Android.Tools;
7+
8+
/// <summary>
9+
/// Represents a port and protocol pair for adb forwarding/reverse operations.
10+
/// </summary>
11+
public record AdbPortSpec (AdbProtocol Protocol, int Port)
12+
{
13+
/// <summary>
14+
/// Returns the adb socket spec string, e.g. "tcp:5000".
15+
/// </summary>
16+
public string ToSocketSpec () => Protocol switch {
17+
AdbProtocol.Tcp => FormattableString.Invariant ($"tcp:{Port}"),
18+
_ => throw new ArgumentOutOfRangeException (nameof (Protocol), Protocol, $"Unsupported ADB protocol: {Protocol}"),
19+
};
20+
21+
/// <summary>
22+
/// Parses an adb socket spec string like "tcp:5000" into an <see cref="AdbPortSpec"/>.
23+
/// Returns null if the format is unrecognized.
24+
/// </summary>
25+
public static AdbPortSpec? TryParse (string? socketSpec)
26+
{
27+
if (socketSpec is not { Length: > 0 } value || string.IsNullOrWhiteSpace (value))
28+
return null;
29+
30+
var colonIndex = value.IndexOf (':');
31+
if (colonIndex <= 0 || colonIndex >= value.Length - 1)
32+
return null;
33+
34+
var protocolStr = value.Substring (0, colonIndex);
35+
var portStr = value.Substring (colonIndex + 1);
36+
37+
if (!int.TryParse (portStr, out var port) || port <= 0 || port > 65535)
38+
return null;
39+
40+
var protocol = protocolStr.ToLowerInvariant () switch {
41+
"tcp" => (AdbProtocol?) AdbProtocol.Tcp,
42+
_ => null,
43+
};
44+
45+
return protocol.HasValue ? new AdbPortSpec (protocol.Value, port) : null;
46+
}
47+
48+
public override string ToString () => ToSocketSpec ();
49+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Xamarin.Android.Tools;
5+
6+
/// <summary>
7+
/// Protocol types supported by adb port forwarding and reverse port forwarding.
8+
/// </summary>
9+
public enum AdbProtocol
10+
{
11+
/// <summary>
12+
/// TCP socket spec, e.g. "tcp:5000".
13+
/// </summary>
14+
Tcp,
15+
}

src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,117 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati
244244
return null;
245245
}
246246

247+
/// <summary>
248+
/// Sets up reverse port forwarding via 'adb -s &lt;serial&gt; reverse &lt;remote&gt; &lt;local&gt;'.
249+
/// </summary>
250+
/// <param name="serial">Device serial number.</param>
251+
/// <param name="remote">Remote (device-side) port spec.</param>
252+
/// <param name="local">Local (host-side) port spec.</param>
253+
/// <param name="cancellationToken">Cancellation token.</param>
254+
public virtual async Task ReversePortAsync (string serial, AdbPortSpec remote, AdbPortSpec local, CancellationToken cancellationToken = default)
255+
{
256+
if (string.IsNullOrWhiteSpace (serial))
257+
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
258+
if (remote is null)
259+
throw new ArgumentNullException (nameof (remote));
260+
if (local is null)
261+
throw new ArgumentNullException (nameof (local));
262+
if (remote.Port <= 0 || remote.Port > 65535)
263+
throw new ArgumentOutOfRangeException (nameof (remote), remote.Port, "Port must be between 1 and 65535.");
264+
if (local.Port <= 0 || local.Port > 65535)
265+
throw new ArgumentOutOfRangeException (nameof (local), local.Port, "Port must be between 1 and 65535.");
266+
267+
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "reverse", remote.ToSocketSpec (), local.ToSocketSpec ());
268+
using var stderr = new StringWriter ();
269+
var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
270+
ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} reverse {remote} {local}", stderr);
271+
}
272+
273+
/// <summary>
274+
/// Removes a specific reverse port forwarding rule via
275+
/// 'adb -s &lt;serial&gt; reverse --remove &lt;remote&gt;'.
276+
/// </summary>
277+
/// <param name="serial">Device serial number.</param>
278+
/// <param name="remote">Remote (device-side) port spec to remove.</param>
279+
/// <param name="cancellationToken">Cancellation token.</param>
280+
public virtual async Task RemoveReversePortAsync (string serial, AdbPortSpec remote, CancellationToken cancellationToken = default)
281+
{
282+
if (string.IsNullOrWhiteSpace (serial))
283+
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
284+
if (remote is null)
285+
throw new ArgumentNullException (nameof (remote));
286+
if (remote.Port <= 0 || remote.Port > 65535)
287+
throw new ArgumentOutOfRangeException (nameof (remote), remote.Port, "Port must be between 1 and 65535.");
288+
289+
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "reverse", "--remove", remote.ToSocketSpec ());
290+
using var stderr = new StringWriter ();
291+
var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
292+
ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} reverse --remove {remote}", stderr);
293+
}
294+
295+
/// <summary>
296+
/// Removes all reverse port forwarding rules via
297+
/// 'adb -s &lt;serial&gt; reverse --remove-all'.
298+
/// </summary>
299+
public virtual async Task RemoveAllReversePortsAsync (string serial, CancellationToken cancellationToken = default)
300+
{
301+
if (string.IsNullOrWhiteSpace (serial))
302+
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
303+
304+
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "reverse", "--remove-all");
305+
using var stderr = new StringWriter ();
306+
var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
307+
ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} reverse --remove-all", stderr);
308+
}
309+
310+
/// <summary>
311+
/// Lists all active reverse port forwarding rules via
312+
/// 'adb -s &lt;serial&gt; reverse --list'.
313+
/// </summary>
314+
public virtual async Task<IReadOnlyList<AdbPortRule>> ListReversePortsAsync (string serial, CancellationToken cancellationToken = default)
315+
{
316+
if (string.IsNullOrWhiteSpace (serial))
317+
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
318+
319+
using var stdout = new StringWriter ();
320+
using var stderr = new StringWriter ();
321+
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "reverse", "--list");
322+
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
323+
ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} reverse --list", stderr, stdout);
324+
325+
return ParseReverseListOutput (stdout.ToString ().Split ('\n'));
326+
}
327+
328+
/// <summary>
329+
/// Parses the output of 'adb reverse --list'.
330+
/// Each line is "(reverse) &lt;remote&gt; &lt;local&gt;", e.g. "(reverse) tcp:5000 tcp:5000".
331+
/// Lines with unparseable socket specs are skipped.
332+
/// </summary>
333+
internal static IReadOnlyList<AdbPortRule> ParseReverseListOutput (IEnumerable<string> lines)
334+
{
335+
var rules = new List<AdbPortRule> ();
336+
337+
foreach (var line in lines) {
338+
var trimmed = line.Trim ();
339+
if (string.IsNullOrEmpty (trimmed))
340+
continue;
341+
342+
// Expected format: "(reverse) tcp:5000 tcp:5000"
343+
if (!trimmed.StartsWith ("(reverse)", StringComparison.Ordinal))
344+
continue;
345+
346+
var parts = trimmed.Substring ("(reverse)".Length).Trim ().Split ((char[]?) null, StringSplitOptions.RemoveEmptyEntries);
347+
if (parts.Length >= 2) {
348+
var remote = AdbPortSpec.TryParse (parts [0]);
349+
var local = AdbPortSpec.TryParse (parts [1]);
350+
if (remote is { } r && local is { } l)
351+
rules.Add (new AdbPortRule (r, l));
352+
}
353+
}
354+
355+
return rules;
356+
}
357+
247358
/// <summary>
248359
/// Parses the output lines from 'adb devices -l'.
249360
/// Accepts an <see cref="IEnumerable{T}"/> to avoid allocating a joined string.

0 commit comments

Comments
 (0)