diff --git a/Documentation/guides/FastDeploy2.md b/Documentation/guides/FastDeploy2.md index 88626b32381..ed3ecd93e66 100644 --- a/Documentation/guides/FastDeploy2.md +++ b/Documentation/guides/FastDeploy2.md @@ -23,9 +23,8 @@ properties intended for end users are: | `$(_AndroidFastDeployAppFileTransferMode)` | `Symlink` (for `FastDeploy2`) | How staged files are surfaced in the override directory: `Symlink` or `Copy`. | | `$(AndroidFastDeploymentAdbCompressionAlgorithm)` | `any` | The `adb push -z` compression algorithm. `FastDeploy2` relies on a modern Android SDK Platform-Tools `adb` for multi-file `push -z` support. | -The following internal/unsupported properties tune batching. They exist mainly so -the batching paths can be exercised with smaller batches while testing; their -defaults match the matching task properties: +The following internal/unsupported properties tune or disable implementation +details: | Property | Default | Description | | --- | --- | --- | @@ -33,6 +32,7 @@ defaults match the matching task properties: | `$(_AndroidFastDeployCopyBatchSize)` | `25` | Number of files copied per batch when staging fast-deployment files. | | `$(_AndroidFastDeployMaxShellCommandLength)` | `900` | Maximum length of a single `adb shell` command line before it is split. | | `$(_AndroidFastDeployMaxAdbCommandLength)` | `4096` | Maximum length of a single `adb` command line before it is split. | +| `$(_AndroidFastDeploySkipCleanup)` | blank | Set to `true` to skip orphan staging cleanup. | ## On-device layout @@ -47,6 +47,13 @@ defaults match the matching task properties: deployed manifest so the next build can detect whether the device is already up to date and skip redundant work. +After installing or reinstalling an APK, FastDeploy2 checks for orphaned staging +directories. In one `adb shell` command it enumerates staged +`/` directories, compares them with +`pm list packages --user `, and removes directories for packages that +are no longer installed. Incremental deployments that do not install the APK +skip cleanup entirely. + Changing `$(_AndroidFastDevStrategy)` or `$(_AndroidFastDeployAppFileTransferMode)` invalidates the deployment configuration. In particular, switching from `FastDeploy2` to legacy @@ -196,3 +203,55 @@ Install failures are reported with `ADB####` codes; fast-deployment shell failures (`mkdir`/`rm`/`push`/`ln`) are reported with `XA0129`. `run-as` diagnostics map to `XA0131`–`XA0137`. See the [build/deploy message docs](../docs-mobile/messages/index.md) for details. + +## Command Compatibility + +.NET for Android supports Android 7.0 (API level 24) and later. FastDeploy2's +device-side commands are available by Android 6.0 (API level 23), before the +supported device floor. The API levels below are approximate because shell +utilities are not Android SDK APIs. + +Host-side `adb` commands depend on the installed Android SDK Platform-Tools +version rather than the device API level: + +| Command | FastDeploy2 use | Compatibility | +| --- | --- | --- | +| `adb devices`, `adb -s shell ...` | Device selection and all device-side operations | Standard Platform-Tools commands | +| `adb install -r -d [-t] [--user ]` | APK installation and replacement | Standard Platform-Tools command; `--user` corresponds to Android multi-user support introduced in API 17 | +| `adb push -z ` | Batched compressed staging-file upload | Modern Platform-Tools capability; not controlled by the device application API level | + +FastDeploy2 uses these device-side commands and shell features: + +| Command or shell feature | FastDeploy2 use | Approximate availability | +| --- | --- | --- | +| `sh`/mksh syntax, `[ ... ]`, `test`, `command -v`, `cd`, `pwd`, `echo`, globbing, command/parameter/arithmetic expansion, and redirection | Combined checks, override updates, and cleanup control flow | API 14; Android has used mksh since Android 4.0 | +| `getprop` | Validate `run-as` compatibility properties | API 1 | +| `run-as ` | Access the private data directory of a debuggable app | Early Android; availability alone is insufficient because the package must be debuggable and the device must permit `run-as` | +| `su ` | Access files for a system application when adbd is not root | Not guaranteed on production devices; used only for the system-app fallback | +| `cat`, `true`, `rm -f`, `rm -rf`, `mkdir -p`, `rmdir`, `cp -p`, and `ln -sf` | Read markers and manage staging/override files, copies, and symlinks | API 21 or earlier; supplied by toolbox/BSD utilities before toybox | +| `readlink -f`, `whoami` | Resolve system-app paths and determine whether adbd is root | Reliably available by API 23 | +| `pidof` | Find the running application process | Reliably available by API 23 | +| `find -type f -exec stat ... {} +` and `stat -c` | Enumerate and compare staged and override files | API 23; supplied by toybox starting in Android 6.0 | +| `printf %s` | Write manifest hash markers without a trailing newline | API 23; supplied by toybox starting in Android 6.0 | +| `grep -Fqx` | Exact installed-package lookup during orphan cleanup | API 21 or earlier | +| `pm list packages --user ` | Find installed packages during orphan cleanup | API 17 | +| `pm uninstall [-k] [--user ]` | Remove an incompatible package before retrying installation | Base command predates the supported floor; `--user` requires API 17 multi-user support | +| `am force-stop ` | Stop the app before replacing fast-deployment files | API 8 or earlier | +| `am start-user -w ` | Ensure a secondary Android user is running before `run-as` | API 17 multi-user support | + +The orphan cleanup command additionally checks that each external utility is +present before cleanup. Missing utilities, failed, empty, or malformed `pm` +output, symlinked staging paths, and `grep` errors all skip deletion. Only +`grep` exit status `1`, meaning a definite non-match, permits a staging +directory to be removed. + +These estimates are based on the +[AOSP shell and utility inventories][aosp-shell-utilities], the +[Android 6.0 toybox build][aosp-marshmallow-toybox], the +[Android 4.2 `pm --user` implementation][aosp-pm-user], and the +[Android Debug Bridge documentation][adb-docs]. + +[aosp-shell-utilities]: https://android.googlesource.com/platform/system/core/+/refs/heads/main/shell_and_utilities/README.md +[aosp-marshmallow-toybox]: https://android.googlesource.com/platform/external/toybox/+/android-6.0.1_r81/Android.mk +[aosp-pm-user]: https://android.googlesource.com/platform/frameworks/base/+/android-4.2_r1/cmds/pm/src/com/android/commands/pm/Pm.java +[adb-docs]: https://developer.android.com/tools/adb diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs index d03b2991186..391a19b742d 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs @@ -17,6 +17,34 @@ public partial class FastDeploy2 string RemoteStagingRoot => RemoteStagingRootPath; + async Task CleanupRemoteStagingDirectories () + { + string command = CreateRemoteStagingCleanupCommand (RemoteStagingRoot); + if (command.Length >= MaxShellCommandLength) { + LogDiagnostic ($"FastDeploy2 orphan staging cleanup command length {command.Length} exceeds the configured maximum of {MaxShellCommandLength}; cleanup will be skipped."); + return; + } + AdbCommandResult result = await RunAdbShellCommand (command); + if (result.ExitCode != 0) { + LogDiagnostic ($"FastDeploy2 orphan staging cleanup failed and will be skipped. Output: {result.Output}"); + } else if (!string.IsNullOrEmpty (result.StandardOutput)) { + LogDiagnostic (result.StandardOutput); + } + } + + static string CreateRemoteStagingCleanupCommand (string remoteStagingRoot) + { + return string.Join ("; ", new [] { + $"r={QuoteShellArgument (remoteStagingRoot)}", + "[ -d \"$r\" ]&&[ ! -L \"$r\" ]||exit 0", + "for t in rm rmdir pm grep;do command -v \"$t\">/dev/null 2>&1||exit 0;done", + "n=0;s=0;u=''", + "for c in \"$r\"/*/*;do [ -d \"$c\" ]||continue;i=${c##*/};case \"$i\" in ''|*[!0-9]*)continue;;esac;case \" $u \" in *\" $i \"*)continue;;esac;u=\"$u $i\";p=$(pm list packages --user \"$i\");x=$?;if [ $x -ne 0 ]||[ -z \"$p\" ];then s=1;continue;fi;echo \"$p\"|grep -qv '^package:';[ $? -eq 1 ]||{ s=1;continue;};for d in \"$r\"/*/\"$i\";do [ -d \"$d\" ]||continue;q=${d%/*};a=${q##*/};echo \"$p\"|grep -Fqx \"package:$a\";x=$?;[ $x -eq 0 ]&&continue;if [ $x -ne 1 ];then s=1;continue;fi;if [ -L \"$r\" ]||[ -L \"$q\" ]||[ -L \"$d\" ];then s=1;continue;fi;if rm -rf \"$d\";then rmdir \"$q\" 2>/dev/null||true;n=$((n+1));else s=1;fi;done;done", + "echo \"FastDeploy2 orphan staging cleanup: removed $n directories\"", + "exit \"$s\"", + }); + } + async Task DeployFastDevFilesWithAdbPush (string overridePath, bool forceFreshDeployment = false) { var files = PrepareDirectPushFiles (); diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs index 4c9cebb38fb..593d86ceea9 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs @@ -62,6 +62,8 @@ public partial class FastDeploy2 : AsyncTask public bool DiagnosticLogging { get; set; } = false; + public bool FastDeploySkipCleanup { get; set; } = false; + public string UserID { get; set; } public bool IsTestOnly { get; set; } @@ -210,13 +212,12 @@ async Task RunInstall () await RemoveOverrideDirectory (); } + bool packageFileOutOfDate = !string.IsNullOrEmpty (PackageFile) && + (packageInfo.InternalPath.IndexOf ("unknown", StringComparison.OrdinalIgnoreCase) >= 0 || ReInstall || IsPackageFileOutOfDate ()); if (ReInstall && !string.IsNullOrEmpty (PackageFile)) { await UninstallPackage (PackageName, preserveData: PreserveUserData, user: UserID); } - bool packageFileOutOfDate = !string.IsNullOrEmpty (PackageFile) && - (packageInfo.InternalPath.IndexOf ("unknown", StringComparison.OrdinalIgnoreCase) >= 0 || ReInstall || IsPackageFileOutOfDate ()); - if (packageFileOutOfDate) { try { await InstallPackage (); @@ -224,6 +225,9 @@ async Task RunInstall () LogFastDeploy2Error (GetErrorCode (ex), ex.ToString ()); return; } + if (!FastDeploySkipCleanup) { + await CleanupRemoteStagingDirectories (); + } if (!EmbedAssembliesIntoApk && packageInfo.InternalPath.IndexOf ("unknown", StringComparison.OrdinalIgnoreCase) >= 0) { packageInfo.InternalPath = null; await CheckAppInstalledAndDebuggable (PackageName); diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Common.Debugging.targets b/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Common.Debugging.targets index 34ab9814ad6..7ffa52326dc 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Common.Debugging.targets +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Common.Debugging.targets @@ -399,6 +399,7 @@ Copyright (C) 2016 Xamarin. All rights reserved. CopyBatchSize="$(_AndroidFastDeployCopyBatchSize)" MaxShellCommandLength="$(_AndroidFastDeployMaxShellCommandLength)" MaxAdbCommandLength="$(_AndroidFastDeployMaxAdbCommandLength)" + FastDeploySkipCleanup="$(_AndroidFastDeploySkipCleanup)" /> diff --git a/tests/MSBuildDeviceIntegration/Tests/FastDevTest.cs b/tests/MSBuildDeviceIntegration/Tests/FastDevTest.cs index 249b9d2bed0..b20d06d1ad6 100644 --- a/tests/MSBuildDeviceIntegration/Tests/FastDevTest.cs +++ b/tests/MSBuildDeviceIntegration/Tests/FastDevTest.cs @@ -321,6 +321,77 @@ public void FastDeploy2RestoresMissingRemoteDirectory () } } + [Test] + public void FastDeploy2CleansOrphanStagingDirectoriesAfterApkInstall () + { + string [] orphanDirectories = { + "/data/local/tmp/fastdeploy2/com.xamarin.fastdeploy2_cleanup_one/0", + "/data/local/tmp/fastdeploy2/com.xamarin.fastdeploy2_cleanup_two/0", + }; + string incrementalOrphanDirectory = "/data/local/tmp/fastdeploy2/com.xamarin.fastdeploy2_cleanup_incremental/0"; + string symlinkDirectory = "/data/local/tmp/fastdeploy2/com.xamarin.fastdeploy2_cleanup_symlink"; + string symlinkTargetDirectory = "/data/local/tmp/fastdeploy2_cleanup_symlink_target/0"; + var proj = new XamarinAndroidApplicationProject { + PackageName = "com.xamarin.fastdeploy2_cleanup", + }; + proj.MainActivity = proj.DefaultMainActivity; + proj.SetDefaultTargetDevice (); + proj.SetProperty ("_AndroidFastDevStrategy", "FastDeploy2"); + var installedProj = new XamarinAndroidApplicationProject { + PackageName = "com.xamarin.fastdeploy2_cleanup_installed", + ProjectName = "FastDeploy2CleanupInstalled", + }; + installedProj.SetDefaultTargetDevice (); + string installedDirectory = $"/data/local/tmp/fastdeploy2/{installedProj.PackageName}/0"; + + using (var installedBuilder = CreateApkBuilder (Path.Combine ("temp", TestName, installedProj.ProjectName))) + using (var builder = CreateApkBuilder (Path.Combine ("temp", TestName, proj.ProjectName))) { + builder.Verbosity = LoggerVerbosity.Detailed; + try { + Assert.IsTrue (installedBuilder.Install (installedProj), "Installed-package fixture should have installed successfully."); + foreach (string directory in orphanDirectories) { + RunAdbCommand ($"shell mkdir -p {directory}"); + RunAdbCommand ($"shell touch {directory}/orphan.txt"); + } + RunAdbCommand ($"shell mkdir -p {installedDirectory}"); + RunAdbCommand ($"shell touch {installedDirectory}/installed.txt"); + RunAdbCommand ($"shell mkdir -p {symlinkTargetDirectory}"); + RunAdbCommand ($"shell touch {symlinkTargetDirectory}/outside.txt"); + RunAdbCommand ($"shell ln -s {Path.GetDirectoryName (symlinkTargetDirectory).Replace ('\\', '/')} {symlinkDirectory}"); + + Assert.IsTrue (builder.Install (proj), "FastDeploy2 install should have succeeded."); + + foreach (string directory in orphanDirectories) { + Assert.AreEqual ("missing", RunAdbCommand ($"shell if test -e {directory}; then echo exists; else echo missing; fi").Trim (), + $"Orphan staging directory '{directory}' should have been deleted."); + } + Assert.AreEqual ("exists", RunAdbCommand ($"shell if test -f {installedDirectory}/installed.txt; then echo exists; else echo missing; fi").Trim (), + "Staging for an installed package should not be deleted."); + Assert.AreEqual ("exists", RunAdbCommand ($"shell if test -f {symlinkTargetDirectory}/outside.txt; then echo exists; else echo missing; fi").Trim (), + "Cleanup should not follow a symlinked package directory outside the staging root."); + + RunAdbCommand ($"shell mkdir -p {incrementalOrphanDirectory}"); + RunAdbCommand ($"shell touch {incrementalOrphanDirectory}/orphan.txt"); + proj.MainActivity = proj.MainActivity.Replace ("clicks", "CLICKS"); + proj.Touch ("MainActivity.cs"); + Assert.IsTrue (builder.Install (proj, doNotCleanupOnUpdate: true, saveProject: false), "Incremental FastDeploy2 install should have succeeded."); + Assert.IsFalse (builder.Output.IsApkInstalled, "The APK should not have been reinstalled."); + Assert.AreEqual ("exists", RunAdbCommand ($"shell if test -f {incrementalOrphanDirectory}/orphan.txt; then echo exists; else echo missing; fi").Trim (), + "Cleanup should not run during an incremental deployment that skips APK installation."); + } finally { + foreach (string directory in orphanDirectories) { + RunAdbCommand ($"shell rm -rf {directory}"); + } + RunAdbCommand ($"shell rm -rf {installedDirectory}"); + RunAdbCommand ($"shell rm -rf {incrementalOrphanDirectory}"); + RunAdbCommand ($"shell rm -rf {symlinkDirectory}"); + RunAdbCommand ($"shell rm -rf {Path.GetDirectoryName (symlinkTargetDirectory).Replace ('\\', '/')}"); + builder.Uninstall (proj); + installedBuilder.Uninstall (installedProj); + } + } + } + string GetOverrideFileKind (string packageName, string path) { return RunAdbCommand ($"shell run-as {packageName} sh -c 'if test -L {path}; then echo symlink; elif test -f {path}; then echo regular; else echo missing; fi'").Trim ();