-
Notifications
You must be signed in to change notification settings - Fork 33
Expand file tree
/
Copy pathAdbRunner.cs
More file actions
652 lines (565 loc) · 28.2 KB
/
AdbRunner.cs
File metadata and controls
652 lines (565 loc) · 28.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
// 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.Globalization;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace Xamarin.Android.Tools;
/// <summary>
/// Runs Android Debug Bridge (adb) commands.
/// Parsing logic ported from dotnet/android GetAvailableAndroidDevices task.
/// </summary>
public class AdbRunner
{
readonly string adbPath;
readonly IDictionary<string, string>? environmentVariables;
readonly Action<TraceLevel, string> logger;
// Pattern to match device lines: <serial> <state> [key:value ...]
// Uses \s+ to match one or more whitespace characters (spaces or tabs) between fields.
// Explicit state list prevents false positives from non-device lines.
static readonly Regex AdbDevicesRegex = new Regex (
@"^([^\s]+)\s+(device|offline|unauthorized|authorizing|no permissions|recovery|sideload|bootloader|connecting|host)\s*(.*)$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
static readonly Regex ApiRegex = new Regex (@"\bApi\b", RegexOptions.Compiled);
/// <summary>
/// Creates a new AdbRunner with the full path to the adb executable.
/// </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 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))
throw new ArgumentException ("Path to adb must not be empty.", nameof (adbPath));
this.adbPath = adbPath;
this.environmentVariables = environmentVariables;
this.logger = logger ?? RunnerDefaults.NullLogger;
}
/// <summary>
/// Lists connected devices using 'adb devices -l'.
/// 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)
{
using var stdout = new StringWriter ();
using var stderr = new StringWriter ();
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "devices", "-l");
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
ProcessUtils.ThrowIfFailed (exitCode, "adb devices -l", stderr);
var devices = ParseAdbDevicesOutput (stdout.ToString ().Split ('\n'));
// 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 && 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}");
}
}
return devices;
}
/// <summary>
/// Queries the emulator for its AVD name.
/// Tries <c>adb shell getprop ro.boot.qemu.avd_name</c> first (reliable on all modern
/// emulators), then falls back to <c>adb -s <serial> 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: 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");
await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken, environmentVariables).ConfigureAwait (false);
foreach (var line in stdout.ToString ().Split ('\n')) {
var trimmed = line.Trim ();
if (!string.IsNullOrEmpty (trimmed) &&
!string.Equals (trimmed, "OK", StringComparison.OrdinalIgnoreCase)) {
return trimmed;
}
}
} catch (OperationCanceledException) {
throw;
} catch (Exception ex) {
logger.Invoke (TraceLevel.Warning, $"GetEmulatorAvdNameAsync: both methods failed for {serial}: {ex.Message}");
}
return null;
}
public async Task WaitForDeviceAsync (string? serial = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
var effectiveTimeout = timeout ?? TimeSpan.FromSeconds (60);
if (effectiveTimeout <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException (nameof (timeout), effectiveTimeout, "Timeout must be a positive value.");
var args = serial is { Length: > 0 } s
? new [] { "-s", s, "wait-for-device" }
: new [] { "wait-for-device" };
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, args);
using var cts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken);
cts.CancelAfter (effectiveTimeout);
using var stdout = new StringWriter ();
using var stderr = new StringWriter ();
try {
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cts.Token, environmentVariables).ConfigureAwait (false);
ProcessUtils.ThrowIfFailed (exitCode, "adb wait-for-device", stderr, stdout);
} catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) {
throw new TimeoutException ($"Timed out waiting for device after {effectiveTimeout.TotalSeconds}s.");
}
}
public async Task StopEmulatorAsync (string serial, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace (serial))
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "emu", "kill");
using var stderr = new StringWriter ();
var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} emu kill", stderr);
}
/// <summary>
/// Gets a system property from a device via 'adb -s <serial> shell getprop <property>'.
/// Returns the property value (first non-empty line of stdout), or <c>null</c> on failure.
/// </summary>
public virtual async Task<string?> GetShellPropertyAsync (string serial, string propertyName, CancellationToken cancellationToken = default)
{
using var stdout = new StringWriter ();
using var stderr = new StringWriter ();
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "shell", "getprop", propertyName);
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
if (exitCode != 0) {
var stderrText = stderr.ToString ().Trim ();
if (stderrText.Length > 0)
logger.Invoke (TraceLevel.Warning, $"adb shell getprop {propertyName} failed (exit {exitCode}): {stderrText}");
return null;
}
return FirstNonEmptyLine (stdout.ToString ());
}
/// <summary>
/// Runs a shell command on a device via 'adb -s <serial> shell <command>'.
/// Returns the full stdout output trimmed, or <c>null</c> on failure.
/// </summary>
/// <remarks>
/// The <paramref name="command"/> is passed as a single argument to <c>adb shell</c>,
/// which means the device's shell interprets it (shell expansion, pipes, semicolons are active).
/// Do not pass untrusted or user-supplied input without proper validation.
/// </remarks>
public virtual async Task<string?> RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken)
{
using var stdout = new StringWriter ();
using var stderr = new StringWriter ();
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "shell", command);
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
if (exitCode != 0) {
var stderrText = stderr.ToString ().Trim ();
if (stderrText.Length > 0)
logger.Invoke (TraceLevel.Warning, $"adb shell {command} failed (exit {exitCode}): {stderrText}");
return null;
}
var output = stdout.ToString ().Trim ();
return output.Length > 0 ? output : null;
}
/// <summary>
/// Runs a shell command on a device via <c>adb -s <serial> shell <command> <args></c>.
/// Returns the full stdout output trimmed, or <c>null</c> on failure.
/// </summary>
/// <remarks>
/// When <c>adb shell</c> receives the command and arguments as separate tokens, it uses
/// <c>exec()</c> directly on the device — bypassing the device's shell interpreter.
/// This avoids shell expansion, pipes, and injection risks, making it safer for dynamic input.
/// </remarks>
public virtual async Task<string?> RunShellCommandAsync (string serial, string command, string[] args, CancellationToken cancellationToken = default)
{
using var stdout = new StringWriter ();
using var stderr = new StringWriter ();
// Build: adb -s <serial> shell <command> <arg1> <arg2> ...
var allArgs = new string [3 + 1 + args.Length];
allArgs [0] = "-s";
allArgs [1] = serial;
allArgs [2] = "shell";
allArgs [3] = command;
Array.Copy (args, 0, allArgs, 4, args.Length);
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, allArgs);
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
if (exitCode != 0) {
var stderrText = stderr.ToString ().Trim ();
if (stderrText.Length > 0)
logger.Invoke (TraceLevel.Warning, $"adb shell {command} failed (exit {exitCode}): {stderrText}");
return null;
}
var output = stdout.ToString ().Trim ();
return output.Length > 0 ? output : null;
}
internal static string? FirstNonEmptyLine (string output)
{
foreach (var line in output.Split ('\n')) {
var trimmed = line.Trim ();
if (trimmed.Length > 0)
return trimmed;
}
return null;
}
/// <summary>
/// Sets up reverse port forwarding via 'adb -s <serial> reverse <remote> <local>'.
/// </summary>
/// <param name="serial">Device serial number.</param>
/// <param name="remote">Remote (device-side) port spec.</param>
/// <param name="local">Local (host-side) port spec.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public virtual async Task ReversePortAsync (string serial, AdbPortSpec remote, AdbPortSpec local, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace (serial))
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
if (remote is null)
throw new ArgumentNullException (nameof (remote));
if (local is null)
throw new ArgumentNullException (nameof (local));
if (remote.Port <= 0 || remote.Port > 65535)
throw new ArgumentOutOfRangeException (nameof (remote), remote.Port, "Port must be between 1 and 65535.");
if (local.Port <= 0 || local.Port > 65535)
throw new ArgumentOutOfRangeException (nameof (local), local.Port, "Port must be between 1 and 65535.");
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "reverse", remote.ToSocketSpec (), local.ToSocketSpec ());
using var stderr = new StringWriter ();
var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} reverse {remote} {local}", stderr);
}
/// <summary>
/// Removes a specific reverse port forwarding rule via
/// 'adb -s <serial> reverse --remove <remote>'.
/// </summary>
/// <param name="serial">Device serial number.</param>
/// <param name="remote">Remote (device-side) port spec to remove.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public virtual async Task RemoveReversePortAsync (string serial, AdbPortSpec remote, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace (serial))
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
if (remote is null)
throw new ArgumentNullException (nameof (remote));
if (remote.Port <= 0 || remote.Port > 65535)
throw new ArgumentOutOfRangeException (nameof (remote), remote.Port, "Port must be between 1 and 65535.");
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "reverse", "--remove", remote.ToSocketSpec ());
using var stderr = new StringWriter ();
var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} reverse --remove {remote}", stderr);
}
/// <summary>
/// Removes all reverse port forwarding rules via
/// 'adb -s <serial> reverse --remove-all'.
/// </summary>
public virtual async Task RemoveAllReversePortsAsync (string serial, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace (serial))
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "reverse", "--remove-all");
using var stderr = new StringWriter ();
var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} reverse --remove-all", stderr);
}
/// <summary>
/// Lists all active reverse port forwarding rules via
/// 'adb -s <serial> reverse --list'.
/// </summary>
public virtual async Task<IReadOnlyList<AdbPortRule>> ListReversePortsAsync (string serial, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace (serial))
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
using var stdout = new StringWriter ();
using var stderr = new StringWriter ();
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "reverse", "--list");
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} reverse --list", stderr, stdout);
return ParseReverseListOutput (stdout.ToString ().Split ('\n'));
}
/// <summary>
/// Parses the output of 'adb reverse --list'.
/// Each line is "(reverse) <remote> <local>", e.g. "(reverse) tcp:5000 tcp:5000".
/// Lines with unparseable socket specs are skipped.
/// </summary>
internal static IReadOnlyList<AdbPortRule> ParseReverseListOutput (IEnumerable<string> lines)
{
var rules = new List<AdbPortRule> ();
foreach (var line in lines) {
var trimmed = line.Trim ();
if (string.IsNullOrEmpty (trimmed))
continue;
// Expected format: "(reverse) tcp:5000 tcp:5000"
if (!trimmed.StartsWith ("(reverse)", StringComparison.Ordinal))
continue;
var parts = trimmed.Substring ("(reverse)".Length).Trim ().Split ((char[]?) null, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2) {
var remote = AdbPortSpec.TryParse (parts [0]);
var local = AdbPortSpec.TryParse (parts [1]);
if (remote is { } r && local is { } l)
rules.Add (new AdbPortRule (r, l));
}
}
return rules;
}
/// <summary>
/// Sets up forward port forwarding via 'adb -s <serial> forward <local> <remote>'.
/// The host-side <local> socket is forwarded to the device-side <remote> socket,
/// the symmetric pair to <see cref="ReversePortAsync"/>.
/// </summary>
/// <param name="serial">Device serial number.</param>
/// <param name="local">Local (host-side) port spec.</param>
/// <param name="remote">Remote (device-side) port spec.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public virtual async Task ForwardPortAsync (string serial, AdbPortSpec local, AdbPortSpec remote, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace (serial))
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
if (local is null)
throw new ArgumentNullException (nameof (local));
if (remote is null)
throw new ArgumentNullException (nameof (remote));
if (local.Port <= 0 || local.Port > 65535)
throw new ArgumentOutOfRangeException (nameof (local), local.Port, "Port must be between 1 and 65535.");
if (remote.Port <= 0 || remote.Port > 65535)
throw new ArgumentOutOfRangeException (nameof (remote), remote.Port, "Port must be between 1 and 65535.");
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "forward", local.ToSocketSpec (), remote.ToSocketSpec ());
using var stderr = new StringWriter ();
var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} forward {local} {remote}", stderr);
}
/// <summary>
/// Removes a specific forward port forwarding rule via
/// 'adb -s <serial> forward --remove <local>'.
/// </summary>
/// <param name="serial">Device serial number.</param>
/// <param name="local">Local (host-side) port spec to remove.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public virtual async Task RemoveForwardPortAsync (string serial, AdbPortSpec local, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace (serial))
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
if (local is null)
throw new ArgumentNullException (nameof (local));
if (local.Port <= 0 || local.Port > 65535)
throw new ArgumentOutOfRangeException (nameof (local), local.Port, "Port must be between 1 and 65535.");
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "forward", "--remove", local.ToSocketSpec ());
using var stderr = new StringWriter ();
var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} forward --remove {local}", stderr);
}
/// <summary>
/// Removes all forward port forwarding rules for the device via
/// 'adb -s <serial> forward --remove-all'.
/// Note that the underlying adb command operates globally, but we scope it via -s.
/// </summary>
public virtual async Task RemoveAllForwardPortsAsync (string serial, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace (serial))
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "forward", "--remove-all");
using var stderr = new StringWriter ();
var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} forward --remove-all", stderr);
}
/// <summary>
/// Lists active forward port forwarding rules for the specified device via
/// 'adb forward --list'.
/// The underlying command always lists rules across all devices, so the
/// result is filtered to entries matching <paramref name="serial"/>.
/// </summary>
public virtual async Task<IReadOnlyList<AdbPortRule>> ListForwardPortsAsync (string serial, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace (serial))
throw new ArgumentException ("Serial must not be empty.", nameof (serial));
using var stdout = new StringWriter ();
using var stderr = new StringWriter ();
var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "forward", "--list");
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
ProcessUtils.ThrowIfFailed (exitCode, $"adb forward --list", stderr, stdout);
return ParseForwardListOutput (stdout.ToString ().Split ('\n'), serial);
}
/// <summary>
/// Parses the output of 'adb forward --list'.
/// Each line is "<serial> <local> <remote>", e.g. "emulator-5554 tcp:5000 tcp:6000".
/// Only rules matching <paramref name="serial"/> are returned. Lines with
/// unparseable socket specs are skipped.
/// </summary>
internal static IReadOnlyList<AdbPortRule> ParseForwardListOutput (IEnumerable<string> lines, string serial)
{
var rules = new List<AdbPortRule> ();
if (string.IsNullOrEmpty (serial))
return rules;
foreach (var line in lines) {
var trimmed = line.Trim ();
if (string.IsNullOrEmpty (trimmed))
continue;
// Expected format: "<serial> <local> <remote>"
var parts = trimmed.Split ((char[]?) null, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 3)
continue;
if (!string.Equals (parts [0], serial, StringComparison.Ordinal))
continue;
var local = AdbPortSpec.TryParse (parts [1]);
var remote = AdbPortSpec.TryParse (parts [2]);
if (local is { } l && remote is { } r)
rules.Add (new AdbPortRule (r, l));
}
return rules;
}
/// <summary>
/// Parses the output lines from 'adb devices -l'.
/// Accepts an <see cref="IEnumerable{T}"/> to avoid allocating a joined string.
/// </summary>
public static IReadOnlyList<AdbDeviceInfo> ParseAdbDevicesOutput (IEnumerable<string> lines)
{
var devices = new List<AdbDeviceInfo> ();
foreach (var line in lines) {
var trimmed = line.Trim ();
if (string.IsNullOrEmpty (trimmed) ||
trimmed.IndexOf ("List of devices", StringComparison.OrdinalIgnoreCase) >= 0 ||
trimmed.StartsWith ("*", StringComparison.Ordinal))
continue;
var match = AdbDevicesRegex.Match (trimmed);
if (!match.Success)
continue;
var serial = match.Groups [1].Value.Trim ();
var state = match.Groups [2].Value.Trim ();
var properties = match.Groups [3].Value.Trim ();
// Parse key:value pairs from the properties string
var propDict = new Dictionary<string, string> (StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrEmpty (properties)) {
var pairs = properties.Split (new [] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var pair in pairs) {
var colonIndex = pair.IndexOf (':');
if (colonIndex > 0 && colonIndex < pair.Length - 1) {
var key = pair.Substring (0, colonIndex);
var value = pair.Substring (colonIndex + 1);
propDict [key] = value;
}
}
}
var deviceType = serial.StartsWith ("emulator-", StringComparison.OrdinalIgnoreCase)
? AdbDeviceType.Emulator
: AdbDeviceType.Device;
var device = new AdbDeviceInfo {
Serial = serial,
Type = deviceType,
Status = MapAdbStateToStatus (state),
};
if (propDict.TryGetValue ("model", out var model))
device.Model = model;
if (propDict.TryGetValue ("product", out var product))
device.Product = product;
if (propDict.TryGetValue ("device", out var deviceCodeName))
device.Device = deviceCodeName;
if (propDict.TryGetValue ("transport_id", out var transportId))
device.TransportId = transportId;
// Build description (will be updated later if emulator AVD name is available)
device.Description = BuildDeviceDescription (device);
devices.Add (device);
}
return devices;
}
/// <summary>
/// Maps adb device states to status values.
/// Ported from dotnet/android GetAvailableAndroidDevices.MapAdbStateToStatus.
/// </summary>
public static AdbDeviceStatus MapAdbStateToStatus (string adbState) => adbState.ToLowerInvariant () switch {
"device" => AdbDeviceStatus.Online,
"offline" => AdbDeviceStatus.Offline,
"unauthorized" => AdbDeviceStatus.Unauthorized,
"no permissions" => AdbDeviceStatus.NoPermissions,
_ => AdbDeviceStatus.Unknown,
};
/// <summary>
/// Builds a human-friendly description for a device.
/// Priority: AVD name (for emulators) > model > product > device > serial.
/// Ported from dotnet/android GetAvailableAndroidDevices.BuildDeviceDescription.
/// </summary>
public static string BuildDeviceDescription (AdbDeviceInfo device, Action<TraceLevel, string>? logger = null)
{
logger ??= RunnerDefaults.NullLogger;
if (device.Type == AdbDeviceType.Emulator && device.AvdName is { Length: > 0 } avdName) {
logger.Invoke (TraceLevel.Verbose, $"Emulator {device.Serial}, original AVD name: {avdName}");
var formatted = FormatDisplayName (avdName);
logger.Invoke (TraceLevel.Verbose, $"Emulator {device.Serial}, formatted AVD display name: {formatted}");
return formatted;
}
if (device.Model is { Length: > 0 } model)
return model.Replace ('_', ' ');
if (device.Product is { Length: > 0 } product)
return product.Replace ('_', ' ');
if (device.Device is { Length: > 0 } deviceName)
return deviceName.Replace ('_', ' ');
return device.Serial;
}
/// <summary>
/// Formats an AVD name into a user-friendly display name.
/// Replaces underscores with spaces, applies title case, and capitalizes "API".
/// ToTitleCase naturally preserves fully-uppercase segments (e.g. "XL", "SE").
/// Ported from dotnet/android GetAvailableAndroidDevices.FormatDisplayName.
/// </summary>
public static string FormatDisplayName (string avdName)
{
if (string.IsNullOrEmpty (avdName))
return avdName ?? string.Empty;
var textInfo = CultureInfo.InvariantCulture.TextInfo;
avdName = textInfo.ToTitleCase (avdName.Replace ('_', ' '));
// Replace "Api" with "API"
avdName = ApiRegex.Replace (avdName, "API");
return avdName;
}
/// <summary>
/// Merges devices from adb with available emulators from 'emulator -list-avds'.
/// Running emulators are not duplicated. Non-running emulators are added with Status=NotRunning.
/// Ported from dotnet/android GetAvailableAndroidDevices.MergeDevicesAndEmulators.
/// </summary>
public static IReadOnlyList<AdbDeviceInfo> MergeDevicesAndEmulators (IReadOnlyList<AdbDeviceInfo> adbDevices, IReadOnlyList<string> availableEmulators, Action<TraceLevel, string>? logger = null)
{
logger ??= RunnerDefaults.NullLogger;
var result = new List<AdbDeviceInfo> (adbDevices);
// Build a set of AVD names that are already running
var runningAvdNames = new HashSet<string> (StringComparer.OrdinalIgnoreCase);
foreach (var device in adbDevices) {
if (device.AvdName is { Length: > 0 } avdName)
runningAvdNames.Add (avdName);
}
logger.Invoke (TraceLevel.Verbose, $"Running emulators AVD names: {string.Join (", ", runningAvdNames)}");
// Add non-running emulators
foreach (var avdName in availableEmulators) {
if (runningAvdNames.Contains (avdName)) {
logger.Invoke (TraceLevel.Verbose, $"Emulator '{avdName}' is already running, skipping");
continue;
}
var displayName = FormatDisplayName (avdName);
result.Add (new AdbDeviceInfo {
Serial = avdName,
Description = displayName + " (Not Running)",
Type = AdbDeviceType.Emulator,
Status = AdbDeviceStatus.NotRunning,
AvdName = avdName,
});
logger.Invoke (TraceLevel.Verbose, $"Added non-running emulator: {avdName}");
}
// Sort: online devices first, then not-running emulators, alphabetically by description
result.Sort ((a, b) => {
var aNotRunning = a.Status == AdbDeviceStatus.NotRunning;
var bNotRunning = b.Status == AdbDeviceStatus.NotRunning;
if (aNotRunning != bNotRunning)
return aNotRunning ? 1 : -1;
return string.Compare (a.Description, b.Description, StringComparison.OrdinalIgnoreCase);
});
return result;
}
}