From 1bb81207127116930bb91b6115edd7e76a187d1a Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 18 Jun 2026 11:31:05 +0200 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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}'.");