Skip to content
Draft
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
32 changes: 32 additions & 0 deletions src/Microsoft.Android.Run/AdbHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Diagnostics;
using Xamarin.Android.Tools;

static class AdbHelper
{
public static ProcessStartInfo CreateStartInfo (string adbPath, string? adbTarget, string arguments)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this make sense going to android-tools? and use that nupkg?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we should open a PR here to test out any new Adb APIs from dotnet/android-tools. None are merged yet, right?

It would be proof the new APIs work, though.

{
var fullArguments = string.IsNullOrEmpty (adbTarget) ? arguments : $"{adbTarget} {arguments}";
return new ProcessStartInfo {
FileName = adbPath,
Arguments = fullArguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
}

public static async Task<(int ExitCode, string Output, string Error)> RunAsync (string adbPath, string? adbTarget, string arguments, CancellationToken cancellationToken, bool verbose = false)
{
var psi = CreateStartInfo (adbPath, adbTarget, arguments);

if (verbose)
Console.WriteLine ($"Running: adb {psi.Arguments}");

using var stdout = new StringWriter ();
using var stderr = new StringWriter ();
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken);

return (exitCode, stdout.ToString (), stderr.ToString ());
}
}
173 changes: 111 additions & 62 deletions src/Microsoft.Android.Run/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,23 @@
string? package = null;
string? activity = null;
string? deviceUserId = null;
string? instrumentation = null;
bool verbose = false;
int? logcatPid = null;
Process? logcatProcess = null;
CancellationTokenSource cts = new ();
string? logcatArgs = null;

try {
return Run (args);
return await RunAsync (args);
} catch (Exception ex) {
Console.Error.WriteLine ($"Error: {ex.Message}");
if (verbose)
Console.Error.WriteLine (ex.ToString ());
return 1;
}

int Run (string[] args)
async Task<int> RunAsync (string[] args)
{
bool showHelp = false;
bool showVersion = false;
Expand All @@ -47,11 +48,15 @@ int Run (string[] args)
"The Android application {PACKAGE} name (e.g., com.example.myapp). Required.",
v => package = v },
{ "c|activity=",
"The {ACTIVITY} class name to launch. Required.",
"The {ACTIVITY} class name to launch. Required unless --instrument is used.",
v => activity = v },
{ "user=",
"The Android device {USER_ID} to launch the activity under (e.g., 10 for a work profile).",
v => deviceUserId = v },
{ "i|instrument=",
"The instrumentation {RUNNER} class name (e.g., com.example.myapp.TestInstrumentation). " +
"When specified, runs 'am instrument' instead of 'am start'.",
v => instrumentation = v },
{ "v|verbose",
"Enable verbose output for debugging.",
v => verbose = v != null },
Expand Down Expand Up @@ -95,9 +100,9 @@ int Run (string[] args)
options.WriteOptionDescriptions (Console.Out);
Console.WriteLine ();
Console.WriteLine ("Examples:");
Console.WriteLine ($" {Name} -p com.example.myapp");
Console.WriteLine ($" {Name} -p com.example.myapp -c com.example.myapp.MainActivity");
Console.WriteLine ($" {Name} --adb /path/to/adb -p com.example.myapp");
Console.WriteLine ($" {Name} -p com.example.myapp -i com.example.myapp.TestInstrumentation");
Console.WriteLine ($" {Name} --adb /path/to/adb -p com.example.myapp -c com.example.myapp.MainActivity");
Console.WriteLine ();
Console.WriteLine ("Press Ctrl+C while running to stop the Android application and exit.");
return 0;
Expand All @@ -109,8 +114,16 @@ int Run (string[] args)
return 1;
}

if (string.IsNullOrEmpty (activity)) {
Console.Error.WriteLine ("Error: --activity is required.");
bool isInstrumentMode = !string.IsNullOrEmpty (instrumentation);

if (!isInstrumentMode && string.IsNullOrEmpty (activity)) {
Console.Error.WriteLine ("Error: --activity or --instrument is required.");
Console.Error.WriteLine ($"Try '{Name} --help' for more information.");
return 1;
}

if (isInstrumentMode && !string.IsNullOrEmpty (activity)) {
Console.Error.WriteLine ("Error: --activity and --instrument cannot be used together.");
Console.Error.WriteLine ($"Try '{Name} --help' for more information.");
return 1;
}
Expand All @@ -129,20 +142,27 @@ int Run (string[] args)
return 1;
}

