Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
728114f
[dotnet test] Add `androidtest` project template and `dotnet run` ins…
jonathanpeppers Feb 26, 2026
f121c7c
`--user`
jonathanpeppers Mar 6, 2026
c7eb96b
Use `ProcessUtils` and async
jonathanpeppers Mar 6, 2026
1afca66
Pass in CancellationToken
jonathanpeppers Mar 6, 2026
2a62e8c
[xabt] Handle native PE files in HasMonoAndroidReference
jonathanpeppers Mar 9, 2026
7e8d16e
Merge branch 'main' into dev/peppers/androidtest
jonathanpeppers Mar 11, 2026
6467668
[xabt] Fix duplicate assembly in _ComputeFilesToPublishForRuntimeIden…
jonathanpeppers Mar 11, 2026
fba4516
[xabt] Fix duplicate assembly names in _ComputeFilesToPublishForRunti…
jonathanpeppers Mar 12, 2026
280ecd7
Merge branch 'main' into dev/peppers/androidtest
jonathanpeppers Mar 12, 2026
d9e04ed
Exclude native DLLs from ResolvedFileToPublish re-addition
jonathanpeppers Mar 12, 2026
9d213bb
[xabt] Filter non-Android .so files from _ResolvedNativeLibraries
jonathanpeppers Mar 13, 2026
93cd761
Merge branch 'main' into dev/peppers/androidtest
jonathanpeppers Mar 16, 2026
7dc4b2b
Simplify _SourceItemsToCopyToPublishDirectory handling in _ComputeFil…
jonathanpeppers Mar 16, 2026
80db32c
Update MSTest to 4.1.0 in androidtest template
jonathanpeppers Mar 16, 2026
c5b8a2c
Fix duplicate ResolvedFileToPublish from NuGet Reference items
jonathanpeppers Mar 16, 2026
4f36330
Remove obsolete CancelledTestNodeStateProperty usage
jonathanpeppers Mar 17, 2026
27796e3
Fix missing MSTestAdapter.PlatformServices.dll in APK
jonathanpeppers Mar 18, 2026
0066738
Merge remote-tracking branch 'origin/main' into dev/peppers/androidtest
jonathanpeppers Mar 19, 2026
111a2cc
Merge main into dev/peppers/androidtest
jonathanpeppers Mar 19, 2026
53796cc
Revert "Fix missing MSTestAdapter.PlatformServices.dll in APK"
jonathanpeppers Mar 19, 2026
9203869
Fix missing MSTestAdapter.PlatformServices.dll in APK
jonathanpeppers Mar 19, 2026
af3aba6
Only promote @(None) items with CopyToOutputDirectory=Always|Preserve…
jonathanpeppers Mar 19, 2026
3085945
Qualify %(None.CopyToOutputDirectory) to fix MSB4096
jonathanpeppers Mar 19, 2026
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)
{
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