From 1bb81207127116930bb91b6115edd7e76a187d1a Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 18 Jun 2026 11:31:05 +0200 Subject: [PATCH 01/11] Add manifest-driven FastDeploy2 Add a new FastDeploy2 strategy that uses a local manifest to push only changed files to temporary device storage and mirrors the app override directory with shell-created symlinks. Keep legacy FastDeploy selectable while making FastDeploy2 the default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/FastDeploy2.Manifest.cs | 487 +++++ .../Tasks/FastDeploy2.cs | 1599 +++++++++++++++++ .../Xamarin.Android.Common.Debugging.targets | 37 + 3 files changed, 2123 insertions(+) create mode 100644 src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs create mode 100644 src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs new file mode 100644 index 00000000000..c208e138934 --- /dev/null +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs @@ -0,0 +1,487 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Xamarin.Android.Tasks +{ + public class FastDeploy2 : FastDeploy2Base + { + const string RemoteStagingRootPath = "/tmp/fastdeploy2"; + const string RemoteReadyMarker = ".fastdeploy2-ready"; + const int MaxAdbCommandLength = 4096; + + public override string TaskPrefix => "FD2"; + + protected override string RemoteStagingRoot => RemoteStagingRootPath; + + protected override async Task DeployFastDevFilesWithAdbPush (string overridePath) + { + var phase = Stopwatch.StartNew (); + var files = PrepareDirectPushFiles (); + var expectedFiles = new HashSet (files.Select (file => file.RelativePath), StringComparer.Ordinal); + var currentManifest = CreateManifest (files); + SetDiagnosticElapsed ("deploy.fastdeploy2.local.stage.ms", phase); + if (files.Count == 0) { + LogDiagnostic ("No FastDev files were prepared for adb push deployment."); + return true; + } + + string remoteStagingPath = GetRemoteAdbPushStagingPath (); + bool remoteReady = await IsRemoteReady (remoteStagingPath); + var previousManifest = remoteReady ? LoadPreviousManifest () : null; + if (previousManifest == null) { + SetDiagnosticProperty ("deploy.fastdeploy2.manifest.full.push", 1); + } + + var changedFiles = GetChangedFiles (currentManifest, previousManifest); + var removedFiles = GetRemovedFiles (currentManifest, previousManifest); + SetDiagnosticProperty ("deploy.fastdeploy2.manifest.changed.files", changedFiles.Count); + SetDiagnosticProperty ("deploy.fastdeploy2.manifest.removed.files", removedFiles.Count); + + phase.Restart (); + string output = await CreateRemoteStagingDirectories (remoteStagingPath, expectedFiles); + SetDiagnosticElapsed ("deploy.fastdeploy2.remote.mkdir.ms", phase); + if (!string.IsNullOrEmpty (output) && IsShellError (output, "mkdir")) { + LogFastDeploy2Error ("XA0129", output, remoteStagingPath); + return false; + } + + phase.Restart (); + if (!await RemoveRemoteStaleFiles (remoteStagingPath, removedFiles)) { + return false; + } + SetDiagnosticElapsed ("deploy.fastdeploy2.remote.staging.cleanup.ms", phase); + + phase.Restart (); + if (!await UploadChangedFiles (remoteStagingPath, files, changedFiles)) { + return false; + } + SetDiagnosticElapsed ("deploy.fastdeploy2.upload.ms", phase); + + bool result; + if (UseShellSymlinkAppFileTransfer ()) { + result = await UpdateOverrideShellSymlinks (remoteStagingPath, overridePath, currentManifest, previousManifest, removedFiles); + } else { + result = await UpdateOverrideCopies (remoteStagingPath, overridePath); + } + + if (result) { + WriteManifest (currentManifest); + await MarkRemoteReady (remoteStagingPath); + } + return result; + } + + bool UseShellSymlinkAppFileTransfer () + { + return string.Equals (AppFileTransferMode, "Symlink", StringComparison.OrdinalIgnoreCase); + } + + async Task UpdateOverrideShellSymlinks (string remoteStagingPath, string overridePath, Dictionary currentManifest, Dictionary previousManifest, List removedFiles) + { + var newFiles = previousManifest == null ? + new HashSet (currentManifest.Keys, StringComparer.Ordinal) : + new HashSet (currentManifest.Keys.Where (file => !previousManifest.ContainsKey (file)), StringComparer.Ordinal); + SetDiagnosticProperty ("deploy.fastdeploy2.changed.files", newFiles.Count); + SetDiagnosticProperty ("deploy.symlink.created.files", newFiles.Count); + SetDiagnosticProperty ("deploy.symlink.removed.files", removedFiles.Count + newFiles.Count); + SetDiagnosticProperty ("deploy.fastdeploy2.stale.files", removedFiles.Count); + SetDiagnosticProperty ("deploy.symlink.tool.result", "shell"); + + var phase = Stopwatch.StartNew (); + if (!await RunCombinedShellSymlinkUpdate (remoteStagingPath, overridePath, currentManifest, previousManifest, newFiles, removedFiles)) { + SetDiagnosticElapsed ("deploy.symlink.shell.update.ms", phase); + return await FallbackToCopy (remoteStagingPath, overridePath); + } + SetDiagnosticElapsed ("deploy.symlink.shell.update.ms", phase); + + return true; + } + + async Task RunCombinedShellSymlinkUpdate (string remoteStagingPath, string overridePath, Dictionary currentManifest, Dictionary previousManifest, HashSet newFiles, List removedFiles) + { + var directories = new HashSet (StringComparer.Ordinal); + foreach (string file in currentManifest.Keys.Concat (removedFiles)) { + directories.Add (GetDirectoryName (file)); + } + + foreach (string directory in directories) { + var currentInDirectory = currentManifest.Keys.Where (file => GetDirectoryName (file) == directory).ToList (); + var newInDirectory = newFiles.Where (file => GetDirectoryName (file) == directory).ToList (); + var removedInDirectory = removedFiles.Where (file => GetDirectoryName (file) == directory).ToList (); + string targetDirectory = string.IsNullOrEmpty (directory) ? overridePath : $"{overridePath}/{directory}"; + string sourceDirectory = string.IsNullOrEmpty (directory) ? remoteStagingPath : $"{remoteStagingPath}/{directory}"; + + if (previousManifest == null || newInDirectory.Count == currentInDirectory.Count) { + string script = $"rm -f {ShellQuote (targetDirectory)}/*; mkdir -p {ShellQuote (targetDirectory)}; ln -sf {ShellQuote (sourceDirectory)}/* {ShellQuote (targetDirectory)}/"; + string output = await RunAs ("sh", "-c", script); + if (RaiseRunAsError (output) || IsShellError (output, "rm") || IsShellError (output, "mkdir") || IsShellError (output, "ln")) { + LogDiagnostic ($"Shell symlink glob update failed with '{output}'."); + return false; + } + continue; + } + + foreach (string script in CreateShellSymlinkScripts (remoteStagingPath, overridePath, newInDirectory, removedInDirectory)) { + string output = await RunAs ("sh", "-c", script); + if (RaiseRunAsError (output) || IsShellError (output, "rm") || IsShellError (output, "mkdir") || IsShellError (output, "ln")) { + LogDiagnostic ($"Shell symlink batch update failed with '{output}'."); + return false; + } + } + } + + return true; + } + + IEnumerable CreateShellSymlinkScripts (string remoteStagingPath, string overridePath, List newFiles, List removedFiles) + { + var filesToRemove = removedFiles.Concat (newFiles).Select (file => $"{overridePath}/{file}").ToList (); + foreach (var batch in BatchShellArguments ("rm -f", filesToRemove)) { + yield return batch; + } + + foreach (var group in newFiles.GroupBy (GetDirectoryName, StringComparer.Ordinal)) { + string targetDirectory = string.IsNullOrEmpty (group.Key) ? overridePath : $"{overridePath}/{group.Key}"; + var prefix = $"mkdir -p {ShellQuote (targetDirectory)}; ln -sf"; + var suffix = ShellQuote (targetDirectory) + "/"; + var sources = group.Select (file => $"{remoteStagingPath}/{file}"); + foreach (var batch in BatchShellArguments (prefix, sources, suffix)) { + yield return batch; + } + } + } + + IEnumerable BatchShellArguments (string prefix, IEnumerable arguments, string suffix = "") + { + var builder = new StringBuilder (prefix); + int count = 0; + foreach (string argument in arguments) { + string quoted = " " + ShellQuote (argument); + if (count > 0 && builder.Length + quoted.Length + suffix.Length >= MaxAdbCommandLength) { + if (!string.IsNullOrEmpty (suffix)) { + builder.Append (' ').Append (suffix); + } + yield return builder.ToString (); + builder.Clear (); + builder.Append (prefix); + count = 0; + } + builder.Append (quoted); + count++; + } + if (count > 0) { + if (!string.IsNullOrEmpty (suffix)) { + builder.Append (' ').Append (suffix); + } + yield return builder.ToString (); + } + } + + static string GetDirectoryName (string file) + { + return Path.GetDirectoryName (file)?.Replace ("\\", "/") ?? ""; + } + + static string ShellQuote (string value) + { + return "'" + value.Replace ("'", "'\"'\"'") + "'"; + } + + async Task RemoveOverridePaths (string overridePath, IEnumerable paths) + { + foreach (var batch in BatchArguments ("rm", "-f", paths.Select (file => $"{overridePath}/{file}"))) { + string output = await RunAs (batch.ToArray ()); + if (RaiseRunAsError (output) || IsShellError (output, "rm")) { + LogDiagnostic ($"Shell symlink remove failed with '{output}'."); + return false; + } + } + return true; + } + + async Task CreateOverrideShellSymlinks (string remoteStagingPath, string overridePath, HashSet newFiles) + { + var filesByDirectory = new Dictionary> (StringComparer.Ordinal); + foreach (string file in newFiles) { + string directory = Path.GetDirectoryName (file)?.Replace ("\\", "/") ?? ""; + if (!filesByDirectory.TryGetValue (directory, out List files)) { + files = new List (); + filesByDirectory.Add (directory, files); + } + files.Add (file); + } + + var phase = Stopwatch.StartNew (); + foreach (var group in filesByDirectory) { + string targetDirectory = string.IsNullOrEmpty (group.Key) ? overridePath : $"{overridePath}/{group.Key}"; + phase.Restart (); + string output = await RunAs ("mkdir", "-p", targetDirectory); + AddDiagnosticElapsed ("deploy.fastdeploy2.override.mkdir.ms", phase); + if (RaiseRunAsError (output) || IsShellError (output, "mkdir")) { + LogDiagnostic ($"Shell symlink mkdir failed with '{output}'."); + return false; + } + + for (int i = 0; i < group.Value.Count; i += 25) { + var args = new List { "ln", "-sf" }; + foreach (string file in group.Value.Skip (i).Take (25)) { + args.Add ($"{remoteStagingPath}/{file}"); + } + args.Add (targetDirectory); + phase.Restart (); + output = await RunAs (args.ToArray ()); + AddDiagnosticElapsed ("deploy.fastdeploy2.override.copy.ms", phase); + if (RaiseRunAsError (output) || IsShellError (output, "ln")) { + LogDiagnostic ($"Shell symlink ln failed with '{output}'."); + return false; + } + } + } + + return true; + } + + async Task FallbackToCopy (string remoteStagingPath, string overridePath) + { + SetDiagnosticProperty ("deploy.symlink.tool.result", "shell fallback to copy"); + return await UpdateOverrideCopies (remoteStagingPath, overridePath); + } + + async Task UpdateOverrideCopies (string remoteStagingPath, string overridePath) + { + var phase = Stopwatch.StartNew (); + var stagedFileData = await GetRemoteFileData (remoteStagingPath, runAs: false); + SetDiagnosticElapsed ("deploy.fastdeploy2.staging.stat.ms", phase); + if (stagedFileData == null) { + return false; + } + + phase.Restart (); + var overrideFileData = await GetRemoteFileData (overridePath, runAs: true); + SetDiagnosticElapsed ("deploy.fastdeploy2.override.stat.ms", phase); + if (overrideFileData == null) { + return false; + } + + if (!await RemoveStaleOverrideFiles (overridePath, stagedFileData, overrideFileData)) { + return false; + } + + return await CopyChangedFiles (remoteStagingPath, overridePath, stagedFileData, overrideFileData); + } + + Dictionary CreateManifest (List files) + { + var manifest = new Dictionary (StringComparer.Ordinal); + foreach (var file in files) { + var info = new FileInfo (file.LocalPath); + manifest [file.RelativePath] = new ManifestEntry { + RelativePath = file.RelativePath, + LocalPath = file.LocalPath, + Size = info.Length, + LastWriteTimeUtcTicks = info.LastWriteTimeUtc.Ticks, + }; + } + return manifest; + } + + HashSet GetChangedFiles (Dictionary currentManifest, Dictionary previousManifest) + { + if (previousManifest == null) { + return new HashSet (currentManifest.Keys, StringComparer.Ordinal); + } + + var changedFiles = new HashSet (StringComparer.Ordinal); + foreach (var entry in currentManifest) { + if (!previousManifest.TryGetValue (entry.Key, out ManifestEntry previous) || + previous.Size != entry.Value.Size || + previous.LastWriteTimeUtcTicks != entry.Value.LastWriteTimeUtcTicks) { + changedFiles.Add (entry.Key); + } + } + return changedFiles; + } + + List GetRemovedFiles (Dictionary currentManifest, Dictionary previousManifest) + { + var removedFiles = new List (); + if (previousManifest == null) { + return removedFiles; + } + + foreach (var entry in previousManifest.Keys) { + if (!currentManifest.ContainsKey (entry)) { + removedFiles.Add (entry); + } + } + return removedFiles; + } + + async Task UploadChangedFiles (string remoteStagingPath, List files, HashSet changedFiles) + { + int pushed = 0; + int skipped = 0; + int batches = 0; + var changedFileList = files.Where (file => changedFiles.Contains (file.RelativePath)).ToList (); + foreach (var group in changedFileList.GroupBy (file => Path.GetDirectoryName (file.RelativePath)?.Replace ("\\", "/") ?? "", StringComparer.Ordinal)) { + string remoteDirectory = string.IsNullOrEmpty (group.Key) ? remoteStagingPath : $"{remoteStagingPath}/{group.Key}"; + foreach (var batch in BatchPushFilesWithoutSync (group.ToList (), remoteDirectory)) { + var result = await RunAdbCommand (batch.ToArray ()); + if (result.ExitCode != 0) { + LogFastDeploy2Error ("XA0129", result.Output, remoteDirectory); + return false; + } + var counts = TryParsePushSummary (result.Output); + pushed += counts.pushed; + skipped += counts.skipped; + batches++; + LogDiagnostic (result.Output); + } + } + SetDiagnosticProperty ("deploy.fastdeploy2.adb.pushed.files", pushed); + SetDiagnosticProperty ("deploy.fastdeploy2.adb.skipped.files", skipped); + SetDiagnosticProperty ("deploy.fastdeploy2.bulk.batches", batches); + SetDiagnosticProperty ("deploy.fastdeploy2.changed.files", changedFiles.Count); + return true; + } + + async Task RemoveRemoteStaleFiles (string remoteStagingPath, List removedFiles) + { + foreach (var batch in BatchArguments ("rm", "-f", removedFiles.Select (file => $"{remoteStagingPath}/{file}"))) { + var args = new [] { "shell" }.Concat (batch).ToArray (); + var result = await RunAdbCommand (args); + if (result.ExitCode != 0 || IsShellError (result.Output, "rm")) { + LogFastDeploy2Error ("XA0129", result.Output, remoteStagingPath); + return false; + } + } + return true; + } + + IEnumerable> BatchPushFilesWithoutSync (List files, string remoteDirectory) + { + var batch = CreatePushArgsPrefix (); + int length = EstimateCommandLength (batch) + remoteDirectory.Length + 4; + foreach (var file in files) { + if (Path.GetFileName (file.LocalPath) != Path.GetFileName (file.RelativePath)) { + yield return CreatePushArgs (file.LocalPath, $"{remoteDirectory}/{Path.GetFileName (file.RelativePath)}"); + continue; + } + + int itemLength = file.LocalPath.Length + 3; + if (batch.Count > 1 && length + itemLength >= MaxAdbCommandLength) { + batch.Add (remoteDirectory); + yield return batch; + batch = CreatePushArgsPrefix (); + length = EstimateCommandLength (batch) + remoteDirectory.Length + 4; + } + batch.Add (file.LocalPath); + length += itemLength; + } + if (batch.Count > 1) { + batch.Add (remoteDirectory); + yield return batch; + } + } + + List CreatePushArgs (string localPath, string remotePath) + { + var args = CreatePushArgsPrefix (); + args.Add (localPath); + args.Add (remotePath); + return args; + } + + List CreatePushArgsPrefix () + { + var args = new List { "push" }; + if (!string.IsNullOrEmpty (AdbPushCompressionAlgorithm)) { + args.Add ("-z"); + args.Add (AdbPushCompressionAlgorithm); + } + return args; + } + + int EstimateCommandLength (List args) + { + int length = 0; + foreach (var arg in args) { + length += arg.Length + 3; + } + return length; + } + + async Task IsRemoteReady (string remoteStagingPath) + { + var result = await RunAdbCommand ("shell", "test", "-f", $"{remoteStagingPath}/{RemoteReadyMarker}"); + return result.ExitCode == 0; + } + + async Task MarkRemoteReady (string remoteStagingPath) + { + await RunAdbCommand ("shell", "touch", $"{remoteStagingPath}/{RemoteReadyMarker}"); + } + + Dictionary LoadPreviousManifest () + { + string manifestFile = GetManifestFilePath (); + if (!File.Exists (manifestFile)) { + return null; + } + + try { + var manifest = JsonSerializer.Deserialize> (File.ReadAllText (manifestFile)); + return manifest == null ? null : new Dictionary (manifest, StringComparer.Ordinal); + } catch (Exception ex) { + LogDiagnostic ($"Ignoring FastDeploy2 manifest '{manifestFile}'. {ex}"); + return null; + } + } + + void WriteManifest (Dictionary manifest) + { + string manifestFile = GetManifestFilePath (); + Directory.CreateDirectory (Path.GetDirectoryName (manifestFile)); + File.WriteAllText (manifestFile, JsonSerializer.Serialize (manifest, new JsonSerializerOptions { WriteIndented = true })); + } + + string GetManifestFilePath () + { + return Path.Combine (GetFullPath (IntermediateOutputPath), "fastdeploy2", GetSafeFileName (PackageName), GetSafeFileName (GetUserId ()), GetSafeFileName (PrimaryCpuAbi), "manifest.json"); + } + + static string GetSafeFileName (string value) + { + if (string.IsNullOrEmpty (value)) { + return "_"; + } + + var builder = new StringBuilder (value.Length); + foreach (char c in value) { + builder.Append (char.IsLetterOrDigit (c) || c == '.' || c == '-' || c == '_' ? c : '_'); + } + return builder.ToString (); + } + + class ManifestEntry { + [JsonPropertyName ("relativePath")] + public string RelativePath { get; set; } + + [JsonPropertyName ("localPath")] + public string LocalPath { get; set; } + + [JsonPropertyName ("size")] + public long Size { get; set; } + + [JsonPropertyName ("lastWriteTimeUtcTicks")] + public long LastWriteTimeUtcTicks { get; set; } + } + } +} diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs new file mode 100644 index 00000000000..ca8f17b51ca --- /dev/null +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs @@ -0,0 +1,1599 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Framework; +using Mono.AndroidTools; +using Mono.AndroidTools.Util; +using Xamarin.Android.Build.Debugging.Tasks.Properties; + +namespace Xamarin.Android.Tasks +{ + public abstract class FastDeploy2Base : AsyncTask + { + const string OverridePath = "files/.__override__"; + const int StaleFileRemovalBatchSize = 100; + const int CopyBatchSize = 25; + const int MaxShellCommandLength = 900; + + public override string TaskPrefix => "FD2"; + + public string AdbTarget { get; set; } + public string UploadFlagFile { get; set; } + public bool EmbedAssembliesIntoApk { get; set; } + public bool ReInstall { get; set; } = false; + + [Required] + public string PackageName { get; set; } + + public string PackageFile { get; set; } + + public string PrimaryCpuAbi { get; set; } + public string ToolsAbi { get; set; } + + public ITaskItem [] FastDevFiles { get; set; } + + public bool PreserveUserData { get; set; } = true; + + [Required] + public string FastDevToolPath { get; set; } + + [Required] + public string ToolVersion { get; set; } + + public bool DiagnosticLogging { get; set; } = false; + + public bool UsingAndroidNETSdk { get; set; } + + public string UserID { get; set; } + + public bool IsTestOnly { get; set; } + + [Required] + public string IntermediateOutputPath { get; set; } + + public ITaskItem [] EnvironmentFiles { get; set; } + + public string AdbToolPath { get; set; } + + public string AdbToolExe { get; set; } + + public string AdbPushCompressionAlgorithm { get; set; } = "any"; + + public string AppFileTransferMode { get; set; } = "Copy"; + + AndroidDevice Device; + PackageInfo packageInfo = new PackageInfo (); + DateTime lastUpload = DateTime.MinValue; + Queue diagnosticLogs = new Queue (); + DiagnosticData diagnosticData = new DiagnosticData (); + + protected virtual string RemoteStagingRoot => "/tmp/fastdev2"; + + string OverrideFullPath { + get { return packageInfo.IsSystemApplication ? $"{packageInfo.InternalPath}/{OverridePath}" : OverridePath; } + } + + class PackageInfo { + string internalPath = null; + public string InternalPath { + get { return internalPath; } + set { internalPath = value?.Trim () ?? null; } + } + + public bool IsSystemApplication { get; set; } = false; + public bool AdbIsRoot { get; set; } = false; + public string UserId { get; set; } = null; + public string PackageName { get; set; } = null; + public int ProcessId { get; set; } = 0; + } + + class DiagnosticData { + [JsonPropertyName ("Task")] + public string Task { get; set; } = nameof (FastDeploy2); + + [JsonPropertyName ("Properties")] + public Dictionary Properties { get; set; } = new Dictionary () { + { "target.prop.ro.product.build.version.sdk", "" }, + { "target.prop.ro.product.cpu.abilist", "" }, + { "target.prop.ro.product.manufacturer", "" }, + { "target.prop.ro.product.model", "" }, + { "target.prop.ro.product.cpu.abi", "" }, + { "deploy.error.code", "" }, + { "deploy.tool", "adb push" }, + { "deploy.result", "Success" }, + { "deploy.supports.fastdev", "True" }, + { "deploy.systemapp", "False" }, + { "deploy.duration.ms", "0" }, + { "deploy.fastdeploy2.adb.pushed.files", "" }, + { "deploy.fastdeploy2.adb.skipped.files", "" }, + { "deploy.fastdeploy2.changed.files", "" }, + { "deploy.fastdeploy2.stale.files", "" }, + { "deploy.fastdeploy2.local.stage.ms", "" }, + { "deploy.fastdeploy2.remote.mkdir.ms", "" }, + { "deploy.fastdeploy2.remote.staging.cleanup.ms", "" }, + { "deploy.fastdeploy2.upload.ms", "" }, + { "deploy.fastdeploy2.staging.stat.ms", "" }, + { "deploy.fastdeploy2.override.stat.ms", "" }, + { "deploy.fastdeploy2.compare.ms", "" }, + { "deploy.fastdeploy2.stale.remove.ms", "" }, + { "deploy.fastdeploy2.override.mkdir.ms", "" }, + { "deploy.fastdeploy2.override.copy.ms", "" }, + { "deploy.orchestration.ensure-properties.ms", "" }, + { "deploy.orchestration.property-checks.ms", "" }, + { "deploy.orchestration.package-check.ms", "" }, + { "deploy.orchestration.package-timestamp.ms", "" }, + { "deploy.orchestration.install.ms", "" }, + { "deploy.orchestration.terminate.ms", "" }, + { "deploy.orchestration.empty-check.ms", "" }, + { "deploy.execute.parse-target.ms", "" }, + { "deploy.execute.no-abi-check.ms", "" }, + { "deploy.execute.upload-flag-stat.ms", "" }, + { "deploy.execute.task-cache.ms", "" }, + { "deploy.orchestration.property-capture.ms", "" }, + { "deploy.orchestration.redirect-stdio-check.ms", "" }, + { "deploy.orchestration.run-as-disabled-check.ms", "" }, + { "deploy.orchestration.package-check.ensure-user.ms", "" }, + { "deploy.orchestration.package-check.run-as-pwd.ms", "" }, + { "deploy.orchestration.package-check.run-as-pwd-pidof.ms", "" }, + { "deploy.orchestration.package-check.readlink.ms", "" }, + { "deploy.orchestration.package-check.system-app.ms", "" }, + { "deploy.orchestration.package-check.evaluate.ms", "" }, + { "deploy.orchestration.package-timestamp.path-stat.ms", "" }, + { "deploy.orchestration.install.push-install.ms", "" }, + { "deploy.orchestration.install.retry-delete.ms", "" }, + { "deploy.orchestration.install.retry-uninstall.ms", "" }, + { "deploy.orchestration.install.retry-reinstall.ms", "" }, + { "deploy.orchestration.terminate.get-pid.ms", "" }, + { "deploy.orchestration.terminate.kill.ms", "" }, + { "deploy.app.file.transfer.mode", "" }, + { "deploy.fastdeploy2.bulk.batches", "" }, + { "deploy.symlink.created.files", "" }, + { "deploy.symlink.removed.files", "" }, + { "deploy.symlink.shell.update.ms", "" }, + { "pii.deploy.error", "" }, + { "pii.deploy.file", "" }, + }; + + internal void SetProperty (string key, bool? value) + { + Properties [key] = value?.ToString () ?? "False"; + } + + internal void SetProperty (string key, int? value) + { + Properties [key] = value?.ToString () ?? "-1"; + } + + internal void SetProperty (string key, long? value) + { + Properties [key] = value?.ToString () ?? "-1"; + } + + internal void SetProperty (string key, string value) + { + Properties [key] = value ?? "unknown"; + } + } + + protected class RemoteFileInfo { + public long Size { get; set; } + public long ModifiedTime { get; set; } + } + + protected class DirectPushFile { + public string LocalPath { get; set; } + public string RelativePath { get; set; } + } + + void DebugHandler (string task, string message) + { + LogDiagnostic ($"DEBUG {task} {message}"); + } + + public override bool Execute () + { + var phase = Stopwatch.StartNew (); + Device = AndroidHelper.ParseTarget (AdbTarget, LogMessage, LogCodedError, logErrors: true, engine4: BuildEngine4); + SetDiagnosticElapsed ("deploy.execute.parse-target.ms", phase); + if (Device == null) { + PrintDiagnostics (); + return false; + } + LogMessage ($"Found device: {Device.ID}"); + + phase.Restart (); + if (string.IsNullOrEmpty (PrimaryCpuAbi) && !EmbedAssembliesIntoApk) { + SetDiagnosticElapsed ("deploy.execute.no-abi-check.ms", phase); + PrintDiagnostics (); + LogCodedError ("XA0010", Resources.XA0010_NoAbi, Device.ID); + return false; + } + SetDiagnosticElapsed ("deploy.execute.no-abi-check.ms", phase); + + phase.Restart (); + var flagFilePath = GetFullPath (UploadFlagFile); + lastUpload = File.GetLastWriteTimeUtc (flagFilePath); + LogDiagnostic ($"LastWriteTime of `{flagFilePath}`: {lastUpload}"); + diagnosticData.Task = GetType ().Name; + SetDiagnosticElapsed ("deploy.execute.upload-flag-stat.ms", phase); + + phase.Restart (); + var lifetime = RegisteredTaskObjectLifetime.AppDomain; + var key = ProjectSpecificTaskObjectKey ($"{Device.ID}_{PackageName}_{GetType ().Name}"); + if (!File.Exists (UploadFlagFile)) { + packageInfo = new PackageInfo (); + } else { + packageInfo = BuildEngine4.GetRegisteredTaskObjectAssemblyLocal (key, lifetime) ?? new PackageInfo (); + } + SetDiagnosticElapsed ("deploy.execute.task-cache.ms", phase); + + AndroidLogger.Debug += DebugHandler; + try { + return base.Execute (); + } finally { + BuildEngine4.RegisterTaskObjectAssemblyLocal (key, packageInfo, lifetime, allowEarlyCollection: false); + AndroidLogger.Debug -= DebugHandler; + } + } + + public async override Task RunTaskAsync () + { + var sw = Stopwatch.StartNew (); + try { + await RunInstall (); + } catch { + PrintDiagnostics (); + throw; + } finally { + sw.Stop (); + SaveDiagnosticData (sw.ElapsedMilliseconds); + } + } + + async Task RunInstall () + { + var phase = Stopwatch.StartNew (); + await Device.EnsureProperties (CancellationToken).ConfigureAwait (false); + SetDiagnosticElapsed ("deploy.orchestration.ensure-properties.ms", phase); + + phase.Restart (); + diagnosticData.SetProperty ("target.prop.ro.product.build.version.sdk", Device.Properties?.BuildVersionSdk); + diagnosticData.SetProperty ("target.prop.ro.product.cpu.abilist", string.Join (";", Device.Properties?.ProductCpuAbiList ?? Array.Empty ())); + diagnosticData.SetProperty ("target.prop.ro.product.cpu.abi", PrimaryCpuAbi); + diagnosticData.SetProperty ("target.prop.ro.product.manufacturer", Device.Properties?.ProductManufacturer); + diagnosticData.SetProperty ("target.prop.ro.product.model", Device.Properties?.ProductModel); + SetDiagnosticElapsed ("deploy.orchestration.property-capture.ms", phase); + + phase.Restart (); + string redirectStdio = Device.Properties.Get ("log.redirect-stdio"); + SetDiagnosticElapsed ("deploy.orchestration.redirect-stdio-check.ms", phase); + if (redirectStdio != null && string.Equals ("true", redirectStdio.Trim (), StringComparison.OrdinalIgnoreCase)) { + LogFastDeploy2Error ("XA0128", Resources.XA0128_RedirectStdioIsEnabled); + return; + } + + phase.Restart (); + string runAsDisabled = Device.Properties.Get ("ro.boot.disable_runas"); + SetDiagnosticElapsed ("deploy.orchestration.run-as-disabled-check.ms", phase); + if (runAsDisabled != null && string.Equals ("true", runAsDisabled.Trim (), StringComparison.OrdinalIgnoreCase)) { + LogFastDeploy2Error ("XA0131", Resources.XA0131_DeveloperModeNotEnabled); + return; + } + SetDiagnosticElapsed ("deploy.orchestration.property-checks.ms", phase); + + phase.Restart (); + await CheckAppInstalledAndDebuggable (PackageName); + SetDiagnosticElapsed ("deploy.orchestration.package-check.ms", phase); + + if (EmbedAssembliesIntoApk) { + await RemoveOverrideDirectory (); + } + + if (ReInstall && !string.IsNullOrEmpty (PackageFile)) { + await Device.UninstallPackage (PackageName, PreserveUserData, CancellationToken); + } + + phase.Restart (); + bool packageFileOutOfDate = !string.IsNullOrEmpty (PackageFile) && + (packageInfo.InternalPath.IndexOf ("unknown", StringComparison.OrdinalIgnoreCase) >= 0 || ReInstall || IsPackageFileOutOfDate ()); + SetDiagnosticElapsed ("deploy.orchestration.package-timestamp.ms", phase); + + if (packageFileOutOfDate) { + try { + phase.Restart (); + await InstallPackage (); + AddDiagnosticElapsed ("deploy.orchestration.install.ms", phase); + } catch (Exception ex) { + AddDiagnosticElapsed ("deploy.orchestration.install.ms", phase); + LogFastDeploy2Error (GetErrorCode (ex), ex.ToString ()); + return; + } + if (!EmbedAssembliesIntoApk && packageInfo.InternalPath.IndexOf ("unknown", StringComparison.OrdinalIgnoreCase) >= 0) { + packageInfo.InternalPath = null; + phase.Restart (); + await CheckAppInstalledAndDebuggable (PackageName); + AddDiagnosticElapsed ("deploy.orchestration.package-check.ms", phase); + if (RaiseRunAsError (packageInfo.InternalPath)) { + return; + } + } + } + + if (EmbedAssembliesIntoApk) + return; + + phase.Restart (); + if ((FastDevFiles?.Length ?? 0) == 0 && (EnvironmentFiles?.Length ?? 0) == 0) { + SetDiagnosticElapsed ("deploy.orchestration.empty-check.ms", phase); + return; + } + SetDiagnosticElapsed ("deploy.orchestration.empty-check.ms", phase); + + diagnosticData.SetProperty ("deploy.app.file.transfer.mode", AppFileTransferMode); + phase.Restart (); + await TerminateApp (); + SetDiagnosticElapsed ("deploy.orchestration.terminate.ms", phase); + await DeployFastDevFilesWithAdbPush (OverrideFullPath); + } + + bool IsPackageFileOutOfDate () + { + var phase = Stopwatch.StartNew (); + var packageFile = GetFullPath (PackageFile); + var lastPackage = File.GetLastWriteTimeUtc (packageFile); + LogDiagnostic ($"LastWriteTime of `{packageFile}`: {lastPackage}"); + SetDiagnosticElapsed ("deploy.orchestration.package-timestamp.path-stat.ms", phase); + return lastUpload < lastPackage; + } + + async Task CheckAppInstalledAndDebuggable (string packageName) + { + var phase = Stopwatch.StartNew (); + packageInfo.UserId = UserID; + packageInfo.PackageName = packageName; + packageInfo.ProcessId = 0; + await EnsureUserIsRunning (); + SetDiagnosticElapsed ("deploy.orchestration.package-check.ensure-user.ms", phase); + phase.Restart (); + string packageInfoOutput = IsSafePackageNameForShell (packageName) ? + await RunAs ("sh", "-c", $"pwd; pidof {packageName} 2>/dev/null || true") : + await RunAs ("pwd"); + SetDiagnosticElapsed ("deploy.orchestration.package-check.run-as-pwd-pidof.ms", phase); + ParsePackageInfoOutput (packageInfoOutput); + if (string.IsNullOrEmpty (packageInfo.InternalPath)) { + packageInfo.InternalPath = packageInfoOutput?.Trim (); + } + phase.Restart (); + SetDiagnosticElapsed ("deploy.orchestration.package-check.run-as-pwd.ms", phase); + if (packageInfo.InternalPath.IndexOf ("Permission denied", StringComparison.OrdinalIgnoreCase) >= 0) { + phase.Restart (); + packageInfo.InternalPath = await RunAs ("readlink", "-f", "."); + SetDiagnosticElapsed ("deploy.orchestration.package-check.readlink.ms", phase); + } + phase.Restart (); + if (packageInfo.InternalPath.IndexOf ("not an application", StringComparison.OrdinalIgnoreCase) >= 0) { + LogDiagnostic ($"Package {packageInfo.PackageName} is a system application."); + packageInfo.IsSystemApplication = true; + diagnosticData.SetProperty ("deploy.systemapp", value: true); + string whoami = await Device.RunShellCommand ("whoami"); + packageInfo.AdbIsRoot = whoami.Trim () == "root"; + LogDiagnostic ($"using {(packageInfo.AdbIsRoot ? "root" : $"su {packageInfo.UserId}")} to install fast deployment files."); + packageInfo.InternalPath = $"/data/user/{(packageInfo.UserId ?? "0")}/{packageInfo.PackageName}"; + SetDiagnosticElapsed ("deploy.orchestration.package-check.system-app.ms", phase); + return; + } + if (packageInfo.InternalPath.IndexOf ("not debuggable", StringComparison.OrdinalIgnoreCase) >= 0) { + LogDiagnostic ($"Package {packageInfo.PackageName} was not debuggable. Forcing ReInstall"); + ReInstall = true; + SetDiagnosticElapsed ("deploy.orchestration.package-check.evaluate.ms", phase); + return; + } + if (packageInfo.InternalPath.IndexOf ("unknown", StringComparison.OrdinalIgnoreCase) >= 0) { + LogDiagnostic ($"Package {packageInfo.PackageName} was not installed."); + SetDiagnosticElapsed ("deploy.orchestration.package-check.evaluate.ms", phase); + return; + } + if (packageInfo.InternalPath.IndexOf ("Permission denied", StringComparison.OrdinalIgnoreCase) >= 0) { + LogDiagnostic ("run-as not supported on this device."); + diagnosticData.SetProperty ("deploy.supports.fastdev", value: false); + } + SetDiagnosticElapsed ("deploy.orchestration.package-check.evaluate.ms", phase); + } + + static bool IsSafePackageNameForShell (string packageName) + { + if (string.IsNullOrEmpty (packageName)) { + return false; + } + foreach (char c in packageName) { + if (!(char.IsLetterOrDigit (c) || c == '.' || c == '_')) { + return false; + } + } + return true; + } + + void ParsePackageInfoOutput (string output) + { + if (string.IsNullOrEmpty (output)) { + return; + } + + string [] lines = output.Replace ("\r", "").Split (new char [] { '\n' }, StringSplitOptions.None); + if (lines.Length > 0 && !string.IsNullOrEmpty (lines [0])) { + packageInfo.InternalPath = lines [0].Trim (); + } + if (lines.Length <= 1) { + return; + } + + string pidLine = lines [1].Trim (); + int space = pidLine.IndexOf (' '); + if (space >= 0) { + pidLine = pidLine.Substring (0, space); + } + if (int.TryParse (pidLine, out int pid)) { + packageInfo.ProcessId = pid; + } + } + + async Task EnsureUserIsRunning () + { + var userId = (UserID ?? "").Trim (); + if (userId.Length == 0 || (int.TryParse (userId, out var id) && id == 0)) { + return; + } + LogDiagnostic ($"Ensuring Android user {userId} is in the 'running' state before run-as queries."); + string output = await Device.RunShellCommand (CancellationToken, "am", "start-user", "-w", userId); + LogDiagnostic ($"'am start-user -w {userId}' returned: {(string.IsNullOrWhiteSpace (output) ? "" : output.Trim ())}"); + } + + async Task InstallPackage () + { + LogDebugMessage ($"Installing Package {PackageName}"); + try { + var phase = Stopwatch.StartNew (); + await Device.PushAndInstallPackageAsync (new PushAndInstallCommand { + ApkFile = PackageFile, + PackageName = PackageName, + ReInstall = ReInstall, + User = UserID, + TestOnly = IsTestOnly, + }, token: CancellationToken); + SetDiagnosticElapsed ("deploy.orchestration.install.push-install.ms", phase); + LogDebugMessage ($"Installed Package {PackageName}."); + } catch (Exception exception) { + var ex = exception; + if (exception is AggregateException aex) { + ex = aex.Flatten ().InnerException; + } + if (!await ShouldThrowIfPackageInstallFailed (ex as PackageAlreadyExistsException)) { + LogDebugMessage ($"Installed Package {PackageName}."); + return; + } + throw; + } + } + + async Task ShouldThrowIfPackageInstallFailed (PackageAlreadyExistsException e) + { + if (e == null) + return true; + + int s = (e.PackageFile ?? "").LastIndexOf ('/'); + string apkBasename = s >= 0 ? e.PackageFile.Substring (s + 1) : e.PackageFile; + + if (apkBasename != Path.GetFileName (PackageFile)) + return false; + + LogDebugMessage (string.Format ("Package '{0}' already exists. Retrying...", PackageName)); + var phase = Stopwatch.StartNew (); + try { + await Device.DeleteFile (e.PackageFile, true, CancellationToken); + } catch { + } + SetDiagnosticElapsed ("deploy.orchestration.install.retry-delete.ms", phase); + bool preserveData = !(e is RequiresUninstallException); + LogDebugMessage (string.Format ("Forcing complete uninstall of '{0}'... Preserving Data: {1}", PackageName, preserveData)); + var uninstallCommand = new PmUninstallCommand () { PackageName = PackageName, User = UserID, PreserveData = preserveData }; + phase.Restart (); + await Device.UninstallPackage (uninstallCommand, cancellationToken: CancellationToken); + SetDiagnosticElapsed ("deploy.orchestration.install.retry-uninstall.ms", phase); + LogDebugMessage (string.Format ("Installing '{0}'...", PackageName)); + phase.Restart (); + await Device.PushAndInstallPackageAsync (new PushAndInstallCommand { + ApkFile = PackageFile, + PackageName = PackageName, + ReInstall = false, + User = UserID + }, token: CancellationToken); + SetDiagnosticElapsed ("deploy.orchestration.install.retry-reinstall.ms", phase); + return false; + } + + async Task RemoveOverrideDirectory () + { + await RunAs ("rm", "-Rf", OverrideFullPath); + } + + async Task TerminateApp () + { + var phase = Stopwatch.StartNew (); + var pid = packageInfo.ProcessId; + if (pid == 0 && packageInfo.IsSystemApplication) { + pid = await Device.GetProcessId (PackageName, CancellationToken); + } + SetDiagnosticElapsed ("deploy.orchestration.terminate.get-pid.ms", phase); + if (pid == 0) { + LogDebugMessage ($"{PackageName} was not running, skipping kill"); + return; + } + LogDebugMessage ($"Terminating {PackageName}..."); + phase.Restart (); + await Device.KillProcessAndWaitForExit (PackageName, CancellationToken); + SetDiagnosticElapsed ("deploy.orchestration.terminate.kill.ms", phase); + LogDebugMessage ($"{PackageName} Terminated."); + } + + protected virtual async Task DeployFastDevFilesWithAdbPush (string overridePath) + { + var phase = Stopwatch.StartNew (); + var directPushFiles = PrepareDirectPushFiles (); + var stagedFiles = new HashSet (directPushFiles.Select (file => file.RelativePath), StringComparer.Ordinal); + SetDiagnosticElapsed ("deploy.fastdeploy2.local.stage.ms", phase); + if (stagedFiles.Count == 0) { + LogDiagnostic ("No FastDev files were prepared for adb push deployment."); + return true; + } + + string remoteStagingPath = GetRemoteAdbPushStagingPath (); + phase.Restart (); + string output = await CreateRemoteStagingDirectories (remoteStagingPath, stagedFiles); + SetDiagnosticElapsed ("deploy.fastdeploy2.remote.mkdir.ms", phase); + if (!string.IsNullOrEmpty (output) && IsShellError (output, "mkdir")) { + LogFastDeploy2Error ("XA0129", output, remoteStagingPath); + return false; + } + + if (!await RemoveStaleRemoteStagingFiles (remoteStagingPath, stagedFiles)) { + return false; + } + + phase.Restart (); + if (!await UploadFiles (remoteStagingPath, directPushFiles)) { + return false; + } + SetDiagnosticElapsed ("deploy.fastdeploy2.upload.ms", phase); + + phase.Restart (); + var stagedFileData = await GetRemoteFileData (remoteStagingPath, runAs: false); + SetDiagnosticElapsed ("deploy.fastdeploy2.staging.stat.ms", phase); + if (stagedFileData == null) { + return false; + } + + phase.Restart (); + var overrideFileData = await GetRemoteFileData (overridePath, runAs: true); + SetDiagnosticElapsed ("deploy.fastdeploy2.override.stat.ms", phase); + if (overrideFileData == null) { + return false; + } + + if (!await RemoveStaleOverrideFiles (overridePath, stagedFileData, overrideFileData)) { + return false; + } + + return await CopyChangedFiles (remoteStagingPath, overridePath, stagedFileData, overrideFileData); + } + + protected async Task CreateRemoteStagingDirectories (string remoteStagingPath, HashSet stagedFiles) + { + var directories = new HashSet (StringComparer.Ordinal) { remoteStagingPath }; + foreach (var file in stagedFiles) { + string directory = Path.GetDirectoryName (file)?.Replace ("\\", "/") ?? ""; + if (!string.IsNullOrEmpty (directory)) { + directories.Add ($"{remoteStagingPath}/{directory}"); + } + } + + var output = new StringBuilder (); + foreach (var batch in BatchArguments ("mkdir", "-p", directories)) { + output.Append (await Device.RunShellCommand (CancellationToken, batch.ToArray ())); + } + return output.ToString (); + } + + protected List PrepareDirectPushFiles () + { + var files = new List (); + foreach (var file in FastDevFiles ?? Array.Empty ()) { + if (Path.GetExtension (file.ItemSpec) == ".so") { + string abi = AndroidRidAbiHelper.GetNativeLibraryAbi (file); + if (abi != PrimaryCpuAbi) { + LogDebugMessage ($"NotifySync SkipCopyFile {file.ItemSpec} abi not suitable for this device."); + continue; + } + } + + files.Add (new DirectPushFile { + LocalPath = GetFullPath (file.ItemSpec), + RelativePath = GetAdbPushTargetPath (file), + }); + LogDiagnostic ($"Prepared {file.ItemSpec} => {files [files.Count - 1].RelativePath}"); + } + + if (EnvironmentFiles?.Length > 0) { + byte [] environmentData = CreateEnvironmentFileData (EnvironmentFiles, out DateTime newestFileDateTime); + if (environmentData.Length > 0) { + string environmentFile = Path.Combine (GetFullPath (IntermediateOutputPath), "fastdeploy2-environment", PrimaryCpuAbi, "environment"); + WriteFileIfChanged (environmentFile, environmentData, newestFileDateTime); + files.Add (new DirectPushFile { + LocalPath = environmentFile, + RelativePath = $"{PrimaryCpuAbi}/environment", + }); + } + } + + return files; + } + + protected bool WriteFileIfChanged (string path, byte [] contents, DateTime modifiedDateTime) + { + if (File.Exists (path) && File.ReadAllBytes (path).SequenceEqual (contents)) { + return false; + } + + Directory.CreateDirectory (Path.GetDirectoryName (path)); + File.WriteAllBytes (path, contents); + File.SetLastWriteTimeUtc (path, modifiedDateTime); + return true; + } + + protected virtual bool UseSymlinkAppFileTransfer () + { + return false; + } + + protected HashSet PrepareAdbPushStagingDirectory (string stagingDirectory) + { + if (Directory.Exists (stagingDirectory)) { + Directory.Delete (stagingDirectory, recursive: true); + } + Directory.CreateDirectory (stagingDirectory); + + var stagedFiles = new HashSet (StringComparer.Ordinal); + foreach (var file in FastDevFiles ?? Array.Empty ()) { + if (!File.Exists (file.ItemSpec)) { + LogDebugMessage ($"File '{file.ItemSpec}' does not exists. Skipping."); + continue; + } + if (Path.GetExtension (file.ItemSpec) == ".so") { + string abi = AndroidRidAbiHelper.GetNativeLibraryAbi (file); + if (abi != PrimaryCpuAbi) { + LogDebugMessage ($"NotifySync SkipCopyFile {file.ItemSpec} abi not suitable for this device."); + continue; + } + } + + string targetPath = GetAdbPushTargetPath (file); + string destination = GetStagingFilePath (stagingDirectory, targetPath); + Directory.CreateDirectory (Path.GetDirectoryName (destination)); + File.Copy (file.ItemSpec, destination, overwrite: true); + File.SetLastWriteTimeUtc (destination, File.GetLastWriteTimeUtc (file.ItemSpec)); + stagedFiles.Add (targetPath.Replace ("\\", "/")); + LogDiagnostic ($"Staged {file.ItemSpec} => {targetPath}"); + } + + if (EnvironmentFiles?.Length > 0) { + string targetPath = $"{PrimaryCpuAbi}/environment"; + string destination = GetStagingFilePath (stagingDirectory, targetPath); + Directory.CreateDirectory (Path.GetDirectoryName (destination)); + byte [] environmentData = CreateEnvironmentFileData (EnvironmentFiles, out DateTime newestFileDateTime); + if (environmentData.Length > 0) { + File.WriteAllBytes (destination, environmentData); + File.SetLastWriteTimeUtc (destination, newestFileDateTime); + stagedFiles.Add (targetPath); + LogDiagnostic ($"Staged @(AndroidEnvironment) files => {targetPath}"); + } + } + + return stagedFiles; + } + + string GetAdbPushTargetPath (ITaskItem file) + { + string targetPath = file.GetMetadata ("TargetPath"); + if (string.IsNullOrEmpty (targetPath)) { + LogDiagnostic ($"'TargetPath' meta data not found on '{file.ItemSpec}'. Falling back to'DestinationSubPath'"); + targetPath = file.GetMetadata ("DestinationSubPath"); + } + if (!string.IsNullOrEmpty (targetPath)) { + return targetPath.Replace ("\\", "/"); + } + return Path.GetFileName (file.ItemSpec); + } + + static string GetStagingFilePath (string stagingDirectory, string targetPath) + { + string fullStagingDirectory = Path.GetFullPath (stagingDirectory); + string destination = Path.GetFullPath (Path.Combine (fullStagingDirectory, targetPath.Replace ('/', Path.DirectorySeparatorChar))); + string stagingPrefix = fullStagingDirectory.EndsWith (Path.DirectorySeparatorChar.ToString (), StringComparison.Ordinal) ? + fullStagingDirectory : + fullStagingDirectory + Path.DirectorySeparatorChar; + if (!destination.StartsWith (stagingPrefix, StringComparison.Ordinal)) { + throw new InvalidOperationException ($"FastDev target path '{targetPath}' escapes staging directory '{stagingDirectory}'."); + } + return destination; + } + + byte [] CreateEnvironmentFileData (ITaskItem [] environments, out DateTime newestFileDateTime) + { + int maxKeyLength = 0; + int maxValueLength = 0; + newestFileDateTime = DateTime.MinValue; + var data = new Dictionary (); + foreach (ITaskItem env in environments ?? Array.Empty ()) { + if (!File.Exists (env.ItemSpec)) + continue; + DateTime modifiedDateTime = File.GetLastWriteTimeUtc (env.ItemSpec); + if (modifiedDateTime > newestFileDateTime) + newestFileDateTime = modifiedDateTime; + foreach (string line in File.ReadLines (env.ItemSpec)) { + if (string.IsNullOrEmpty (line)) + continue; + int index = line.IndexOf ('='); + if (index == -1) { + LogDebugMessage ($"Skipping invalid environment line: {line}"); + continue; + } + var key = line.Substring (0, index); + var value = line.Substring (index + 1); + maxKeyLength = Math.Max (maxKeyLength, key.Length); + maxValueLength = Math.Max (maxValueLength, value.Length); + data [key] = value; + } + } + + if (newestFileDateTime == DateTime.MinValue) { + return Array.Empty (); + } + + maxKeyLength++; + maxValueLength++; + + using (var stream = new MemoryStream ()) + using (var binaryWriter = new BinaryWriter (stream, Encoding.ASCII)) { + binaryWriter.Write (Encoding.ASCII.GetBytes ("0x" + maxKeyLength.ToString ("X8") + '\0')); + binaryWriter.Write (Encoding.ASCII.GetBytes ("0x" + maxValueLength.ToString ("X8") + '\0')); + foreach (var kvp in data) { + binaryWriter.Write (Encoding.ASCII.GetBytes (kvp.Key.PadRight (maxKeyLength, '\0'))); + binaryWriter.Write (Encoding.ASCII.GetBytes (kvp.Value.PadRight (maxValueLength, '\0'))); + } + binaryWriter.Flush (); + return stream.ToArray (); + } + } + + protected async Task RemoveStaleRemoteStagingFiles (string remoteStagingPath, HashSet stagedFiles) + { + var phase = Stopwatch.StartNew (); + string filelist = await Device.RunShellCommand (CancellationToken, "find", remoteStagingPath, "-type", "f"); + if (IsShellError (filelist, "find")) { + LogFastDeploy2Error ("XA0129", filelist, remoteStagingPath); + return false; + } + + string prefix = remoteStagingPath.TrimEnd ('/') + "/"; + var staleFiles = new List (); + foreach (string line in filelist.Split (new char [] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) { + string remoteFile = line.Trim (); + if (!remoteFile.StartsWith (prefix, StringComparison.Ordinal)) { + continue; + } + string relativePath = remoteFile.Substring (prefix.Length); + if (!stagedFiles.Contains (relativePath)) { + staleFiles.Add (remoteFile); + } + } + + for (int i = 0; i < staleFiles.Count; i += StaleFileRemovalBatchSize) { + var args = new List { "rm", "-f" }; + args.AddRange (staleFiles.Skip (i).Take (StaleFileRemovalBatchSize)); + string output = await Device.RunShellCommand (CancellationToken, args.ToArray ()); + if (IsShellError (output, "rm")) { + LogFastDeploy2Error ("XA0129", output, remoteStagingPath); + return false; + } + } + + SetDiagnosticElapsed ("deploy.fastdeploy2.remote.staging.cleanup.ms", phase); + return true; + } + + protected async Task> GetRemoteFileData (string rootPath, bool runAs) + { + string output; + if (runAs) { + output = await RunAs ("find", rootPath, "-type", "f", "-exec", "stat", "-c", "%n|%s|%Y", "{}", "+"); + if (RaiseRunAsError (output)) { + return null; + } + } else { + output = await Device.RunShellCommand (CancellationToken, "find", rootPath, "-type", "f", "-exec", "stat", "-c", "%n|%s|%Y", "{}", "+"); + } + + if (IsMissingDirectoryError (output)) { + return new Dictionary (StringComparer.Ordinal); + } + if (IsShellError (output, "find") || IsShellError (output, "stat")) { + LogFastDeploy2Error ("XA0129", output, rootPath); + return null; + } + + return ParseRemoteFileData (rootPath, output); + } + + Dictionary ParseRemoteFileData (string rootPath, string output) + { + var files = new Dictionary (StringComparer.Ordinal); + string prefix = rootPath.TrimEnd ('/') + "/"; + foreach (string line in output.Split (new char [] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) { + var entries = line.Split (new char [] { '|' }, 3); + if (entries.Length != 3) { + LogDebugMessage ($"Ignoring remote file entry '{line}'. Line is incorrectly formatted."); + continue; + } + string remoteFile = entries [0].Trim (); + if (!remoteFile.StartsWith (prefix, StringComparison.Ordinal)) { + LogDebugMessage ($"Ignoring remote file entry '{line}'. Path is outside '{rootPath}'."); + continue; + } + if (!long.TryParse (entries [1].Trim (), out long size) || !long.TryParse (entries [2].Trim (), out long mtime)) { + LogDebugMessage ($"Ignoring remote file entry '{line}'. Size or timestamp is invalid."); + continue; + } + files [remoteFile.Substring (prefix.Length)] = new RemoteFileInfo { + Size = size, + ModifiedTime = mtime, + }; + } + return files; + } + + protected async Task RemoveStaleOverrideFiles (string overridePath, Dictionary stagedFiles, Dictionary overrideFiles) + { + var phase = Stopwatch.StartNew (); + var staleFiles = new List (); + foreach (var file in overrideFiles.Keys) { + if (!stagedFiles.ContainsKey (file)) { + staleFiles.Add ($"{overridePath}/{file}"); + } + } + + LogDiagnostic ($"FastDeploy2 removing {staleFiles.Count} stale override files."); + diagnosticData.SetProperty ("deploy.fastdeploy2.stale.files", staleFiles.Count); + for (int i = 0; i < staleFiles.Count; i += StaleFileRemovalBatchSize) { + var args = new List { "rm", "-f" }; + args.AddRange (staleFiles.Skip (i).Take (StaleFileRemovalBatchSize)); + string output = await RunAs (args.ToArray ()); + if (RaiseRunAsError (output) || IsShellError (output, "rm")) { + LogFastDeploy2Error ("XA0129", output, overridePath); + return false; + } + } + SetDiagnosticElapsed ("deploy.fastdeploy2.stale.remove.ms", phase); + return true; + } + + protected async Task CopyChangedFiles (string remoteStagingPath, string overridePath, Dictionary stagedFiles, Dictionary overrideFiles) + { + var phase = Stopwatch.StartNew (); + var changedFiles = new List (); + foreach (var file in stagedFiles) { + if (!overrideFiles.TryGetValue (file.Key, out RemoteFileInfo existing) || + existing.Size != file.Value.Size || + existing.ModifiedTime != file.Value.ModifiedTime) { + changedFiles.Add (file.Key); + } + } + SetDiagnosticElapsed ("deploy.fastdeploy2.compare.ms", phase); + + LogDiagnostic ($"FastDeploy2 copying {changedFiles.Count} changed override files."); + diagnosticData.SetProperty ("deploy.fastdeploy2.changed.files", changedFiles.Count); + var filesByDirectory = new Dictionary> (StringComparer.Ordinal); + foreach (string file in changedFiles) { + string directory = Path.GetDirectoryName (file)?.Replace ("\\", "/") ?? ""; + if (!filesByDirectory.TryGetValue (directory, out List files)) { + files = new List (); + filesByDirectory.Add (directory, files); + } + files.Add (file); + } + + foreach (var group in filesByDirectory) { + string targetDirectory = string.IsNullOrEmpty (group.Key) ? overridePath : $"{overridePath}/{group.Key}"; + phase.Restart (); + string output = await RunAs ("mkdir", "-p", targetDirectory); + AddDiagnosticElapsed ("deploy.fastdeploy2.override.mkdir.ms", phase); + if (RaiseRunAsError (output) || IsShellError (output, "mkdir")) { + LogFastDeploy2Error ("XA0129", output, targetDirectory); + return false; + } + + for (int i = 0; i < group.Value.Count; i += CopyBatchSize) { + var args = new List { "cp", "-p" }; + foreach (string file in group.Value.Skip (i).Take (CopyBatchSize)) { + args.Add ($"{remoteStagingPath}/{file}"); + } + args.Add (targetDirectory); + phase.Restart (); + output = await RunAs (args.ToArray ()); + AddDiagnosticElapsed ("deploy.fastdeploy2.override.copy.ms", phase); + if (RaiseRunAsError (output) || IsShellError (output, "cp")) { + LogFastDeploy2Error ("XA0129", output, targetDirectory); + return false; + } + } + } + + return true; + } + + protected IEnumerable> BatchArguments (string command, string option, IEnumerable values) + { + var batch = new List { command, option }; + int length = command.Length + option.Length + 2; + foreach (var value in values) { + int itemLength = value.Length + 3; + if (batch.Count > 2 && length + itemLength >= MaxShellCommandLength) { + yield return batch; + batch = new List { command, option }; + length = command.Length + option.Length + 2; + } + batch.Add (value); + length += itemLength; + } + if (batch.Count > 2) { + yield return batch; + } + } + + protected void SetDiagnosticElapsed (string key, Stopwatch stopwatch) + { + diagnosticData.SetProperty (key, stopwatch.ElapsedMilliseconds); + } + + protected void AddDiagnosticElapsed (string key, Stopwatch stopwatch) + { + if (!long.TryParse (diagnosticData.Properties [key], out long current)) { + current = 0; + } + diagnosticData.SetProperty (key, current + stopwatch.ElapsedMilliseconds); + } + + protected void SetDiagnosticProperty (string key, int value) + { + diagnosticData.SetProperty (key, value); + } + + protected void SetDiagnosticProperty (string key, string value) + { + diagnosticData.SetProperty (key, value); + } + + protected virtual string GetLocalStagingDirectory () + { + return Path.Combine (GetFullPath (IntermediateOutputPath), "fastdeploy2"); + } + + protected virtual async Task UploadStagingDirectory (string stagingDirectory, string remoteStagingPath) + { + var args = new List { "push" }; + if (!string.IsNullOrEmpty (AdbPushCompressionAlgorithm)) { + args.Add ("-z"); + args.Add (AdbPushCompressionAlgorithm); + } + args.Add ("--sync"); + args.Add (Path.Combine (stagingDirectory, ".")); + args.Add (remoteStagingPath); + + var result = await RunAdbCommand (args.ToArray ()); + if (result.ExitCode != 0) { + LogFastDeploy2Error ("XA0129", result.Output, stagingDirectory); + return false; + } + SetAdbPushFileCounts (result.Output); + LogDiagnostic (result.Output); + return true; + } + + protected async Task UploadFiles (string remoteStagingPath, List files) + { + int pushed = 0; + int skipped = 0; + int batches = 0; + foreach (var group in files.GroupBy (file => Path.GetDirectoryName (file.RelativePath)?.Replace ("\\", "/") ?? "", StringComparer.Ordinal)) { + string remoteDirectory = string.IsNullOrEmpty (group.Key) ? remoteStagingPath : $"{remoteStagingPath}/{group.Key}"; + foreach (var batch in BatchPushFiles (group.ToList (), remoteDirectory)) { + var result = await RunAdbCommand (batch.ToArray ()); + if (result.ExitCode != 0) { + LogFastDeploy2Error ("XA0129", result.Output, remoteDirectory); + return false; + } + var counts = TryParsePushSummary (result.Output); + pushed += counts.pushed; + skipped += counts.skipped; + batches++; + LogDiagnostic (result.Output); + } + } + SetDiagnosticProperty ("deploy.fastdeploy2.adb.pushed.files", pushed); + SetDiagnosticProperty ("deploy.fastdeploy2.adb.skipped.files", skipped); + SetDiagnosticProperty ("deploy.fastdeploy2.bulk.batches", batches); + return true; + } + + IEnumerable> BatchPushFiles (List files, string remoteDirectory) + { + var batch = CreatePushArgsPrefix (); + int length = EstimateCommandLength (batch) + remoteDirectory.Length + 4; + foreach (var file in files) { + if (Path.GetFileName (file.LocalPath) != Path.GetFileName (file.RelativePath)) { + yield return CreatePushArgs (file.LocalPath, $"{remoteDirectory}/{Path.GetFileName (file.RelativePath)}"); + continue; + } + + int itemLength = file.LocalPath.Length + 3; + if (batch.Count > 3 && length + itemLength >= 4096) { + batch.Add (remoteDirectory); + yield return batch; + batch = CreatePushArgsPrefix (); + length = EstimateCommandLength (batch) + remoteDirectory.Length + 4; + } + batch.Add (file.LocalPath); + length += itemLength; + } + if (batch.Count > 3) { + batch.Add (remoteDirectory); + yield return batch; + } + } + + List CreatePushArgs (string localPath, string remotePath) + { + var args = CreatePushArgsPrefix (); + args.Add (localPath); + args.Add (remotePath); + return args; + } + + List CreatePushArgsPrefix () + { + var args = new List { "push" }; + if (!string.IsNullOrEmpty (AdbPushCompressionAlgorithm)) { + args.Add ("-z"); + args.Add (AdbPushCompressionAlgorithm); + } + args.Add ("--sync"); + return args; + } + + int EstimateCommandLength (List args) + { + int length = 0; + foreach (var arg in args) { + length += arg.Length + 3; + } + return length; + } + + protected (int pushed, int skipped) TryParsePushSummary (string output) + { + int pushed = 0; + int skipped = 0; + foreach (var line in output.Split (new char [] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) { + var match = AdbPushSummaryRegex.Match (line); + if (!match.Success) { + continue; + } + pushed = int.Parse (match.Groups ["pushed"].Value); + skipped = int.Parse (match.Groups ["skipped"].Value); + } + return (pushed, skipped); + } + + protected void SetAdbPushFileCounts (string output) + { + var match = AdbPushSummaryRegex.Match (output ?? ""); + if (!match.Success) { + return; + } + diagnosticData.SetProperty ("deploy.fastdeploy2.adb.pushed.files", match.Groups ["pushed"].Value); + diagnosticData.SetProperty ("deploy.fastdeploy2.adb.skipped.files", match.Groups ["skipped"].Value); + } + + protected async Task RunAdbCommand (params string [] arguments) + { + return await RunAdbCommand (arguments, environmentVariables: null); + } + + protected async Task RunAdbCommand (string [] arguments, Dictionary environmentVariables) + { + string adb = ResolveAdbPath (); + var processArguments = new ProcessArgumentBuilder (); + if (Device != null && !string.IsNullOrEmpty (Device.ID) && !string.Equals (Device.ID, "any", StringComparison.OrdinalIgnoreCase)) { + processArguments.AddQuoted ("-s"); + processArguments.AddQuoted (Device.ID); + } + processArguments.AddQuoted (arguments); + + var stdout = new StringBuilder (); + var stderr = new StringBuilder (); + var stdoutCompleted = new ManualResetEvent (false); + var stderrCompleted = new ManualResetEvent (false); + var psi = new ProcessStartInfo { + FileName = adb, + Arguments = processArguments.ToString (), + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + }; + if (environmentVariables != null) { + foreach (var kvp in environmentVariables) { + psi.EnvironmentVariables [kvp.Key] = kvp.Value; + } + } + + LogDiagnostic ($"Running adb: {psi.FileName} {psi.Arguments}"); + using (var process = new Process ()) { + process.StartInfo = psi; + process.OutputDataReceived += (sender, e) => { + if (e.Data != null) { + lock (stdout) { + stdout.AppendLine (e.Data); + } + LogDiagnostic (e.Data); + } else { + stdoutCompleted.Set (); + } + }; + process.ErrorDataReceived += (sender, e) => { + if (e.Data != null) { + lock (stderr) { + stderr.AppendLine (e.Data); + } + LogDiagnostic (e.Data); + } else { + stderrCompleted.Set (); + } + }; + + process.Start (); + process.BeginOutputReadLine (); + process.BeginErrorReadLine (); + using (CancellationToken.Register (() => { + try { + if (!process.HasExited) { + process.Kill (); + } + } catch (InvalidOperationException) { + } + })) { + await Task.Run (() => process.WaitForExit (), CancellationToken); + } + stdoutCompleted.WaitOne (TimeSpan.FromSeconds (30)); + stderrCompleted.WaitOne (TimeSpan.FromSeconds (30)); + return new AdbCommandResult { + ExitCode = process.ExitCode, + Output = $"{stdout}{stderr}".Trim (), + }; + } + } + + List BuildRunAsArgs () + { + List args = new List (); + if (packageInfo.IsSystemApplication) { + if (!packageInfo.AdbIsRoot) { + args.Add ("su"); + args.Add (packageInfo.UserId ?? "0"); + } + return args; + } + args.Add ("run-as"); + args.Add (packageInfo.PackageName); + if (!string.IsNullOrEmpty (packageInfo.UserId)) { + args.Add ("--user"); + args.Add (packageInfo.UserId); + } + return args; + } + + protected async Task RunAs (params string [] arguments) + { + List args = BuildRunAsArgs (); + args.AddRange (arguments); + string result = await Device.RunShellCommand (CancellationToken, args.ToArray ()); + LogDebugMessage ($"{arguments [0]} returned: {result}"); + return result; + } + + protected string ResolveAdbPath () + { + var exe = string.IsNullOrEmpty (AdbToolExe) ? "adb" : AdbToolExe; + return string.IsNullOrEmpty (AdbToolPath) ? exe : Path.Combine (AdbToolPath, exe); + } + + protected virtual string GetRemoteAdbPushStagingPath () + { + return $"{RemoteStagingRoot}/{PackageName}/{GetUserId ()}"; + } + + protected string GetUserId () + { + return string.IsNullOrEmpty (UserID) ? "0" : UserID; + } + + protected void LogFastDeploy2Error (string errorCode, string error, string file = "") + { + LogDiagnosticDataError (errorCode, error, file); + PrintDiagnostics (); + if (errorCode == "XA0129") { + LogCodedError (errorCode, Resources.XA0129_ErrorDeployingFile, file); + } else { + LogCodedError (errorCode, error); + } + } + + protected void LogDiagnostic (string message) + { + if (DiagnosticLogging) { + LogDebugMessage (message); + return; + } + diagnosticLogs.Enqueue (message); + } + + void PrintDiagnostics () + { + while (diagnosticLogs.Count > 0) { + LogMessage (diagnosticLogs.Dequeue ()); + } + LogMessage ($"{diagnosticData.Task}"); + foreach (var t in diagnosticData.Properties) { + LogMessage ($"\t{t.Key}: {t.Value}"); + } + } + + void LogDiagnosticDataError (string errorCode, string error, string file = "") + { + diagnosticData.SetProperty ("deploy.result", "Failed"); + if (!string.IsNullOrEmpty (file)) + diagnosticData.SetProperty ("pii.deploy.file", file); + diagnosticData.SetProperty ("pii.deploy.error", error); + diagnosticData.SetProperty ("deploy.error.code", errorCode); + } + + void SaveDiagnosticData (long ms) + { + JsonSerializerOptions options = new JsonSerializerOptions { + WriteIndented = true + }; + diagnosticData.SetProperty ("deploy.duration.ms", ms); + string newPath = Path.Combine (IntermediateOutputPath, "diagnostics", $"{GetType ().Name.ToLowerInvariant ()}.json"); + File.WriteAllText (newPath, JsonSerializer.Serialize (diagnosticData, options)); + } + + protected string GetFullPath (string dir) => Path.IsPathRooted (dir) ? dir : Path.GetFullPath (Path.Combine (WorkingDirectory, dir)); + + protected bool RaiseRunAsError (string error) + { + if (TryGetRunAsErrorCode (error, out var err)) { + LogDiagnosticDataError (err.code, err.message); + PrintDiagnostics (); + LogCodedError (err.code, err.message, error); + return true; + } + return false; + } + + bool TryGetRunAsErrorCode (string error, out (string error, string code, string message) errTuple) + { + errTuple = (error: "unknown", code: "XA0132", message: error); + foreach (var err in runas_codes) { + if (error.IndexOf (err.error, StringComparison.OrdinalIgnoreCase) >= 0) { + errTuple = err; + return true; + } + } + return false; + } + + string GetErrorCode (Exception ex) + { + switch (ex) { + case IncompatibleCpuAbiException e: + return "ADB0020"; + case RequiresUninstallException e: + return "ADB0030"; + case SdkNotSupportedException e: + return "ADB0040"; + case PackageAlreadyExistsException e: + return "ADB0050"; + case InsufficientSpaceException e: + return "ADB0060"; + case InstallFailedException e: + return "ADB0010"; + default: + return GetErrorCode (ex.Message); + } + } + + static string GetErrorCode (string message) + { + foreach (var errorCode in error_codes) + if (message.IndexOf (errorCode.message, StringComparison.OrdinalIgnoreCase) >= 0) + return errorCode.code; + + return "ADB1000"; + } + + protected static bool IsShellError (string output, string command) + { + if (string.IsNullOrEmpty (output)) { + return false; + } + return output.IndexOf ($"{command}:", StringComparison.OrdinalIgnoreCase) >= 0 || + output.IndexOf ("No such file or directory", StringComparison.OrdinalIgnoreCase) >= 0 || + output.IndexOf ("Permission denied", StringComparison.OrdinalIgnoreCase) >= 0 || + output.IndexOf ("Read-only file system", StringComparison.OrdinalIgnoreCase) >= 0 || + output.IndexOf ("not found", StringComparison.OrdinalIgnoreCase) >= 0; + } + + protected static bool IsMissingDirectoryError (string output) + { + return !string.IsNullOrEmpty (output) && + output.IndexOf ("No such file or directory", StringComparison.OrdinalIgnoreCase) >= 0; + } + + protected struct AdbCommandResult + { + public int ExitCode; + public string Output; + } + + static readonly List<(string error, string code, string message)> runas_codes = new List<(string error, string code, string message)> () { + { (error: "run-as is disabled", code: "XA0131", message: Resources.XA0131_DeveloperModeNotEnabled ) }, + { (error: "Could not set capabilities", code: "XA0131", message: Resources.XA0131_DeveloperModeNotEnabled ) }, + { (error: "unknown", code: "XA0132", message: Resources.XA0132_PackageNotInstalled ) }, + { (error: "Permission denied", code: "XA0133", message: Resources.XA0133_RunAsPermissionDenied ) }, + { (error: "package not debuggable", code: "XA0134", message: Resources.XA0134_RunAsPackageNotDebuggable ) }, + { (error: "package not an application", code: "XA0135", message: Resources.XA0135_RunAsPackageNotAndApplication ) }, + { (error: "has corrupt installation", code: "XA0136", message: Resources.XA0136_RunAsCorruptInstallation ) }, + { (error: "users can run this program", code: "XA0137", message: Resources.XA0137_RunAsOSCorrupt ) }, + { (error: "set SELinux security context", code: "XA0137", message: Resources.XA0137_RunAsOSCorrupt ) }, + { (error: "to package's data directory", code: "XA0137", message: Resources.XA0137_RunAsOSCorrupt ) }, + { (error: "couldn't stat", code: "XA0137", message: Resources.XA0137_RunAsOSCorrupt ) }, + { (error: "has wrong owner", code: "XA0137", message: Resources.XA0137_RunAsOSCorrupt ) }, + { (error: "readable or writable by others", code: "XA0137", message: Resources.XA0137_RunAsOSCorrupt ) }, + { (error: "not a directory", code: "XA0137", message: Resources.XA0137_RunAsOSCorrupt ) }, + { (error: "run-as:", code: "XA0137", message: Resources.XA0137_RunAsOSCorrupt ) }, + }; + + static readonly List<(string code, string message)> error_codes = new List<(string code, string message)> () { + { (code: "ADB0010", message: nameof (InstallFailedException)) }, + { (code: "ADB0020", message: nameof (IncompatibleCpuAbiException)) }, + { (code: "ADB0030", message: nameof (RequiresUninstallException)) }, + { (code: "ADB0040", message: nameof (SdkNotSupportedException)) }, + { (code: "ADB0050", message: nameof (PackageAlreadyExistsException)) }, + { (code: "ADB0060", message: nameof (InsufficientSpaceException)) }, + { (code: "ADB1001", message: "failed to create session") }, + { (code: "ADB1002", message: "failed to finalize session") }, + { (code: "ADB1003", message: "product directory not specified; set $ANDROID_PRODUCT_OUT") }, + { (code: "ADB1004", message: "server didn't ACK") }, + { (code: "ADB1005", message: "server killed by remote request") }, + { (code: "ADB1006", message: "timed out waiting for threads to finish reading from ADB server") }, + { (code: "ADB1007", message: "usage:") }, + { (code: "ADB1008", message: "bulkIn endpoint not assigned") }, + { (code: "ADB1009", message: "bulkOut endpoint not assigned") }, + { (code: "ADB1010", message: "cannot start server on remote host") }, + { (code: "ADB1011", message: "cap_clear_flag(INHERITABLE) failed") }, + { (code: "ADB1012", message: "cap_clear_flag(PEMITTED) failed") }, + { (code: "ADB1013", message: "cap_set_proc() failed") }, + { (code: "ADB1014", message: "Client not connected") }, + { (code: "ADB1015", message: "Could not find device interface") }, + { (code: "ADB1016", message: "Could not set SELinux context") }, + { (code: "ADB1017", message: "Could not start mdnsd") }, + { (code: "ADB1018", message: "could not start server") }, + { (code: "ADB1019", message: "couldn't allocate StdinReadArgs object") }, + { (code: "ADB1020", message: "couldn't create USB matching dictionary") }, + { (code: "ADB1021", message: "daemon started successfully") }, + { (code: "ADB1022", message: "daemon still not running") }, + { (code: "ADB1023", message: "error: no emulator detected") }, + { (code: "ADB1024", message: "error: shell command too long") }, + { (code: "ADB1025", message: "Failed to allocate key") }, + { (code: "ADB1026", message: "failed to allocate memory for ShellProtocol object") }, + { (code: "ADB1027", message: "failed to allocate new subprocess") }, + { (code: "ADB1028", message: "Failed to convert to public key") }, + { (code: "ADB1029", message: "failed to create pipe to report error") }, + { (code: "ADB1030", message: "failed to create run queue notify socketpair") }, + { (code: "ADB1031", message: "failed to empty run queue notify fd") }, + { (code: "ADB1032", message: "failed to encode RSA public key") }, + { (code: "ADB1033", message: "Failed to generate new key") }, + { (code: "ADB1034", message: "failed to get matching services") }, + { (code: "ADB1035", message: "failed to get user home directory") }, + { (code: "ADB1036", message: "Failed to get user key") }, + { (code: "ADB1037", message: "failed to make run queue notify socket nonblocking") }, + { (code: "ADB1038", message: "Failed to read key") }, + { (code: "ADB1039", message: "failed to register libusb hotplug callback") }, + { (code: "ADB1040", message: "failed to start daemon") }, + { (code: "ADB1041", message: "failed to write to run queue notify fd") }, + { (code: "ADB1042", message: "Key must be a null-terminated string") }, + { (code: "ADB1043", message: "Pipe stalled, clearing stall") }, + { (code: "ADB1044", message: "Public key too large to base64 encode") }, + { (code: "ADB1045", message: "reply fd for adb server to client communication not specified") }, + { (code: "ADB1046", message: "run queue notify fd was closed") }, + { (code: "ADB1047", message: "Unable to get interface class, subclass and protocol") }, + { (code: "ADB1048", message: "usb_read interface was null") }, + { (code: "ADB1049", message: "usb_write interface was null") }, + { (code: "ADB1050", message: "cannot fit pipe handle value into 32-bits") }, + { (code: "ADB1051", message: "connect error for create") }, + { (code: "ADB1052", message: "connect error for finalize") }, + { (code: "ADB1053", message: "connect error for write") }, + { (code: "ADB1054", message: "could not open adb service") }, + { (code: "ADB1055", message: "couldn't parse 'wait-for' command") }, + { (code: "ADB1056", message: "CreateFileW 'nul' failed") }, + { (code: "ADB1057", message: "only wrote") }, + { (code: "ADB1058", message: "error response") }, + { (code: "ADB1059", message: "failed to install") }, + { (code: "ADB1060", message: "failed to read block") }, + { (code: "ADB1061", message: "failed to write data") }, + { (code: "ADB1062", message: "invalid reply fd") }, + { (code: "ADB1063", message: "pre-KitKat sideload connection failed") }, + { (code: "ADB1064", message: "doesn't match this client") }, + { (code: "ADB1065", message: "sideload connection failed") }, + { (code: "ADB1066", message: "unable to connect for backup") }, + { (code: "ADB1067", message: "unable to connect for restore") }, + { (code: "ADB1068", message: "unable to connect for") }, + { (code: "ADB1069", message: "unexpected output length for") }, + { (code: "ADB1070", message: "expected 'any', 'local', or 'usb'") }, + { (code: "ADB1071", message: "attempted to close unregistered usb_handle for") }, + { (code: "ADB1072", message: "attempted to reinitialize adb_server_socket_spec") }, + { (code: "ADB1073", message: "cannot connect to daemon at") }, + { (code: "ADB1074", message: "Cannot mkdir") }, + { (code: "ADB1075", message: "Connection banner is too long") }, + { (code: "ADB1076", message: "Could not clear pipe stall both ends") }, + { (code: "ADB1077", message: "Could not install smartsocket listener") }, + { (code: "ADB1078", message: "Could not open interface") }, + { (code: "ADB1079", message: "Could not register mDNS service") }, + { (code: "ADB1080", message: "Couldn't create a device interface") }, + { (code: "ADB1081", message: "Couldn't grab device from interface") }, + { (code: "ADB1082", message: "Couldn't query the interface") }, + { (code: "ADB1083", message: "daemon not running; starting now at") }, + { (code: "ADB1084", message: "destroying fde not created by fdevent_create") }, + { (code: "ADB1085", message: "Encountered mDNS registration error") }, + { (code: "ADB1086", message: "not implemented on Win32") }, + { (code: "ADB1087", message: "could not connect to TCP port") }, + { (code: "ADB1088", message: "no emulator connected") }, + { (code: "ADB1089", message: "only supports allocating a pty") }, + { (code: "ADB1090", message: "failed to connect to socket") }, + { (code: "ADB1091", message: "failed to convert errno") }, + { (code: "ADB1092", message: "failed to initialize libusb") }, + { (code: "ADB1093", message: "Failed to parse key") }, + { (code: "ADB1094", message: "failed to set non-blocking mode for fd") }, + { (code: "ADB1095", message: "failed to start subprocess management thread") }, + { (code: "ADB1096", message: "failed to start subprocess") }, + { (code: "ADB1097", message: "FindDeviceInterface - could not get pipe properties") }, + { (code: "ADB1098", message: "Invalid base64 key") }, + { (code: "ADB1099", message: "Key too long") }, + { (code: "ADB1100", message: "No ':' found in shell service arguments") }, + { (code: "ADB1101", message: "observed inotify event for unmonitored path") }, + { (code: "ADB1102", message: "packet data length doesn't match payload") }, + { (code: "ADB1103", message: "Unable to create a device plug-in") }, + { (code: "ADB1104", message: "Unable to create an interface plug-in") }, + { (code: "ADB1105", message: "Unable to get number of endpoints") }, + { (code: "ADB1106", message: "unexpected type for") }, + { (code: "ADB1107", message: "Unknown socket type") }, + { (code: "ADB1108", message: "Unknown trace flag") }, + { (code: "ADB1109", message: "usb_read failed with status") }, + { (code: "ADB1110", message: "usb_write failed with status") }, + { (code: "ADB1111", message: "adb_socket_accept: failed to allocate accepted socket") }, + { (code: "ADB1112", message: "cannot create service socket pair") }, + { (code: "ADB1113", message: "cannot create socket pair") }, + { (code: "ADB1114", message: "Error generating token") }, + { (code: "ADB1115", message: "Error getting user key filename") }, + { (code: "ADB1116", message: "Failed to accept") }, + { (code: "ADB1117", message: "failed to create inotify fd") }, + { (code: "ADB1118", message: "Failed to get adbd socket") }, + { (code: "ADB1119", message: "failed to shutdown writes to FD") }, + { (code: "ADB1120", message: "Failed to write PK") }, + { (code: "ADB1121", message: "failed to write the exit code packet") }, + { (code: "ADB1122", message: "read of inotify event failed") }, + { (code: "ADB1123", message: "remote usb: 1 - write terminated") }, + { (code: "ADB1124", message: "remote usb: 2 - write terminated") }, + { (code: "ADB1125", message: "remote usb: read terminated (message)") }, + { (code: "ADB1126", message: "remote usb: terminated (data)") }, + { (code: "ADB1127", message: "select failed, closing subprocess pipes") }, + { (code: "ADB1128", message: "backup unable to create file") }, + { (code: "ADB1129", message: "cannot create thread") }, + { (code: "ADB1130", message: "cannot get executable path") }, + { (code: "ADB1131", message: "cannot make handle") }, + { (code: "ADB1132", message: "CreatePipe failed") }, + { (code: "ADB1133", message: "CreateProcessW failed") }, + { (code: "ADB1134", message: "error while reading for") }, + { (code: "ADB1135", message: "execl returned") }, + { (code: "ADB1136", message: "failed to duplicate file descriptor for") }, + { (code: "ADB1137", message: "failed to get file descriptor for") }, + { (code: "ADB1138", message: "failed to open duplicate stream for") }, + { (code: "ADB1139", message: "failed to open file") }, + { (code: "ADB1140", message: "failed to read command") }, + { (code: "ADB1141", message: "failed to read data from") }, + { (code: "ADB1142", message: "failed to read from") }, + { (code: "ADB1143", message: "failed to read package block") }, + { (code: "ADB1144", message: "failed to seek to package block") }, + { (code: "ADB1145", message: "failed to set binary mode for duplicate of") }, + { (code: "ADB1146", message: "failed to stat file") }, + { (code: "ADB1147", message: "failed to stat") }, + { (code: "ADB1148", message: "failed to unbuffer") }, + { (code: "ADB1149", message: "adb_socket_accept: accept on fd") }, + { (code: "ADB1150", message: "unable to open file") }, + { (code: "ADB1151", message: "unexpected result waiting for threads") }, + { (code: "ADB1152", message: "aio: got error event on") }, + { (code: "ADB1153", message: "aio: got error submitting") }, + { (code: "ADB1154", message: "aio: got error waiting") }, + { (code: "ADB1155", message: "cannot open bulk-in endpoint") }, + { (code: "ADB1156", message: "cannot open bulk-out endpoint") }, + { (code: "ADB1157", message: "cannot open control endpoint") }, + { (code: "ADB1158", message: "Can't load") }, + { (code: "ADB1159", message: "could not read ok from ADB Server") }, + { (code: "ADB1160", message: "couldn't allocate state_info") }, + { (code: "ADB1161", message: "Couldn't read") }, + { (code: "ADB1162", message: "cannot write to emulator") }, + { (code: "ADB1163", message: "error reading output FD") }, + { (code: "ADB1164", message: "error reading protocol FD") }, + { (code: "ADB1165", message: "error reading stdin FD") }, + { (code: "ADB1166", message: "write failure during connection") }, + { (code: "ADB1167", message: "failed to fcntl(F_GETFL) for fd") }, + { (code: "ADB1168", message: "failed to fcntl(F_SETFL) for fd") }, + { (code: "ADB1169", message: "failed to inotify_add_watch on path") }, + { (code: "ADB1170", message: "Failed to listen on") }, + { (code: "ADB1171", message: "failed to open directory") }, + { (code: "ADB1172", message: "Failed to write public key to") }, + { (code: "ADB1173", message: "failure closing FD") }, + { (code: "ADB1174", message: "pipe failed in launch_server") }, + { (code: "ADB1175", message: "poll() }, ret =") }, + { (code: "ADB1176", message: "remote usb: read overflow") }, + { (code: "ADB1177", message: "received framework auth socket connection again") }, + { (code: "ADB1178", message: "failed to claim adb interface for device") }, + { (code: "ADB1179", message: "failed to clear halt on device") }, + { (code: "ADB1180", message: "failed to get active config descriptor for device at") }, + { (code: "ADB1181", message: "failed to get device descriptor for device at") }, + { (code: "ADB1182", message: "failed to get serial from device at") }, + { (code: "ADB1183", message: "failed to open usb device at") }, + { (code: "ADB1184", message: "failed to set interface alt setting for device") }, + { (code: "ADB1185", message: "failed to submit zero-length write") }, + { (code: "ADB1186", message: "failed to submit") }, + { (code: "ADB1187", message: "Ignoring unknown shell service argument") }, + { (code: "ADB1188", message: "transfer failed:") }, + { (code: "ADB1189", message: "received empty serial from device at") }, + { (code: "ADB1190", message: "refusing to recurse into directory") }, + { (code: "ADB1191", message: "unmonitored event for") }, + { (code: "ADB1192", message: "Failed to open") }, + { (code: "ADB1193", message: "failed to write") }, + }; + + static readonly Regex AdbPushSummaryRegex = new Regex (@"(?\d+) files? pushed, (?\d+) skipped", RegexOptions.Compiled); + } +} 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 dc270a09cda..71afbd052f1 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 @@ -23,6 +23,7 @@ Copyright (C) 2016 Xamarin. All rights reserved. + @@ -321,7 +322,17 @@ Copyright (C) 2016 Xamarin. All rights reserved. <_ReInstall Condition=" '$(_ReInstall)' == '' ">False <_AndroidIsTestOnlyPackage Condition=" '$(_AndroidIsTestOnlyPackage)' == '' ">False <_FastDeploymentDiagnosticLogging Condition=" '$(_FastDeploymentDiagnosticLogging)' == '' ">False + <_AndroidFastDevStrategy Condition=" '$(_AndroidFastDevStrategy)' == '' ">FastDeploy2 + <_AndroidFastDeployAppFileTransferMode Condition=" '$(_AndroidFastDeployAppFileTransferMode)' == '' And '$(_AndroidFastDevStrategy)' == 'FastDeploy2' ">Symlink + <_AndroidFastDeployAppFileTransferMode Condition=" '$(_AndroidFastDeployAppFileTransferMode)' == '' ">Copy + any + + @@ -331,6 +342,7 @@ Copyright (C) 2016 Xamarin. All rights reserved. <_EnvironmentFiles Include="@(AndroidEnvironment);@(LibraryEnvironments)" /> + Date: Thu, 18 Jun 2026 12:30:05 +0200 Subject: [PATCH 02/11] Fix FastDeploy2 copy fallback from symlinks Remove existing override paths before copying changed files so Copy mode can safely recover from a symlink-based override tree. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/FastDeploy2.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs index ca8f17b51ca..912741ca771 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs @@ -931,8 +931,19 @@ protected async Task CopyChangedFiles (string remoteStagingPath, string ov } for (int i = 0; i < group.Value.Count; i += CopyBatchSize) { + var batchFiles = group.Value.Skip (i).Take (CopyBatchSize).ToList (); + var removeArgs = new List { "rm", "-f" }; + foreach (string file in batchFiles) { + removeArgs.Add ($"{targetDirectory}/{Path.GetFileName (file)}"); + } + output = await RunAs (removeArgs.ToArray ()); + if (RaiseRunAsError (output) || IsShellError (output, "rm")) { + LogFastDeploy2Error ("XA0129", output, targetDirectory); + return false; + } + var args = new List { "cp", "-p" }; - foreach (string file in group.Value.Skip (i).Take (CopyBatchSize)) { + foreach (string file in batchFiles) { args.Add ($"{remoteStagingPath}/{file}"); } args.Add (targetDirectory); From 807edd7deca60378387c07ee365dc2c52520ea05 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 18 Jun 2026 13:01:12 +0200 Subject: [PATCH 03/11] Address FastDeploy2 review feedback Fix manifest reset handling, device-scoped manifest state, adb batching edge cases, symlink/copy recovery, and diagnostics logging concurrency. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/FastDeploy2.Manifest.cs | 70 ++++++++++++++++--- .../Tasks/FastDeploy2.cs | 35 +++++++--- .../Tests/PerformanceTest.cs | 2 +- 3 files changed, 89 insertions(+), 18 deletions(-) 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 c208e138934..7d7a8769a58 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs @@ -14,6 +14,7 @@ public class FastDeploy2 : FastDeploy2Base { const string RemoteStagingRootPath = "/tmp/fastdeploy2"; const string RemoteReadyMarker = ".fastdeploy2-ready"; + const string OverrideSymlinkReadyMarker = ".fastdeploy2-symlinks"; const int MaxAdbCommandLength = 4096; public override string TaskPrefix => "FD2"; @@ -37,6 +38,9 @@ protected override async Task DeployFastDevFilesWithAdbPush (string overri var previousManifest = remoteReady ? LoadPreviousManifest () : null; if (previousManifest == null) { SetDiagnosticProperty ("deploy.fastdeploy2.manifest.full.push", 1); + if (!await ResetRemoteStagingDirectory (remoteStagingPath)) { + return false; + } } var changedFiles = GetChangedFiles (currentManifest, previousManifest); @@ -85,9 +89,11 @@ bool UseShellSymlinkAppFileTransfer () async Task UpdateOverrideShellSymlinks (string remoteStagingPath, string overridePath, Dictionary currentManifest, Dictionary previousManifest, List removedFiles) { - var newFiles = previousManifest == null ? + bool overrideSymlinksReady = await IsOverrideSymlinkReady (overridePath); + var previousSymlinkManifest = overrideSymlinksReady ? previousManifest : null; + var newFiles = previousSymlinkManifest == null ? new HashSet (currentManifest.Keys, StringComparer.Ordinal) : - new HashSet (currentManifest.Keys.Where (file => !previousManifest.ContainsKey (file)), StringComparer.Ordinal); + new HashSet (currentManifest.Keys.Where (file => !previousSymlinkManifest.ContainsKey (file)), StringComparer.Ordinal); SetDiagnosticProperty ("deploy.fastdeploy2.changed.files", newFiles.Count); SetDiagnosticProperty ("deploy.symlink.created.files", newFiles.Count); SetDiagnosticProperty ("deploy.symlink.removed.files", removedFiles.Count + newFiles.Count); @@ -95,12 +101,16 @@ async Task UpdateOverrideShellSymlinks (string remoteStagingPath, string o SetDiagnosticProperty ("deploy.symlink.tool.result", "shell"); var phase = Stopwatch.StartNew (); - if (!await RunCombinedShellSymlinkUpdate (remoteStagingPath, overridePath, currentManifest, previousManifest, newFiles, removedFiles)) { + if (!await RunCombinedShellSymlinkUpdate (remoteStagingPath, overridePath, currentManifest, previousSymlinkManifest, newFiles, removedFiles)) { SetDiagnosticElapsed ("deploy.symlink.shell.update.ms", phase); return await FallbackToCopy (remoteStagingPath, overridePath); } SetDiagnosticElapsed ("deploy.symlink.shell.update.ms", phase); + if (!await MarkOverrideSymlinkReady (overridePath)) { + return await FallbackToCopy (remoteStagingPath, overridePath); + } + return true; } @@ -118,7 +128,7 @@ async Task RunCombinedShellSymlinkUpdate (string remoteStagingPath, string string targetDirectory = string.IsNullOrEmpty (directory) ? overridePath : $"{overridePath}/{directory}"; string sourceDirectory = string.IsNullOrEmpty (directory) ? remoteStagingPath : $"{remoteStagingPath}/{directory}"; - if (previousManifest == null || newInDirectory.Count == currentInDirectory.Count) { + if (currentInDirectory.Count > 0 && (previousManifest == null || newInDirectory.Count == currentInDirectory.Count)) { string script = $"rm -f {ShellQuote (targetDirectory)}/*; mkdir -p {ShellQuote (targetDirectory)}; ln -sf {ShellQuote (sourceDirectory)}/* {ShellQuote (targetDirectory)}/"; string output = await RunAs ("sh", "-c", script); if (RaiseRunAsError (output) || IsShellError (output, "rm") || IsShellError (output, "mkdir") || IsShellError (output, "ln")) { @@ -257,11 +267,16 @@ async Task FallbackToCopy (string remoteStagingPath, string overridePath) async Task UpdateOverrideCopies (string remoteStagingPath, string overridePath) { var phase = Stopwatch.StartNew (); + if (!await ClearOverrideSymlinkReady (overridePath)) { + return false; + } + var stagedFileData = await GetRemoteFileData (remoteStagingPath, runAs: false); SetDiagnosticElapsed ("deploy.fastdeploy2.staging.stat.ms", phase); if (stagedFileData == null) { return false; } + stagedFileData.Remove (RemoteReadyMarker); phase.Restart (); var overrideFileData = await GetRemoteFileData (overridePath, runAs: true); @@ -342,7 +357,6 @@ async Task UploadChangedFiles (string remoteStagingPath, List RemoveRemoteStaleFiles (string remoteStagingPath, List return true; } + async Task ResetRemoteStagingDirectory (string remoteStagingPath) + { + var result = await RunAdbCommand ("shell", "rm", "-rf", remoteStagingPath); + if (result.ExitCode != 0 || IsShellError (result.Output, "rm")) { + LogFastDeploy2Error ("XA0129", result.Output, remoteStagingPath); + return false; + } + return true; + } + IEnumerable> BatchPushFilesWithoutSync (List files, string remoteDirectory) { var batch = CreatePushArgsPrefix (); + int prefixCount = batch.Count; int length = EstimateCommandLength (batch) + remoteDirectory.Length + 4; foreach (var file in files) { if (Path.GetFileName (file.LocalPath) != Path.GetFileName (file.RelativePath)) { @@ -376,7 +401,7 @@ IEnumerable> BatchPushFilesWithoutSync (List files, } int itemLength = file.LocalPath.Length + 3; - if (batch.Count > 1 && length + itemLength >= MaxAdbCommandLength) { + if (batch.Count > prefixCount && length + itemLength >= MaxAdbCommandLength) { batch.Add (remoteDirectory); yield return batch; batch = CreatePushArgsPrefix (); @@ -385,7 +410,7 @@ IEnumerable> BatchPushFilesWithoutSync (List files, batch.Add (file.LocalPath); length += itemLength; } - if (batch.Count > 1) { + if (batch.Count > prefixCount) { batch.Add (remoteDirectory); yield return batch; } @@ -424,6 +449,35 @@ async Task IsRemoteReady (string remoteStagingPath) return result.ExitCode == 0; } + async Task IsOverrideSymlinkReady (string overridePath) + { + string output = await RunAs ("sh", "-c", $"test -f {ShellQuote ($"{overridePath}/{OverrideSymlinkReadyMarker}")} && echo yes || true"); + if (RaiseRunAsError (output)) { + return false; + } + return string.Equals (output?.Trim (), "yes", StringComparison.Ordinal); + } + + async Task MarkOverrideSymlinkReady (string overridePath) + { + string output = await RunAs ("sh", "-c", $"mkdir -p {ShellQuote (overridePath)}; touch {ShellQuote ($"{overridePath}/{OverrideSymlinkReadyMarker}")}"); + if (RaiseRunAsError (output) || IsShellError (output, "mkdir") || IsShellError (output, "touch")) { + LogFastDeploy2Error ("XA0129", output, overridePath); + return false; + } + return true; + } + + async Task ClearOverrideSymlinkReady (string overridePath) + { + string output = await RunAs ("rm", "-f", $"{overridePath}/{OverrideSymlinkReadyMarker}"); + if (RaiseRunAsError (output) || IsShellError (output, "rm")) { + LogFastDeploy2Error ("XA0129", output, overridePath); + return false; + } + return true; + } + async Task MarkRemoteReady (string remoteStagingPath) { await RunAdbCommand ("shell", "touch", $"{remoteStagingPath}/{RemoteReadyMarker}"); @@ -454,7 +508,7 @@ void WriteManifest (Dictionary manifest) string GetManifestFilePath () { - return Path.Combine (GetFullPath (IntermediateOutputPath), "fastdeploy2", GetSafeFileName (PackageName), GetSafeFileName (GetUserId ()), GetSafeFileName (PrimaryCpuAbi), "manifest.json"); + return Path.Combine (GetFullPath (IntermediateOutputPath), "fastdeploy2", GetSafeFileName (GetDeviceId ()), GetSafeFileName (PackageName), GetSafeFileName (GetUserId ()), GetSafeFileName (PrimaryCpuAbi), "manifest.json"); } static string GetSafeFileName (string value) diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs index 912741ca771..137725e2979 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs @@ -75,6 +75,7 @@ public abstract class FastDeploy2Base : AsyncTask PackageInfo packageInfo = new PackageInfo (); DateTime lastUpload = DateTime.MinValue; Queue diagnosticLogs = new Queue (); + readonly object diagnosticLogsLock = new object (); DiagnosticData diagnosticData = new DiagnosticData (); protected virtual string RemoteStagingRoot => "/tmp/fastdev2"; @@ -674,7 +675,7 @@ protected HashSet PrepareAdbPushStagingDirectory (string stagingDirector var stagedFiles = new HashSet (StringComparer.Ordinal); foreach (var file in FastDevFiles ?? Array.Empty ()) { if (!File.Exists (file.ItemSpec)) { - LogDebugMessage ($"File '{file.ItemSpec}' does not exists. Skipping."); + LogDebugMessage ($"File '{file.ItemSpec}' does not exist. Skipping."); continue; } if (Path.GetExtension (file.ItemSpec) == ".so") { @@ -714,7 +715,7 @@ string GetAdbPushTargetPath (ITaskItem file) { string targetPath = file.GetMetadata ("TargetPath"); if (string.IsNullOrEmpty (targetPath)) { - LogDiagnostic ($"'TargetPath' meta data not found on '{file.ItemSpec}'. Falling back to'DestinationSubPath'"); + LogDiagnostic ($"'TargetPath' metadata not found on '{file.ItemSpec}'. Falling back to 'DestinationSubPath'"); targetPath = file.GetMetadata ("DestinationSubPath"); } if (!string.IsNullOrEmpty (targetPath)) { @@ -1024,7 +1025,6 @@ protected virtual async Task UploadStagingDirectory (string stagingDirecto return false; } SetAdbPushFileCounts (result.Output); - LogDiagnostic (result.Output); return true; } @@ -1045,7 +1045,6 @@ protected async Task UploadFiles (string remoteStagingPath, List UploadFiles (string remoteStagingPath, List> BatchPushFiles (List files, string remoteDirectory) { var batch = CreatePushArgsPrefix (); + int prefixCount = batch.Count; int length = EstimateCommandLength (batch) + remoteDirectory.Length + 4; foreach (var file in files) { if (Path.GetFileName (file.LocalPath) != Path.GetFileName (file.RelativePath)) { @@ -1065,7 +1065,7 @@ IEnumerable> BatchPushFiles (List files, string rem } int itemLength = file.LocalPath.Length + 3; - if (batch.Count > 3 && length + itemLength >= 4096) { + if (batch.Count > prefixCount && length + itemLength >= 4096) { batch.Add (remoteDirectory); yield return batch; batch = CreatePushArgsPrefix (); @@ -1074,7 +1074,7 @@ IEnumerable> BatchPushFiles (List files, string rem batch.Add (file.LocalPath); length += itemLength; } - if (batch.Count > 3) { + if (batch.Count > prefixCount) { batch.Add (remoteDirectory); yield return batch; } @@ -1257,6 +1257,14 @@ protected string GetUserId () return string.IsNullOrEmpty (UserID) ? "0" : UserID; } + protected string GetDeviceId () + { + if (Device != null && !string.IsNullOrEmpty (Device.ID)) { + return Device.ID; + } + return string.IsNullOrEmpty (AdbTarget) ? "any" : AdbTarget; + } + protected void LogFastDeploy2Error (string errorCode, string error, string file = "") { LogDiagnosticDataError (errorCode, error, file); @@ -1274,13 +1282,22 @@ protected void LogDiagnostic (string message) LogDebugMessage (message); return; } - diagnosticLogs.Enqueue (message); + lock (diagnosticLogsLock) { + diagnosticLogs.Enqueue (message); + } } void PrintDiagnostics () { - while (diagnosticLogs.Count > 0) { - LogMessage (diagnosticLogs.Dequeue ()); + while (true) { + string message; + lock (diagnosticLogsLock) { + if (diagnosticLogs.Count == 0) { + break; + } + message = diagnosticLogs.Dequeue (); + } + LogMessage (message); } LogMessage ($"{diagnosticData.Task}"); foreach (var t in diagnosticData.Properties) { diff --git a/tests/MSBuildDeviceIntegration/Tests/PerformanceTest.cs b/tests/MSBuildDeviceIntegration/Tests/PerformanceTest.cs index 915cbf9c57c..aa8f75c6cf1 100644 --- a/tests/MSBuildDeviceIntegration/Tests/PerformanceTest.cs +++ b/tests/MSBuildDeviceIntegration/Tests/PerformanceTest.cs @@ -381,7 +381,7 @@ public void Install_CSharp_FromClean () builder.Verbosity = LoggerVerbosity.Quiet; builder.Install (proj); builder.AutomaticNuGetRestore = false; - ProfileTask (builder, "FastDeploy", 20, b => { + ProfileTask (builder, "FastDeploy2", 20, b => { b.Uninstall (proj); b.Install (proj); }); From 00522fe9ebf20913c74680058cc42514005c0819 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 18 Jun 2026 14:09:49 +0200 Subject: [PATCH 04/11] Shorten FastDeploy2 symlink shell commands Use shell variables and cd to avoid repeating long staging and override paths in generated run-as symlink scripts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/FastDeploy2.Manifest.cs | 44 +++++++++++-------- .../Tasks/FastDeploy2.cs | 17 +++++++ 2 files changed, 43 insertions(+), 18 deletions(-) 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 7d7a8769a58..247e703baa4 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs @@ -129,8 +129,8 @@ async Task RunCombinedShellSymlinkUpdate (string remoteStagingPath, string string sourceDirectory = string.IsNullOrEmpty (directory) ? remoteStagingPath : $"{remoteStagingPath}/{directory}"; if (currentInDirectory.Count > 0 && (previousManifest == null || newInDirectory.Count == currentInDirectory.Count)) { - string script = $"rm -f {ShellQuote (targetDirectory)}/*; mkdir -p {ShellQuote (targetDirectory)}; ln -sf {ShellQuote (sourceDirectory)}/* {ShellQuote (targetDirectory)}/"; - string output = await RunAs ("sh", "-c", script); + string script = $"d={ShellQuote (targetDirectory)};s={ShellQuote (sourceDirectory)};mkdir -p \"$d\"&&cd \"$d\"&&rm -f ./*&&ln -sf \"$s\"/* ."; + string output = await RunAsShell (script); if (RaiseRunAsError (output) || IsShellError (output, "rm") || IsShellError (output, "mkdir") || IsShellError (output, "ln")) { LogDiagnostic ($"Shell symlink glob update failed with '{output}'."); return false; @@ -139,7 +139,7 @@ async Task RunCombinedShellSymlinkUpdate (string remoteStagingPath, string } foreach (string script in CreateShellSymlinkScripts (remoteStagingPath, overridePath, newInDirectory, removedInDirectory)) { - string output = await RunAs ("sh", "-c", script); + string output = await RunAsShell (script); if (RaiseRunAsError (output) || IsShellError (output, "rm") || IsShellError (output, "mkdir") || IsShellError (output, "ln")) { LogDiagnostic ($"Shell symlink batch update failed with '{output}'."); return false; @@ -152,43 +152,51 @@ async Task RunCombinedShellSymlinkUpdate (string remoteStagingPath, string IEnumerable CreateShellSymlinkScripts (string remoteStagingPath, string overridePath, List newFiles, List removedFiles) { - var filesToRemove = removedFiles.Concat (newFiles).Select (file => $"{overridePath}/{file}").ToList (); - foreach (var batch in BatchShellArguments ("rm -f", filesToRemove)) { - yield return batch; + foreach (var group in removedFiles.Concat (newFiles).GroupBy (GetDirectoryName, StringComparer.Ordinal)) { + string targetDirectory = string.IsNullOrEmpty (group.Key) ? overridePath : $"{overridePath}/{group.Key}"; + var prefix = $"d={ShellQuote (targetDirectory)};mkdir -p \"$d\"&&cd \"$d\"&&rm -f"; + foreach (var batch in BatchShellWords (prefix, group.Select (file => ShellQuote (Path.GetFileName (file))))) { + yield return batch; + } } foreach (var group in newFiles.GroupBy (GetDirectoryName, StringComparer.Ordinal)) { string targetDirectory = string.IsNullOrEmpty (group.Key) ? overridePath : $"{overridePath}/{group.Key}"; - var prefix = $"mkdir -p {ShellQuote (targetDirectory)}; ln -sf"; - var suffix = ShellQuote (targetDirectory) + "/"; - var sources = group.Select (file => $"{remoteStagingPath}/{file}"); - foreach (var batch in BatchShellArguments (prefix, sources, suffix)) { + string sourceDirectory = string.IsNullOrEmpty (group.Key) ? remoteStagingPath : $"{remoteStagingPath}/{group.Key}"; + var prefix = $"d={ShellQuote (targetDirectory)};s={ShellQuote (sourceDirectory)};mkdir -p \"$d\"&&cd \"$d\"&&ln -sf"; + var sources = group.Select (file => "\"$s\"/" + ShellQuote (Path.GetFileName (file))); + foreach (var batch in BatchShellWords (prefix, sources, " .")) { yield return batch; } } } IEnumerable BatchShellArguments (string prefix, IEnumerable arguments, string suffix = "") + { + return BatchShellWords (prefix, arguments.Select (ShellQuote), string.IsNullOrEmpty (suffix) ? "" : " " + suffix); + } + + IEnumerable BatchShellWords (string prefix, IEnumerable words, string suffix = "") { var builder = new StringBuilder (prefix); int count = 0; - foreach (string argument in arguments) { - string quoted = " " + ShellQuote (argument); - if (count > 0 && builder.Length + quoted.Length + suffix.Length >= MaxAdbCommandLength) { + foreach (string word in words) { + string argument = " " + word; + if (count > 0 && builder.Length + argument.Length + suffix.Length >= MaxAdbCommandLength) { if (!string.IsNullOrEmpty (suffix)) { - builder.Append (' ').Append (suffix); + builder.Append (suffix); } yield return builder.ToString (); builder.Clear (); builder.Append (prefix); count = 0; } - builder.Append (quoted); + builder.Append (argument); count++; } if (count > 0) { if (!string.IsNullOrEmpty (suffix)) { - builder.Append (' ').Append (suffix); + builder.Append (suffix); } yield return builder.ToString (); } @@ -451,7 +459,7 @@ async Task IsRemoteReady (string remoteStagingPath) async Task IsOverrideSymlinkReady (string overridePath) { - string output = await RunAs ("sh", "-c", $"test -f {ShellQuote ($"{overridePath}/{OverrideSymlinkReadyMarker}")} && echo yes || true"); + string output = await RunAsShell ($"test -f {ShellQuote ($"{overridePath}/{OverrideSymlinkReadyMarker}")} && echo yes || true"); if (RaiseRunAsError (output)) { return false; } @@ -460,7 +468,7 @@ async Task IsOverrideSymlinkReady (string overridePath) async Task MarkOverrideSymlinkReady (string overridePath) { - string output = await RunAs ("sh", "-c", $"mkdir -p {ShellQuote (overridePath)}; touch {ShellQuote ($"{overridePath}/{OverrideSymlinkReadyMarker}")}"); + string output = await RunAsShell ($"mkdir -p {ShellQuote (overridePath)}; touch {ShellQuote ($"{overridePath}/{OverrideSymlinkReadyMarker}")}"); if (RaiseRunAsError (output) || IsShellError (output, "mkdir") || IsShellError (output, "touch")) { LogFastDeploy2Error ("XA0129", output, overridePath); return false; diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs index 137725e2979..6dd4aa694d5 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs @@ -1241,6 +1241,23 @@ protected async Task RunAs (params string [] arguments) return result; } + protected async Task RunAsShell (string script) + { + List args = BuildRunAsArgs (); + args.Add ("sh"); + args.Add ("-c"); + args.Add (script); + string command = string.Join (" ", args.Select (QuoteShellArgument)); + string result = await Device.RunShellCommand (command, CancellationToken); + LogDebugMessage ($"sh returned: {result}"); + return result; + } + + static string QuoteShellArgument (string value) + { + return "'" + value.Replace ("'", "'\"'\"'") + "'"; + } + protected string ResolveAdbPath () { var exe = string.IsNullOrEmpty (AdbToolExe) ? "adb" : AdbToolExe; From 149eb9d2f97ce39bfaae9e2ac3cc8605a8829e78 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 18 Jun 2026 14:12:51 +0200 Subject: [PATCH 05/11] Remove unused FastDeploy2 helpers Drop leftover experimental staging and symlink helper methods that are no longer referenced by the manifest-driven direct-push implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/FastDeploy2.Manifest.cs | 59 ------------ .../Tasks/FastDeploy2.cs | 89 ------------------- 2 files changed, 148 deletions(-) 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 247e703baa4..a18dc23009d 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs @@ -171,11 +171,6 @@ IEnumerable CreateShellSymlinkScripts (string remoteStagingPath, string } } - IEnumerable BatchShellArguments (string prefix, IEnumerable arguments, string suffix = "") - { - return BatchShellWords (prefix, arguments.Select (ShellQuote), string.IsNullOrEmpty (suffix) ? "" : " " + suffix); - } - IEnumerable BatchShellWords (string prefix, IEnumerable words, string suffix = "") { var builder = new StringBuilder (prefix); @@ -212,60 +207,6 @@ static string ShellQuote (string value) return "'" + value.Replace ("'", "'\"'\"'") + "'"; } - async Task RemoveOverridePaths (string overridePath, IEnumerable paths) - { - foreach (var batch in BatchArguments ("rm", "-f", paths.Select (file => $"{overridePath}/{file}"))) { - string output = await RunAs (batch.ToArray ()); - if (RaiseRunAsError (output) || IsShellError (output, "rm")) { - LogDiagnostic ($"Shell symlink remove failed with '{output}'."); - return false; - } - } - return true; - } - - async Task CreateOverrideShellSymlinks (string remoteStagingPath, string overridePath, HashSet newFiles) - { - var filesByDirectory = new Dictionary> (StringComparer.Ordinal); - foreach (string file in newFiles) { - string directory = Path.GetDirectoryName (file)?.Replace ("\\", "/") ?? ""; - if (!filesByDirectory.TryGetValue (directory, out List files)) { - files = new List (); - filesByDirectory.Add (directory, files); - } - files.Add (file); - } - - var phase = Stopwatch.StartNew (); - foreach (var group in filesByDirectory) { - string targetDirectory = string.IsNullOrEmpty (group.Key) ? overridePath : $"{overridePath}/{group.Key}"; - phase.Restart (); - string output = await RunAs ("mkdir", "-p", targetDirectory); - AddDiagnosticElapsed ("deploy.fastdeploy2.override.mkdir.ms", phase); - if (RaiseRunAsError (output) || IsShellError (output, "mkdir")) { - LogDiagnostic ($"Shell symlink mkdir failed with '{output}'."); - return false; - } - - for (int i = 0; i < group.Value.Count; i += 25) { - var args = new List { "ln", "-sf" }; - foreach (string file in group.Value.Skip (i).Take (25)) { - args.Add ($"{remoteStagingPath}/{file}"); - } - args.Add (targetDirectory); - phase.Restart (); - output = await RunAs (args.ToArray ()); - AddDiagnosticElapsed ("deploy.fastdeploy2.override.copy.ms", phase); - if (RaiseRunAsError (output) || IsShellError (output, "ln")) { - LogDiagnostic ($"Shell symlink ln failed with '{output}'."); - return false; - } - } - } - - return true; - } - async Task FallbackToCopy (string remoteStagingPath, string overridePath) { SetDiagnosticProperty ("deploy.symlink.tool.result", "shell fallback to copy"); diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs index 6dd4aa694d5..00003af2735 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs @@ -660,57 +660,6 @@ protected bool WriteFileIfChanged (string path, byte [] contents, DateTime modif return true; } - protected virtual bool UseSymlinkAppFileTransfer () - { - return false; - } - - protected HashSet PrepareAdbPushStagingDirectory (string stagingDirectory) - { - if (Directory.Exists (stagingDirectory)) { - Directory.Delete (stagingDirectory, recursive: true); - } - Directory.CreateDirectory (stagingDirectory); - - var stagedFiles = new HashSet (StringComparer.Ordinal); - foreach (var file in FastDevFiles ?? Array.Empty ()) { - if (!File.Exists (file.ItemSpec)) { - LogDebugMessage ($"File '{file.ItemSpec}' does not exist. Skipping."); - continue; - } - if (Path.GetExtension (file.ItemSpec) == ".so") { - string abi = AndroidRidAbiHelper.GetNativeLibraryAbi (file); - if (abi != PrimaryCpuAbi) { - LogDebugMessage ($"NotifySync SkipCopyFile {file.ItemSpec} abi not suitable for this device."); - continue; - } - } - - string targetPath = GetAdbPushTargetPath (file); - string destination = GetStagingFilePath (stagingDirectory, targetPath); - Directory.CreateDirectory (Path.GetDirectoryName (destination)); - File.Copy (file.ItemSpec, destination, overwrite: true); - File.SetLastWriteTimeUtc (destination, File.GetLastWriteTimeUtc (file.ItemSpec)); - stagedFiles.Add (targetPath.Replace ("\\", "/")); - LogDiagnostic ($"Staged {file.ItemSpec} => {targetPath}"); - } - - if (EnvironmentFiles?.Length > 0) { - string targetPath = $"{PrimaryCpuAbi}/environment"; - string destination = GetStagingFilePath (stagingDirectory, targetPath); - Directory.CreateDirectory (Path.GetDirectoryName (destination)); - byte [] environmentData = CreateEnvironmentFileData (EnvironmentFiles, out DateTime newestFileDateTime); - if (environmentData.Length > 0) { - File.WriteAllBytes (destination, environmentData); - File.SetLastWriteTimeUtc (destination, newestFileDateTime); - stagedFiles.Add (targetPath); - LogDiagnostic ($"Staged @(AndroidEnvironment) files => {targetPath}"); - } - } - - return stagedFiles; - } - string GetAdbPushTargetPath (ITaskItem file) { string targetPath = file.GetMetadata ("TargetPath"); @@ -724,19 +673,6 @@ string GetAdbPushTargetPath (ITaskItem file) return Path.GetFileName (file.ItemSpec); } - static string GetStagingFilePath (string stagingDirectory, string targetPath) - { - string fullStagingDirectory = Path.GetFullPath (stagingDirectory); - string destination = Path.GetFullPath (Path.Combine (fullStagingDirectory, targetPath.Replace ('/', Path.DirectorySeparatorChar))); - string stagingPrefix = fullStagingDirectory.EndsWith (Path.DirectorySeparatorChar.ToString (), StringComparison.Ordinal) ? - fullStagingDirectory : - fullStagingDirectory + Path.DirectorySeparatorChar; - if (!destination.StartsWith (stagingPrefix, StringComparison.Ordinal)) { - throw new InvalidOperationException ($"FastDev target path '{targetPath}' escapes staging directory '{stagingDirectory}'."); - } - return destination; - } - byte [] CreateEnvironmentFileData (ITaskItem [] environments, out DateTime newestFileDateTime) { int maxKeyLength = 0; @@ -1003,31 +939,6 @@ protected void SetDiagnosticProperty (string key, string value) diagnosticData.SetProperty (key, value); } - protected virtual string GetLocalStagingDirectory () - { - return Path.Combine (GetFullPath (IntermediateOutputPath), "fastdeploy2"); - } - - protected virtual async Task UploadStagingDirectory (string stagingDirectory, string remoteStagingPath) - { - var args = new List { "push" }; - if (!string.IsNullOrEmpty (AdbPushCompressionAlgorithm)) { - args.Add ("-z"); - args.Add (AdbPushCompressionAlgorithm); - } - args.Add ("--sync"); - args.Add (Path.Combine (stagingDirectory, ".")); - args.Add (remoteStagingPath); - - var result = await RunAdbCommand (args.ToArray ()); - if (result.ExitCode != 0) { - LogFastDeploy2Error ("XA0129", result.Output, stagingDirectory); - return false; - } - SetAdbPushFileCounts (result.Output); - return true; - } - protected async Task UploadFiles (string remoteStagingPath, List files) { int pushed = 0; From 6edf60ee4fc86f94813c63cd2d35572b7a9181f5 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 18 Jun 2026 14:19:37 +0200 Subject: [PATCH 06/11] Fix FastDeploy2 copy recovery edge cases Skip missing FastDev inputs before manifest creation and clear symlink-managed override state before Copy mode so stale symlinks cannot survive mode switches or fallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/FastDeploy2.Manifest.cs | 25 +++++++++++++++---- .../Tasks/FastDeploy2.cs | 7 +++++- 2 files changed, 26 insertions(+), 6 deletions(-) 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 a18dc23009d..abcbc459c88 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs @@ -210,13 +210,17 @@ static string ShellQuote (string value) async Task FallbackToCopy (string remoteStagingPath, string overridePath) { SetDiagnosticProperty ("deploy.symlink.tool.result", "shell fallback to copy"); - return await UpdateOverrideCopies (remoteStagingPath, overridePath); + return await UpdateOverrideCopies (remoteStagingPath, overridePath, clearOverrideDirectory: true); } - async Task UpdateOverrideCopies (string remoteStagingPath, string overridePath) + async Task UpdateOverrideCopies (string remoteStagingPath, string overridePath, bool clearOverrideDirectory = false) { var phase = Stopwatch.StartNew (); - if (!await ClearOverrideSymlinkReady (overridePath)) { + if (clearOverrideDirectory) { + if (!await ClearOverrideDirectory (overridePath)) { + return false; + } + } else if (!await ClearOverrideSymlinkState (overridePath)) { return false; } @@ -417,9 +421,20 @@ async Task MarkOverrideSymlinkReady (string overridePath) return true; } - async Task ClearOverrideSymlinkReady (string overridePath) + async Task ClearOverrideSymlinkState (string overridePath) + { + string markerPath = $"{overridePath}/{OverrideSymlinkReadyMarker}"; + string output = await RunAsShell ($"if test -f {ShellQuote (markerPath)}; then rm -rf {ShellQuote (overridePath)}; else rm -f {ShellQuote (markerPath)}; fi"); + if (RaiseRunAsError (output) || IsShellError (output, "rm")) { + LogFastDeploy2Error ("XA0129", output, overridePath); + return false; + } + return true; + } + + async Task ClearOverrideDirectory (string overridePath) { - string output = await RunAs ("rm", "-f", $"{overridePath}/{OverrideSymlinkReadyMarker}"); + string output = await RunAs ("rm", "-rf", overridePath); if (RaiseRunAsError (output) || IsShellError (output, "rm")) { LogFastDeploy2Error ("XA0129", output, overridePath); return false; diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs index 00003af2735..beb0efb9fa1 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs @@ -618,6 +618,11 @@ protected List PrepareDirectPushFiles () { var files = new List (); foreach (var file in FastDevFiles ?? Array.Empty ()) { + string localPath = GetFullPath (file.ItemSpec); + if (!File.Exists (localPath)) { + LogDebugMessage ($"File '{file.ItemSpec}' does not exist. Skipping."); + continue; + } if (Path.GetExtension (file.ItemSpec) == ".so") { string abi = AndroidRidAbiHelper.GetNativeLibraryAbi (file); if (abi != PrimaryCpuAbi) { @@ -627,7 +632,7 @@ protected List PrepareDirectPushFiles () } files.Add (new DirectPushFile { - LocalPath = GetFullPath (file.ItemSpec), + LocalPath = localPath, RelativePath = GetAdbPushTargetPath (file), }); LogDiagnostic ($"Prepared {file.ItemSpec} => {files [files.Count - 1].RelativePath}"); From 5750867eae2de54762907f08c772f31f67a3890b Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 18 Jun 2026 16:22:56 +0200 Subject: [PATCH 07/11] Address FastDeploy2 review cleanup Move FastDeploy2 diagnostic JSON helpers out of the main task, use System.Text.Json source generation for FastDeploy2 JSON, remove unused FastDeploy2 task inputs, and consolidate repeated path/grouping helpers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/FastDeploy2.Diagnostics.cs | 170 ++++++++++++ .../Tasks/FastDeploy2.Manifest.cs | 94 +++---- .../Tasks/FastDeploy2.cs | 256 +++--------------- .../Tasks/FastDeploy2JsonSerializerContext.cs | 12 + .../Xamarin.Android.Common.Debugging.targets | 4 - 5 files changed, 268 insertions(+), 268 deletions(-) create mode 100644 src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Diagnostics.cs create mode 100644 src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2JsonSerializerContext.cs diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Diagnostics.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Diagnostics.cs new file mode 100644 index 00000000000..7d2358674d0 --- /dev/null +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Diagnostics.cs @@ -0,0 +1,170 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Xamarin.Android.Tasks +{ + public abstract partial class FastDeploy2Base + { + internal class DiagnosticData { + [JsonPropertyName ("Task")] + public string Task { get; set; } = nameof (FastDeploy2); + + [JsonPropertyName ("Properties")] + public Dictionary Properties { get; set; } = new Dictionary () { + { "target.prop.ro.product.build.version.sdk", "" }, + { "target.prop.ro.product.cpu.abilist", "" }, + { "target.prop.ro.product.manufacturer", "" }, + { "target.prop.ro.product.model", "" }, + { "target.prop.ro.product.cpu.abi", "" }, + { "deploy.error.code", "" }, + { "deploy.tool", "adb push" }, + { "deploy.result", "Success" }, + { "deploy.supports.fastdev", "True" }, + { "deploy.systemapp", "False" }, + { "deploy.duration.ms", "0" }, + { "deploy.fastdeploy2.adb.pushed.files", "" }, + { "deploy.fastdeploy2.adb.skipped.files", "" }, + { "deploy.fastdeploy2.changed.files", "" }, + { "deploy.fastdeploy2.stale.files", "" }, + { "deploy.fastdeploy2.local.stage.ms", "" }, + { "deploy.fastdeploy2.remote.mkdir.ms", "" }, + { "deploy.fastdeploy2.remote.staging.cleanup.ms", "" }, + { "deploy.fastdeploy2.upload.ms", "" }, + { "deploy.fastdeploy2.staging.stat.ms", "" }, + { "deploy.fastdeploy2.override.stat.ms", "" }, + { "deploy.fastdeploy2.compare.ms", "" }, + { "deploy.fastdeploy2.stale.remove.ms", "" }, + { "deploy.fastdeploy2.override.mkdir.ms", "" }, + { "deploy.fastdeploy2.override.copy.ms", "" }, + { "deploy.orchestration.ensure-properties.ms", "" }, + { "deploy.orchestration.property-checks.ms", "" }, + { "deploy.orchestration.package-check.ms", "" }, + { "deploy.orchestration.package-timestamp.ms", "" }, + { "deploy.orchestration.install.ms", "" }, + { "deploy.orchestration.terminate.ms", "" }, + { "deploy.orchestration.empty-check.ms", "" }, + { "deploy.execute.parse-target.ms", "" }, + { "deploy.execute.no-abi-check.ms", "" }, + { "deploy.execute.upload-flag-stat.ms", "" }, + { "deploy.execute.task-cache.ms", "" }, + { "deploy.orchestration.property-capture.ms", "" }, + { "deploy.orchestration.redirect-stdio-check.ms", "" }, + { "deploy.orchestration.run-as-disabled-check.ms", "" }, + { "deploy.orchestration.package-check.ensure-user.ms", "" }, + { "deploy.orchestration.package-check.run-as-pwd.ms", "" }, + { "deploy.orchestration.package-check.run-as-pwd-pidof.ms", "" }, + { "deploy.orchestration.package-check.readlink.ms", "" }, + { "deploy.orchestration.package-check.system-app.ms", "" }, + { "deploy.orchestration.package-check.evaluate.ms", "" }, + { "deploy.orchestration.package-timestamp.path-stat.ms", "" }, + { "deploy.orchestration.install.push-install.ms", "" }, + { "deploy.orchestration.install.retry-delete.ms", "" }, + { "deploy.orchestration.install.retry-uninstall.ms", "" }, + { "deploy.orchestration.install.retry-reinstall.ms", "" }, + { "deploy.orchestration.terminate.get-pid.ms", "" }, + { "deploy.orchestration.terminate.kill.ms", "" }, + { "deploy.app.file.transfer.mode", "" }, + { "deploy.fastdeploy2.bulk.batches", "" }, + { "deploy.symlink.created.files", "" }, + { "deploy.symlink.removed.files", "" }, + { "deploy.symlink.shell.update.ms", "" }, + { "pii.deploy.error", "" }, + { "pii.deploy.file", "" }, + }; + + internal void SetProperty (string key, bool? value) + { + Properties [key] = value?.ToString () ?? "False"; + } + + internal void SetProperty (string key, int? value) + { + Properties [key] = value?.ToString () ?? "-1"; + } + + internal void SetProperty (string key, long? value) + { + Properties [key] = value?.ToString () ?? "-1"; + } + + internal void SetProperty (string key, string value) + { + Properties [key] = value ?? "unknown"; + } + } + + protected void SetDiagnosticElapsed (string key, Stopwatch stopwatch) + { + diagnosticData.SetProperty (key, stopwatch.ElapsedMilliseconds); + } + + protected void AddDiagnosticElapsed (string key, Stopwatch stopwatch) + { + if (!long.TryParse (diagnosticData.Properties [key], out long current)) { + current = 0; + } + diagnosticData.SetProperty (key, current + stopwatch.ElapsedMilliseconds); + } + + protected void SetDiagnosticProperty (string key, int value) + { + diagnosticData.SetProperty (key, value); + } + + protected void SetDiagnosticProperty (string key, string value) + { + diagnosticData.SetProperty (key, value); + } + + protected void LogDiagnostic (string message) + { + if (DiagnosticLogging) { + LogDebugMessage (message); + return; + } + lock (diagnosticLogsLock) { + diagnosticLogs.Enqueue (message); + } + } + + void PrintDiagnostics () + { + while (true) { + string message; + lock (diagnosticLogsLock) { + if (diagnosticLogs.Count == 0) { + break; + } + message = diagnosticLogs.Dequeue (); + } + LogMessage (message); + } + LogMessage ($"{diagnosticData.Task}"); + foreach (var t in diagnosticData.Properties) { + LogMessage ($"\t{t.Key}: {t.Value}"); + } + } + + void LogDiagnosticDataError (string errorCode, string error, string file = "") + { + diagnosticData.SetProperty ("deploy.result", "Failed"); + if (!string.IsNullOrEmpty (file)) + diagnosticData.SetProperty ("pii.deploy.file", file); + diagnosticData.SetProperty ("pii.deploy.error", error); + diagnosticData.SetProperty ("deploy.error.code", errorCode); + } + + void SaveDiagnosticData (long ms) + { + diagnosticData.SetProperty ("deploy.duration.ms", ms); + string newPath = Path.Combine (IntermediateOutputPath, "diagnostics", $"{GetType ().Name.ToLowerInvariant ()}.json"); + File.WriteAllText (newPath, JsonSerializer.Serialize ( + diagnosticData, + typeof (DiagnosticData), + FastDeploy2JsonSerializerContext.Default)); + } + } +} 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 abcbc459c88..59a7a8ce79f 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs @@ -15,7 +15,6 @@ public class FastDeploy2 : FastDeploy2Base const string RemoteStagingRootPath = "/tmp/fastdeploy2"; const string RemoteReadyMarker = ".fastdeploy2-ready"; const string OverrideSymlinkReadyMarker = ".fastdeploy2-symlinks"; - const int MaxAdbCommandLength = 4096; public override string TaskPrefix => "FD2"; @@ -116,20 +115,24 @@ async Task UpdateOverrideShellSymlinks (string remoteStagingPath, string o async Task RunCombinedShellSymlinkUpdate (string remoteStagingPath, string overridePath, Dictionary currentManifest, Dictionary previousManifest, HashSet newFiles, List removedFiles) { - var directories = new HashSet (StringComparer.Ordinal); - foreach (string file in currentManifest.Keys.Concat (removedFiles)) { - directories.Add (GetDirectoryName (file)); - } + var currentByDirectory = GroupFilesByDirectory (currentManifest.Keys); + var newByDirectory = GroupFilesByDirectory (newFiles); + var removedByDirectory = GroupFilesByDirectory (removedFiles); + var directories = new HashSet (currentByDirectory.Keys, StringComparer.Ordinal); + directories.UnionWith (removedByDirectory.Keys); foreach (string directory in directories) { - var currentInDirectory = currentManifest.Keys.Where (file => GetDirectoryName (file) == directory).ToList (); - var newInDirectory = newFiles.Where (file => GetDirectoryName (file) == directory).ToList (); - var removedInDirectory = removedFiles.Where (file => GetDirectoryName (file) == directory).ToList (); - string targetDirectory = string.IsNullOrEmpty (directory) ? overridePath : $"{overridePath}/{directory}"; - string sourceDirectory = string.IsNullOrEmpty (directory) ? remoteStagingPath : $"{remoteStagingPath}/{directory}"; + currentByDirectory.TryGetValue (directory, out List currentInDirectory); + newByDirectory.TryGetValue (directory, out List newInDirectory); + removedByDirectory.TryGetValue (directory, out List removedInDirectory); + currentInDirectory = currentInDirectory ?? []; + newInDirectory = newInDirectory ?? []; + removedInDirectory = removedInDirectory ?? []; + string targetDirectory = CombineRemotePath (overridePath, directory); + string sourceDirectory = CombineRemotePath (remoteStagingPath, directory); if (currentInDirectory.Count > 0 && (previousManifest == null || newInDirectory.Count == currentInDirectory.Count)) { - string script = $"d={ShellQuote (targetDirectory)};s={ShellQuote (sourceDirectory)};mkdir -p \"$d\"&&cd \"$d\"&&rm -f ./*&&ln -sf \"$s\"/* ."; + string script = $"d={QuoteShellArgument (targetDirectory)};s={QuoteShellArgument (sourceDirectory)};mkdir -p \"$d\"&&cd \"$d\"&&rm -f ./*&&ln -sf \"$s\"/* ."; string output = await RunAsShell (script); if (RaiseRunAsError (output) || IsShellError (output, "rm") || IsShellError (output, "mkdir") || IsShellError (output, "ln")) { LogDiagnostic ($"Shell symlink glob update failed with '{output}'."); @@ -153,18 +156,18 @@ async Task RunCombinedShellSymlinkUpdate (string remoteStagingPath, string IEnumerable CreateShellSymlinkScripts (string remoteStagingPath, string overridePath, List newFiles, List removedFiles) { foreach (var group in removedFiles.Concat (newFiles).GroupBy (GetDirectoryName, StringComparer.Ordinal)) { - string targetDirectory = string.IsNullOrEmpty (group.Key) ? overridePath : $"{overridePath}/{group.Key}"; - var prefix = $"d={ShellQuote (targetDirectory)};mkdir -p \"$d\"&&cd \"$d\"&&rm -f"; - foreach (var batch in BatchShellWords (prefix, group.Select (file => ShellQuote (Path.GetFileName (file))))) { + string targetDirectory = CombineRemotePath (overridePath, group.Key); + var prefix = $"d={QuoteShellArgument (targetDirectory)};mkdir -p \"$d\"&&cd \"$d\"&&rm -f"; + foreach (var batch in BatchShellWords (prefix, group.Select (file => QuoteShellArgument (Path.GetFileName (file))))) { yield return batch; } } foreach (var group in newFiles.GroupBy (GetDirectoryName, StringComparer.Ordinal)) { - string targetDirectory = string.IsNullOrEmpty (group.Key) ? overridePath : $"{overridePath}/{group.Key}"; - string sourceDirectory = string.IsNullOrEmpty (group.Key) ? remoteStagingPath : $"{remoteStagingPath}/{group.Key}"; - var prefix = $"d={ShellQuote (targetDirectory)};s={ShellQuote (sourceDirectory)};mkdir -p \"$d\"&&cd \"$d\"&&ln -sf"; - var sources = group.Select (file => "\"$s\"/" + ShellQuote (Path.GetFileName (file))); + string targetDirectory = CombineRemotePath (overridePath, group.Key); + string sourceDirectory = CombineRemotePath (remoteStagingPath, group.Key); + var prefix = $"d={QuoteShellArgument (targetDirectory)};s={QuoteShellArgument (sourceDirectory)};mkdir -p \"$d\"&&cd \"$d\"&&ln -sf"; + var sources = group.Select (file => "\"$s\"/" + QuoteShellArgument (Path.GetFileName (file))); foreach (var batch in BatchShellWords (prefix, sources, " .")) { yield return batch; } @@ -197,16 +200,6 @@ IEnumerable BatchShellWords (string prefix, IEnumerable words, s } } - static string GetDirectoryName (string file) - { - return Path.GetDirectoryName (file)?.Replace ("\\", "/") ?? ""; - } - - static string ShellQuote (string value) - { - return "'" + value.Replace ("'", "'\"'\"'") + "'"; - } - async Task FallbackToCopy (string remoteStagingPath, string overridePath) { SetDiagnosticProperty ("deploy.symlink.tool.result", "shell fallback to copy"); @@ -298,8 +291,8 @@ async Task UploadChangedFiles (string remoteStagingPath, List changedFiles.Contains (file.RelativePath)).ToList (); - foreach (var group in changedFileList.GroupBy (file => Path.GetDirectoryName (file.RelativePath)?.Replace ("\\", "/") ?? "", StringComparer.Ordinal)) { - string remoteDirectory = string.IsNullOrEmpty (group.Key) ? remoteStagingPath : $"{remoteStagingPath}/{group.Key}"; + foreach (var group in changedFileList.GroupBy (file => GetDirectoryName (file.RelativePath), StringComparer.Ordinal)) { + string remoteDirectory = CombineRemotePath (remoteStagingPath, group.Key); foreach (var batch in BatchPushFilesWithoutSync (group.ToList (), remoteDirectory)) { var result = await RunAdbCommand (batch.ToArray ()); if (result.ExitCode != 0) { @@ -321,7 +314,7 @@ async Task UploadChangedFiles (string remoteStagingPath, List RemoveRemoteStaleFiles (string remoteStagingPath, List removedFiles) { - foreach (var batch in BatchArguments ("rm", "-f", removedFiles.Select (file => $"{remoteStagingPath}/{file}"))) { + foreach (var batch in BatchArguments ("rm", "-f", removedFiles.Select (file => CombineRemotePath (remoteStagingPath, file)))) { var args = new [] { "shell" }.Concat (batch).ToArray (); var result = await RunAdbCommand (args); if (result.ExitCode != 0 || IsShellError (result.Output, "rm")) { @@ -398,13 +391,13 @@ int EstimateCommandLength (List args) async Task IsRemoteReady (string remoteStagingPath) { - var result = await RunAdbCommand ("shell", "test", "-f", $"{remoteStagingPath}/{RemoteReadyMarker}"); + var result = await RunAdbCommand ("shell", "test", "-f", CombineRemotePath (remoteStagingPath, RemoteReadyMarker)); return result.ExitCode == 0; } async Task IsOverrideSymlinkReady (string overridePath) { - string output = await RunAsShell ($"test -f {ShellQuote ($"{overridePath}/{OverrideSymlinkReadyMarker}")} && echo yes || true"); + string output = await RunAsShell ($"test -f {QuoteShellArgument (CombineRemotePath (overridePath, OverrideSymlinkReadyMarker))} && echo yes || true"); if (RaiseRunAsError (output)) { return false; } @@ -413,7 +406,7 @@ async Task IsOverrideSymlinkReady (string overridePath) async Task MarkOverrideSymlinkReady (string overridePath) { - string output = await RunAsShell ($"mkdir -p {ShellQuote (overridePath)}; touch {ShellQuote ($"{overridePath}/{OverrideSymlinkReadyMarker}")}"); + string output = await RunAsShell ($"mkdir -p {QuoteShellArgument (overridePath)}; touch {QuoteShellArgument (CombineRemotePath (overridePath, OverrideSymlinkReadyMarker))}"); if (RaiseRunAsError (output) || IsShellError (output, "mkdir") || IsShellError (output, "touch")) { LogFastDeploy2Error ("XA0129", output, overridePath); return false; @@ -423,8 +416,8 @@ async Task MarkOverrideSymlinkReady (string overridePath) async Task ClearOverrideSymlinkState (string overridePath) { - string markerPath = $"{overridePath}/{OverrideSymlinkReadyMarker}"; - string output = await RunAsShell ($"if test -f {ShellQuote (markerPath)}; then rm -rf {ShellQuote (overridePath)}; else rm -f {ShellQuote (markerPath)}; fi"); + string markerPath = CombineRemotePath (overridePath, OverrideSymlinkReadyMarker); + string output = await RunAsShell ($"if test -f {QuoteShellArgument (markerPath)}; then rm -rf {QuoteShellArgument (overridePath)}; else rm -f {QuoteShellArgument (markerPath)}; fi"); if (RaiseRunAsError (output) || IsShellError (output, "rm")) { LogFastDeploy2Error ("XA0129", output, overridePath); return false; @@ -444,7 +437,7 @@ async Task ClearOverrideDirectory (string overridePath) async Task MarkRemoteReady (string remoteStagingPath) { - await RunAdbCommand ("shell", "touch", $"{remoteStagingPath}/{RemoteReadyMarker}"); + await RunAdbCommand ("shell", "touch", CombineRemotePath (remoteStagingPath, RemoteReadyMarker)); } Dictionary LoadPreviousManifest () @@ -455,8 +448,8 @@ Dictionary LoadPreviousManifest () } try { - var manifest = JsonSerializer.Deserialize> (File.ReadAllText (manifestFile)); - return manifest == null ? null : new Dictionary (manifest, StringComparer.Ordinal); + var manifest = JsonSerializer.Deserialize (File.ReadAllText (manifestFile), typeof (Dictionary), FastDeploy2JsonSerializerContext.Default); + return manifest is Dictionary entries ? new Dictionary (entries, StringComparer.Ordinal) : null; } catch (Exception ex) { LogDiagnostic ($"Ignoring FastDeploy2 manifest '{manifestFile}'. {ex}"); return null; @@ -467,28 +460,27 @@ void WriteManifest (Dictionary manifest) { string manifestFile = GetManifestFilePath (); Directory.CreateDirectory (Path.GetDirectoryName (manifestFile)); - File.WriteAllText (manifestFile, JsonSerializer.Serialize (manifest, new JsonSerializerOptions { WriteIndented = true })); + File.WriteAllText (manifestFile, JsonSerializer.Serialize (manifest, typeof (Dictionary), FastDeploy2JsonSerializerContext.Default)); } string GetManifestFilePath () { - return Path.Combine (GetFullPath (IntermediateOutputPath), "fastdeploy2", GetSafeFileName (GetDeviceId ()), GetSafeFileName (PackageName), GetSafeFileName (GetUserId ()), GetSafeFileName (PrimaryCpuAbi), "manifest.json"); + return Path.Combine ( + GetFullPath (IntermediateOutputPath), + "fastdeploy2", + GetSafeFileName (GetDeviceId ()), + GetSafeFileName (PackageName), + GetSafeFileName (GetUserId ()), + GetSafeFileName (PrimaryCpuAbi), + "manifest.json"); } static string GetSafeFileName (string value) { - if (string.IsNullOrEmpty (value)) { - return "_"; - } - - var builder = new StringBuilder (value.Length); - foreach (char c in value) { - builder.Append (char.IsLetterOrDigit (c) || c == '.' || c == '-' || c == '_' ? c : '_'); - } - return builder.ToString (); + return string.IsNullOrEmpty (value) ? "_" : Uri.EscapeDataString (value); } - class ManifestEntry { + internal class ManifestEntry { [JsonPropertyName ("relativePath")] public string RelativePath { get; set; } diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs index beb0efb9fa1..d6c605f7538 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs @@ -4,8 +4,6 @@ using System.IO; using System.Linq; using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -18,12 +16,13 @@ namespace Xamarin.Android.Tasks { - public abstract class FastDeploy2Base : AsyncTask + public abstract partial class FastDeploy2Base : AsyncTask { const string OverridePath = "files/.__override__"; const int StaleFileRemovalBatchSize = 100; const int CopyBatchSize = 25; const int MaxShellCommandLength = 900; + protected const int MaxAdbCommandLength = 4096; public override string TaskPrefix => "FD2"; @@ -38,22 +37,13 @@ public abstract class FastDeploy2Base : AsyncTask public string PackageFile { get; set; } public string PrimaryCpuAbi { get; set; } - public string ToolsAbi { get; set; } public ITaskItem [] FastDevFiles { get; set; } public bool PreserveUserData { get; set; } = true; - [Required] - public string FastDevToolPath { get; set; } - - [Required] - public string ToolVersion { get; set; } - public bool DiagnosticLogging { get; set; } = false; - public bool UsingAndroidNETSdk { get; set; } - public string UserID { get; set; } public bool IsTestOnly { get; set; } @@ -98,94 +88,6 @@ public string InternalPath { public int ProcessId { get; set; } = 0; } - class DiagnosticData { - [JsonPropertyName ("Task")] - public string Task { get; set; } = nameof (FastDeploy2); - - [JsonPropertyName ("Properties")] - public Dictionary Properties { get; set; } = new Dictionary () { - { "target.prop.ro.product.build.version.sdk", "" }, - { "target.prop.ro.product.cpu.abilist", "" }, - { "target.prop.ro.product.manufacturer", "" }, - { "target.prop.ro.product.model", "" }, - { "target.prop.ro.product.cpu.abi", "" }, - { "deploy.error.code", "" }, - { "deploy.tool", "adb push" }, - { "deploy.result", "Success" }, - { "deploy.supports.fastdev", "True" }, - { "deploy.systemapp", "False" }, - { "deploy.duration.ms", "0" }, - { "deploy.fastdeploy2.adb.pushed.files", "" }, - { "deploy.fastdeploy2.adb.skipped.files", "" }, - { "deploy.fastdeploy2.changed.files", "" }, - { "deploy.fastdeploy2.stale.files", "" }, - { "deploy.fastdeploy2.local.stage.ms", "" }, - { "deploy.fastdeploy2.remote.mkdir.ms", "" }, - { "deploy.fastdeploy2.remote.staging.cleanup.ms", "" }, - { "deploy.fastdeploy2.upload.ms", "" }, - { "deploy.fastdeploy2.staging.stat.ms", "" }, - { "deploy.fastdeploy2.override.stat.ms", "" }, - { "deploy.fastdeploy2.compare.ms", "" }, - { "deploy.fastdeploy2.stale.remove.ms", "" }, - { "deploy.fastdeploy2.override.mkdir.ms", "" }, - { "deploy.fastdeploy2.override.copy.ms", "" }, - { "deploy.orchestration.ensure-properties.ms", "" }, - { "deploy.orchestration.property-checks.ms", "" }, - { "deploy.orchestration.package-check.ms", "" }, - { "deploy.orchestration.package-timestamp.ms", "" }, - { "deploy.orchestration.install.ms", "" }, - { "deploy.orchestration.terminate.ms", "" }, - { "deploy.orchestration.empty-check.ms", "" }, - { "deploy.execute.parse-target.ms", "" }, - { "deploy.execute.no-abi-check.ms", "" }, - { "deploy.execute.upload-flag-stat.ms", "" }, - { "deploy.execute.task-cache.ms", "" }, - { "deploy.orchestration.property-capture.ms", "" }, - { "deploy.orchestration.redirect-stdio-check.ms", "" }, - { "deploy.orchestration.run-as-disabled-check.ms", "" }, - { "deploy.orchestration.package-check.ensure-user.ms", "" }, - { "deploy.orchestration.package-check.run-as-pwd.ms", "" }, - { "deploy.orchestration.package-check.run-as-pwd-pidof.ms", "" }, - { "deploy.orchestration.package-check.readlink.ms", "" }, - { "deploy.orchestration.package-check.system-app.ms", "" }, - { "deploy.orchestration.package-check.evaluate.ms", "" }, - { "deploy.orchestration.package-timestamp.path-stat.ms", "" }, - { "deploy.orchestration.install.push-install.ms", "" }, - { "deploy.orchestration.install.retry-delete.ms", "" }, - { "deploy.orchestration.install.retry-uninstall.ms", "" }, - { "deploy.orchestration.install.retry-reinstall.ms", "" }, - { "deploy.orchestration.terminate.get-pid.ms", "" }, - { "deploy.orchestration.terminate.kill.ms", "" }, - { "deploy.app.file.transfer.mode", "" }, - { "deploy.fastdeploy2.bulk.batches", "" }, - { "deploy.symlink.created.files", "" }, - { "deploy.symlink.removed.files", "" }, - { "deploy.symlink.shell.update.ms", "" }, - { "pii.deploy.error", "" }, - { "pii.deploy.file", "" }, - }; - - internal void SetProperty (string key, bool? value) - { - Properties [key] = value?.ToString () ?? "False"; - } - - internal void SetProperty (string key, int? value) - { - Properties [key] = value?.ToString () ?? "-1"; - } - - internal void SetProperty (string key, long? value) - { - Properties [key] = value?.ToString () ?? "-1"; - } - - internal void SetProperty (string key, string value) - { - Properties [key] = value ?? "unknown"; - } - } - protected class RemoteFileInfo { public long Size { get; set; } public long ModifiedTime { get; set; } @@ -269,7 +171,7 @@ async Task RunInstall () phase.Restart (); diagnosticData.SetProperty ("target.prop.ro.product.build.version.sdk", Device.Properties?.BuildVersionSdk); - diagnosticData.SetProperty ("target.prop.ro.product.cpu.abilist", string.Join (";", Device.Properties?.ProductCpuAbiList ?? Array.Empty ())); + diagnosticData.SetProperty ("target.prop.ro.product.cpu.abilist", string.Join (";", Device.Properties?.ProductCpuAbiList ?? [])); diagnosticData.SetProperty ("target.prop.ro.product.cpu.abi", PrimaryCpuAbi); diagnosticData.SetProperty ("target.prop.ro.product.manufacturer", Device.Properties?.ProductManufacturer); diagnosticData.SetProperty ("target.prop.ro.product.model", Device.Properties?.ProductModel); @@ -601,9 +503,9 @@ protected async Task CreateRemoteStagingDirectories (string remoteStagin { var directories = new HashSet (StringComparer.Ordinal) { remoteStagingPath }; foreach (var file in stagedFiles) { - string directory = Path.GetDirectoryName (file)?.Replace ("\\", "/") ?? ""; + string directory = GetDirectoryName (file); if (!string.IsNullOrEmpty (directory)) { - directories.Add ($"{remoteStagingPath}/{directory}"); + directories.Add (CombineRemotePath (remoteStagingPath, directory)); } } @@ -617,7 +519,7 @@ protected async Task CreateRemoteStagingDirectories (string remoteStagin protected List PrepareDirectPushFiles () { var files = new List (); - foreach (var file in FastDevFiles ?? Array.Empty ()) { + foreach (var file in FastDevFiles ?? []) { string localPath = GetFullPath (file.ItemSpec); if (!File.Exists (localPath)) { LogDebugMessage ($"File '{file.ItemSpec}' does not exist. Skipping."); @@ -684,7 +586,7 @@ byte [] CreateEnvironmentFileData (ITaskItem [] environments, out DateTime newes int maxValueLength = 0; newestFileDateTime = DateTime.MinValue; var data = new Dictionary (); - foreach (ITaskItem env in environments ?? Array.Empty ()) { + foreach (ITaskItem env in environments ?? []) { if (!File.Exists (env.ItemSpec)) continue; DateTime modifiedDateTime = File.GetLastWriteTimeUtc (env.ItemSpec); @@ -707,7 +609,7 @@ byte [] CreateEnvironmentFileData (ITaskItem [] environments, out DateTime newes } if (newestFileDateTime == DateTime.MinValue) { - return Array.Empty (); + return []; } maxKeyLength++; @@ -818,7 +720,7 @@ protected async Task RemoveStaleOverrideFiles (string overridePath, Dictio var staleFiles = new List (); foreach (var file in overrideFiles.Keys) { if (!stagedFiles.ContainsKey (file)) { - staleFiles.Add ($"{overridePath}/{file}"); + staleFiles.Add (CombineRemotePath (overridePath, file)); } } @@ -852,18 +754,10 @@ protected async Task CopyChangedFiles (string remoteStagingPath, string ov LogDiagnostic ($"FastDeploy2 copying {changedFiles.Count} changed override files."); diagnosticData.SetProperty ("deploy.fastdeploy2.changed.files", changedFiles.Count); - var filesByDirectory = new Dictionary> (StringComparer.Ordinal); - foreach (string file in changedFiles) { - string directory = Path.GetDirectoryName (file)?.Replace ("\\", "/") ?? ""; - if (!filesByDirectory.TryGetValue (directory, out List files)) { - files = new List (); - filesByDirectory.Add (directory, files); - } - files.Add (file); - } + var filesByDirectory = GroupFilesByDirectory (changedFiles); foreach (var group in filesByDirectory) { - string targetDirectory = string.IsNullOrEmpty (group.Key) ? overridePath : $"{overridePath}/{group.Key}"; + string targetDirectory = CombineRemotePath (overridePath, group.Key); phase.Restart (); string output = await RunAs ("mkdir", "-p", targetDirectory); AddDiagnosticElapsed ("deploy.fastdeploy2.override.mkdir.ms", phase); @@ -876,7 +770,7 @@ protected async Task CopyChangedFiles (string remoteStagingPath, string ov var batchFiles = group.Value.Skip (i).Take (CopyBatchSize).ToList (); var removeArgs = new List { "rm", "-f" }; foreach (string file in batchFiles) { - removeArgs.Add ($"{targetDirectory}/{Path.GetFileName (file)}"); + removeArgs.Add (CombineRemotePath (targetDirectory, Path.GetFileName (file))); } output = await RunAs (removeArgs.ToArray ()); if (RaiseRunAsError (output) || IsShellError (output, "rm")) { @@ -886,7 +780,7 @@ protected async Task CopyChangedFiles (string remoteStagingPath, string ov var args = new List { "cp", "-p" }; foreach (string file in batchFiles) { - args.Add ($"{remoteStagingPath}/{file}"); + args.Add (CombineRemotePath (remoteStagingPath, file)); } args.Add (targetDirectory); phase.Restart (); @@ -921,36 +815,13 @@ protected IEnumerable> BatchArguments (string command, string optio } } - protected void SetDiagnosticElapsed (string key, Stopwatch stopwatch) - { - diagnosticData.SetProperty (key, stopwatch.ElapsedMilliseconds); - } - - protected void AddDiagnosticElapsed (string key, Stopwatch stopwatch) - { - if (!long.TryParse (diagnosticData.Properties [key], out long current)) { - current = 0; - } - diagnosticData.SetProperty (key, current + stopwatch.ElapsedMilliseconds); - } - - protected void SetDiagnosticProperty (string key, int value) - { - diagnosticData.SetProperty (key, value); - } - - protected void SetDiagnosticProperty (string key, string value) - { - diagnosticData.SetProperty (key, value); - } - protected async Task UploadFiles (string remoteStagingPath, List files) { int pushed = 0; int skipped = 0; int batches = 0; - foreach (var group in files.GroupBy (file => Path.GetDirectoryName (file.RelativePath)?.Replace ("\\", "/") ?? "", StringComparer.Ordinal)) { - string remoteDirectory = string.IsNullOrEmpty (group.Key) ? remoteStagingPath : $"{remoteStagingPath}/{group.Key}"; + foreach (var group in files.GroupBy (file => GetDirectoryName (file.RelativePath), StringComparer.Ordinal)) { + string remoteDirectory = CombineRemotePath (remoteStagingPath, group.Key); foreach (var batch in BatchPushFiles (group.ToList (), remoteDirectory)) { var result = await RunAdbCommand (batch.ToArray ()); if (result.ExitCode != 0) { @@ -976,12 +847,12 @@ IEnumerable> BatchPushFiles (List files, string rem int length = EstimateCommandLength (batch) + remoteDirectory.Length + 4; foreach (var file in files) { if (Path.GetFileName (file.LocalPath) != Path.GetFileName (file.RelativePath)) { - yield return CreatePushArgs (file.LocalPath, $"{remoteDirectory}/{Path.GetFileName (file.RelativePath)}"); + yield return CreatePushArgs (file.LocalPath, CombineRemotePath (remoteDirectory, Path.GetFileName (file.RelativePath))); continue; } int itemLength = file.LocalPath.Length + 3; - if (batch.Count > prefixCount && length + itemLength >= 4096) { + if (batch.Count > prefixCount && length + itemLength >= MaxAdbCommandLength) { batch.Add (remoteDirectory); yield return batch; batch = CreatePushArgsPrefix (); @@ -1039,16 +910,6 @@ int EstimateCommandLength (List args) return (pushed, skipped); } - protected void SetAdbPushFileCounts (string output) - { - var match = AdbPushSummaryRegex.Match (output ?? ""); - if (!match.Success) { - return; - } - diagnosticData.SetProperty ("deploy.fastdeploy2.adb.pushed.files", match.Groups ["pushed"].Value); - diagnosticData.SetProperty ("deploy.fastdeploy2.adb.skipped.files", match.Groups ["skipped"].Value); - } - protected async Task RunAdbCommand (params string [] arguments) { return await RunAdbCommand (arguments, environmentVariables: null); @@ -1169,7 +1030,7 @@ protected async Task RunAsShell (string script) return result; } - static string QuoteShellArgument (string value) + protected static string QuoteShellArgument (string value) { return "'" + value.Replace ("'", "'\"'\"'") + "'"; } @@ -1209,56 +1070,32 @@ protected void LogFastDeploy2Error (string errorCode, string error, string file } } - protected void LogDiagnostic (string message) - { - if (DiagnosticLogging) { - LogDebugMessage (message); - return; - } - lock (diagnosticLogsLock) { - diagnosticLogs.Enqueue (message); - } - } + protected string GetFullPath (string dir) => Path.IsPathRooted (dir) ? dir : Path.GetFullPath (Path.Combine (WorkingDirectory, dir)); - void PrintDiagnostics () + protected static string GetDirectoryName (string file) { - while (true) { - string message; - lock (diagnosticLogsLock) { - if (diagnosticLogs.Count == 0) { - break; - } - message = diagnosticLogs.Dequeue (); - } - LogMessage (message); - } - LogMessage ($"{diagnosticData.Task}"); - foreach (var t in diagnosticData.Properties) { - LogMessage ($"\t{t.Key}: {t.Value}"); - } + return Path.GetDirectoryName (file)?.Replace ("\\", "/") ?? ""; } - void LogDiagnosticDataError (string errorCode, string error, string file = "") + protected static string CombineRemotePath (string rootPath, string relativePath) { - diagnosticData.SetProperty ("deploy.result", "Failed"); - if (!string.IsNullOrEmpty (file)) - diagnosticData.SetProperty ("pii.deploy.file", file); - diagnosticData.SetProperty ("pii.deploy.error", error); - diagnosticData.SetProperty ("deploy.error.code", errorCode); + return string.IsNullOrEmpty (relativePath) ? rootPath : $"{rootPath}/{relativePath}"; } - void SaveDiagnosticData (long ms) + protected static Dictionary> GroupFilesByDirectory (IEnumerable files) { - JsonSerializerOptions options = new JsonSerializerOptions { - WriteIndented = true - }; - diagnosticData.SetProperty ("deploy.duration.ms", ms); - string newPath = Path.Combine (IntermediateOutputPath, "diagnostics", $"{GetType ().Name.ToLowerInvariant ()}.json"); - File.WriteAllText (newPath, JsonSerializer.Serialize (diagnosticData, options)); + var filesByDirectory = new Dictionary> (StringComparer.Ordinal); + foreach (string file in files) { + string directory = GetDirectoryName (file); + if (!filesByDirectory.TryGetValue (directory, out List filesInDirectory)) { + filesInDirectory = new List (); + filesByDirectory.Add (directory, filesInDirectory); + } + filesInDirectory.Add (file); + } + return filesByDirectory; } - protected string GetFullPath (string dir) => Path.IsPathRooted (dir) ? dir : Path.GetFullPath (Path.Combine (WorkingDirectory, dir)); - protected bool RaiseRunAsError (string error) { if (TryGetRunAsErrorCode (error, out var err)) { @@ -1284,22 +1121,15 @@ bool TryGetRunAsErrorCode (string error, out (string error, string code, string string GetErrorCode (Exception ex) { - switch (ex) { - case IncompatibleCpuAbiException e: - return "ADB0020"; - case RequiresUninstallException e: - return "ADB0030"; - case SdkNotSupportedException e: - return "ADB0040"; - case PackageAlreadyExistsException e: - return "ADB0050"; - case InsufficientSpaceException e: - return "ADB0060"; - case InstallFailedException e: - return "ADB0010"; - default: - return GetErrorCode (ex.Message); - } + return ex switch { + IncompatibleCpuAbiException => "ADB0020", + RequiresUninstallException => "ADB0030", + SdkNotSupportedException => "ADB0040", + PackageAlreadyExistsException => "ADB0050", + InsufficientSpaceException => "ADB0060", + InstallFailedException => "ADB0010", + _ => GetErrorCode (ex.Message), + }; } static string GetErrorCode (string message) diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2JsonSerializerContext.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2JsonSerializerContext.cs new file mode 100644 index 00000000000..6f92f7b6d9a --- /dev/null +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2JsonSerializerContext.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Xamarin.Android.Tasks +{ + [JsonSourceGenerationOptions (WriteIndented = true)] + [JsonSerializable (typeof (Dictionary))] + [JsonSerializable (typeof (FastDeploy2Base.DiagnosticData))] + internal partial class FastDeploy2JsonSerializerContext : JsonSerializerContext + { + } +} 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 71afbd052f1..bb19572ed95 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 @@ -367,21 +367,17 @@ Copyright (C) 2016 Xamarin. All rights reserved. AdbTarget="$(AdbTarget)" DiagnosticLogging="$(_FastDeploymentDiagnosticLogging)" FastDevFiles="@(_FilteredFastDevFiles);@(_ResolvedConfigFiles)" - FastDevToolPath="$(MSBuildThisFileDirectory)\lib" EmbedAssembliesIntoApk="$(EmbedAssembliesIntoApk)" PrimaryCpuAbi="$(_PrimaryCpuAbi)" - ToolsAbi="$(_FastDevToolsAbi)" PreserveUserData="$(AndroidPreserveUserData)" PackageName="$(_AndroidPackage)" PackageFile="$(_ApkToInstall)" ReInstall="$(_ReInstall)" - ToolVersion="$(AndroidFastDeploymentToolVersion)" AdbToolPath="$(AdbToolPath)" AdbToolExe="$(AdbToolExe)" AdbPushCompressionAlgorithm="$(AndroidFastDeploymentAdbCompressionAlgorithm)" AppFileTransferMode="$(_AndroidFastDeployAppFileTransferMode)" UploadFlagFile="$(_UploadFlag)" - UsingAndroidNETSdk="$(UsingAndroidNETSdk)" UserID="$(AndroidDeviceUserId)" IsTestOnly="$(_AndroidIsTestOnlyPackage)" IntermediateOutputPath="$(IntermediateOutputPath)" From ace7857ff8c32376928a88cab89ffb7c1e203b87 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 18 Jun 2026 16:35:33 +0200 Subject: [PATCH 08/11] Simplify FastDeploy2 implementation Flatten FastDeploy2 into a single concrete task, remove the development diagnostics property bag and JSON payload, and keep only the functional manifest serialization state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/FastDeploy2.Diagnostics.cs | 170 ---------- .../Tasks/FastDeploy2.Manifest.cs | 90 +---- .../Tasks/FastDeploy2.cs | 319 ++++-------------- .../Tasks/FastDeploy2JsonSerializerContext.cs | 1 - 4 files changed, 76 insertions(+), 504 deletions(-) delete mode 100644 src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Diagnostics.cs diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Diagnostics.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Diagnostics.cs deleted file mode 100644 index 7d2358674d0..00000000000 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Diagnostics.cs +++ /dev/null @@ -1,170 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Xamarin.Android.Tasks -{ - public abstract partial class FastDeploy2Base - { - internal class DiagnosticData { - [JsonPropertyName ("Task")] - public string Task { get; set; } = nameof (FastDeploy2); - - [JsonPropertyName ("Properties")] - public Dictionary Properties { get; set; } = new Dictionary () { - { "target.prop.ro.product.build.version.sdk", "" }, - { "target.prop.ro.product.cpu.abilist", "" }, - { "target.prop.ro.product.manufacturer", "" }, - { "target.prop.ro.product.model", "" }, - { "target.prop.ro.product.cpu.abi", "" }, - { "deploy.error.code", "" }, - { "deploy.tool", "adb push" }, - { "deploy.result", "Success" }, - { "deploy.supports.fastdev", "True" }, - { "deploy.systemapp", "False" }, - { "deploy.duration.ms", "0" }, - { "deploy.fastdeploy2.adb.pushed.files", "" }, - { "deploy.fastdeploy2.adb.skipped.files", "" }, - { "deploy.fastdeploy2.changed.files", "" }, - { "deploy.fastdeploy2.stale.files", "" }, - { "deploy.fastdeploy2.local.stage.ms", "" }, - { "deploy.fastdeploy2.remote.mkdir.ms", "" }, - { "deploy.fastdeploy2.remote.staging.cleanup.ms", "" }, - { "deploy.fastdeploy2.upload.ms", "" }, - { "deploy.fastdeploy2.staging.stat.ms", "" }, - { "deploy.fastdeploy2.override.stat.ms", "" }, - { "deploy.fastdeploy2.compare.ms", "" }, - { "deploy.fastdeploy2.stale.remove.ms", "" }, - { "deploy.fastdeploy2.override.mkdir.ms", "" }, - { "deploy.fastdeploy2.override.copy.ms", "" }, - { "deploy.orchestration.ensure-properties.ms", "" }, - { "deploy.orchestration.property-checks.ms", "" }, - { "deploy.orchestration.package-check.ms", "" }, - { "deploy.orchestration.package-timestamp.ms", "" }, - { "deploy.orchestration.install.ms", "" }, - { "deploy.orchestration.terminate.ms", "" }, - { "deploy.orchestration.empty-check.ms", "" }, - { "deploy.execute.parse-target.ms", "" }, - { "deploy.execute.no-abi-check.ms", "" }, - { "deploy.execute.upload-flag-stat.ms", "" }, - { "deploy.execute.task-cache.ms", "" }, - { "deploy.orchestration.property-capture.ms", "" }, - { "deploy.orchestration.redirect-stdio-check.ms", "" }, - { "deploy.orchestration.run-as-disabled-check.ms", "" }, - { "deploy.orchestration.package-check.ensure-user.ms", "" }, - { "deploy.orchestration.package-check.run-as-pwd.ms", "" }, - { "deploy.orchestration.package-check.run-as-pwd-pidof.ms", "" }, - { "deploy.orchestration.package-check.readlink.ms", "" }, - { "deploy.orchestration.package-check.system-app.ms", "" }, - { "deploy.orchestration.package-check.evaluate.ms", "" }, - { "deploy.orchestration.package-timestamp.path-stat.ms", "" }, - { "deploy.orchestration.install.push-install.ms", "" }, - { "deploy.orchestration.install.retry-delete.ms", "" }, - { "deploy.orchestration.install.retry-uninstall.ms", "" }, - { "deploy.orchestration.install.retry-reinstall.ms", "" }, - { "deploy.orchestration.terminate.get-pid.ms", "" }, - { "deploy.orchestration.terminate.kill.ms", "" }, - { "deploy.app.file.transfer.mode", "" }, - { "deploy.fastdeploy2.bulk.batches", "" }, - { "deploy.symlink.created.files", "" }, - { "deploy.symlink.removed.files", "" }, - { "deploy.symlink.shell.update.ms", "" }, - { "pii.deploy.error", "" }, - { "pii.deploy.file", "" }, - }; - - internal void SetProperty (string key, bool? value) - { - Properties [key] = value?.ToString () ?? "False"; - } - - internal void SetProperty (string key, int? value) - { - Properties [key] = value?.ToString () ?? "-1"; - } - - internal void SetProperty (string key, long? value) - { - Properties [key] = value?.ToString () ?? "-1"; - } - - internal void SetProperty (string key, string value) - { - Properties [key] = value ?? "unknown"; - } - } - - protected void SetDiagnosticElapsed (string key, Stopwatch stopwatch) - { - diagnosticData.SetProperty (key, stopwatch.ElapsedMilliseconds); - } - - protected void AddDiagnosticElapsed (string key, Stopwatch stopwatch) - { - if (!long.TryParse (diagnosticData.Properties [key], out long current)) { - current = 0; - } - diagnosticData.SetProperty (key, current + stopwatch.ElapsedMilliseconds); - } - - protected void SetDiagnosticProperty (string key, int value) - { - diagnosticData.SetProperty (key, value); - } - - protected void SetDiagnosticProperty (string key, string value) - { - diagnosticData.SetProperty (key, value); - } - - protected void LogDiagnostic (string message) - { - if (DiagnosticLogging) { - LogDebugMessage (message); - return; - } - lock (diagnosticLogsLock) { - diagnosticLogs.Enqueue (message); - } - } - - void PrintDiagnostics () - { - while (true) { - string message; - lock (diagnosticLogsLock) { - if (diagnosticLogs.Count == 0) { - break; - } - message = diagnosticLogs.Dequeue (); - } - LogMessage (message); - } - LogMessage ($"{diagnosticData.Task}"); - foreach (var t in diagnosticData.Properties) { - LogMessage ($"\t{t.Key}: {t.Value}"); - } - } - - void LogDiagnosticDataError (string errorCode, string error, string file = "") - { - diagnosticData.SetProperty ("deploy.result", "Failed"); - if (!string.IsNullOrEmpty (file)) - diagnosticData.SetProperty ("pii.deploy.file", file); - diagnosticData.SetProperty ("pii.deploy.error", error); - diagnosticData.SetProperty ("deploy.error.code", errorCode); - } - - void SaveDiagnosticData (long ms) - { - diagnosticData.SetProperty ("deploy.duration.ms", ms); - string newPath = Path.Combine (IntermediateOutputPath, "diagnostics", $"{GetType ().Name.ToLowerInvariant ()}.json"); - File.WriteAllText (newPath, JsonSerializer.Serialize ( - diagnosticData, - typeof (DiagnosticData), - FastDeploy2JsonSerializerContext.Default)); - } - } -} 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 59a7a8ce79f..f2f6963e6d5 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using System.Text; @@ -10,23 +9,19 @@ namespace Xamarin.Android.Tasks { - public class FastDeploy2 : FastDeploy2Base + public partial class FastDeploy2 { const string RemoteStagingRootPath = "/tmp/fastdeploy2"; const string RemoteReadyMarker = ".fastdeploy2-ready"; const string OverrideSymlinkReadyMarker = ".fastdeploy2-symlinks"; - public override string TaskPrefix => "FD2"; + string RemoteStagingRoot => RemoteStagingRootPath; - protected override string RemoteStagingRoot => RemoteStagingRootPath; - - protected override async Task DeployFastDevFilesWithAdbPush (string overridePath) + async Task DeployFastDevFilesWithAdbPush (string overridePath) { - var phase = Stopwatch.StartNew (); var files = PrepareDirectPushFiles (); var expectedFiles = new HashSet (files.Select (file => file.RelativePath), StringComparer.Ordinal); var currentManifest = CreateManifest (files); - SetDiagnosticElapsed ("deploy.fastdeploy2.local.stage.ms", phase); if (files.Count == 0) { LogDiagnostic ("No FastDev files were prepared for adb push deployment."); return true; @@ -36,7 +31,6 @@ protected override async Task DeployFastDevFilesWithAdbPush (string overri bool remoteReady = await IsRemoteReady (remoteStagingPath); var previousManifest = remoteReady ? LoadPreviousManifest () : null; if (previousManifest == null) { - SetDiagnosticProperty ("deploy.fastdeploy2.manifest.full.push", 1); if (!await ResetRemoteStagingDirectory (remoteStagingPath)) { return false; } @@ -44,28 +38,21 @@ protected override async Task DeployFastDevFilesWithAdbPush (string overri var changedFiles = GetChangedFiles (currentManifest, previousManifest); var removedFiles = GetRemovedFiles (currentManifest, previousManifest); - SetDiagnosticProperty ("deploy.fastdeploy2.manifest.changed.files", changedFiles.Count); - SetDiagnosticProperty ("deploy.fastdeploy2.manifest.removed.files", removedFiles.Count); + LogDiagnostic ($"FastDeploy2 manifest changed files: {changedFiles.Count}; removed files: {removedFiles.Count}."); - phase.Restart (); string output = await CreateRemoteStagingDirectories (remoteStagingPath, expectedFiles); - SetDiagnosticElapsed ("deploy.fastdeploy2.remote.mkdir.ms", phase); if (!string.IsNullOrEmpty (output) && IsShellError (output, "mkdir")) { LogFastDeploy2Error ("XA0129", output, remoteStagingPath); return false; } - phase.Restart (); if (!await RemoveRemoteStaleFiles (remoteStagingPath, removedFiles)) { return false; } - SetDiagnosticElapsed ("deploy.fastdeploy2.remote.staging.cleanup.ms", phase); - phase.Restart (); if (!await UploadChangedFiles (remoteStagingPath, files, changedFiles)) { return false; } - SetDiagnosticElapsed ("deploy.fastdeploy2.upload.ms", phase); bool result; if (UseShellSymlinkAppFileTransfer ()) { @@ -93,18 +80,11 @@ async Task UpdateOverrideShellSymlinks (string remoteStagingPath, string o var newFiles = previousSymlinkManifest == null ? new HashSet (currentManifest.Keys, StringComparer.Ordinal) : new HashSet (currentManifest.Keys.Where (file => !previousSymlinkManifest.ContainsKey (file)), StringComparer.Ordinal); - SetDiagnosticProperty ("deploy.fastdeploy2.changed.files", newFiles.Count); - SetDiagnosticProperty ("deploy.symlink.created.files", newFiles.Count); - SetDiagnosticProperty ("deploy.symlink.removed.files", removedFiles.Count + newFiles.Count); - SetDiagnosticProperty ("deploy.fastdeploy2.stale.files", removedFiles.Count); - SetDiagnosticProperty ("deploy.symlink.tool.result", "shell"); + LogDiagnostic ($"FastDeploy2 symlink update new files: {newFiles.Count}; removed files: {removedFiles.Count}."); - var phase = Stopwatch.StartNew (); if (!await RunCombinedShellSymlinkUpdate (remoteStagingPath, overridePath, currentManifest, previousSymlinkManifest, newFiles, removedFiles)) { - SetDiagnosticElapsed ("deploy.symlink.shell.update.ms", phase); return await FallbackToCopy (remoteStagingPath, overridePath); } - SetDiagnosticElapsed ("deploy.symlink.shell.update.ms", phase); if (!await MarkOverrideSymlinkReady (overridePath)) { return await FallbackToCopy (remoteStagingPath, overridePath); @@ -122,12 +102,9 @@ async Task RunCombinedShellSymlinkUpdate (string remoteStagingPath, string directories.UnionWith (removedByDirectory.Keys); foreach (string directory in directories) { - currentByDirectory.TryGetValue (directory, out List currentInDirectory); - newByDirectory.TryGetValue (directory, out List newInDirectory); - removedByDirectory.TryGetValue (directory, out List removedInDirectory); - currentInDirectory = currentInDirectory ?? []; - newInDirectory = newInDirectory ?? []; - removedInDirectory = removedInDirectory ?? []; + var currentInDirectory = GetFilesInDirectory (currentByDirectory, directory); + var newInDirectory = GetFilesInDirectory (newByDirectory, directory); + var removedInDirectory = GetFilesInDirectory (removedByDirectory, directory); string targetDirectory = CombineRemotePath (overridePath, directory); string sourceDirectory = CombineRemotePath (remoteStagingPath, directory); @@ -153,6 +130,11 @@ async Task RunCombinedShellSymlinkUpdate (string remoteStagingPath, string return true; } + static List GetFilesInDirectory (Dictionary> filesByDirectory, string directory) + { + return filesByDirectory.TryGetValue (directory, out List files) ? files : []; + } + IEnumerable CreateShellSymlinkScripts (string remoteStagingPath, string overridePath, List newFiles, List removedFiles) { foreach (var group in removedFiles.Concat (newFiles).GroupBy (GetDirectoryName, StringComparer.Ordinal)) { @@ -202,13 +184,12 @@ IEnumerable BatchShellWords (string prefix, IEnumerable words, s async Task FallbackToCopy (string remoteStagingPath, string overridePath) { - SetDiagnosticProperty ("deploy.symlink.tool.result", "shell fallback to copy"); + LogDiagnostic ("FastDeploy2 symlink update failed; falling back to copy mode."); return await UpdateOverrideCopies (remoteStagingPath, overridePath, clearOverrideDirectory: true); } async Task UpdateOverrideCopies (string remoteStagingPath, string overridePath, bool clearOverrideDirectory = false) { - var phase = Stopwatch.StartNew (); if (clearOverrideDirectory) { if (!await ClearOverrideDirectory (overridePath)) { return false; @@ -218,15 +199,12 @@ async Task UpdateOverrideCopies (string remoteStagingPath, string override } var stagedFileData = await GetRemoteFileData (remoteStagingPath, runAs: false); - SetDiagnosticElapsed ("deploy.fastdeploy2.staging.stat.ms", phase); if (stagedFileData == null) { return false; } stagedFileData.Remove (RemoteReadyMarker); - phase.Restart (); var overrideFileData = await GetRemoteFileData (overridePath, runAs: true); - SetDiagnosticElapsed ("deploy.fastdeploy2.override.stat.ms", phase); if (overrideFileData == null) { return false; } @@ -287,9 +265,6 @@ List GetRemovedFiles (Dictionary currentManifest, async Task UploadChangedFiles (string remoteStagingPath, List files, HashSet changedFiles) { - int pushed = 0; - int skipped = 0; - int batches = 0; var changedFileList = files.Where (file => changedFiles.Contains (file.RelativePath)).ToList (); foreach (var group in changedFileList.GroupBy (file => GetDirectoryName (file.RelativePath), StringComparer.Ordinal)) { string remoteDirectory = CombineRemotePath (remoteStagingPath, group.Key); @@ -299,16 +274,8 @@ async Task UploadChangedFiles (string remoteStagingPath, List> BatchPushFilesWithoutSync (List files, int length = EstimateCommandLength (batch) + remoteDirectory.Length + 4; foreach (var file in files) { if (Path.GetFileName (file.LocalPath) != Path.GetFileName (file.RelativePath)) { - yield return CreatePushArgs (file.LocalPath, $"{remoteDirectory}/{Path.GetFileName (file.RelativePath)}"); + yield return CreatePushArgs (file.LocalPath, CombineRemotePath (remoteDirectory, Path.GetFileName (file.RelativePath))); continue; } @@ -362,33 +329,6 @@ IEnumerable> BatchPushFilesWithoutSync (List files, } } - List CreatePushArgs (string localPath, string remotePath) - { - var args = CreatePushArgsPrefix (); - args.Add (localPath); - args.Add (remotePath); - return args; - } - - List CreatePushArgsPrefix () - { - var args = new List { "push" }; - if (!string.IsNullOrEmpty (AdbPushCompressionAlgorithm)) { - args.Add ("-z"); - args.Add (AdbPushCompressionAlgorithm); - } - return args; - } - - int EstimateCommandLength (List args) - { - int length = 0; - foreach (var arg in args) { - length += arg.Length + 3; - } - return length; - } - async Task IsRemoteReady (string remoteStagingPath) { var result = await RunAdbCommand ("shell", "test", "-f", CombineRemotePath (remoteStagingPath, RemoteReadyMarker)); diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs index d6c605f7538..b83755b77c0 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs @@ -16,13 +16,13 @@ namespace Xamarin.Android.Tasks { - public abstract partial class FastDeploy2Base : AsyncTask + public partial class FastDeploy2 : AsyncTask { const string OverridePath = "files/.__override__"; const int StaleFileRemovalBatchSize = 100; const int CopyBatchSize = 25; const int MaxShellCommandLength = 900; - protected const int MaxAdbCommandLength = 4096; + const int MaxAdbCommandLength = 4096; public override string TaskPrefix => "FD2"; @@ -66,9 +66,6 @@ public abstract partial class FastDeploy2Base : AsyncTask DateTime lastUpload = DateTime.MinValue; Queue diagnosticLogs = new Queue (); readonly object diagnosticLogsLock = new object (); - DiagnosticData diagnosticData = new DiagnosticData (); - - protected virtual string RemoteStagingRoot => "/tmp/fastdev2"; string OverrideFullPath { get { return packageInfo.IsSystemApplication ? $"{packageInfo.InternalPath}/{OverridePath}" : OverridePath; } @@ -88,16 +85,41 @@ public string InternalPath { public int ProcessId { get; set; } = 0; } - protected class RemoteFileInfo { + class RemoteFileInfo { public long Size { get; set; } public long ModifiedTime { get; set; } } - protected class DirectPushFile { + class DirectPushFile { public string LocalPath { get; set; } public string RelativePath { get; set; } } + void LogDiagnostic (string message) + { + if (DiagnosticLogging) { + LogDebugMessage (message); + return; + } + lock (diagnosticLogsLock) { + diagnosticLogs.Enqueue (message); + } + } + + void PrintDiagnostics () + { + while (true) { + string message; + lock (diagnosticLogsLock) { + if (diagnosticLogs.Count == 0) { + break; + } + message = diagnosticLogs.Dequeue (); + } + LogMessage (message); + } + } + void DebugHandler (string task, string message) { LogDiagnostic ($"DEBUG {task} {message}"); @@ -105,32 +127,23 @@ void DebugHandler (string task, string message) public override bool Execute () { - var phase = Stopwatch.StartNew (); Device = AndroidHelper.ParseTarget (AdbTarget, LogMessage, LogCodedError, logErrors: true, engine4: BuildEngine4); - SetDiagnosticElapsed ("deploy.execute.parse-target.ms", phase); if (Device == null) { PrintDiagnostics (); return false; } LogMessage ($"Found device: {Device.ID}"); - phase.Restart (); if (string.IsNullOrEmpty (PrimaryCpuAbi) && !EmbedAssembliesIntoApk) { - SetDiagnosticElapsed ("deploy.execute.no-abi-check.ms", phase); PrintDiagnostics (); LogCodedError ("XA0010", Resources.XA0010_NoAbi, Device.ID); return false; } - SetDiagnosticElapsed ("deploy.execute.no-abi-check.ms", phase); - phase.Restart (); var flagFilePath = GetFullPath (UploadFlagFile); lastUpload = File.GetLastWriteTimeUtc (flagFilePath); LogDiagnostic ($"LastWriteTime of `{flagFilePath}`: {lastUpload}"); - diagnosticData.Task = GetType ().Name; - SetDiagnosticElapsed ("deploy.execute.upload-flag-stat.ms", phase); - phase.Restart (); var lifetime = RegisteredTaskObjectLifetime.AppDomain; var key = ProjectSpecificTaskObjectKey ($"{Device.ID}_{PackageName}_{GetType ().Name}"); if (!File.Exists (UploadFlagFile)) { @@ -138,7 +151,6 @@ public override bool Execute () } else { packageInfo = BuildEngine4.GetRegisteredTaskObjectAssemblyLocal (key, lifetime) ?? new PackageInfo (); } - SetDiagnosticElapsed ("deploy.execute.task-cache.ms", phase); AndroidLogger.Debug += DebugHandler; try { @@ -151,52 +163,31 @@ public override bool Execute () public async override Task RunTaskAsync () { - var sw = Stopwatch.StartNew (); try { await RunInstall (); } catch { PrintDiagnostics (); throw; - } finally { - sw.Stop (); - SaveDiagnosticData (sw.ElapsedMilliseconds); } } async Task RunInstall () { - var phase = Stopwatch.StartNew (); await Device.EnsureProperties (CancellationToken).ConfigureAwait (false); - SetDiagnosticElapsed ("deploy.orchestration.ensure-properties.ms", phase); - phase.Restart (); - diagnosticData.SetProperty ("target.prop.ro.product.build.version.sdk", Device.Properties?.BuildVersionSdk); - diagnosticData.SetProperty ("target.prop.ro.product.cpu.abilist", string.Join (";", Device.Properties?.ProductCpuAbiList ?? [])); - diagnosticData.SetProperty ("target.prop.ro.product.cpu.abi", PrimaryCpuAbi); - diagnosticData.SetProperty ("target.prop.ro.product.manufacturer", Device.Properties?.ProductManufacturer); - diagnosticData.SetProperty ("target.prop.ro.product.model", Device.Properties?.ProductModel); - SetDiagnosticElapsed ("deploy.orchestration.property-capture.ms", phase); - - phase.Restart (); string redirectStdio = Device.Properties.Get ("log.redirect-stdio"); - SetDiagnosticElapsed ("deploy.orchestration.redirect-stdio-check.ms", phase); if (redirectStdio != null && string.Equals ("true", redirectStdio.Trim (), StringComparison.OrdinalIgnoreCase)) { LogFastDeploy2Error ("XA0128", Resources.XA0128_RedirectStdioIsEnabled); return; } - phase.Restart (); string runAsDisabled = Device.Properties.Get ("ro.boot.disable_runas"); - SetDiagnosticElapsed ("deploy.orchestration.run-as-disabled-check.ms", phase); if (runAsDisabled != null && string.Equals ("true", runAsDisabled.Trim (), StringComparison.OrdinalIgnoreCase)) { LogFastDeploy2Error ("XA0131", Resources.XA0131_DeveloperModeNotEnabled); return; } - SetDiagnosticElapsed ("deploy.orchestration.property-checks.ms", phase); - phase.Restart (); await CheckAppInstalledAndDebuggable (PackageName); - SetDiagnosticElapsed ("deploy.orchestration.package-check.ms", phase); if (EmbedAssembliesIntoApk) { await RemoveOverrideDirectory (); @@ -206,26 +197,19 @@ async Task RunInstall () await Device.UninstallPackage (PackageName, PreserveUserData, CancellationToken); } - phase.Restart (); bool packageFileOutOfDate = !string.IsNullOrEmpty (PackageFile) && (packageInfo.InternalPath.IndexOf ("unknown", StringComparison.OrdinalIgnoreCase) >= 0 || ReInstall || IsPackageFileOutOfDate ()); - SetDiagnosticElapsed ("deploy.orchestration.package-timestamp.ms", phase); if (packageFileOutOfDate) { try { - phase.Restart (); await InstallPackage (); - AddDiagnosticElapsed ("deploy.orchestration.install.ms", phase); } catch (Exception ex) { - AddDiagnosticElapsed ("deploy.orchestration.install.ms", phase); LogFastDeploy2Error (GetErrorCode (ex), ex.ToString ()); return; } if (!EmbedAssembliesIntoApk && packageInfo.InternalPath.IndexOf ("unknown", StringComparison.OrdinalIgnoreCase) >= 0) { packageInfo.InternalPath = null; - phase.Restart (); await CheckAppInstalledAndDebuggable (PackageName); - AddDiagnosticElapsed ("deploy.orchestration.package-check.ms", phase); if (RaiseRunAsError (packageInfo.InternalPath)) { return; } @@ -235,82 +219,59 @@ async Task RunInstall () if (EmbedAssembliesIntoApk) return; - phase.Restart (); if ((FastDevFiles?.Length ?? 0) == 0 && (EnvironmentFiles?.Length ?? 0) == 0) { - SetDiagnosticElapsed ("deploy.orchestration.empty-check.ms", phase); return; } - SetDiagnosticElapsed ("deploy.orchestration.empty-check.ms", phase); - diagnosticData.SetProperty ("deploy.app.file.transfer.mode", AppFileTransferMode); - phase.Restart (); await TerminateApp (); - SetDiagnosticElapsed ("deploy.orchestration.terminate.ms", phase); await DeployFastDevFilesWithAdbPush (OverrideFullPath); } bool IsPackageFileOutOfDate () { - var phase = Stopwatch.StartNew (); var packageFile = GetFullPath (PackageFile); var lastPackage = File.GetLastWriteTimeUtc (packageFile); LogDiagnostic ($"LastWriteTime of `{packageFile}`: {lastPackage}"); - SetDiagnosticElapsed ("deploy.orchestration.package-timestamp.path-stat.ms", phase); return lastUpload < lastPackage; } async Task CheckAppInstalledAndDebuggable (string packageName) { - var phase = Stopwatch.StartNew (); packageInfo.UserId = UserID; packageInfo.PackageName = packageName; packageInfo.ProcessId = 0; await EnsureUserIsRunning (); - SetDiagnosticElapsed ("deploy.orchestration.package-check.ensure-user.ms", phase); - phase.Restart (); string packageInfoOutput = IsSafePackageNameForShell (packageName) ? await RunAs ("sh", "-c", $"pwd; pidof {packageName} 2>/dev/null || true") : await RunAs ("pwd"); - SetDiagnosticElapsed ("deploy.orchestration.package-check.run-as-pwd-pidof.ms", phase); ParsePackageInfoOutput (packageInfoOutput); if (string.IsNullOrEmpty (packageInfo.InternalPath)) { packageInfo.InternalPath = packageInfoOutput?.Trim (); } - phase.Restart (); - SetDiagnosticElapsed ("deploy.orchestration.package-check.run-as-pwd.ms", phase); if (packageInfo.InternalPath.IndexOf ("Permission denied", StringComparison.OrdinalIgnoreCase) >= 0) { - phase.Restart (); packageInfo.InternalPath = await RunAs ("readlink", "-f", "."); - SetDiagnosticElapsed ("deploy.orchestration.package-check.readlink.ms", phase); } - phase.Restart (); if (packageInfo.InternalPath.IndexOf ("not an application", StringComparison.OrdinalIgnoreCase) >= 0) { LogDiagnostic ($"Package {packageInfo.PackageName} is a system application."); packageInfo.IsSystemApplication = true; - diagnosticData.SetProperty ("deploy.systemapp", value: true); string whoami = await Device.RunShellCommand ("whoami"); packageInfo.AdbIsRoot = whoami.Trim () == "root"; LogDiagnostic ($"using {(packageInfo.AdbIsRoot ? "root" : $"su {packageInfo.UserId}")} to install fast deployment files."); packageInfo.InternalPath = $"/data/user/{(packageInfo.UserId ?? "0")}/{packageInfo.PackageName}"; - SetDiagnosticElapsed ("deploy.orchestration.package-check.system-app.ms", phase); return; } if (packageInfo.InternalPath.IndexOf ("not debuggable", StringComparison.OrdinalIgnoreCase) >= 0) { LogDiagnostic ($"Package {packageInfo.PackageName} was not debuggable. Forcing ReInstall"); ReInstall = true; - SetDiagnosticElapsed ("deploy.orchestration.package-check.evaluate.ms", phase); return; } if (packageInfo.InternalPath.IndexOf ("unknown", StringComparison.OrdinalIgnoreCase) >= 0) { LogDiagnostic ($"Package {packageInfo.PackageName} was not installed."); - SetDiagnosticElapsed ("deploy.orchestration.package-check.evaluate.ms", phase); return; } if (packageInfo.InternalPath.IndexOf ("Permission denied", StringComparison.OrdinalIgnoreCase) >= 0) { LogDiagnostic ("run-as not supported on this device."); - diagnosticData.SetProperty ("deploy.supports.fastdev", value: false); } - SetDiagnosticElapsed ("deploy.orchestration.package-check.evaluate.ms", phase); } static bool IsSafePackageNameForShell (string packageName) @@ -365,7 +326,6 @@ async Task InstallPackage () { LogDebugMessage ($"Installing Package {PackageName}"); try { - var phase = Stopwatch.StartNew (); await Device.PushAndInstallPackageAsync (new PushAndInstallCommand { ApkFile = PackageFile, PackageName = PackageName, @@ -373,7 +333,6 @@ await Device.PushAndInstallPackageAsync (new PushAndInstallCommand { User = UserID, TestOnly = IsTestOnly, }, token: CancellationToken); - SetDiagnosticElapsed ("deploy.orchestration.install.push-install.ms", phase); LogDebugMessage ($"Installed Package {PackageName}."); } catch (Exception exception) { var ex = exception; @@ -400,27 +359,21 @@ async Task ShouldThrowIfPackageInstallFailed (PackageAlreadyExistsExceptio return false; LogDebugMessage (string.Format ("Package '{0}' already exists. Retrying...", PackageName)); - var phase = Stopwatch.StartNew (); try { await Device.DeleteFile (e.PackageFile, true, CancellationToken); } catch { } - SetDiagnosticElapsed ("deploy.orchestration.install.retry-delete.ms", phase); bool preserveData = !(e is RequiresUninstallException); LogDebugMessage (string.Format ("Forcing complete uninstall of '{0}'... Preserving Data: {1}", PackageName, preserveData)); var uninstallCommand = new PmUninstallCommand () { PackageName = PackageName, User = UserID, PreserveData = preserveData }; - phase.Restart (); await Device.UninstallPackage (uninstallCommand, cancellationToken: CancellationToken); - SetDiagnosticElapsed ("deploy.orchestration.install.retry-uninstall.ms", phase); LogDebugMessage (string.Format ("Installing '{0}'...", PackageName)); - phase.Restart (); await Device.PushAndInstallPackageAsync (new PushAndInstallCommand { ApkFile = PackageFile, PackageName = PackageName, ReInstall = false, User = UserID }, token: CancellationToken); - SetDiagnosticElapsed ("deploy.orchestration.install.retry-reinstall.ms", phase); return false; } @@ -431,75 +384,20 @@ async Task RemoveOverrideDirectory () async Task TerminateApp () { - var phase = Stopwatch.StartNew (); var pid = packageInfo.ProcessId; if (pid == 0 && packageInfo.IsSystemApplication) { pid = await Device.GetProcessId (PackageName, CancellationToken); } - SetDiagnosticElapsed ("deploy.orchestration.terminate.get-pid.ms", phase); if (pid == 0) { LogDebugMessage ($"{PackageName} was not running, skipping kill"); return; } LogDebugMessage ($"Terminating {PackageName}..."); - phase.Restart (); await Device.KillProcessAndWaitForExit (PackageName, CancellationToken); - SetDiagnosticElapsed ("deploy.orchestration.terminate.kill.ms", phase); LogDebugMessage ($"{PackageName} Terminated."); } - protected virtual async Task DeployFastDevFilesWithAdbPush (string overridePath) - { - var phase = Stopwatch.StartNew (); - var directPushFiles = PrepareDirectPushFiles (); - var stagedFiles = new HashSet (directPushFiles.Select (file => file.RelativePath), StringComparer.Ordinal); - SetDiagnosticElapsed ("deploy.fastdeploy2.local.stage.ms", phase); - if (stagedFiles.Count == 0) { - LogDiagnostic ("No FastDev files were prepared for adb push deployment."); - return true; - } - - string remoteStagingPath = GetRemoteAdbPushStagingPath (); - phase.Restart (); - string output = await CreateRemoteStagingDirectories (remoteStagingPath, stagedFiles); - SetDiagnosticElapsed ("deploy.fastdeploy2.remote.mkdir.ms", phase); - if (!string.IsNullOrEmpty (output) && IsShellError (output, "mkdir")) { - LogFastDeploy2Error ("XA0129", output, remoteStagingPath); - return false; - } - - if (!await RemoveStaleRemoteStagingFiles (remoteStagingPath, stagedFiles)) { - return false; - } - - phase.Restart (); - if (!await UploadFiles (remoteStagingPath, directPushFiles)) { - return false; - } - SetDiagnosticElapsed ("deploy.fastdeploy2.upload.ms", phase); - - phase.Restart (); - var stagedFileData = await GetRemoteFileData (remoteStagingPath, runAs: false); - SetDiagnosticElapsed ("deploy.fastdeploy2.staging.stat.ms", phase); - if (stagedFileData == null) { - return false; - } - - phase.Restart (); - var overrideFileData = await GetRemoteFileData (overridePath, runAs: true); - SetDiagnosticElapsed ("deploy.fastdeploy2.override.stat.ms", phase); - if (overrideFileData == null) { - return false; - } - - if (!await RemoveStaleOverrideFiles (overridePath, stagedFileData, overrideFileData)) { - return false; - } - - return await CopyChangedFiles (remoteStagingPath, overridePath, stagedFileData, overrideFileData); - } - - protected async Task CreateRemoteStagingDirectories (string remoteStagingPath, HashSet stagedFiles) + async Task CreateRemoteStagingDirectories (string remoteStagingPath, HashSet stagedFiles) { var directories = new HashSet (StringComparer.Ordinal) { remoteStagingPath }; foreach (var file in stagedFiles) { @@ -516,7 +414,7 @@ protected async Task CreateRemoteStagingDirectories (string remoteStagin return output.ToString (); } - protected List PrepareDirectPushFiles () + List PrepareDirectPushFiles () { var files = new List (); foreach (var file in FastDevFiles ?? []) { @@ -555,7 +453,7 @@ protected List PrepareDirectPushFiles () return files; } - protected bool WriteFileIfChanged (string path, byte [] contents, DateTime modifiedDateTime) + bool WriteFileIfChanged (string path, byte [] contents, DateTime modifiedDateTime) { if (File.Exists (path) && File.ReadAllBytes (path).SequenceEqual (contents)) { return false; @@ -628,43 +526,7 @@ byte [] CreateEnvironmentFileData (ITaskItem [] environments, out DateTime newes } } - protected async Task RemoveStaleRemoteStagingFiles (string remoteStagingPath, HashSet stagedFiles) - { - var phase = Stopwatch.StartNew (); - string filelist = await Device.RunShellCommand (CancellationToken, "find", remoteStagingPath, "-type", "f"); - if (IsShellError (filelist, "find")) { - LogFastDeploy2Error ("XA0129", filelist, remoteStagingPath); - return false; - } - - string prefix = remoteStagingPath.TrimEnd ('/') + "/"; - var staleFiles = new List (); - foreach (string line in filelist.Split (new char [] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) { - string remoteFile = line.Trim (); - if (!remoteFile.StartsWith (prefix, StringComparison.Ordinal)) { - continue; - } - string relativePath = remoteFile.Substring (prefix.Length); - if (!stagedFiles.Contains (relativePath)) { - staleFiles.Add (remoteFile); - } - } - - for (int i = 0; i < staleFiles.Count; i += StaleFileRemovalBatchSize) { - var args = new List { "rm", "-f" }; - args.AddRange (staleFiles.Skip (i).Take (StaleFileRemovalBatchSize)); - string output = await Device.RunShellCommand (CancellationToken, args.ToArray ()); - if (IsShellError (output, "rm")) { - LogFastDeploy2Error ("XA0129", output, remoteStagingPath); - return false; - } - } - - SetDiagnosticElapsed ("deploy.fastdeploy2.remote.staging.cleanup.ms", phase); - return true; - } - - protected async Task> GetRemoteFileData (string rootPath, bool runAs) + async Task> GetRemoteFileData (string rootPath, bool runAs) { string output; if (runAs) { @@ -714,9 +576,8 @@ Dictionary ParseRemoteFileData (string rootPath, string return files; } - protected async Task RemoveStaleOverrideFiles (string overridePath, Dictionary stagedFiles, Dictionary overrideFiles) + async Task RemoveStaleOverrideFiles (string overridePath, Dictionary stagedFiles, Dictionary overrideFiles) { - var phase = Stopwatch.StartNew (); var staleFiles = new List (); foreach (var file in overrideFiles.Keys) { if (!stagedFiles.ContainsKey (file)) { @@ -725,7 +586,6 @@ protected async Task RemoveStaleOverrideFiles (string overridePath, Dictio } LogDiagnostic ($"FastDeploy2 removing {staleFiles.Count} stale override files."); - diagnosticData.SetProperty ("deploy.fastdeploy2.stale.files", staleFiles.Count); for (int i = 0; i < staleFiles.Count; i += StaleFileRemovalBatchSize) { var args = new List { "rm", "-f" }; args.AddRange (staleFiles.Skip (i).Take (StaleFileRemovalBatchSize)); @@ -735,13 +595,11 @@ protected async Task RemoveStaleOverrideFiles (string overridePath, Dictio return false; } } - SetDiagnosticElapsed ("deploy.fastdeploy2.stale.remove.ms", phase); return true; } - protected async Task CopyChangedFiles (string remoteStagingPath, string overridePath, Dictionary stagedFiles, Dictionary overrideFiles) + async Task CopyChangedFiles (string remoteStagingPath, string overridePath, Dictionary stagedFiles, Dictionary overrideFiles) { - var phase = Stopwatch.StartNew (); var changedFiles = new List (); foreach (var file in stagedFiles) { if (!overrideFiles.TryGetValue (file.Key, out RemoteFileInfo existing) || @@ -750,17 +608,13 @@ protected async Task CopyChangedFiles (string remoteStagingPath, string ov changedFiles.Add (file.Key); } } - SetDiagnosticElapsed ("deploy.fastdeploy2.compare.ms", phase); LogDiagnostic ($"FastDeploy2 copying {changedFiles.Count} changed override files."); - diagnosticData.SetProperty ("deploy.fastdeploy2.changed.files", changedFiles.Count); var filesByDirectory = GroupFilesByDirectory (changedFiles); foreach (var group in filesByDirectory) { string targetDirectory = CombineRemotePath (overridePath, group.Key); - phase.Restart (); string output = await RunAs ("mkdir", "-p", targetDirectory); - AddDiagnosticElapsed ("deploy.fastdeploy2.override.mkdir.ms", phase); if (RaiseRunAsError (output) || IsShellError (output, "mkdir")) { LogFastDeploy2Error ("XA0129", output, targetDirectory); return false; @@ -783,9 +637,7 @@ protected async Task CopyChangedFiles (string remoteStagingPath, string ov args.Add (CombineRemotePath (remoteStagingPath, file)); } args.Add (targetDirectory); - phase.Restart (); output = await RunAs (args.ToArray ()); - AddDiagnosticElapsed ("deploy.fastdeploy2.override.copy.ms", phase); if (RaiseRunAsError (output) || IsShellError (output, "cp")) { LogFastDeploy2Error ("XA0129", output, targetDirectory); return false; @@ -796,7 +648,7 @@ protected async Task CopyChangedFiles (string remoteStagingPath, string ov return true; } - protected IEnumerable> BatchArguments (string command, string option, IEnumerable values) + IEnumerable> BatchArguments (string command, string option, IEnumerable values) { var batch = new List { command, option }; int length = command.Length + option.Length + 2; @@ -815,58 +667,6 @@ protected IEnumerable> BatchArguments (string command, string optio } } - protected async Task UploadFiles (string remoteStagingPath, List files) - { - int pushed = 0; - int skipped = 0; - int batches = 0; - foreach (var group in files.GroupBy (file => GetDirectoryName (file.RelativePath), StringComparer.Ordinal)) { - string remoteDirectory = CombineRemotePath (remoteStagingPath, group.Key); - foreach (var batch in BatchPushFiles (group.ToList (), remoteDirectory)) { - var result = await RunAdbCommand (batch.ToArray ()); - if (result.ExitCode != 0) { - LogFastDeploy2Error ("XA0129", result.Output, remoteDirectory); - return false; - } - var counts = TryParsePushSummary (result.Output); - pushed += counts.pushed; - skipped += counts.skipped; - batches++; - } - } - SetDiagnosticProperty ("deploy.fastdeploy2.adb.pushed.files", pushed); - SetDiagnosticProperty ("deploy.fastdeploy2.adb.skipped.files", skipped); - SetDiagnosticProperty ("deploy.fastdeploy2.bulk.batches", batches); - return true; - } - - IEnumerable> BatchPushFiles (List files, string remoteDirectory) - { - var batch = CreatePushArgsPrefix (); - int prefixCount = batch.Count; - int length = EstimateCommandLength (batch) + remoteDirectory.Length + 4; - foreach (var file in files) { - if (Path.GetFileName (file.LocalPath) != Path.GetFileName (file.RelativePath)) { - yield return CreatePushArgs (file.LocalPath, CombineRemotePath (remoteDirectory, Path.GetFileName (file.RelativePath))); - continue; - } - - int itemLength = file.LocalPath.Length + 3; - if (batch.Count > prefixCount && length + itemLength >= MaxAdbCommandLength) { - batch.Add (remoteDirectory); - yield return batch; - batch = CreatePushArgsPrefix (); - length = EstimateCommandLength (batch) + remoteDirectory.Length + 4; - } - batch.Add (file.LocalPath); - length += itemLength; - } - if (batch.Count > prefixCount) { - batch.Add (remoteDirectory); - yield return batch; - } - } - List CreatePushArgs (string localPath, string remotePath) { var args = CreatePushArgsPrefix (); @@ -882,7 +682,6 @@ List CreatePushArgsPrefix () args.Add ("-z"); args.Add (AdbPushCompressionAlgorithm); } - args.Add ("--sync"); return args; } @@ -895,7 +694,7 @@ int EstimateCommandLength (List args) return length; } - protected (int pushed, int skipped) TryParsePushSummary (string output) + (int pushed, int skipped) TryParsePushSummary (string output) { int pushed = 0; int skipped = 0; @@ -910,12 +709,12 @@ int EstimateCommandLength (List args) return (pushed, skipped); } - protected async Task RunAdbCommand (params string [] arguments) + async Task RunAdbCommand (params string [] arguments) { return await RunAdbCommand (arguments, environmentVariables: null); } - protected async Task RunAdbCommand (string [] arguments, Dictionary environmentVariables) + async Task RunAdbCommand (string [] arguments, Dictionary environmentVariables) { string adb = ResolveAdbPath (); var processArguments = new ProcessArgumentBuilder (); @@ -1009,7 +808,7 @@ List BuildRunAsArgs () return args; } - protected async Task RunAs (params string [] arguments) + async Task RunAs (params string [] arguments) { List args = BuildRunAsArgs (); args.AddRange (arguments); @@ -1018,7 +817,7 @@ protected async Task RunAs (params string [] arguments) return result; } - protected async Task RunAsShell (string script) + async Task RunAsShell (string script) { List args = BuildRunAsArgs (); args.Add ("sh"); @@ -1030,28 +829,28 @@ protected async Task RunAsShell (string script) return result; } - protected static string QuoteShellArgument (string value) + static string QuoteShellArgument (string value) { return "'" + value.Replace ("'", "'\"'\"'") + "'"; } - protected string ResolveAdbPath () + string ResolveAdbPath () { var exe = string.IsNullOrEmpty (AdbToolExe) ? "adb" : AdbToolExe; return string.IsNullOrEmpty (AdbToolPath) ? exe : Path.Combine (AdbToolPath, exe); } - protected virtual string GetRemoteAdbPushStagingPath () + string GetRemoteAdbPushStagingPath () { return $"{RemoteStagingRoot}/{PackageName}/{GetUserId ()}"; } - protected string GetUserId () + string GetUserId () { return string.IsNullOrEmpty (UserID) ? "0" : UserID; } - protected string GetDeviceId () + string GetDeviceId () { if (Device != null && !string.IsNullOrEmpty (Device.ID)) { return Device.ID; @@ -1059,9 +858,13 @@ protected string GetDeviceId () return string.IsNullOrEmpty (AdbTarget) ? "any" : AdbTarget; } - protected void LogFastDeploy2Error (string errorCode, string error, string file = "") + void LogFastDeploy2Error (string errorCode, string error, string file = "") { - LogDiagnosticDataError (errorCode, error, file); + if (!string.IsNullOrEmpty (file)) { + LogDiagnostic ($"{errorCode} while deploying '{file}': {error}"); + } else { + LogDiagnostic ($"{errorCode}: {error}"); + } PrintDiagnostics (); if (errorCode == "XA0129") { LogCodedError (errorCode, Resources.XA0129_ErrorDeployingFile, file); @@ -1070,19 +873,19 @@ protected void LogFastDeploy2Error (string errorCode, string error, string file } } - protected string GetFullPath (string dir) => Path.IsPathRooted (dir) ? dir : Path.GetFullPath (Path.Combine (WorkingDirectory, dir)); + string GetFullPath (string dir) => Path.IsPathRooted (dir) ? dir : Path.GetFullPath (Path.Combine (WorkingDirectory, dir)); - protected static string GetDirectoryName (string file) + static string GetDirectoryName (string file) { return Path.GetDirectoryName (file)?.Replace ("\\", "/") ?? ""; } - protected static string CombineRemotePath (string rootPath, string relativePath) + static string CombineRemotePath (string rootPath, string relativePath) { return string.IsNullOrEmpty (relativePath) ? rootPath : $"{rootPath}/{relativePath}"; } - protected static Dictionary> GroupFilesByDirectory (IEnumerable files) + static Dictionary> GroupFilesByDirectory (IEnumerable files) { var filesByDirectory = new Dictionary> (StringComparer.Ordinal); foreach (string file in files) { @@ -1096,10 +899,10 @@ protected static Dictionary> GroupFilesByDirectory (IEnumer return filesByDirectory; } - protected bool RaiseRunAsError (string error) + bool RaiseRunAsError (string error) { if (TryGetRunAsErrorCode (error, out var err)) { - LogDiagnosticDataError (err.code, err.message); + LogDiagnostic ($"{err.code}: {err.message}"); PrintDiagnostics (); LogCodedError (err.code, err.message, error); return true; @@ -1141,7 +944,7 @@ static string GetErrorCode (string message) return "ADB1000"; } - protected static bool IsShellError (string output, string command) + static bool IsShellError (string output, string command) { if (string.IsNullOrEmpty (output)) { return false; @@ -1153,13 +956,13 @@ protected static bool IsShellError (string output, string command) output.IndexOf ("not found", StringComparison.OrdinalIgnoreCase) >= 0; } - protected static bool IsMissingDirectoryError (string output) + static bool IsMissingDirectoryError (string output) { return !string.IsNullOrEmpty (output) && output.IndexOf ("No such file or directory", StringComparison.OrdinalIgnoreCase) >= 0; } - protected struct AdbCommandResult + struct AdbCommandResult { public int ExitCode; public string Output; diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2JsonSerializerContext.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2JsonSerializerContext.cs index 6f92f7b6d9a..cbb3ba06e6e 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2JsonSerializerContext.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2JsonSerializerContext.cs @@ -5,7 +5,6 @@ namespace Xamarin.Android.Tasks { [JsonSourceGenerationOptions (WriteIndented = true)] [JsonSerializable (typeof (Dictionary))] - [JsonSerializable (typeof (FastDeploy2Base.DiagnosticData))] internal partial class FastDeploy2JsonSerializerContext : JsonSerializerContext { } From d20c46dd1fa03180560df57960b2bddeee5354c3 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 18 Jun 2026 17:21:37 +0200 Subject: [PATCH 09/11] Reduce FastDeploy2 binlog noise Avoid logging empty run-as command output and buffer optional missing-file messages behind FastDeploy2 diagnostics so normal install binlogs stay readable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/FastDeploy2.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs index b83755b77c0..3edc8a0cddb 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs @@ -420,7 +420,7 @@ List PrepareDirectPushFiles () foreach (var file in FastDevFiles ?? []) { string localPath = GetFullPath (file.ItemSpec); if (!File.Exists (localPath)) { - LogDebugMessage ($"File '{file.ItemSpec}' does not exist. Skipping."); + LogDiagnostic ($"File '{file.ItemSpec}' does not exist. Skipping."); continue; } if (Path.GetExtension (file.ItemSpec) == ".so") { @@ -813,7 +813,7 @@ async Task RunAs (params string [] arguments) List args = BuildRunAsArgs (); args.AddRange (arguments); string result = await Device.RunShellCommand (CancellationToken, args.ToArray ()); - LogDebugMessage ($"{arguments [0]} returned: {result}"); + LogCommandOutput (arguments [0], result); return result; } @@ -825,10 +825,18 @@ async Task RunAsShell (string script) args.Add (script); string command = string.Join (" ", args.Select (QuoteShellArgument)); string result = await Device.RunShellCommand (command, CancellationToken); - LogDebugMessage ($"sh returned: {result}"); + LogCommandOutput ("sh", result); return result; } + void LogCommandOutput (string command, string output) + { + if (string.IsNullOrWhiteSpace (output)) { + return; + } + LogDebugMessage ($"{command} returned: {output.Trim ()}"); + } + static string QuoteShellArgument (string value) { return "'" + value.Replace ("'", "'\"'\"'") + "'"; From 03b083fb1505e5aa6eaa88ce76e9f5431742c00b Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 18 Jun 2026 23:21:03 +0200 Subject: [PATCH 10/11] Use Android temp directory for FastDeploy2 staging Stage FastDeploy2 files under /data/local/tmp instead of /tmp so Android emulators with read-only /tmp can install. Also remove existing override contents recursively before full symlink refreshes so resource/culture directories do not fail rm. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/FastDeploy2.Manifest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 f2f6963e6d5..222561d0db0 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs @@ -11,7 +11,7 @@ namespace Xamarin.Android.Tasks { public partial class FastDeploy2 { - const string RemoteStagingRootPath = "/tmp/fastdeploy2"; + const string RemoteStagingRootPath = "/data/local/tmp/fastdeploy2"; const string RemoteReadyMarker = ".fastdeploy2-ready"; const string OverrideSymlinkReadyMarker = ".fastdeploy2-symlinks"; @@ -109,7 +109,7 @@ async Task RunCombinedShellSymlinkUpdate (string remoteStagingPath, string string sourceDirectory = CombineRemotePath (remoteStagingPath, directory); if (currentInDirectory.Count > 0 && (previousManifest == null || newInDirectory.Count == currentInDirectory.Count)) { - string script = $"d={QuoteShellArgument (targetDirectory)};s={QuoteShellArgument (sourceDirectory)};mkdir -p \"$d\"&&cd \"$d\"&&rm -f ./*&&ln -sf \"$s\"/* ."; + string script = $"d={QuoteShellArgument (targetDirectory)};s={QuoteShellArgument (sourceDirectory)};mkdir -p \"$d\"&&cd \"$d\"&&rm -rf ./*&&ln -sf \"$s\"/* ."; string output = await RunAsShell (script); if (RaiseRunAsError (output) || IsShellError (output, "rm") || IsShellError (output, "mkdir") || IsShellError (output, "ln")) { LogDiagnostic ($"Shell symlink glob update failed with '{output}'."); From f0d4842cc36c4e89921d595035d17f7008688f82 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 22 Jun 2026 11:36:43 +0200 Subject: [PATCH 11/11] Track FastDeploy2 device state with manifest hashes Add target identity to FastDeploy2 manifests and store the manifest hash on both the remote staging tree and the app override tree. Read both device-side hashes in one adb shell command before deciding whether incremental deploy state can be trusted, and expand adb diagnostics to include exact commands, exit codes, stdout, and stderr. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/FastDeploy2.Manifest.cs | 193 +++++++++++++----- .../Tasks/FastDeploy2.cs | 132 ++++++++---- .../Tasks/FastDeploy2JsonSerializerContext.cs | 2 +- 3 files changed, 233 insertions(+), 94 deletions(-) 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 222561d0db0..555b82c0f64 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -12,8 +13,7 @@ namespace Xamarin.Android.Tasks public partial class FastDeploy2 { const string RemoteStagingRootPath = "/data/local/tmp/fastdeploy2"; - const string RemoteReadyMarker = ".fastdeploy2-ready"; - const string OverrideSymlinkReadyMarker = ".fastdeploy2-symlinks"; + const string ManifestHashMarker = ".fastdeploy2-manifest-hash"; string RemoteStagingRoot => RemoteStagingRootPath; @@ -28,9 +28,14 @@ async Task DeployFastDevFilesWithAdbPush (string overridePath) } string remoteStagingPath = GetRemoteAdbPushStagingPath (); - bool remoteReady = await IsRemoteReady (remoteStagingPath); - var previousManifest = remoteReady ? LoadPreviousManifest () : null; - if (previousManifest == null) { + var previousManifest = LoadPreviousManifest (); + string previousManifestHash = previousManifest == null ? "" : ComputeManifestHash (previousManifest); + var deviceManifestState = previousManifest == null ? new DeviceManifestState () : await GetDeviceManifestState (remoteStagingPath, overridePath); + bool remoteReady = previousManifest != null && string.Equals (deviceManifestState.RemoteHash, previousManifestHash, StringComparison.Ordinal); + bool overrideSymlinksReady = previousManifest != null && string.Equals (deviceManifestState.OverrideHash, previousManifestHash, StringComparison.Ordinal); + if (!remoteReady) { + previousManifest = null; + overrideSymlinksReady = false; if (!await ResetRemoteStagingDirectory (remoteStagingPath)) { return false; } @@ -56,14 +61,20 @@ async Task DeployFastDevFilesWithAdbPush (string overridePath) bool result; if (UseShellSymlinkAppFileTransfer ()) { - result = await UpdateOverrideShellSymlinks (remoteStagingPath, overridePath, currentManifest, previousManifest, removedFiles); + result = await UpdateOverrideShellSymlinks (remoteStagingPath, overridePath, currentManifest, previousManifest, overrideSymlinksReady, removedFiles); } else { result = await UpdateOverrideCopies (remoteStagingPath, overridePath); } if (result) { + string currentManifestHash = ComputeManifestHash (currentManifest); + if (!await MarkRemoteManifest (remoteStagingPath, currentManifestHash)) { + return false; + } + if (UseShellSymlinkAppFileTransfer () && !await MarkOverrideManifest (overridePath, currentManifestHash)) { + return false; + } WriteManifest (currentManifest); - await MarkRemoteReady (remoteStagingPath); } return result; } @@ -73,29 +84,24 @@ bool UseShellSymlinkAppFileTransfer () return string.Equals (AppFileTransferMode, "Symlink", StringComparison.OrdinalIgnoreCase); } - async Task UpdateOverrideShellSymlinks (string remoteStagingPath, string overridePath, Dictionary currentManifest, Dictionary previousManifest, List removedFiles) + async Task UpdateOverrideShellSymlinks (string remoteStagingPath, string overridePath, ManifestData currentManifest, ManifestData previousManifest, bool overrideSymlinksReady, List removedFiles) { - bool overrideSymlinksReady = await IsOverrideSymlinkReady (overridePath); var previousSymlinkManifest = overrideSymlinksReady ? previousManifest : null; var newFiles = previousSymlinkManifest == null ? - new HashSet (currentManifest.Keys, StringComparer.Ordinal) : - new HashSet (currentManifest.Keys.Where (file => !previousSymlinkManifest.ContainsKey (file)), StringComparer.Ordinal); + new HashSet (currentManifest.Files.Keys, StringComparer.Ordinal) : + new HashSet (currentManifest.Files.Keys.Where (file => !previousSymlinkManifest.Files.ContainsKey (file)), StringComparer.Ordinal); LogDiagnostic ($"FastDeploy2 symlink update new files: {newFiles.Count}; removed files: {removedFiles.Count}."); if (!await RunCombinedShellSymlinkUpdate (remoteStagingPath, overridePath, currentManifest, previousSymlinkManifest, newFiles, removedFiles)) { return await FallbackToCopy (remoteStagingPath, overridePath); } - if (!await MarkOverrideSymlinkReady (overridePath)) { - return await FallbackToCopy (remoteStagingPath, overridePath); - } - return true; } - async Task RunCombinedShellSymlinkUpdate (string remoteStagingPath, string overridePath, Dictionary currentManifest, Dictionary previousManifest, HashSet newFiles, List removedFiles) + async Task RunCombinedShellSymlinkUpdate (string remoteStagingPath, string overridePath, ManifestData currentManifest, ManifestData previousManifest, HashSet newFiles, List removedFiles) { - var currentByDirectory = GroupFilesByDirectory (currentManifest.Keys); + var currentByDirectory = GroupFilesByDirectory (currentManifest.Files.Keys); var newByDirectory = GroupFilesByDirectory (newFiles); var removedByDirectory = GroupFilesByDirectory (removedFiles); var directories = new HashSet (currentByDirectory.Keys, StringComparer.Ordinal); @@ -202,7 +208,7 @@ async Task UpdateOverrideCopies (string remoteStagingPath, string override if (stagedFileData == null) { return false; } - stagedFileData.Remove (RemoteReadyMarker); + stagedFileData.Remove (ManifestHashMarker); var overrideFileData = await GetRemoteFileData (overridePath, runAs: true); if (overrideFileData == null) { @@ -216,12 +222,18 @@ async Task UpdateOverrideCopies (string remoteStagingPath, string override return await CopyChangedFiles (remoteStagingPath, overridePath, stagedFileData, overrideFileData); } - Dictionary CreateManifest (List files) + ManifestData CreateManifest (List files) { - var manifest = new Dictionary (StringComparer.Ordinal); + var manifest = new ManifestData { + DeviceId = GetDeviceId (), + PackageName = PackageName, + UserId = GetUserId (), + PrimaryCpuAbi = PrimaryCpuAbi, + Files = new Dictionary (StringComparer.Ordinal), + }; foreach (var file in files) { var info = new FileInfo (file.LocalPath); - manifest [file.RelativePath] = new ManifestEntry { + manifest.Files [file.RelativePath] = new ManifestEntry { RelativePath = file.RelativePath, LocalPath = file.LocalPath, Size = info.Length, @@ -231,15 +243,15 @@ Dictionary CreateManifest (List files) return manifest; } - HashSet GetChangedFiles (Dictionary currentManifest, Dictionary previousManifest) + HashSet GetChangedFiles (ManifestData currentManifest, ManifestData previousManifest) { if (previousManifest == null) { - return new HashSet (currentManifest.Keys, StringComparer.Ordinal); + return new HashSet (currentManifest.Files.Keys, StringComparer.Ordinal); } var changedFiles = new HashSet (StringComparer.Ordinal); - foreach (var entry in currentManifest) { - if (!previousManifest.TryGetValue (entry.Key, out ManifestEntry previous) || + foreach (var entry in currentManifest.Files) { + if (!previousManifest.Files.TryGetValue (entry.Key, out ManifestEntry previous) || previous.Size != entry.Value.Size || previous.LastWriteTimeUtcTicks != entry.Value.LastWriteTimeUtcTicks) { changedFiles.Add (entry.Key); @@ -248,15 +260,15 @@ HashSet GetChangedFiles (Dictionary currentManife return changedFiles; } - List GetRemovedFiles (Dictionary currentManifest, Dictionary previousManifest) + List GetRemovedFiles (ManifestData currentManifest, ManifestData previousManifest) { var removedFiles = new List (); if (previousManifest == null) { return removedFiles; } - foreach (var entry in previousManifest.Keys) { - if (!currentManifest.ContainsKey (entry)) { + foreach (var entry in previousManifest.Files.Keys) { + if (!currentManifest.Files.ContainsKey (entry)) { removedFiles.Add (entry); } } @@ -329,58 +341,76 @@ IEnumerable> BatchPushFilesWithoutSync (List files, } } - async Task IsRemoteReady (string remoteStagingPath) + async Task GetDeviceManifestState (string remoteStagingPath, string overridePath) { - var result = await RunAdbCommand ("shell", "test", "-f", CombineRemotePath (remoteStagingPath, RemoteReadyMarker)); - return result.ExitCode == 0; + string remoteMarkerPath = CombineRemotePath (remoteStagingPath, ManifestHashMarker); + string overrideMarkerPath = CombineRemotePath (overridePath, ManifestHashMarker); + string runAsCommand = string.Join (" ", BuildRunAsArgs ().Concat (new [] { + "sh", + "-c", + $"cat {QuoteShellArgument (overrideMarkerPath)} 2>/dev/null || true" + }).Select (QuoteShellArgument)); + string script = $"printf 'remote='; cat {QuoteShellArgument (remoteMarkerPath)} 2>/dev/null || true; printf '\\noverride='; {runAsCommand} 2>/dev/null || true; printf '\\n'"; + var result = await RunAdbShellCommand (script); + return ParseDeviceManifestState (result.Output); } - async Task IsOverrideSymlinkReady (string overridePath) + async Task ClearOverrideSymlinkState (string overridePath) { - string output = await RunAsShell ($"test -f {QuoteShellArgument (CombineRemotePath (overridePath, OverrideSymlinkReadyMarker))} && echo yes || true"); - if (RaiseRunAsError (output)) { + string markerPath = CombineRemotePath (overridePath, ManifestHashMarker); + string output = await RunAsShell ($"if test -f {QuoteShellArgument (markerPath)}; then rm -rf {QuoteShellArgument (overridePath)}; else rm -f {QuoteShellArgument (markerPath)}; fi"); + if (RaiseRunAsError (output) || IsShellError (output, "rm")) { + LogFastDeploy2Error ("XA0129", output, overridePath); return false; } - return string.Equals (output?.Trim (), "yes", StringComparison.Ordinal); + return true; } - async Task MarkOverrideSymlinkReady (string overridePath) + async Task ClearOverrideDirectory (string overridePath) { - string output = await RunAsShell ($"mkdir -p {QuoteShellArgument (overridePath)}; touch {QuoteShellArgument (CombineRemotePath (overridePath, OverrideSymlinkReadyMarker))}"); - if (RaiseRunAsError (output) || IsShellError (output, "mkdir") || IsShellError (output, "touch")) { + string output = await RunAs ("rm", "-rf", overridePath); + if (RaiseRunAsError (output) || IsShellError (output, "rm")) { LogFastDeploy2Error ("XA0129", output, overridePath); return false; } return true; } - async Task ClearOverrideSymlinkState (string overridePath) + async Task MarkRemoteManifest (string remoteStagingPath, string manifestHash) { - string markerPath = CombineRemotePath (overridePath, OverrideSymlinkReadyMarker); - string output = await RunAsShell ($"if test -f {QuoteShellArgument (markerPath)}; then rm -rf {QuoteShellArgument (overridePath)}; else rm -f {QuoteShellArgument (markerPath)}; fi"); - if (RaiseRunAsError (output) || IsShellError (output, "rm")) { - LogFastDeploy2Error ("XA0129", output, overridePath); + string markerPath = CombineRemotePath (remoteStagingPath, ManifestHashMarker); + var result = await RunAdbShellCommand ($"printf %s {QuoteShellArgument (manifestHash)} > {QuoteShellArgument (markerPath)}"); + if (result.ExitCode != 0 || IsShellError (result.Output, "printf")) { + LogFastDeploy2Error ("XA0129", result.Output, markerPath); return false; } return true; } - async Task ClearOverrideDirectory (string overridePath) + async Task MarkOverrideManifest (string overridePath, string manifestHash) { - string output = await RunAs ("rm", "-rf", overridePath); - if (RaiseRunAsError (output) || IsShellError (output, "rm")) { + string output = await RunAsShell ($"mkdir -p {QuoteShellArgument (overridePath)}; printf %s {QuoteShellArgument (manifestHash)} > {QuoteShellArgument (CombineRemotePath (overridePath, ManifestHashMarker))}"); + if (RaiseRunAsError (output) || IsShellError (output, "mkdir") || IsShellError (output, "printf")) { LogFastDeploy2Error ("XA0129", output, overridePath); return false; } return true; } - async Task MarkRemoteReady (string remoteStagingPath) + static DeviceManifestState ParseDeviceManifestState (string output) { - await RunAdbCommand ("shell", "touch", CombineRemotePath (remoteStagingPath, RemoteReadyMarker)); + var state = new DeviceManifestState (); + foreach (string line in output.Split (new char [] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) { + if (line.StartsWith ("remote=", StringComparison.Ordinal)) { + state.RemoteHash = line.Substring ("remote=".Length).Trim (); + } else if (line.StartsWith ("override=", StringComparison.Ordinal)) { + state.OverrideHash = line.Substring ("override=".Length).Trim (); + } + } + return state; } - Dictionary LoadPreviousManifest () + ManifestData LoadPreviousManifest () { string manifestFile = GetManifestFilePath (); if (!File.Exists (manifestFile)) { @@ -388,19 +418,54 @@ Dictionary LoadPreviousManifest () } try { - var manifest = JsonSerializer.Deserialize (File.ReadAllText (manifestFile), typeof (Dictionary), FastDeploy2JsonSerializerContext.Default); - return manifest is Dictionary entries ? new Dictionary (entries, StringComparer.Ordinal) : null; + var manifest = JsonSerializer.Deserialize (File.ReadAllText (manifestFile), typeof (ManifestData), FastDeploy2JsonSerializerContext.Default) as ManifestData; + return IsManifestForCurrentTarget (manifest) ? manifest : null; } catch (Exception ex) { LogDiagnostic ($"Ignoring FastDeploy2 manifest '{manifestFile}'. {ex}"); return null; } } - void WriteManifest (Dictionary manifest) + void WriteManifest (ManifestData manifest) { string manifestFile = GetManifestFilePath (); Directory.CreateDirectory (Path.GetDirectoryName (manifestFile)); - File.WriteAllText (manifestFile, JsonSerializer.Serialize (manifest, typeof (Dictionary), FastDeploy2JsonSerializerContext.Default)); + File.WriteAllText (manifestFile, JsonSerializer.Serialize (manifest, typeof (ManifestData), FastDeploy2JsonSerializerContext.Default)); + } + + bool IsManifestForCurrentTarget (ManifestData manifest) + { + return manifest != null && + string.Equals (manifest.DeviceId, GetDeviceId (), StringComparison.Ordinal) && + string.Equals (manifest.PackageName, PackageName, StringComparison.Ordinal) && + string.Equals (manifest.UserId, GetUserId (), StringComparison.Ordinal) && + string.Equals (manifest.PrimaryCpuAbi, PrimaryCpuAbi, StringComparison.Ordinal) && + manifest.Files != null; + } + + static string ComputeManifestHash (ManifestData manifest) + { + using (var hash = SHA256.Create ()) { + byte [] bytes = Encoding.UTF8.GetBytes (GetCanonicalManifestText (manifest)); + return BitConverter.ToString (hash.ComputeHash (bytes)).Replace ("-", "").ToLowerInvariant (); + } + } + + static string GetCanonicalManifestText (ManifestData manifest) + { + var builder = new StringBuilder (); + builder.AppendLine (manifest.DeviceId ?? ""); + builder.AppendLine (manifest.PackageName ?? ""); + builder.AppendLine (manifest.UserId ?? ""); + builder.AppendLine (manifest.PrimaryCpuAbi ?? ""); + foreach (var entry in manifest.Files.OrderBy (entry => entry.Key, StringComparer.Ordinal)) { + builder.Append (entry.Key).Append ('\t') + .Append (entry.Value.LocalPath).Append ('\t') + .Append (entry.Value.RelativePath).Append ('\t') + .Append (entry.Value.Size).Append ('\t') + .AppendLine (entry.Value.LastWriteTimeUtcTicks.ToString ()); + } + return builder.ToString (); } string GetManifestFilePath () @@ -420,6 +485,28 @@ static string GetSafeFileName (string value) return string.IsNullOrEmpty (value) ? "_" : Uri.EscapeDataString (value); } + class DeviceManifestState { + public string RemoteHash { get; set; } = ""; + public string OverrideHash { get; set; } = ""; + } + + internal class ManifestData { + [JsonPropertyName ("deviceId")] + public string DeviceId { get; set; } + + [JsonPropertyName ("packageName")] + public string PackageName { get; set; } + + [JsonPropertyName ("userId")] + public string UserId { get; set; } + + [JsonPropertyName ("primaryCpuAbi")] + public string PrimaryCpuAbi { get; set; } + + [JsonPropertyName ("files")] + public Dictionary Files { get; set; } + } + internal class ManifestEntry { [JsonPropertyName ("relativePath")] public string RelativePath { get; set; } diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs index 3edc8a0cddb..c4159dd8673 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs @@ -173,7 +173,7 @@ public async override Task RunTaskAsync () async Task RunInstall () { - await Device.EnsureProperties (CancellationToken).ConfigureAwait (false); + await RunLoggedDeviceOperation ("EnsureProperties", () => Device.EnsureProperties (CancellationToken)); string redirectStdio = Device.Properties.Get ("log.redirect-stdio"); if (redirectStdio != null && string.Equals ("true", redirectStdio.Trim (), StringComparison.OrdinalIgnoreCase)) { @@ -194,7 +194,11 @@ async Task RunInstall () } if (ReInstall && !string.IsNullOrEmpty (PackageFile)) { - await Device.UninstallPackage (PackageName, PreserveUserData, CancellationToken); + var uninstallCommand = new PmUninstallCommand { + PackageName = PackageName, + PreserveData = PreserveUserData, + }; + await RunLoggedDeviceOperation ($"UninstallPackage {uninstallCommand}", () => Device.UninstallPackage (uninstallCommand, CancellationToken)); } bool packageFileOutOfDate = !string.IsNullOrEmpty (PackageFile) && @@ -254,8 +258,8 @@ async Task CheckAppInstalledAndDebuggable (string packageName) if (packageInfo.InternalPath.IndexOf ("not an application", StringComparison.OrdinalIgnoreCase) >= 0) { LogDiagnostic ($"Package {packageInfo.PackageName} is a system application."); packageInfo.IsSystemApplication = true; - string whoami = await Device.RunShellCommand ("whoami"); - packageInfo.AdbIsRoot = whoami.Trim () == "root"; + var whoami = await RunAdbShellCommand ("whoami"); + packageInfo.AdbIsRoot = whoami.Output.Trim () == "root"; LogDiagnostic ($"using {(packageInfo.AdbIsRoot ? "root" : $"su {packageInfo.UserId}")} to install fast deployment files."); packageInfo.InternalPath = $"/data/user/{(packageInfo.UserId ?? "0")}/{packageInfo.PackageName}"; return; @@ -318,21 +322,23 @@ async Task EnsureUserIsRunning () return; } LogDiagnostic ($"Ensuring Android user {userId} is in the 'running' state before run-as queries."); - string output = await Device.RunShellCommand (CancellationToken, "am", "start-user", "-w", userId); + var result = await RunAdbShellCommand ("am", "start-user", "-w", userId); + string output = result.Output; LogDiagnostic ($"'am start-user -w {userId}' returned: {(string.IsNullOrWhiteSpace (output) ? "" : output.Trim ())}"); } async Task InstallPackage () { LogDebugMessage ($"Installing Package {PackageName}"); + var installCommand = new PushAndInstallCommand { + ApkFile = PackageFile, + PackageName = PackageName, + ReInstall = ReInstall, + User = UserID, + TestOnly = IsTestOnly, + }; try { - await Device.PushAndInstallPackageAsync (new PushAndInstallCommand { - ApkFile = PackageFile, - PackageName = PackageName, - ReInstall = ReInstall, - User = UserID, - TestOnly = IsTestOnly, - }, token: CancellationToken); + await RunLoggedDeviceOperation (FormatPushAndInstallOperation (installCommand), () => Device.PushAndInstallPackageAsync (installCommand, token: CancellationToken)); LogDebugMessage ($"Installed Package {PackageName}."); } catch (Exception exception) { var ex = exception; @@ -360,23 +366,29 @@ async Task ShouldThrowIfPackageInstallFailed (PackageAlreadyExistsExceptio LogDebugMessage (string.Format ("Package '{0}' already exists. Retrying...", PackageName)); try { - await Device.DeleteFile (e.PackageFile, true, CancellationToken); + await RunLoggedDeviceOperation ($"DeleteFile {e.PackageFile} ignoreError=True", () => Device.DeleteFile (e.PackageFile, true, CancellationToken)); } catch { } bool preserveData = !(e is RequiresUninstallException); LogDebugMessage (string.Format ("Forcing complete uninstall of '{0}'... Preserving Data: {1}", PackageName, preserveData)); var uninstallCommand = new PmUninstallCommand () { PackageName = PackageName, User = UserID, PreserveData = preserveData }; - await Device.UninstallPackage (uninstallCommand, cancellationToken: CancellationToken); + await RunLoggedDeviceOperation ($"UninstallPackage {uninstallCommand}", () => Device.UninstallPackage (uninstallCommand, cancellationToken: CancellationToken)); LogDebugMessage (string.Format ("Installing '{0}'...", PackageName)); - await Device.PushAndInstallPackageAsync (new PushAndInstallCommand { + var installCommand = new PushAndInstallCommand { ApkFile = PackageFile, PackageName = PackageName, ReInstall = false, User = UserID - }, token: CancellationToken); + }; + await RunLoggedDeviceOperation (FormatPushAndInstallOperation (installCommand), () => Device.PushAndInstallPackageAsync (installCommand, token: CancellationToken)); return false; } + static string FormatPushAndInstallOperation (PushAndInstallCommand command) + { + return $"PushAndInstallPackage ApkFile={command.ApkFile}, PackageName={command.PackageName}, ReInstall={command.ReInstall}, User={command.User ?? ""}, TestOnly={command.TestOnly}"; + } + async Task RemoveOverrideDirectory () { await RunAs ("rm", "-Rf", OverrideFullPath); @@ -386,14 +398,14 @@ async Task TerminateApp () { var pid = packageInfo.ProcessId; if (pid == 0 && packageInfo.IsSystemApplication) { - pid = await Device.GetProcessId (PackageName, CancellationToken); + pid = await RunLoggedDeviceOperation ($"GetProcessId {PackageName}", () => Device.GetProcessId (PackageName, CancellationToken)); } if (pid == 0) { LogDebugMessage ($"{PackageName} was not running, skipping kill"); return; } LogDebugMessage ($"Terminating {PackageName}..."); - await Device.KillProcessAndWaitForExit (PackageName, CancellationToken); + await RunLoggedDeviceOperation ($"KillProcessAndWaitForExit {PackageName}", () => Device.KillProcessAndWaitForExit (PackageName, CancellationToken)); LogDebugMessage ($"{PackageName} Terminated."); } @@ -409,7 +421,7 @@ async Task CreateRemoteStagingDirectories (string remoteStagingPath, Has var output = new StringBuilder (); foreach (var batch in BatchArguments ("mkdir", "-p", directories)) { - output.Append (await Device.RunShellCommand (CancellationToken, batch.ToArray ())); + output.Append ((await RunAdbShellCommand (batch.ToArray ())).Output); } return output.ToString (); } @@ -535,7 +547,8 @@ async Task> GetRemoteFileData (string rootPat return null; } } else { - output = await Device.RunShellCommand (CancellationToken, "find", rootPath, "-type", "f", "-exec", "stat", "-c", "%n|%s|%Y", "{}", "+"); + var result = await RunAdbShellCommand ("find", rootPath, "-type", "f", "-exec", "stat", "-c", "%n|%s|%Y", "{}", "+"); + output = result.Output; } if (IsMissingDirectoryError (output)) { @@ -714,6 +727,11 @@ async Task RunAdbCommand (params string [] arguments) return await RunAdbCommand (arguments, environmentVariables: null); } + async Task RunAdbShellCommand (params string [] arguments) + { + return await RunAdbCommand (new [] { "shell" }.Concat (arguments).ToArray ()); + } + async Task RunAdbCommand (string [] arguments, Dictionary environmentVariables) { string adb = ResolveAdbPath (); @@ -743,7 +761,7 @@ async Task RunAdbCommand (string [] arguments, Dictionary { @@ -751,7 +769,6 @@ async Task RunAdbCommand (string [] arguments, Dictionary RunAdbCommand (string [] arguments, Dictionary RunAdbCommand (string [] arguments, Dictionary action) + { + LogDiagnostic ($"AndroidDevice operation: {operation}"); + await action (); + LogDiagnostic ($"AndroidDevice operation completed: {operation}"); + } + + async Task RunLoggedDeviceOperation (string operation, Func> action) + { + LogDiagnostic ($"AndroidDevice operation: {operation}"); + T result = await action (); + LogDiagnostic ($"AndroidDevice operation completed: {operation} => {result}"); + return result; } List BuildRunAsArgs () @@ -812,9 +861,8 @@ async Task RunAs (params string [] arguments) { List args = BuildRunAsArgs (); args.AddRange (arguments); - string result = await Device.RunShellCommand (CancellationToken, args.ToArray ()); - LogCommandOutput (arguments [0], result); - return result; + var result = await RunAdbShellCommand (args.ToArray ()); + return result.Output; } async Task RunAsShell (string script) @@ -824,17 +872,8 @@ async Task RunAsShell (string script) args.Add ("-c"); args.Add (script); string command = string.Join (" ", args.Select (QuoteShellArgument)); - string result = await Device.RunShellCommand (command, CancellationToken); - LogCommandOutput ("sh", result); - return result; - } - - void LogCommandOutput (string command, string output) - { - if (string.IsNullOrWhiteSpace (output)) { - return; - } - LogDebugMessage ($"{command} returned: {output.Trim ()}"); + var result = await RunAdbShellCommand (command); + return result.Output; } static string QuoteShellArgument (string value) @@ -973,7 +1012,20 @@ static bool IsMissingDirectoryError (string output) struct AdbCommandResult { public int ExitCode; - public string Output; + public string StandardOutput; + public string StandardError; + + public string Output { + get { + if (string.IsNullOrEmpty (StandardOutput)) { + return StandardError ?? ""; + } + if (string.IsNullOrEmpty (StandardError)) { + return StandardOutput; + } + return $"{StandardOutput}{Environment.NewLine}{StandardError}"; + } + } } static readonly List<(string error, string code, string message)> runas_codes = new List<(string error, string code, string message)> () { diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2JsonSerializerContext.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2JsonSerializerContext.cs index cbb3ba06e6e..933625c5dd5 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2JsonSerializerContext.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2JsonSerializerContext.cs @@ -4,7 +4,7 @@ namespace Xamarin.Android.Tasks { [JsonSourceGenerationOptions (WriteIndented = true)] - [JsonSerializable (typeof (Dictionary))] + [JsonSerializable (typeof (FastDeploy2.ManifestData))] internal partial class FastDeploy2JsonSerializerContext : JsonSerializerContext { }