Debug.Assert (adbPath != null, "adbPath should be non-null after validation");

if (verbose) {
Console.WriteLine ($"Using adb: {adbPath}");
if (!string.IsNullOrEmpty (adbTarget))
Console.WriteLine ($"Target: {adbTarget}");
Console.WriteLine ($"Package: {package}");
if (!string.IsNullOrEmpty (activity))
Console.WriteLine ($"Activity: {activity}");
if (isInstrumentMode)
Console.WriteLine ($"Instrumentation runner: {instrumentation}");
}

// Set up Ctrl+C handler
Console.CancelKeyPress += OnCancelKeyPress;

try {
return RunApp ();
if (isInstrumentMode)
return await RunInstrumentationAsync ();

return await RunAppAsync ();
} finally {
Console.CancelKeyPress -= OnCancelKeyPress;
cts.Dispose ();
Expand All @@ -157,8 +177,8 @@ void OnCancelKeyPress (object? sender, ConsoleCancelEventArgs e)

cts.Cancel ();

// Force-stop the app
StopApp ();
// Force-stop the app (fire-and-forget in cancel handler)
_ = StopAppAsync ();

// Kill logcat process if running
try {
Expand All @@ -171,14 +191,80 @@ void OnCancelKeyPress (object? sender, ConsoleCancelEventArgs e)
}
}

int RunApp ()
async Task<int> RunInstrumentationAsync ()
{
// Build the am instrument command
var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}";
var cmdArgs = $"shell am instrument -w{userArg} {package}/{instrumentation}";

if (verbose)
Console.WriteLine ($"Running instrumentation: adb {cmdArgs}");

// Run instrumentation with streaming output
var psi = AdbHelper.CreateStartInfo (adbPath, adbTarget, cmdArgs);
using var instrumentProcess = new Process { StartInfo = psi };

var locker = new Lock ();

instrumentProcess.OutputDataReceived += (s, e) => {
if (e.Data != null)
lock (locker)
Console.WriteLine (e.Data);
};

instrumentProcess.ErrorDataReceived += (s, e) => {
if (e.Data != null)
lock (locker)
Console.Error.WriteLine (e.Data);
};

instrumentProcess.Start ();
instrumentProcess.BeginOutputReadLine ();
instrumentProcess.BeginErrorReadLine ();

// Also start logcat in the background for additional debug output
logcatPid = await GetAppPidAsync ();
if (logcatPid != null)
StartLogcat ();

// Wait for instrumentation to complete or Ctrl+C
try {
while (!instrumentProcess.HasExited && !cts.Token.IsCancellationRequested)
await Task.Delay (250, cts.Token).ConfigureAwait (ConfigureAwaitOptions.SuppressThrowing);

if (cts.Token.IsCancellationRequested) {
try { instrumentProcess.Kill (); } catch { }
return 1;
}

instrumentProcess.WaitForExit ();
} finally {
// Clean up logcat
try {
if (logcatProcess != null && !logcatProcess.HasExited) {
logcatProcess.Kill ();
logcatProcess.WaitForExit (1000);
}
} catch { }
}

// Check exit status
if (instrumentProcess.ExitCode != 0) {
Console.Error.WriteLine ($"Error: adb instrument exited with code {instrumentProcess.ExitCode}");
return 1;
}

return 0;
}

