From bc5ecce26421809ebb147c4375caf0f3fa989eff Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 8 Apr 2026 17:40:10 +0100 Subject: [PATCH] Add ADB device tracking via host:track-devices-l protocol Add AdbDeviceTracker class for real-time device connection monitoring via the ADB daemon socket protocol. Connects to localhost:5037, sends host:track-devices-l, and pushes device list updates through a callback. Features: - Auto-reconnect with exponential backoff (500ms to 16s) - Callback-based StartAsync() with CancellationToken support - CurrentDevices snapshot property - IDisposable lifecycle management - Reuses AdbRunner.ParseAdbDevicesOutput() for parsing Includes 11 unit tests covering protocol parsing, edge cases, and lifecycle management. PublicAPI entries for both net10.0 and netstandard2.0. Closes #323 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 5 + .../netstandard2.0/PublicAPI.Unshipped.txt | 5 + .../Runners/AdbDeviceTracker.cs | 189 ++++++++++++++++++ .../AdbDeviceTrackerTests.cs | 127 ++++++++++++ 4 files changed, 326 insertions(+) create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbDeviceTracker.cs create mode 100644 tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbDeviceTrackerTests.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 61fc0e0b..ecab0d25 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,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? environmentVariables = null, System.Action? logger = null) -> void +Xamarin.Android.Tools.AdbDeviceTracker.CurrentDevices.get -> System.Collections.Generic.IReadOnlyList! +Xamarin.Android.Tools.AdbDeviceTracker.Dispose() -> void +Xamarin.Android.Tools.AdbDeviceTracker.StartAsync(System.Action!>! onDevicesChanged, 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..ecab0d25 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,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? environmentVariables = null, System.Action? logger = null) -> void +Xamarin.Android.Tools.AdbDeviceTracker.CurrentDevices.get -> System.Collections.Generic.IReadOnlyList! +Xamarin.Android.Tools.AdbDeviceTracker.Dispose() -> void +Xamarin.Android.Tools.AdbDeviceTracker.StartAsync(System.Action!>! onDevicesChanged, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbDeviceTracker.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbDeviceTracker.cs new file mode 100644 index 00000000..1a67b612 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbDeviceTracker.cs @@ -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; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Xamarin.Android.Tools; + +/// +/// Monitors ADB device connections in real-time via the host:track-devices-l socket protocol. +/// Pushes device list updates through a callback whenever devices connect, disconnect, or change state. +/// +public sealed class AdbDeviceTracker : IDisposable +{ + readonly int port; + readonly Action logger; + readonly string? adbPath; + readonly IDictionary? environmentVariables; + IReadOnlyList currentDevices = Array.Empty (); + CancellationTokenSource? trackingCts; + bool disposed; + + /// + /// Creates a new AdbDeviceTracker. + /// + /// Optional path to the adb executable for starting the server if needed. + /// ADB daemon port (default 5037). + /// Optional environment variables for adb processes. + /// Optional logger callback. + public AdbDeviceTracker (string? adbPath = null, int port = 5037, + IDictionary? environmentVariables = null, + Action? 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; + } + + /// + /// Current snapshot of tracked devices. + /// + public IReadOnlyList CurrentDevices => currentDevices; + + /// + /// Starts tracking device changes. Calls whenever + /// the device list changes. Blocks until cancelled or disposed. + /// Automatically reconnects on connection drops with exponential backoff. + /// + /// Callback invoked with the updated device list on each change. + /// Token to stop tracking. + public async Task StartAsync ( + Action> 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; + } + } + + const int InitialBackoffMs = 500; + const int MaxBackoffMs = 16000; + + async Task TrackDevicesAsync ( + Action> 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> + 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 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 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 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 (); + } +} diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbDeviceTrackerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbDeviceTrackerTests.cs new file mode 100644 index 00000000..cbed3f6d --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbDeviceTrackerTests.cs @@ -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 (() => new AdbDeviceTracker (port: 0)); + Assert.Throws (() => new AdbDeviceTracker (port: -1)); + Assert.Throws (() => 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 (() => tracker.StartAsync (null!)); + } + + [Test] + public void StartAsync_AfterDispose_ThrowsObjectDisposedException () + { + var tracker = new AdbDeviceTracker (); + tracker.Dispose (); + Assert.ThrowsAsync (() => 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 ()); + + 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 ( + () => 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 ( + () => AdbDeviceTracker.TryReadLengthPrefixedAsync (stream, CancellationToken.None)); + } +}