diff --git a/Documentation/guides/managed-activity-debugging.md b/Documentation/guides/managed-activity-debugging.md new file mode 100644 index 00000000000..fbea23bbef9 --- /dev/null +++ b/Documentation/guides/managed-activity-debugging.md @@ -0,0 +1,162 @@ +# 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, 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 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 + 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-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. +`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, 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. 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: 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, 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) + +## Regression coverage + +`ManagedActivityLaunchTests` in `Xamarin.Android.Tools.AndroidSdk-Tests` exercises +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/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/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 new file mode 100644 index 00000000000..703001be6d2 --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/ManagedActivityLaunchTests.cs @@ -0,0 +1,1094 @@ +// 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; +using ManagedActivityLaunch = Microsoft.Android.Run.ManagedActivityLaunch; + +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; + } + + 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 (); + 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'"); + 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 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 = LaunchAsync (server.Device, 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 (() => LaunchAsync (server.Device, 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 LaunchAsync (server.Device, 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 LaunchAsync (server.Device, 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 (() => LaunchAsync (server.Device, Configuration (), CancellationToken.None)); + if (notFound) + Assert.IsTrue (error.ActivityNotFound); + 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; + 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); + } + + [Test] + public async Task ActivityCanOverrideCustomApplicationProcessBackToPackage () + { + await using var server = new LaunchAdbServer { ApplicationProcessName = "com.example.managed:app" }; + 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); + } + + [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; + 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"))); + } + + [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 (() => LaunchAsync (server.Device, 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 (() => LaunchAsync (server.Device, 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 (() => 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"); + } + + [Test] + public async Task AttachTimeoutCleansAndAllowsRetry () + { + await using var server = new LaunchAdbServer { AttachOnDump = false }; + var configuration = Configuration (); + configuration.Debugger.Timeout = TimeSpan.FromMilliseconds (150); + 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 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))); + } + + [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 (() => LaunchAsync (server.Device, 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 (() => 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 ("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 (() => LaunchAsync (server.Device, 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 (() => LaunchAsync (server.Device, 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 (() => 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) { + 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 (() => LaunchAsync (server.Device, 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 (() => 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 (() => 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 ("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"; + if (reason == "older-api") + server.ApiLevel = 30; + var expected = command.ToString (); + var messages = new List (); + configuration.LogWiter = messages.Add; + 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"))); + } + + [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 (() => LaunchAsync (server.Device, 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 LaunchAsync (server.Device, 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 = LaunchAsync (server.Device, 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 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"))); + } + + [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 (() => 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)] + [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 (() => LaunchAsync (server.Device, Configuration (), cancellation.Token)); + } else { + server.FailTransportCommand = "dumpsys activity processes"; + 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))); + } + + [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 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 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 { + 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 (() => LaunchAsync (server.Device, 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 LaunchAsync (server.Device, 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 (() => 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 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.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 = LaunchAsync (server.Device, 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 = 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 = LaunchAsync (server.CreateDevice (), 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 = 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); + 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 = 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 (); + } + } + + 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. + 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; + 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 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 (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 (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) + { + 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 (); + 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); + 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)) { + // 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 == "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) + 3).Trim ('\'', '"'); + 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 (); + 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 @@ + + + + + + +