diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/D8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/D8.cs index c7c15fcf065..797d63e657c 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/D8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/D8.cs @@ -16,6 +16,8 @@ public class D8 : JavaToolTask { public override string TaskPrefix => "DX8"; + string? responseFilePath; + [Required] public string JarPath { get; set; } = ""; @@ -44,6 +46,17 @@ public class D8 : JavaToolTask public string? ExtraArguments { get; set; } + public override bool RunTask () + { + try { + return base.RunTask (); + } finally { + if (!responseFilePath.IsNullOrEmpty () && File.Exists (responseFilePath)) { + File.Delete (responseFilePath); + } + } + } + protected override string GenerateCommandLineCommands () { return GetCommandLineBuilder ().ToString (); @@ -57,32 +70,72 @@ protected virtual CommandLineBuilder GetCommandLineBuilder () { var cmd = new CommandLineBuilder (); - if (JavaOptions is { Length: > 0 }) { + // Only JVM arguments go on the command line, everything else goes in the response file + if (!JavaOptions.IsNullOrEmpty ()) { cmd.AppendSwitch (JavaOptions); } cmd.AppendSwitchIfNotNull ("-Xmx", JavaMaximumHeapSize); cmd.AppendSwitchIfNotNull ("-classpath ", JarPath); cmd.AppendSwitch (MainClass); - if (ExtraArguments is { Length: > 0 }) - cmd.AppendSwitch (ExtraArguments); // it should contain "--dex". + // Create response file with all D8/R8 arguments to avoid command line length limits + responseFilePath = CreateResponseFile (); + cmd.AppendSwitch ($"@{responseFilePath}"); + + return cmd; + } + + /// + /// Creates a response file containing all D8/R8 arguments. + /// This avoids command line length limits that can occur with many jar libraries. + /// + protected virtual string CreateResponseFile () + { + var responseFile = Path.GetTempFileName (); + Log.LogDebugMessage ($"[{MainClass}] response file: {responseFile}"); + + using var response = new StreamWriter (responseFile, append: false, encoding: Files.UTF8withoutBOM); + + // D8/R8 switches + if (!ExtraArguments.IsNullOrEmpty ()) + WriteArg (response, ExtraArguments); // it should contain "--dex". if (Debug) - cmd.AppendSwitch ("--debug"); + WriteArg (response, "--debug"); else - cmd.AppendSwitch ("--release"); + WriteArg (response, "--release"); //NOTE: if this is blank, we can omit --min-api in this call - if (AndroidManifestFile is { Length: > 0 }) { + if (!AndroidManifestFile.IsNullOrEmpty ()) { var doc = AndroidAppManifest.Load (AndroidManifestFile, MonoAndroidHelper.SupportedVersions); if (doc.MinSdkVersion.HasValue) { MinSdkVersion = doc.MinSdkVersion.Value; - cmd.AppendSwitchIfNotNull ("--min-api ", MinSdkVersion.ToString ()); + WriteArg (response, "--min-api"); + WriteArg (response, MinSdkVersion.ToString ()); } } if (!EnableDesugar) - cmd.AppendSwitch ("--no-desugaring"); + WriteArg (response, "--no-desugaring"); + if (!OutputDirectory.IsNullOrEmpty ()) { + WriteArg (response, "--output"); + WriteArg (response, OutputDirectory); + } + + // --map-diagnostics + if (MapDiagnostics != null) { + foreach (var diagnostic in MapDiagnostics) { + var from = diagnostic.ItemSpec; + var to = diagnostic.GetMetadata ("To"); + if (from.IsNullOrEmpty () || to.IsNullOrEmpty ()) + continue; + WriteArg (response, "--map-diagnostics"); + WriteArg (response, from); + WriteArg (response, to); + } + } + + // --lib and input jars var injars = new List (); var libjars = new List (); if (AlternativeJarLibrariesToEmbed?.Length > 0) { @@ -92,7 +145,7 @@ protected virtual CommandLineBuilder GetCommandLineBuilder () } } else if (JavaLibrariesToEmbed != null) { Log.LogDebugMessage (" processing ClassesZip, JavaLibrariesToEmbed..."); - if (ClassesZip is { Length: > 0 } && File.Exists (ClassesZip)) { + if (!ClassesZip.IsNullOrEmpty () && File.Exists (ClassesZip)) { injars.Add (ClassesZip); } foreach (var jar in JavaLibrariesToEmbed) { @@ -106,25 +159,25 @@ protected virtual CommandLineBuilder GetCommandLineBuilder () } } - cmd.AppendSwitchIfNotNull ("--output ", OutputDirectory); - foreach (var jar in libjars) - cmd.AppendSwitchIfNotNull ("--lib ", jar); - foreach (var jar in injars) - cmd.AppendFileNameIfNotNull (jar); - - if (MapDiagnostics != null) { - foreach (var diagnostic in MapDiagnostics) { - var from = diagnostic.ItemSpec; - var to = diagnostic.GetMetadata ("To"); - if (from is not { Length: > 0 } || to is not { Length: > 0 }) - continue; - cmd.AppendSwitch ("--map-diagnostics"); - cmd.AppendSwitch (from); - cmd.AppendSwitch (to); - } + foreach (var jar in libjars) { + WriteArg (response, "--lib"); + WriteArg (response, jar); + } + foreach (var jar in injars) { + WriteArg (response, jar); } - return cmd; + return responseFile; + } + + /// + /// Writes a single argument to the response file. + /// R8/D8 response files treat each line as a complete argument, so no quoting is needed. + /// + protected void WriteArg (StreamWriter writer, string arg) + { + writer.WriteLine (arg); + Log.LogDebugMessage ($" {arg}"); } // Note: We do not want to call the base.LogEventsFromTextOutput as it will incorrectly identify diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs index f2dee391d4b..fbf66e7bf48 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs @@ -51,9 +51,17 @@ public override bool RunTask () } } - protected override CommandLineBuilder GetCommandLineBuilder () + /// + /// Override CreateResponseFile to add R8-specific arguments to the response file. + /// This ensures all arguments are passed via response file to avoid command line length limits. + /// + protected override string CreateResponseFile () { - var cmd = base.GetCommandLineBuilder (); + // First, get the base response file path and write base D8 arguments + var responseFile = base.CreateResponseFile (); + + // Now append R8-specific arguments to the response file + using var response = new StreamWriter (responseFile, append: true, encoding: Files.UTF8withoutBOM); if (EnableMultiDex) { if (MinSdkVersion >= 21) { @@ -77,9 +85,12 @@ protected override CommandLineBuilder GetCommandLineBuilder () } File.WriteAllText (temp, string.Concat (content)); - cmd.AppendSwitchIfNotNull ("--main-dex-list ", temp); - cmd.AppendSwitchIfNotNull ("--main-dex-rules ", Path.Combine (AndroidSdkBuildToolsPath, "mainDexClasses.rules")); - cmd.AppendSwitchIfNotNull ("--main-dex-list-output ", MultiDexMainDexListFile); + WriteArg (response, "--main-dex-list"); + WriteArg (response, temp); + WriteArg (response, "--main-dex-rules"); + WriteArg (response, Path.Combine (AndroidSdkBuildToolsPath, "mainDexClasses.rules")); + WriteArg (response, "--main-dex-list-output"); + WriteArg (response, MultiDexMainDexListFile); } } @@ -112,8 +123,8 @@ protected override CommandLineBuilder GetCommandLineBuilder () } } else { //NOTE: we may be calling r8 *only* for multi-dex, and all shrinking is disabled - cmd.AppendSwitch ("--no-tree-shaking"); - cmd.AppendSwitch ("--no-minification"); + WriteArg (response, "--no-tree-shaking"); + WriteArg (response, "--no-minification"); // Rules to turn off optimizations var temp = Path.GetTempFileName (); var lines = new List { @@ -131,18 +142,21 @@ protected override CommandLineBuilder GetCommandLineBuilder () } File.WriteAllLines (temp, lines); tempFiles.Add (temp); - cmd.AppendSwitchIfNotNull ("--pg-conf ", temp); + WriteArg (response, "--pg-conf"); + WriteArg (response, temp); } if (ProguardConfigurationFiles != null) { foreach (var file in ProguardConfigurationFiles) { - if (File.Exists (file)) - cmd.AppendSwitchIfNotNull ("--pg-conf ", file); - else + if (File.Exists (file)) { + WriteArg (response, "--pg-conf"); + WriteArg (response, file); + } else { Log.LogCodedWarning ("XA4304", file, 0, Properties.Resources.XA4304, file); + } } } - return cmd; + return responseFile; } // Note: We do not want to call the base.LogEventsFromTextOutput as it will incorrectly identify diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/D8Tests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/D8Tests.cs new file mode 100644 index 00000000000..f86c0b4f9d4 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/D8Tests.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using NUnit.Framework; +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Build.Tests +{ + [TestFixture] + public class D8Tests + { + MockBuildEngine engine; + List messages; + string tempDir; + + [SetUp] + public void Setup () + { + engine = new MockBuildEngine (TestContext.Out, + messages: messages = new List ()); + tempDir = Path.Combine (Path.GetTempPath (), "D8Tests_" + Guid.NewGuid ().ToString ("N")); + Directory.CreateDirectory (tempDir); + } + + [TearDown] + public void TearDown () + { + if (Directory.Exists (tempDir)) { + Directory.Delete (tempDir, recursive: true); + } + } + + /// + /// Tests that the D8 task creates a response file with the expected content. + /// This test uses a test subclass to avoid actually running Java. + /// + [Test] + public void ResponseFileContainsLibAndInputJars () + { + // Create mock jar files for testing + var platformJar = Path.Combine (tempDir, "android.jar"); + var inputJar1 = Path.Combine (tempDir, "input1.jar"); + var inputJar2 = Path.Combine (tempDir, "input2.jar"); + var libJar1 = Path.Combine (tempDir, "lib1.jar"); + File.WriteAllText (platformJar, "mock"); + File.WriteAllText (inputJar1, "mock"); + File.WriteAllText (inputJar2, "mock"); + File.WriteAllText (libJar1, "mock"); + + var d8Task = new D8TestTask { + BuildEngine = engine, + JarPath = "d8.jar", + JavaPlatformJarPath = platformJar, + OutputDirectory = tempDir, + JavaLibrariesToEmbed = new ITaskItem [] { + new TaskItem (inputJar1), + new TaskItem (inputJar2), + }, + JavaLibrariesToReference = new ITaskItem [] { + new TaskItem (libJar1), + }, + }; + + string commandLine = d8Task.TestGenerateCommandLineCommands (); + string responseFilePath = d8Task.ResponseFilePath; + + try { + // Verify response file was created + Assert.IsNotNull (responseFilePath, "Response file path should not be null"); + FileAssert.Exists (responseFilePath, "Response file should exist"); + + // Verify the response file is referenced in the command line + Assert.IsTrue (commandLine.Contains ($"@{responseFilePath}"), "Command line should reference the response file"); + + // Read and verify response file content + string [] responseFileContent = File.ReadAllLines (responseFilePath); + + // Should contain --lib entries (written as separate lines: --lib on one line, path on next) + Assert.IsTrue (responseFileContent.Any (line => line == "--lib"), "Response file should contain --lib switch"); + Assert.IsTrue (responseFileContent.Any (line => line.Contains ("android.jar")), "Response file should contain android.jar"); + Assert.IsTrue (responseFileContent.Any (line => line.Contains ("lib1.jar")), "Response file should contain lib1.jar"); + + // Should contain input jars as direct arguments (no --lib prefix) + Assert.IsTrue (responseFileContent.Any (line => line.Contains ("input1.jar")), "Response file should contain input1.jar"); + Assert.IsTrue (responseFileContent.Any (line => line.Contains ("input2.jar")), "Response file should contain input2.jar"); + + } finally { + // Clean up response file + if (responseFilePath != null && File.Exists (responseFilePath)) { + File.Delete (responseFilePath); + } + } + } + + /// + /// Tests that paths with spaces are written correctly to the response file. + /// R8/D8 response files treat each line as a complete argument, so no quoting is needed. + /// + [Test] + public void ResponseFileHandlesPathsWithSpaces () + { + var pathWithSpaces = Path.Combine (tempDir, "path with spaces"); + Directory.CreateDirectory (pathWithSpaces); + + // Create mock jar files for testing + var platformJar = Path.Combine (pathWithSpaces, "android.jar"); + var inputJar = Path.Combine (pathWithSpaces, "input.jar"); + File.WriteAllText (platformJar, "mock"); + File.WriteAllText (inputJar, "mock"); + + var d8Task = new D8TestTask { + BuildEngine = engine, + JarPath = "d8.jar", + JavaPlatformJarPath = platformJar, + OutputDirectory = tempDir, + JavaLibrariesToEmbed = new ITaskItem [] { + new TaskItem (inputJar), + }, + }; + + d8Task.TestGenerateCommandLineCommands (); + string responseFilePath = d8Task.ResponseFilePath; + + try { + FileAssert.Exists (responseFilePath, "Response file should exist"); + string responseFileContent = File.ReadAllText (responseFilePath); + + // Paths with spaces should NOT be quoted (R8/D8 treats each line as a complete argument) + Assert.IsFalse (responseFileContent.Contains ("\""), "Response file should not contain quoted paths"); + Assert.IsTrue (responseFileContent.Contains ("path with spaces"), "Response file should contain the path with spaces"); + + } finally { + if (responseFilePath != null && File.Exists (responseFilePath)) { + File.Delete (responseFilePath); + } + } + } + } + + /// + /// Test subclass of D8 that exposes internal methods for testing without needing Java. + /// + internal class D8TestTask : D8 + { + /// + /// The path to the response file created by the last call to TestGenerateCommandLineCommands. + /// + public string ResponseFilePath { get; private set; } + + /// + /// Test method that generates command line without actually running the task. + /// + public string TestGenerateCommandLineCommands () + { + var cmd = GetCommandLineBuilder (); + // Capture the response file path after command line generation + ResponseFilePath = GetResponseFilePathFromCommandLine (cmd.ToString ()); + return cmd.ToString (); + } + + private static string GetResponseFilePathFromCommandLine (string commandLine) + { + // Find the @filepath argument + var startIndex = commandLine.IndexOf ("@", StringComparison.Ordinal); + if (startIndex < 0) return null; + + // Check if '@' is the last character + if (startIndex + 1 >= commandLine.Length) return null; + + var endIndex = commandLine.IndexOf (" ", startIndex, StringComparison.Ordinal); + if (endIndex < 0) endIndex = commandLine.Length; + + return commandLine.Substring (startIndex + 1, endIndex - startIndex - 1); + } + } +}