From b482b9705676c98c7900a6d80992511e0888e88a Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sat, 27 Jun 2026 23:20:32 +0200 Subject: [PATCH 1/3] [TrimmableTypeMap] Keep user AndroidJavaSource Java under R8 shrinking The trimmable NativeAOT path enables R8 with shrinking (AndroidLinkTool=r8 -> _R8EnableShrinking=True). When the application ProGuard config is generated from the acw-map (the default, UseTrimmableNativeAotProguardConfiguration=false), the R8 task only emits -keep rules for managed-mapped Java types. User-authored AndroidJavaSource (Bind != true) has no managed peer and is therefore absent from the acw-map, so R8 shrank it away. This made BuildAfterMultiDexIsNotRequired fail on NativeAOT: the huge ManyMethods.java classes were removed, so multidex was no longer required and classes2.dex was never produced. Pass the user AndroidJavaSource (.java with Bind != true) to the R8 task and emit '-keep class . { *; }' for each, so user Java survives shrinking. The type name is '.' (Java requires the public top-level type name to match the file name). Verified locally: BuildAfterMultiDexIsNotRequired(NativeAOT) and (CoreCLR) pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Xamarin.Android.Build.Tasks/Tasks/R8.cs | 55 +++++++++++++++++++ .../Xamarin.Android.D8.targets | 4 ++ 2 files changed, 59 insertions(+) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs index afcc44839fb..631a8ff7283 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs @@ -37,6 +37,10 @@ public class R8 : D8 public ITaskItem []? ProguardConfigurationFiles { get; set; } public bool UseTrimmableNativeAotProguardConfiguration { get; set; } + // User-authored AndroidJavaSource (Bind != true) .java files. These have no managed peer and are + // therefore absent from the acw-map, so they must be kept explicitly when shrinking is enabled. + public ITaskItem []? JavaSourceFiles { get; set; } + protected override string MainClass => "com.android.tools.r8.R8"; readonly List tempFiles = new List (); @@ -52,6 +56,52 @@ public override bool RunTask () } } + // Derive the fully-qualified Java type name from each user .java source file. Java requires the + // public top-level type name to match the file name, so '.' is + // the type to keep. Files that no longer exist are skipped. + IEnumerable GetUserJavaTypes () + { + if (JavaSourceFiles == null) { + yield break; + } + var seen = new HashSet (StringComparer.Ordinal); + foreach (var item in JavaSourceFiles) { + var path = item.ItemSpec; + if (path.IsNullOrEmpty () || !File.Exists (path)) { + continue; + } + var typeName = Path.GetFileNameWithoutExtension (path); + var package = ReadJavaPackage (path); + if (!package.IsNullOrEmpty ()) { + typeName = $"{package}.{typeName}"; + } + if (seen.Add (typeName)) { + yield return typeName; + } + } + } + + static string? ReadJavaPackage (string path) + { + foreach (var raw in File.ReadLines (path)) { + var line = raw.Trim (); + if (line.Length == 0 || line.StartsWith ("//", StringComparison.Ordinal) || line.StartsWith ("*", StringComparison.Ordinal) || line.StartsWith ("/*", StringComparison.Ordinal)) { + continue; + } + if (line.StartsWith ("package ", StringComparison.Ordinal)) { + var end = line.IndexOf (';'); + if (end > "package ".Length) { + return line.Substring ("package ".Length, end - "package ".Length).Trim (); + } + } + // The package declaration, if present, must precede any type declaration. + if (line.StartsWith ("import ", StringComparison.Ordinal) || line.Contains ("class ") || line.Contains ("interface ") || line.Contains ("enum ")) { + break; + } + } + return null; + } + /// /// 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. @@ -109,6 +159,11 @@ protected override string CreateResponseFile () foreach (var java in javaTypes) { appcfg.WriteLine ($"-keep class {java} {{ *; }}"); } + // User-authored AndroidJavaSource (Bind != true) has no managed peer and is absent + // from the acw-map, so keep it explicitly; otherwise shrinking removes it. + foreach (var java in GetUserJavaTypes ()) { + appcfg.WriteLine ($"-keep class {java} {{ *; }}"); + } } } if (!ProguardCommonXamarinConfiguration.IsNullOrWhiteSpace ()) { diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets index 40b45798c35..f66903d0449 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets @@ -51,6 +51,9 @@ Copyright (C) 2018 Xamarin. All rights reserved. <_AndroidD8MapDiagnostics Condition=" '$(AndroidD8IgnoreWarnings)' == 'true' " Include="warning" To="info" /> <_AndroidR8MapDiagnostics Condition=" '$(AndroidR8IgnoreWarnings)' == 'true' " Include="warning" To="info" /> + + <_R8KeepJavaSource Include="@(AndroidJavaSource)" Condition=" '%(AndroidJavaSource.Bind)' != 'True' " /> Date: Mon, 29 Jun 2026 09:58:36 +0200 Subject: [PATCH 2/3] [R8] Add ReadJavaPackage unit tests; make it internal Cover package detection: present, trailing space, comment headers, no package, and package-after-import/type (ignored). Exposes ReadJavaPackage as internal for direct testing, matching the TryGetDisallowedOption pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Xamarin.Android.Build.Tasks/Tasks/R8.cs | 2 +- .../Tasks/R8Tests.cs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs index 631a8ff7283..8ff8b0569b5 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs @@ -81,7 +81,7 @@ IEnumerable GetUserJavaTypes () } } - static string? ReadJavaPackage (string path) + internal static string? ReadJavaPackage (string path) { foreach (var raw in File.ReadLines (path)) { var line = raw.Trim (); diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs index 8366adfd341..988650c2d81 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs @@ -1,3 +1,4 @@ +using System.IO; using NUnit.Framework; using Xamarin.Android.Tasks; @@ -30,6 +31,23 @@ public void TryGetDisallowedOption (string line, bool expected, string expectedO Assert.AreEqual (expected, actual); Assert.AreEqual (expectedOption, option); } + + [TestCase ("package com.example.app;\npublic class Foo {}", "com.example.app")] + [TestCase ("package com.example.app ;\npublic class Foo {}", "com.example.app")] // space before ';' + [TestCase ("// header\n/* license */\npackage com.example.app;\nclass Foo {}", "com.example.app")] // skip comments + [TestCase ("public class Foo {}", null)] // no package + [TestCase ("import java.util.List;\npackage com.late;\nclass Foo {}", null)] // package after import is ignored + [TestCase ("class Foo {\npackage com.late;\n}", null)] // package after type is ignored + public void ReadJavaPackage (string content, string expected) + { + var path = Path.GetTempFileName (); + try { + File.WriteAllText (path, content); + Assert.AreEqual (expected, R8.ReadJavaPackage (path)); + } finally { + File.Delete (path); + } + } } } From c4134035112fdae0e99663e98be43671e2419922 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 29 Jun 2026 10:13:15 +0200 Subject: [PATCH 3/3] Address review: nullable test param, clarify keep scope/heuristic * R8Tests: expected parameter is string? (TestCase passes null). * R8: comment that only the public top-level type is kept, and that ReadJavaPackage is a lightweight scan (package precedes types in practice). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Xamarin.Android.Build.Tasks/Tasks/R8.cs | 7 +++++-- .../Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs index 8ff8b0569b5..a525bab3a8c 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs @@ -58,7 +58,8 @@ public override bool RunTask () // Derive the fully-qualified Java type name from each user .java source file. Java requires the // public top-level type name to match the file name, so '.' is - // the type to keep. Files that no longer exist are skipped. + // the type to keep. Files that no longer exist are skipped. Only the public top-level type is kept; + // secondary/non-public types in the same file rely on the public type's '{ *; }' or being unused. IEnumerable GetUserJavaTypes () { if (JavaSourceFiles == null) { @@ -94,7 +95,9 @@ IEnumerable GetUserJavaTypes () return line.Substring ("package ".Length, end - "package ".Length).Trim (); } } - // The package declaration, if present, must precede any type declaration. + // The package declaration, if present, must precede any type declaration. This is a + // lightweight scan (not a full Java parser): the first 'import'/type keyword ends the + // search, and earlier comment lines are skipped, so package always wins in practice. if (line.StartsWith ("import ", StringComparison.Ordinal) || line.Contains ("class ") || line.Contains ("interface ") || line.Contains ("enum ")) { break; } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs index 988650c2d81..d6f5f8b1bec 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs @@ -38,7 +38,7 @@ public void TryGetDisallowedOption (string line, bool expected, string expectedO [TestCase ("public class Foo {}", null)] // no package [TestCase ("import java.util.List;\npackage com.late;\nclass Foo {}", null)] // package after import is ignored [TestCase ("class Foo {\npackage com.late;\n}", null)] // package after type is ignored - public void ReadJavaPackage (string content, string expected) + public void ReadJavaPackage (string content, string? expected) { var path = Path.GetTempFileName (); try {