async Task<int> RunAppAsync ()
{
// 1. Start the app
if (!StartApp ())
if (!await StartAppAsync ())
return 1;

// 2. Get the PID
logcatPid = GetAppPid ();
logcatPid = await GetAppPidAsync ();
if (logcatPid == null) {
Console.Error.WriteLine ("Error: App started but could not retrieve PID. The app may have crashed.");
return 1;
Expand All @@ -191,16 +277,16 @@ int RunApp ()
StartLogcat ();

// 4. Wait for app to exit or Ctrl+C
WaitForAppExit ();
await WaitForAppExitAsync ();

return 0;
}

bool StartApp ()
async Task<bool> StartAppAsync ()
{
var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}";
var cmdArgs = $"shell am start -S -W{userArg} -n \"{package}/{activity}\"";
var (exitCode, output, error) = RunAdb (cmdArgs);
var (exitCode, output, error) = await AdbHelper.RunAsync (adbPath, adbTarget, cmdArgs, cts.Token, verbose);
if (exitCode != 0) {
Console.Error.WriteLine ($"Error: Failed to start app: {error}");
return false;
Expand All @@ -212,10 +298,10 @@ bool StartApp ()
return true;
}

int? GetAppPid ()
async Task<int?> GetAppPidAsync ()
{
var cmdArgs = $"shell pidof {package}";
var (exitCode, output, error) = RunAdb (cmdArgs);
var (exitCode, output, error) = await AdbHelper.RunAsync (adbPath, adbTarget, cmdArgs, cts.Token, verbose);
if (exitCode != 0 || string.IsNullOrWhiteSpace (output))
return null;

Expand All @@ -235,20 +321,12 @@ void StartLogcat ()
if (!string.IsNullOrEmpty (logcatArgs))
logcatArguments += $" {logcatArgs}";

var fullArguments = string.IsNullOrEmpty (adbTarget) ? logcatArguments : $"{adbTarget} {logcatArguments}";
var psi = AdbHelper.CreateStartInfo (adbPath, adbTarget, logcatArguments);

if (verbose)
Console.WriteLine ($"Running: adb {fullArguments}");
Console.WriteLine ($"Running: adb {psi.Arguments}");

var locker = new Lock();
var psi = new ProcessStartInfo {
FileName = adbPath,
Arguments = fullArguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};

logcatProcess = new Process { StartInfo = psi };

Expand All @@ -269,11 +347,11 @@ void StartLogcat ()
logcatProcess.BeginErrorReadLine ();
}

void WaitForAppExit ()
async Task WaitForAppExitAsync ()
{
while (!cts!.Token.IsCancellationRequested) {
// Check if app is still running
var pid = GetAppPid ();
var pid = await GetAppPidAsync ();
if (pid == null || pid != logcatPid) {
if (verbose)
Console.WriteLine ("App has exited.");
Expand All @@ -287,7 +365,7 @@ void WaitForAppExit ()
break;
}

Thread.Sleep (1000);
await Task.Delay (1000, cts.Token).ConfigureAwait (ConfigureAwaitOptions.SuppressThrowing);
}

// Clean up logcat process
Expand All @@ -302,13 +380,13 @@ void WaitForAppExit ()
}
}

void StopApp ()
async Task StopAppAsync ()
{
if (string.IsNullOrEmpty (package) || string.IsNullOrEmpty (adbPath))
return;

var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}";
RunAdb ($"shell am force-stop{userArg} {package}");
await AdbHelper.RunAsync (adbPath, adbTarget, $"shell am force-stop{userArg} {package}", CancellationToken.None, verbose);
}

string? FindAdbPath ()
Expand All @@ -332,35 +410,6 @@ void StopApp ()
return null;
}

(int ExitCode, string Output, string Error) RunAdb (string arguments)
{
var fullArguments = string.IsNullOrEmpty (adbTarget) ? arguments : $"{adbTarget} {arguments}";

if (verbose)
Console.WriteLine ($"Running: adb {fullArguments}");

var psi = new ProcessStartInfo {
FileName = adbPath,
Arguments = fullArguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};

using var process = Process.Start (psi);
if (process == null)
return (-1, "", "Failed to start process");

// Read both streams asynchronously to avoid potential deadlock
var outputTask = process.StandardOutput.ReadToEndAsync ();
var errorTask = process.StandardError.ReadToEndAsync ();

process.WaitForExit ();

return (process.ExitCode, outputTask.Result, errorTask.Result);
}

(string? Version, string? Commit) GetVersionInfo ()
{
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"$schema": "http://json.schemastore.org/template",
"author": "Microsoft",
"classifications": [ "Android", "Mobile", "Test" ],
"identity": "Microsoft.Android.AndroidTest",
"name": "Android Test Project",
"description": "A project for creating a .NET for Android test project using MSTest",
"shortName": "androidtest",
"tags": {
"language": "C#",
"type": "project"
},
"sourceName": "AndroidTest1",
"preferNameDirectory": true,
"primaryOutputs": [
{ "path": "AndroidTest1.csproj" }
],
"symbols": {
"packageName": {
"type": "parameter",
"description": "Overrides the package name in the AndroidManifest.xml",
"datatype": "string",
"replaces": "com.companyname.AndroidTest1"
},
"supportedOSVersion": {
"type": "parameter",
"description": "Overrides $(SupportedOSPlatformVersion) in the project",
"datatype": "string",
"replaces": "SUPPORTED_OS_PLATFORM_VERSION",
"defaultValue": "24"
}
},
"defaultName": "AndroidTest1"
}
Loading