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..858cfa6c 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(int port = 5037, 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..858cfa6c 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(int port = 5037, 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/AdbClient.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbClient.cs new file mode 100644 index 00000000..96833732 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbClient.cs @@ -0,0 +1,321 @@ +// 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.Buffers; +using System.IO; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Xamarin.Android.Tools; + +/// +/// Low-level ADB daemon socket protocol client. +/// Encapsulates a single TCP connection to the ADB server and exposes +/// the wire protocol operations (send command, read status, read length-prefixed payloads). +/// One instance can be reused across reconnections via . +/// Dispose closes the socket. +/// +/// +/// This class is not thread-safe. All protocol operations must be serialized by the caller. +/// +internal sealed class AdbClient : IDisposable +{ + // Reusable 4-byte buffer for status/length reads (safe: single-caller, non-concurrent) + readonly byte[] headerBuffer = new byte [4]; + + TcpClient? client; + NetworkStream? stream; + bool disposed; + + /// + /// Connects to the ADB daemon at 127.0.0.1 on the specified port. + /// + public async Task ConnectAsync (int port, CancellationToken cancellationToken = default) + { + ThrowIfDisposed (); + var tcp = new TcpClient (); + client = tcp; +#if NET5_0_OR_GREATER + await tcp.ConnectAsync ("127.0.0.1", port, cancellationToken).ConfigureAwait (false); +#else + await tcp.ConnectAsync ("127.0.0.1", port).ConfigureAwait (false); + cancellationToken.ThrowIfCancellationRequested (); +#endif + stream = tcp.GetStream (); + } + + /// + /// Closes the current connection and establishes a new one. + /// + public async Task ReconnectAsync (int port, CancellationToken cancellationToken = default) + { + CloseConnection (); + await ConnectAsync (port, cancellationToken).ConfigureAwait (false); + } + + /// + /// Sends a length-prefixed command to the ADB daemon. + /// Wire format: <4-digit hex byte length><command bytes> + /// + public async Task SendCommandAsync (string command, CancellationToken cancellationToken = default) + { + var s = GetStream (); + // Compute byte count without allocating a separate commandBytes array + var byteCount = Encoding.ASCII.GetByteCount (command); + var packetLength = 4 + byteCount; + var packet = ArrayPool.Shared.Rent (packetLength); + try { + // Write 4-hex-digit length prefix directly into packet + WriteHexLength (packet, byteCount); + // Encode command directly into packet after the prefix + Encoding.ASCII.GetBytes (command, 0, command.Length, packet, 4); +#if NET5_0_OR_GREATER + await s.WriteAsync (packet.AsMemory (0, packetLength), cancellationToken).ConfigureAwait (false); +#else + await s.WriteAsync (packet, 0, packetLength, cancellationToken).ConfigureAwait (false); +#endif + } + finally { + ArrayPool.Shared.Return (packet); + } + await s.FlushAsync (cancellationToken).ConfigureAwait (false); + } + + /// + /// Reads the 4-byte status response from the ADB daemon. + /// + public async Task ReadStatusAsync (CancellationToken cancellationToken = default) + { + var s = GetStream (); + await ReadExactBytesIntoBufferAsync (s, headerBuffer, 4, cancellationToken).ConfigureAwait (false); + if (headerBuffer [0] == (byte) 'O' && headerBuffer [1] == (byte) 'K' && + headerBuffer [2] == (byte) 'A' && headerBuffer [3] == (byte) 'Y') + return AdbResponseStatus.Okay; + if (headerBuffer [0] == (byte) 'F' && headerBuffer [1] == (byte) 'A' && + headerBuffer [2] == (byte) 'I' && headerBuffer [3] == (byte) 'L') + return AdbResponseStatus.Fail; + + var status = Encoding.ASCII.GetString (headerBuffer, 0, 4); + throw new InvalidOperationException ($"Unexpected ADB status: '{status}'"); + } + + /// + /// Reads the failure message after a FAIL status. + /// + public async Task ReadFailMessageAsync (CancellationToken cancellationToken = default) + { + return await ReadLengthPrefixedStringAsync (cancellationToken).ConfigureAwait (false) ?? string.Empty; + } + + /// + /// Ensures the last status was OKAY; throws with the FAIL message otherwise. + /// + public async Task EnsureOkayAsync (CancellationToken cancellationToken = default) + { + var status = await ReadStatusAsync (cancellationToken).ConfigureAwait (false); + if (status == AdbResponseStatus.Fail) { + var message = await ReadFailMessageAsync (cancellationToken).ConfigureAwait (false); + throw new InvalidOperationException ($"ADB command failed: {message}"); + } + } + + /// + /// Reads a length-prefixed ASCII string payload from the daemon. + /// Returns null if the connection is closed cleanly before the length prefix. + /// + public async Task ReadLengthPrefixedStringAsync (CancellationToken cancellationToken = default) + { + var s = GetStream (); + // Read 4-byte length prefix into reusable buffer + if (!await TryReadExactBytesIntoBufferAsync (s, headerBuffer, 4, cancellationToken).ConfigureAwait (false)) + return null; + + var length = ParseHexLength (headerBuffer); + + if (length == 0) + return string.Empty; + + // Rent from pool, decode to string, return immediately + var buffer = ArrayPool.Shared.Rent (length); + try { + await ReadExactBytesIntoBufferAsync (s, buffer, length, cancellationToken).ConfigureAwait (false); + return Encoding.ASCII.GetString (buffer, 0, length); + } + finally { + ArrayPool.Shared.Return (buffer); + } + } + + /// + /// Reads a length-prefixed byte payload from the daemon. + /// Returns null if the connection is closed cleanly before the length prefix. + /// The returned byte[] is caller-owned (not pooled). + /// + public async Task ReadLengthPrefixedBytesAsync (CancellationToken cancellationToken = default) + { + var s = GetStream (); + // Read 4-byte length prefix into reusable buffer + if (!await TryReadExactBytesIntoBufferAsync (s, headerBuffer, 4, cancellationToken).ConfigureAwait (false)) + return null; + + var length = ParseHexLength (headerBuffer); + + if (length == 0) + return Array.Empty (); + + var result = new byte [length]; + await ReadExactBytesIntoBufferAsync (s, result, length, cancellationToken).ConfigureAwait (false); + return result; + } + + /// + /// Forcibly closes the underlying socket, unblocking any pending reads. + /// + public void Close () + { + CloseConnection (); + } + + public void Dispose () + { + if (disposed) + return; + disposed = true; + CloseConnection (); + } + + void CloseConnection () + { + stream = null; + var tcp = client; + client = null; + if (tcp != null) { + tcp.Close (); + tcp.Dispose (); + } + } + + NetworkStream GetStream () + { + ThrowIfDisposed (); + if (stream == null) + throw new InvalidOperationException ("Not connected. Call ConnectAsync first."); + return stream; + } + + void ThrowIfDisposed () + { + if (disposed) + throw new ObjectDisposedException (nameof (AdbClient)); + } + + // --- Shared core implementations (used by static method for tests) --- + + /// + /// Reads a length-prefixed ASCII string from a raw stream. + /// Used by tests that cannot construct an AdbClient instance. + /// Allocates fresh buffers (no pooling) since it has no instance state. + /// + internal static async Task ReadLengthPrefixedStringFromStreamAsync (Stream stream, CancellationToken cancellationToken) + { + var lengthBytes = new byte [4]; + if (!await TryReadExactBytesIntoBufferAsync (stream, lengthBytes, 4, cancellationToken).ConfigureAwait (false)) + return null; + + var length = ParseHexLength (lengthBytes); + + if (length == 0) + return string.Empty; + + var payload = new byte [length]; + await ReadExactBytesIntoBufferAsync (stream, payload, length, cancellationToken).ConfigureAwait (false); + return Encoding.ASCII.GetString (payload, 0, length); + } + + // --- Low-level I/O helpers --- + + /// + /// Reads exactly bytes into the provided buffer. + /// Throws IOException if the stream ends prematurely. + /// + static async Task ReadExactBytesIntoBufferAsync (Stream stream, byte[] buffer, int count, CancellationToken cancellationToken) + { + 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) + throw new IOException ($"Unexpected end of stream (read {totalRead} of {count} bytes)."); + totalRead += read; + } + } + + /// + /// Tries to read exactly bytes into the buffer. + /// Returns false if the stream ends cleanly before the first byte. + /// Throws IOException on partial reads. + /// + static async Task TryReadExactBytesIntoBufferAsync (Stream stream, byte[] buffer, int count, CancellationToken cancellationToken) + { + 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) { + if (totalRead == 0) + return false; + throw new IOException ($"Unexpected end of stream (read {totalRead} of {count} bytes)."); + } + totalRead += read; + } + return true; + } + + // --- Hex encoding/decoding helpers (avoid string allocations) --- + + static readonly byte[] HexChars = Encoding.ASCII.GetBytes ("0123456789abcdef"); + + /// + /// Writes a 4-digit lowercase hex representation of into the first 4 bytes of . + /// + static void WriteHexLength (byte[] buffer, int value) + { + buffer [0] = HexChars [(value >> 12) & 0xF]; + buffer [1] = HexChars [(value >> 8) & 0xF]; + buffer [2] = HexChars [(value >> 4) & 0xF]; + buffer [3] = HexChars [value & 0xF]; + } + + /// + /// Parses a 4-byte ASCII hex length prefix without allocating a string. + /// + static int ParseHexLength (byte[] buffer) + { + var value = 0; + for (var i = 0; i < 4; i++) { + var b = buffer [i]; + int nibble; + if (b >= (byte) '0' && b <= (byte) '9') + nibble = b - '0'; + else if (b >= (byte) 'a' && b <= (byte) 'f') + nibble = b - 'a' + 10; + else if (b >= (byte) 'A' && b <= (byte) 'F') + nibble = b - 'A' + 10; + else + throw new FormatException ($"Invalid ADB length prefix: '{Encoding.ASCII.GetString (buffer, 0, 4)}'"); + value = (value << 4) | nibble; + } + return value; + } +} 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..d6f25c38 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbDeviceTracker.cs @@ -0,0 +1,147 @@ +// 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.Net.Sockets; +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 object syncLock = new object (); + readonly int port; + readonly Action logger; + readonly AdbClient adbClient; + volatile IReadOnlyList currentDevices = Array.Empty (); + CancellationTokenSource? trackingCts; + bool isTracking; + bool disposed; + + /// + /// Creates a new AdbDeviceTracker. + /// + /// ADB daemon port (default 5037). + /// Optional logger callback. + public AdbDeviceTracker (int port = 5037, + Action? logger = null) + { + if (port <= 0 || port > 65535) + throw new ArgumentOutOfRangeException (nameof (port), "Port must be between 1 and 65535."); + this.port = port; + this.logger = logger ?? RunnerDefaults.NullLogger; + this.adbClient = new AdbClient (); + } + + /// + /// 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. + /// Thrown if tracking is already active. + public async Task StartAsync ( + Action> onDevicesChanged, + CancellationToken cancellationToken = default) + { + if (onDevicesChanged == null) + throw new ArgumentNullException (nameof (onDevicesChanged)); + + CancellationTokenSource cts; + lock (syncLock) { + if (disposed) + throw new ObjectDisposedException (nameof (AdbDeviceTracker)); + if (isTracking) + throw new InvalidOperationException ("Tracking is already active. Cancel the token or dispose before starting again."); + isTracking = true; + cts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); + trackingCts = cts; + } + + var token = cts.Token; + var backoffMs = InitialBackoffMs; + + try { + while (!token.IsCancellationRequested) { + try { + await TrackDevicesAsync (onDevicesChanged, token).ConfigureAwait (false); + } catch (OperationCanceledException) when (token.IsCancellationRequested) { + break; + } catch (Exception ex) when (ex is IOException || ex is SocketException || ex is ObjectDisposedException) { + if (token.IsCancellationRequested) + break; + 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; + } + backoffMs = InitialBackoffMs; + } + } finally { + lock (syncLock) { + isTracking = false; + trackingCts = null; + } + cts.Dispose (); + } + } + + const int InitialBackoffMs = 500; + const int MaxBackoffMs = 16000; + + async Task TrackDevicesAsync ( + Action> onDevicesChanged, + CancellationToken cancellationToken) + { + await adbClient.ReconnectAsync (port, cancellationToken).ConfigureAwait (false); + logger.Invoke (TraceLevel.Verbose, "Connected to ADB daemon, sending track-devices-l command"); + + await adbClient.SendCommandAsync ("host:track-devices-l", cancellationToken).ConfigureAwait (false); + await adbClient.EnsureOkayAsync (cancellationToken).ConfigureAwait (false); + + logger.Invoke (TraceLevel.Verbose, "ADB tracking active"); + + // Read length-prefixed device list updates + while (!cancellationToken.IsCancellationRequested) { + var payload = await adbClient.ReadLengthPrefixedStringAsync (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); + } + } + + public void Dispose () + { + lock (syncLock) { + if (disposed) + return; + disposed = true; + trackingCts?.Cancel (); + adbClient.Close (); + trackingCts?.Dispose (); + } + adbClient.Dispose (); + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbResponseStatus.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbResponseStatus.cs new file mode 100644 index 00000000..409eec99 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbResponseStatus.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Xamarin.Android.Tools; + +/// +/// Status response from the ADB daemon after sending a command. +/// +internal enum AdbResponseStatus +{ + Okay, + Fail, +} diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbClientTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbClientTests.cs new file mode 100644 index 00000000..32b434c2 --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbClientTests.cs @@ -0,0 +1,86 @@ +// 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 AdbClientTests +{ + [Test] + public async Task ReadLengthPrefixedStringFromStreamAsync_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 AdbClient.ReadLengthPrefixedStringFromStreamAsync (stream, CancellationToken.None); + Assert.AreEqual (payload, result); + } + + [Test] + public async Task ReadLengthPrefixedStringFromStreamAsync_EmptyPayload () + { + var data = Encoding.ASCII.GetBytes ("0000"); + using var stream = new MemoryStream (data); + + var result = await AdbClient.ReadLengthPrefixedStringFromStreamAsync (stream, CancellationToken.None); + Assert.AreEqual (string.Empty, result); + } + + [Test] + public async Task ReadLengthPrefixedStringFromStreamAsync_EndOfStream_ReturnsNull () + { + using var stream = new MemoryStream (Array.Empty ()); + + var result = await AdbClient.ReadLengthPrefixedStringFromStreamAsync (stream, CancellationToken.None); + Assert.IsNull (result); + } + + [Test] + public async Task ReadLengthPrefixedStringFromStreamAsync_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 AdbClient.ReadLengthPrefixedStringFromStreamAsync (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 ReadLengthPrefixedStringFromStreamAsync_InvalidHex_ThrowsFormatException () + { + var data = Encoding.ASCII.GetBytes ("ZZZZ"); + using var stream = new MemoryStream (data); + + Assert.ThrowsAsync ( + () => AdbClient.ReadLengthPrefixedStringFromStreamAsync (stream, CancellationToken.None)); + } + + [Test] + public void ReadLengthPrefixedStringFromStreamAsync_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 ( + () => AdbClient.ReadLengthPrefixedStringFromStreamAsync (stream, CancellationToken.None)); + } +} 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..2670cded --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbDeviceTrackerTests.cs @@ -0,0 +1,70 @@ +// 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.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 async Task StartAsync_CalledTwice_ThrowsInvalidOperationException () + { + // Use a port where nothing is listening so ConnectAsync yields quickly + using var tracker = new AdbDeviceTracker (port: 59999); + using var cts = new CancellationTokenSource (); + + // First call sets isTracking synchronously before the first await + var trackingTask = tracker.StartAsync (_ => { }, cts.Token); + + // Second call should throw because tracking is already active + Assert.ThrowsAsync ( + () => tracker.StartAsync (_ => { }, cts.Token)); + + cts.Cancel (); + try { await trackingTask.ConfigureAwait (false); } catch (OperationCanceledException) { } + } + + [Test] + public void Dispose_MultipleTimes_DoesNotThrow () + { + var tracker = new AdbDeviceTracker (); + tracker.Dispose (); + Assert.DoesNotThrow (() => tracker.Dispose ()); + } +}