diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs index afcc44839fb..a525bab3a8c 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,55 @@ 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. 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) { + 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; + } + } + } + + internal 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. 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; + } + } + 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 +162,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/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs index 8366adfd341..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 @@ -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); + } + } } } 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' " />