-
Notifications
You must be signed in to change notification settings - Fork 33
Add ADB device tracking (host:track-devices) for real-time device monitoring #327
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
bc5ecce
5bea4a4
7da61a6
7ea635a
a8a7f5c
93233c1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| 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
+21
to
+40
|
||
| } | ||
|
|
||
| /// <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; | ||
|
rmarinho marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
|
|
||
| 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 | ||
|
rmarinho marked this conversation as resolved.
Outdated
|
||
|
|
||
| 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)); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.