Skip to content
Open
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,8 @@ 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!
Xamarin.Android.Tools.AdbDeviceTracker
Xamarin.Android.Tools.AdbDeviceTracker.AdbDeviceTracker(string? adbPath = null, int port = 5037, System.Collections.Generic.IDictionary<string!, string!>? environmentVariables = null, System.Action<System.Diagnostics.TraceLevel, string!>? logger = null) -> void
Xamarin.Android.Tools.AdbDeviceTracker.CurrentDevices.get -> System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AdbDeviceInfo!>!
Xamarin.Android.Tools.AdbDeviceTracker.Dispose() -> void
Xamarin.Android.Tools.AdbDeviceTracker.StartAsync(System.Action<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AdbDeviceInfo!>!>! onDevicesChanged, 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,8 @@ 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!
Xamarin.Android.Tools.AdbDeviceTracker
Xamarin.Android.Tools.AdbDeviceTracker.AdbDeviceTracker(string? adbPath = null, int port = 5037, System.Collections.Generic.IDictionary<string!, string!>? environmentVariables = null, System.Action<System.Diagnostics.TraceLevel, string!>? logger = null) -> void
Xamarin.Android.Tools.AdbDeviceTracker.CurrentDevices.get -> System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AdbDeviceInfo!>!
Xamarin.Android.Tools.AdbDeviceTracker.Dispose() -> void
Xamarin.Android.Tools.AdbDeviceTracker.StartAsync(System.Action<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.AdbDeviceInfo!>!>! onDevicesChanged, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
189 changes: 189 additions & 0 deletions src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbDeviceTracker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using System.Linq; is not used in this file and will trigger CS8019 (and can fail Release builds when warnings are treated as errors). Remove the unnecessary using.

Suggested change
using System.Linq;

Copilot uses AI. Check for mistakes.
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace Xamarin.Android.Tools;

/// <summary>
/// Monitors ADB device connections in real-time via the <c>host:track-devices-l</c> socket protocol.
/// Pushes device list updates through a callback whenever devices connect, disconnect, or change state.
/// </summary>
public sealed class AdbDeviceTracker : IDisposable
{
readonly int port;
readonly Action<TraceLevel, string> logger;
readonly string? adbPath;
readonly IDictionary<string, string>? environmentVariables;
IReadOnlyList<AdbDeviceInfo> currentDevices = Array.Empty<AdbDeviceInfo> ();
CancellationTokenSource? trackingCts;
bool disposed;

/// <summary>
/// Creates a new AdbDeviceTracker.
/// </summary>
/// <param name="adbPath">Optional path to the adb executable for starting the server if needed.</param>
/// <param name="port">ADB daemon port (default 5037).</param>
/// <param name="environmentVariables">Optional environment variables for adb processes.</param>
/// <param name="logger">Optional logger callback.</param>
public AdbDeviceTracker (string? adbPath = null, int port = 5037,
IDictionary<string, string>? environmentVariables = null,
Action<TraceLevel, string>? logger = null)
{
if (port <= 0 || port > 65535)
throw new ArgumentOutOfRangeException (nameof (port), "Port must be between 1 and 65535.");
this.adbPath = adbPath;
this.port = port;
this.environmentVariables = environmentVariables;
this.logger = logger ?? RunnerDefaults.NullLogger;
Comment on lines +22 to +46
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adbPath and environmentVariables are assigned but never used, which will raise CS0414 (and can fail Release builds when warnings are treated as errors). Either implement the advertised behavior (e.g., start/ensure the ADB server using adbPath and pass environmentVariables to that process) or remove these fields/ctor parameters to avoid a misleading public API surface.

Copilot uses AI. Check for mistakes.
}

/// <summary>
/// Current snapshot of tracked devices.
/// </summary>
public IReadOnlyList<AdbDeviceInfo> CurrentDevices => currentDevices;

/// <summary>
/// Starts tracking device changes. Calls <paramref name="onDevicesChanged"/> whenever
/// the device list changes. Blocks until cancelled or disposed.
/// Automatically reconnects on connection drops with exponential backoff.
/// </summary>
/// <param name="onDevicesChanged">Callback invoked with the updated device list on each change.</param>
/// <param name="cancellationToken">Token to stop tracking.</param>
public async Task StartAsync (
Action<IReadOnlyList<AdbDeviceInfo>> onDevicesChanged,
CancellationToken cancellationToken = default)
{
if (onDevicesChanged == null)
throw new ArgumentNullException (nameof (onDevicesChanged));
if (disposed)
throw new ObjectDisposedException (nameof (AdbDeviceTracker));

trackingCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken);
var token = trackingCts.Token;
var backoffMs = InitialBackoffMs;

while (!token.IsCancellationRequested) {
try {
await TrackDevicesAsync (onDevicesChanged, token).ConfigureAwait (false);
} catch (OperationCanceledException) when (token.IsCancellationRequested) {
break;
} catch (Exception ex) {
logger.Invoke (TraceLevel.Warning, $"ADB tracking connection lost: {ex.Message}. Reconnecting in {backoffMs}ms...");
try {
await Task.Delay (backoffMs, token).ConfigureAwait (false);
} catch (OperationCanceledException) {
break;
}
backoffMs = Math.Min (backoffMs * 2, MaxBackoffMs);
continue;
}
// Reset backoff on clean connection
backoffMs = InitialBackoffMs;
Comment on lines +70 to +90
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StartAsync creates and stores a linked CancellationTokenSource but never disposes it when StartAsync completes (only on Dispose()), and repeated StartAsync calls will overwrite trackingCts. Consider using a local CTS with a try/finally to dispose it, and guarding against multiple concurrent starts (throw or no-op) to avoid leaks/races.

Suggested change
trackingCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken);
var token = trackingCts.Token;
var backoffMs = InitialBackoffMs;
while (!token.IsCancellationRequested) {
try {
await TrackDevicesAsync (onDevicesChanged, token).ConfigureAwait (false);
} catch (OperationCanceledException) when (token.IsCancellationRequested) {
break;
} catch (Exception ex) {
logger.Invoke (TraceLevel.Warning, $"ADB tracking connection lost: {ex.Message}. Reconnecting in {backoffMs}ms...");
try {
await Task.Delay (backoffMs, token).ConfigureAwait (false);
} catch (OperationCanceledException) {
break;
}
backoffMs = Math.Min (backoffMs * 2, MaxBackoffMs);
continue;
}
// Reset backoff on clean connection
backoffMs = InitialBackoffMs;
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken);
if (Interlocked.CompareExchange (ref trackingCts, linkedCts, null) != null) {
linkedCts.Dispose ();
throw new InvalidOperationException ("Device tracking has already been started.");
}
try {
var token = linkedCts.Token;
var backoffMs = InitialBackoffMs;
while (!token.IsCancellationRequested) {
try {
await TrackDevicesAsync (onDevicesChanged, token).ConfigureAwait (false);
} catch (OperationCanceledException) when (token.IsCancellationRequested) {
break;
} catch (Exception ex) {
logger.Invoke (TraceLevel.Warning, $"ADB tracking connection lost: {ex.Message}. Reconnecting in {backoffMs}ms...");
try {
await Task.Delay (backoffMs, token).ConfigureAwait (false);
} catch (OperationCanceledException) {
break;
}
backoffMs = Math.Min (backoffMs * 2, MaxBackoffMs);
continue;
}
// Reset backoff on clean connection
backoffMs = InitialBackoffMs;
}
} finally {
Interlocked.CompareExchange (ref trackingCts, null, linkedCts);
linkedCts.Dispose ();

Copilot uses AI. Check for mistakes.
}
}

const int InitialBackoffMs = 500;
const int MaxBackoffMs = 16000;

async Task TrackDevicesAsync (
Action<IReadOnlyList<AdbDeviceInfo>> onDevicesChanged,
CancellationToken cancellationToken)
{
using var client = new TcpClient ();
#if NET5_0_OR_GREATER
await client.ConnectAsync ("127.0.0.1", port, cancellationToken).ConfigureAwait (false);
#else
await client.ConnectAsync ("127.0.0.1", port).ConfigureAwait (false);
cancellationToken.ThrowIfCancellationRequested ();
#endif

var stream = client.GetStream ();
logger.Invoke (TraceLevel.Verbose, "Connected to ADB daemon, sending track-devices-l command");

// Send: <4-digit hex length><command>
var command = "host:track-devices-l";
var header = command.Length.ToString ("x4") + command;
var headerBytes = Encoding.ASCII.GetBytes (header);
await stream.WriteAsync (headerBytes, 0, headerBytes.Length, cancellationToken).ConfigureAwait (false);
await stream.FlushAsync (cancellationToken).ConfigureAwait (false);

// Read response status (OKAY or FAIL)
var status = await ReadExactAsync (stream, 4, cancellationToken).ConfigureAwait (false);
if (status != "OKAY") {
var failMsg = await TryReadLengthPrefixedAsync (stream, cancellationToken).ConfigureAwait (false);
throw new InvalidOperationException ($"ADB daemon rejected track-devices: {status} {failMsg}");
}

logger.Invoke (TraceLevel.Verbose, "ADB tracking active");

// Read length-prefixed device list updates
while (!cancellationToken.IsCancellationRequested) {
var payload = await TryReadLengthPrefixedAsync (stream, cancellationToken).ConfigureAwait (false);
if (payload == null)
throw new IOException ("ADB daemon closed the connection.");

var lines = payload.Split ('\n');
var devices = AdbRunner.ParseAdbDevicesOutput (lines);
currentDevices = devices;
onDevicesChanged (devices);
}
}

internal static async Task<string?> TryReadLengthPrefixedAsync (Stream stream, CancellationToken cancellationToken)
{
// Length is a 4-digit hex string
var lengthHex = await ReadExactOrNullAsync (stream, 4, cancellationToken).ConfigureAwait (false);
if (lengthHex == null)
return null;

if (!int.TryParse (lengthHex, System.Globalization.NumberStyles.HexNumber, null, out var length))
throw new FormatException ($"Invalid ADB length prefix: '{lengthHex}'");

if (length == 0)
return string.Empty;

return await ReadExactAsync (stream, length, cancellationToken).ConfigureAwait (false);
}

static async Task<string> ReadExactAsync (Stream stream, int count, CancellationToken cancellationToken)
{
var result = await ReadExactOrNullAsync (stream, count, cancellationToken).ConfigureAwait (false);
return result ?? throw new IOException ($"Unexpected end of stream (expected {count} bytes).");
}

static async Task<string?> ReadExactOrNullAsync (Stream stream, int count, CancellationToken cancellationToken)
{
var buffer = new byte [count];
var totalRead = 0;
while (totalRead < count) {
cancellationToken.ThrowIfCancellationRequested ();
#if NET5_0_OR_GREATER
var read = await stream.ReadAsync (buffer.AsMemory (totalRead, count - totalRead), cancellationToken).ConfigureAwait (false);
#else
var read = await stream.ReadAsync (buffer, totalRead, count - totalRead, cancellationToken).ConfigureAwait (false);
#endif
if (read == 0)
return totalRead == 0 ? null : throw new IOException ($"Unexpected end of stream (read {totalRead} of {count} bytes).");
totalRead += read;
}
return Encoding.ASCII.GetString (buffer, 0, count);
}

public void Dispose ()
{
if (disposed)
return;
disposed = true;
trackingCts?.Cancel ();
trackingCts?.Dispose ();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;

namespace Xamarin.Android.Tools.Tests;

[TestFixture]
public class AdbDeviceTrackerTests
{
[Test]
public void Constructor_InvalidPort_ThrowsArgumentOutOfRangeException ()
{
Assert.Throws<ArgumentOutOfRangeException> (() => new AdbDeviceTracker (port: 0));
Assert.Throws<ArgumentOutOfRangeException> (() => new AdbDeviceTracker (port: -1));
Assert.Throws<ArgumentOutOfRangeException> (() => new AdbDeviceTracker (port: 70000));
}

[Test]
public void Constructor_ValidPort_Succeeds ()
{
using var tracker = new AdbDeviceTracker (port: 5037);
Assert.IsNotNull (tracker);
Assert.AreEqual (0, tracker.CurrentDevices.Count);
}

[Test]
public void StartAsync_NullCallback_ThrowsArgumentNullException ()
{
using var tracker = new AdbDeviceTracker ();
Assert.ThrowsAsync<ArgumentNullException> (() => tracker.StartAsync (null!));
}

[Test]
public void StartAsync_AfterDispose_ThrowsObjectDisposedException ()
{
var tracker = new AdbDeviceTracker ();
tracker.Dispose ();
Assert.ThrowsAsync<ObjectDisposedException> (() => tracker.StartAsync (_ => { }));
}

[Test]
public void Dispose_MultipleTimes_DoesNotThrow ()
{
var tracker = new AdbDeviceTracker ();
tracker.Dispose ();
Assert.DoesNotThrow (() => tracker.Dispose ());
}

// --- TryReadLengthPrefixedAsync tests ---

[Test]
public async Task TryReadLengthPrefixedAsync_ValidPayload ()
{
var payload = "emulator-5554\tdevice\n";
var hex = payload.Length.ToString ("x4");
var data = Encoding.ASCII.GetBytes (hex + payload);
using var stream = new MemoryStream (data);

var result = await AdbDeviceTracker.TryReadLengthPrefixedAsync (stream, CancellationToken.None);
Assert.AreEqual (payload, result);
}

[Test]
public async Task TryReadLengthPrefixedAsync_EmptyPayload ()
{
var data = Encoding.ASCII.GetBytes ("0000");
using var stream = new MemoryStream (data);

var result = await AdbDeviceTracker.TryReadLengthPrefixedAsync (stream, CancellationToken.None);
Assert.AreEqual (string.Empty, result);
}

[Test]
public async Task TryReadLengthPrefixedAsync_EndOfStream_ReturnsNull ()
{
using var stream = new MemoryStream (Array.Empty<byte> ());

var result = await AdbDeviceTracker.TryReadLengthPrefixedAsync (stream, CancellationToken.None);
Assert.IsNull (result);
}

[Test]
public async Task TryReadLengthPrefixedAsync_MultipleDevices ()
{
var payload =
"0A041FDD400327\tdevice product:redfin model:Pixel_5 device:redfin transport_id:2\n" +
"emulator-5554\tdevice product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1\n";
var hex = payload.Length.ToString ("x4");
var data = Encoding.ASCII.GetBytes (hex + payload);
using var stream = new MemoryStream (data);

var result = await AdbDeviceTracker.TryReadLengthPrefixedAsync (stream, CancellationToken.None);
Assert.IsNotNull (result);

var devices = AdbRunner.ParseAdbDevicesOutput (result!.Split ('\n'));
Assert.AreEqual (2, devices.Count);
Assert.AreEqual ("0A041FDD400327", devices [0].Serial);
Assert.AreEqual ("emulator-5554", devices [1].Serial);
}

[Test]
public void TryReadLengthPrefixedAsync_InvalidHex_ThrowsFormatException ()
{
var data = Encoding.ASCII.GetBytes ("ZZZZ");
using var stream = new MemoryStream (data);

Assert.ThrowsAsync<FormatException> (
() => AdbDeviceTracker.TryReadLengthPrefixedAsync (stream, CancellationToken.None));
}

[Test]
public void TryReadLengthPrefixedAsync_TruncatedPayload_ThrowsIOException ()
{
// Header says 100 bytes but only 5 are present
var data = Encoding.ASCII.GetBytes ("0064hello");
using var stream = new MemoryStream (data);

Assert.ThrowsAsync<IOException> (
() => AdbDeviceTracker.TryReadLengthPrefixedAsync (stream, CancellationToken.None));
}
}