Skip to content
Merged
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
51 changes: 28 additions & 23 deletions src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public class AdbRunner
/// </summary>
/// <param name="adbPath">Full path to the adb executable (e.g., "/path/to/sdk/platform-tools/adb").</param>
/// <param name="environmentVariables">Optional environment variables to pass to adb processes.</param>
/// <param name="logger">Optional logger callback for diagnostic messages.</param>
/// <param name="logger">Optional logger callback receiving a <see cref="TraceLevel"/> and message string.</param>
public AdbRunner (string adbPath, IDictionary<string, string>? environmentVariables = null, Action<TraceLevel, string>? logger = null)
{
if (string.IsNullOrWhiteSpace (adbPath))
Expand All @@ -48,7 +48,8 @@ public AdbRunner (string adbPath, IDictionary<string, string>? environmentVariab

/// <summary>
/// Lists connected devices using 'adb devices -l'.
/// For emulators, queries the AVD name using 'adb -s &lt;serial&gt; emu avd name'.
/// For online emulators, queries the AVD name via <c>getprop</c> / <c>emu avd name</c>.
/// Offline emulators are included but without AVD names (querying them would fail).
/// </summary>
public virtual async Task<IReadOnlyList<AdbDeviceInfo>> ListDevicesAsync (CancellationToken cancellationToken = default)
{
Expand All @@ -61,11 +62,15 @@ public virtual async Task<IReadOnlyList<AdbDeviceInfo>> ListDevicesAsync (Cancel

var devices = ParseAdbDevicesOutput (stdout.ToString ().Split ('\n'));

// For each emulator, try to get the AVD name
// For each online emulator, try to get the AVD name.
// Skip offline emulators — neither getprop nor 'emu avd name' work on them
// and attempting these commands causes unnecessary delays during boot polling.
foreach (var device in devices) {
if (device.Type == AdbDeviceType.Emulator) {
if (device.Type == AdbDeviceType.Emulator && device.Status == AdbDeviceStatus.Online) {
device.AvdName = await GetEmulatorAvdNameAsync (device.Serial, cancellationToken).ConfigureAwait (false);
device.Description = BuildDeviceDescription (device);
} else if (device.Type == AdbDeviceType.Emulator) {
logger.Invoke (TraceLevel.Verbose, $"Skipping AVD name query for {device.Status} emulator {device.Serial}");
}
}

Expand All @@ -74,15 +79,26 @@ public virtual async Task<IReadOnlyList<AdbDeviceInfo>> ListDevicesAsync (Cancel

/// <summary>
/// Queries the emulator for its AVD name.
/// Tries <c>adb -s &lt;serial&gt; emu avd name</c> first (emulator console protocol),
/// then falls back to <c>adb shell getprop ro.boot.qemu.avd_name</c> which reads the
/// boot property set by the emulator kernel. The fallback is needed because
/// <c>emu avd name</c> can return empty output on some adb/emulator version
/// combinations (observed with adb v36).
/// Tries <c>adb shell getprop ro.boot.qemu.avd_name</c> first (reliable on all modern
/// emulators), then falls back to <c>adb -s &lt;serial&gt; emu avd name</c> (emulator
/// console protocol) for older emulator versions. The <c>emu avd name</c> command returns
/// empty output on emulator 36.4.10+ (observed with adb v36), so <c>getprop</c> is the
/// preferred method.
/// </summary>
internal async Task<string?> GetEmulatorAvdNameAsync (string serial, CancellationToken cancellationToken = default)
{
// Try 1: Console command (fast, works on most emulator versions)
// Try 1: Shell property (reliable on modern emulators, always set by the emulator kernel)
try {
var avdName = await GetShellPropertyAsync (serial, "ro.boot.qemu.avd_name", cancellationToken).ConfigureAwait (false);
if (avdName is { Length: > 0 } name && !string.IsNullOrWhiteSpace (name))
return name.Trim ();
} catch (OperationCanceledException) {
throw;
} catch (Exception ex) {
logger.Invoke (TraceLevel.Warning, $"GetEmulatorAvdNameAsync: getprop failed for {serial}: {ex.Message}");
}

// Try 2: Console command (fallback for older emulators where getprop may not be available)
try {
using var stdout = new StringWriter ();
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "emu", "avd", "name");
Expand All @@ -97,19 +113,8 @@ public virtual async Task<IReadOnlyList<AdbDeviceInfo>> ListDevicesAsync (Cancel
}
} catch (OperationCanceledException) {
throw;
} catch {
// Fall through to getprop fallback
}

// Try 2: Shell property (fallback when 'adb emu avd name' returns empty on some adb/emulator versions)
try {
var avdName = await GetShellPropertyAsync (serial, "ro.boot.qemu.avd_name", cancellationToken).ConfigureAwait (false);
if (avdName is { Length: > 0 } name && !string.IsNullOrWhiteSpace (name))
return name.Trim ();
} catch (OperationCanceledException) {
throw;
} catch {
// Both methods failed
} catch (Exception ex) {
logger.Invoke (TraceLevel.Warning, $"GetEmulatorAvdNameAsync: both methods failed for {serial}: {ex.Message}");
}

return null;
Expand Down
142 changes: 142 additions & 0 deletions tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;

namespace Xamarin.Android.Tools.Tests;
Expand Down Expand Up @@ -714,4 +715,145 @@ public void FirstNonEmptyLine_PmPathOutput ()
var output = "package:/system/framework/framework-res.apk\n";
Assert.AreEqual ("package:/system/framework/framework-res.apk", AdbRunner.FirstNonEmptyLine (output));
}

// --- GetEmulatorAvdNameAsync + ListDevicesAsync tests ---
// These tests use a fake 'adb' script to control process output,
// verifying AVD detection order and offline emulator handling.

static string CreateFakeAdb (string scriptBody)
{
if (OS.IsWindows)
Assert.Ignore ("Fake adb tests use bash scripts and are not supported on Windows.");

var dir = Path.Combine (Path.GetTempPath (), $"fake-adb-{Guid.NewGuid ():N}");
Directory.CreateDirectory (dir);
var path = Path.Combine (dir, "adb");
File.WriteAllText (path, "#!/bin/bash\n" + scriptBody);
FileUtil.Chmod (path, 0x1ED); // 0755

return path;
}

static void CleanupFakeAdb (string adbPath)
{
var dir = Path.GetDirectoryName (adbPath);
if (dir is { Length: > 0 }) {
File.Delete (adbPath);
Directory.Delete (dir);
}
}

[Test]
public async Task GetEmulatorAvdNameAsync_PrefersGetprop ()
{
// getprop returns a value — should be used, emu avd name should NOT be needed
var adbPath = CreateFakeAdb ("""
if [[ "$3" == "shell" && "$4" == "getprop" ]]; then
echo "My_AVD_Name"
exit 0
fi
if [[ "$3" == "emu" ]]; then
echo "WRONG_NAME"
echo "OK"
exit 0
fi
exit 1
""");

try {
var runner = new AdbRunner (adbPath);
var name = await runner.GetEmulatorAvdNameAsync ("emulator-5554");
Assert.AreEqual ("My_AVD_Name", name, "Should return getprop result");
} finally {
CleanupFakeAdb (adbPath);
}
}

[Test]
public async Task GetEmulatorAvdNameAsync_FallsBackToEmuAvdName ()
{
// getprop returns empty — should fall back to emu avd name
var adbPath = CreateFakeAdb ("""
if [[ "$3" == "shell" && "$4" == "getprop" ]]; then
echo ""
exit 0
fi
if [[ "$3" == "emu" ]]; then
echo "Fallback_AVD"
echo "OK"
exit 0
fi
exit 1
""");

try {
var runner = new AdbRunner (adbPath);
var name = await runner.GetEmulatorAvdNameAsync ("emulator-5554");
Assert.AreEqual ("Fallback_AVD", name, "Should fall back to emu avd name");
} finally {
CleanupFakeAdb (adbPath);
}
}

[Test]
public async Task GetEmulatorAvdNameAsync_BothFail_ReturnsNull ()
{
// Both getprop and emu avd name return empty
var adbPath = CreateFakeAdb ("""
echo ""
exit 0
""");

try {
var runner = new AdbRunner (adbPath);
var name = await runner.GetEmulatorAvdNameAsync ("emulator-5554");
Assert.IsNull (name, "Should return null when both methods fail");
} finally {
CleanupFakeAdb (adbPath);
}
}

[Test]
public async Task ListDevicesAsync_SkipsAvdQueryForOfflineEmulators ()
{
// adb devices returns one online emulator and one offline emulator.
// Only the online one should get an AVD name query.
var adbPath = CreateFakeAdb ("""
if [[ "$1" == "devices" ]]; then
echo "List of devices attached"
echo "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1"
echo "emulator-5556 offline"
exit 0
fi
if [[ "$1" == "-s" && "$2" == "emulator-5554" && "$3" == "shell" && "$4" == "getprop" ]]; then
echo "Online_AVD"
exit 0
fi
if [[ "$1" == "-s" && "$2" == "emulator-5556" ]]; then
# This should NOT be called for offline emulators.
# Return a name anyway so we can detect if it was incorrectly queried.
echo "OFFLINE_SHOULD_NOT_APPEAR"
exit 0
fi
exit 1
""");

try {
var runner = new AdbRunner (adbPath);
var devices = await runner.ListDevicesAsync ();

Assert.AreEqual (2, devices.Count, "Should return both emulators");

var online = devices.First (d => d.Serial == "emulator-5554");
var offline = devices.First (d => d.Serial == "emulator-5556");

Assert.AreEqual (AdbDeviceStatus.Online, online.Status);
Assert.AreEqual ("Online_AVD", online.AvdName, "Online emulator should have AVD name");

Assert.AreEqual (AdbDeviceStatus.Offline, offline.Status);
Assert.IsNull (offline.AvdName, "Offline emulator should NOT have AVD name queried");
} finally {
CleanupFakeAdb (adbPath);
}
}
}