From 663060b22b02f872217f8c602db5671ad64ce03a Mon Sep 17 00:00:00 2001 From: David Nguyen <87228593+davidnguyen-tech@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:45:01 +0200 Subject: [PATCH 1/2] [debugging] Protect managed-only cold activity startup from ANRs Use a bounded transient non-wait debug-app transaction for eligible cold activity launches, observe process attach before clearing, and preserve unsupported launch paths with explicit diagnostics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../guides/managed-activity-debugging.md | 86 ++ src/Mono.AndroidTools/AndroidDevice.cs | 28 + .../Debugging/DebuggingExtensions.cs | 25 +- .../Debugging/ManagedActivityLaunch.cs | 257 +++++ .../Properties/Resources.Designer.cs | 40 + .../Properties/Resources.resx | 30 + .../ManagedActivityLaunchTests.cs | 960 ++++++++++++++++++ 7 files changed, 1425 insertions(+), 1 deletion(-) create mode 100644 Documentation/guides/managed-activity-debugging.md create mode 100644 src/Xamarin.AndroidTools/Debugging/ManagedActivityLaunch.cs create mode 100644 tests/Xamarin.Android.Tools.AndroidSdk-Tests/ManagedActivityLaunchTests.cs diff --git a/Documentation/guides/managed-activity-debugging.md b/Documentation/guides/managed-activity-debugging.md new file mode 100644 index 00000000000..656e0326e2d --- /dev/null +++ b/Documentation/guides/managed-activity-debugging.md @@ -0,0 +1,86 @@ +# Managed-only activity startup + +`_AndroidAllowJavaDebugging` already defaults to `False` in the MSBuild debugging +targets. This avoids launching the activity with `am start -D`, which requests +Java debugger startup. It does not by itself tell ActivityManager that a managed +debugger may deliberately pause the process during startup. + +For a managed-only forced cold activity launch, `StartWithDebuggingAsync` uses +`am set-debug-app ` without `-w` or `--persistent`. Android selects +`DEBUG_ON`, rather than `DEBUG_WAIT`: it marks the process as debugging without +waiting for a Java debugger. This preserves ActivityManager's debugging state +while the managed debugger attaches. AOSP's `appNotResponding` skips a process +marked as debugging instead of treating its intentional startup pause as an ANR. + +The launch transaction: + +- Serializes activity debug launches to the same device serial within the host + process, including cleanup. +- Checks the package/component identity, Android users, and ActivityManager + debug-app state before arming the one-shot setting. +- Awaits `am start` and then observes both the consumed global setting and the + matching process's `mDebugging=true` record. A visible PID is not sufficient. + It does not use `am start -W` or wait for application code to run. +- Cleans up with a separate five-second cancellation budget, including after + launch failure, timeout, or cancellation. Cleanup errors are reported without + replacing a primary launch error. + +In AOSP, `attachApplicationLocked` sets the process debugging flag before restoring +the original global debug-app/wait settings, under the ActivityManager lock. +`mDebugTransient` remains true after consumption. Clearing the global setting +after observing this transition does not clear the process's debugging flag. + +## Scope and limitations + +Protection requires Android 12 or later, an explicit activity in the configured +package, `ForceStop=true`, no wait/repeat option, and a single-user device with +the launch targeting that user. Older Android versions, warm launches, repeated +or waiting launches, and multi-user devices retain their existing launch behavior +with a diagnostic that they are not protected. In particular, `set-debug-app` +force-stops the package for **all users**; it must not silently replace a +user-scoped force-stop on a multi-user device. + +The explicit component's package determines eligibility; the command's optional, +non-emitted `PackageName` bookkeeping is not required. Implicit intents retain +their existing unprotected launch with a diagnostic. Fallback launches check +cancellation both before and after executing the legacy intent command. + +An unsupported initial ActivityManager process dump layout, including a vendor +postamble after the expected AOSP ending, also retains the unprotected launch +with a diagnostic and no debug-app mutation. This does not treat unknown output +as empty state: recognized layouts with malformed ownership records still fail. +Transport failures and cancellation are not layout fallbacks. After any marker +mutation, including during cleanup, unrecognized or truncated dumps remain errors. +No vendor-specific parsing or OEM-device validation is claimed. + +Explicit Java-debugging, no-debug, null-command, broadcast, and instrumentation +paths do not use this transaction. Public Java-debugging options remain available. +Before arming, `pm resolve-activity` checks the selected user's installed activity +metadata. Android resolves application-level process inheritance and activity-level +overrides, including relative process names, into `ActivityInfo.processName`. +Custom processes and metadata that cannot be confirmed retain the unprotected +launch with an explicit diagnostic. The process name is never passed to +`set-debug-app`, because that command also force-stops a **package**. + +Unrelated adb clients do not participate in the host lock. An observed different +debug target or persistent/original setting is not cleared. Android has no atomic +compare-and-clear API or ownership identifier: concurrent external changes, +including another launch of the same package, cannot be made race-free. Likewise, +a disconnected device or an unresponsive remote shell can prevent cleanup; a +host-side timeout cannot guarantee that a remote command will never execute late. +Do not run competing debug-app transactions on the same device. + +## Public Android references + +- [Android 16 ActivityManagerService: attach and setDebugApp](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-16.0.0_r1/services/core/java/com/android/server/am/ActivityManagerService.java) +- [Android 16 ProcessRecord: process debugging state](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-16.0.0_r1/services/core/java/com/android/server/am/ProcessRecord.java) +- [Android 16 ActivityThread: DEBUG_WAIT versus DEBUG_ON](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-16.0.0_r1/core/java/android/app/ActivityThread.java) +- [Android 16 ProcessErrorStateRecord: debugged-process ANR exemption](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-16.0.0_r1/services/core/java/com/android/server/am/ProcessErrorStateRecord.java) +- [Android 12 ActivityManagerService](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-12.0.0_r1/services/core/java/com/android/server/am/ActivityManagerService.java) +- [Android 12 PackageManagerShellCommand: resolve-activity](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-12.0.0_r1/services/core/java/com/android/server/pm/PackageManagerShellCommand.java) +- [Android 12 ComponentInfo: effective process dump](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-12.0.0_r1/core/java/android/content/pm/ComponentInfo.java) + +`ManagedActivityLaunchTests` in `Xamarin.Android.Tools.AndroidSdk-Tests` exercises +the real shared launch implementation and ADB transport against a private TCP +server. These deterministic tests do not replace device-side managed debugger +startup and breakpoint validation. diff --git a/src/Mono.AndroidTools/AndroidDevice.cs b/src/Mono.AndroidTools/AndroidDevice.cs index b73d40d0861..93d97ac9d39 100644 --- a/src/Mono.AndroidTools/AndroidDevice.cs +++ b/src/Mono.AndroidTools/AndroidDevice.cs @@ -696,10 +696,38 @@ public Task StartActivity (string action, string [] categories, string package, /// Executes the given intent command, if logWriter is not null passes the output of the command to logWriter /// public async Task ExecuteIntentCommandAsync(AmIntentCommand intentCommand, Action logWiter, CancellationToken cancellationToken = default(CancellationToken)) + { + await ExecuteIntentCommandAsync (intentCommand, logWiter, cancellationToken, waitForCompletion: false).ConfigureAwait (false); + } + + /// + /// Executes an intent, optionally awaiting the shell command instead of returning + /// after five seconds. The caller must supply a bounded cancellation token when waiting. + /// + public async Task ExecuteIntentCommandAsync (AmIntentCommand intentCommand, Action logWiter, CancellationToken cancellationToken, bool waitForCompletion) { var command = intentCommand.ToString(); var log = new AndroidTaskLog("StartIntent", command); + if (waitForCompletion) { + var output = await RunShellCommand (command, cancellationToken).ConfigureAwait (false); + cancellationToken.ThrowIfCancellationRequested (); + AndroidLogger.LogTask (log.Complete (output)); + logWiter?.Invoke (output); + AdbOutputParsing.CheckStartResult (output, intentCommand.Component ?? intentCommand.Intent); + if (intentCommand is AmStartCommand) { + bool starting = false; + foreach (var line in output.Split ('\n')) { + starting |= line.StartsWith ("Starting: Intent {", StringComparison.Ordinal) && line.TrimEnd ('\r').EndsWith ("}", StringComparison.Ordinal); + if (line.StartsWith ("Error:", StringComparison.Ordinal) || line.StartsWith ("Exception occurred while executing", StringComparison.Ordinal)) + throw new AdbException (output); + } + if (!starting) + throw new AdbException (output); + } + return; + } + var shellTask = RunShellCommand(command, cancellationToken).ContinueWith(t => { if (t.IsFaulted) { AndroidLogger.LogError("Error executing intent", t.Exception); diff --git a/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs b/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs index 2915cd57159..c176ef96a0a 100644 --- a/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs +++ b/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs @@ -29,6 +29,22 @@ public static class DebuggingExtensions /// Starts the process debugging using the given execution configuration /// public async static Task StartWithDebuggingAsync(this IAndroidDevice device, ExecutionConfiguration configuration, CancellationToken token) + { + if (configuration.RunCommand is AmStartCommand) { + var gate = ManagedActivityLaunch.GetGate (device.ID); + if (!await gate.WaitAsync (TimeSpan.FromSeconds (30), token).ConfigureAwait (false)) + throw new TimeoutException (Properties.Resources.ManagedLaunchGateTimeout); + try { + await StartWithDebuggingCoreAsync (device, configuration, token).ConfigureAwait (false); + } finally { + gate.Release (); + } + } else { + await StartWithDebuggingCoreAsync (device, configuration, token).ConfigureAwait (false); + } + } + + static async Task StartWithDebuggingCoreAsync (IAndroidDevice device, ExecutionConfiguration configuration, CancellationToken token) { // TODO: refactor IAndroidDevice some more to remove casts var androidDevice = (AndroidDevice)device; @@ -43,6 +59,9 @@ public async static Task StartWithDebuggingAsync(this IAndroidDevice device, Exe } bool javaDebugging = false; + if (!configuration.AllowJavaDebugging && configuration.RunCommand is AmStartCommand managedCommand) + managedCommand.EnableDebugging = false; + if (configuration.AllowJavaDebugging && configuration.RunCommand is AmStartCommand) { var cmd = ((AmStartCommand)configuration.RunCommand); if (androidDevice.IsWSA() || androidDevice.IsEmulator) // force -D for WSA and Emulators @@ -59,7 +78,11 @@ public async static Task StartWithDebuggingAsync(this IAndroidDevice device, Exe configuration.LogWiter(configuration.RunCommand.ToString()); } - await androidDevice.ExecuteIntentCommandAsync(configuration.RunCommand, configuration.LogWiter, token).ConfigureAwait(false); + if (!configuration.AllowJavaDebugging && configuration.RunCommand is AmStartCommand startCommand) { + await ManagedActivityLaunch.RunAsync (androidDevice, configuration, startCommand, token).ConfigureAwait (false); + } else { + await androidDevice.ExecuteIntentCommandAsync(configuration.RunCommand, configuration.LogWiter, token).ConfigureAwait(false); + } if (javaDebugging) await androidDevice.ConnectJdwpAsync (configuration, token).ConfigureAwait(false); } diff --git a/src/Xamarin.AndroidTools/Debugging/ManagedActivityLaunch.cs b/src/Xamarin.AndroidTools/Debugging/ManagedActivityLaunch.cs new file mode 100644 index 00000000000..a5cb2d06acf --- /dev/null +++ b/src/Xamarin.AndroidTools/Debugging/ManagedActivityLaunch.cs @@ -0,0 +1,257 @@ +// 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.Concurrent; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Mono.AndroidTools; +using Mono.AndroidTools.Util; +using Xamarin.AndroidTools.Properties; + +namespace Xamarin.AndroidTools.Debugging +{ + // ActivityManager has one debug-app slot per device, not per package or user. + // Keep the gate through cleanup, including when another AndroidDevice instance + // targets the same serial. Unrelated adb clients do not participate in this lock. + static class ManagedActivityLaunch + { + static readonly ConcurrentDictionary gates = new ConcurrentDictionary (StringComparer.Ordinal); + const int CleanupTimeoutMilliseconds = 5000; + const int PollMilliseconds = 100; + const string DumpCommand = "dumpsys activity processes"; + const string ClearCommand = "am clear-debug-app"; + static readonly Regex packagePattern = new Regex (@"\A[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+\z"); + static readonly Regex markerPattern = new Regex (@"(?m)^ mDebugApp=(\S+)/orig=(\S+) mDebugTransient=(true|false) mOrigWaitForDebugger=(true|false)\r?$"); + + internal static SemaphoreSlim GetGate (string serial) => gates.GetOrAdd (serial, _ => new SemaphoreSlim (1, 1)); + + internal static async Task RunAsync (AndroidDevice device, ExecutionConfiguration configuration, AmStartCommand command, CancellationToken token) + { + // set-debug-app force-stops even an already-running package. Never turn + // a warm launch into a cold one, or reinterpret caller-requested waits/repeats. + if (!command.ForceStop || command.Wait || command.Repeat != 0 || device.BuildVersionSdk < 31) { + await LaunchUnprotectedAsync (device, configuration, command, Resources.ManagedLaunchUnsupported, token).ConfigureAwait (false); + return; + } + + var package = configuration.PackageName; + if (!packagePattern.IsMatch (package)) + throw new ArgumentException (Resources.ManagedLaunchPackageMismatch, nameof (configuration)); + if (string.IsNullOrEmpty (command.Component)) { + await LaunchUnprotectedAsync (device, configuration, command, Resources.ManagedLaunchComponentUnsupported, token).ConfigureAwait (false); + return; + } + if (!command.Component.StartsWith (package + "/", StringComparison.Ordinal) || command.Component.Length == package.Length + 1) + throw new ArgumentException (Resources.ManagedLaunchPackageMismatch, nameof (configuration)); + if (configuration.Debugger.Timeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException (nameof (configuration.Debugger.Timeout)); + + using (var timeout = CancellationTokenSource.CreateLinkedTokenSource (token)) { + timeout.CancelAfter (configuration.Debugger.Timeout); + bool armed = false; + Exception primaryError = null; + try { + // AOSP's setDebugApp uses USER_ALL. Restrict this transaction to + // single-user devices so an explicit --user never kills another profile. + var users = await device.RunShellCommand ("pm list users", timeout.Token).ConfigureAwait (false); + var userLines = users.Trim ().Split ('\n'); + var userIds = Regex.Matches (users, @"(?m)^[ \t]*UserInfo\{(\d+):[^{}\r\n]*:[0-9a-fA-F]+\}(?: running)?\r?$"); + if (userLines [0].TrimEnd ('\r') != "Users:" || userIds.Count == 0 || userIds.Count != userLines.Length - 1) + throw new InvalidOperationException (Resources.ManagedLaunchStateUnavailable); + if (userIds.Count != 1 || (!string.IsNullOrEmpty (command.User) && command.User != "current" && command.User != userIds [0].Groups [1].Value)) { + await LaunchUnprotectedAsync (device, configuration, command, Resources.ManagedLaunchUnsupported, token).ConfigureAwait (false); + return; + } + + if (!await UsesPackageProcessAsync (device, command, package, userIds [0].Groups [1].Value, timeout.Token).ConfigureAwait (false)) { + await LaunchUnprotectedAsync (device, configuration, command, Resources.ManagedLaunchProcessUnsupported, token).ConfigureAwait (false); + return; + } + + DebugAppState state; + try { + state = await ReadStateAsync (device, timeout.Token).ConfigureAwait (false); + } catch (UnsupportedDumpLayoutException) { + // Before any mutation, an unknown dump layout only means that + // protection is unavailable. Never apply this fallback after arming. + await LaunchUnprotectedAsync (device, configuration, command, Resources.ManagedLaunchLayoutUnsupported, token).ConfigureAwait (false); + return; + } + if (!state.CanClear (package)) + throw new InvalidOperationException (Resources.ManagedLaunchStateConflict); + if (state.HasMarker) { + armed = true; + using (var mutation = new CancellationTokenSource (CleanupTimeoutMilliseconds)) + await ExecuteEmptyCommandAsync (device, ClearCommand, mutation.Token).ConfigureAwait (false); + } + timeout.Token.ThrowIfCancellationRequested (); + + var builder = new ProcessArgumentBuilder (); + builder.Add ("am", "set-debug-app"); + builder.AddQuoted (package); + armed = true; + // Do not abandon an in-flight mutation on caller cancellation: + // drain its reply before cleanup can overtake it on another connection. + using (var mutation = new CancellationTokenSource (CleanupTimeoutMilliseconds)) + await ExecuteEmptyCommandAsync (device, builder.ToString (), mutation.Token).ConfigureAwait (false); + state = await ReadStateAsync (device, timeout.Token).ConfigureAwait (false); + if (!state.IsPending (package)) + throw new InvalidOperationException (Resources.ManagedLaunchStateConflict); + + await device.ExecuteIntentCommandAsync (command, configuration.LogWiter, timeout.Token, waitForCompletion: true).ConfigureAwait (false); + while (true) { + state = await ReadStateAsync (device, timeout.Token).ConfigureAwait (false); + if (!state.CanClear (package)) + throw new InvalidOperationException (Resources.ManagedLaunchStateConflict); + // attachApplicationLocked sets mDebugging before restoring the + // globals, under the same AMS lock used by dumpsys. A PID alone + // is too early, and waiting for activity startup would deadlock + // managed debugger attach. mDebugTransient remains true here. + if (state.IsConsumed && state.HasDebuggingProcess (package, userIds [0].Groups [1].Value)) + break; + await Task.Delay (PollMilliseconds, timeout.Token).ConfigureAwait (false); + } + token.ThrowIfCancellationRequested (); + } catch (Exception ex) { + primaryError = ex; + if (timeout.IsCancellationRequested) { + primaryError = token.IsCancellationRequested + ? (Exception) new OperationCanceledException (token) + : new TimeoutException (Resources.ManagedLaunchTimeout, ex); + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture (primaryError).Throw (); + } + throw; + } finally { + if (armed) { + using (var cleanup = new CancellationTokenSource (CleanupTimeoutMilliseconds)) { + try { + var state = await ReadStateAsync (device, cleanup.Token).ConfigureAwait (false); + if (!state.CanClear (package)) + throw new InvalidOperationException (Resources.ManagedLaunchStateConflict); + if (state.HasMarker) + await ExecuteEmptyCommandAsync (device, ClearCommand, cleanup.Token).ConfigureAwait (false); + state = await ReadStateAsync (device, cleanup.Token).ConfigureAwait (false); + if (state.HasMarker) + throw new InvalidOperationException (Resources.ManagedLaunchStateConflict); + } catch (Exception cleanupError) when (primaryError != null) { + // Preserve launch/cancellation errors, but never hide failed cleanup. + AndroidLogger.LogError (Resources.ManagedLaunchCleanupFailed, cleanupError); + } + } + } + } + } + token.ThrowIfCancellationRequested (); + } + + static async Task LaunchUnprotectedAsync (AndroidDevice device, ExecutionConfiguration configuration, AmStartCommand command, string diagnostic, CancellationToken token) + { + AndroidLogger.LogInfo (diagnostic); + configuration.LogWiter?.Invoke (diagnostic); + // The legacy intent continuation can turn a canceled shell into empty + // output. Preserve its launch behavior, but not cancellation-as-success. + token.ThrowIfCancellationRequested (); + await device.ExecuteIntentCommandAsync (command, configuration.LogWiter, token).ConfigureAwait (false); + token.ThrowIfCancellationRequested (); + } + + static async Task UsesPackageProcessAsync (AndroidDevice device, AmStartCommand command, string package, string user, CancellationToken token) + { + // No existing device metadata helper exposes ActivityInfo.processName. + // PackageManager resolves both application inheritance and activity overrides. + var builder = new ProcessArgumentBuilder (); + builder.Add ("pm", "resolve-activity", "--user"); + builder.AddQuoted (user); + builder.Add ("-n"); + builder.AddQuoted (command.Component); + var output = await device.RunShellCommand (builder.ToString (), token).ConfigureAwait (false); + token.ThrowIfCancellationRequested (); + + var activityName = command.Component.Substring (package.Length + 1); + if (activityName.StartsWith (".", StringComparison.Ordinal)) + activityName = package + activityName; + var activity = Regex.Match (output, @"(?m)^ActivityInfo:\r?\n(?(?: [^\r\n]*\r?\n)+)"); + if (!activity.Success || activity.NextMatch ().Success) + return false; + var fields = activity.Groups ["fields"].Value; + if (!Regex.IsMatch (fields, @"(?m)^ name=" + Regex.Escape (activityName) + @"\r?$") || + !Regex.IsMatch (fields, @"(?m)^ packageName=" + Regex.Escape (package) + @"\r?$") || + !Regex.IsMatch (fields, @"(?m)^ enabled=(true|false) exported=(true|false) directBootAware=(true|false)\r?$") || + !Regex.IsMatch (fields, @"(?m)^ ApplicationInfo:\r?$")) + return false; + + // ComponentInfo omits processName when it equals the package. Only read + // the two-space activity field, never ApplicationInfo's four-space value: + // an activity can override a custom application process back to the package. + var processes = Regex.Matches (fields, @"(?m)^ processName=(\S+)\r?$"); + return processes.Count == 0 + ? !Regex.IsMatch (fields, @"(?m)^ processName=") + : processes.Count == 1 && processes [0].Groups [1].Value == package; + } + + static async Task ExecuteEmptyCommandAsync (AndroidDevice device, string command, CancellationToken token) + { + var output = await device.RunShellCommand (command, token).ConfigureAwait (false); + token.ThrowIfCancellationRequested (); + if (!string.IsNullOrWhiteSpace (output)) + throw new AdbException (output); + } + + static async Task ReadStateAsync (AndroidDevice device, CancellationToken token) + { + var dump = await device.RunShellCommand (DumpCommand, token).ConfigureAwait (false); + token.ThrowIfCancellationRequested (); + return new DebugAppState (dump); + } + + sealed class UnsupportedDumpLayoutException : InvalidOperationException + { + internal UnsupportedDumpLayoutException () : base (Resources.ManagedLaunchStateUnavailable) + { + } + } + + sealed class DebugAppState + { + readonly string dump; + readonly Match marker; + + internal DebugAppState (string dump) + { + this.dump = dump; + if (!dump.StartsWith ("ACTIVITY MANAGER RUNNING PROCESSES (dumpsys activity processes)", StringComparison.Ordinal) || + !Regex.IsMatch (dump, @"(?m)^ mForceBackgroundCheck=(true|false)\s*\z")) + throw new UnsupportedDumpLayoutException (); + marker = markerPattern.Match (dump); + if ((dump.Contains ("mDebugApp=") && !marker.Success) || marker.NextMatch ().Success) + throw new InvalidOperationException (Resources.ManagedLaunchStateUnavailable); + } + + internal bool HasMarker => marker.Success; + internal bool IsConsumed => HasMarker && marker.Groups [1].Value == "null" && CanClear ("null"); + internal bool IsPending (string package) => HasMarker && marker.Groups [1].Value == package && CanClear (package); + internal bool CanClear (string package) => !HasMarker || + ((marker.Groups [1].Value == package || marker.Groups [1].Value == "null") && + marker.Groups [2].Value == "null" && marker.Groups [3].Value == "true" && marker.Groups [4].Value == "false"); + + internal bool HasDebuggingProcess (string package, string user) + { + // Restrict mDebugging to this process's full *APP* record, not a + // substring match against another package or a later process record. + var records = Regex.Split (dump, @"(?m)^ \*APP\* "); + for (int i = 1; i < records.Length; i++) { + var newline = records [i].IndexOf ('\n'); + if (newline < 0) + continue; + var header = records [i].Substring (0, newline); + if (Regex.IsMatch (header, @"ProcessRecord\{\S+ [1-9][0-9]*:" + Regex.Escape (package) + "/u" + user + @"a[0-9]+\}") && + Regex.IsMatch (records [i], @"(?m)^ mDebugging=true\r?$")) + return true; + } + return false; + } + } + } +} diff --git a/src/Xamarin.AndroidTools/Properties/Resources.Designer.cs b/src/Xamarin.AndroidTools/Properties/Resources.Designer.cs index 6f8a8b99922..cdf8ff8e7e4 100644 --- a/src/Xamarin.AndroidTools/Properties/Resources.Designer.cs +++ b/src/Xamarin.AndroidTools/Properties/Resources.Designer.cs @@ -68,6 +68,46 @@ internal static string JdwpClientDisconnectError { return ResourceManager.GetString("JdwpClientDisconnectError", resourceCulture); } } + + internal static string ManagedLaunchUnsupported { + get { return ResourceManager.GetString("ManagedLaunchUnsupported", resourceCulture); } + } + + internal static string ManagedLaunchPackageMismatch { + get { return ResourceManager.GetString("ManagedLaunchPackageMismatch", resourceCulture); } + } + + internal static string ManagedLaunchComponentUnsupported { + get { return ResourceManager.GetString("ManagedLaunchComponentUnsupported", resourceCulture); } + } + + internal static string ManagedLaunchLayoutUnsupported { + get { return ResourceManager.GetString("ManagedLaunchLayoutUnsupported", resourceCulture); } + } + + internal static string ManagedLaunchGateTimeout { + get { return ResourceManager.GetString("ManagedLaunchGateTimeout", resourceCulture); } + } + + internal static string ManagedLaunchProcessUnsupported { + get { return ResourceManager.GetString("ManagedLaunchProcessUnsupported", resourceCulture); } + } + + internal static string ManagedLaunchStateUnavailable { + get { return ResourceManager.GetString("ManagedLaunchStateUnavailable", resourceCulture); } + } + + internal static string ManagedLaunchStateConflict { + get { return ResourceManager.GetString("ManagedLaunchStateConflict", resourceCulture); } + } + + internal static string ManagedLaunchTimeout { + get { return ResourceManager.GetString("ManagedLaunchTimeout", resourceCulture); } + } + + internal static string ManagedLaunchCleanupFailed { + get { return ResourceManager.GetString("ManagedLaunchCleanupFailed", resourceCulture); } + } /// /// Looks up a localized string similar to The Android SDK directory could not be found. Check that the Android SDK Manager in Visual Studio shows a valid installation. To use a custom SDK path for a command line build, set the 'AndroidSdkDirectory' MSBuild property to the custom path.. diff --git a/src/Xamarin.AndroidTools/Properties/Resources.resx b/src/Xamarin.AndroidTools/Properties/Resources.resx index ebdb18844d2..9377972154f 100644 --- a/src/Xamarin.AndroidTools/Properties/Resources.resx +++ b/src/Xamarin.AndroidTools/Properties/Resources.resx @@ -120,6 +120,36 @@ Unexpected error occurred trying to disconnect Jdwp client. + + Managed startup ANR protection requires Android 12 or later and a force-stopped, non-waiting, non-repeated activity launch on a single-user device. Launching without changing the device debug-app setting. + + + Managed startup protection requires a valid package name and an explicit activity component in that same package. + + + Managed startup ANR protection requires an explicit activity component. Launching without protection or changing the device debug-app setting. + + + The ActivityManager process dump layout is not supported for managed startup ANR protection. Launching without protection or changing the device debug-app setting. + + + Timed out waiting for another activity debug launch on this device to finish. This launch has not started. + + + The activity uses a custom process, or its package process could not be confirmed. Launching without managed startup ANR protection or changing the device debug-app setting. + + + Could not read ActivityManager debug-app state or Android users safely. + + + The device debug-app setting belongs to another launch or has changed unexpectedly. It has not been cleared. + + + Timed out waiting for ActivityManager to attach the managed debug process and consume the transient debug-app setting. + + + Failed to clean up the transient Android debug-app setting after a managed launch failure. + An exception occurred while retrieving the properties of the selected Java SDK installation. Check that the selected Java SDK installation contains a compatible version of Java and that 'java -XshowSettings:properties -version' runs successfully for that installation. Exception: {0} The following terms should not be translated: java -XshowSettings:properties -version diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/ManagedActivityLaunchTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/ManagedActivityLaunchTests.cs new file mode 100644 index 00000000000..e4b498bd8c5 --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/ManagedActivityLaunchTests.cs @@ -0,0 +1,960 @@ +// 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.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Mono.AndroidTools; +using NUnit.Framework; +using Xamarin.AndroidTools.Debugging; + +namespace Xamarin.Android.Tools.Tests; + +[TestFixture] +public class ManagedActivityLaunchTests +{ + const string PackageName = "com.example.managed"; + + static ExecutionConfiguration Configuration (bool forceStop = true) + { + var configuration = new ExecutionConfiguration (PackageName, new AmStartCommand (PackageName, ".MainActivity") { + ForceStop = forceStop, + User = "0", + }) { + AllowJavaDebugging = false, + }; + configuration.Debugger.Timeout = TimeSpan.FromSeconds (2); + return configuration; + } + + [Test] + public async Task ManagedLaunchArmsWithoutJavaWaitAndCleansAfterAttach () + { + await using var server = new LaunchAdbServer (); + await server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None); + + var commands = server.Commands.ToArray (); + CollectionAssert.Contains (commands, "am set-debug-app \"com.example.managed\""); + Assert.IsFalse (commands.Any (c => c.Contains ("-w") || c.Contains ("--persistent") || c.Contains (" -D"))); + Assert.Greater (Array.LastIndexOf (commands, "am clear-debug-app"), Array.FindIndex (commands, c => c.StartsWith ("am start ", StringComparison.Ordinal))); + Assert.IsTrue (server.Attached); + Assert.IsNull (server.DebugApp); + } + + [Test] + public async Task DisallowJavaDebuggingClearsReusedCommandFlag () + { + await using var server = new LaunchAdbServer (); + var configuration = Configuration (); + var command = configuration.RunCommand as AmStartCommand; + Assert.IsNotNull (command); + command.EnableDebugging = true; + + await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + + Assert.IsFalse (command.EnableDebugging); + Assert.IsFalse (server.Commands.Any (c => c.Contains (" -D"))); + } + + [Test] + public async Task VisiblePidDoesNotAllowCleanupBeforeAttach () + { + await using var server = new LaunchAdbServer { AttachOnDump = false }; + var observedPid = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var allowAttach = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + server.TransformResponse = (command, output) => { + if (command == "dumpsys activity processes" && output.Contains ("pid=1234")) { + observedPid.TrySetResult (); + server.AttachOnDump = allowAttach.Task.IsCompleted; + } + return output; + }; + var launch = server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None); + await observedPid.Task.WaitAsync (TimeSpan.FromSeconds (5)); + Assert.IsFalse (launch.IsCompleted); + Assert.AreEqual (PackageName, server.DebugApp); + Assert.IsFalse (server.Commands.Contains ("am clear-debug-app")); + allowAttach.SetResult (); + await launch; + Assert.IsTrue (server.Attached); + Assert.IsNull (server.DebugApp); + } + + [TestCase ("Error: Activity not started, unable to resolve Intent")] + [TestCase ("Starting: Intent { cmp=com.example.managed/.MainActivity }\nError type 3\nError: Activity class does not exist.")] + [TestCase ("java.lang.SecurityException: Permission Denial")] + [TestCase ("/system/bin/sh: am: not found")] + [TestCase ("Starting: Intent { cmp=com.example.managed/.MainActivity }\nError: Permission denied")] + [TestCase ("Starting: Intent { cmp=com.example.managed/.MainActivity }\nException occurred while executing 'start':\njava.lang.SecurityException")] + public async Task LaunchTextErrorsArePropagatedAndCleaned (string error) + { + await using var server = new LaunchAdbServer (); + server.TransformResponse = (command, output) => command.StartsWith ("am start ", StringComparison.Ordinal) ? error : output; + Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + Assert.IsNull (server.DebugApp); + CollectionAssert.Contains (server.Commands, "am clear-debug-app"); + } + + [TestCase ("ExceptionActivity", "https://example.com/")] + [TestCase ("MainActivity", "https://example.com/Error:Details")] + public async Task DiagnosticWordsInSuccessfulIntentAreNotLaunchErrors (string activity, string uri) + { + await using var server = new LaunchAdbServer (); + var configuration = Configuration (); + configuration.RunCommand.Component = PackageName + "/." + activity; + configuration.RunCommand.DataUri = uri; + server.TransformResponse = (command, output) => command.StartsWith ("am start ", StringComparison.Ordinal) + ? $"Starting: Intent {{ dat={uri} cmp={configuration.RunCommand.Component} }}\n" + : output; + await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + Assert.IsTrue (server.Attached); + Assert.IsNull (server.DebugApp); + } + + [TestCase ("")] + [TestCase ("Warning: Activity not started because the current activity is being kept for the user.\n")] + [TestCase ("Warning: Activity not started, intent has been delivered to currently running top-most instance.\n")] + [TestCase ("Warning: Activity not started because intent should be handled by the caller\n")] + [TestCase ("Warning: Activity not started, its current task has been brought to the front\n")] + public async Task ForceStopPreambleAndSuccessfulWarningsAreAccepted (string warning) + { + await using var server = new LaunchAdbServer (); + server.TransformResponse = (command, output) => command.StartsWith ("am start ", StringComparison.Ordinal) ? output + warning : output; + await server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None); + Assert.IsTrue (server.Attached); + Assert.IsNull (server.DebugApp); + } + + [TestCase ("Error: Permission denied\n", false)] + [TestCase ("Exception occurred while executing 'start':\njava.lang.SecurityException\n", false)] + [TestCase ("Error type 3\nError: Activity class {com.example.managed/.MainActivity} does not exist.\n", true)] + public async Task ForceStopPreambleDoesNotHideLaterErrors (string diagnostic, bool notFound) + { + await using var server = new LaunchAdbServer (); + server.TransformResponse = (command, output) => command.StartsWith ("am start ", StringComparison.Ordinal) ? output + diagnostic : output; + var error = Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + if (notFound) + Assert.IsInstanceOf (error); + else + StringAssert.Contains (diagnostic.Trim (), error.Message); + Assert.IsNull (server.DebugApp); + } + + [TestCase ("application :app", "com.example.managed:app", "com.example.managed:app")] + [TestCase ("application absolute", "com.example.shared", "com.example.shared")] + [TestCase ("activity :ui", "com.example.managed", "com.example.managed:ui")] + [TestCase ("activity absolute", "com.example.managed", "com.example.ui")] + [TestCase ("activity overrides application", "com.example.managed:app", "com.example.ui")] + public async Task CustomProcessRetainsUnprotectedLaunch (string declaration, string applicationProcess, string effectiveProcess) + { + await using var server = new LaunchAdbServer { + ApplicationProcessName = applicationProcess, + EffectiveProcessName = effectiveProcess, + }; + // These are PackageManager's resolved values for the named manifest declarations, + // not a test-side reimplementation of manifest process-name resolution. + var configuration = Configuration (); + var messages = new List (); + configuration.LogWiter = messages.Add; + configuration.Debugger.Timeout = TimeSpan.FromMilliseconds (200); + // Isolate process eligibility from the separate force-stop preamble regression. + server.TransformResponse = (command, output) => command.StartsWith ("am start ", StringComparison.Ordinal) + ? output.Replace ("Stopping: com.example.managed\n", "") + : output; + await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + CollectionAssert.Contains (server.Commands, configuration.RunCommand.ToString (), declaration); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app")), declaration); + Assert.IsTrue (messages.Any (m => m.Contains ("process")), declaration); + } + + [Test] + public async Task ActivityCanOverrideCustomApplicationProcessBackToPackage () + { + await using var server = new LaunchAdbServer { ApplicationProcessName = "com.example.managed:app" }; + await server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None); + CollectionAssert.Contains (server.Commands, "pm resolve-activity --user \"0\" -n \"com.example.managed/.MainActivity\""); + CollectionAssert.Contains (server.Commands, "am set-debug-app \"com.example.managed\""); + Assert.IsTrue (server.Attached); + } + + [TestCase ("")] + [TestCase ("No activity found\n")] + [TestCase ("ActivityInfo:\n name=com.example.managed.MainActivity\n packageName=com.example.managed\n")] + [TestCase ("ActivityInfo:\n name=com.example.managed.MainActivity\n packageName=com.example.managed\n processName=\n enabled=true exported=true directBootAware=false\n ApplicationInfo:\n")] + public async Task UnconfirmedProcessMetadataDoesNotArm (string metadata) + { + await using var server = new LaunchAdbServer (); + var configuration = Configuration (); + var messages = new List (); + configuration.LogWiter = messages.Add; + server.TransformResponse = (command, output) => command.StartsWith ("pm resolve-activity ", StringComparison.Ordinal) ? metadata : output; + await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + CollectionAssert.Contains (server.Commands, configuration.RunCommand.ToString ()); + Assert.IsTrue (messages.Any (m => m.Contains ("could not be confirmed"))); + } + + [Test] + public async Task CancellationDuringProcessResolutionDoesNotLaunchOrArm () + { + await using var server = new LaunchAdbServer (); + using var cancellation = new CancellationTokenSource (); + server.TransformResponse = (command, output) => { + if (command.StartsWith ("pm resolve-activity ", StringComparison.Ordinal)) + cancellation.Cancel (); + return output; + }; + Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), cancellation.Token)); + Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am ", StringComparison.Ordinal))); + } + + [Test] + public async Task ResolverTransportFailureIsPropagatedBeforeArming () + { + await using var server = new LaunchAdbServer { + FailTransportCommand = "pm resolve-activity --user \"0\" -n \"com.example.managed/.MainActivity\"", + }; + var error = Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + StringAssert.Contains ("simulated transport fail", error.ToString ()); + Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am ", StringComparison.Ordinal))); + } + + [Test] + public async Task LaunchTransportFailureIsNotSuccess () + { + await using var server = new LaunchAdbServer (); + var configuration = Configuration (); + server.FailTransportCommand = configuration.RunCommand.ToString (); + var error = Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None)); + StringAssert.Contains ("simulated transport fail", error.ToString ()); + Assert.IsNull (server.DebugApp); + CollectionAssert.Contains (server.Commands, "am clear-debug-app"); + } + + [Test] + public async Task AttachTimeoutCleansAndAllowsRetry () + { + await using var server = new LaunchAdbServer { AttachOnDump = false }; + var configuration = Configuration (); + configuration.Debugger.Timeout = TimeSpan.FromMilliseconds (150); + Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None)); + Assert.IsNull (server.DebugApp); + server.AttachOnDump = true; + configuration.Debugger.Timeout = TimeSpan.FromSeconds (2); + await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + Assert.IsNull (server.DebugApp); + Assert.AreEqual (2, server.Commands.Count (c => c.StartsWith ("am set-debug-app", StringComparison.Ordinal))); + } + + [TestCase ("flag-missing")] + [TestCase ("package-prefix")] + [TestCase ("wrong-user")] + [TestCase ("marker-missing")] + public async Task ConsumptionRequiresMatchingDebuggingProcessAndGlobalState (string mismatch) + { + await using var server = new LaunchAdbServer (); + server.TransformResponse = (command, output) => { + if (command != "dumpsys activity processes" || !server.Attached) + return output; + if (mismatch == "flag-missing") + return output.Replace ("mDebugging=true", "mDebugging=false"); + if (mismatch == "package-prefix") + return output.Replace (PackageName + "/u", PackageName + ".other/u"); + if (mismatch == "wrong-user") + return output.Replace ("/u0a", "/u10a"); + return output.Replace (" mDebugApp=null/orig=null mDebugTransient=true mOrigWaitForDebugger=false\n", ""); + }; + var configuration = Configuration (); + configuration.Debugger.Timeout = TimeSpan.FromMilliseconds (150); + Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None)); + } + + [TestCase (0)] + [TestCase (-1)] + public async Task UnboundedOrZeroTimeoutCannotArm (int milliseconds) + { + await using var server = new LaunchAdbServer (); + var configuration = Configuration (); + configuration.Debugger.Timeout = TimeSpan.FromMilliseconds (milliseconds); + Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None)); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + } + + [TestCase ("pm list users")] + [TestCase ("am set-debug-app \"com.example.managed\"")] + [TestCase ("armed-state")] + [TestCase ("launch")] + [TestCase ("attached-state")] + [TestCase ("am clear-debug-app")] + public async Task CancellationAtTransactionBoundariesUsesIndependentCleanup (string boundary) + { + await using var server = new LaunchAdbServer (); + using var cancellation = new CancellationTokenSource (); + server.TransformResponse = (command, output) => { + if (command == boundary || + (boundary == "armed-state" && command == "dumpsys activity processes" && server.DebugApp == PackageName) || + (boundary == "launch" && command.StartsWith ("am start ", StringComparison.Ordinal)) || + (boundary == "attached-state" && server.Attached)) + cancellation.Cancel (); + return output; + }; + Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), cancellation.Token)); + Assert.IsNull (server.DebugApp); + if (boundary != "pm list users") + CollectionAssert.Contains (server.Commands, "am clear-debug-app"); + } + + [Test] + public async Task AlreadyCanceledLaunchDoesNotTouchDevice () + { + await using var server = new LaunchAdbServer (); + using var cancellation = new CancellationTokenSource (); + cancellation.Cancel (); + Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), cancellation.Token)); + Assert.IsEmpty (server.Commands); + } + + [TestCase (false)] + [TestCase (true)] + public async Task CleanupFailurePreservesPrimaryErrorAndIsReported (bool changeOwner) + { + await using var server = new LaunchAdbServer (); + var errors = new ConcurrentQueue (); + MessageHandler log = (_, message) => errors.Enqueue (message); + AndroidLogger.Error += log; + try { + server.TransformResponse = (command, output) => { + if (command.StartsWith ("am start ", StringComparison.Ordinal)) { + if (changeOwner) + server.DebugApp = "com.example.other"; + return "Error: Activity not started, primary launch failure"; + } + if (command == "am clear-debug-app") + return "cleanup failure"; + return output; + }; + var error = Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + StringAssert.Contains ("primary launch failure", error.Message); + Assert.IsTrue (errors.Any (e => e.Contains ("Failed to clean up"))); + if (changeOwner) { + Assert.AreEqual ("com.example.other", server.DebugApp); + Assert.IsFalse (server.Commands.Contains ("am clear-debug-app")); + } + } finally { + AndroidLogger.Error -= log; + } + } + + [Test] + public async Task CleanupFailureAfterSuccessFailsLaunch () + { + await using var server = new LaunchAdbServer (); + server.TransformResponse = (command, output) => command == "am clear-debug-app" ? "cleanup failure" : output; + var error = Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + Assert.AreEqual ("cleanup failure", error.Message); + } + + [TestCase ("")] + [TestCase ("Users:\n")] + [TestCase ("Users:\n\tUserInfo{0:Owner:13} running\n\tUserInfo{broken}\n")] + [TestCase ("Permission Denial")] + public async Task MalformedUsersCannotMasqueradeAsSingleUser (string users) + { + await using var server = new LaunchAdbServer { UserList = users }; + Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am ", StringComparison.Ordinal))); + } + + [TestCase ("ACTIVITY MANAGER RUNNING PROCESSES (dumpsys activity processes)\n mDebugApp=broken\n mForceBackgroundCheck=false\n")] + public async Task MalformedDumpCannotMasqueradeAsUnowned (string dump) + { + await using var server = new LaunchAdbServer (); + server.TransformResponse = (command, output) => command == "dumpsys activity processes" ? dump : output; + Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am ", StringComparison.Ordinal))); + } + + [TestCase ("warm")] + [TestCase ("multiple-users")] + [TestCase ("different-user")] + [TestCase ("wait")] + [TestCase ("repeat")] + [TestCase ("older-api")] + public async Task UnsupportedLaunchesPreserveCommandWithoutDebugAppMutation (string reason) + { + await using var server = new LaunchAdbServer (); + var configuration = Configuration (reason != "warm"); + var command = configuration.RunCommand as AmStartCommand; + Assert.IsNotNull (command); + if (reason == "multiple-users") + server.UserList += "\tUserInfo{10:Work:30} running\n"; + if (reason == "different-user") + command.User = "10"; + command.Wait = reason == "wait"; + command.Repeat = reason == "repeat" ? 2 : 0; + if (reason == "older-api") + server.ApiLevel = 30; + var expected = command.ToString (); + var messages = new List (); + configuration.LogWiter = messages.Add; + await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + CollectionAssert.Contains (server.Commands, expected); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + Assert.IsTrue (messages.Any (m => m.Contains ("Launching without changing"))); + } + + [TestCase ("com.example.other", "com.example.other/.Activity")] + [TestCase ("com.example.managed", "com.example.other/.Activity")] + [TestCase ("com.example.managed", "com.example.managed/")] + public async Task ComponentAndPackageMustMatchBeforeArming (string package, string component) + { + await using var server = new LaunchAdbServer (); + var configuration = Configuration (); + configuration.RunCommand.PackageName = package; + configuration.RunCommand.Component = component; + Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None)); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + } + + [TestCase (null)] + [TestCase ("")] + [TestCase ("com.example.other")] + public async Task ExplicitComponentDoesNotRequireNonEmittedPackageBookkeeping (string bookkeepingPackage) + { + await using var server = new LaunchAdbServer (); + var command = new AmStartCommand { + Component = PackageName + "/.MainActivity", + PackageName = bookkeepingPackage, + ForceStop = true, + }; + var configuration = new ExecutionConfiguration (PackageName, command) { AllowJavaDebugging = false }; + await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + CollectionAssert.Contains (server.Commands, command.ToString ()); + CollectionAssert.Contains (server.Commands, "am set-debug-app \"com.example.managed\""); + } + + [Test] + public async Task FallbackPreservesLaunchAndCancellation ( + [Values ("implicit", "warm", "multiple-users", "custom-process", "unconfirmed-process", "unsupported-layout")] string reason, + [Values ("none", "diagnostic", "pending")] string boundary) + { + await using var server = new LaunchAdbServer (); + using var cancellation = new CancellationTokenSource (); + var configuration = Configuration (reason != "warm"); + if (reason == "implicit") { + configuration.RunCommand.Component = null; + configuration.RunCommand.Intent = PackageName; + } + if (reason == "multiple-users") + server.UserList += "\tUserInfo{10:Work:30} running\n"; + if (reason == "custom-process") + server.EffectiveProcessName = PackageName + ":custom"; + server.TransformResponse = (command, output) => { + if (reason == "unconfirmed-process" && command.StartsWith ("pm resolve-activity ", StringComparison.Ordinal)) + return "No activity found\n"; + if (reason == "unsupported-layout" && command == "dumpsys activity processes") + return output + " vendor postamble\n"; + return output; + }; + var messages = new List (); + configuration.LogWiter = message => { + messages.Add (message); + if (boundary == "diagnostic" && message.Contains ("Launching without")) + cancellation.Cancel (); + }; + var pending = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + server.BeforeResponse = command => { + if (boundary == "pending" && command.StartsWith ("am start ", StringComparison.Ordinal)) { + pending.TrySetResult (); + return release.Task; + } + return Task.CompletedTask; + }; + try { + var launch = server.Device.StartWithDebuggingAsync (configuration, cancellation.Token); + if (boundary == "pending") { + await pending.Task.WaitAsync (TimeSpan.FromSeconds (5)); + cancellation.Cancel (); + } + if (boundary == "none") + await launch; + else + Assert.CatchAsync (async () => await launch.WaitAsync (TimeSpan.FromSeconds (7))); + Assert.IsTrue (messages.Any (m => m.Contains ("Launching without"))); + Assert.AreEqual (boundary != "diagnostic", server.Commands.Any (c => c.StartsWith ("am start ", StringComparison.Ordinal))); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + } finally { + release.TrySetResult (); + } + } + + [TestCase ("")] + [TestCase ("ACTIVITY MANAGER RUNNING PROCESSES (dumpsys activity processes)\n")] + public async Task UnrecognizedInitialLayoutLaunchesWithoutMutation (string dump) + { + await using var server = new LaunchAdbServer (); + server.TransformResponse = (command, output) => command == "dumpsys activity processes" ? dump : output; + await server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None); + Assert.IsTrue (server.Commands.Any (c => c.StartsWith ("am start ", StringComparison.Ordinal))); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + } + + [Test] + public async Task UnsupportedLayoutAfterMutationStillFailsClosed ( + [Values ("armed", "cleanup", "stale-clear")] string boundary, + [Values ("truncated", "malformed-marker")] string layout) + { + await using var server = new LaunchAdbServer (); + if (boundary == "stale-clear") + server.SetDebugAppState (PackageName, true); + server.TransformResponse = (command, output) => { + if (command == "dumpsys activity processes" && + ((boundary == "armed" && server.Commands.Any (c => c.StartsWith ("am set-debug-app", StringComparison.Ordinal))) || + (boundary != "armed" && server.Commands.Contains ("am clear-debug-app")))) + return layout == "truncated" + ? output.Replace (" mForceBackgroundCheck=false\n", "") + : output.Replace (" mForceBackgroundCheck=false", " mDebugApp=broken\n mForceBackgroundCheck=false"); + return output; + }; + Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + } + + [TestCase (false)] + [TestCase (true)] + public async Task InitialDumpTransportOrCancellationDoesNotFallback (bool cancel) + { + await using var server = new LaunchAdbServer (); + using var cancellation = new CancellationTokenSource (); + if (cancel) { + server.TransformResponse = (command, output) => { + if (command == "dumpsys activity processes") + cancellation.Cancel (); + return output; + }; + Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), cancellation.Token)); + } else { + server.FailTransportCommand = "dumpsys activity processes"; + var error = Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + StringAssert.Contains ("simulated transport fail", error.ToString ()); + } + Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am ", StringComparison.Ordinal))); + } + + [Test] + public async Task GateTimeoutDescribesAdmissionRatherThanProcessAttach () + { + await using var server = new LaunchAdbServer (); + using var cancellation = new CancellationTokenSource (); + var pending = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + server.BeforeResponse = command => { + if (command == "date +%s") { + pending.TrySetResult (); + return release.Task; + } + return Task.CompletedTask; + }; + var first = server.Device.StartWithDebuggingAsync (Configuration (), cancellation.Token); + try { + await pending.Task.WaitAsync (TimeSpan.FromSeconds (5)); + var error = Assert.ThrowsAsync (async () => + await server.CreateDevice ().StartWithDebuggingAsync (Configuration (), CancellationToken.None).WaitAsync (TimeSpan.FromSeconds (35))); + StringAssert.Contains ("another activity debug launch", error.Message); + Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am ", StringComparison.Ordinal))); + } finally { + cancellation.Cancel (); + release.TrySetResult (); + Assert.CatchAsync (async () => await first.WaitAsync (TimeSpan.FromSeconds (5))); + } + } + + [TestCase ("com.example.app;echo bad")] + [TestCase ("com.example.$(id)")] + [TestCase ("com.example.`id`")] + [TestCase ("com.example.\"bad")] + [TestCase ("com.example.\napp")] + public async Task InvalidPackageCannotReachDebugAppCommand (string package) + { + await using var server = new LaunchAdbServer (); + var configuration = new ExecutionConfiguration (package, new AmStartCommand (package, ".Activity") { ForceStop = true }) { + AllowJavaDebugging = false, + }; + Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None)); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + } + + [TestCase ("java-allowed")] + [TestCase ("without-debugging")] + [TestCase ("broadcast")] + [TestCase ("instrumentation")] + [TestCase ("null")] + public async Task OtherLaunchPathsDoNotUseManagedTransaction (string path) + { + await using var server = new LaunchAdbServer (); + var configuration = Configuration (); + if (path == "broadcast") + configuration = new ExecutionConfiguration (PackageName, new AmBroadcastCommand { Action = "test" }) { AllowJavaDebugging = false }; + if (path == "instrumentation") + configuration = new ExecutionConfiguration (PackageName, new InstrumentationCommand ()) { AllowJavaDebugging = false }; + if (path == "null") + configuration = new ExecutionConfiguration (PackageName, null) { AllowJavaDebugging = false }; + if (path == "java-allowed") + configuration.AllowJavaDebugging = true; + if (path == "without-debugging") + await server.Device.StartWithoutDebuggingAsync (configuration, CancellationToken.None); + else + await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + if (path == "broadcast") + Assert.IsTrue (server.Commands.Any (c => c.Contains ("--include-stopped-packages"))); + if (path == "null") + Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am ", StringComparison.Ordinal))); + } + + [Test] + public async Task ExplicitJavaDebuggingStillStartsWithDAndRequestsJdwpPid () + { + await using var server = new LaunchAdbServer (); + using var cancellation = new CancellationTokenSource (); + var configuration = Configuration (); + configuration.AllowJavaDebugging = true; + var command = configuration.RunCommand as AmStartCommand; + Assert.IsNotNull (command); + command.EnableDebugging = true; + server.TransformResponse = (cmd, output) => { + if (cmd.StartsWith ("ps", StringComparison.Ordinal)) { + // Stop before port forwarding: this branch still uses AdbServer.Default. + cancellation.Cancel (); + return "USER PID PPID VSIZE RSS WCHAN PC NAME\n"; + } + return output; + }; + Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (configuration, cancellation.Token)); + Assert.IsTrue (server.Commands.Any (c => c.StartsWith ("am start ", StringComparison.Ordinal) && c.Contains (" -D"))); + Assert.IsTrue (server.Commands.Any (c => c.StartsWith ("ps", StringComparison.Ordinal))); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + } + + [TestCase (null)] + [TestCase ("current")] + [TestCase ("0")] + public async Task SingleUserCommandRetainsRequestedUser (string user) + { + await using var server = new LaunchAdbServer (); + var configuration = Configuration (); + var command = configuration.RunCommand as AmStartCommand; + Assert.IsNotNull (command); + command.User = user; + var expected = command.ToString (); + await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + CollectionAssert.Contains (server.Commands, expected); + CollectionAssert.Contains (server.Commands, "am set-debug-app \"com.example.managed\""); + } + + [TestCase ("com.example.other", false)] + [TestCase ("com.example.managed", false)] + [TestCase ("com.example.other", true)] + public async Task PreexistingForeignOrPersistentDebugAppIsNotCleared (string package, bool transient) + { + await using var server = new LaunchAdbServer (); + server.SetDebugAppState (package, transient); + Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + Assert.AreEqual (package, server.DebugApp); + } + + [Test] + public async Task MatchingStaleTransientStateIsClearedBeforeRearming () + { + await using var server = new LaunchAdbServer (); + server.SetDebugAppState (PackageName, true); + await server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None); + var commands = server.Commands.ToArray (); + Assert.Less (Array.IndexOf (commands, "am clear-debug-app"), Array.IndexOf (commands, "am set-debug-app \"com.example.managed\"")); + Assert.AreEqual (2, commands.Count (c => c == "am clear-debug-app")); + } + + [Test] + public async Task CancellationDrainsArmingBeforeCleanup () + { + await using var server = new LaunchAdbServer (); + using var cancellation = new CancellationTokenSource (); + var arming = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + server.BeforeResponse = command => { + if (command.StartsWith ("am set-debug-app", StringComparison.Ordinal)) { + arming.TrySetResult (); + return release.Task; + } + return Task.CompletedTask; + }; + var launch = server.Device.StartWithDebuggingAsync (Configuration (), cancellation.Token); + await arming.Task.WaitAsync (TimeSpan.FromSeconds (5)); + cancellation.Cancel (); + Assert.IsFalse (launch.IsCompleted); + Assert.IsFalse (server.Commands.Contains ("am clear-debug-app")); + release.SetResult (); + Assert.CatchAsync (async () => await launch.WaitAsync (TimeSpan.FromSeconds (5))); + Assert.IsNull (server.DebugApp); + CollectionAssert.Contains (server.Commands, "am clear-debug-app"); + } + + [Test] + public async Task GateIncludesCleanupAcrossDeviceInstances () + { + await using var server = new LaunchAdbServer (); + var cleaning = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + server.BeforeResponse = command => { + if (command == "am clear-debug-app") { + cleaning.TrySetResult (); + return release.Task; + } + return Task.CompletedTask; + }; + var first = server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None); + await cleaning.Task.WaitAsync (TimeSpan.FromSeconds (5)); + var commandCount = server.Commands.Count; + using var cancellation = new CancellationTokenSource (); + var second = server.CreateDevice ().StartWithDebuggingAsync (Configuration (), cancellation.Token); + Assert.IsFalse (second.IsCompleted); + Assert.AreEqual (commandCount, server.Commands.Count); + cancellation.Cancel (); + Assert.CatchAsync (async () => await second); + release.SetResult (); + await first; + Assert.IsNull (server.DebugApp); + } + + [Test] + public async Task TimedOutShellCompletionCannotRunCleanupAfterNextOwner () + { + await using var server = new LaunchAdbServer (); + var starting = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var completed = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + server.BeforeResponse = command => { + if (command.StartsWith ("am start ", StringComparison.Ordinal)) { + starting.TrySetResult (); + return release.Task; + } + return Task.CompletedTask; + }; + server.TransformResponse = (command, output) => { + if (command.StartsWith ("am start ", StringComparison.Ordinal)) + completed.TrySetResult (); + return output; + }; + var configuration = Configuration (); + configuration.Debugger.Timeout = TimeSpan.FromMilliseconds (250); + var launch = server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + await starting.Task.WaitAsync (TimeSpan.FromSeconds (5)); + Assert.ThrowsAsync (async () => await launch.WaitAsync (TimeSpan.FromSeconds (5))); + Assert.IsNull (server.DebugApp); + var clears = server.Commands.Count (c => c == "am clear-debug-app"); + server.DebugApp = "com.example.next"; + release.SetResult (); + await completed.Task.WaitAsync (TimeSpan.FromSeconds (5)); + Assert.AreEqual ("com.example.next", server.DebugApp); + Assert.AreEqual (clears, server.Commands.Count (c => c == "am clear-debug-app")); + } + + [Test] + public async Task HungCleanupIsBoundedAndDoesNotReplaceLaunchError () + { + await using var server = new LaunchAdbServer (); + var release = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + server.BeforeResponse = command => command == "am clear-debug-app" ? release.Task : Task.CompletedTask; + server.TransformResponse = (command, output) => command.StartsWith ("am start ", StringComparison.Ordinal) + ? "Error: Activity not started, primary failure" + : output; + try { + var launch = server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None); + var error = Assert.ThrowsAsync (async () => await launch.WaitAsync (TimeSpan.FromSeconds (8))); + StringAssert.Contains ("primary failure", error.Message); + } finally { + release.TrySetResult (); + } + } + + sealed class InstrumentationCommand : AmIntentCommand + { + protected override void AppendTo (Mono.AndroidTools.Util.ProcessArgumentBuilder builder) + { + builder.Add ("am", "instrument", "com.example.managed/runner"); + } + } + + // A private TCP endpoint exercises AndroidDevice's actual ADB transport without + // replacing the launch algorithm or touching an installed adb server/device. + sealed class LaunchAdbServer : IAsyncDisposable + { + readonly TcpListener listener = new TcpListener (IPAddress.Loopback, 0); + readonly CancellationTokenSource stop = new CancellationTokenSource (); + readonly List connections = []; + readonly Task accepting; + string debugProperty = ""; + bool started; + bool transient; + + public ConcurrentQueue Commands { get; } = new ConcurrentQueue (); + public string DebugApp { get; set; } + public string OriginalDebugApp { get; set; } + public bool AttachOnDump { get; set; } = true; + public string UserList { get; set; } = "Users:\n\tUserInfo{0:Owner:13} running\n"; + public int ApiLevel { get; set; } = 36; + public string EffectiveProcessName { get; set; } = PackageName; + public string ApplicationProcessName { get; set; } = PackageName; + public Func BeforeResponse { get; set; } = _ => Task.CompletedTask; + public Func TransformResponse { get; set; } = (_, output) => output; + public string FailTransportCommand { get; set; } + public bool Attached { get; private set; } + public AndroidDevice Device { get; } + readonly AdbServer adb; + + public LaunchAdbServer () + { + listener.Start (); + adb = new AdbServer (IPAddress.Loopback, ((IPEndPoint) listener.LocalEndpoint).Port); + Device = CreateDevice (); + accepting = AcceptAsync (); + } + + public AndroidDevice CreateDevice () => new AndroidDevice ("managed-launch-test", adb: adb); + + public void SetDebugAppState (string package, bool isTransient) + { + DebugApp = package; + transient = isTransient; + } + + async Task AcceptAsync () + { + try { + while (!stop.IsCancellationRequested) { + var client = await listener.AcceptTcpClientAsync (stop.Token); + connections.Add (RespondAsync (client)); + } + } catch (OperationCanceledException) when (stop.IsCancellationRequested) { + } + } + + async Task RespondAsync (TcpClient client) + { + try { + using (client) { + var stream = client.GetStream (); + Assert.AreEqual ("host:transport:managed-launch-test", await ReadCommandAsync (stream)); + await stream.WriteAsync (Encoding.ASCII.GetBytes ("OKAY"), stop.Token); + var command = await ReadCommandAsync (stream); + Assert.IsTrue (command.StartsWith ("shell:", StringComparison.Ordinal), command); + command = command.Substring (6); + Commands.Enqueue (command); + await BeforeResponse (command).WaitAsync (stop.Token); + if (command == FailTransportCommand) { + await stream.WriteAsync (Encoding.ASCII.GetBytes ("FAIL0018simulated transport fail"), stop.Token); + return; + } + var response = TransformResponse (command, Respond (command)); + await stream.WriteAsync (Encoding.UTF8.GetBytes ("OKAY" + response), stop.Token); + } + } catch (OperationCanceledException) when (stop.IsCancellationRequested) { + } + } + + string Respond (string command) + { + if (command == "date +%s") + return "1000\n"; + if (command.StartsWith ("setprop ", StringComparison.Ordinal)) { + // SetFastDevPropertyFile skips file transfer when getprop reflects setprop. + debugProperty = command.Substring (command.IndexOf ("\" ", StringComparison.Ordinal) + 3).Trim ('"'); + return ""; + } + if (command == "getprop") + return $"[ro.build.version.sdk]: [{ApiLevel}]\n[debug.mono.extra]: [{debugProperty}]\n"; + if (command == "pm list users") + return UserList; + if (command.StartsWith ("pm resolve-activity ", StringComparison.Ordinal)) { + var component = command.Substring (command.IndexOf ("-n \"", StringComparison.Ordinal) + 4).TrimEnd ('"'); + var activity = component.Substring (component.IndexOf ('/') + 1); + if (activity.StartsWith (".", StringComparison.Ordinal)) + activity = PackageName + activity; + var process = EffectiveProcessName == PackageName ? "" : $" processName={EffectiveProcessName}\n"; + // ResolveInfo.dump -> ActivityInfo.dump -> ComponentInfo.dumpFront. + // Component processName is OMITTED for the default package process, + // whereas nested ApplicationInfo always prints its own processName. + return "priority=0 preferredOrder=0 match=0x0 specificIndex=-1 isDefault=false\n" + + $"ActivityInfo:\n name={activity}\n packageName={PackageName}\n" + + process + " enabled=true exported=true directBootAware=false\n" + + $" ApplicationInfo:\n packageName={PackageName}\n processName={ApplicationProcessName}\n" + + " uid=10123 flags=0x0 privateFlags=0x0 theme=0x0\n"; + } + if (command == "am set-debug-app \"com.example.managed\"") { + DebugApp = PackageName; + transient = true; + started = false; + Attached = false; + return ""; + } + if (command == "am clear-debug-app") { + DebugApp = null; + transient = false; + return ""; + } + if (command.StartsWith ("am start ", StringComparison.Ordinal)) { + started = true; + return (command.Contains (" -S") ? "Stopping: com.example.managed\n" : "") + + "Starting: Intent { cmp=com.example.managed/.MainActivity }\n"; + } + if (command == "dumpsys activity processes") { + if (AttachOnDump && started && DebugApp == EffectiveProcessName) { + Attached = true; + DebugApp = null; + } + var process = started + ? $" *APP* UID 10123 ProcessRecord{{abc 1234:{EffectiveProcessName}/u0a123}}\n pid=1234\n" + + (Attached ? " mDebugging=true\n" : "") + : ""; + var marker = transient || DebugApp != null + ? $" mDebugApp={DebugApp ?? "null"}/orig={OriginalDebugApp ?? "null"} mDebugTransient={transient.ToString ().ToLowerInvariant ()} mOrigWaitForDebugger=false\n" + : ""; + return "ACTIVITY MANAGER RUNNING PROCESSES (dumpsys activity processes)\n" + process + marker + " mForceBackgroundCheck=false\n"; + } + if (command.StartsWith ("am broadcast ", StringComparison.Ordinal) || command.StartsWith ("am instrument ", StringComparison.Ordinal) || + command.StartsWith ("\"run-as\" ", StringComparison.Ordinal)) + return ""; + if (command.StartsWith ("ps", StringComparison.Ordinal)) + return "USER PID PPID VSIZE RSS WCHAN PC NAME\nu0_a123 1234 1 0 0 0 0 com.example.managed\n"; + throw new InvalidOperationException ("Unexpected ADB command: " + command); + } + + async Task ReadCommandAsync (NetworkStream stream) + { + var header = new byte [4]; + await stream.ReadExactlyAsync (header, stop.Token); + var length = int.Parse (Encoding.ASCII.GetString (header), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var data = new byte [length]; + await stream.ReadExactlyAsync (data, stop.Token); + return Encoding.UTF8.GetString (data); + } + + public async ValueTask DisposeAsync () + { + stop.Cancel (); + await accepting; + listener.Stop (); + await Task.WhenAll (connections); + stop.Dispose (); + } + } +} From 1dd55d6b36fead281a20b1699f5c71703da2d5cb Mon Sep 17 00:00:00 2001 From: David Nguyen <87228593+davidnguyen-tech@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:57:09 +0200 Subject: [PATCH 2/2] [debugging] Move managed launch protection into active entry points Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 04451b92-11f6-4da6-99b3-c9843d508975 --- .../guides/managed-activity-debugging.md | 112 ++- src/Microsoft.Android.Run/AdbHelper.cs | 12 +- .../ManagedActivityLaunch.cs | 351 +++++++ ...ManagedActivityLaunchResources.Designer.cs | 38 + .../ManagedActivityLaunchResources.resx | 66 ++ src/Microsoft.Android.Run/Program.cs | 152 +++- .../Properties/AssemblyInfo.cs | 3 + src/Mono.AndroidTools/AndroidDevice.cs | 28 - .../Tasks/RunActivity.cs | 49 +- ...marin.Android.Build.Debugging.Tasks.csproj | 11 + .../Microsoft.Android.Sdk.Application.targets | 4 +- .../Debugging/DebuggingExtensions.cs | 25 +- .../Debugging/ManagedActivityLaunch.cs | 257 ------ .../Properties/Resources.Designer.cs | 40 - .../Properties/Resources.resx | 30 - .../ManagedActivityLaunchEntryPointTests.cs | 861 ++++++++++++++++++ .../ManagedActivityLaunchTests.cs | 302 ++++-- ...arin.Android.Tools.AndroidSdk-Tests.csproj | 7 + 18 files changed, 1841 insertions(+), 507 deletions(-) create mode 100644 src/Microsoft.Android.Run/ManagedActivityLaunch.cs create mode 100644 src/Microsoft.Android.Run/ManagedActivityLaunchResources.Designer.cs create mode 100644 src/Microsoft.Android.Run/ManagedActivityLaunchResources.resx create mode 100644 src/Microsoft.Android.Run/Properties/AssemblyInfo.cs delete mode 100644 src/Xamarin.AndroidTools/Debugging/ManagedActivityLaunch.cs create mode 100644 tests/Xamarin.Android.Tools.AndroidSdk-Tests/ManagedActivityLaunchEntryPointTests.cs diff --git a/Documentation/guides/managed-activity-debugging.md b/Documentation/guides/managed-activity-debugging.md index 656e0326e2d..fbea23bbef9 100644 --- a/Documentation/guides/managed-activity-debugging.md +++ b/Documentation/guides/managed-activity-debugging.md @@ -5,17 +5,64 @@ targets. This avoids launching the activity with `am start -D`, which requests Java debugger startup. It does not by itself tell ActivityManager that a managed debugger may deliberately pause the process during startup. -For a managed-only forced cold activity launch, `StartWithDebuggingAsync` uses +For a managed-only forced cold activity launch, the run entry points use `am set-debug-app ` without `-w` or `--persistent`. Android selects `DEBUG_ON`, rather than `DEBUG_WAIT`: it marks the process as debugging without waiting for a Java debugger. This preserves ActivityManager's debugging state while the managed debugger attaches. AOSP's `appNotResponding` skips a process marked as debugging instead of treating its intentional startup pause as an ANR. +## Entry points and debug intent + +- **`dotnet run`:** `src/Microsoft.Android.Run/Program.cs` protects activity launches + only with `--attach-debugger`. `_AndroidComputeRunArguments` carries the existing + `AndroidAttachDebugger=true` MSBuild property to this switch for activity launches. + No new public MSBuild opt-in is required. `Debug` configuration, port mappings, + and `WaitForExit=false` / `--no-wait` are not evidence of debug intent. +- **`-t:Run`:** `src/Xamarin.Android.Build.Debugging.Tasks/Tasks/RunActivity.cs` + protects only `AttachDebugger && !AllowJavaDebugging`. The MSBuild debugging + targets already default `_AndroidAllowJavaDebugging` to `False`. The task still + uses `SetDebugPropertiesAsync` for managed setup, and its explicit Java and + non-debug branches retain their existing launch behavior. + +`ManagedActivityLaunch.cs` is an internal implementation owned by +`Microsoft.Android.Run` and source-linked, with its localized resources, into the +debugging task. It uses host transport, setup, fallback, and logging callbacks; +it has no dependency on `AndroidDevice`, `ExecutionConfiguration`, or the other +legacy tooling types. `Mono.AndroidTools` and `Xamarin.AndroidTools` retain their +pre-existing behavior and do not own this protection. + +Instrumentation and `dotnet test` do not use the activity transaction, even if +the CLI receives the debug switch. Ordinary launch, wait/logcat, and Ctrl+C +behavior is unchanged. For an explicit debug activity launch, waiting for app +exit/logcat is still supported, but the initial `am start` never uses `-W`: +application startup may be waiting for the managed debugger to attach. +When exit/logcat waiting is requested for a debug launch, the host polls for its +PID for up to 30 seconds before starting logcat. This also covers unprotected +fallbacks where `am start` only schedules process creation; it does not wait for +application code. `--no-wait` does not perform this polling. +When activity metadata confirms a custom process, both PID readiness and +subsequent exit/logcat tracking use that exact process name, quoted for the +device shell. The helper returns an unconfirmed result when metadata is +unavailable; those fallbacks retain the existing package-name probe rather than +guessing a custom process or issuing another metadata query. Package identity +still controls debug-app and force-stop operations. + +Debug PID reads retry normal `pidof` no-match responses (nonzero exit with empty +stdout and stderr), but report actual ADB diagnostics immediately. An offline or +unauthorized device is not reported as a PID timeout or successful application +exit. Ordinary non-debug PID handling is unchanged. + +Activity launch validation reads both stdout and stderr. Successful non-waiting +`am start` status warnings on stderr are not failures, but nonzero exit codes +and error/exception records remain failures. Mutation and state-query output +checks remain strict. + The launch transaction: -- Serializes activity debug launches to the same device serial within the host - process, including cleanup. +- Serializes managed-only transactions for the same device serial within each + launch host, including managed setup and cleanup. Admission has its own + 30-second timeout; its diagnostic distinguishes this from a startup timeout. - Checks the package/component identity, Android users, and ActivityManager debug-app state before arming the one-shot setting. - Awaits `am start` and then observes both the consumed global setting and the @@ -23,7 +70,18 @@ The launch transaction: It does not use `am start -W` or wait for application code to run. - Cleans up with a separate five-second cancellation budget, including after launch failure, timeout, or cancellation. Cleanup errors are reported without - replacing a primary launch error. + replacing a primary launch error. In-flight mutations also get a separate + five-second drain budget so cleanup cannot overtake their replies. + +Startup, including setup and metadata queries, is bounded by the managed debugger +timeout (30 seconds by default). `RunActivity` keeps its completion/logging loop +alive during managed-only cancellation until the worker finishes cleanup, rather +than letting the base task return success while cleanup is still running. +`dotnet run` resolves and pins the device serial before the transaction; Ctrl+C +finishes cleanup before its existing force-stop operation. +Expiration of a private mutation, cleanup, or startup deadline is reported as a +timeout failure, not caller cancellation. The run program returns exit code 1 +with a diagnostic for these failures; exit code 130 is reserved for actual Ctrl+C. In AOSP, `attachApplicationLocked` sets the process debugging flag before restoring the original global debug-app/wait settings, under the ActivityManager lock. @@ -33,28 +91,28 @@ after observing this transition does not clear the process's debugging flag. ## Scope and limitations Protection requires Android 12 or later, an explicit activity in the configured -package, `ForceStop=true`, no wait/repeat option, and a single-user device with -the launch targeting that user. Older Android versions, warm launches, repeated -or waiting launches, and multi-user devices retain their existing launch behavior -with a diagnostic that they are not protected. In particular, `set-debug-app` +package, a force-stopped, non-waiting activity command, and a single-user device +with the launch targeting that user. Both protected entry points construct +non-repeated, non-waiting activity commands. Older Android versions, warm launches, +and multi-user devices retain their existing launch behavior with a diagnostic +that they are not protected. In particular, `set-debug-app` force-stops the package for **all users**; it must not silently replace a user-scoped force-stop on a multi-user device. -The explicit component's package determines eligibility; the command's optional, -non-emitted `PackageName` bookkeeping is not required. Implicit intents retain -their existing unprotected launch with a diagnostic. Fallback launches check -cancellation both before and after executing the legacy intent command. +The explicit component's package determines eligibility. Missing components retain +the host's unprotected launch with a diagnostic. Fallback launches check +cancellation both before and after executing the host's launch operation. An unsupported initial ActivityManager process dump layout, including a vendor postamble after the expected AOSP ending, also retains the unprotected launch with a diagnostic and no debug-app mutation. This does not treat unknown output -as empty state: recognized layouts with malformed ownership records still fail. +as empty state: malformed or contradictory ownership records still fail. Transport failures and cancellation are not layout fallbacks. After any marker mutation, including during cleanup, unrecognized or truncated dumps remain errors. No vendor-specific parsing or OEM-device validation is claimed. -Explicit Java-debugging, no-debug, null-command, broadcast, and instrumentation -paths do not use this transaction. Public Java-debugging options remain available. +Explicit Java-debugging, no-debug, broadcast, and instrumentation paths do not +use this transaction. Public Java-debugging options remain available. Before arming, `pm resolve-activity` checks the selected user's installed activity metadata. Android resolves application-level process inheritance and activity-level overrides, including relative process names, into `ActivityInfo.processName`. @@ -80,7 +138,25 @@ Do not run competing debug-app transactions on the same device. - [Android 12 PackageManagerShellCommand: resolve-activity](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-12.0.0_r1/services/core/java/com/android/server/pm/PackageManagerShellCommand.java) - [Android 12 ComponentInfo: effective process dump](https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-12.0.0_r1/core/java/android/content/pm/ComponentInfo.java) +## Regression coverage + `ManagedActivityLaunchTests` in `Xamarin.Android.Tools.AndroidSdk-Tests` exercises -the real shared launch implementation and ADB transport against a private TCP -server. These deterministic tests do not replace device-side managed debugger -startup and breakpoint validation. +the shared implementation compiled into the real run executable, using the +existing ADB transport against a private TCP server. Coverage includes process +metadata, ownership/layout validation, attach observation, startup and gate +timeouts, cancellation boundaries, mutation drain, and cleanup failures. + +`ManagedActivityLaunchEntryPointTests` executes the real `RunActivity` task through +its existing per-build device cache, runs `Microsoft.Android.Run` with a fake ADB +process connected to the same private fixture, and evaluates the shipping +`_AndroidComputeRunArguments` target. It covers explicit debug gating, no-debug +ports/no-wait launches, instrumentation/test dispatch, shell quoting, typed task +errors, Java-debug launch routing, and cancellation/cleanup at the host boundary. +The fake-ADB process and Ctrl+C probes require bash and run on Unix; task, shared +transaction, and MSBuild argument tests are cross-platform. + +The focused NUnit project builds both consumers: the run program's modern .NET +target and the task's `netstandard2.0` target. Its build graph also prepares the +version tasks required by a standalone debugging-task build; it does not require +a native Android SDK build or a device. These deterministic tests do not replace +device-side managed debugger startup, breakpoint, or OEM validation. diff --git a/src/Microsoft.Android.Run/AdbHelper.cs b/src/Microsoft.Android.Run/AdbHelper.cs index 0fa5a468ad6..e3f0a66d2c0 100644 --- a/src/Microsoft.Android.Run/AdbHelper.cs +++ b/src/Microsoft.Android.Run/AdbHelper.cs @@ -41,12 +41,16 @@ public static ProcessStartInfo CreateStartInfo (string adbPath, string? adbTarge return psi; } - 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); + public static Task<(int ExitCode, string Output, string Error)> RunAsync (string adbPath, string? adbTarget, string arguments, CancellationToken cancellationToken, bool verbose = false) => + RunAsync (CreateStartInfo (adbPath, adbTarget, arguments), cancellationToken, verbose); + + public static Task<(int ExitCode, string Output, string Error)> RunAsync (string adbPath, string? adbTarget, IEnumerable arguments, CancellationToken cancellationToken, bool verbose = false) => + RunAsync (CreateStartInfo (adbPath, adbTarget, arguments), cancellationToken, verbose); + static async Task<(int ExitCode, string Output, string Error)> RunAsync (ProcessStartInfo psi, CancellationToken cancellationToken, bool verbose) + { if (verbose) - Console.WriteLine ($"Running: adb {psi.Arguments}"); + Console.WriteLine ($"Running: adb {(psi.ArgumentList.Count == 0 ? psi.Arguments : string.Join (" ", psi.ArgumentList))}"); using var stdout = new StringWriter (); using var stderr = new StringWriter (); diff --git a/src/Microsoft.Android.Run/ManagedActivityLaunch.cs b/src/Microsoft.Android.Run/ManagedActivityLaunch.cs new file mode 100644 index 00000000000..3f5e96e8686 --- /dev/null +++ b/src/Microsoft.Android.Run/ManagedActivityLaunch.cs @@ -0,0 +1,351 @@ +#nullable enable +// 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.Concurrent; +using System.Globalization; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Resources = Microsoft.Android.Run.ManagedActivityLaunchResources; + +namespace Microsoft.Android.Run +{ + // ActivityManager has one debug-app slot per device, not per package or user. + // Source-linked into the RunActivity task so both surviving launch owners use + // the same transaction without adding APIs to the deprecated tools libraries. + // Unrelated host processes/adb clients do not participate in this lock. + static class ManagedActivityLaunch + { + static readonly ConcurrentDictionary gates = new ConcurrentDictionary (StringComparer.Ordinal); + const int MutationDrainTimeoutMilliseconds = 5000; + const int CleanupTimeoutMilliseconds = 5000; + const int PollMilliseconds = 100; + const string DumpCommand = "dumpsys activity processes"; + const string ClearCommand = "am clear-debug-app"; + static readonly Regex packagePattern = new Regex (@"\A[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+\z"); + static readonly Regex markerPattern = new Regex (@"(?m)^ mDebugApp=(\S+)/orig=(\S+) mDebugTransient=(true|false) mOrigWaitForDebugger=(true|false)\r?$"); + + // Return only the confirmed effective activity process, not a guess for + // fallbacks that did not resolve metadata. Hosts can retain their legacy probe. + internal static async Task RunAsync ( + string serial, string package, string? component, string? user, + bool forceStop, string startCommand, TimeSpan startupTimeout, + Func prepare, + Func> runShellCommand, + Func launchUnprotected, + Action log, Action logCleanupError, + CancellationToken token) + { + token.ThrowIfCancellationRequested (); + if (string.IsNullOrEmpty (serial)) + throw new ArgumentException (nameof (serial)); + if (string.IsNullOrEmpty (package) || !packagePattern.IsMatch (package)) + throw new ArgumentException (Resources.ManagedLaunchPackageMismatch, nameof (package)); + if (component != null && component.Length != 0 && + (!component.StartsWith (package + "/", StringComparison.Ordinal) || component.Length == package.Length + 1)) + throw new ArgumentException (Resources.ManagedLaunchPackageMismatch, nameof (component)); + if (startupTimeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException (nameof (startupTimeout)); + + var gate = gates.GetOrAdd (serial, _ => new SemaphoreSlim (1, 1)); + if (!await gate.WaitAsync (TimeSpan.FromSeconds (30), token).ConfigureAwait (false)) + throw new TimeoutException (Resources.ManagedLaunchGateTimeout); + string? processName = null; + try { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource (token); + timeout.CancelAfter (startupTimeout); + bool armed = false; + Exception? primaryError = null; + try { + // Hold the gate through managed debugger setup as well as cleanup. + await prepare (timeout.Token).ConfigureAwait (false); + timeout.Token.ThrowIfCancellationRequested (); + // Both callers supply a no-wait, non-repeated activity command. + // set-debug-app force-stops the package: never make a warm launch cold. + if (!forceStop) { + await LaunchUnprotectedAsync (Resources.ManagedLaunchUnsupported).ConfigureAwait (false); + return null; + } + var sdk = await runShellCommand ("getprop ro.build.version.sdk", timeout.Token).ConfigureAwait (false); + timeout.Token.ThrowIfCancellationRequested (); + if (!int.TryParse (sdk.Trim (), NumberStyles.None, CultureInfo.InvariantCulture, out int apiLevel) || apiLevel <= 0) + throw new InvalidOperationException (Resources.ManagedLaunchStateUnavailable); + if (apiLevel < 31) { + await LaunchUnprotectedAsync (Resources.ManagedLaunchUnsupported).ConfigureAwait (false); + return null; + } + if (component == null || component.Length == 0) { + await LaunchUnprotectedAsync (Resources.ManagedLaunchComponentUnsupported).ConfigureAwait (false); + return null; + } + + // AOSP's setDebugApp uses USER_ALL. Restrict this transaction to + // single-user devices so an explicit --user never kills another profile. + var users = await runShellCommand ("pm list users", timeout.Token).ConfigureAwait (false); + timeout.Token.ThrowIfCancellationRequested (); + var userLines = users.Trim ().Split ('\n'); + var userIds = Regex.Matches (users, @"(?m)^[ \t]*UserInfo\{(\d+):[^{}\r\n]*:[0-9a-fA-F]+\}(?: running)?\r?$"); + if (userLines [0].TrimEnd ('\r') != "Users:" || userIds.Count == 0 || userIds.Count != userLines.Length - 1) + throw new InvalidOperationException (Resources.ManagedLaunchStateUnavailable); + if (userIds.Count != 1 || (!string.IsNullOrEmpty (user) && user != "current" && user != userIds [0].Groups [1].Value)) { + await LaunchUnprotectedAsync (Resources.ManagedLaunchUnsupported).ConfigureAwait (false); + return null; + } + + processName = await ResolveActivityProcessNameAsync (runShellCommand, component, package, userIds [0].Groups [1].Value, timeout.Token).ConfigureAwait (false); + if (processName != package) { + await LaunchUnprotectedAsync (Resources.ManagedLaunchProcessUnsupported).ConfigureAwait (false); + return processName; + } + + DebugAppState state; + try { + state = await ReadStateAsync (runShellCommand, timeout.Token).ConfigureAwait (false); + } catch (UnsupportedDumpLayoutException) { + // Before any mutation, an unknown dump layout only means that + // protection is unavailable. Never apply this fallback after arming. + await LaunchUnprotectedAsync (Resources.ManagedLaunchLayoutUnsupported).ConfigureAwait (false); + return processName; + } + if (!state.CanClear (package)) + throw new InvalidOperationException (Resources.ManagedLaunchStateConflict); + if (state.HasMarker) { + armed = true; + await ExecuteMutationAsync (runShellCommand, ClearCommand).ConfigureAwait (false); + // A clear is already a mutation. Recheck strictly before rearming, + // not just later when cleanup happens to read the state again. + state = await ReadStateAsync (runShellCommand, timeout.Token).ConfigureAwait (false); + if (state.HasMarker) + throw new InvalidOperationException (Resources.ManagedLaunchStateConflict); + } + timeout.Token.ThrowIfCancellationRequested (); + + armed = true; + // Do not abandon an in-flight mutation on caller cancellation: + // drain its reply before cleanup can overtake it on another connection. + await ExecuteMutationAsync (runShellCommand, "am set-debug-app " + QuoteForDeviceShell (package)).ConfigureAwait (false); + state = await ReadStateAsync (runShellCommand, timeout.Token).ConfigureAwait (false); + if (!state.IsPending (package)) + throw new InvalidOperationException (Resources.ManagedLaunchStateConflict); + + // The legacy intent executor returns after five seconds and can + // hide transport failures. Await the host transport and check the + // complete reply here, without a dependency on that executor. + log (startCommand); + var output = await runShellCommand (startCommand, timeout.Token).ConfigureAwait (false); + timeout.Token.ThrowIfCancellationRequested (); + log (output); + CheckStartResult (output, component); + while (true) { + state = await ReadStateAsync (runShellCommand, timeout.Token).ConfigureAwait (false); + if (!state.CanClear (package)) + throw new InvalidOperationException (Resources.ManagedLaunchStateConflict); + // attachApplicationLocked sets mDebugging before restoring the + // globals, under the same AMS lock used by dumpsys. A PID alone + // is too early, and waiting for activity startup would deadlock + // managed debugger attach. mDebugTransient remains true here. + if (state.IsConsumed && state.HasDebuggingProcess (package, userIds [0].Groups [1].Value)) + break; + await Task.Delay (PollMilliseconds, timeout.Token).ConfigureAwait (false); + } + token.ThrowIfCancellationRequested (); + } catch (Exception ex) { + primaryError = ex; + if (timeout.IsCancellationRequested) { + primaryError = token.IsCancellationRequested + ? (Exception) new OperationCanceledException (token) + : new TimeoutException (Resources.ManagedLaunchTimeout); + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture (primaryError).Throw (); + } + throw; + } finally { + if (armed) { + try { + await CleanupAsync (runShellCommand, package).ConfigureAwait (false); + } catch (Exception cleanupError) when (primaryError != null || token.IsCancellationRequested) { + // Preserve launch/cancellation errors, but never hide failed cleanup. + logCleanupError (Resources.ManagedLaunchCleanupFailed, cleanupError); + if (primaryError == null) + token.ThrowIfCancellationRequested (); + } + } + } + async Task LaunchUnprotectedAsync (string diagnostic) + { + log (diagnostic); + // The legacy continuation can turn a canceled shell into empty + // output. Preserve its command/behavior, not cancellation-as-success. + timeout.Token.ThrowIfCancellationRequested (); + await launchUnprotected (timeout.Token).ConfigureAwait (false); + timeout.Token.ThrowIfCancellationRequested (); + } + } finally { + gate.Release (); + } + token.ThrowIfCancellationRequested (); + return processName; + } + + static async Task ResolveActivityProcessNameAsync (Func> runShellCommand, string component, string package, string user, CancellationToken token) + { + // No existing device metadata helper exposes ActivityInfo.processName. + // PackageManager resolves both application inheritance and activity overrides. + var command = $"pm resolve-activity --user {QuoteForDeviceShell (user)} -n {QuoteForDeviceShell (component)}"; + var output = await runShellCommand (command, token).ConfigureAwait (false); + token.ThrowIfCancellationRequested (); + + var activityName = component.Substring (package.Length + 1); + if (activityName.StartsWith (".", StringComparison.Ordinal)) + activityName = package + activityName; + var activity = Regex.Match (output, @"(?m)^ActivityInfo:\r?\n(?(?: [^\r\n]*\r?\n)+)"); + if (!activity.Success || activity.NextMatch ().Success) + return null; + var fields = activity.Groups ["fields"].Value; + if (!Regex.IsMatch (fields, @"(?m)^ name=" + Regex.Escape (activityName) + @"\r?$") || + !Regex.IsMatch (fields, @"(?m)^ packageName=" + Regex.Escape (package) + @"\r?$") || + !Regex.IsMatch (fields, @"(?m)^ enabled=(true|false) exported=(true|false) directBootAware=(true|false)\r?$") || + !Regex.IsMatch (fields, @"(?m)^ ApplicationInfo:\r?$")) + return null; + + // ComponentInfo omits processName when it equals the package. Only read + // the two-space activity field, never ApplicationInfo's four-space value: + // an activity can override a custom application process back to the package. + var processes = Regex.Matches (fields, @"(?m)^ processName=(\S+)\r?$"); + if (processes.Count == 0) + return Regex.IsMatch (fields, @"(?m)^ processName=") ? null : package; + return processes.Count == 1 ? processes [0].Groups [1].Value : null; + } + + static async Task ExecuteMutationAsync (Func> runShellCommand, string command) + { + using var mutation = new CancellationTokenSource (MutationDrainTimeoutMilliseconds); + try { + await ExecuteEmptyCommandAsync (runShellCommand, command, mutation.Token).ConfigureAwait (false); + } catch (OperationCanceledException) when (mutation.IsCancellationRequested) { + // Deadline cancellation is an implementation detail, not the cause + // reported to callers. MSBuild classifies GetBaseException(), so do + // not nest that cancellation inside the timeout diagnostic. + throw new TimeoutException (Resources.ManagedLaunchMutationTimeout); + } + } + + static async Task ExecuteEmptyCommandAsync (Func> runShellCommand, string command, CancellationToken token) + { + var output = await runShellCommand (command, token).ConfigureAwait (false); + token.ThrowIfCancellationRequested (); + if (!string.IsNullOrWhiteSpace (output)) + throw new CommandFailedException (output); + } + + static async Task CleanupAsync (Func> runShellCommand, string package) + { + using var cleanup = new CancellationTokenSource (CleanupTimeoutMilliseconds); + try { + var state = await ReadStateAsync (runShellCommand, cleanup.Token).ConfigureAwait (false); + if (!state.CanClear (package)) + throw new InvalidOperationException (Resources.ManagedLaunchStateConflict); + if (state.HasMarker) + await ExecuteEmptyCommandAsync (runShellCommand, ClearCommand, cleanup.Token).ConfigureAwait (false); + state = await ReadStateAsync (runShellCommand, cleanup.Token).ConfigureAwait (false); + if (state.HasMarker) + throw new InvalidOperationException (Resources.ManagedLaunchStateConflict); + } catch (OperationCanceledException) when (cleanup.IsCancellationRequested) { + throw new TimeoutException (Resources.ManagedLaunchCleanupTimeout); + } + } + + static async Task ReadStateAsync (Func> runShellCommand, CancellationToken token) + { + var dump = await runShellCommand (DumpCommand, token).ConfigureAwait (false); + token.ThrowIfCancellationRequested (); + return new DebugAppState (dump); + } + + // adb shell joins its arguments; host argv quoting alone does not protect + // values from expansion by the device shell. Also used for instrumentation. + internal static string QuoteForDeviceShell (string value) => + "'" + value.Replace ("'", "'\\''") + "'"; + + internal static void CheckStartResult (string output, string component) + { + bool starting = false, failed = false, notFound = false; + foreach (var rawLine in output.Split ('\n')) { + var line = rawLine.TrimEnd ('\r'); + // am start -S prints Stopping before Starting. Do not mistake + // diagnostic-looking words in a component/URI for error records. + starting |= line.StartsWith ("Starting: Intent {", StringComparison.Ordinal) && line.EndsWith ("}", StringComparison.Ordinal); + bool error = line.StartsWith ("Error:", StringComparison.Ordinal); + notFound |= error && (line.StartsWith ("Error: Bad component name", StringComparison.Ordinal) || line.EndsWith ("does not exist.", StringComparison.Ordinal)); + failed |= error || line.StartsWith ("Error type ", StringComparison.Ordinal) || + line.StartsWith ("Exception occurred while executing", StringComparison.Ordinal) || + Regex.IsMatch (line, @"^(?:[A-Za-z_$][A-Za-z0-9_$]*\.)*(?:[A-Za-z_$][A-Za-z0-9_$]*)?(?:Exception|Error)(?::|$)"); + } + if (failed || !starting) + throw new CommandFailedException (notFound + ? string.Format (CultureInfo.CurrentCulture, Resources.ManagedLaunchActivityNotFound, component) + : string.IsNullOrWhiteSpace (output) ? Resources.ManagedLaunchStartFailed : output, notFound); + } + + // The task maps this back to its existing typed ADB diagnostics. The shared + // implementation and dotnet run must not reference Mono.AndroidTools. + internal sealed class CommandFailedException : Exception + { + internal bool ActivityNotFound { get; } + + internal CommandFailedException (string message, bool activityNotFound = false) : base (message) + { + ActivityNotFound = activityNotFound; + } + } + + sealed class UnsupportedDumpLayoutException : InvalidOperationException + { + internal UnsupportedDumpLayoutException () : base (Resources.ManagedLaunchStateUnavailable) + { + } + } + + sealed class DebugAppState + { + readonly string dump; + readonly Match marker; + + internal DebugAppState (string dump) + { + this.dump = dump; + marker = markerPattern.Match (dump); + if (Regex.Matches (dump, "mDebugApp=").Count != (marker.Success ? 1 : 0)) + throw new InvalidOperationException (Resources.ManagedLaunchStateUnavailable); + if (!dump.StartsWith ("ACTIVITY MANAGER RUNNING PROCESSES (dumpsys activity processes)", StringComparison.Ordinal) || + !Regex.IsMatch (dump, @"(?m)^ mForceBackgroundCheck=(true|false)\s*\z")) + throw new UnsupportedDumpLayoutException (); + } + + internal bool HasMarker => marker.Success; + internal bool IsConsumed => HasMarker && marker.Groups [1].Value == "null" && CanClear ("null"); + internal bool IsPending (string package) => HasMarker && marker.Groups [1].Value == package && CanClear (package); + internal bool CanClear (string package) => !HasMarker || + ((marker.Groups [1].Value == package || marker.Groups [1].Value == "null") && + marker.Groups [2].Value == "null" && marker.Groups [3].Value == "true" && marker.Groups [4].Value == "false"); + + internal bool HasDebuggingProcess (string package, string user) + { + // Restrict mDebugging to this process's full *APP* record, not a + // substring match against another package or a later process record. + var records = Regex.Split (dump, @"(?m)^ \*APP\* "); + for (int i = 1; i < records.Length; i++) { + var newline = records [i].IndexOf ('\n'); + if (newline < 0) + continue; + var header = records [i].Substring (0, newline); + if (Regex.IsMatch (header, @"ProcessRecord\{\S+ [1-9][0-9]*:" + Regex.Escape (package) + "/u" + user + @"a[0-9]+\}") && + Regex.IsMatch (records [i], @"(?m)^ mDebugging=true\r?$")) + return true; + } + return false; + } + } + } +} diff --git a/src/Microsoft.Android.Run/ManagedActivityLaunchResources.Designer.cs b/src/Microsoft.Android.Run/ManagedActivityLaunchResources.Designer.cs new file mode 100644 index 00000000000..d69af572e7c --- /dev/null +++ b/src/Microsoft.Android.Run/ManagedActivityLaunchResources.Designer.cs @@ -0,0 +1,38 @@ +#nullable enable +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Resources; + +namespace Microsoft.Android.Run +{ + internal static class ManagedActivityLaunchResources + { + static readonly ResourceManager resourceManager = new ResourceManager ( + "Microsoft.Android.Run.ManagedActivityLaunchResources", typeof (ManagedActivityLaunchResources).Assembly); + + internal static CultureInfo? Culture { get; set; } + + static string GetString (string name) => + resourceManager.GetString (name, Culture) ?? throw new MissingManifestResourceException (name); + + internal static string ManagedLaunchUnsupported => GetString (nameof (ManagedLaunchUnsupported)); + internal static string ManagedLaunchPackageMismatch => GetString (nameof (ManagedLaunchPackageMismatch)); + internal static string ManagedLaunchComponentUnsupported => GetString (nameof (ManagedLaunchComponentUnsupported)); + internal static string ManagedLaunchLayoutUnsupported => GetString (nameof (ManagedLaunchLayoutUnsupported)); + internal static string ManagedLaunchGateTimeout => GetString (nameof (ManagedLaunchGateTimeout)); + internal static string ManagedLaunchProcessUnsupported => GetString (nameof (ManagedLaunchProcessUnsupported)); + internal static string ManagedLaunchStateUnavailable => GetString (nameof (ManagedLaunchStateUnavailable)); + internal static string ManagedLaunchStateConflict => GetString (nameof (ManagedLaunchStateConflict)); + internal static string ManagedLaunchTimeout => GetString (nameof (ManagedLaunchTimeout)); + internal static string ManagedLaunchCleanupFailed => GetString (nameof (ManagedLaunchCleanupFailed)); + internal static string ManagedLaunchMutationTimeout => GetString (nameof (ManagedLaunchMutationTimeout)); + internal static string ManagedLaunchCleanupTimeout => GetString (nameof (ManagedLaunchCleanupTimeout)); + internal static string ManagedLaunchPidTimeout => GetString (nameof (ManagedLaunchPidTimeout)); + internal static string ManagedLaunchActivityNotFound => GetString (nameof (ManagedLaunchActivityNotFound)); + internal static string ManagedLaunchStartFailed => GetString (nameof (ManagedLaunchStartFailed)); + internal static string ManagedLaunchAdbFailed => GetString (nameof (ManagedLaunchAdbFailed)); + internal static string ManagedLaunchDeviceUnavailable => GetString (nameof (ManagedLaunchDeviceUnavailable)); + } +} diff --git a/src/Microsoft.Android.Run/ManagedActivityLaunchResources.resx b/src/Microsoft.Android.Run/ManagedActivityLaunchResources.resx new file mode 100644 index 00000000000..9516cdee73f --- /dev/null +++ b/src/Microsoft.Android.Run/ManagedActivityLaunchResources.resx @@ -0,0 +1,66 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Managed startup ANR protection requires Android 12 or later and a force-stopped activity launch on a single-user device. Launching without changing the device debug-app setting. + + + Managed startup protection requires a valid package name and an explicit activity component in that same package. + + + Managed startup ANR protection requires an explicit activity component. Launching without protection or changing the device debug-app setting. + + + The ActivityManager process dump layout is not supported for managed startup ANR protection. Launching without protection or changing the device debug-app setting. + + + Timed out waiting for another activity debug launch on this device to finish. This launch has not started. + + + The activity uses a custom process, or its package process could not be confirmed. Launching without managed startup ANR protection or changing the device debug-app setting. + + + Could not read ActivityManager debug-app state or Android users safely. + + + The device debug-app setting belongs to another launch or has changed unexpectedly. It has not been cleared. + + + Timed out preparing or starting the managed debug activity, or waiting for ActivityManager to attach its process and consume the transient debug-app setting. + + + Failed to clean up the transient Android debug-app setting after a managed launch failure. + + + Timed out updating the transient Android debug-app setting. + + + Timed out cleaning up the transient Android debug-app setting. + + + Timed out waiting for the Android application process to appear. + + + Device could not find component named: {0} + + + ActivityManager did not report starting the activity. + + + ADB command failed (exit code {0}): {1} + + + Could not resolve the Android device serial for the managed debug launch. + + diff --git a/src/Microsoft.Android.Run/Program.cs b/src/Microsoft.Android.Run/Program.cs index 95584690be8..519ebcfeff0 100644 --- a/src/Microsoft.Android.Run/Program.cs +++ b/src/Microsoft.Android.Run/Program.cs @@ -1,13 +1,19 @@ using System.Diagnostics; +using System.Globalization; using System.Text; using Microsoft.Testing.Extensions; +using Microsoft.Android.Run; using Mono.Options; using Xamarin.Android.Tools; +using static Microsoft.Android.Run.ManagedActivityLaunch; const string Name = "Microsoft.Android.Run"; const string VersionsFileName = "Microsoft.Android.versions.txt"; const int CtrlCExitCode = 130; // Standard Unix exit code for SIGINT: 128 + signal 2. const int StopAppTimeoutSeconds = 10; +// Match the existing managed debugger's 30-second startup window. +const int ManagedLaunchTimeoutSeconds = 30; +const int AppPidPollMilliseconds = 250; string? adbPath = null; string? adbTarget = null; @@ -25,12 +31,14 @@ bool isDotnetTestMode = false; string? dotnetTestPipe = null; bool waitForExit = true; +bool attachDebugger = false; +string? debugPidTarget = null; List forwardPorts = []; List reversePorts = []; try { return await RunAsync (args); -} catch (OperationCanceledException) { +} catch (OperationCanceledException) when (Volatile.Read (ref ctrlCRequested) != 0) { return CtrlCExitCode; } catch (Exception ex) { Console.Error.WriteLine ($"Error: {ex.Message}"); @@ -88,6 +96,9 @@ async Task RunAsync (string[] args) { "no-wait", "Launch the application without waiting for it to exit or streaming logcat.", v => waitForExit = v == null }, + { "attach-debugger", + "Protect activity startup while a managed debugger attaches. Does not request Java debugger attach.", + v => attachDebugger = v != null }, { "forward-port=", "Forward a TCP port from the host to the device in {MAPPING} format (HOST_PORT:DEVICE_PORT). May be repeated.", v => forwardPorts.Add (ParsePortMapping (v, "--forward-port")) }, @@ -381,16 +392,6 @@ static bool IsBundleKey (ReadOnlySpan key) } } -/// -/// Wraps a value in single quotes so the shell on the device treats it as a -/// single token. `adb shell` deliberately does not escape the arguments it -/// forwards, it just joins them with spaces (like `ssh`), so quoting for the -/// device shell is up to the caller. The surrounding quoting needed to survive -/// the *local* command line is handled by . -/// -static string QuoteForDeviceShell (string value) => - "'" + value.Replace ("'", "'\\''") + "'"; - /// /// Inspects `am instrument` output for signs that the instrumentation crashed or /// reported failure. Returns a human readable reason, or null on success. @@ -434,13 +435,13 @@ async Task StartLogcatWhenAppStartsAsync () { try { while (!cts.Token.IsCancellationRequested) { - var pid = await GetAppPidAsync (); + var pid = await GetAppPidAsync (cts.Token); if (pid != null) { logcatPid = pid; StartLogcat (); return; } - await Task.Delay (250, cts.Token).ConfigureAwait (ConfigureAwaitOptions.SuppressThrowing); + await Task.Delay (AppPidPollMilliseconds, cts.Token).ConfigureAwait (ConfigureAwaitOptions.SuppressThrowing); } } catch (OperationCanceledException) { // The instrumentation finished (or was cancelled) before the app process was seen @@ -519,7 +520,7 @@ async Task RunAppAsync () return 0; // 2. Get the PID - logcatPid = await GetAppPidAsync (); + logcatPid = attachDebugger ? await WaitForAppPidAsync () : await GetAppPidAsync (cts.Token); if (logcatPid == null) { Console.Error.WriteLine ("Error: App started but could not retrieve PID. The app may have crashed."); return 1; @@ -539,6 +540,13 @@ async Task RunAppAsync () async Task StartAppAsync () { + // Only explicit managed debug intent selects the transaction. No-wait and + // port mappings also serve ordinary launches and instrumentation. + if (attachDebugger) { + await StartManagedAppAsync (); + return true; + } + var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}"; // Device preparation is best effort; am start must run and determine the shell exit code. var wakeDeviceCommand = wakeDevice ? "input keyevent KEYCODE_WAKEUP; wm dismiss-keyguard; " : ""; @@ -556,6 +564,86 @@ async Task StartAppAsync () return true; } +async Task StartManagedAppAsync () +{ + if (string.IsNullOrEmpty (adbPath) || string.IsNullOrEmpty (package) || string.IsNullOrEmpty (activity)) + throw new InvalidOperationException (ManagedActivityLaunchResources.ManagedLaunchPackageMismatch); + var validatedAdbPath = adbPath; + var validatedPackage = package; + var component = $"{package}/{activity}"; + + string serial; + using (var timeout = CancellationTokenSource.CreateLinkedTokenSource (cts.Token)) { + timeout.CancelAfter (TimeSpan.FromSeconds (ManagedLaunchTimeoutSeconds)); + try { + var (output, error) = await RunCheckedAdbAsync (["get-serialno"], timeout.Token); + // ADB can successfully start its daemon while writing diagnostics to + // stderr. Validate the returned serial, rather than rejecting that startup. + if (!string.IsNullOrWhiteSpace (error)) + Console.Error.Write (error); + serial = output.Trim (); + } catch (OperationCanceledException ex) when (!cts.IsCancellationRequested) { + throw new TimeoutException (ManagedActivityLaunchResources.ManagedLaunchDeviceUnavailable, ex); + } + } + if (string.IsNullOrEmpty (serial) || serial == "unknown" || serial.Any (char.IsWhiteSpace)) + throw new InvalidOperationException (ManagedActivityLaunchResources.ManagedLaunchDeviceUnavailable); + // Pin automatic device selection for the transaction, logcat and Ctrl+C. + adbTarget = $"-s {serial}"; + + var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {QuoteForDeviceShell (deviceUserId)}"; + // Never use am start -W here, even when streaming logcat until app exit: + // managed application startup can be blocked waiting for debugger attach. + var startCommand = $"am start -S{userArg} -n {QuoteForDeviceShell (component)}"; + var processName = await ManagedActivityLaunch.RunAsync ( + serial, validatedPackage, component, deviceUserId, forceStop: true, + startCommand: startCommand, startupTimeout: TimeSpan.FromSeconds (ManagedLaunchTimeoutSeconds), + prepare: async token => { + if (wakeDevice) { + // As on the ordinary path, wake/keyguard preparation is best effort. + var (_, output, error) = await AdbHelper.RunAsync (validatedAdbPath, adbTarget, + new [] { "shell", "input keyevent KEYCODE_WAKEUP; wm dismiss-keyguard" }, token, verbose); + if (verbose) + Console.Write (output); + if (!string.IsNullOrWhiteSpace (error)) + Console.Error.Write (error); + } + }, + runShellCommand: RunShellAsync, + launchUnprotected: async token => { + var output = await RunShellAsync (startCommand, token); + Console.WriteLine (output); + CheckStartResult (output, component); + }, + log: Console.WriteLine, + logCleanupError: (message, error) => Console.Error.WriteLine ($"{message}{Environment.NewLine}{error}"), + token: cts.Token); + // Reuse confirmed ActivityInfo metadata for startup and exit tracking. When + // unavailable, retain the legacy package probe; never guess a custom process. + debugPidTarget = processName ?? validatedPackage; + + async Task RunShellAsync (string command, CancellationToken token) + { + var (output, error) = await RunCheckedAdbAsync (["shell", command], token); + // Non-waiting am start writes successful status warnings to stderr. + // Validate both channels, but keep mutation and state-query output strict. + if (command == startCommand) + return error.Length == 0 ? output : output + "\n" + error; + if (!string.IsNullOrWhiteSpace (error)) + throw new CommandFailedException (error); + return output; + } + + async Task<(string Output, string Error)> RunCheckedAdbAsync (IEnumerable arguments, CancellationToken token) + { + var (exitCode, output, error) = await AdbHelper.RunAsync (validatedAdbPath, adbTarget, arguments, token, verbose); + if (exitCode != 0) + throw new CommandFailedException (string.Format ( + CultureInfo.CurrentCulture, ManagedActivityLaunchResources.ManagedLaunchAdbFailed, exitCode, output + error)); + return (output, error); + } +} + async Task ConfigurePortMappingsAsync () { foreach (var port in forwardPorts) { @@ -593,10 +681,36 @@ source is < 1 or > 65535 || return new PortMapping (source, destination); } -async Task GetAppPidAsync () +async Task WaitForAppPidAsync () { - var cmdArgs = $"shell pidof {package}"; - var (exitCode, output, error) = await AdbHelper.RunAsync (adbPath, adbTarget, cmdArgs, cts.Token, verbose); + // Without am start -W, an unprotected debug fallback can return before its + // process exists. Reuse the instrumentation PID polling cadence, not a + // settling delay or a wait for application code to finish starting. + using var timeout = CancellationTokenSource.CreateLinkedTokenSource (cts.Token); + timeout.CancelAfter (TimeSpan.FromSeconds (ManagedLaunchTimeoutSeconds)); + try { + while (true) { + var pid = await GetAppPidAsync (timeout.Token, debugPidTarget); + timeout.Token.ThrowIfCancellationRequested (); + if (pid is int processId) + return processId; + await Task.Delay (AppPidPollMilliseconds, timeout.Token); + } + } catch (OperationCanceledException) when (timeout.IsCancellationRequested && !cts.IsCancellationRequested) { + throw new TimeoutException (ManagedActivityLaunchResources.ManagedLaunchPidTimeout); + } +} + +async Task GetAppPidAsync (CancellationToken token, string? debugProcessName = null) +{ + var (exitCode, output, error) = debugProcessName == null + ? await AdbHelper.RunAsync (adbPath, adbTarget, $"shell pidof {package}", token, verbose) + : await AdbHelper.RunAsync (adbPath, adbTarget, new [] { "shell", "pidof", QuoteForDeviceShell (debugProcessName) }, token, verbose); + // pidof normally exits nonzero with no output when the process is absent. + // Actual ADB diagnostics are failures, not startup retries or a clean app exit. + if (debugProcessName != null && (!string.IsNullOrWhiteSpace (error) || (exitCode != 0 && !string.IsNullOrWhiteSpace (output)))) + throw new CommandFailedException (string.Format ( + CultureInfo.CurrentCulture, ManagedActivityLaunchResources.ManagedLaunchAdbFailed, exitCode, output + error)); if (exitCode != 0 || string.IsNullOrWhiteSpace (output)) return null; @@ -604,6 +718,8 @@ source is < 1 or > 65535 || if (int.TryParse (pidStr, out int pid)) return pid; + if (debugProcessName != null) + throw new CommandFailedException (output); return null; } @@ -647,7 +763,7 @@ async Task WaitForAppExitAsync () try { while (!cts.Token.IsCancellationRequested) { // Check if app is still running - var pid = await GetAppPidAsync (); + var pid = await GetAppPidAsync (cts.Token, debugPidTarget); if (pid == null || pid != logcatPid) { if (verbose) Console.WriteLine ("App has exited."); diff --git a/src/Microsoft.Android.Run/Properties/AssemblyInfo.cs b/src/Microsoft.Android.Run/Properties/AssemblyInfo.cs new file mode 100644 index 00000000000..e943d330a51 --- /dev/null +++ b/src/Microsoft.Android.Run/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo ("Xamarin.Android.Tools.AndroidSdk-Tests, PublicKey=0024000004800000940000000602000000240000525341310004000011000000438ac2a5acfbf16cbd2b2b47a62762f273df9cb2795ceccdf77d10bf508e69e7a362ea7a45455bbf3ac955e1f2e2814f144e5d817efc4c6502cc012df310783348304e3ae38573c6d658c234025821fda87a0be8a0d504df564e2c93b2b878925f42503e9d54dfef9f9586d9e6f38a305769587b1de01f6c0410328b2c9733db")] diff --git a/src/Mono.AndroidTools/AndroidDevice.cs b/src/Mono.AndroidTools/AndroidDevice.cs index 93d97ac9d39..b73d40d0861 100644 --- a/src/Mono.AndroidTools/AndroidDevice.cs +++ b/src/Mono.AndroidTools/AndroidDevice.cs @@ -696,38 +696,10 @@ public Task StartActivity (string action, string [] categories, string package, /// Executes the given intent command, if logWriter is not null passes the output of the command to logWriter /// public async Task ExecuteIntentCommandAsync(AmIntentCommand intentCommand, Action logWiter, CancellationToken cancellationToken = default(CancellationToken)) - { - await ExecuteIntentCommandAsync (intentCommand, logWiter, cancellationToken, waitForCompletion: false).ConfigureAwait (false); - } - - /// - /// Executes an intent, optionally awaiting the shell command instead of returning - /// after five seconds. The caller must supply a bounded cancellation token when waiting. - /// - public async Task ExecuteIntentCommandAsync (AmIntentCommand intentCommand, Action logWiter, CancellationToken cancellationToken, bool waitForCompletion) { var command = intentCommand.ToString(); var log = new AndroidTaskLog("StartIntent", command); - if (waitForCompletion) { - var output = await RunShellCommand (command, cancellationToken).ConfigureAwait (false); - cancellationToken.ThrowIfCancellationRequested (); - AndroidLogger.LogTask (log.Complete (output)); - logWiter?.Invoke (output); - AdbOutputParsing.CheckStartResult (output, intentCommand.Component ?? intentCommand.Intent); - if (intentCommand is AmStartCommand) { - bool starting = false; - foreach (var line in output.Split ('\n')) { - starting |= line.StartsWith ("Starting: Intent {", StringComparison.Ordinal) && line.TrimEnd ('\r').EndsWith ("}", StringComparison.Ordinal); - if (line.StartsWith ("Error:", StringComparison.Ordinal) || line.StartsWith ("Exception occurred while executing", StringComparison.Ordinal)) - throw new AdbException (output); - } - if (!starting) - throw new AdbException (output); - } - return; - } - var shellTask = RunShellCommand(command, cancellationToken).ContinueWith(t => { if (t.IsFaulted) { AndroidLogger.LogError("Error executing intent", t.Exception); diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/RunActivity.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/RunActivity.cs index 83ede651789..ecc6dc472e2 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/RunActivity.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/RunActivity.cs @@ -26,19 +26,23 @@ // THE SOFTWARE. using System.IO; +using System.Threading; using System.Xml; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using Mono.AndroidTools; using Microsoft.Android.Build.Tasks; +using Microsoft.Android.Run; using Xamarin.AndroidTools; using Xamarin.AndroidTools.Debugging; using Xamarin.Android.Build.Debugging.Tasks.Properties; namespace Xamarin.Android.Tasks { - public class RunActivity : AsyncTask + public class RunActivity : AsyncTask, ICancelableTask { + readonly CancellationTokenSource managedLaunchCancellation = new CancellationTokenSource (); + public override string TaskPrefix => "RUNA"; [Required] @@ -71,6 +75,20 @@ public RunActivity () Port = "10000"; } + /// + /// Cancels the launch while allowing a managed-only transaction to finish cleanup. + /// + public new void Cancel () + { + // AsyncTask.Cancel exits its completion/logging pump before the worker + // finishes. Keep that pump alive for this transaction's mutation drain + // and cleanup; leave cancellation of the legacy paths unchanged. + if (AttachDebugger && !AllowJavaDebugging) + managedLaunchCancellation.Cancel (); + else + base.Cancel (); + } + public override bool Execute () { Device = AndroidHelper.ParseTarget (AdbTarget, LogMessage, LogCodedError, logErrors: true, engine4: BuildEngine4); @@ -78,7 +96,7 @@ public override bool Execute () return false; } LogMessage ($"Found device: {Device.ID}"); - return base.Execute (); + return base.Execute () && !(AttachDebugger && !AllowJavaDebugging && managedLaunchCancellation.IsCancellationRequested); } public async override System.Threading.Tasks.Task RunTaskAsync () @@ -109,7 +127,32 @@ public async override System.Threading.Tasks.Task RunTaskAsync () startConfiguration.Debugger.StdoutPort = -1; startConfiguration.Debugger.Server = Server; LogMessage (string.Format (Resources.StartDebugger_ipAddress_port, ipAddress, port), MessageImportance.High); - await device.StartWithDebuggingAsync (startConfiguration, CancellationToken); + if (AllowJavaDebugging) { + await device.StartWithDebuggingAsync (startConfiguration, CancellationToken); + } else { + // Keep the managed-only transaction in this task, not in the + // deprecated libraries still needed by the other launch paths. + var component = amStartCommand.Component; + var command = $"am start{(ForceStop ? " -S" : "")} --user {ManagedActivityLaunch.QuoteForDeviceShell (amStartCommand.User)}" + + " -a android.intent.action.MAIN -c android.intent.category.LAUNCHER" + + $" -n {ManagedActivityLaunch.QuoteForDeviceShell (component)}"; + try { + await ManagedActivityLaunch.RunAsync ( + device.ID, PackageName, component, amStartCommand.User, ForceStop, + startCommand: command, startupTimeout: startConfiguration.Debugger.Timeout, + prepare: token => device.SetDebugPropertiesAsync (PackageName, startConfiguration.Debugger, token), + runShellCommand: device.RunShellCommand, + launchUnprotected: token => device.ExecuteIntentCommandAsync (amStartCommand, startConfiguration.LogWiter, token), + log: message => LogMessage (message), + logCleanupError: (message, error) => this.LogUnhandledException (TaskPrefix, new AdbException (message, error)), + token: managedLaunchCancellation.Token); + } catch (ManagedActivityLaunch.CommandFailedException ex) { + // Preserve the task's existing typed ADB launch diagnostics. + if (ex.ActivityNotFound) + throw new ActivityNotFoundException (ex.Message); + throw new AdbException (ex.Message, ex); + } + } } else { await device.StartWithoutDebuggingAsync (startConfiguration, CancellationToken); } diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Build.Debugging.Tasks.csproj b/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Build.Debugging.Tasks.csproj index 37f23860cd5..5f3ecd0006a 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Build.Debugging.Tasks.csproj +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Build.Debugging.Tasks.csproj @@ -67,6 +67,17 @@ + + + + + + + + + + Microsoft.Android.Run.ManagedActivityLaunchResources.resources + --activity "$(AndroidLaunchActivity)" <_AndroidRunNoWaitArg Condition=" '$(WaitForExit)' == 'false' ">--no-wait <_AndroidRunNoWakeDeviceArg Condition=" '$(WaitForExit)' == 'false' ">--no-wake-device + <_AndroidRunAttachDebuggerArg> + <_AndroidRunAttachDebuggerArg Condition=" '$(AndroidAttachDebugger)' == 'true' and '$(AndroidInstrumentation)' == '' ">--attach-debugger <_AndroidRunForwardPortArg Condition=" '$(AndroidAttachDebugger)' == 'true' and '$(AndroidDebuggerServer)' == 'true' ">--forward-port "$(AndroidSdbTargetPort):$(AndroidSdbHostPort)" dotnet - exec "$(_AndroidRunPath)" --adb "$(_AdbToolPath)" $(_AndroidRunAdbTargetArg) --package "$(_AndroidPackage)" $(_AndroidRunActivityArg) $(_AndroidRunInstrumentArg) --logcat-args "$(_AndroidRunLogcatArgs)" $(_AndroidRunUserArg) $(_AndroidRunNoWaitArg) $(_AndroidRunNoWakeDeviceArg) $(_AndroidRunForwardPortArg) $(_AndroidRunExtraArgs) + exec "$(_AndroidRunPath)" --adb "$(_AdbToolPath)" $(_AndroidRunAdbTargetArg) --package "$(_AndroidPackage)" $(_AndroidRunActivityArg) $(_AndroidRunInstrumentArg) --logcat-args "$(_AndroidRunLogcatArgs)" $(_AndroidRunUserArg) $(_AndroidRunNoWaitArg) $(_AndroidRunNoWakeDeviceArg) $(_AndroidRunAttachDebuggerArg) $(_AndroidRunForwardPortArg) $(_AndroidRunExtraArgs) diff --git a/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs b/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs index c176ef96a0a..2915cd57159 100644 --- a/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs +++ b/src/Xamarin.AndroidTools/Debugging/DebuggingExtensions.cs @@ -29,22 +29,6 @@ public static class DebuggingExtensions /// Starts the process debugging using the given execution configuration /// public async static Task StartWithDebuggingAsync(this IAndroidDevice device, ExecutionConfiguration configuration, CancellationToken token) - { - if (configuration.RunCommand is AmStartCommand) { - var gate = ManagedActivityLaunch.GetGate (device.ID); - if (!await gate.WaitAsync (TimeSpan.FromSeconds (30), token).ConfigureAwait (false)) - throw new TimeoutException (Properties.Resources.ManagedLaunchGateTimeout); - try { - await StartWithDebuggingCoreAsync (device, configuration, token).ConfigureAwait (false); - } finally { - gate.Release (); - } - } else { - await StartWithDebuggingCoreAsync (device, configuration, token).ConfigureAwait (false); - } - } - - static async Task StartWithDebuggingCoreAsync (IAndroidDevice device, ExecutionConfiguration configuration, CancellationToken token) { // TODO: refactor IAndroidDevice some more to remove casts var androidDevice = (AndroidDevice)device; @@ -59,9 +43,6 @@ static async Task StartWithDebuggingCoreAsync (IAndroidDevice device, ExecutionC } bool javaDebugging = false; - if (!configuration.AllowJavaDebugging && configuration.RunCommand is AmStartCommand managedCommand) - managedCommand.EnableDebugging = false; - if (configuration.AllowJavaDebugging && configuration.RunCommand is AmStartCommand) { var cmd = ((AmStartCommand)configuration.RunCommand); if (androidDevice.IsWSA() || androidDevice.IsEmulator) // force -D for WSA and Emulators @@ -78,11 +59,7 @@ static async Task StartWithDebuggingCoreAsync (IAndroidDevice device, ExecutionC configuration.LogWiter(configuration.RunCommand.ToString()); } - if (!configuration.AllowJavaDebugging && configuration.RunCommand is AmStartCommand startCommand) { - await ManagedActivityLaunch.RunAsync (androidDevice, configuration, startCommand, token).ConfigureAwait (false); - } else { - await androidDevice.ExecuteIntentCommandAsync(configuration.RunCommand, configuration.LogWiter, token).ConfigureAwait(false); - } + await androidDevice.ExecuteIntentCommandAsync(configuration.RunCommand, configuration.LogWiter, token).ConfigureAwait(false); if (javaDebugging) await androidDevice.ConnectJdwpAsync (configuration, token).ConfigureAwait(false); } diff --git a/src/Xamarin.AndroidTools/Debugging/ManagedActivityLaunch.cs b/src/Xamarin.AndroidTools/Debugging/ManagedActivityLaunch.cs deleted file mode 100644 index a5cb2d06acf..00000000000 --- a/src/Xamarin.AndroidTools/Debugging/ManagedActivityLaunch.cs +++ /dev/null @@ -1,257 +0,0 @@ -// 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.Concurrent; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Mono.AndroidTools; -using Mono.AndroidTools.Util; -using Xamarin.AndroidTools.Properties; - -namespace Xamarin.AndroidTools.Debugging -{ - // ActivityManager has one debug-app slot per device, not per package or user. - // Keep the gate through cleanup, including when another AndroidDevice instance - // targets the same serial. Unrelated adb clients do not participate in this lock. - static class ManagedActivityLaunch - { - static readonly ConcurrentDictionary gates = new ConcurrentDictionary (StringComparer.Ordinal); - const int CleanupTimeoutMilliseconds = 5000; - const int PollMilliseconds = 100; - const string DumpCommand = "dumpsys activity processes"; - const string ClearCommand = "am clear-debug-app"; - static readonly Regex packagePattern = new Regex (@"\A[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+\z"); - static readonly Regex markerPattern = new Regex (@"(?m)^ mDebugApp=(\S+)/orig=(\S+) mDebugTransient=(true|false) mOrigWaitForDebugger=(true|false)\r?$"); - - internal static SemaphoreSlim GetGate (string serial) => gates.GetOrAdd (serial, _ => new SemaphoreSlim (1, 1)); - - internal static async Task RunAsync (AndroidDevice device, ExecutionConfiguration configuration, AmStartCommand command, CancellationToken token) - { - // set-debug-app force-stops even an already-running package. Never turn - // a warm launch into a cold one, or reinterpret caller-requested waits/repeats. - if (!command.ForceStop || command.Wait || command.Repeat != 0 || device.BuildVersionSdk < 31) { - await LaunchUnprotectedAsync (device, configuration, command, Resources.ManagedLaunchUnsupported, token).ConfigureAwait (false); - return; - } - - var package = configuration.PackageName; - if (!packagePattern.IsMatch (package)) - throw new ArgumentException (Resources.ManagedLaunchPackageMismatch, nameof (configuration)); - if (string.IsNullOrEmpty (command.Component)) { - await LaunchUnprotectedAsync (device, configuration, command, Resources.ManagedLaunchComponentUnsupported, token).ConfigureAwait (false); - return; - } - if (!command.Component.StartsWith (package + "/", StringComparison.Ordinal) || command.Component.Length == package.Length + 1) - throw new ArgumentException (Resources.ManagedLaunchPackageMismatch, nameof (configuration)); - if (configuration.Debugger.Timeout <= TimeSpan.Zero) - throw new ArgumentOutOfRangeException (nameof (configuration.Debugger.Timeout)); - - using (var timeout = CancellationTokenSource.CreateLinkedTokenSource (token)) { - timeout.CancelAfter (configuration.Debugger.Timeout); - bool armed = false; - Exception primaryError = null; - try { - // AOSP's setDebugApp uses USER_ALL. Restrict this transaction to - // single-user devices so an explicit --user never kills another profile. - var users = await device.RunShellCommand ("pm list users", timeout.Token).ConfigureAwait (false); - var userLines = users.Trim ().Split ('\n'); - var userIds = Regex.Matches (users, @"(?m)^[ \t]*UserInfo\{(\d+):[^{}\r\n]*:[0-9a-fA-F]+\}(?: running)?\r?$"); - if (userLines [0].TrimEnd ('\r') != "Users:" || userIds.Count == 0 || userIds.Count != userLines.Length - 1) - throw new InvalidOperationException (Resources.ManagedLaunchStateUnavailable); - if (userIds.Count != 1 || (!string.IsNullOrEmpty (command.User) && command.User != "current" && command.User != userIds [0].Groups [1].Value)) { - await LaunchUnprotectedAsync (device, configuration, command, Resources.ManagedLaunchUnsupported, token).ConfigureAwait (false); - return; - } - - if (!await UsesPackageProcessAsync (device, command, package, userIds [0].Groups [1].Value, timeout.Token).ConfigureAwait (false)) { - await LaunchUnprotectedAsync (device, configuration, command, Resources.ManagedLaunchProcessUnsupported, token).ConfigureAwait (false); - return; - } - - DebugAppState state; - try { - state = await ReadStateAsync (device, timeout.Token).ConfigureAwait (false); - } catch (UnsupportedDumpLayoutException) { - // Before any mutation, an unknown dump layout only means that - // protection is unavailable. Never apply this fallback after arming. - await LaunchUnprotectedAsync (device, configuration, command, Resources.ManagedLaunchLayoutUnsupported, token).ConfigureAwait (false); - return; - } - if (!state.CanClear (package)) - throw new InvalidOperationException (Resources.ManagedLaunchStateConflict); - if (state.HasMarker) { - armed = true; - using (var mutation = new CancellationTokenSource (CleanupTimeoutMilliseconds)) - await ExecuteEmptyCommandAsync (device, ClearCommand, mutation.Token).ConfigureAwait (false); - } - timeout.Token.ThrowIfCancellationRequested (); - - var builder = new ProcessArgumentBuilder (); - builder.Add ("am", "set-debug-app"); - builder.AddQuoted (package); - armed = true; - // Do not abandon an in-flight mutation on caller cancellation: - // drain its reply before cleanup can overtake it on another connection. - using (var mutation = new CancellationTokenSource (CleanupTimeoutMilliseconds)) - await ExecuteEmptyCommandAsync (device, builder.ToString (), mutation.Token).ConfigureAwait (false); - state = await ReadStateAsync (device, timeout.Token).ConfigureAwait (false); - if (!state.IsPending (package)) - throw new InvalidOperationException (Resources.ManagedLaunchStateConflict); - - await device.ExecuteIntentCommandAsync (command, configuration.LogWiter, timeout.Token, waitForCompletion: true).ConfigureAwait (false); - while (true) { - state = await ReadStateAsync (device, timeout.Token).ConfigureAwait (false); - if (!state.CanClear (package)) - throw new InvalidOperationException (Resources.ManagedLaunchStateConflict); - // attachApplicationLocked sets mDebugging before restoring the - // globals, under the same AMS lock used by dumpsys. A PID alone - // is too early, and waiting for activity startup would deadlock - // managed debugger attach. mDebugTransient remains true here. - if (state.IsConsumed && state.HasDebuggingProcess (package, userIds [0].Groups [1].Value)) - break; - await Task.Delay (PollMilliseconds, timeout.Token).ConfigureAwait (false); - } - token.ThrowIfCancellationRequested (); - } catch (Exception ex) { - primaryError = ex; - if (timeout.IsCancellationRequested) { - primaryError = token.IsCancellationRequested - ? (Exception) new OperationCanceledException (token) - : new TimeoutException (Resources.ManagedLaunchTimeout, ex); - System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture (primaryError).Throw (); - } - throw; - } finally { - if (armed) { - using (var cleanup = new CancellationTokenSource (CleanupTimeoutMilliseconds)) { - try { - var state = await ReadStateAsync (device, cleanup.Token).ConfigureAwait (false); - if (!state.CanClear (package)) - throw new InvalidOperationException (Resources.ManagedLaunchStateConflict); - if (state.HasMarker) - await ExecuteEmptyCommandAsync (device, ClearCommand, cleanup.Token).ConfigureAwait (false); - state = await ReadStateAsync (device, cleanup.Token).ConfigureAwait (false); - if (state.HasMarker) - throw new InvalidOperationException (Resources.ManagedLaunchStateConflict); - } catch (Exception cleanupError) when (primaryError != null) { - // Preserve launch/cancellation errors, but never hide failed cleanup. - AndroidLogger.LogError (Resources.ManagedLaunchCleanupFailed, cleanupError); - } - } - } - } - } - token.ThrowIfCancellationRequested (); - } - - static async Task LaunchUnprotectedAsync (AndroidDevice device, ExecutionConfiguration configuration, AmStartCommand command, string diagnostic, CancellationToken token) - { - AndroidLogger.LogInfo (diagnostic); - configuration.LogWiter?.Invoke (diagnostic); - // The legacy intent continuation can turn a canceled shell into empty - // output. Preserve its launch behavior, but not cancellation-as-success. - token.ThrowIfCancellationRequested (); - await device.ExecuteIntentCommandAsync (command, configuration.LogWiter, token).ConfigureAwait (false); - token.ThrowIfCancellationRequested (); - } - - static async Task UsesPackageProcessAsync (AndroidDevice device, AmStartCommand command, string package, string user, CancellationToken token) - { - // No existing device metadata helper exposes ActivityInfo.processName. - // PackageManager resolves both application inheritance and activity overrides. - var builder = new ProcessArgumentBuilder (); - builder.Add ("pm", "resolve-activity", "--user"); - builder.AddQuoted (user); - builder.Add ("-n"); - builder.AddQuoted (command.Component); - var output = await device.RunShellCommand (builder.ToString (), token).ConfigureAwait (false); - token.ThrowIfCancellationRequested (); - - var activityName = command.Component.Substring (package.Length + 1); - if (activityName.StartsWith (".", StringComparison.Ordinal)) - activityName = package + activityName; - var activity = Regex.Match (output, @"(?m)^ActivityInfo:\r?\n(?(?: [^\r\n]*\r?\n)+)"); - if (!activity.Success || activity.NextMatch ().Success) - return false; - var fields = activity.Groups ["fields"].Value; - if (!Regex.IsMatch (fields, @"(?m)^ name=" + Regex.Escape (activityName) + @"\r?$") || - !Regex.IsMatch (fields, @"(?m)^ packageName=" + Regex.Escape (package) + @"\r?$") || - !Regex.IsMatch (fields, @"(?m)^ enabled=(true|false) exported=(true|false) directBootAware=(true|false)\r?$") || - !Regex.IsMatch (fields, @"(?m)^ ApplicationInfo:\r?$")) - return false; - - // ComponentInfo omits processName when it equals the package. Only read - // the two-space activity field, never ApplicationInfo's four-space value: - // an activity can override a custom application process back to the package. - var processes = Regex.Matches (fields, @"(?m)^ processName=(\S+)\r?$"); - return processes.Count == 0 - ? !Regex.IsMatch (fields, @"(?m)^ processName=") - : processes.Count == 1 && processes [0].Groups [1].Value == package; - } - - static async Task ExecuteEmptyCommandAsync (AndroidDevice device, string command, CancellationToken token) - { - var output = await device.RunShellCommand (command, token).ConfigureAwait (false); - token.ThrowIfCancellationRequested (); - if (!string.IsNullOrWhiteSpace (output)) - throw new AdbException (output); - } - - static async Task ReadStateAsync (AndroidDevice device, CancellationToken token) - { - var dump = await device.RunShellCommand (DumpCommand, token).ConfigureAwait (false); - token.ThrowIfCancellationRequested (); - return new DebugAppState (dump); - } - - sealed class UnsupportedDumpLayoutException : InvalidOperationException - { - internal UnsupportedDumpLayoutException () : base (Resources.ManagedLaunchStateUnavailable) - { - } - } - - sealed class DebugAppState - { - readonly string dump; - readonly Match marker; - - internal DebugAppState (string dump) - { - this.dump = dump; - if (!dump.StartsWith ("ACTIVITY MANAGER RUNNING PROCESSES (dumpsys activity processes)", StringComparison.Ordinal) || - !Regex.IsMatch (dump, @"(?m)^ mForceBackgroundCheck=(true|false)\s*\z")) - throw new UnsupportedDumpLayoutException (); - marker = markerPattern.Match (dump); - if ((dump.Contains ("mDebugApp=") && !marker.Success) || marker.NextMatch ().Success) - throw new InvalidOperationException (Resources.ManagedLaunchStateUnavailable); - } - - internal bool HasMarker => marker.Success; - internal bool IsConsumed => HasMarker && marker.Groups [1].Value == "null" && CanClear ("null"); - internal bool IsPending (string package) => HasMarker && marker.Groups [1].Value == package && CanClear (package); - internal bool CanClear (string package) => !HasMarker || - ((marker.Groups [1].Value == package || marker.Groups [1].Value == "null") && - marker.Groups [2].Value == "null" && marker.Groups [3].Value == "true" && marker.Groups [4].Value == "false"); - - internal bool HasDebuggingProcess (string package, string user) - { - // Restrict mDebugging to this process's full *APP* record, not a - // substring match against another package or a later process record. - var records = Regex.Split (dump, @"(?m)^ \*APP\* "); - for (int i = 1; i < records.Length; i++) { - var newline = records [i].IndexOf ('\n'); - if (newline < 0) - continue; - var header = records [i].Substring (0, newline); - if (Regex.IsMatch (header, @"ProcessRecord\{\S+ [1-9][0-9]*:" + Regex.Escape (package) + "/u" + user + @"a[0-9]+\}") && - Regex.IsMatch (records [i], @"(?m)^ mDebugging=true\r?$")) - return true; - } - return false; - } - } - } -} diff --git a/src/Xamarin.AndroidTools/Properties/Resources.Designer.cs b/src/Xamarin.AndroidTools/Properties/Resources.Designer.cs index cdf8ff8e7e4..6f8a8b99922 100644 --- a/src/Xamarin.AndroidTools/Properties/Resources.Designer.cs +++ b/src/Xamarin.AndroidTools/Properties/Resources.Designer.cs @@ -68,46 +68,6 @@ internal static string JdwpClientDisconnectError { return ResourceManager.GetString("JdwpClientDisconnectError", resourceCulture); } } - - internal static string ManagedLaunchUnsupported { - get { return ResourceManager.GetString("ManagedLaunchUnsupported", resourceCulture); } - } - - internal static string ManagedLaunchPackageMismatch { - get { return ResourceManager.GetString("ManagedLaunchPackageMismatch", resourceCulture); } - } - - internal static string ManagedLaunchComponentUnsupported { - get { return ResourceManager.GetString("ManagedLaunchComponentUnsupported", resourceCulture); } - } - - internal static string ManagedLaunchLayoutUnsupported { - get { return ResourceManager.GetString("ManagedLaunchLayoutUnsupported", resourceCulture); } - } - - internal static string ManagedLaunchGateTimeout { - get { return ResourceManager.GetString("ManagedLaunchGateTimeout", resourceCulture); } - } - - internal static string ManagedLaunchProcessUnsupported { - get { return ResourceManager.GetString("ManagedLaunchProcessUnsupported", resourceCulture); } - } - - internal static string ManagedLaunchStateUnavailable { - get { return ResourceManager.GetString("ManagedLaunchStateUnavailable", resourceCulture); } - } - - internal static string ManagedLaunchStateConflict { - get { return ResourceManager.GetString("ManagedLaunchStateConflict", resourceCulture); } - } - - internal static string ManagedLaunchTimeout { - get { return ResourceManager.GetString("ManagedLaunchTimeout", resourceCulture); } - } - - internal static string ManagedLaunchCleanupFailed { - get { return ResourceManager.GetString("ManagedLaunchCleanupFailed", resourceCulture); } - } /// /// Looks up a localized string similar to The Android SDK directory could not be found. Check that the Android SDK Manager in Visual Studio shows a valid installation. To use a custom SDK path for a command line build, set the 'AndroidSdkDirectory' MSBuild property to the custom path.. diff --git a/src/Xamarin.AndroidTools/Properties/Resources.resx b/src/Xamarin.AndroidTools/Properties/Resources.resx index 9377972154f..ebdb18844d2 100644 --- a/src/Xamarin.AndroidTools/Properties/Resources.resx +++ b/src/Xamarin.AndroidTools/Properties/Resources.resx @@ -120,36 +120,6 @@ Unexpected error occurred trying to disconnect Jdwp client. - - Managed startup ANR protection requires Android 12 or later and a force-stopped, non-waiting, non-repeated activity launch on a single-user device. Launching without changing the device debug-app setting. - - - Managed startup protection requires a valid package name and an explicit activity component in that same package. - - - Managed startup ANR protection requires an explicit activity component. Launching without protection or changing the device debug-app setting. - - - The ActivityManager process dump layout is not supported for managed startup ANR protection. Launching without protection or changing the device debug-app setting. - - - Timed out waiting for another activity debug launch on this device to finish. This launch has not started. - - - The activity uses a custom process, or its package process could not be confirmed. Launching without managed startup ANR protection or changing the device debug-app setting. - - - Could not read ActivityManager debug-app state or Android users safely. - - - The device debug-app setting belongs to another launch or has changed unexpectedly. It has not been cleared. - - - Timed out waiting for ActivityManager to attach the managed debug process and consume the transient debug-app setting. - - - Failed to clean up the transient Android debug-app setting after a managed launch failure. - An exception occurred while retrieving the properties of the selected Java SDK installation. Check that the selected Java SDK installation contains a compatible version of Java and that 'java -XshowSettings:properties -version' runs successfully for that installation. Exception: {0} The following terms should not be translated: java -XshowSettings:properties -version diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/ManagedActivityLaunchEntryPointTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/ManagedActivityLaunchEntryPointTests.cs new file mode 100644 index 00000000000..5c6af14e415 --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/ManagedActivityLaunchEntryPointTests.cs @@ -0,0 +1,861 @@ +// 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.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.Android.Build.BaseTasks.Tests.Utilities; +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Framework; +using Mono.AndroidTools; +using NUnit.Framework; +using Xamarin.Android.Tasks; +using Xamarin.AndroidTools.Debugging; +using LaunchAdbServer = Xamarin.Android.Tools.Tests.ManagedActivityLaunchTests.LaunchAdbServer; + +namespace Xamarin.Android.Tools.Tests; + +[TestFixture] +public class ManagedActivityLaunchEntryPointTests +{ + const string PackageName = "com.example.managed"; + + [Test] + public async Task RunProgramAcceptsExplicitDebugIntent () + { + var (exitCode, output, error) = await RunProgramAsync ("--help"); + Assert.AreEqual (0, exitCode, error); + StringAssert.Contains ("--attach-debugger", output); + } + + [Test] + public async Task RunProgramProtectsExplicitDebugLaunchWithoutActivityWait ([Values] bool noWait) + { + await using var server = new LaunchAdbServer (); + var arguments = new List { "--activity", ".MainActivity", "--attach-debugger", "--user", "0", "--no-wake-device" }; + if (noWait) + arguments.Add ("--no-wait"); + + var (exitCode, output, error) = await RunProgramAsync (server, arguments.ToArray ()); + + Assert.AreEqual (0, exitCode, output + error); + var commands = server.Commands.ToArray (); + CollectionAssert.Contains (commands, "am set-debug-app 'com.example.managed'"); + var start = commands.Single (c => c.StartsWith ("am start ", StringComparison.Ordinal)); + Assert.AreEqual ("am start -S --user '0' -n 'com.example.managed/.MainActivity'", start); + Assert.IsFalse (commands.Any (c => c.Contains (" -D") || c.Contains (" -W") || c.Contains (" -w") || c.Contains ("--persistent"))); + Assert.IsTrue (server.Attached); + Assert.IsNull (server.DebugApp); + Assert.Greater (Array.IndexOf (commands, "am clear-debug-app"), Array.IndexOf (commands, start)); + Assert.AreEqual (!noWait, commands.Any (c => c.StartsWith ("logcat ", StringComparison.Ordinal))); + Assert.That (server.AdbCommands.Where (c => !c.EndsWith ("get-serialno", StringComparison.Ordinal)), + Is.All.StartsWith ("-s " + server.Serial + " "), "Resolve automatic selection once, then pin the same device."); + } + + [Test] + public async Task RunProgramAllowsAdbDaemonStartupDiagnostics () + { + await using var server = new LaunchAdbServer { LogDaemonStartup = true }; + var (exitCode, output, error) = await RunProgramAsync (server, + "--activity", ".MainActivity", "--attach-debugger", "--no-wait", "--no-wake-device"); + Assert.AreEqual (0, exitCode, output + error); + StringAssert.Contains ("daemon started successfully", error); + Assert.IsTrue (server.Attached); + Assert.IsNull (server.DebugApp); + } + + [Test] + public async Task RunProgramCtrlCDrainsMutationAndCleansBeforeStoppingApp () + { + await using var server = new LaunchAdbServer (); + var arguments = new [] { + Assembly.Load ("Microsoft.Android.Run").Location, + "--adb", server.CreateFakeAdb (), "--package", PackageName, "--activity", ".MainActivity", + "--attach-debugger", "--no-wait", "--no-wake-device", "--user", "0", + }; + var processId = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var arming = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + server.BeforeResponse = command => { + if (command.StartsWith ("am set-debug-app ", StringComparison.Ordinal)) { + arming.TrySetResult (); + return release.Task; + } + return Task.CompletedTask; + }; + var run = RunDotnetAsync (arguments, process => processId.TrySetResult (process.Id)); + try { + await arming.Task.WaitAsync (TimeSpan.FromSeconds (5)); + var pid = await processId.Task; + await SendCtrlCAsync (pid); + await Task.WhenAny (run, Task.Delay (100)); + Assert.IsFalse (run.IsCompleted); + Assert.IsFalse (server.Commands.Contains ("am clear-debug-app")); + release.TrySetResult (); + var (exitCode, output, stderr) = await run.WaitAsync (TimeSpan.FromSeconds (10)); + Assert.AreEqual (130, exitCode, output + stderr); + StringAssert.Contains ("Stopping application...", output); + Assert.IsNull (server.DebugApp); + var commands = server.Commands.ToArray (); + int clear = Array.IndexOf (commands, "am clear-debug-app"); + int stop = Array.FindIndex (commands, c => c.StartsWith ("am force-stop ", StringComparison.Ordinal)); + Assert.GreaterOrEqual (clear, 0); + Assert.Greater (stop, clear); + Assert.IsFalse (commands.Any (c => c.StartsWith ("am start ", StringComparison.Ordinal))); + } finally { + release.TrySetResult (); + await run.WaitAsync (TimeSpan.FromSeconds (10)); + } + } + + [TestCase ("arm")] + [TestCase ("cleanup")] + [Category ("ManagedLaunchBoundaryRegression")] + public async Task RunProgramPrivateBudgetTimeoutIsFailure (string boundary) + { + await using var server = new LaunchAdbServer (); + var pending = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var command = boundary == "arm" ? "am set-debug-app 'com.example.managed'" : "am clear-debug-app"; + server.BeforeResponse = value => value == command ? pending.Task : Task.CompletedTask; + var elapsed = Stopwatch.StartNew (); + + var (exitCode, output, error) = await RunProgramAsync (server, + "--activity", ".MainActivity", "--attach-debugger", "--no-wait", "--no-wake-device"); + + Assert.AreEqual (1, exitCode, output + error); + StringAssert.Contains ("Timed out", error); + StringAssert.Contains ("debug-app", error); + Assert.GreaterOrEqual (elapsed.Elapsed, TimeSpan.FromSeconds (5)); + Assert.Less (elapsed.Elapsed, TimeSpan.FromSeconds (15)); + Assert.IsFalse (output.Contains ("Stopping application..."), "No Ctrl+C was sent."); + Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am force-stop ", StringComparison.Ordinal))); + Assert.AreEqual (boundary == "cleanup", server.Attached); + } + + [Test] + public async Task RunProgramNoWaitAndPortsAreNotDebugIntent ([Values] bool noWait, [Values] bool ports) + { + await using var server = new LaunchAdbServer (); + server.SetDebugAppState ("com.example.other", true); + var arguments = new List { "--activity", ".MainActivity", "--no-wake-device" }; + if (noWait) + arguments.Add ("--no-wait"); + if (ports) + arguments.AddRange (new [] { "--forward-port", "10000:10000", "--reverse-port", "8000:8001" }); + + var (exitCode, output, error) = await RunProgramAsync (server, arguments.ToArray ()); + + Assert.AreEqual (0, exitCode, output + error); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app") || c == "dumpsys activity processes")); + Assert.AreEqual ("com.example.other", server.DebugApp); + var start = server.Commands.Single (c => c.StartsWith ("am start ", StringComparison.Ordinal)); + Assert.AreEqual (!noWait, start.Contains (" -W")); + Assert.AreEqual (!noWait, server.Commands.Any (c => c.StartsWith ("logcat ", StringComparison.Ordinal))); + Assert.AreEqual (ports, server.Commands.Contains ("forward tcp:10000 tcp:10000")); + Assert.AreEqual (ports, server.Commands.Contains ("reverse tcp:8000 tcp:8001")); + } + + [TestCase ("older-api")] + [TestCase ("multiple-users")] + [TestCase ("unsupported-layout")] + [TestCase ("custom-process")] + [Category ("ManagedLaunchBoundaryRegression")] + public async Task RunProgramDebugFallbackWaitsForPid (string reason) + { + await using var server = new LaunchAdbServer (); + if (reason == "older-api") + server.ApiLevel = 30; + if (reason == "multiple-users") + server.UserList += "\tUserInfo{10:Work:30} running\n"; + if (reason == "custom-process") + server.EffectiveProcessName = PackageName + ":custom"; + int pidQueries = 0; + server.TransformResponse = (command, output) => { + if (command.StartsWith ("pidof ", StringComparison.Ordinal)) + return (command == "pidof " + server.EffectiveProcessName || command == "pidof '" + server.EffectiveProcessName + "'") && + Interlocked.Increment (ref pidQueries) > 3 ? "1234\n" : ""; + if (reason == "unsupported-layout" && command == "dumpsys activity processes") + return output + " vendor postamble\n"; + return output; + }; + + var (exitCode, output, error) = await RunProgramAsync (server, + "--activity", ".MainActivity", "--attach-debugger", "--no-wake-device"); + + Assert.AreEqual (0, exitCode, output + error); + Assert.GreaterOrEqual (pidQueries, 4); + StringAssert.Contains ("managed-launch logcat", output); + Assert.IsTrue (server.Commands.Any (c => c.StartsWith ("logcat ", StringComparison.Ordinal))); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app") || c.Contains (" -W"))); + } + + [TestCase ("com.example.managed:custom")] + [TestCase ("com.example.managed:custom$worker")] + [Category ("ManagedLaunchPidRegression")] + public async Task RunProgramCustomProcessTracksStartupAndExit (string processName) + { + await using var server = new LaunchAdbServer { EffectiveProcessName = processName }; + var pidCommand = "pidof '" + processName + "'"; + int matchingQueries = 0; + var logcatPending = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + // Keep logcat alive so success requires observing the process exit, not + // merely the fake logcat executable finishing before the next PID query. + server.BeforeResponse = command => command.StartsWith ("logcat ", StringComparison.Ordinal) ? logcatPending.Task : Task.CompletedTask; + server.TransformResponse = (command, output) => { + if (!command.StartsWith ("pidof ", StringComparison.Ordinal)) + return output; + if (command != pidCommand) + return ""; + int query = Interlocked.Increment (ref matchingQueries); + return query is 3 or 4 ? "1234\n" : ""; + }; + + var (exitCode, output, error) = await RunProgramAsync (server, + "--activity", ".MainActivity", "--attach-debugger", "--no-wake-device", "--verbose"); + + Assert.AreEqual (0, exitCode, output + error); + Assert.AreEqual (5, matchingQueries, "Two no-matches, startup PID, running PID, then exit."); + Assert.That (server.Commands.Where (c => c.StartsWith ("pidof ", StringComparison.Ordinal)), Is.All.EqualTo (pidCommand)); + CollectionAssert.Contains (server.Commands, "logcat --pid=1234"); + StringAssert.Contains ("App has exited.", output); + Assert.AreEqual (1, server.Commands.Count (c => c.StartsWith ("pm resolve-activity ", StringComparison.Ordinal)), + "Reuse the process identity already resolved for launch eligibility."); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + CollectionAssert.Contains (server.Commands, "am start -S -n 'com.example.managed/.MainActivity'"); + } + + [TestCase ("stderr", false)] + [TestCase ("stdout", false)] + [TestCase ("stderr", true)] + [Category ("ManagedLaunchPidRegression")] + public async Task RunProgramDebugPidDiagnosticsFailPromptly (string channel, bool afterStartup) + { + await using var server = new LaunchAdbServer { ApiLevel = 30 }; + const string diagnostic = "adb: device offline\n"; + int pidQueries = 0; + int failOnQuery = afterStartup ? 2 : 1; + var logcatPending = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + server.BeforeResponse = command => command.StartsWith ("logcat ", StringComparison.Ordinal) ? logcatPending.Task : Task.CompletedTask; + server.TransformResponse = (command, output) => { + if (!command.StartsWith ("pidof ", StringComparison.Ordinal)) + return output; + if (Interlocked.Increment (ref pidQueries) < failOnQuery) + return "1234\n"; + return channel == "stdout" ? diagnostic : ""; + }; + server.CliResult = command => command.StartsWith ("pidof ", StringComparison.Ordinal) && pidQueries >= failOnQuery + ? (1, channel == "stderr" ? diagnostic : "") + : (0, ""); + var elapsed = Stopwatch.StartNew (); + + var (exitCode, output, error) = await RunProgramAsync (server, + "--activity", ".MainActivity", "--attach-debugger", "--no-wake-device", "--verbose"); + + Assert.AreEqual (1, exitCode, output + error); + StringAssert.Contains (diagnostic.Trim (), error); + StringAssert.DoesNotContain ("Timed out", error); + Assert.AreEqual (failOnQuery, pidQueries, "Do not retry an ADB failure as pidof no-match."); + Assert.Less (elapsed.Elapsed, TimeSpan.FromSeconds (10)); + if (afterStartup) + StringAssert.Contains ("App PID: 1234", output); + else + Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("logcat ", StringComparison.Ordinal))); + } + + [Test] + [Category ("ManagedLaunchPidRegression")] + public async Task RunProgramNonDebugPidDiagnosticsRetainExistingBehavior () + { + await using var server = new LaunchAdbServer (); + server.TransformResponse = (command, output) => command.StartsWith ("pidof ", StringComparison.Ordinal) ? "" : output; + server.CliResult = command => command.StartsWith ("pidof ", StringComparison.Ordinal) ? (1, "adb: device offline\n") : (0, ""); + + var (exitCode, output, error) = await RunProgramAsync (server, "--activity", ".MainActivity", "--no-wake-device"); + + Assert.AreEqual (1, exitCode, output + error); + StringAssert.Contains ("could not retrieve PID", error); + Assert.AreEqual (1, server.Commands.Count (c => c.StartsWith ("pidof ", StringComparison.Ordinal))); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + } + + [Test] + [Category ("ManagedLaunchBoundaryRegression")] + public async Task RunProgramPidPollingTimeoutIsFailure () + { + await using var server = new LaunchAdbServer { ApiLevel = 30 }; + server.TransformResponse = (command, output) => command.StartsWith ("pidof ", StringComparison.Ordinal) ? "" : output; + var elapsed = Stopwatch.StartNew (); + + var (exitCode, output, error) = await RunProgramAsync (server, + "--activity", ".MainActivity", "--attach-debugger", "--no-wake-device"); + + Assert.AreEqual (1, exitCode, output + error); + StringAssert.Contains ("Timed out", error); + StringAssert.Contains ("process", error); + Assert.Greater (server.Commands.Count (c => c.StartsWith ("pidof ", StringComparison.Ordinal)), 1); + Assert.GreaterOrEqual (elapsed.Elapsed, TimeSpan.FromSeconds (30)); + Assert.Less (elapsed.Elapsed, TimeSpan.FromSeconds (40)); + Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("logcat ", StringComparison.Ordinal))); + } + + [Test] + [Category ("ManagedLaunchBoundaryRegression")] + public async Task RunProgramPidPollingHonorsCtrlC () + { + await using var server = new LaunchAdbServer { ApiLevel = 30 }; + var arguments = new [] { + Assembly.Load ("Microsoft.Android.Run").Location, + "--adb", server.CreateFakeAdb (), "--package", PackageName, "--activity", ".MainActivity", + "--attach-debugger", "--no-wake-device", + }; + var processId = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var querying = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var pending = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + server.BeforeResponse = command => { + if (command.StartsWith ("pidof ", StringComparison.Ordinal)) { + querying.TrySetResult (); + return pending.Task; + } + return Task.CompletedTask; + }; + var run = RunDotnetAsync (arguments, process => processId.TrySetResult (process.Id)); + await querying.Task.WaitAsync (TimeSpan.FromSeconds (5)); + await SendCtrlCAsync (await processId.Task); + + var (exitCode, output, error) = await run.WaitAsync (TimeSpan.FromSeconds (10)); + Assert.AreEqual (130, exitCode, output + error); + StringAssert.Contains ("Stopping application...", output); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app") || c.StartsWith ("logcat ", StringComparison.Ordinal))); + Assert.IsTrue (server.Commands.Any (c => c.StartsWith ("am force-stop ", StringComparison.Ordinal))); + } + + [TestCase (false, false)] + [TestCase (false, true)] + [TestCase (true, true)] + [Category ("ManagedLaunchBoundaryRegression")] + public async Task RunProgramPidPollingIsDebugWaitOnly (bool attachDebugger, bool noWait) + { + await using var server = new LaunchAdbServer { ApiLevel = 30 }; + server.TransformResponse = (command, output) => command.StartsWith ("pidof ", StringComparison.Ordinal) ? "" : output; + var arguments = new List { "--activity", ".MainActivity", "--no-wake-device" }; + if (attachDebugger) + arguments.Add ("--attach-debugger"); + if (noWait) + arguments.Add ("--no-wait"); + + var (exitCode, output, error) = await RunProgramAsync (server, arguments.ToArray ()); + + Assert.AreEqual (noWait ? 0 : 1, exitCode, output + error); + Assert.AreEqual (noWait ? 0 : 1, server.Commands.Count (c => c.StartsWith ("pidof ", StringComparison.Ordinal))); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app") || c.StartsWith ("logcat ", StringComparison.Ordinal))); + } + + [Test] + public async Task RunProgramInstrumentationIgnoresActivityDebugIntent () + { + await using var server = new LaunchAdbServer (); + var (exitCode, output, error) = await RunProgramAsync (server, + "--instrument", "runner", "--attach-debugger", "--no-wait", "--forward-port", "10000:10000"); + + Assert.AreEqual (0, exitCode, output + error); + Assert.IsTrue (server.Commands.Any (c => c.StartsWith ("am instrument ", StringComparison.Ordinal))); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app") || c.StartsWith ("am start ", StringComparison.Ordinal))); + } + + [Test] + public async Task RunProgramDotnetTestDispatchDoesNotBecomeAnActivityLaunch () + { + await using var server = new LaunchAdbServer (); + var (exitCode, output, error) = await RunProgramAsync (server, + "--instrument", "runner", "--server", "dotnettestcli", "--attach-debugger", "--no-wait"); + + // No test pipe is provided: stop at the real MTP entry point without + // starting a test host or replacing it with an activity launch. + Assert.AreEqual (1, exitCode, output + error); + StringAssert.Contains ("--dotnet-test-pipe", error); + Assert.IsEmpty (server.Commands); + } + + [TestCase ("Error: Activity not started, primary failure\n")] + [TestCase ("Starting: Intent { cmp=com.example.managed/.MainActivity }\njava.lang.SecurityException: Permission Denial\n")] + public async Task RunProgramPropagatesLaunchErrorsAndCleans (string output) + { + await using var server = new LaunchAdbServer (); + server.TransformResponse = (command, result) => command.StartsWith ("am start ", StringComparison.Ordinal) ? output : result; + var (exitCode, _, error) = await RunProgramAsync (server, "--activity", ".MainActivity", "--attach-debugger", "--no-wait"); + + Assert.AreEqual (1, exitCode); + StringAssert.Contains (output.Trim (), error); + Assert.IsNull (server.DebugApp); + CollectionAssert.Contains (server.Commands, "am clear-debug-app"); + } + + [Test] + [Category ("ManagedLaunchBoundaryRegression")] + public async Task RunProgramStartWarningsOnStderrAreSuccessful ( + [Values ("Warning: Activity not started, intent has been delivered to currently running top-most instance.\n", + "Warning: Activity not started, its current task has been brought to the front\n")] string warning, + [Values] bool fallback) + { + await using var server = new LaunchAdbServer { ApiLevel = fallback ? 30 : 36 }; + server.CliResult = command => command.StartsWith ("am start ", StringComparison.Ordinal) ? (0, warning) : (0, ""); + + var (exitCode, output, error) = await RunProgramAsync (server, + "--activity", ".MainActivity", "--attach-debugger", "--no-wait", "--no-wake-device"); + + Assert.AreEqual (0, exitCode, output + error); + StringAssert.Contains ("Stopping: com.example.managed", output); + StringAssert.Contains ("Starting: Intent {", output); + StringAssert.Contains (warning.Trim (), output + error); + Assert.AreEqual (!fallback, server.Attached); + Assert.IsNull (server.DebugApp); + Assert.AreEqual (!fallback, server.Commands.Contains ("am clear-debug-app")); + } + + [Test] + [Category ("ManagedLaunchBoundaryRegression")] + public async Task RunProgramStartErrorsOnStderrRemainFailures ( + [Values ("Error: Permission denied\n", "Exception occurred while executing 'start':\njava.lang.SecurityException\n", + "java.lang.SecurityException: Permission Denial\n")] string diagnostic, + [Values] bool fallback) + { + await using var server = new LaunchAdbServer { ApiLevel = fallback ? 30 : 36 }; + server.CliResult = command => command.StartsWith ("am start ", StringComparison.Ordinal) ? (0, diagnostic) : (0, ""); + + var (exitCode, output, error) = await RunProgramAsync (server, + "--activity", ".MainActivity", "--attach-debugger", "--no-wait", "--no-wake-device"); + + Assert.AreEqual (1, exitCode, output + error); + StringAssert.Contains (diagnostic.Trim (), error); + Assert.IsNull (server.DebugApp); + Assert.AreEqual (!fallback, server.Commands.Contains ("am clear-debug-app")); + } + + [Test] + [Category ("ManagedLaunchBoundaryRegression")] + public async Task RunProgramStartNonzeroExitIsFailure () + { + await using var server = new LaunchAdbServer (); + server.CliResult = command => command.StartsWith ("am start ", StringComparison.Ordinal) + ? (1, "Warning: Activity not started, its current task has been brought to the front\n") + : (0, ""); + + var (exitCode, output, error) = await RunProgramAsync (server, + "--activity", ".MainActivity", "--attach-debugger", "--no-wait", "--no-wake-device"); + + Assert.AreEqual (1, exitCode, output + error); + StringAssert.Contains ("exit code 1", error); + Assert.IsNull (server.DebugApp); + CollectionAssert.Contains (server.Commands, "am clear-debug-app"); + } + + [TestCase ("am set-debug-app 'com.example.managed'")] + [TestCase ("am clear-debug-app")] + [Category ("ManagedLaunchBoundaryRegression")] + public async Task RunProgramMutationStderrRemainsFailure (string command) + { + await using var server = new LaunchAdbServer (); + const string diagnostic = "Warning: unexpected mutation output\n"; + server.CliResult = value => value == command ? (0, diagnostic) : (0, ""); + + var (exitCode, output, error) = await RunProgramAsync (server, + "--activity", ".MainActivity", "--attach-debugger", "--no-wait", "--no-wake-device"); + + Assert.AreEqual (1, exitCode, output + error); + StringAssert.Contains (diagnostic.Trim (), error); + CollectionAssert.Contains (server.Commands, command); + } + + [TestCase ("getprop ro.build.version.sdk")] + [TestCase ("dumpsys activity processes")] + [Category ("ManagedLaunchBoundaryRegression")] + public async Task RunProgramQueryStderrDoesNotFallback (string command) + { + await using var server = new LaunchAdbServer (); + const string diagnostic = "Error: Permission denied\n"; + server.CliResult = value => value == command ? (0, diagnostic) : (0, ""); + + var (exitCode, output, error) = await RunProgramAsync (server, + "--activity", ".MainActivity", "--attach-debugger", "--no-wait", "--no-wake-device"); + + Assert.AreEqual (1, exitCode, output + error); + StringAssert.Contains (diagnostic.Trim (), error); + Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am ", StringComparison.Ordinal))); + } + + [TestCase ("get-serialno")] + [TestCase ("getprop ro.build.version.sdk")] + [TestCase ("dumpsys activity processes")] + public async Task RunProgramTransportFailureDoesNotFallback (string command) + { + await using var server = new LaunchAdbServer { FailTransportCommand = command }; + var (exitCode, _, error) = await RunProgramAsync (server, "--activity", ".MainActivity", "--attach-debugger", "--no-wait"); + + Assert.AreEqual (1, exitCode); + StringAssert.Contains ("simulated transport fail", error); + Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am ", StringComparison.Ordinal))); + } + + [TestCase (".Outer$Inner")] + [TestCase (".Activity'Literal")] + public async Task RunProgramQuotesTheDeviceComponent (string activity) + { + await using var server = new LaunchAdbServer (); + // Force a metadata fallback: even that path must quote for the device + // shell, not just ProcessStartInfo's host-side argument parser. + server.TransformResponse = (command, output) => command.StartsWith ("pm resolve-activity ", StringComparison.Ordinal) ? "" : output; + var (exitCode, output, error) = await RunProgramAsync (server, + "--activity", activity, "--attach-debugger", "--no-wait", "--no-wake-device"); + + Assert.AreEqual (0, exitCode, output + error); + var literal = "'" + PackageName + "/" + activity.Replace ("'", "'\\''") + "'"; + CollectionAssert.Contains (server.Commands, $"pm resolve-activity --user '0' -n {literal}"); + CollectionAssert.Contains (server.Commands, $"am start -S -n {literal}"); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + } + + [Test] + public async Task RunArgumentsCarryOnlyExplicitActivityDebugIntent ( + [Values ("", "false", "TrUe")] string attachDebugger, + [Values] bool debuggerServer, + [Values] bool instrumentation) + { + var directory = Path.Combine (Path.GetTempPath (), $"managed-launch-msbuild-{Guid.NewGuid ():N}"); + Directory.CreateDirectory (directory); + var project = Path.Combine (directory, "run.proj"); + try { + // Import the shipping target, not a test-side copy of its conditions. + new XDocument (new XElement ("Project", + new XElement ("PropertyGroup", + new XElement ("_XamarinAndroidBuildTasksAssembly", typeof (RunActivity).Assembly.Location), + new XElement ("PrepTasksAssembly", typeof (RunActivity).Assembly.Location)), + new XElement ("Import", new XAttribute ("Project", Path.Combine (TestContext.CurrentContext.TestDirectory, "Microsoft.Android.Sdk.Application.targets"))), + new XElement ("PropertyGroup", + new XElement ("_AndroidComputeRunArgumentsDependsOn", "TestNoOp"), + new XElement ("_AndroidPackage", PackageName), + new XElement ("AndroidLaunchActivity", ".MainActivity"), + new XElement ("AndroidInstrumentation", instrumentation ? "runner" : ""), + new XElement ("AndroidAttachDebugger", attachDebugger), + new XElement ("AndroidDebuggerServer", debuggerServer), + new XElement ("_AndroidRunAttachDebuggerArg", "--attach-debugger"), + new XElement ("Configuration", "Debug"), + new XElement ("WaitForExit", "false")), + new XElement ("Target", new XAttribute ("Name", "TestNoOp")), + new XElement ("Target", new XAttribute ("Name", "ComputeRunArguments")))).Save (project); + var (exitCode, arguments, error) = await RunDotnetAsync ( + "msbuild", project, "-nologo", "-t:_AndroidComputeRunArguments", "-getProperty:RunArguments"); + + Assert.AreEqual (0, exitCode, arguments + error); + bool debug = string.Equals (attachDebugger, "true", StringComparison.OrdinalIgnoreCase); + Assert.AreEqual (debug && !instrumentation, arguments.Contains ("--attach-debugger"), arguments); + Assert.AreEqual (debug && debuggerServer, arguments.Contains ("--forward-port"), arguments); + Assert.AreEqual (instrumentation, arguments.Contains ("--instrument"), arguments); + Assert.AreEqual (!instrumentation, arguments.Contains ("--activity"), arguments); + StringAssert.Contains ("--no-wait", arguments); + } finally { + File.Delete (project); + Directory.Delete (directory); + } + } + + [TestCase (false, false)] + [TestCase (false, true)] + [TestCase (true, false)] + [TestCase (true, true)] + public async Task RunActivityProtectsOnlyManagedDebugLaunches (bool attachDebugger, bool allowJavaDebugging) + { + await using var server = new LaunchAdbServer (); + var task = CreateRunActivity (server); + task.AttachDebugger = attachDebugger; + task.AllowJavaDebugging = allowJavaDebugging; + + Assert.IsTrue (task.Execute ()); + + Assert.AreEqual (attachDebugger && !allowJavaDebugging, + server.Commands.Any (c => c.StartsWith ("am set-debug-app ", StringComparison.Ordinal))); + Assert.IsNull (server.DebugApp); + Assert.IsTrue (server.Commands.Any (c => c.StartsWith ("am start ", StringComparison.Ordinal))); + Assert.AreEqual (attachDebugger, server.Commands.Contains ("date +%s"), + "Managed debugger properties must still be prepared on both debug paths."); + if (attachDebugger && !allowJavaDebugging) { + Assert.IsTrue (server.Attached); + Assert.IsFalse (server.Commands.Any (c => c.Contains (" -D") || c.Contains (" -W") || c.StartsWith ("ps", StringComparison.Ordinal))); + var start = server.Commands.Single (c => c.StartsWith ("am start ", StringComparison.Ordinal)); + StringAssert.Contains ("-a android.intent.action.MAIN -c android.intent.category.LAUNCHER", start); + } + } + + [Test] + public async Task RunActivityExplicitJavaDebuggingStillUsesDAndJdwp () + { + await using var server = new LaunchAdbServer ("emulator-5554"); + var task = CreateRunActivity (server); + task.AttachDebugger = true; + task.AllowJavaDebugging = true; + var cancelled = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = task.CancellationToken.Register (() => cancelled.TrySetResult ()); + server.BeforeResponse = command => { + if (command.StartsWith ("ps", StringComparison.Ordinal)) { + // Confirm cancellation before allowing PID discovery to finish: + // the legacy JDWP forwarder otherwise uses AdbServer.Default. + ((ICancelableTask) task).Cancel (); + return cancelled.Task; + } + return Task.CompletedTask; + }; + try { + await Task.Run (() => task.Execute ()).WaitAsync (TimeSpan.FromSeconds (8)); + await task.Finished.Task.WaitAsync (TimeSpan.FromSeconds (5)); + Assert.IsTrue (server.Commands.Any (c => c.StartsWith ("am start ", StringComparison.Ordinal) && c.Contains (" -D"))); + Assert.IsTrue (server.Commands.Any (c => c.StartsWith ("ps", StringComparison.Ordinal))); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + } finally { + ((AsyncTask) task).Cancel (); + } + } + + [TestCase ("warm")] + [TestCase ("older-api")] + [TestCase ("multiple-users")] + [TestCase ("different-user")] + [TestCase ("custom-process")] + [TestCase ("unsupported-layout")] + public async Task RunActivityUnsupportedLaunchesRetainLegacyBehavior (string reason) + { + await using var server = new LaunchAdbServer (); + var messages = new List (); + var task = CreateRunActivity (server, messages: messages); + task.AttachDebugger = true; + task.AllowJavaDebugging = false; + task.ForceStop = reason != "warm"; + if (reason == "older-api") + server.ApiLevel = 30; + if (reason == "multiple-users") + server.UserList += "\tUserInfo{10:Work:30} running\n"; + if (reason == "different-user") + task.UserID = 10; + if (reason == "custom-process") + server.EffectiveProcessName = PackageName + ":custom"; + if (reason == "unsupported-layout") + server.TransformResponse = (command, output) => command == "dumpsys activity processes" ? output + " vendor postamble\n" : output; + + Assert.IsTrue (task.Execute ()); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + var expected = new AmStartCommand (PackageName, ".MainActivity") { + ForceStop = task.ForceStop, + User = task.UserID.ToString (CultureInfo.InvariantCulture), + Action = "android.intent.action.MAIN", + Categories = new [] { "android.intent.category.LAUNCHER" }, + }; + CollectionAssert.Contains (server.Commands, expected.ToString ()); + Assert.IsTrue (messages.Any (m => m.Message.Contains ("Launching without"))); + } + + [TestCase ("Error: Activity class {com.example.managed/.MainActivity} does not exist.\n", true)] + [TestCase ("Error: primary launch failure\n", false)] + public async Task RunActivityPreservesTypedDiagnosticsAndCleans (string diagnostic, bool notFound) + { + await using var server = new LaunchAdbServer (); + var errors = new List (); + var task = CreateRunActivity (server, errors: errors); + task.AttachDebugger = true; + task.AllowJavaDebugging = false; + server.TransformResponse = (command, output) => command.StartsWith ("am start ", StringComparison.Ordinal) ? output + diagnostic : output; + + Assert.IsFalse (task.Execute ()); + Assert.IsTrue (errors.Any (e => e.Code.StartsWith ("XARUNA", StringComparison.Ordinal))); + Assert.IsTrue (errors.Any (e => e.Message.Contains (notFound ? "ActivityNotFoundException" : "primary launch failure"))); + Assert.IsNull (server.DebugApp); + CollectionAssert.Contains (server.Commands, "am clear-debug-app"); + } + + [TestCase ("arm")] + [TestCase ("cleanup")] + [Category ("ManagedLaunchBoundaryRegression")] + public async Task RunActivityPrivateBudgetTimeoutIsFailure (string boundary) + { + await using var server = new LaunchAdbServer (); + var errors = new List (); + var task = CreateRunActivity (server, errors: errors); + task.AttachDebugger = true; + task.AllowJavaDebugging = false; + var pending = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var command = boundary == "arm" ? "am set-debug-app 'com.example.managed'" : "am clear-debug-app"; + server.BeforeResponse = value => value == command ? pending.Task : Task.CompletedTask; + + Assert.IsFalse (await Task.Run (() => task.Execute ()).WaitAsync (TimeSpan.FromSeconds (15))); + Assert.IsTrue (errors.Any (e => e.Code == "XARUNA7017" && e.Message.Contains ("Timed out"))); + Assert.IsFalse (errors.Any (e => e.Code == "XARUNA7012" || e.Code == "XARUNA7013")); + Assert.AreEqual (boundary == "cleanup", server.Attached); + } + + [Test] + public async Task RunActivityReportsBothPrimaryAndCleanupErrors () + { + await using var server = new LaunchAdbServer (); + var errors = new List (); + var task = CreateRunActivity (server, errors: errors); + task.AttachDebugger = true; + task.AllowJavaDebugging = false; + server.TransformResponse = (command, output) => command.StartsWith ("am start ", StringComparison.Ordinal) + ? "Error: primary launch failure" + : command == "am clear-debug-app" ? "cleanup failure" : output; + + Assert.IsFalse (task.Execute ()); + Assert.IsTrue (errors.Any (e => e.Message.Contains ("primary launch failure"))); + Assert.IsTrue (errors.Any (e => e.Message.Contains ("Failed to clean up") && e.Message.Contains ("cleanup failure"))); + } + + [Test] + public async Task RunActivityCancellationDrainsMutationBeforeReturning () + { + await using var server = new LaunchAdbServer (); + var task = CreateRunActivity (server); + task.AttachDebugger = true; + task.AllowJavaDebugging = false; + var arming = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + server.BeforeResponse = command => { + if (command.StartsWith ("am set-debug-app ", StringComparison.Ordinal)) { + arming.TrySetResult (); + return release.Task; + } + return Task.CompletedTask; + }; + var launch = Task.Run (() => task.Execute ()); + try { + await arming.Task.WaitAsync (TimeSpan.FromSeconds (5)); + ((ICancelableTask) task).Cancel (); + await Task.WhenAny (launch, Task.Delay (100)); + Assert.IsFalse (launch.IsCompleted, "MSBuild must not return success or abandon mutation cleanup on cancellation."); + Assert.IsFalse (server.Commands.Contains ("am clear-debug-app"), "Cleanup cannot overtake the in-flight set-debug-app reply."); + release.TrySetResult (); + Assert.IsFalse (await launch.WaitAsync (TimeSpan.FromSeconds (8))); + Assert.IsNull (server.DebugApp); + Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am start ", StringComparison.Ordinal))); + } finally { + release.TrySetResult (); + await task.Finished.Task.WaitAsync (TimeSpan.FromSeconds (8)); + } + } + + [TestCase ("warm")] + [TestCase ("custom-process")] + [TestCase ("unsupported-layout")] + public async Task RunActivityFallbackCancellationIsNotSuccess (string reason) + { + await using var server = new LaunchAdbServer (); + var task = CreateRunActivity (server); + task.AttachDebugger = true; + task.AllowJavaDebugging = false; + task.ForceStop = reason != "warm"; + if (reason == "custom-process") + server.EffectiveProcessName = PackageName + ":custom"; + if (reason == "unsupported-layout") + server.TransformResponse = (command, output) => command == "dumpsys activity processes" ? output + " vendor postamble\n" : output; + var starting = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + server.BeforeResponse = command => { + if (command.StartsWith ("am start ", StringComparison.Ordinal)) { + starting.TrySetResult (); + return release.Task; + } + return Task.CompletedTask; + }; + var launch = Task.Run (() => task.Execute ()); + try { + await starting.Task.WaitAsync (TimeSpan.FromSeconds (5)); + ((ICancelableTask) task).Cancel (); + Assert.IsFalse (await launch.WaitAsync (TimeSpan.FromSeconds (7))); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + } finally { + release.TrySetResult (); + await task.Finished.Task.WaitAsync (TimeSpan.FromSeconds (8)); + } + } + + [Test] + public async Task LegacyDebuggingLibraryDoesNotOwnManagedProtection () + { + await using var server = new LaunchAdbServer (); + var configuration = new ExecutionConfiguration (PackageName, new AmStartCommand (PackageName, ".MainActivity") { + ForceStop = true, + }) { + AllowJavaDebugging = false, + }; + + await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app")), + "The deprecated library must retain its pre-PR behavior, not implement the new transaction."); + } + + static ObservedRunActivity CreateRunActivity (LaunchAdbServer server, IList errors = null, IList messages = null) + { + var target = "-s " + server.Serial; + IBuildEngine4 engine = new MockBuildEngine (TestContext.Out, errors: errors, messages: messages); + // Use the task's existing per-build device cache, never AdbServer.Default. + engine.RegisterTaskObjectAssemblyLocal ( + Tuple.Create ("AndroidHelper_AndroidDevice", target), + server.Device, RegisteredTaskObjectLifetime.Build); + return new ObservedRunActivity { + BuildEngine = engine, + AdbTarget = target, + PackageName = PackageName, + ActivityName = ".MainActivity", + Server = true, + }; + } + + sealed class ObservedRunActivity : RunActivity + { + internal TaskCompletionSource Finished { get; } = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + + public override async Task RunTaskAsync () + { + try { + await base.RunTaskAsync (); + } finally { + Finished.TrySetResult (); + } + } + } + + static Task<(int ExitCode, string Output, string Error)> RunProgramAsync (LaunchAdbServer server, params string [] arguments) => + RunProgramAsync (new [] { "--adb", server.CreateFakeAdb (), "--adb-target", "-d", "--package", PackageName }.Concat (arguments).ToArray ()); + + static Task<(int ExitCode, string Output, string Error)> RunProgramAsync (params string [] arguments) + { + var program = Assembly.Load ("Microsoft.Android.Run").Location; + return RunDotnetAsync (new [] { program }.Concat (arguments).ToArray ()); + } + + static async Task SendCtrlCAsync (int processId) + { + var signal = ProcessUtils.CreateProcessStartInfo ("/bin/kill", "-INT", processId.ToString (CultureInfo.InvariantCulture)); + using var error = new StringWriter (); + using var timeout = new CancellationTokenSource (TimeSpan.FromSeconds (5)); + Assert.AreEqual (0, await ProcessUtils.StartProcess (signal, TextWriter.Null, error, timeout.Token), error.ToString ()); + } + + static Task<(int ExitCode, string Output, string Error)> RunDotnetAsync (params string [] arguments) => + RunDotnetAsync (arguments, onStarted: null); + + static async Task<(int ExitCode, string Output, string Error)> RunDotnetAsync (string [] arguments, Action onStarted) + { + var dotnet = Environment.GetEnvironmentVariable ("DOTNET_HOST_PATH") ?? "dotnet"; + var psi = ProcessUtils.CreateProcessStartInfo (dotnet, arguments); + using var output = new StringWriter (); + using var error = new StringWriter (); + using var timeout = new CancellationTokenSource (TimeSpan.FromSeconds (45)); + var exitCode = await ProcessUtils.StartProcess (psi, output, error, timeout.Token, onStarted: onStarted); + return (exitCode, output.ToString (), error.ToString ()); + } +} diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/ManagedActivityLaunchTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/ManagedActivityLaunchTests.cs index e4b498bd8c5..703001be6d2 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/ManagedActivityLaunchTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/ManagedActivityLaunchTests.cs @@ -15,6 +15,7 @@ using Mono.AndroidTools; using NUnit.Framework; using Xamarin.AndroidTools.Debugging; +using ManagedActivityLaunch = Microsoft.Android.Run.ManagedActivityLaunch; namespace Xamarin.Android.Tools.Tests; @@ -35,35 +36,38 @@ static ExecutionConfiguration Configuration (bool forceStop = true) return configuration; } + static Task LaunchAsync (AndroidDevice device, ExecutionConfiguration configuration, CancellationToken token) + { + var command = configuration.RunCommand as AmStartCommand; + Assert.IsNotNull (command); + // Exercise the implementation compiled into the real run executable; only + // transport/setup/fallback callbacks come from this private ADB fixture. + return ManagedActivityLaunch.RunAsync ( + device.ID, configuration.PackageName, command.Component, command.User, command.ForceStop, + command.ToString (), configuration.Debugger.Timeout, + prepare: t => device.SetDebugPropertiesAsync (configuration.PackageName, configuration.Debugger, t), + runShellCommand: device.RunShellCommand, + launchUnprotected: t => device.ExecuteIntentCommandAsync (command, configuration.LogWiter, t), + log: message => configuration.LogWiter?.Invoke (message), + logCleanupError: AndroidLogger.LogError, + token: token); + } + [Test] public async Task ManagedLaunchArmsWithoutJavaWaitAndCleansAfterAttach () { await using var server = new LaunchAdbServer (); - await server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None); + var processName = await LaunchAsync (server.Device, Configuration (), CancellationToken.None); + Assert.AreEqual (PackageName, processName); var commands = server.Commands.ToArray (); - CollectionAssert.Contains (commands, "am set-debug-app \"com.example.managed\""); + CollectionAssert.Contains (commands, "am set-debug-app 'com.example.managed'"); Assert.IsFalse (commands.Any (c => c.Contains ("-w") || c.Contains ("--persistent") || c.Contains (" -D"))); Assert.Greater (Array.LastIndexOf (commands, "am clear-debug-app"), Array.FindIndex (commands, c => c.StartsWith ("am start ", StringComparison.Ordinal))); Assert.IsTrue (server.Attached); Assert.IsNull (server.DebugApp); } - [Test] - public async Task DisallowJavaDebuggingClearsReusedCommandFlag () - { - await using var server = new LaunchAdbServer (); - var configuration = Configuration (); - var command = configuration.RunCommand as AmStartCommand; - Assert.IsNotNull (command); - command.EnableDebugging = true; - - await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); - - Assert.IsFalse (command.EnableDebugging); - Assert.IsFalse (server.Commands.Any (c => c.Contains (" -D"))); - } - [Test] public async Task VisiblePidDoesNotAllowCleanupBeforeAttach () { @@ -77,7 +81,7 @@ public async Task VisiblePidDoesNotAllowCleanupBeforeAttach () } return output; }; - var launch = server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None); + var launch = LaunchAsync (server.Device, Configuration (), CancellationToken.None); await observedPid.Task.WaitAsync (TimeSpan.FromSeconds (5)); Assert.IsFalse (launch.IsCompleted); Assert.AreEqual (PackageName, server.DebugApp); @@ -98,7 +102,7 @@ public async Task LaunchTextErrorsArePropagatedAndCleaned (string error) { await using var server = new LaunchAdbServer (); server.TransformResponse = (command, output) => command.StartsWith ("am start ", StringComparison.Ordinal) ? error : output; - Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + Assert.CatchAsync (() => LaunchAsync (server.Device, Configuration (), CancellationToken.None)); Assert.IsNull (server.DebugApp); CollectionAssert.Contains (server.Commands, "am clear-debug-app"); } @@ -114,7 +118,7 @@ public async Task DiagnosticWordsInSuccessfulIntentAreNotLaunchErrors (string ac server.TransformResponse = (command, output) => command.StartsWith ("am start ", StringComparison.Ordinal) ? $"Starting: Intent {{ dat={uri} cmp={configuration.RunCommand.Component} }}\n" : output; - await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + await LaunchAsync (server.Device, configuration, CancellationToken.None); Assert.IsTrue (server.Attached); Assert.IsNull (server.DebugApp); } @@ -128,7 +132,7 @@ public async Task ForceStopPreambleAndSuccessfulWarningsAreAccepted (string warn { await using var server = new LaunchAdbServer (); server.TransformResponse = (command, output) => command.StartsWith ("am start ", StringComparison.Ordinal) ? output + warning : output; - await server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None); + await LaunchAsync (server.Device, Configuration (), CancellationToken.None); Assert.IsTrue (server.Attached); Assert.IsNull (server.DebugApp); } @@ -140,9 +144,9 @@ public async Task ForceStopPreambleDoesNotHideLaterErrors (string diagnostic, bo { await using var server = new LaunchAdbServer (); server.TransformResponse = (command, output) => command.StartsWith ("am start ", StringComparison.Ordinal) ? output + diagnostic : output; - var error = Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + var error = Assert.CatchAsync (() => LaunchAsync (server.Device, Configuration (), CancellationToken.None)); if (notFound) - Assert.IsInstanceOf (error); + Assert.IsTrue (error.ActivityNotFound); else StringAssert.Contains (diagnostic.Trim (), error.Message); Assert.IsNull (server.DebugApp); @@ -169,7 +173,8 @@ public async Task CustomProcessRetainsUnprotectedLaunch (string declaration, str server.TransformResponse = (command, output) => command.StartsWith ("am start ", StringComparison.Ordinal) ? output.Replace ("Stopping: com.example.managed\n", "") : output; - await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + var processName = await LaunchAsync (server.Device, configuration, CancellationToken.None); + Assert.AreEqual (effectiveProcess, processName); CollectionAssert.Contains (server.Commands, configuration.RunCommand.ToString (), declaration); Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app")), declaration); Assert.IsTrue (messages.Any (m => m.Contains ("process")), declaration); @@ -179,9 +184,10 @@ public async Task CustomProcessRetainsUnprotectedLaunch (string declaration, str public async Task ActivityCanOverrideCustomApplicationProcessBackToPackage () { await using var server = new LaunchAdbServer { ApplicationProcessName = "com.example.managed:app" }; - await server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None); - CollectionAssert.Contains (server.Commands, "pm resolve-activity --user \"0\" -n \"com.example.managed/.MainActivity\""); - CollectionAssert.Contains (server.Commands, "am set-debug-app \"com.example.managed\""); + var processName = await LaunchAsync (server.Device, Configuration (), CancellationToken.None); + Assert.AreEqual (PackageName, processName); + CollectionAssert.Contains (server.Commands, "pm resolve-activity --user '0' -n 'com.example.managed/.MainActivity'"); + CollectionAssert.Contains (server.Commands, "am set-debug-app 'com.example.managed'"); Assert.IsTrue (server.Attached); } @@ -196,7 +202,8 @@ public async Task UnconfirmedProcessMetadataDoesNotArm (string metadata) var messages = new List (); configuration.LogWiter = messages.Add; server.TransformResponse = (command, output) => command.StartsWith ("pm resolve-activity ", StringComparison.Ordinal) ? metadata : output; - await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + var processName = await LaunchAsync (server.Device, configuration, CancellationToken.None); + Assert.IsNull (processName, "Unconfirmed metadata must not produce a guessed process identity."); Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); CollectionAssert.Contains (server.Commands, configuration.RunCommand.ToString ()); Assert.IsTrue (messages.Any (m => m.Contains ("could not be confirmed"))); @@ -212,7 +219,7 @@ public async Task CancellationDuringProcessResolutionDoesNotLaunchOrArm () cancellation.Cancel (); return output; }; - Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), cancellation.Token)); + Assert.CatchAsync (() => LaunchAsync (server.Device, Configuration (), cancellation.Token)); Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am ", StringComparison.Ordinal))); } @@ -220,9 +227,9 @@ public async Task CancellationDuringProcessResolutionDoesNotLaunchOrArm () public async Task ResolverTransportFailureIsPropagatedBeforeArming () { await using var server = new LaunchAdbServer { - FailTransportCommand = "pm resolve-activity --user \"0\" -n \"com.example.managed/.MainActivity\"", + FailTransportCommand = "pm resolve-activity --user '0' -n 'com.example.managed/.MainActivity'", }; - var error = Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + var error = Assert.CatchAsync (() => LaunchAsync (server.Device, Configuration (), CancellationToken.None)); StringAssert.Contains ("simulated transport fail", error.ToString ()); Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am ", StringComparison.Ordinal))); } @@ -233,7 +240,7 @@ public async Task LaunchTransportFailureIsNotSuccess () await using var server = new LaunchAdbServer (); var configuration = Configuration (); server.FailTransportCommand = configuration.RunCommand.ToString (); - var error = Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None)); + var error = Assert.CatchAsync (() => LaunchAsync (server.Device, configuration, CancellationToken.None)); StringAssert.Contains ("simulated transport fail", error.ToString ()); Assert.IsNull (server.DebugApp); CollectionAssert.Contains (server.Commands, "am clear-debug-app"); @@ -245,11 +252,12 @@ public async Task AttachTimeoutCleansAndAllowsRetry () await using var server = new LaunchAdbServer { AttachOnDump = false }; var configuration = Configuration (); configuration.Debugger.Timeout = TimeSpan.FromMilliseconds (150); - Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None)); + var error = Assert.ThrowsAsync (() => LaunchAsync (server.Device, configuration, CancellationToken.None)); + Assert.IsInstanceOf (error.GetBaseException (), "The task host must classify the deadline as a timeout."); Assert.IsNull (server.DebugApp); server.AttachOnDump = true; configuration.Debugger.Timeout = TimeSpan.FromSeconds (2); - await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + await LaunchAsync (server.Device, configuration, CancellationToken.None); Assert.IsNull (server.DebugApp); Assert.AreEqual (2, server.Commands.Count (c => c.StartsWith ("am set-debug-app", StringComparison.Ordinal))); } @@ -274,7 +282,7 @@ public async Task ConsumptionRequiresMatchingDebuggingProcessAndGlobalState (str }; var configuration = Configuration (); configuration.Debugger.Timeout = TimeSpan.FromMilliseconds (150); - Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None)); + Assert.ThrowsAsync (() => LaunchAsync (server.Device, configuration, CancellationToken.None)); } [TestCase (0)] @@ -284,12 +292,12 @@ public async Task UnboundedOrZeroTimeoutCannotArm (int milliseconds) await using var server = new LaunchAdbServer (); var configuration = Configuration (); configuration.Debugger.Timeout = TimeSpan.FromMilliseconds (milliseconds); - Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None)); + Assert.ThrowsAsync (() => LaunchAsync (server.Device, configuration, CancellationToken.None)); Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); } [TestCase ("pm list users")] - [TestCase ("am set-debug-app \"com.example.managed\"")] + [TestCase ("am set-debug-app 'com.example.managed'")] [TestCase ("armed-state")] [TestCase ("launch")] [TestCase ("attached-state")] @@ -306,7 +314,7 @@ public async Task CancellationAtTransactionBoundariesUsesIndependentCleanup (str cancellation.Cancel (); return output; }; - Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), cancellation.Token)); + Assert.CatchAsync (() => LaunchAsync (server.Device, Configuration (), cancellation.Token)); Assert.IsNull (server.DebugApp); if (boundary != "pm list users") CollectionAssert.Contains (server.Commands, "am clear-debug-app"); @@ -318,7 +326,7 @@ public async Task AlreadyCanceledLaunchDoesNotTouchDevice () await using var server = new LaunchAdbServer (); using var cancellation = new CancellationTokenSource (); cancellation.Cancel (); - Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), cancellation.Token)); + Assert.CatchAsync (() => LaunchAsync (server.Device, Configuration (), cancellation.Token)); Assert.IsEmpty (server.Commands); } @@ -341,7 +349,7 @@ public async Task CleanupFailurePreservesPrimaryErrorAndIsReported (bool changeO return "cleanup failure"; return output; }; - var error = Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + var error = Assert.ThrowsAsync (() => LaunchAsync (server.Device, Configuration (), CancellationToken.None)); StringAssert.Contains ("primary launch failure", error.Message); Assert.IsTrue (errors.Any (e => e.Contains ("Failed to clean up"))); if (changeOwner) { @@ -358,7 +366,7 @@ public async Task CleanupFailureAfterSuccessFailsLaunch () { await using var server = new LaunchAdbServer (); server.TransformResponse = (command, output) => command == "am clear-debug-app" ? "cleanup failure" : output; - var error = Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + var error = Assert.ThrowsAsync (() => LaunchAsync (server.Device, Configuration (), CancellationToken.None)); Assert.AreEqual ("cleanup failure", error.Message); } @@ -369,24 +377,24 @@ public async Task CleanupFailureAfterSuccessFailsLaunch () public async Task MalformedUsersCannotMasqueradeAsSingleUser (string users) { await using var server = new LaunchAdbServer { UserList = users }; - Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + Assert.ThrowsAsync (() => LaunchAsync (server.Device, Configuration (), CancellationToken.None)); Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am ", StringComparison.Ordinal))); } [TestCase ("ACTIVITY MANAGER RUNNING PROCESSES (dumpsys activity processes)\n mDebugApp=broken\n mForceBackgroundCheck=false\n")] + [TestCase ("ACTIVITY MANAGER RUNNING PROCESSES (dumpsys activity processes)\n mDebugApp=com.example.managed/orig=null mDebugTransient=true mOrigWaitForDebugger=false\n mDebugApp=broken\n mForceBackgroundCheck=false\n")] + [TestCase ("ACTIVITY MANAGER RUNNING PROCESSES (dumpsys activity processes)\n mDebugApp=com.example.managed/orig=null mDebugTransient=true mOrigWaitForDebugger=false\n mDebugApp=com.example.other/orig=null mDebugTransient=true mOrigWaitForDebugger=false\n mForceBackgroundCheck=false\n")] public async Task MalformedDumpCannotMasqueradeAsUnowned (string dump) { await using var server = new LaunchAdbServer (); server.TransformResponse = (command, output) => command == "dumpsys activity processes" ? dump : output; - Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + Assert.ThrowsAsync (() => LaunchAsync (server.Device, Configuration (), CancellationToken.None)); Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am ", StringComparison.Ordinal))); } [TestCase ("warm")] [TestCase ("multiple-users")] [TestCase ("different-user")] - [TestCase ("wait")] - [TestCase ("repeat")] [TestCase ("older-api")] public async Task UnsupportedLaunchesPreserveCommandWithoutDebugAppMutation (string reason) { @@ -398,14 +406,12 @@ public async Task UnsupportedLaunchesPreserveCommandWithoutDebugAppMutation (str server.UserList += "\tUserInfo{10:Work:30} running\n"; if (reason == "different-user") command.User = "10"; - command.Wait = reason == "wait"; - command.Repeat = reason == "repeat" ? 2 : 0; if (reason == "older-api") server.ApiLevel = 30; var expected = command.ToString (); var messages = new List (); configuration.LogWiter = messages.Add; - await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + await LaunchAsync (server.Device, configuration, CancellationToken.None); CollectionAssert.Contains (server.Commands, expected); Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); Assert.IsTrue (messages.Any (m => m.Contains ("Launching without changing"))); @@ -420,7 +426,7 @@ public async Task ComponentAndPackageMustMatchBeforeArming (string package, stri var configuration = Configuration (); configuration.RunCommand.PackageName = package; configuration.RunCommand.Component = component; - Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None)); + Assert.ThrowsAsync (() => LaunchAsync (server.Device, configuration, CancellationToken.None)); Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); } @@ -436,9 +442,9 @@ public async Task ExplicitComponentDoesNotRequireNonEmittedPackageBookkeeping (s ForceStop = true, }; var configuration = new ExecutionConfiguration (PackageName, command) { AllowJavaDebugging = false }; - await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + await LaunchAsync (server.Device, configuration, CancellationToken.None); CollectionAssert.Contains (server.Commands, command.ToString ()); - CollectionAssert.Contains (server.Commands, "am set-debug-app \"com.example.managed\""); + CollectionAssert.Contains (server.Commands, "am set-debug-app 'com.example.managed'"); } [Test] @@ -480,7 +486,7 @@ public async Task FallbackPreservesLaunchAndCancellation ( return Task.CompletedTask; }; try { - var launch = server.Device.StartWithDebuggingAsync (configuration, cancellation.Token); + var launch = LaunchAsync (server.Device, configuration, cancellation.Token); if (boundary == "pending") { await pending.Task.WaitAsync (TimeSpan.FromSeconds (5)); cancellation.Cancel (); @@ -503,7 +509,7 @@ public async Task UnrecognizedInitialLayoutLaunchesWithoutMutation (string dump) { await using var server = new LaunchAdbServer (); server.TransformResponse = (command, output) => command == "dumpsys activity processes" ? dump : output; - await server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None); + await LaunchAsync (server.Device, Configuration (), CancellationToken.None); Assert.IsTrue (server.Commands.Any (c => c.StartsWith ("am start ", StringComparison.Ordinal))); Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); } @@ -525,7 +531,10 @@ public async Task UnsupportedLayoutAfterMutationStillFailsClosed ( : output.Replace (" mForceBackgroundCheck=false", " mDebugApp=broken\n mForceBackgroundCheck=false"); return output; }; - Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + Assert.CatchAsync (() => LaunchAsync (server.Device, Configuration (), CancellationToken.None)); + if (boundary == "stale-clear") + Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am set-debug-app ", StringComparison.Ordinal)), + "After any clear, reject unsupported state before rearming, not only during final cleanup."); } [TestCase (false)] @@ -540,10 +549,10 @@ public async Task InitialDumpTransportOrCancellationDoesNotFallback (bool cancel cancellation.Cancel (); return output; }; - Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), cancellation.Token)); + Assert.CatchAsync (() => LaunchAsync (server.Device, Configuration (), cancellation.Token)); } else { server.FailTransportCommand = "dumpsys activity processes"; - var error = Assert.CatchAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + var error = Assert.CatchAsync (() => LaunchAsync (server.Device, Configuration (), CancellationToken.None)); StringAssert.Contains ("simulated transport fail", error.ToString ()); } Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am ", StringComparison.Ordinal))); @@ -563,11 +572,13 @@ public async Task GateTimeoutDescribesAdmissionRatherThanProcessAttach () } return Task.CompletedTask; }; - var first = server.Device.StartWithDebuggingAsync (Configuration (), cancellation.Token); + var configuration = Configuration (); + configuration.Debugger.Timeout = TimeSpan.FromSeconds (40); + var first = LaunchAsync (server.Device, configuration, cancellation.Token); try { await pending.Task.WaitAsync (TimeSpan.FromSeconds (5)); var error = Assert.ThrowsAsync (async () => - await server.CreateDevice ().StartWithDebuggingAsync (Configuration (), CancellationToken.None).WaitAsync (TimeSpan.FromSeconds (35))); + await LaunchAsync (server.CreateDevice (), Configuration (), CancellationToken.None).WaitAsync (TimeSpan.FromSeconds (35))); StringAssert.Contains ("another activity debug launch", error.Message); Assert.IsFalse (server.Commands.Any (c => c.StartsWith ("am ", StringComparison.Ordinal))); } finally { @@ -588,7 +599,7 @@ public async Task InvalidPackageCannotReachDebugAppCommand (string package) var configuration = new ExecutionConfiguration (package, new AmStartCommand (package, ".Activity") { ForceStop = true }) { AllowJavaDebugging = false, }; - Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None)); + Assert.ThrowsAsync (() => LaunchAsync (server.Device, configuration, CancellationToken.None)); Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); } @@ -655,9 +666,9 @@ public async Task SingleUserCommandRetainsRequestedUser (string user) Assert.IsNotNull (command); command.User = user; var expected = command.ToString (); - await server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + await LaunchAsync (server.Device, configuration, CancellationToken.None); CollectionAssert.Contains (server.Commands, expected); - CollectionAssert.Contains (server.Commands, "am set-debug-app \"com.example.managed\""); + CollectionAssert.Contains (server.Commands, "am set-debug-app 'com.example.managed'"); } [TestCase ("com.example.other", false)] @@ -667,19 +678,34 @@ public async Task PreexistingForeignOrPersistentDebugAppIsNotCleared (string pac { await using var server = new LaunchAdbServer (); server.SetDebugAppState (package, transient); - Assert.ThrowsAsync (() => server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None)); + Assert.ThrowsAsync (() => LaunchAsync (server.Device, Configuration (), CancellationToken.None)); Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); Assert.AreEqual (package, server.DebugApp); } + [TestCase ("com.example.managed", false)] + [TestCase ("com.example.other", false)] + [TestCase (null, true)] + public async Task OriginalDebugAppOrWaitSettingIsNotCleared (string original, bool wait) + { + await using var server = new LaunchAdbServer { OriginalDebugApp = original }; + server.SetDebugAppState (PackageName, true); + if (wait) + server.TransformResponse = (command, output) => output.Replace ("mOrigWaitForDebugger=false", "mOrigWaitForDebugger=true"); + Assert.ThrowsAsync (() => LaunchAsync (server.Device, Configuration (), CancellationToken.None)); + Assert.IsFalse (server.Commands.Any (c => c.Contains ("debug-app"))); + Assert.AreEqual (PackageName, server.DebugApp); + Assert.AreEqual (original, server.OriginalDebugApp); + } + [Test] public async Task MatchingStaleTransientStateIsClearedBeforeRearming () { await using var server = new LaunchAdbServer (); server.SetDebugAppState (PackageName, true); - await server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None); + await LaunchAsync (server.Device, Configuration (), CancellationToken.None); var commands = server.Commands.ToArray (); - Assert.Less (Array.IndexOf (commands, "am clear-debug-app"), Array.IndexOf (commands, "am set-debug-app \"com.example.managed\"")); + Assert.Less (Array.IndexOf (commands, "am clear-debug-app"), Array.IndexOf (commands, "am set-debug-app 'com.example.managed'")); Assert.AreEqual (2, commands.Count (c => c == "am clear-debug-app")); } @@ -697,7 +723,7 @@ public async Task CancellationDrainsArmingBeforeCleanup () } return Task.CompletedTask; }; - var launch = server.Device.StartWithDebuggingAsync (Configuration (), cancellation.Token); + var launch = LaunchAsync (server.Device, Configuration (), cancellation.Token); await arming.Task.WaitAsync (TimeSpan.FromSeconds (5)); cancellation.Cancel (); Assert.IsFalse (launch.IsCompleted); @@ -721,11 +747,11 @@ public async Task GateIncludesCleanupAcrossDeviceInstances () } return Task.CompletedTask; }; - var first = server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None); + var first = LaunchAsync (server.Device, Configuration (), CancellationToken.None); await cleaning.Task.WaitAsync (TimeSpan.FromSeconds (5)); var commandCount = server.Commands.Count; using var cancellation = new CancellationTokenSource (); - var second = server.CreateDevice ().StartWithDebuggingAsync (Configuration (), cancellation.Token); + var second = LaunchAsync (server.CreateDevice (), Configuration (), cancellation.Token); Assert.IsFalse (second.IsCompleted); Assert.AreEqual (commandCount, server.Commands.Count); cancellation.Cancel (); @@ -756,7 +782,7 @@ public async Task TimedOutShellCompletionCannotRunCleanupAfterNextOwner () }; var configuration = Configuration (); configuration.Debugger.Timeout = TimeSpan.FromMilliseconds (250); - var launch = server.Device.StartWithDebuggingAsync (configuration, CancellationToken.None); + var launch = LaunchAsync (server.Device, configuration, CancellationToken.None); await starting.Task.WaitAsync (TimeSpan.FromSeconds (5)); Assert.ThrowsAsync (async () => await launch.WaitAsync (TimeSpan.FromSeconds (5))); Assert.IsNull (server.DebugApp); @@ -778,8 +804,8 @@ public async Task HungCleanupIsBoundedAndDoesNotReplaceLaunchError () ? "Error: Activity not started, primary failure" : output; try { - var launch = server.Device.StartWithDebuggingAsync (Configuration (), CancellationToken.None); - var error = Assert.ThrowsAsync (async () => await launch.WaitAsync (TimeSpan.FromSeconds (8))); + var launch = LaunchAsync (server.Device, Configuration (), CancellationToken.None); + var error = Assert.ThrowsAsync (async () => await launch.WaitAsync (TimeSpan.FromSeconds (8))); StringAssert.Contains ("primary failure", error.Message); } finally { release.TrySetResult (); @@ -796,17 +822,21 @@ protected override void AppendTo (Mono.AndroidTools.Util.ProcessArgumentBuilder // A private TCP endpoint exercises AndroidDevice's actual ADB transport without // replacing the launch algorithm or touching an installed adb server/device. - sealed class LaunchAdbServer : IAsyncDisposable + internal sealed class LaunchAdbServer : IAsyncDisposable { readonly TcpListener listener = new TcpListener (IPAddress.Loopback, 0); readonly CancellationTokenSource stop = new CancellationTokenSource (); readonly List connections = []; readonly Task accepting; string debugProperty = ""; + string fakeAdbPath; bool started; bool transient; + int pidRequests; public ConcurrentQueue Commands { get; } = new ConcurrentQueue (); + public ConcurrentQueue AdbCommands { get; } = new ConcurrentQueue (); + public bool LogDaemonStartup { get; set; } public string DebugApp { get; set; } public string OriginalDebugApp { get; set; } public bool AttachOnDump { get; set; } = true; @@ -816,20 +846,61 @@ sealed class LaunchAdbServer : IAsyncDisposable public string ApplicationProcessName { get; set; } = PackageName; public Func BeforeResponse { get; set; } = _ => Task.CompletedTask; public Func TransformResponse { get; set; } = (_, output) => output; + public Func CliResult { get; set; } = _ => (0, ""); public string FailTransportCommand { get; set; } public bool Attached { get; private set; } public AndroidDevice Device { get; } + public string Serial { get; } readonly AdbServer adb; - public LaunchAdbServer () + public LaunchAdbServer (string serial = "managed-launch-test") { + Serial = serial; listener.Start (); adb = new AdbServer (IPAddress.Loopback, ((IPEndPoint) listener.LocalEndpoint).Port); Device = CreateDevice (); accepting = AcceptAsync (); } - public AndroidDevice CreateDevice () => new AndroidDevice ("managed-launch-test", adb: adb); + public AndroidDevice CreateDevice () => new AndroidDevice (Serial, adb: adb); + + public string CreateFakeAdb () + { + if (OS.IsWindows) + Assert.Ignore ("Fake adb process tests require bash, like AdbRunnerTests."); + Assert.IsNull (fakeAdbPath); + var directory = Path.Combine (Path.GetTempPath (), $"managed-launch-adb-{Guid.NewGuid ():N}"); + Directory.CreateDirectory (directory); + fakeAdbPath = Path.Combine (directory, "adb"); + // This is only a process-to-fixture bridge. All state transitions and + // responses stay in the same private server used by the task tests. + var daemonOutput = LogDaemonStartup + ? "if [[ \"$command\" == *get-serialno ]]; then\n printf '%s\\n' '* daemon not running; starting now at tcp:5037' '* daemon started successfully' >&2\nfi" + : ""; + File.WriteAllText (fakeAdbPath, $$""" + #!/bin/bash + set -e + exec 3<>/dev/tcp/127.0.0.1/{{((IPEndPoint) listener.LocalEndpoint).Port}} + LC_ALL=C + command="$*" + {{daemonOutput}} + printf '%04x%s' "${#command}" "$command" >&3 + IFS= read -r status <&3 + while IFS= read -r record <&3; do + case "$record" in + O*) printf '%s\n' "${record:1}" ;; + o*) printf '%s' "${record:1}" ;; + E*) printf '%s\n' "${record:1}" >&2 ;; + e*) printf '%s' "${record:1}" >&2 ;; + *) printf '%s\n' 'Invalid fake ADB response record' >&2; exit 1 ;; + esac + done + exit "$status" + + """); + FileUtil.Chmod (fakeAdbPath, 0x1ED); // 0755 + return fakeAdbPath; + } public void SetDebugAppState (string package, bool isTransient) { @@ -853,26 +924,83 @@ async Task RespondAsync (TcpClient client) try { using (client) { var stream = client.GetStream (); - Assert.AreEqual ("host:transport:managed-launch-test", await ReadCommandAsync (stream)); + var request = await ReadCommandAsync (stream); + if (!request.StartsWith ("host:transport:", StringComparison.Ordinal)) { + AdbCommands.Enqueue (request); + foreach (var target in new [] { $"-s {Serial} ", "-d ", "-e " }) { + if (request.StartsWith (target, StringComparison.Ordinal)) { + request = request.Substring (target.Length); + break; + } + } + if (request.StartsWith ("shell ", StringComparison.Ordinal)) + request = request.Substring (6); + var reply = await RespondToCommandAsync (request); + var result = reply.Success ? CliResult (request) : (ExitCode: 1, Error: reply.Output); + if (reply.Success && request.StartsWith ("pidof ", StringComparison.Ordinal) && + string.IsNullOrWhiteSpace (reply.Output) && result.ExitCode == 0 && string.IsNullOrWhiteSpace (result.Error)) + result = (1, ""); + var cliResponse = new StringBuilder ().Append (result.ExitCode.ToString (CultureInfo.InvariantCulture)).Append ('\n'); + AppendCliOutput (cliResponse, 'O', reply.Success ? reply.Output : ""); + AppendCliOutput (cliResponse, 'E', result.Error); + await stream.WriteAsync (Encoding.UTF8.GetBytes (cliResponse.ToString ()), stop.Token); + return; + } + Assert.AreEqual ("host:transport:" + Serial, request); await stream.WriteAsync (Encoding.ASCII.GetBytes ("OKAY"), stop.Token); var command = await ReadCommandAsync (stream); Assert.IsTrue (command.StartsWith ("shell:", StringComparison.Ordinal), command); command = command.Substring (6); - Commands.Enqueue (command); - await BeforeResponse (command).WaitAsync (stop.Token); - if (command == FailTransportCommand) { - await stream.WriteAsync (Encoding.ASCII.GetBytes ("FAIL0018simulated transport fail"), stop.Token); - return; - } - var response = TransformResponse (command, Respond (command)); - await stream.WriteAsync (Encoding.UTF8.GetBytes ("OKAY" + response), stop.Token); + var response = await RespondToCommandAsync (command); + var status = response.Success ? "OKAY" : "FAIL" + Encoding.UTF8.GetByteCount (response.Output).ToString ("X4", CultureInfo.InvariantCulture); + await stream.WriteAsync (Encoding.UTF8.GetBytes (status + response.Output), stop.Token); } } catch (OperationCanceledException) when (stop.IsCancellationRequested) { } } + async Task<(bool Success, string Output)> RespondToCommandAsync (string command) + { + Commands.Enqueue (command); + await BeforeResponse (command).WaitAsync (stop.Token); + if (command == FailTransportCommand) + return (false, "simulated transport fail"); + return (true, TransformResponse (command, Respond (command))); + } + + static void AppendCliOutput (StringBuilder response, char channel, string output) + { + // Preserve both channels and whether the last line was terminated. + // The shell bridge emits the bytes, not a merged approximation of ADB. + var lines = output.Split ('\n'); + for (int i = 0; i < lines.Length; i++) { + bool last = i == lines.Length - 1; + if (last && lines [i].Length == 0) + break; + response.Append (last ? char.ToLowerInvariant (channel) : channel).Append (lines [i]).Append ('\n'); + } + } + string Respond (string command) { + if (command == "get-serialno") + return Serial + "\n"; + if (command.StartsWith ("forward ", StringComparison.Ordinal) || command.StartsWith ("reverse ", StringComparison.Ordinal)) + return ""; + if (command.StartsWith ("pidof ", StringComparison.Ordinal)) { + var process = command.Substring ("pidof ".Length); + if (process != EffectiveProcessName && process != "'" + EffectiveProcessName.Replace ("'", "'\\''") + "'") + return ""; + return Interlocked.Increment (ref pidRequests) == 1 ? "1234\n" : ""; + } + if (command.StartsWith ("logcat ", StringComparison.Ordinal)) + return "managed-launch logcat\n"; + if (command.StartsWith ("am force-stop ", StringComparison.Ordinal)) + return ""; + if (command.StartsWith ("input keyevent KEYCODE_WAKEUP; wm dismiss-keyguard", StringComparison.Ordinal)) { + var start = command.IndexOf ("am start ", StringComparison.Ordinal); + return start >= 0 ? Respond (command.Substring (start)) : ""; + } if (command == "date +%s") return "1000\n"; if (command.StartsWith ("setprop ", StringComparison.Ordinal)) { @@ -882,10 +1010,12 @@ string Respond (string command) } if (command == "getprop") return $"[ro.build.version.sdk]: [{ApiLevel}]\n[debug.mono.extra]: [{debugProperty}]\n"; + if (command == "getprop ro.build.version.sdk") + return ApiLevel.ToString (CultureInfo.InvariantCulture) + "\n"; if (command == "pm list users") return UserList; if (command.StartsWith ("pm resolve-activity ", StringComparison.Ordinal)) { - var component = command.Substring (command.IndexOf ("-n \"", StringComparison.Ordinal) + 4).TrimEnd ('"'); + var component = command.Substring (command.IndexOf ("-n ", StringComparison.Ordinal) + 3).Trim ('\'', '"'); var activity = component.Substring (component.IndexOf ('/') + 1); if (activity.StartsWith (".", StringComparison.Ordinal)) activity = PackageName + activity; @@ -899,7 +1029,7 @@ string Respond (string command) $" ApplicationInfo:\n packageName={PackageName}\n processName={ApplicationProcessName}\n" + " uid=10123 flags=0x0 privateFlags=0x0 theme=0x0\n"; } - if (command == "am set-debug-app \"com.example.managed\"") { + if (command == "am set-debug-app 'com.example.managed'") { DebugApp = PackageName; transient = true; started = false; @@ -955,6 +1085,10 @@ public async ValueTask DisposeAsync () listener.Stop (); await Task.WhenAll (connections); stop.Dispose (); + if (fakeAdbPath != null) { + File.Delete (fakeAdbPath); + Directory.Delete (Path.GetDirectoryName (fakeAdbPath)); + } } } } diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/Xamarin.Android.Tools.AndroidSdk-Tests.csproj b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/Xamarin.Android.Tools.AndroidSdk-Tests.csproj index 189fa0bbb78..1b55f6ef1ff 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/Xamarin.Android.Tools.AndroidSdk-Tests.csproj +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/Xamarin.Android.Tools.AndroidSdk-Tests.csproj @@ -27,6 +27,13 @@ + + + + + + +