From dd7b44236f79e7ffbdf4b1b293ea9a434ad5e0ab Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 16:43:27 +0200 Subject: [PATCH 01/21] Rewrite managed JNI metadata from R8 mappings Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/RewriteJniNamesForR8.cs | 122 ++++ .../Tasks/RewriteJniNamesForR8Tests.cs | 158 +++++ .../JniRemapping/JniAssemblyRewriterTests.cs | 661 ++++++++++++++++++ .../CustomAttributeStringRewriter.cs | 75 ++ .../JniRemapping/JniAssemblyRewriter.cs | 69 ++ .../JniRemapping/JniRewritePlanner.cs | 336 +++++++++ 6 files changed, 1421 insertions(+) create mode 100644 src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniAssemblyRewriter.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs new file mode 100644 index 00000000000..28bbb2ce523 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs @@ -0,0 +1,122 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Xamarin.Android.Tasks.JniRemapping; + +namespace Xamarin.Android.Tasks +{ + /// + /// Rewrites JNI names embedded in compiled assemblies: Android.Runtime.RegisterAttribute, + /// the Java.Interop.Jni*SignatureAttribute family, and generated JniPeerMembers / + /// RegisterNatives ldstr strings. The R8 mapping.txt supplies the obfuscated + /// Java names that managed metadata must reference. + /// + /// Each assembly is fully reconstructed with System.Reflection.Metadata: every metadata table + /// row is cloned in its original order, so every entity token keeps its value, while the + /// heaps, method bodies, managed resources, and mapped field data are re-emitted. Replacements + /// may therefore be of any length. + /// + /// An adjacent PDB is copied unchanged: it stays valid because method tokens, IL offsets, and + /// the PE's CodeView identity (GUID, age, path) are all preserved. + /// + public class RewriteJniNamesForR8 : AndroidTask + { + public override string TaskPrefix => "RJN"; + + [Required] + public ITaskItem [] SourceFiles { get; set; } = []; + + public ITaskItem [] DestinationFiles { get; set; } = []; + + public string? DestinationDirectory { get; set; } + + [Required] + public string MappingFile { get; set; } = ""; + + public string? RewriteManifestFile { get; set; } + + [Output] + public ITaskItem [] RewrittenFiles { get; set; } = []; + + public override bool RunTask () + { + if (DestinationDirectory.IsNullOrEmpty () && SourceFiles.Length != DestinationFiles.Length) { + Log.LogCodedError ("RJN0000", "SourceFiles and DestinationFiles must contain the same number of items."); + return !Log.HasLoggedErrors; + } + + R8Mapping mapping = R8Mapping.Load (MappingFile); + var rewrittenFiles = new ITaskItem [SourceFiles.Length]; + + for (int i = 0; i < SourceFiles.Length; i++) { + string source = SourceFiles [i].ItemSpec; + string destination = DestinationDirectory.IsNullOrEmpty () + ? DestinationFiles [i].ItemSpec + : Path.Combine (DestinationDirectory, Path.GetFileName (source)); + try { + RewriteAssembly (source, destination, mapping); + var rewritten = new TaskItem (SourceFiles [i]) { + ItemSpec = destination, + }; + rewritten.SetMetadata ("OriginalItemSpec", source); + rewrittenFiles [i] = rewritten; + } catch (JniRewriteException e) { + Log.LogCodedError ("RJN0001", $"Could not rewrite the JNI names in '{source}': {e.Message}"); + } + } + + RewrittenFiles = rewrittenFiles; + if (!Log.HasLoggedErrors && !RewriteManifestFile.IsNullOrEmpty ()) { + WriteRewriteManifest (RewriteManifestFile, mapping.AccessedEntries); + } + return !Log.HasLoggedErrors; + } + + static void WriteRewriteManifest (string path, IEnumerable entries) + { + string? directory = Path.GetDirectoryName (path); + if (!directory.IsNullOrEmpty ()) { + Directory.CreateDirectory (directory); + } + Files.CopyIfStringChanged (R8Mapping.CreateManifestContent (entries), path); + } + + void RewriteAssembly (string sourcePath, string destinationPath, R8Mapping mapping) + { + string? destinationDirectory = Path.GetDirectoryName (destinationPath); + if (!destinationDirectory.IsNullOrEmpty ()) { + Directory.CreateDirectory (destinationDirectory); + } + + JniRewriteResult result = JniAssemblyRewriter.Rewrite (File.ReadAllBytes (sourcePath), mapping, Log); + + Log.LogDebugMessage ($"RewriteJniNamesForR8: rewrote {result.ReplacementCount} JNI name(s) in '{Path.GetFileName (sourcePath)}'."); + if (result.StrongNameSignatureCleared) { + Log.LogDebugMessage ($"RewriteJniNamesForR8: '{Path.GetFileName (sourcePath)}' is strong-named; preserved its public-key identity and emitted a delay-signed linker input."); + } + + bool inPlace = String.Equals (Path.GetFullPath (sourcePath), Path.GetFullPath (destinationPath), StringComparison.Ordinal); + if (!inPlace || result.ReplacementCount != 0) { + using var output = new MemoryStream (result.Image, writable: false); + Files.CopyIfStreamChanged (output, destinationPath); + } + if (!inPlace) { + CopyAdjacentPdbUnchanged (sourcePath, destinationPath); + } + } + + static void CopyAdjacentPdbUnchanged (string sourcePath, string destinationPath) + { + string pdbSource = Path.ChangeExtension (sourcePath, "pdb"); + if (File.Exists (pdbSource)) { + string pdbDestination = Path.ChangeExtension (destinationPath, "pdb"); + Files.CopyIfChanged (pdbSource, pdbDestination); + } + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs new file mode 100644 index 00000000000..67c6cbeb2af --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Immutable; +using System.IO; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; +using NUnit.Framework; +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Build.Tests +{ + [TestFixture] + [Parallelizable (ParallelScope.Children)] + public class RewriteJniNamesForR8Tests : BaseTest + { + static byte [] BuildTrivialAssembly () + { + var metadata = new MetadataBuilder (); + var il = new BlobBuilder (); + + metadata.AddModule (0, metadata.GetOrAddString ("Fixture.dll"), metadata.GetOrAddGuid (Guid.NewGuid ()), default, default); + metadata.AddAssembly (metadata.GetOrAddString ("Fixture"), new Version (1, 0, 0, 0), default, default, 0, AssemblyHashAlgorithm.None); + metadata.AddTypeDefinition (default, default, metadata.GetOrAddString (""), default, + MetadataTokens.FieldDefinitionHandle (1), MetadataTokens.MethodDefinitionHandle (1)); + + var peHeaderBuilder = new PEHeaderBuilder (imageCharacteristics: Characteristics.Dll); + var peBuilder = new ManagedPEBuilder (peHeaderBuilder, new MetadataRootBuilder (metadata), il); + var peBlob = new BlobBuilder (); + peBuilder.Serialize (peBlob); + + using var stream = new MemoryStream (); + peBlob.WriteContentTo (stream); + return stream.ToArray (); + } + + [Test] + public void CopiesSourceToDestinationAndAdjacentPdbUnchanged () + { + string path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + + string sourceDll = Path.Combine (path, "source", "Test.dll"); + Directory.CreateDirectory (Path.GetDirectoryName (sourceDll)); + File.WriteAllBytes (sourceDll, BuildTrivialAssembly ()); + string sourcePdb = Path.ChangeExtension (sourceDll, "pdb"); + byte [] pdbContent = { 1, 2, 3, 4, 5 }; + File.WriteAllBytes (sourcePdb, pdbContent); + + string mappingFile = Path.Combine (path, "mapping.txt"); + File.WriteAllText (mappingFile, "acme.orig.Unused -> a.b.C:\n"); + + string destinationDll = Path.Combine (path, "destination", "nested", "Test.dll"); + string destinationPdb = Path.ChangeExtension (destinationDll, "pdb"); + string rewriteManifest = Path.Combine (path, "destination", "rewrite-manifest.txt"); + + var task = new RewriteJniNamesForR8 { + BuildEngine = new MockBuildEngine (TestContext.Out), + SourceFiles = new [] { new Microsoft.Build.Utilities.TaskItem (sourceDll) }, + DestinationFiles = new [] { new Microsoft.Build.Utilities.TaskItem (destinationDll) }, + MappingFile = mappingFile, + RewriteManifestFile = rewriteManifest, + }; + + Assert.IsTrue (task.Execute (), "Task should succeed."); + + FileAssert.Exists (destinationDll); + FileAssert.Exists (destinationPdb); + FileAssert.Exists (rewriteManifest); + Assert.AreEqual ("", File.ReadAllText (rewriteManifest)); + CollectionAssert.AreEqual (File.ReadAllBytes (sourceDll), File.ReadAllBytes (destinationDll), "An assembly with no JNI replacements must remain byte-identical."); + CollectionAssert.AreEqual (pdbContent, File.ReadAllBytes (destinationPdb), "The adjacent PDB must be copied unchanged."); + + using var sourceReader = new PEReader (ImmutableArray.Create (File.ReadAllBytes (sourceDll))); + using var peReader = new PEReader (ImmutableArray.Create (File.ReadAllBytes (destinationDll))); + Assert.IsTrue (peReader.HasMetadata, "The destination must still be a valid managed PE."); + + MetadataReader before = sourceReader.GetMetadataReader (); + MetadataReader after = peReader.GetMetadataReader (); + Assert.AreEqual (before.GetGuid (before.GetModuleDefinition ().Mvid), after.GetGuid (after.GetModuleDefinition ().Mvid)); + Assert.AreEqual ("Fixture", after.GetString (after.GetAssemblyDefinition ().Name)); + } + + [Test] + public void LeavesInPlaceAssemblyWithNoReplacementsUntouched () + { + string path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + + string assembly = Path.Combine (path, "Test.dll"); + byte [] content = BuildTrivialAssembly (); + File.WriteAllBytes (assembly, content); + DateTime originalWriteTime = new DateTime (2020, 1, 2, 3, 4, 5, DateTimeKind.Utc); + File.SetLastWriteTimeUtc (assembly, originalWriteTime); + + string mappingFile = Path.Combine (path, "mapping.txt"); + File.WriteAllText (mappingFile, "acme.orig.Unused -> a.b.C:\n"); + + var task = new RewriteJniNamesForR8 { + BuildEngine = new MockBuildEngine (TestContext.Out), + SourceFiles = new [] { new Microsoft.Build.Utilities.TaskItem (assembly) }, + DestinationFiles = new [] { new Microsoft.Build.Utilities.TaskItem (assembly) }, + MappingFile = mappingFile, + }; + + Assert.IsTrue (task.Execute (), "Task should succeed."); + CollectionAssert.AreEqual (content, File.ReadAllBytes (assembly)); + Assert.AreEqual (originalWriteTime, File.GetLastWriteTimeUtc (assembly), "An in-place no-op must not write the assembly."); + } + + [Test] + public void FailsWithACodedErrorWhenSourceAndDestinationCountsDiffer () + { + string path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + string mappingFile = Path.Combine (path, "mapping.txt"); + File.WriteAllText (mappingFile, ""); + + var task = new RewriteJniNamesForR8 { + BuildEngine = new MockBuildEngine (TestContext.Out), + SourceFiles = new [] { new Microsoft.Build.Utilities.TaskItem ("a.dll"), new Microsoft.Build.Utilities.TaskItem ("b.dll") }, + DestinationFiles = new [] { new Microsoft.Build.Utilities.TaskItem ("a.dll") }, + MappingFile = mappingFile, + }; + + Assert.IsFalse (task.Execute (), "Task should fail when SourceFiles/DestinationFiles counts differ."); + } + + [Test] + public void HandlesMultipleFilesInOneInvocation () + { + string path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + + string source1 = Path.Combine (path, "One.dll"); + string source2 = Path.Combine (path, "Two.dll"); + File.WriteAllBytes (source1, BuildTrivialAssembly ()); + File.WriteAllBytes (source2, BuildTrivialAssembly ()); + + string mappingFile = Path.Combine (path, "mapping.txt"); + File.WriteAllText (mappingFile, ""); + + string destination1 = Path.Combine (path, "out", "One.dll"); + string destination2 = Path.Combine (path, "out", "Two.dll"); + + var task = new RewriteJniNamesForR8 { + BuildEngine = new MockBuildEngine (TestContext.Out), + SourceFiles = new [] { new Microsoft.Build.Utilities.TaskItem (source1), new Microsoft.Build.Utilities.TaskItem (source2) }, + DestinationFiles = new [] { new Microsoft.Build.Utilities.TaskItem (destination1), new Microsoft.Build.Utilities.TaskItem (destination2) }, + MappingFile = mappingFile, + }; + + Assert.IsTrue (task.Execute ()); + FileAssert.Exists (destination1); + FileAssert.Exists (destination2); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs new file mode 100644 index 00000000000..4b47dc2158e --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs @@ -0,0 +1,661 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; +using Microsoft.Build.Utilities; +using NUnit.Framework; +using Xamarin.Android.Tasks.JniRemapping; + +namespace Xamarin.Android.Build.Tests +{ + /// + /// End-to-end tests that build managed PE fixtures with System.Reflection.Metadata, rewrite + /// their JNI-bearing attributes and strings, and verify the reconstructed assembly. + /// + [TestFixture] + [Parallelizable (ParallelScope.Children)] + public class JniAssemblyRewriterTests : BaseTest + { + static JniRewriteResult Rewrite (byte [] sourceImage, R8Mapping mapping) + { + var log = new TaskLoggingHelper (new MockBuildEngine (TestContext.Out), nameof (JniAssemblyRewriterTests)); + return JniAssemblyRewriter.Rewrite (sourceImage, mapping, log); + } + + static R8Mapping Mapping (string text) => R8Mapping.Parse (new StringReader (text)); + + static void AssertReverseScanMatchesRewrite (byte [] rewrittenImage, R8Mapping rewriteMapping, string mappingText) + { + R8Mapping scanMapping = Mapping (mappingText); + var log = new TaskLoggingHelper (new MockBuildEngine (TestContext.Out), nameof (JniAssemblyRewriterTests)); + JniAssemblyRewriter.ScanRewrittenAssembly (rewrittenImage, scanMapping, log); + CollectionAssert.AreEquivalent ( + rewriteMapping.AccessedEntries.ToArray (), + scanMapping.AccessedEntries.ToArray (), + "The reverse post-link scan should recognize every mapping consumed by forward rewriting."); + } + + static IReadOnlyList AttributeStringArgs (MetadataReader reader, CustomAttributeHandleCollection attributes, EntityHandle ctor) + { + foreach (CustomAttributeHandle handle in attributes) { + CustomAttribute attribute = reader.GetCustomAttribute (handle); + if (attribute.Constructor != ctor) { + continue; + } + + var decoded = attribute.DecodeValue (Xamarin.Android.Tasks.DummyCustomAttributeProvider.Instance); + var result = new List (); + foreach (var argument in decoded.FixedArguments) { + result.Add (argument.Value as string); + } + return result; + } + return []; + } + + static string FirstAttributeStringArg (MetadataReader reader, CustomAttributeHandleCollection attributes, EntityHandle ctor) + { + var args = AttributeStringArgs (reader, attributes, ctor); + return args.Count > 0 ? args [0] : null; + } + + static List> LoadedStrings (PEReader peReader, MetadataReader reader, MethodDefinitionHandle method) + { + var result = new List> (); + MethodDefinition definition = reader.GetMethodDefinition (method); + if (definition.RelativeVirtualAddress == 0) { + return result; + } + + byte [] il = peReader.GetMethodBody (definition.RelativeVirtualAddress).GetILBytes (); + int i = 0; + while (i < il.Length) { + if (il [i] == (byte) ILOpCode.Ldstr) { + int token = il [i + 1] | (il [i + 2] << 8) | (il [i + 3] << 16) | (il [i + 4] << 24); + result.Add (new KeyValuePair (i + 1, reader.GetUserString (MetadataTokens.UserStringHandle (token & 0x00FFFFFF)))); + i += 5; + continue; + } + i++; + } + return result; + } + + static void AssertTableRowCountsMatch (MetadataReader expected, MetadataReader actual) + { + for (int i = 0; i < MetadataTokens.TableCount; i++) { + var table = (TableIndex) i; + Assert.AreEqual (expected.GetTableRowCount (table), actual.GetTableRowCount (table), $"Row count of table '{table}' changed."); + } + } + + static List ValuesOf (List> pairs) + { + var values = new List (pairs.Count); + foreach (var pair in pairs) { + values.Add (pair.Value); + } + return values; + } + + static MethodDefinitionHandle FirstMethodOf (MetadataReader reader, TypeDefinitionHandle type) + { + foreach (MethodDefinitionHandle handle in reader.GetTypeDefinition (type).GetMethods ()) { + return handle; + } + return default; + } + + static BlobBuilder IntFieldSignature () + { + var signature = new BlobBuilder (); + new BlobEncoder (signature).FieldSignature ().Int32 (); + return signature; + } + + [Test] + public void RewritesBareMemberAndDescriptorForAReferencedJniClass () + { + var fixture = new JniFixtureBuilder (); + UserStringHandle className = fixture.String ("net/dot/android/ApplicationRegistration"); + UserStringHandle fieldName = fixture.String ("Context"); + UserStringHandle descriptor = fixture.String ("Landroid/content/Context;"); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + MethodDefinitionHandle method = fixture.AddVoidMethod ("GetContext", fixture.EmitLoadStringBody (className, fieldName, descriptor)); + fixture.AddType ("Acme", "ContextAccessor", fieldStart, methodStart); + + JniRewriteResult result = Rewrite (fixture.Serialize (), Mapping ( + "net.dot.android.ApplicationRegistration -> c4:\n" + + " android.content.Context Context -> a\n")); + + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + CollectionAssert.AreEqual (new [] { + "c4", + "a", + "Landroid/content/Context;", + }, LoadedStrings (peReader, reader, method).ConvertAll (entry => entry.Value)); + } + + [Test] + public void RewritesAttributesAndLoadedStrings () + { + var fixture = new JniFixtureBuilder (); + + const string myViewJni = "acme/orig/MyView"; + const string callbackDescriptor = "(Lacme/orig/Callback;)V"; + const string rewrittenCallbackDescriptor = "(La/b/Cb;)V"; + const string registerNativesLine = "onClick:" + callbackDescriptor + ":n_OnClick_Lacme_orig_Callback_Handler"; + + UserStringHandle methodId = fixture.String ("onClick." + callbackDescriptor); + UserStringHandle fieldId = fixture.String ("someField.I"); + UserStringHandle exactClassName = fixture.String ("acme/orig/Marker"); + UserStringHandle singleLine = fixture.String (registerNativesLine); + UserStringHandle multiline = fixture.String (registerNativesLine + "\nunused:()V:n_Unused"); + UserStringHandle trailingNewline = fixture.String (registerNativesLine + "\n"); + UserStringHandle unrelated = fixture.String ("this is an ordinary string, untouched"); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + + var someField = fixture.Metadata.AddFieldDefinition (FieldAttributes.Public, + fixture.Metadata.GetOrAddString ("SomeField"), fixture.Metadata.GetOrAddBlob (IntFieldSignature ())); + fixture.Metadata.AddCustomAttribute (someField, fixture.RegisterCtor1, fixture.AttributeBlob ("someField")); + + int onClickBody = fixture.EmitLoadStringBody (methodId, fieldId, exactClassName, singleLine, multiline, trailingNewline, unrelated); + MethodDefinitionHandle onClick = fixture.AddVoidMethod ("OnClick", onClickBody); + fixture.Metadata.AddCustomAttribute (onClick, fixture.RegisterCtor3, + fixture.AttributeBlob ("onClick", callbackDescriptor, "n_OnClick_Lacme_orig_Callback_Handler")); + fixture.Metadata.AddCustomAttribute (onClick, fixture.JniMethodSignatureCtor2, + fixture.AttributeBlob ("onClick", callbackDescriptor)); + + MethodDefinitionHandle ctor = fixture.AddVoidMethod (".ctor", fixture.EmitReturnOnlyBody (), + MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName); + fixture.Metadata.AddCustomAttribute (ctor, fixture.JniConstructorSignatureCtor1, fixture.AttributeBlob (callbackDescriptor)); + + TypeDefinitionHandle myView = fixture.AddType ("Acme.Orig", "MyView", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (myView, fixture.RegisterCtor1, fixture.AttributeBlob (myViewJni)); + + fieldStart = fixture.NextFieldRid; + methodStart = fixture.NextMethodRid; + TypeDefinitionHandle marker = fixture.AddType ("Acme.Orig", "Marker", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (marker, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Marker")); + + UserStringHandle nestedRun = fixture.String ("run:()V:n_Run"); + fieldStart = fixture.NextFieldRid; + methodStart = fixture.NextMethodRid; + fixture.AddVoidMethod ("Run", fixture.EmitLoadStringBody (nestedRun)); + TypeDefinitionHandle nested = fixture.AddType (null, "Nested", fieldStart, methodStart, + TypeAttributes.NestedPublic | TypeAttributes.Class | TypeAttributes.BeforeFieldInit); + fixture.Metadata.AddNestedType (nested, myView); + + const string mappingText = + "acme.orig.MyView -> a.b.C:\n" + + " void onClick(acme.orig.Callback) -> a\n" + + " int someField -> x\n" + + " void run() -> b\n" + + " void (acme.orig.Callback) -> \n" + + "acme.orig.Callback -> a.b.Cb:\n" + + "acme.orig.Marker -> a.b.D:\n"; + R8Mapping mapping = Mapping (mappingText); + JniRewriteResult result = Rewrite (fixture.Serialize (), mapping); + AssertReverseScanMatchesRewrite (result.Image, mapping, mappingText); + + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + + Assert.AreEqual ("a/b/C", FirstAttributeStringArg (reader, reader.GetTypeDefinition (myView).GetCustomAttributes (), fixture.RegisterCtor1)); + Assert.AreEqual ("a/b/D", FirstAttributeStringArg (reader, reader.GetTypeDefinition (marker).GetCustomAttributes (), fixture.JniTypeSignatureCtor1)); + Assert.AreEqual ("x", FirstAttributeStringArg (reader, reader.GetFieldDefinition (someField).GetCustomAttributes (), fixture.RegisterCtor1)); + + CustomAttributeHandleCollection onClickAttributes = reader.GetMethodDefinition (onClick).GetCustomAttributes (); + CollectionAssert.AreEqual (new [] { "a", rewrittenCallbackDescriptor, "n_OnClick_Lacme_orig_Callback_Handler" }, + AttributeStringArgs (reader, onClickAttributes, fixture.RegisterCtor3)); + CollectionAssert.AreEqual (new [] { "a", rewrittenCallbackDescriptor }, + AttributeStringArgs (reader, onClickAttributes, fixture.JniMethodSignatureCtor2)); + CollectionAssert.AreEqual (new [] { rewrittenCallbackDescriptor }, + AttributeStringArgs (reader, reader.GetMethodDefinition (ctor).GetCustomAttributes (), fixture.JniConstructorSignatureCtor1)); + + CollectionAssert.AreEqual (new [] { + "a." + rewrittenCallbackDescriptor, + "x.I", + "a/b/D", + "a:" + rewrittenCallbackDescriptor + ":n_OnClick_Lacme_orig_Callback_Handler", + "a:" + rewrittenCallbackDescriptor + ":n_OnClick_Lacme_orig_Callback_Handler\nunused:()V:n_Unused", + "a:" + rewrittenCallbackDescriptor + ":n_OnClick_Lacme_orig_Callback_Handler\n", + "this is an ordinary string, untouched", + }, ValuesOf (LoadedStrings (peReader, reader, onClick))); + + MethodDefinitionHandle run = FirstMethodOf (reader, nested); + CollectionAssert.AreEqual (new [] { "b:()V:n_Run" }, ValuesOf (LoadedStrings (peReader, reader, run))); + } + + [Test] + public void SharedLoadedStringGetsOwnerSpecificReplacements () + { + var fixture = new JniFixtureBuilder (); + UserStringHandle shared = fixture.String ("go.()V"); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + fixture.AddVoidMethod ("Go", fixture.EmitLoadStringBody (shared)); + TypeDefinitionHandle first = fixture.AddType ("Acme.Orig", "Dup1", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (first, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Dup1")); + + fieldStart = fixture.NextFieldRid; + methodStart = fixture.NextMethodRid; + fixture.AddVoidMethod ("Go", fixture.EmitLoadStringBody (shared)); + TypeDefinitionHandle second = fixture.AddType ("Acme.Orig", "Dup2", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (second, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Dup2")); + + JniRewriteResult result = Rewrite (fixture.Serialize (), Mapping ( + "acme.orig.Dup1 -> a.b.F1:\n" + + " void go() -> z\n" + + "acme.orig.Dup2 -> a.b.F2:\n" + + " void go() -> q\n")); + + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + + CollectionAssert.AreEqual (new [] { "z.()V" }, ValuesOf (LoadedStrings (peReader, reader, FirstMethodOf (reader, first)))); + CollectionAssert.AreEqual (new [] { "q.()V" }, ValuesOf (LoadedStrings (peReader, reader, FirstMethodOf (reader, second)))); + } + + [Test] + public void AppliesReplacementsLongerThanTheOriginal () + { + var fixture = new JniFixtureBuilder (); + + UserStringHandle memberId = fixture.String ("go.()V"); + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + MethodDefinitionHandle go = fixture.AddVoidMethod ("Go", fixture.EmitLoadStringBody (memberId)); + fixture.Metadata.AddCustomAttribute (go, fixture.RegisterCtor3, fixture.AttributeBlob ("go", "()V", "n_Go")); + TypeDefinitionHandle small = fixture.AddType ("Acme.Orig", "Small", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (small, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Small")); + + const string longName = "aVeryLongReplacementMethodNameThatCouldNeverFitInPlace"; + JniRewriteResult result = Rewrite (fixture.Serialize (), Mapping ( + "acme.orig.Small -> com.example.a.VeryLongObfuscatedClassName:\n" + + " void go() -> " + longName + "\n")); + + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + + Assert.AreEqual ("com/example/a/VeryLongObfuscatedClassName", + FirstAttributeStringArg (reader, reader.GetTypeDefinition (small).GetCustomAttributes (), fixture.JniTypeSignatureCtor1)); + CollectionAssert.AreEqual (new [] { longName, "()V", "n_Go" }, + AttributeStringArgs (reader, reader.GetMethodDefinition (go).GetCustomAttributes (), fixture.RegisterCtor3)); + CollectionAssert.AreEqual (new [] { longName + ".()V" }, ValuesOf (LoadedStrings (peReader, reader, go))); + } + + [Test] + public void PreservesResourcesExceptionRegionsAndComplexIL () + { + var fixture = new JniFixtureBuilder (); + + byte [] resource1 = new byte [] { 1, 2, 3, 4, 5, 6, 7 }; + var resource2 = new byte [300]; + for (int i = 0; i < resource2.Length; i++) { + resource2 [i] = (byte) (i * 7); + } + fixture.AddEmbeddedResource ("First.resources", resource1); + fixture.AddEmbeddedResource ("Second.resources", resource2); + + var localSignature = new BlobBuilder (); + var localEncoder = new BlobEncoder (localSignature).LocalVariableSignature (2); + localEncoder.AddVariable ().Type ().Int32 (); + localEncoder.AddVariable ().Type ().Object (); + StandaloneSignatureHandle locals = fixture.Metadata.AddStandaloneSignature (fixture.Metadata.GetOrAddBlob (localSignature)); + + UserStringHandle jniString = fixture.String ("go.()V"); + var controlFlow = new ControlFlowBuilder (); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + int bodyOffset = fixture.EmitBody (encoder => { + LabelHandle tryStart = encoder.DefineLabel (); + LabelHandle catchStart = encoder.DefineLabel (); + LabelHandle catchEnd = encoder.DefineLabel (); + LabelHandle protectedStart = encoder.DefineLabel (); + LabelHandle finallyStart = encoder.DefineLabel (); + LabelHandle finallyEnd = encoder.DefineLabel (); + LabelHandle afterCatch = encoder.DefineLabel (); + LabelHandle afterFinally = encoder.DefineLabel (); + LabelHandle case0 = encoder.DefineLabel (); + LabelHandle case1 = encoder.DefineLabel (); + LabelHandle case2 = encoder.DefineLabel (); + LabelHandle done = encoder.DefineLabel (); + + encoder.MarkLabel (tryStart); + encoder.LoadString (jniString); + encoder.OpCode (ILOpCode.Pop); + encoder.Branch (ILOpCode.Leave, afterCatch); + + encoder.MarkLabel (catchStart); + encoder.StoreLocal (1); + encoder.Branch (ILOpCode.Leave, afterCatch); + encoder.MarkLabel (catchEnd); + + encoder.MarkLabel (afterCatch); + encoder.MarkLabel (protectedStart); + encoder.OpCode (ILOpCode.Nop); + encoder.Branch (ILOpCode.Leave, afterFinally); + + encoder.MarkLabel (finallyStart); + encoder.OpCode (ILOpCode.Endfinally); + encoder.MarkLabel (finallyEnd); + + encoder.MarkLabel (afterFinally); + encoder.LoadConstantI4 (1); + encoder.StoreLocal (0); + encoder.LoadLocal (0); + SwitchInstructionEncoder switchEncoder = encoder.Switch (3); + switchEncoder.Branch (case0); + switchEncoder.Branch (case1); + switchEncoder.Branch (case2); + + encoder.MarkLabel (case0); + encoder.LoadConstantI4 (0); + encoder.LoadConstantI4 (1); + encoder.OpCode (ILOpCode.Ceq); + encoder.OpCode (ILOpCode.Pop); + encoder.Branch (ILOpCode.Br_s, done); + + encoder.MarkLabel (case1); + encoder.OpCode (ILOpCode.Sizeof); + encoder.Token (fixture.ExceptionReference); + encoder.OpCode (ILOpCode.Pop); + encoder.Branch (ILOpCode.Br, done); + + encoder.MarkLabel (case2); + encoder.OpCode (ILOpCode.Nop); + + encoder.MarkLabel (done); + encoder.OpCode (ILOpCode.Ret); + + controlFlow.AddCatchRegion (tryStart, catchStart, catchStart, catchEnd, fixture.ExceptionReference); + controlFlow.AddFinallyRegion (protectedStart, finallyStart, finallyStart, finallyEnd); + }, locals, controlFlow); + + MethodDefinitionHandle method = fixture.AddVoidMethod ("Go", bodyOffset); + TypeDefinitionHandle type = fixture.AddType ("Acme.Orig", "Complex", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (type, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Complex")); + + byte [] source = fixture.Serialize (); + JniRewriteResult result = Rewrite (source, Mapping ( + "acme.orig.Complex -> a.b.X:\n" + + " void go() -> z\n")); + + using var sourcePe = new PEReader (ImmutableArray.Create (source)); + using var rewrittenPe = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader before = sourcePe.GetMetadataReader (); + MetadataReader after = rewrittenPe.GetMetadataReader (); + + MethodBodyBlock sourceBody = sourcePe.GetMethodBody (before.GetMethodDefinition (method).RelativeVirtualAddress); + MethodBodyBlock rewrittenBody = rewrittenPe.GetMethodBody (after.GetMethodDefinition (method).RelativeVirtualAddress); + + Assert.AreEqual (sourceBody.GetILBytes ().Length, rewrittenBody.GetILBytes ().Length, "IL length must not change."); + Assert.AreEqual (sourceBody.MaxStack, rewrittenBody.MaxStack); + Assert.AreEqual (sourceBody.LocalVariablesInitialized, rewrittenBody.LocalVariablesInitialized); + Assert.AreEqual (sourceBody.LocalSignature, rewrittenBody.LocalSignature); + Assert.AreEqual (sourceBody.ExceptionRegions.Length, rewrittenBody.ExceptionRegions.Length); + for (int i = 0; i < sourceBody.ExceptionRegions.Length; i++) { + ExceptionRegion expected = sourceBody.ExceptionRegions [i]; + ExceptionRegion actual = rewrittenBody.ExceptionRegions [i]; + Assert.AreEqual (expected.Kind, actual.Kind); + Assert.AreEqual (expected.TryOffset, actual.TryOffset); + Assert.AreEqual (expected.TryLength, actual.TryLength); + Assert.AreEqual (expected.HandlerOffset, actual.HandlerOffset); + Assert.AreEqual (expected.HandlerLength, actual.HandlerLength); + Assert.AreEqual (expected.CatchType, actual.CatchType); + } + + byte [] sourceIL = sourceBody.GetILBytes (); + byte [] rewrittenIL = rewrittenBody.GetILBytes (); + var stringOperands = new HashSet (); + foreach (var pair in LoadedStrings (sourcePe, before, method)) { + for (int i = 0; i < 4; i++) { + stringOperands.Add (pair.Key + i); + } + } + for (int i = 0; i < sourceIL.Length; i++) { + if (!stringOperands.Contains (i)) { + Assert.AreEqual (sourceIL [i], rewrittenIL [i], $"IL byte {i} changed."); + } + } + + CollectionAssert.AreEqual (new [] { "z.()V" }, ValuesOf (LoadedStrings (rewrittenPe, after, method))); + CollectionAssert.AreEqual (resource1, ReadResource (rewrittenPe, after, "First.resources")); + CollectionAssert.AreEqual (resource2, ReadResource (rewrittenPe, after, "Second.resources")); + AssertTableRowCountsMatch (before, after); + } + + static byte [] ReadResource (PEReader peReader, MetadataReader reader, string name) + { + foreach (ManifestResourceHandle handle in reader.ManifestResources) { + ManifestResource resource = reader.GetManifestResource (handle); + if (reader.GetString (resource.Name) != name) { + continue; + } + + DirectoryEntry directory = peReader.PEHeaders.CorHeader.ResourcesDirectory; + PEMemoryBlock block = peReader.GetSectionData (directory.RelativeVirtualAddress); + int offset = (int) resource.Offset; + int size = block.GetReader (offset, sizeof (int)).ReadInt32 (); + return block.GetReader (offset + sizeof (int), size).ReadBytes (size); + } + + Assert.Fail ($"Resource '{name}' is missing."); + return null; + } + + [Test] + public void PreservesTokensIdentityAndDebugDirectory () + { + var fixture = new JniFixtureBuilder (); + + var pdbId = new BlobContentId (new Guid ("2A3B4C5D-6E7F-4011-9223-334455667788"), 0xAABBCCDD); + var debugDirectory = new DebugDirectoryBuilder (); + debugDirectory.AddCodeViewEntry ("/some/where/Fixture.pdb", pdbId, portablePdbVersion: 0x0100); + debugDirectory.AddReproducibleEntry (); + fixture.DebugDirectory = debugDirectory; + + UserStringHandle jniString = fixture.String ("go.()V"); + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + MethodDefinitionHandle go = fixture.AddVoidMethod ("Go", fixture.EmitLoadStringBody (jniString)); + TypeDefinitionHandle type = fixture.AddType ("Acme.Orig", "Identity", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (type, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Identity")); + + byte [] source = fixture.Serialize (); + JniRewriteResult result = Rewrite (source, Mapping ( + "acme.orig.Identity -> a.b.I:\n" + + " void go() -> z\n")); + + using var sourcePe = new PEReader (ImmutableArray.Create (source)); + using var rewrittenPe = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader before = sourcePe.GetMetadataReader (); + MetadataReader after = rewrittenPe.GetMetadataReader (); + + Assert.AreEqual (before.GetGuid (before.GetModuleDefinition ().Mvid), after.GetGuid (after.GetModuleDefinition ().Mvid)); + Assert.AreEqual (before.MetadataVersion, after.MetadataVersion); + Assert.AreEqual (sourcePe.PEHeaders.CoffHeader.TimeDateStamp, rewrittenPe.PEHeaders.CoffHeader.TimeDateStamp); + Assert.AreEqual (sourcePe.PEHeaders.CorHeader.Flags, rewrittenPe.PEHeaders.CorHeader.Flags); + + var sourceEntries = sourcePe.ReadDebugDirectory (); + var rewrittenEntries = rewrittenPe.ReadDebugDirectory (); + Assert.AreEqual (sourceEntries.Length, rewrittenEntries.Length); + for (int i = 0; i < sourceEntries.Length; i++) { + Assert.AreEqual (sourceEntries [i].Type, rewrittenEntries [i].Type); + Assert.AreEqual (sourceEntries [i].Stamp, rewrittenEntries [i].Stamp); + Assert.AreEqual (sourceEntries [i].MajorVersion, rewrittenEntries [i].MajorVersion); + Assert.AreEqual (sourceEntries [i].MinorVersion, rewrittenEntries [i].MinorVersion); + } + + CodeViewDebugDirectoryData codeView = rewrittenPe.ReadCodeViewDebugDirectoryData (rewrittenEntries [0]); + Assert.AreEqual (pdbId.Guid, codeView.Guid); + Assert.AreEqual ("/some/where/Fixture.pdb", codeView.Path); + Assert.AreEqual (1, codeView.Age); + + AssertTableRowCountsMatch (before, after); + + var sourceStrings = LoadedStrings (sourcePe, before, go); + var rewrittenStrings = LoadedStrings (rewrittenPe, after, go); + Assert.AreEqual (sourceStrings [0].Key, rewrittenStrings [0].Key, "The ldstr operand moved."); + Assert.AreEqual ("z.()V", rewrittenStrings [0].Value); + } + + [Test] + public void ClearsTheStrongNameSignedFlagButReservesTheSignatureDirectory () + { + const int OriginalStrongNameSignatureSize = 128; + + var fixture = new JniFixtureBuilder (hasPublicKey: true) { + Flags = CorFlags.ILOnly | CorFlags.StrongNameSigned, + StrongNameSignatureSize = OriginalStrongNameSignatureSize, + }; + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + fixture.AddVoidMethod ("Go", fixture.EmitReturnOnlyBody ()); + TypeDefinitionHandle type = fixture.AddType ("Acme.Orig", "Signed", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (type, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Signed")); + + byte [] source = fixture.Serialize (); + JniRewriteResult result = Rewrite (source, Mapping ("acme.orig.Signed -> a.b.S:\n")); + Assert.IsTrue (result.StrongNameSignatureCleared); + + using var rewrittenPe = new PEReader (ImmutableArray.Create (result.Image)); + CorHeader corHeader = rewrittenPe.PEHeaders.CorHeader; + Assert.AreEqual (CorFlags.ILOnly, corHeader.Flags); + + using var sourcePe = new PEReader (ImmutableArray.Create (source)); + MetadataReader sourceMetadata = sourcePe.GetMetadataReader (); + MetadataReader rewrittenMetadata = rewrittenPe.GetMetadataReader (); + AssemblyDefinition sourceAssembly = sourceMetadata.GetAssemblyDefinition (); + AssemblyDefinition rewrittenAssembly = rewrittenMetadata.GetAssemblyDefinition (); + Assert.AreEqual (sourceAssembly.Flags, rewrittenAssembly.Flags); + CollectionAssert.AreEqual ( + sourceMetadata.GetBlobBytes (sourceAssembly.PublicKey), + rewrittenMetadata.GetBlobBytes (rewrittenAssembly.PublicKey)); + + Assert.AreEqual (OriginalStrongNameSignatureSize, corHeader.StrongNameSignatureDirectory.Size); + Assert.AreNotEqual (0, corHeader.StrongNameSignatureDirectory.RelativeVirtualAddress); + } + + [Test] + public void RewrittenAssemblyStillMatchesItsPortablePdb () + { + var fixture = new JniFixtureBuilder (); + + UserStringHandle jniString = fixture.String ("go.()V"); + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + MethodDefinitionHandle go = fixture.AddVoidMethod ("Go", fixture.EmitLoadStringBody (jniString)); + TypeDefinitionHandle type = fixture.AddType ("Acme.Orig", "Debuggable", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (type, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Debuggable")); + + string directory = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (directory); + string assemblyPath = Path.Combine (directory, "Fixture.dll"); + string pdbPath = Path.Combine (directory, "Fixture.pdb"); + + var pdbMetadata = new MetadataBuilder (); + DocumentHandle document = pdbMetadata.AddDocument ( + pdbMetadata.GetOrAddDocumentName ("/src/Fixture.cs"), default, default, default); + var sequencePoints = new BlobBuilder (); + sequencePoints.WriteCompressedInteger (0); + sequencePoints.WriteCompressedInteger (0); + sequencePoints.WriteCompressedInteger (1); + sequencePoints.WriteCompressedInteger (10); + sequencePoints.WriteCompressedInteger (5); + sequencePoints.WriteCompressedInteger (1); + pdbMetadata.SetCapacity (TableIndex.MethodDebugInformation, MetadataTokens.GetRowNumber (go)); + for (int rid = 1; rid < MetadataTokens.GetRowNumber (go); rid++) { + pdbMetadata.AddMethodDebugInformation (default, default); + } + pdbMetadata.AddMethodDebugInformation (document, pdbMetadata.GetOrAddBlob (sequencePoints)); + + var pdbBuilder = new PortablePdbBuilder (pdbMetadata, fixture.Metadata.GetRowCounts (), default); + var pdbBlob = new BlobBuilder (); + BlobContentId pdbId = pdbBuilder.Serialize (pdbBlob); + + var debugDirectory = new DebugDirectoryBuilder (); + debugDirectory.AddCodeViewEntry (pdbPath, pdbId, pdbBuilder.FormatVersion); + fixture.DebugDirectory = debugDirectory; + + using (var pdbStream = File.Create (pdbPath)) { + pdbBlob.WriteContentTo (pdbStream); + } + + JniRewriteResult result = Rewrite (fixture.Serialize (), Mapping ( + "acme.orig.Debuggable -> a.b.D:\n" + + " void go() -> z\n")); + File.WriteAllBytes (assemblyPath, result.Image); + + using var peReader = new PEReader (File.OpenRead (assemblyPath)); + Assert.IsTrue (peReader.TryOpenAssociatedPortablePdb (assemblyPath, File.OpenRead, out MetadataReaderProvider provider, out string _)); + + using (provider) { + MetadataReader pdbReader = provider.GetMetadataReader (); + MetadataReader reader = peReader.GetMetadataReader (); + var points = new List (pdbReader.GetMethodDebugInformation (go).GetSequencePoints ()); + Assert.AreEqual (1, points.Count); + Assert.AreEqual (0, points [0].Offset); + + byte [] il = peReader.GetMethodBody (reader.GetMethodDefinition (go).RelativeVirtualAddress).GetILBytes (); + Assert.AreEqual ((byte) ILOpCode.Ldstr, il [0]); + CollectionAssert.AreEqual (new [] { "z.()V" }, ValuesOf (LoadedStrings (peReader, reader, go))); + } + } + + [Test] + public void RewrittenAssemblyLoadsAndRunsInTheRuntime () + { + var fixture = new JniFixtureBuilder (); + var signature = new BlobBuilder (); + new BlobEncoder (signature).MethodSignature () + .Parameters (0, out ReturnTypeEncoder returnType, out ParametersEncoder _); + returnType.Type ().Int32 (); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + fixture.Metadata.AddMethodDefinition ( + MethodAttributes.Public | MethodAttributes.Static | MethodAttributes.HideBySig, + MethodImplAttributes.IL, + fixture.Metadata.GetOrAddString ("Answer"), + fixture.Metadata.GetOrAddBlob (signature), + fixture.EmitBody (encoder => { + encoder.LoadConstantI4 (42); + encoder.OpCode (ILOpCode.Ret); + }), + MetadataTokens.ParameterHandle (fixture.Metadata.GetRowCount (TableIndex.Param) + 1)); + TypeReferenceHandle objectType = fixture.Metadata.AddTypeReference ( + fixture.CoreLibraryReference, fixture.Metadata.GetOrAddString ("System"), fixture.Metadata.GetOrAddString ("Object")); + TypeDefinitionHandle type = fixture.AddType ("Acme.Orig", "Loadable", fieldStart, methodStart, baseType: objectType); + fixture.Metadata.AddCustomAttribute (type, fixture.JniTypeSignatureCtor1, fixture.AttributeBlob ("acme/orig/Loadable")); + + JniRewriteResult result = Rewrite (fixture.Serialize (), Mapping ("acme.orig.Loadable -> a.b.L:\n")); + Assert.Greater (result.ReplacementCount, 0); + + var context = new System.Runtime.Loader.AssemblyLoadContext (TestName, isCollectible: true); + try { + using var stream = new MemoryStream (result.Image, writable: false); + Assembly assembly = context.LoadFromStream (stream); + Type loadedType = assembly.GetType ("Acme.Orig.Loadable", throwOnError: true); + MethodInfo answer = loadedType.GetMethod ("Answer", BindingFlags.Public | BindingFlags.Static); + Assert.AreEqual (42, answer.Invoke (null, null)); + } finally { + context.Unload (); + } + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs new file mode 100644 index 00000000000..ab3ff529cbd --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs @@ -0,0 +1,75 @@ +#nullable enable + +using System; +using System.IO; +using System.Text; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + /// + /// Rewrites specific fixed string arguments within a CustomAttribute value blob + /// (ECMA-335 II.23.3), leaving the prolog, any other fixed arguments, and every named + /// argument byte-for-byte untouched. + /// + /// This only supports (and only needs to support) the attributes this task rewrites: + /// Android.Runtime.RegisterAttribute and the Java.Interop.Jni*SignatureAttribute family. + /// + static class CustomAttributeStringRewriter + { + /// + /// Rewrites the leading fixed string arguments of a CustomAttribute value blob. + /// + /// is invoked with the (0-based) argument index and its original value, and should return + /// the replacement value, or null if that argument should be left unchanged. + /// Bytes following those leading strings are copied verbatim, allowing a string prefix to + /// be changed even when later fixed arguments have other SerString-encoded types. + /// + /// Returns null if no argument was rewritten (i.e. the blob does not need to change). + /// + public static byte []? TryRewrite (byte [] originalContent, int fixedArgCount, Func rewriteArg) + { + if (originalContent.Length < 2) { + throw new JniRewriteException ("Malformed custom attribute value blob: missing 2-byte prolog."); + } + + using var ms = new MemoryStream (originalContent.Length); + ms.Write (originalContent, 0, 2); + int pos = 2; + bool changed = false; + + for (int i = 0; i < fixedArgCount; i++) { + if (pos >= originalContent.Length) { + throw new JniRewriteException ("Malformed custom attribute value blob: ran out of bytes while reading fixed arguments."); + } + + int argStart = pos; + string? value; + if (originalContent [pos] == 0xFF) { + value = null; + pos += 1; + } else { + int prefixWidth = MetadataEncoding.ReadCompressedInteger (originalContent, pos, out int strByteLength); + pos += prefixWidth + strByteLength; + if (pos > originalContent.Length) { + throw new JniRewriteException ("Malformed custom attribute value blob: fixed string argument extends past the end of the blob."); + } + value = Encoding.UTF8.GetString (originalContent, argStart + prefixWidth, strByteLength); + } + + string? newValue = value != null ? rewriteArg (i, value) : null; + if (newValue != null && !string.Equals (newValue, value, StringComparison.Ordinal)) { + changed = true; + byte [] utf8 = Encoding.UTF8.GetBytes (newValue); + byte [] prefix = MetadataEncoding.EncodeCompressedInteger (utf8.Length); + ms.Write (prefix, 0, prefix.Length); + ms.Write (utf8, 0, utf8.Length); + } else { + ms.Write (originalContent, argStart, pos - argStart); + } + } + + ms.Write (originalContent, pos, originalContent.Length - pos); + return changed ? ms.ToArray () : null; + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniAssemblyRewriter.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniAssemblyRewriter.cs new file mode 100644 index 00000000000..0253b5aed5e --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniAssemblyRewriter.cs @@ -0,0 +1,69 @@ +#nullable enable + +using System.Collections.Immutable; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using Microsoft.Build.Utilities; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + sealed class JniRewriteResult + { + public byte [] Image { get; } + public int ReplacementCount { get; } + public bool StrongNameSignatureCleared { get; } + + public JniRewriteResult (byte [] image, int replacementCount, bool strongNameSignatureCleared) + { + Image = image; + ReplacementCount = replacementCount; + StrongNameSignatureCleared = strongNameSignatureCleared; + } + } + + /// + /// Rewrites JNI names embedded in Android.Runtime.RegisterAttribute, + /// the Java.Interop.Jni*SignatureAttribute family, and generated + /// JniPeerMembers/RegisterNatives ldstr strings according to an R8 mapping. + /// + /// The rewrite runs in two passes. The first scans the source into an exact plan; the second + /// reconstructs the whole assembly with MetadataBuilder, cloning every table row in its + /// original order (so entity tokens keep their values) while rebuilding the heaps. That lifts + /// the length restrictions of an in-place heap patch and lets two use sites that shared one + /// deduplicated heap entry receive different values. + /// + static class JniAssemblyRewriter + { + public static JniRewriteResult Rewrite (byte [] sourceImage, R8Mapping mapping, TaskLoggingHelper log) + { + using var peReader = new PEReader (ImmutableArray.Create (sourceImage)); + if (!peReader.HasMetadata) { + throw new JniRewriteException ("The file contains no managed metadata."); + } + + MetadataReader reader = peReader.GetMetadataReader (); + JniRewritePlan plan = new JniRewritePlanner (peReader, reader, mapping, log).CreatePlan (); + if (plan.ReplacementCount == 0) { + return new JniRewriteResult (sourceImage, 0, strongNameSignatureCleared: false); + } + + FieldRvaTable fieldRvaTable = FieldRvaTable.Read (peReader, reader); + AssemblyRebuildResult rebuilt = new AssemblyRebuilder (peReader, reader, plan, fieldRvaTable).Build (); + return new JniRewriteResult (rebuilt.Image, plan.ReplacementCount, rebuilt.StrongNameSignatureCleared); + } + + public static void ScanRewrittenAssembly (byte [] sourceImage, R8Mapping mapping, TaskLoggingHelper log) + { + using var peReader = new PEReader (ImmutableArray.Create (sourceImage)); + if (!peReader.HasMetadata) { + throw new JniRewriteException ("The file contains no managed metadata."); + } + + MetadataReader reader = peReader.GetMetadataReader (); + ScanRewrittenAssembly (peReader, reader, mapping, log); + } + + public static void ScanRewrittenAssembly (PEReader peReader, MetadataReader reader, R8Mapping mapping, TaskLoggingHelper log) + => new JniRewritePlanner (peReader, reader, mapping.CreateReverseMapping (), log).CreatePlan (); + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs new file mode 100644 index 00000000000..d6ea8f13f0e --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs @@ -0,0 +1,336 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; +using Microsoft.Build.Utilities; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + /// + /// Pass one of the rewrite: scans an assembly and produces the exact set of JNI-bearing + /// managed metadata values that must change, without mutating anything. The rebuilder then + /// reproduces the assembly and applies the plan. + /// + sealed class JniRewritePlanner + { + const string RegisterAttributeFullName = "Android.Runtime.RegisterAttribute"; + const string JniTypeSignatureAttributeFullName = "Java.Interop.JniTypeSignatureAttribute"; + const string JniMethodSignatureAttributeFullName = "Java.Interop.JniMethodSignatureAttribute"; + const string JniConstructorSignatureAttributeFullName = "Java.Interop.JniConstructorSignatureAttribute"; + + readonly PEReader peReader; + readonly MetadataReader reader; + readonly IJniNameMapping mapping; + readonly TaskLoggingHelper log; + readonly Func renameClass; + readonly Dictionary ownerJniNameCache = new (); + + public JniRewritePlanner (PEReader peReader, MetadataReader reader, IJniNameMapping mapping, TaskLoggingHelper log) + { + this.peReader = peReader; + this.reader = reader; + this.mapping = mapping; + this.log = log; + renameClass = className => mapping.TryMapClass (className, out string renamed) ? renamed : null; + } + + public JniRewritePlan CreatePlan () + { + var plan = new JniRewritePlan (); + foreach (TypeDefinitionHandle typeHandle in reader.TypeDefinitions) { + PlanType (plan, typeHandle); + } + return plan; + } + + void PlanType (JniRewritePlan plan, TypeDefinitionHandle typeHandle) + { + TypeDefinition typeDef = reader.GetTypeDefinition (typeHandle); + string? ownerJniName = ResolveOwnerJniName (typeHandle); + + PlanTypeLevelAttributes (plan, typeDef, ownerJniName); + + foreach (MethodDefinitionHandle methodHandle in typeDef.GetMethods ()) { + PlanMethodAttributes (plan, methodHandle, ownerJniName); + PlanMethodBody (plan, methodHandle, ownerJniName); + } + + foreach (FieldDefinitionHandle fieldHandle in typeDef.GetFields ()) { + PlanMemberNameAttributes (plan, reader.GetFieldDefinition (fieldHandle).GetCustomAttributes (), ownerJniName); + } + + foreach (PropertyDefinitionHandle propertyHandle in typeDef.GetProperties ()) { + PlanMemberNameAttributes (plan, reader.GetPropertyDefinition (propertyHandle).GetCustomAttributes (), ownerJniName); + } + + foreach (EventDefinitionHandle eventHandle in typeDef.GetEvents ()) { + PlanMemberNameAttributes (plan, reader.GetEventDefinition (eventHandle).GetCustomAttributes (), ownerJniName); + } + } + + /// + /// Resolves the JNI class name that owns a type from its own Register/JniTypeSignature + /// argument or, recursively, its enclosing type. + /// + string? ResolveOwnerJniName (TypeDefinitionHandle typeHandle) + { + if (ownerJniNameCache.TryGetValue (typeHandle, out string? cached)) { + return cached; + } + + ownerJniNameCache [typeHandle] = null; + + TypeDefinition typeDef = reader.GetTypeDefinition (typeHandle); + string? result = TryGetTypeLevelJniName (typeDef); + if (result == null) { + TypeDefinitionHandle declaring = typeDef.GetDeclaringType (); + if (!declaring.IsNil) { + result = ResolveOwnerJniName (declaring); + } + } + + ownerJniNameCache [typeHandle] = result; + return result; + } + + string? TryGetTypeLevelJniName (TypeDefinition typeDef) + { + foreach (CustomAttributeHandle caHandle in typeDef.GetCustomAttributes ()) { + CustomAttribute ca = reader.GetCustomAttribute (caHandle); + string? fullName = reader.GetCustomAttributeFullName (ca, log); + if (fullName != RegisterAttributeFullName && fullName != JniTypeSignatureAttributeFullName) { + continue; + } + + var args = ca.GetCustomAttributeArguments ().FixedArguments; + if (args.Length >= 1 && args [0].Value is string s && s.Length > 0) { + return s; + } + } + return null; + } + + void PlanTypeLevelAttributes (JniRewritePlan plan, TypeDefinition typeDef, string? ownerJniName) + { + if (ownerJniName == null || !mapping.TryMapClass (ownerJniName, out string renamedClass)) { + return; + } + + foreach (CustomAttributeHandle caHandle in typeDef.GetCustomAttributes ()) { + CustomAttribute ca = reader.GetCustomAttribute (caHandle); + string? fullName = reader.GetCustomAttributeFullName (ca, log); + if (fullName != RegisterAttributeFullName && fullName != JniTypeSignatureAttributeFullName) { + continue; + } + + var args = ca.GetCustomAttributeArguments ().FixedArguments; + if (args.Length == 0 || args [0].Value is not string current || current != ownerJniName) { + continue; + } + + PlanCustomAttributeRewrite (plan, caHandle, ca, args.Length, (i, _) => i == 0 ? renamedClass : null); + } + } + + void PlanMethodAttributes (JniRewritePlan plan, MethodDefinitionHandle methodHandle, string? ownerJniName) + { + MethodDefinition method = reader.GetMethodDefinition (methodHandle); + + foreach (CustomAttributeHandle caHandle in method.GetCustomAttributes ()) { + CustomAttribute ca = reader.GetCustomAttribute (caHandle); + string? fullName = reader.GetCustomAttributeFullName (ca, log); + + switch (fullName) { + case RegisterAttributeFullName: + case JniMethodSignatureAttributeFullName: + PlanNameAndDescriptorAttribute (plan, caHandle, ca, ownerJniName, nameIndex: 0, descriptorIndex: 1); + break; + case JniConstructorSignatureAttributeFullName: + PlanNameAndDescriptorAttribute (plan, caHandle, ca, ownerJniName, nameIndex: -1, descriptorIndex: 0); + break; + } + } + } + + void PlanNameAndDescriptorAttribute (JniRewritePlan plan, CustomAttributeHandle caHandle, CustomAttribute ca, string? ownerJniName, int nameIndex, int descriptorIndex) + { + var args = ca.GetCustomAttributeArguments ().FixedArguments; + if (args.Length <= descriptorIndex) { + return; + } + + string jniMemberName = nameIndex < 0 + ? ".ctor" + : args [nameIndex].Value as string ?? ""; + if (jniMemberName.Length == 0) { + return; + } + + string? jniDescriptor = args [descriptorIndex].Value as string; + string? newName = TryFindRenamedMethodName (ownerJniName, jniMemberName, jniDescriptor); + string? newDescriptor = jniDescriptor != null && JniDescriptorText.TryRewriteDescriptor (jniDescriptor, renameClass, out string rewrittenDescriptor) + ? rewrittenDescriptor + : null; + + if (newName == null && newDescriptor == null) { + return; + } + + PlanCustomAttributeRewrite (plan, caHandle, ca, args.Length, (i, _) => { + if (i == nameIndex) { + return newName; + } + if (i == descriptorIndex) { + return newDescriptor; + } + return null; + }); + } + + void PlanMemberNameAttributes (JniRewritePlan plan, CustomAttributeHandleCollection attributes, string? ownerJniName) + { + if (ownerJniName == null) { + return; + } + + foreach (CustomAttributeHandle caHandle in attributes) { + CustomAttribute ca = reader.GetCustomAttribute (caHandle); + if (reader.GetCustomAttributeFullName (ca, log) != RegisterAttributeFullName) { + continue; + } + + var args = ca.GetCustomAttributeArguments ().FixedArguments; + if (args.Length == 0 || args [0].Value is not string jniFieldName) { + continue; + } + + if (!mapping.TryMapField (ownerJniName, jniFieldName, out string renamedField)) { + continue; + } + + PlanCustomAttributeRewrite (plan, caHandle, ca, args.Length, (i, _) => i == 0 ? renamedField : null); + } + } + + string? TryFindRenamedMethodName (string? ownerJniName, string jniMemberName, string? jniDescriptor) + { + if (ownerJniName == null) { + return null; + } + + string mappingName = R8Mapping.JniMemberNameToMappingName (jniMemberName); + if (jniDescriptor != null && JniDescriptorText.IsValidMethodDescriptor (jniDescriptor)) { + JniDescriptorText.MethodDescriptorToJavaTypes (jniDescriptor, out var javaParams, out string javaReturnType); + return mapping.TryMapMethod (ownerJniName, mappingName, javaParams, javaReturnType, out string renamed) ? renamed : null; + } + + return mapping.TryMapMethodByNameOnly (ownerJniName, mappingName, out string renamedByNameOnly) + ? renamedByNameOnly + : null; + } + + void PlanCustomAttributeRewrite (JniRewritePlan plan, CustomAttributeHandle caHandle, CustomAttribute ca, int fixedArgCount, Func rewriteArg) + { + BlobReader blobReader = reader.GetBlobReader (ca.Value); + byte [] originalContent = blobReader.ReadBytes (blobReader.Length); + byte []? newContent = CustomAttributeStringRewriter.TryRewrite (originalContent, fixedArgCount, rewriteArg); + if (newContent != null) { + plan.AddCustomAttributeBlob (caHandle, newContent); + } + } + + void PlanMethodBody (JniRewritePlan plan, MethodDefinitionHandle methodHandle, string? ownerJniName) + { + MethodDefinition method = reader.GetMethodDefinition (methodHandle); + if (method.RelativeVirtualAddress == 0) { + return; + } + + byte [] il = GetILBytes (method); + string? referencedOwnerJniName = FindSingleReferencedJniClass (il); + string? pendingMemberName = null; + int pendingMemberNameOffset = 0; + + IlInstructionScanner.Walk (il, (code, _, operandOffset, _) => { + if (code == (ushort) ILOpCode.Ldstr) { + string value = ReadUserString (il, operandOffset); + if (referencedOwnerJniName != null && pendingMemberName != null) { + if (JniDescriptorText.IsValidFieldDescriptor (value) && + mapping.TryMapField (referencedOwnerJniName, pendingMemberName, out string renamedField)) { + plan.AddUserString (methodHandle, pendingMemberNameOffset, renamedField); + } else if (JniDescriptorText.IsValidMethodDescriptor (value)) { + JniDescriptorText.MethodDescriptorToJavaTypes (value, out var javaParams, out string javaReturnType); + string mappingName = R8Mapping.JniMemberNameToMappingName (pendingMemberName); + if (mapping.TryMapMethod (referencedOwnerJniName, mappingName, javaParams, javaReturnType, out string renamedMethod)) { + plan.AddUserString (methodHandle, pendingMemberNameOffset, renamedMethod); + } + } + } + if (LdstrRewriter.TryRewrite (value, ownerJniName, mapping, out string rewritten)) { + plan.AddUserString (methodHandle, operandOffset, rewritten); + } + pendingMemberName = IsBareMemberName (value) ? value : null; + pendingMemberNameOffset = operandOffset; + return; + } + + if (code != (ushort) ILOpCode.Pop) { + pendingMemberName = null; + } + }); + } + + string? FindSingleReferencedJniClass (byte [] il) + { + string? referencedClass = null; + bool ambiguous = false; + IlInstructionScanner.Walk (il, (code, _, operandOffset, _) => { + if (ambiguous || code != (ushort) ILOpCode.Ldstr) { + return; + } + + string value = ReadUserString (il, operandOffset); + if (!mapping.TryMapClass (value, out _)) { + return; + } + if (referencedClass != null && referencedClass != value) { + ambiguous = true; + return; + } + referencedClass = value; + }); + return ambiguous ? null : referencedClass; + } + + static bool IsBareMemberName (string value) + { + if (value.Length == 0) { + return false; + } + foreach (char c in value) { + if (!(char.IsLetterOrDigit (c) || c == '_' || c == '$' || c == '<' || c == '>')) { + return false; + } + } + return true; + } + + byte [] GetILBytes (MethodDefinition method) + { + MethodBodyBlock body = peReader.GetMethodBody (method.RelativeVirtualAddress); + return body.GetILBytes () ?? []; + } + + string ReadUserString (byte [] il, int operandOffset) + { + uint token = IlInstructionScanner.ReadUInt32 (il, operandOffset); + if ((token & 0xFF000000) != 0x70000000) { + throw new JniRewriteException ($"Malformed IL: ldstr operand 0x{token:X8} is not a #US token."); + } + return reader.GetUserString (MetadataTokens.UserStringHandle ((int) (token & 0x00FFFFFF))); + } + } +} From 1f881ea9ac8fe7816b4df82985172797635e2a1e Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 17:08:55 +0200 Subject: [PATCH 02/21] Use documented XA4325 error code for JNI name rewriting failures Replace the ad-hoc RJN0000/RJN0001 error codes and their hard-coded English strings with a single documented, localizable XA4325 product code. The task prefix stays "RJN" so unexpected exceptions keep reporting as XARJN7xxx, consistent with other task-specific prefixes; only the two explicit errors change. The user-visible detail is resource-backed as well: XA4325 is a general wrapper and the two specific failures live in XA4325_SourceDestinationCount and XA4325_AssemblyFailure, so no new English fragments are formatted into the message from code. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Documentation/docs-mobile/TOC.yml | 2 + Documentation/docs-mobile/messages/index.md | 1 + Documentation/docs-mobile/messages/xa4325.md | 56 +++++++++++++++++++ .../Properties/Resources.Designer.cs | 27 +++++++++ .../Properties/Resources.resx | 13 +++++ .../Tasks/RewriteJniNamesForR8.cs | 5 +- .../Tasks/RewriteJniNamesForR8Tests.cs | 8 ++- 7 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 Documentation/docs-mobile/messages/xa4325.md diff --git a/Documentation/docs-mobile/TOC.yml b/Documentation/docs-mobile/TOC.yml index 1244c066b05..d7e4a54bc60 100644 --- a/Documentation/docs-mobile/TOC.yml +++ b/Documentation/docs-mobile/TOC.yml @@ -364,6 +364,8 @@ href: messages/xa4323.md - name: XA4324 href: messages/xa4324.md + - name: XA4325 + href: messages/xa4325.md - name: "XA5xxx: GCC and toolchain" items: - name: "XA5xxx: GCC and toolchain" diff --git a/Documentation/docs-mobile/messages/index.md b/Documentation/docs-mobile/messages/index.md index dabb47571de..e92d6d10f44 100644 --- a/Documentation/docs-mobile/messages/index.md +++ b/Documentation/docs-mobile/messages/index.md @@ -254,6 +254,7 @@ Either change the value in the AndroidManifest.xml to match the $(SupportedOSPla + [XA4322](xa4322.md): Skipping library ProGuard configuration file '{file}' (from {source}) because it contains the unsupported global option '{option}'. Global ProGuard options are only allowed in application projects. + [XA4323](xa4323.md): Ignoring directory '{directory}' as it does not exist. + [XA4324](xa4324.md): [{arch}] Unable to delete source file '{file}'. ++ [XA4325](xa4325.md): Failed to rewrite managed JNI names for R8. {message} ## XA5xxx: GCC and toolchain diff --git a/Documentation/docs-mobile/messages/xa4325.md b/Documentation/docs-mobile/messages/xa4325.md new file mode 100644 index 00000000000..d1a125a4138 --- /dev/null +++ b/Documentation/docs-mobile/messages/xa4325.md @@ -0,0 +1,56 @@ +--- +title: .NET for Android error XA4325 +description: XA4325 error code +ms.date: 09/01/2026 +f1_keywords: + - "XA4325" +--- + +# .NET for Android error XA4325 + +## Example messages + +``` +error XA4325: Failed to rewrite managed JNI names for R8. The 'SourceFiles' and 'DestinationFiles' item groups must contain the same number of items. +``` + +``` +error XA4325: Failed to rewrite managed JNI names for R8. Could not rewrite the JNI names in the assembly 'obj/Release/net11.0-android/android/Acme.App.dll': The file contains no managed metadata. +``` + +## Issue + +When R8 obfuscates Java type and member names, the JNI names embedded in your +managed assemblies must be updated to match the obfuscated names. This error +means that step failed, so the app would not have been able to find its Java +types at run time. + +There are two causes: + +* **Mismatched item groups.** The `SourceFiles` and `DestinationFiles` item + groups passed to the `RewriteJniNamesForR8` task did not contain the same + number of items. This only happens if a custom target invokes the task + directly, or if a target that produces these item groups has been overridden. + +* **An assembly could not be rewritten.** A specific assembly could not be read + or reconstructed. The message names the assembly and includes the underlying + reason, such as the file not containing managed metadata or containing + malformed IL. + +## Solution + +For the mismatched item groups case, review any custom targets that call +`RewriteJniNamesForR8` and make sure `SourceFiles` and `DestinationFiles` are +populated in matching order, or set `DestinationDirectory` instead of +`DestinationFiles`. + +For the assembly failure case, first confirm the named file is a managed +assembly and is not corrupt. Deleting the `bin/` and `obj/` directories and +rebuilding clears a partially written or stale file. + +If the named file is a valid managed assembly and the failure persists, this is +unexpected. Please [report an issue][report-issue] and include the full error +message, the name of the assembly, and, if possible, a project that reproduces +the failure. + +[report-issue]: https://github.com/dotnet/android/issues/new/choose diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs index db2a9c4243c..70681887045 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs @@ -1931,6 +1931,33 @@ public static string XA4324 { } } + /// + /// Looks up a localized string similar to Failed to rewrite managed JNI names for R8. {0}. + /// + public static string XA4325 { + get { + return ResourceManager.GetString("XA4325", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not rewrite the JNI names in the assembly '{0}': {1}. + /// + public static string XA4325_AssemblyFailure { + get { + return ResourceManager.GetString("XA4325_AssemblyFailure", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'SourceFiles' and 'DestinationFiles' item groups must contain the same number of items.. + /// + public static string XA4325_SourceDestinationCount { + get { + return ResourceManager.GetString("XA4325_SourceDestinationCount", resourceCulture); + } + } + /// /// Looks up a localized string similar to Missing Android NDK toolchains directory '{0}'. Please install the Android NDK.. /// diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx index aef2dbc527a..8076c5ca11c 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx @@ -884,6 +884,19 @@ Remove the '{0}' reference from your project and add the '{1}' NuGet package ins {0} - The target architecture, such as Arm, Arm64, or X86_64 {1} - The path to the source file which could not be deleted. + + Failed to rewrite managed JNI names for R8. {0} + {0} - A sentence describing the specific failure. It is supplied by one of the XA4325_* resources. + + + Could not rewrite the JNI names in the assembly '{0}': {1} + {0} - The path of the assembly which could not be rewritten. +{1} - The underlying message describing why the assembly could not be rewritten. It is not localized. + + + The 'SourceFiles' and 'DestinationFiles' item groups must contain the same number of items. + The following are literal MSBuild item group names and should not be translated: 'SourceFiles', 'DestinationFiles'. + Missing Android NDK toolchains directory '{0}'. Please install the Android NDK. {0} - The path of the missing directory diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs index 28bbb2ce523..421137764b3 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs @@ -46,7 +46,7 @@ public class RewriteJniNamesForR8 : AndroidTask public override bool RunTask () { if (DestinationDirectory.IsNullOrEmpty () && SourceFiles.Length != DestinationFiles.Length) { - Log.LogCodedError ("RJN0000", "SourceFiles and DestinationFiles must contain the same number of items."); + Log.LogCodedError ("XA4325", Properties.Resources.XA4325, Properties.Resources.XA4325_SourceDestinationCount); return !Log.HasLoggedErrors; } @@ -66,7 +66,8 @@ public override bool RunTask () rewritten.SetMetadata ("OriginalItemSpec", source); rewrittenFiles [i] = rewritten; } catch (JniRewriteException e) { - Log.LogCodedError ("RJN0001", $"Could not rewrite the JNI names in '{source}': {e.Message}"); + Log.LogCodedError ("XA4325", Properties.Resources.XA4325, + string.Format (Properties.Resources.XA4325_AssemblyFailure, source, e.Message)); } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs index 67c6cbeb2af..c87faf9a71b 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs @@ -1,10 +1,12 @@ using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Reflection; using System.Reflection.Metadata; using System.Reflection.Metadata.Ecma335; using System.Reflection.PortableExecutable; +using Microsoft.Build.Framework; using NUnit.Framework; using Xamarin.Android.Tasks; @@ -116,14 +118,18 @@ public void FailsWithACodedErrorWhenSourceAndDestinationCountsDiffer () string mappingFile = Path.Combine (path, "mapping.txt"); File.WriteAllText (mappingFile, ""); + var errors = new List (); var task = new RewriteJniNamesForR8 { - BuildEngine = new MockBuildEngine (TestContext.Out), + BuildEngine = new MockBuildEngine (TestContext.Out, errors), SourceFiles = new [] { new Microsoft.Build.Utilities.TaskItem ("a.dll"), new Microsoft.Build.Utilities.TaskItem ("b.dll") }, DestinationFiles = new [] { new Microsoft.Build.Utilities.TaskItem ("a.dll") }, MappingFile = mappingFile, }; Assert.IsFalse (task.Execute (), "Task should fail when SourceFiles/DestinationFiles counts differ."); + Assert.AreEqual (1, errors.Count, "Exactly one error should have been logged."); + Assert.AreEqual ("XA4325", errors [0].Code, "The error should use the documented XA4325 code."); + StringAssert.Contains ("SourceFiles", errors [0].Message, "The error should name the mismatched item groups."); } [Test] From 83ee1bd20cf3aa6c60b68025c2ae34ed112a3092 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 18:10:55 +0200 Subject: [PATCH 03/21] Fix managed JNI rewrite sequence handling Associate legacy JNI member lookups with proven FindClass/Get*ID sequences, remove stale copied PDBs, and keep task outputs empty after rewrite failures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/RewriteJniNamesForR8.cs | 18 +- .../Tasks/RewriteJniNamesForR8Tests.cs | 96 +++++ .../JniRemapping/JniAssemblyRewriterTests.cs | 156 +++++++- .../JniRemapping/JniRewritePlanner.cs | 338 +++++++++++++++--- 4 files changed, 552 insertions(+), 56 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs index 421137764b3..21991e1eb5b 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs @@ -45,13 +45,14 @@ public class RewriteJniNamesForR8 : AndroidTask public override bool RunTask () { + RewrittenFiles = []; if (DestinationDirectory.IsNullOrEmpty () && SourceFiles.Length != DestinationFiles.Length) { Log.LogCodedError ("XA4325", Properties.Resources.XA4325, Properties.Resources.XA4325_SourceDestinationCount); return !Log.HasLoggedErrors; } R8Mapping mapping = R8Mapping.Load (MappingFile); - var rewrittenFiles = new ITaskItem [SourceFiles.Length]; + var rewrittenFiles = new List (SourceFiles.Length); for (int i = 0; i < SourceFiles.Length; i++) { string source = SourceFiles [i].ItemSpec; @@ -64,16 +65,18 @@ public override bool RunTask () ItemSpec = destination, }; rewritten.SetMetadata ("OriginalItemSpec", source); - rewrittenFiles [i] = rewritten; + rewrittenFiles.Add (rewritten); } catch (JniRewriteException e) { Log.LogCodedError ("XA4325", Properties.Resources.XA4325, string.Format (Properties.Resources.XA4325_AssemblyFailure, source, e.Message)); } } - RewrittenFiles = rewrittenFiles; - if (!Log.HasLoggedErrors && !RewriteManifestFile.IsNullOrEmpty ()) { - WriteRewriteManifest (RewriteManifestFile, mapping.AccessedEntries); + if (!Log.HasLoggedErrors) { + RewrittenFiles = rewrittenFiles.ToArray (); + if (!RewriteManifestFile.IsNullOrEmpty ()) { + WriteRewriteManifest (RewriteManifestFile, mapping.AccessedEntries); + } } return !Log.HasLoggedErrors; } @@ -114,9 +117,12 @@ void RewriteAssembly (string sourcePath, string destinationPath, R8Mapping mappi static void CopyAdjacentPdbUnchanged (string sourcePath, string destinationPath) { string pdbSource = Path.ChangeExtension (sourcePath, "pdb"); + string pdbDestination = Path.ChangeExtension (destinationPath, "pdb"); if (File.Exists (pdbSource)) { - string pdbDestination = Path.ChangeExtension (destinationPath, "pdb"); Files.CopyIfChanged (pdbSource, pdbDestination); + } else if (File.Exists (pdbDestination)) { + Files.SetWriteable (pdbDestination); + File.Delete (pdbDestination); } } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs index c87faf9a71b..f2f04c71c63 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs @@ -36,6 +36,46 @@ static byte [] BuildTrivialAssembly () return stream.ToArray (); } + static byte [] BuildAssemblyWithMalformedLdstrOperand () + { + var fixture = new JniFixtureBuilder (); + UserStringHandle value = fixture.String ("malformed"); + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + fixture.AddVoidMethod ("Malformed", fixture.EmitLoadStringBody (value)); + fixture.AddType ("Acme", "Malformed", fieldStart, methodStart); + + byte [] image = fixture.Serialize (); + uint token = (uint) MetadataTokens.GetToken (value); + byte [] pattern = { + (byte) ILOpCode.Ldstr, + (byte) token, + (byte) (token >> 8), + (byte) (token >> 16), + (byte) (token >> 24), + (byte) ILOpCode.Pop, + (byte) ILOpCode.Ret, + }; + int match = -1; + for (int i = 0; i <= image.Length - pattern.Length; i++) { + bool matches = true; + for (int j = 0; j < pattern.Length; j++) { + if (image [i + j] != pattern [j]) { + matches = false; + break; + } + } + if (!matches) { + continue; + } + Assert.AreEqual (-1, match, "The fixture should contain exactly one matching ldstr sequence."); + match = i; + } + Assert.AreNotEqual (-1, match, "The fixture's ldstr sequence was not found."); + image [match + sizeof (uint)] = 0x71; + return image; + } + [Test] public void CopiesSourceToDestinationAndAdjacentPdbUnchanged () { @@ -83,6 +123,35 @@ public void CopiesSourceToDestinationAndAdjacentPdbUnchanged () Assert.AreEqual ("Fixture", after.GetString (after.GetAssemblyDefinition ().Name)); } + [Test] + public void RemovesStaleDestinationPdbWhenSourceHasNoPdb () + { + string path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + + string sourceDll = Path.Combine (path, "source", "Test.dll"); + Directory.CreateDirectory (Path.GetDirectoryName (sourceDll)); + File.WriteAllBytes (sourceDll, BuildTrivialAssembly ()); + + string destinationDll = Path.Combine (path, "destination", "Test.dll"); + string destinationPdb = Path.ChangeExtension (destinationDll, "pdb"); + Directory.CreateDirectory (Path.GetDirectoryName (destinationDll)); + File.WriteAllBytes (destinationPdb, new byte [] { 1, 2, 3, 4 }); + + string mappingFile = Path.Combine (path, "mapping.txt"); + File.WriteAllText (mappingFile, ""); + var task = new RewriteJniNamesForR8 { + BuildEngine = new MockBuildEngine (TestContext.Out), + SourceFiles = new [] { new Microsoft.Build.Utilities.TaskItem (sourceDll) }, + DestinationFiles = new [] { new Microsoft.Build.Utilities.TaskItem (destinationDll) }, + MappingFile = mappingFile, + }; + + Assert.IsTrue (task.Execute (), "Task should succeed."); + FileAssert.Exists (destinationDll); + FileAssert.DoesNotExist (destinationPdb, "A PDB from a previous copy must not survive when the source PDB is absent."); + } + [Test] public void LeavesInPlaceAssemblyWithNoReplacementsUntouched () { @@ -132,6 +201,33 @@ public void FailsWithACodedErrorWhenSourceAndDestinationCountsDiffer () StringAssert.Contains ("SourceFiles", errors [0].Message, "The error should name the mismatched item groups."); } + [Test] + public void LeavesRewrittenFilesEmptyWhenAnAssemblyCannotBeRewritten () + { + string path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + string source = Path.Combine (path, "Malformed.dll"); + string destination = Path.Combine (path, "out", "Malformed.dll"); + File.WriteAllBytes (source, BuildAssemblyWithMalformedLdstrOperand ()); + + string mappingFile = Path.Combine (path, "mapping.txt"); + File.WriteAllText (mappingFile, ""); + var errors = new List (); + var task = new RewriteJniNamesForR8 { + BuildEngine = new MockBuildEngine (TestContext.Out, errors), + SourceFiles = new [] { new Microsoft.Build.Utilities.TaskItem (source) }, + DestinationFiles = new [] { new Microsoft.Build.Utilities.TaskItem (destination) }, + MappingFile = mappingFile, + }; + + Assert.IsFalse (task.Execute (), "Task should fail for malformed IL."); + Assert.AreEqual (1, errors.Count, "Exactly one error should have been logged."); + Assert.AreEqual ("XA4325", errors [0].Code); + StringAssert.Contains ("Malformed IL", errors [0].Message); + CollectionAssert.IsEmpty (task.RewrittenFiles, "A failed invocation must not publish partial or null output items."); + FileAssert.DoesNotExist (destination); + } + [Test] public void HandlesMultipleFilesInOneInvocation () { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs index 4b47dc2158e..fb51f7b3c28 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs @@ -118,8 +118,24 @@ static BlobBuilder IntFieldSignature () return signature; } + static BlobHandle AddLegacyJniMethodSignature (JniFixtureBuilder fixture, bool findClass) + { + var signature = new BlobBuilder (); + new BlobEncoder (signature).MethodSignature () + .Parameters (findClass ? 1 : 3, out ReturnTypeEncoder returnType, out ParametersEncoder parameters); + returnType.Type ().Int32 (); + if (findClass) { + parameters.AddParameter ().Type ().String (); + } else { + parameters.AddParameter ().Type ().Int32 (); + parameters.AddParameter ().Type ().String (); + parameters.AddParameter ().Type ().String (); + } + return fixture.Metadata.GetOrAddBlob (signature); + } + [Test] - public void RewritesBareMemberAndDescriptorForAReferencedJniClass () + public void DoesNotRewriteUnrelatedBareMemberAndDescriptorStrings () { var fixture = new JniFixtureBuilder (); UserStringHandle className = fixture.String ("net/dot/android/ApplicationRegistration"); @@ -139,11 +155,147 @@ public void RewritesBareMemberAndDescriptorForAReferencedJniClass () MetadataReader reader = peReader.GetMetadataReader (); CollectionAssert.AreEqual (new [] { "c4", - "a", + "Context", "Landroid/content/Context;", }, LoadedStrings (peReader, reader, method).ConvertAll (entry => entry.Value)); } + [Test] + public void RewritesLegacyJniLookupsForTwoClassesAndBothMethodHandleKinds () + { + var fixture = new JniFixtureBuilder (); + BlobHandle findClassSignature = AddLegacyJniMethodSignature (fixture, findClass: true); + BlobHandle memberLookupSignature = AddLegacyJniMethodSignature (fixture, findClass: false); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + MethodDefinitionHandle findClassDefinition = fixture.Metadata.AddMethodDefinition ( + MethodAttributes.Public | MethodAttributes.Static | MethodAttributes.HideBySig, + MethodImplAttributes.Runtime, + fixture.Metadata.GetOrAddString ("FindClass"), + findClassSignature, + 0, + MetadataTokens.ParameterHandle (fixture.Metadata.GetRowCount (TableIndex.Param) + 1)); + MethodDefinitionHandle getStaticFieldDefinition = fixture.Metadata.AddMethodDefinition ( + MethodAttributes.Public | MethodAttributes.Static | MethodAttributes.HideBySig, + MethodImplAttributes.Runtime, + fixture.Metadata.GetOrAddString ("GetStaticFieldID"), + memberLookupSignature, + 0, + MetadataTokens.ParameterHandle (fixture.Metadata.GetRowCount (TableIndex.Param) + 1)); + fixture.AddType ("Android.Runtime", "JNIEnv", fieldStart, methodStart); + + TypeReferenceHandle jniEnvironmentReference = fixture.Metadata.AddTypeReference ( + fixture.CoreLibraryReference, + fixture.Metadata.GetOrAddString ("Android.Runtime"), + fixture.Metadata.GetOrAddString ("JNIEnv")); + MemberReferenceHandle findClassReference = fixture.Metadata.AddMemberReference ( + jniEnvironmentReference, + fixture.Metadata.GetOrAddString ("FindClass"), + findClassSignature); + MemberReferenceHandle getMethodReference = fixture.Metadata.AddMemberReference ( + jniEnvironmentReference, + fixture.Metadata.GetOrAddString ("GetMethodID"), + memberLookupSignature); + + UserStringHandle firstClass = fixture.String ("acme/one/First"); + UserStringHandle firstField = fixture.String ("count"); + UserStringHandle firstDescriptor = fixture.String ("I"); + UserStringHandle firstOtherField = fixture.String ("enabled"); + UserStringHandle firstOtherDescriptor = fixture.String ("Z"); + UserStringHandle secondClass = fixture.String ("acme/two/Second"); + UserStringHandle secondMethod = fixture.String ("run"); + UserStringHandle secondDescriptor = fixture.String ("()V"); + UserStringHandle ambiguousField = fixture.String ("state"); + + var localSignature = new BlobBuilder (); + var localEncoder = new BlobEncoder (localSignature).LocalVariableSignature (2); + localEncoder.AddVariable ().Type ().Int32 (); + localEncoder.AddVariable ().Type ().Int32 (); + StandaloneSignatureHandle locals = fixture.Metadata.AddStandaloneSignature (fixture.Metadata.GetOrAddBlob (localSignature)); + var controlFlow = new ControlFlowBuilder (); + + fieldStart = fixture.NextFieldRid; + methodStart = fixture.NextMethodRid; + MethodDefinitionHandle method = fixture.AddVoidMethod ("LookUpBoth", fixture.EmitBody (encoder => { + LabelHandle ambiguousLookup = encoder.DefineLabel (); + + encoder.LoadString (firstClass); + encoder.OpCode (ILOpCode.Call); + encoder.Token (findClassDefinition); + encoder.StoreLocal (0); + encoder.LoadLocal (0); + encoder.LoadString (firstField); + encoder.LoadString (firstDescriptor); + encoder.OpCode (ILOpCode.Call); + encoder.Token (getStaticFieldDefinition); + encoder.OpCode (ILOpCode.Pop); + + encoder.LoadLocal (0); + encoder.LoadString (firstOtherField); + encoder.LoadString (firstOtherDescriptor); + encoder.OpCode (ILOpCode.Call); + encoder.Token (getStaticFieldDefinition); + encoder.OpCode (ILOpCode.Pop); + + encoder.LoadString (secondClass); + encoder.OpCode (ILOpCode.Call); + encoder.Token (findClassReference); + encoder.StoreLocal (1); + encoder.LoadLocal (1); + encoder.LoadString (secondMethod); + encoder.LoadString (secondDescriptor); + encoder.OpCode (ILOpCode.Call); + encoder.Token (getMethodReference); + encoder.OpCode (ILOpCode.Pop); + + encoder.LoadString (firstClass); + encoder.OpCode (ILOpCode.Call); + encoder.Token (findClassDefinition); + encoder.StoreLocal (0); + encoder.Branch (ILOpCode.Br_s, ambiguousLookup); + encoder.LoadString (secondClass); + encoder.OpCode (ILOpCode.Call); + encoder.Token (findClassReference); + encoder.StoreLocal (0); + encoder.MarkLabel (ambiguousLookup); + encoder.LoadLocal (0); + encoder.LoadString (ambiguousField); + encoder.LoadString (firstDescriptor); + encoder.OpCode (ILOpCode.Call); + encoder.Token (getStaticFieldDefinition); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Ret); + }, locals, controlFlow)); + fixture.AddType ("Acme", "LegacyLookups", fieldStart, methodStart); + + JniRewriteResult result = Rewrite (fixture.Serialize (), Mapping ( + "acme.one.First -> a.b.F:\n" + + " int count -> x\n" + + " boolean enabled -> q\n" + + " int state -> f\n" + + "acme.two.Second -> a.b.S:\n" + + " int state -> s\n" + + " void run() -> y\n")); + + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + CollectionAssert.AreEqual (new [] { + "a/b/F", + "x", + "I", + "q", + "Z", + "a/b/S", + "y", + "()V", + "a/b/F", + "a/b/S", + "state", + "I", + }, ValuesOf (LoadedStrings (peReader, reader, method))); + } + [Test] public void RewritesAttributesAndLoadedStrings () { diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs index d6ea8f13f0e..c555a569921 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs @@ -20,6 +20,7 @@ sealed class JniRewritePlanner const string JniTypeSignatureAttributeFullName = "Java.Interop.JniTypeSignatureAttribute"; const string JniMethodSignatureAttributeFullName = "Java.Interop.JniMethodSignatureAttribute"; const string JniConstructorSignatureAttributeFullName = "Java.Interop.JniConstructorSignatureAttribute"; + const string JniEnvironmentFullName = "Android.Runtime.JNIEnv"; readonly PEReader peReader; readonly MetadataReader reader; @@ -249,60 +250,291 @@ void PlanMethodBody (JniRewritePlan plan, MethodDefinitionHandle methodHandle, s return; } - byte [] il = GetILBytes (method); - string? referencedOwnerJniName = FindSingleReferencedJniClass (il); - string? pendingMemberName = null; - int pendingMemberNameOffset = 0; - - IlInstructionScanner.Walk (il, (code, _, operandOffset, _) => { - if (code == (ushort) ILOpCode.Ldstr) { - string value = ReadUserString (il, operandOffset); - if (referencedOwnerJniName != null && pendingMemberName != null) { - if (JniDescriptorText.IsValidFieldDescriptor (value) && - mapping.TryMapField (referencedOwnerJniName, pendingMemberName, out string renamedField)) { - plan.AddUserString (methodHandle, pendingMemberNameOffset, renamedField); - } else if (JniDescriptorText.IsValidMethodDescriptor (value)) { - JniDescriptorText.MethodDescriptorToJavaTypes (value, out var javaParams, out string javaReturnType); - string mappingName = R8Mapping.JniMemberNameToMappingName (pendingMemberName); - if (mapping.TryMapMethod (referencedOwnerJniName, mappingName, javaParams, javaReturnType, out string renamedMethod)) { - plan.AddUserString (methodHandle, pendingMemberNameOffset, renamedMethod); - } - } - } + MethodBodyBlock body = peReader.GetMethodBody (method.RelativeVirtualAddress); + byte [] il = body.GetILBytes () ?? []; + var instructions = new List (); + IlInstructionScanner.Walk (il, (code, instructionOffset, operandOffset, operandSize) => + instructions.Add (new IlInstruction (code, instructionOffset, operandOffset, operandSize))); + HashSet controlFlowEntries = GetControlFlowEntryOffsets (body, il, instructions); + + for (int i = 0; i < instructions.Count; i++) { + IlInstruction instruction = instructions [i]; + if (instruction.Code == (ushort) ILOpCode.Ldstr) { + string value = ReadUserString (il, instruction.OperandOffset); if (LdstrRewriter.TryRewrite (value, ownerJniName, mapping, out string rewritten)) { - plan.AddUserString (methodHandle, operandOffset, rewritten); + plan.AddUserString (methodHandle, instruction.OperandOffset, rewritten); } - pendingMemberName = IsBareMemberName (value) ? value : null; - pendingMemberNameOffset = operandOffset; - return; + } else if (TryGetJniLookupKind (il, instruction, out bool isField)) { + PlanLegacyJniLookup (plan, methodHandle, il, instructions, controlFlowEntries, i, isField); } + } + } + + void PlanLegacyJniLookup (JniRewritePlan plan, MethodDefinitionHandle methodHandle, byte [] il, + List instructions, HashSet controlFlowEntries, int callIndex, bool isField) + { + int descriptorIndex = PreviousNonNop (instructions, callIndex - 1); + int memberNameIndex = PreviousNonNop (instructions, descriptorIndex - 1); + int classIndex = PreviousNonNop (instructions, memberNameIndex - 1); + if (classIndex < 0 || + instructions [descriptorIndex].Code != (ushort) ILOpCode.Ldstr || + instructions [memberNameIndex].Code != (ushort) ILOpCode.Ldstr || + HasControlFlowEntry (instructions, controlFlowEntries, classIndex, callIndex) || + !TryResolveLegacyLookupClass (il, instructions, controlFlowEntries, classIndex, out string className)) { + return; + } + + string memberName = ReadUserString (il, instructions [memberNameIndex].OperandOffset); + if (!IsBareMemberName (memberName)) { + return; + } + string descriptor = ReadUserString (il, instructions [descriptorIndex].OperandOffset); - if (code != (ushort) ILOpCode.Pop) { - pendingMemberName = null; + if (isField) { + if (JniDescriptorText.IsValidFieldDescriptor (descriptor) && + mapping.TryMapField (className, memberName, out string renamedField)) { + plan.AddUserString (methodHandle, instructions [memberNameIndex].OperandOffset, renamedField); } - }); + } else if (JniDescriptorText.IsValidMethodDescriptor (descriptor)) { + JniDescriptorText.MethodDescriptorToJavaTypes (descriptor, out var javaParams, out string javaReturnType); + string mappingName = R8Mapping.JniMemberNameToMappingName (memberName); + if (mapping.TryMapMethod (className, mappingName, javaParams, javaReturnType, out string renamedMethod)) { + plan.AddUserString (methodHandle, instructions [memberNameIndex].OperandOffset, renamedMethod); + } + } + } + + bool TryResolveLegacyLookupClass (byte [] il, List instructions, + HashSet controlFlowEntries, int classIndex, out string className) + { + className = ""; + IlInstruction classInstruction = instructions [classIndex]; + if (IsJniEnvironmentMethod (il, classInstruction, "FindClass")) { + return TryReadFindClassName (il, instructions, controlFlowEntries, classIndex, classIndex, out className); + } + + if (!TryGetLocalIndex (il, classInstruction, load: true, out int localIndex)) { + return false; + } + + for (int storeIndex = classIndex - 1; storeIndex >= 0; storeIndex--) { + IlInstruction candidate = instructions [storeIndex]; + if (controlFlowEntries.Contains (candidate.InstructionOffset) || IsControlFlowBarrier (candidate.Code)) { + return false; + } + if (!TryGetLocalIndex (il, candidate, load: false, out int storedLocalIndex) || storedLocalIndex != localIndex) { + continue; + } + + int findClassIndex = PreviousNonNop (instructions, storeIndex - 1); + return findClassIndex >= 0 && + IsJniEnvironmentMethod (il, instructions [findClassIndex], "FindClass") && + TryReadFindClassName (il, instructions, controlFlowEntries, findClassIndex, classIndex, out className); + } + return false; + } + + bool TryReadFindClassName (byte [] il, List instructions, HashSet controlFlowEntries, + int findClassIndex, int sequenceEndIndex, out string className) + { + className = ""; + int classNameIndex = PreviousNonNop (instructions, findClassIndex - 1); + if (classNameIndex < 0 || + instructions [classNameIndex].Code != (ushort) ILOpCode.Ldstr || + HasControlFlowEntry (instructions, controlFlowEntries, classNameIndex, sequenceEndIndex)) { + return false; + } + className = ReadUserString (il, instructions [classNameIndex].OperandOffset); + return true; } - string? FindSingleReferencedJniClass (byte [] il) + static bool HasControlFlowEntry (List instructions, HashSet controlFlowEntries, + int startIndex, int endIndex) { - string? referencedClass = null; - bool ambiguous = false; - IlInstructionScanner.Walk (il, (code, _, operandOffset, _) => { - if (ambiguous || code != (ushort) ILOpCode.Ldstr) { - return; + for (int i = startIndex; i <= endIndex; i++) { + if (controlFlowEntries.Contains (instructions [i].InstructionOffset)) { + return true; } + } + return false; + } - string value = ReadUserString (il, operandOffset); - if (!mapping.TryMapClass (value, out _)) { - return; + static HashSet GetControlFlowEntryOffsets (MethodBodyBlock body, byte [] il, List instructions) + { + var entries = new HashSet (); + foreach (ExceptionRegion region in body.ExceptionRegions) { + entries.Add (region.HandlerOffset); + if (region.Kind == ExceptionRegionKind.Filter) { + entries.Add (region.FilterOffset); } - if (referencedClass != null && referencedClass != value) { - ambiguous = true; - return; + } + + foreach (IlInstruction instruction in instructions) { + int nextOffset = instruction.OperandOffset + instruction.OperandSize; + if (IsShortBranch (instruction.Code)) { + entries.Add (nextOffset + unchecked ((sbyte) il [instruction.OperandOffset])); + } else if (IsLongBranch (instruction.Code)) { + entries.Add (nextOffset + unchecked ((int) IlInstructionScanner.ReadUInt32 (il, instruction.OperandOffset))); + } else if (instruction.Code == (ushort) ILOpCode.Switch) { + int branchCount = unchecked ((int) IlInstructionScanner.ReadUInt32 (il, instruction.OperandOffset)); + for (int i = 0; i < branchCount; i++) { + int deltaOffset = instruction.OperandOffset + sizeof (uint) + i * sizeof (uint); + entries.Add (nextOffset + unchecked ((int) IlInstructionScanner.ReadUInt32 (il, deltaOffset))); + } } - referencedClass = value; - }); - return ambiguous ? null : referencedClass; + } + return entries; + } + + static bool IsShortBranch (ushort code) + => code >= (ushort) ILOpCode.Br_s && code <= (ushort) ILOpCode.Blt_un_s || + code == (ushort) ILOpCode.Leave_s; + + static bool IsLongBranch (ushort code) + => code >= (ushort) ILOpCode.Br && code <= (ushort) ILOpCode.Blt_un || + code == (ushort) ILOpCode.Leave; + + bool TryGetJniLookupKind (byte [] il, IlInstruction instruction, out bool isField) + { + isField = false; + if (!TryGetMethodIdentity (il, instruction, out string declaringType, out string methodName) || + declaringType != JniEnvironmentFullName) { + return false; + } + + switch (methodName) { + case "GetFieldID": + case "GetStaticFieldID": + isField = true; + return true; + case "GetMethodID": + case "GetStaticMethodID": + return true; + default: + return false; + } + } + + bool IsJniEnvironmentMethod (byte [] il, IlInstruction instruction, string methodName) + => TryGetMethodIdentity (il, instruction, out string declaringType, out string actualMethodName) && + declaringType == JniEnvironmentFullName && + actualMethodName == methodName; + + bool TryGetMethodIdentity (byte [] il, IlInstruction instruction, out string declaringType, out string methodName) + { + declaringType = ""; + methodName = ""; + if (instruction.Code != (ushort) ILOpCode.Call || instruction.OperandSize != sizeof (uint)) { + return false; + } + + EntityHandle methodHandle = MetadataTokens.EntityHandle ((int) IlInstructionScanner.ReadUInt32 (il, instruction.OperandOffset)); + if (methodHandle.Kind == HandleKind.MethodSpecification) { + methodHandle = reader.GetMethodSpecification ((MethodSpecificationHandle) methodHandle).Method; + } + + EntityHandle declaringTypeHandle; + if (methodHandle.Kind == HandleKind.MethodDefinition) { + MethodDefinition method = reader.GetMethodDefinition ((MethodDefinitionHandle) methodHandle); + methodName = reader.GetString (method.Name); + declaringTypeHandle = method.GetDeclaringType (); + } else if (methodHandle.Kind == HandleKind.MemberReference) { + MemberReference method = reader.GetMemberReference ((MemberReferenceHandle) methodHandle); + methodName = reader.GetString (method.Name); + declaringTypeHandle = method.Parent; + } else { + return false; + } + + switch (declaringTypeHandle.Kind) { + case HandleKind.TypeDefinition: + TypeDefinition typeDefinition = reader.GetTypeDefinition ((TypeDefinitionHandle) declaringTypeHandle); + declaringType = reader.GetString (typeDefinition.Namespace) + "." + reader.GetString (typeDefinition.Name); + return true; + case HandleKind.TypeReference: + TypeReference typeReference = reader.GetTypeReference ((TypeReferenceHandle) declaringTypeHandle); + declaringType = reader.GetString (typeReference.Namespace) + "." + reader.GetString (typeReference.Name); + return true; + default: + return false; + } + } + + static int PreviousNonNop (List instructions, int index) + { + while (index >= 0 && instructions [index].Code == (ushort) ILOpCode.Nop) { + index--; + } + return index; + } + + static bool IsControlFlowBarrier (ushort code) + { + switch ((ILOpCode) code) { + case ILOpCode.Jmp: + case ILOpCode.Br_s: + case ILOpCode.Brfalse_s: + case ILOpCode.Brtrue_s: + case ILOpCode.Beq_s: + case ILOpCode.Bge_s: + case ILOpCode.Bgt_s: + case ILOpCode.Ble_s: + case ILOpCode.Blt_s: + case ILOpCode.Bne_un_s: + case ILOpCode.Bge_un_s: + case ILOpCode.Bgt_un_s: + case ILOpCode.Ble_un_s: + case ILOpCode.Blt_un_s: + case ILOpCode.Br: + case ILOpCode.Brfalse: + case ILOpCode.Brtrue: + case ILOpCode.Beq: + case ILOpCode.Bge: + case ILOpCode.Bgt: + case ILOpCode.Ble: + case ILOpCode.Blt: + case ILOpCode.Bne_un: + case ILOpCode.Bge_un: + case ILOpCode.Bgt_un: + case ILOpCode.Ble_un: + case ILOpCode.Blt_un: + case ILOpCode.Switch: + case ILOpCode.Ret: + case ILOpCode.Throw: + case ILOpCode.Endfinally: + case ILOpCode.Leave: + case ILOpCode.Leave_s: + case ILOpCode.Endfilter: + case ILOpCode.Rethrow: + return true; + default: + return false; + } + } + + static bool TryGetLocalIndex (byte [] il, IlInstruction instruction, bool load, out int index) + { + index = 0; + ushort code = instruction.Code; + ushort first = load ? (ushort) ILOpCode.Ldloc_0 : (ushort) ILOpCode.Stloc_0; + ushort last = load ? (ushort) ILOpCode.Ldloc_3 : (ushort) ILOpCode.Stloc_3; + if (code >= first && code <= last) { + index = code - first; + return true; + } + + ushort shortForm = load ? (ushort) ILOpCode.Ldloc_s : (ushort) ILOpCode.Stloc_s; + if (code == shortForm) { + index = il [instruction.OperandOffset]; + return true; + } + + ushort longForm = load ? (ushort) ILOpCode.Ldloc : (ushort) ILOpCode.Stloc; + if (code == longForm) { + index = il [instruction.OperandOffset] | (il [instruction.OperandOffset + 1] << 8); + return true; + } + return false; } static bool IsBareMemberName (string value) @@ -318,12 +550,6 @@ static bool IsBareMemberName (string value) return true; } - byte [] GetILBytes (MethodDefinition method) - { - MethodBodyBlock body = peReader.GetMethodBody (method.RelativeVirtualAddress); - return body.GetILBytes () ?? []; - } - string ReadUserString (byte [] il, int operandOffset) { uint token = IlInstructionScanner.ReadUInt32 (il, operandOffset); @@ -332,5 +558,21 @@ string ReadUserString (byte [] il, int operandOffset) } return reader.GetUserString (MetadataTokens.UserStringHandle ((int) (token & 0x00FFFFFF))); } + + readonly struct IlInstruction + { + public ushort Code { get; } + public int InstructionOffset { get; } + public int OperandOffset { get; } + public int OperandSize { get; } + + public IlInstruction (ushort code, int instructionOffset, int operandOffset, int operandSize) + { + Code = code; + InstructionOffset = instructionOffset; + OperandOffset = operandOffset; + OperandSize = operandSize; + } + } } } From afe9106875a5be37c6f9311cbcc6bc5465ee9343 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 21:04:36 +0200 Subject: [PATCH 04/21] Complete managed JNI rewrite coverage Preserve managed constructor spellings, resolve cached static JNI class handles through proven FindClass assignments, warn on unsafe renamed lookup sources, and skip identity string rewrites. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Documentation/docs-mobile/TOC.yml | 2 + Documentation/docs-mobile/messages/index.md | 1 + Documentation/docs-mobile/messages/xa4326.md | 39 +++ .../Properties/Resources.Designer.cs | 9 + .../Properties/Resources.resx | 4 + .../Tasks/RewriteJniNamesForR8Tests.cs | 38 +++ .../JniRemapping/JniAssemblyRewriterTests.cs | 229 ++++++++++++++++- .../JniRemapping/JniRewritePlanner.cs | 231 +++++++++++++++++- 8 files changed, 544 insertions(+), 9 deletions(-) create mode 100644 Documentation/docs-mobile/messages/xa4326.md diff --git a/Documentation/docs-mobile/TOC.yml b/Documentation/docs-mobile/TOC.yml index d7e4a54bc60..80cde18b63c 100644 --- a/Documentation/docs-mobile/TOC.yml +++ b/Documentation/docs-mobile/TOC.yml @@ -366,6 +366,8 @@ href: messages/xa4324.md - name: XA4325 href: messages/xa4325.md + - name: XA4326 + href: messages/xa4326.md - name: "XA5xxx: GCC and toolchain" items: - name: "XA5xxx: GCC and toolchain" diff --git a/Documentation/docs-mobile/messages/index.md b/Documentation/docs-mobile/messages/index.md index e92d6d10f44..555dba0b2d1 100644 --- a/Documentation/docs-mobile/messages/index.md +++ b/Documentation/docs-mobile/messages/index.md @@ -255,6 +255,7 @@ Either change the value in the AndroidManifest.xml to match the $(SupportedOSPla + [XA4323](xa4323.md): Ignoring directory '{directory}' as it does not exist. + [XA4324](xa4324.md): [{arch}] Unable to delete source file '{file}'. + [XA4325](xa4325.md): Failed to rewrite managed JNI names for R8. {message} ++ [XA4326](xa4326.md): Unable to safely rewrite a JNI member lookup because its class handle does not have one structurally unambiguous `JNIEnv.FindClass` source. ## XA5xxx: GCC and toolchain diff --git a/Documentation/docs-mobile/messages/xa4326.md b/Documentation/docs-mobile/messages/xa4326.md new file mode 100644 index 00000000000..d793367e06d --- /dev/null +++ b/Documentation/docs-mobile/messages/xa4326.md @@ -0,0 +1,39 @@ +--- +title: .NET for Android warning XA4326 +description: XA4326 warning code +ms.date: 09/01/2026 +f1_keywords: + - "XA4326" +--- + +# .NET for Android warning XA4326 + +## Example message + +``` +warning XA4326: Unable to safely rewrite a JNI member lookup because its class handle does not have one structurally unambiguous JNIEnv.FindClass source. +``` + +## Issue + +R8 renamed a Java class referenced by a managed JNI member lookup. The class +handle is assigned more than once, comes from an ambiguous control-flow path, or +cannot be proven to come directly from `JNIEnv.FindClass`. + +The class name can be rewritten, but the corresponding member name cannot be +safely associated with one original Java class. Guessing could make the managed +assembly request a member from the wrong obfuscated class. + +## Solution + +This warning is unexpected for code generated by .NET for Android. Please +[report an issue][report-issue] and include the full warning, the affected +assembly, its R8 mapping file, and, if possible, a project that reproduces the +warning. + +If the assembly contains custom JNI code, keep each `JNIEnv.FindClass` call and +the member lookup that uses its result in an unambiguous sequence. Initialize +each cached class-handle field from one direct `JNIEnv.FindClass` call and do +not assign another class handle to the same field. + +[report-issue]: https://github.com/dotnet/android/issues/new/choose diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs index 70681887045..50ef76b2b2e 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs @@ -1958,6 +1958,15 @@ public static string XA4325_SourceDestinationCount { } } + /// + /// Looks up a localized string similar to Unable to safely rewrite a JNI member lookup because its class handle does not have one structurally unambiguous JNIEnv.FindClass source.. + /// + public static string XA4326 { + get { + return ResourceManager.GetString("XA4326", resourceCulture); + } + } + /// /// Looks up a localized string similar to Missing Android NDK toolchains directory '{0}'. Please install the Android NDK.. /// diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx index 8076c5ca11c..d1b5859ed62 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx @@ -897,6 +897,10 @@ Remove the '{0}' reference from your project and add the '{1}' NuGet package ins The 'SourceFiles' and 'DestinationFiles' item groups must contain the same number of items. The following are literal MSBuild item group names and should not be translated: 'SourceFiles', 'DestinationFiles'. + + Unable to safely rewrite a JNI member lookup because its class handle does not have one structurally unambiguous JNIEnv.FindClass source. + The following are literal API names and should not be translated: JNI, JNIEnv.FindClass. + Missing Android NDK toolchains directory '{0}'. Please install the Android NDK. {0} - The path of the missing directory diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs index f2f04c71c63..edc8f6f46e7 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs @@ -76,6 +76,17 @@ static byte [] BuildAssemblyWithMalformedLdstrOperand () return image; } + static byte [] BuildAssemblyWithLoadedString (string value) + { + var fixture = new JniFixtureBuilder (); + UserStringHandle loadedValue = fixture.String (value); + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + fixture.AddVoidMethod ("LoadString", fixture.EmitLoadStringBody (loadedValue)); + fixture.AddType ("Acme", "StringLoader", fieldStart, methodStart); + return fixture.Serialize (); + } + [Test] public void CopiesSourceToDestinationAndAdjacentPdbUnchanged () { @@ -179,6 +190,33 @@ public void LeavesInPlaceAssemblyWithNoReplacementsUntouched () Assert.AreEqual (originalWriteTime, File.GetLastWriteTimeUtc (assembly), "An in-place no-op must not write the assembly."); } + [Test] + public void LeavesInPlaceAssemblyWithIdentityMappingUntouched () + { + string path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + + string assembly = Path.Combine (path, "Test.dll"); + byte [] content = BuildAssemblyWithLoadedString ("acme/orig/Identity"); + File.WriteAllBytes (assembly, content); + DateTime originalWriteTime = new DateTime (2020, 1, 2, 3, 4, 5, DateTimeKind.Utc); + File.SetLastWriteTimeUtc (assembly, originalWriteTime); + + string mappingFile = Path.Combine (path, "mapping.txt"); + File.WriteAllText (mappingFile, "acme.orig.Identity -> acme.orig.Identity:\n"); + + var task = new RewriteJniNamesForR8 { + BuildEngine = new MockBuildEngine (TestContext.Out), + SourceFiles = new [] { new Microsoft.Build.Utilities.TaskItem (assembly) }, + DestinationFiles = new [] { new Microsoft.Build.Utilities.TaskItem (assembly) }, + MappingFile = mappingFile, + }; + + Assert.IsTrue (task.Execute (), "Task should succeed."); + CollectionAssert.AreEqual (content, File.ReadAllBytes (assembly)); + Assert.AreEqual (originalWriteTime, File.GetLastWriteTimeUtc (assembly), "An in-place identity mapping must not write the assembly."); + } + [Test] public void FailsWithACodedErrorWhenSourceAndDestinationCountsDiffer () { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs index fb51f7b3c28..41c88167bf4 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs @@ -7,6 +7,7 @@ using System.Reflection.Metadata; using System.Reflection.Metadata.Ecma335; using System.Reflection.PortableExecutable; +using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using NUnit.Framework; using Xamarin.Android.Tasks.JniRemapping; @@ -27,6 +28,13 @@ static JniRewriteResult Rewrite (byte [] sourceImage, R8Mapping mapping) return JniAssemblyRewriter.Rewrite (sourceImage, mapping, log); } + static JniRewriteResult Rewrite (byte [] sourceImage, R8Mapping mapping, IList warnings) + { + var engine = new MockBuildEngine (TestContext.Out, warnings: warnings); + var log = new TaskLoggingHelper (engine, nameof (JniAssemblyRewriterTests)); + return JniAssemblyRewriter.Rewrite (sourceImage, mapping, log); + } + static R8Mapping Mapping (string text) => R8Mapping.Parse (new StringReader (text)); static void AssertReverseScanMatchesRewrite (byte [] rewrittenImage, R8Mapping rewriteMapping, string mappingText) @@ -269,6 +277,7 @@ public void RewritesLegacyJniLookupsForTwoClassesAndBothMethodHandleKinds () }, locals, controlFlow)); fixture.AddType ("Acme", "LegacyLookups", fieldStart, methodStart); + var warnings = new List (); JniRewriteResult result = Rewrite (fixture.Serialize (), Mapping ( "acme.one.First -> a.b.F:\n" + " int count -> x\n" + @@ -276,7 +285,7 @@ public void RewritesLegacyJniLookupsForTwoClassesAndBothMethodHandleKinds () " int state -> f\n" + "acme.two.Second -> a.b.S:\n" + " int state -> s\n" + - " void run() -> y\n")); + " void run() -> y\n"), warnings); using var peReader = new PEReader (ImmutableArray.Create (result.Image)); MetadataReader reader = peReader.GetMetadataReader (); @@ -294,6 +303,220 @@ public void RewritesLegacyJniLookupsForTwoClassesAndBothMethodHandleKinds () "state", "I", }, ValuesOf (LoadedStrings (peReader, reader, method))); + Assert.AreEqual (1, warnings.Count, "The ambiguous local class source should produce one warning."); + Assert.AreEqual ("XA4326", warnings [0].Code); + } + + [Test] + public void RewritesLegacyJniLookupsUsingAUniqueCachedStaticClassHandle () + { + var fixture = new JniFixtureBuilder (); + BlobHandle findClassSignature = AddLegacyJniMethodSignature (fixture, findClass: true); + BlobHandle memberLookupSignature = AddLegacyJniMethodSignature (fixture, findClass: false); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + MethodDefinitionHandle findClass = fixture.Metadata.AddMethodDefinition ( + MethodAttributes.Public | MethodAttributes.Static | MethodAttributes.HideBySig, + MethodImplAttributes.Runtime, + fixture.Metadata.GetOrAddString ("FindClass"), + findClassSignature, + 0, + MetadataTokens.ParameterHandle (fixture.Metadata.GetRowCount (TableIndex.Param) + 1)); + MethodDefinitionHandle getField = fixture.Metadata.AddMethodDefinition ( + MethodAttributes.Public | MethodAttributes.Static | MethodAttributes.HideBySig, + MethodImplAttributes.Runtime, + fixture.Metadata.GetOrAddString ("GetFieldID"), + memberLookupSignature, + 0, + MetadataTokens.ParameterHandle (fixture.Metadata.GetRowCount (TableIndex.Param) + 1)); + fixture.AddType ("Android.Runtime", "JNIEnv", fieldStart, methodStart); + + TypeReferenceHandle jniEnvironmentReference = fixture.Metadata.AddTypeReference ( + fixture.CoreLibraryReference, + fixture.Metadata.GetOrAddString ("Android.Runtime"), + fixture.Metadata.GetOrAddString ("JNIEnv")); + MemberReferenceHandle findClassReference = fixture.Metadata.AddMemberReference ( + jniEnvironmentReference, + fixture.Metadata.GetOrAddString ("FindClass"), + findClassSignature); + MemberReferenceHandle getMethod = fixture.Metadata.AddMemberReference ( + jniEnvironmentReference, + fixture.Metadata.GetOrAddString ("GetMethodID"), + memberLookupSignature); + + fieldStart = fixture.NextFieldRid; + FieldDefinitionHandle classRef = fixture.Metadata.AddFieldDefinition ( + FieldAttributes.Private | FieldAttributes.Static, + fixture.Metadata.GetOrAddString ("class_ref"), + fixture.Metadata.GetOrAddBlob (IntFieldSignature ())); + FieldDefinitionHandle ambiguousClassRef = fixture.Metadata.AddFieldDefinition ( + FieldAttributes.Private | FieldAttributes.Static, + fixture.Metadata.GetOrAddString ("ambiguous_class_ref"), + fixture.Metadata.GetOrAddBlob (IntFieldSignature ())); + FieldDefinitionHandle branchTargetClassRef = fixture.Metadata.AddFieldDefinition ( + FieldAttributes.Private | FieldAttributes.Static, + fixture.Metadata.GetOrAddString ("branch_target_class_ref"), + fixture.Metadata.GetOrAddBlob (IntFieldSignature ())); + FieldDefinitionHandle frameworkClassRef = fixture.Metadata.AddFieldDefinition ( + FieldAttributes.Private | FieldAttributes.Static, + fixture.Metadata.GetOrAddString ("framework_class_ref"), + fixture.Metadata.GetOrAddBlob (IntFieldSignature ())); + TypeReferenceHandle contentValuesReference = fixture.Metadata.AddTypeReference ( + fixture.CoreLibraryReference, + fixture.Metadata.GetOrAddString ("Acme"), + fixture.Metadata.GetOrAddString ("ContentValues")); + MemberReferenceHandle classRefReference = fixture.Metadata.AddMemberReference ( + contentValuesReference, + fixture.Metadata.GetOrAddString ("class_ref"), + fixture.Metadata.GetOrAddBlob (IntFieldSignature ())); + + UserStringHandle contentValuesClass = fixture.String ("acme/orig/ContentValues"); + UserStringHandle otherClass = fixture.String ("acme/orig/Other"); + UserStringHandle fieldName = fixture.String ("size"); + UserStringHandle fieldDescriptor = fixture.String ("I"); + UserStringHandle methodName = fixture.String ("clear"); + UserStringHandle methodDescriptor = fixture.String ("()V"); + UserStringHandle ambiguousName = fixture.String ("value"); + UserStringHandle frameworkClass = fixture.String ("android/content/Context"); + UserStringHandle otherFrameworkClass = fixture.String ("android/view/View"); + + methodStart = fixture.NextMethodRid; + var initializerControlFlow = new ControlFlowBuilder (); + MethodDefinitionHandle initializer = fixture.AddVoidMethod (".cctor", fixture.EmitBody (encoder => { + LabelHandle branchTargetAssignment = encoder.DefineLabel (); + + encoder.LoadString (contentValuesClass); + encoder.OpCode (ILOpCode.Call); + encoder.Token (findClass); + encoder.OpCode (ILOpCode.Stsfld); + encoder.Token (classRef); + + encoder.LoadString (contentValuesClass); + encoder.OpCode (ILOpCode.Call); + encoder.Token (findClass); + encoder.OpCode (ILOpCode.Stsfld); + encoder.Token (ambiguousClassRef); + encoder.LoadString (otherClass); + encoder.OpCode (ILOpCode.Call); + encoder.Token (findClassReference); + encoder.OpCode (ILOpCode.Stsfld); + encoder.Token (ambiguousClassRef); + + encoder.Branch (ILOpCode.Br_s, branchTargetAssignment); + encoder.MarkLabel (branchTargetAssignment); + encoder.LoadString (contentValuesClass); + encoder.OpCode (ILOpCode.Call); + encoder.Token (findClass); + encoder.OpCode (ILOpCode.Stsfld); + encoder.Token (branchTargetClassRef); + + encoder.LoadString (frameworkClass); + encoder.OpCode (ILOpCode.Call); + encoder.Token (findClass); + encoder.OpCode (ILOpCode.Stsfld); + encoder.Token (frameworkClassRef); + encoder.LoadString (otherFrameworkClass); + encoder.OpCode (ILOpCode.Call); + encoder.Token (findClassReference); + encoder.OpCode (ILOpCode.Stsfld); + encoder.Token (frameworkClassRef); + encoder.OpCode (ILOpCode.Ret); + }, controlFlow: initializerControlFlow), MethodAttributes.Private | MethodAttributes.Static | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName); + MethodDefinitionHandle lookup = fixture.AddVoidMethod ("LookupMembers", fixture.EmitBody (encoder => { + encoder.OpCode (ILOpCode.Ldsfld); + encoder.Token (classRef); + encoder.LoadString (fieldName); + encoder.LoadString (fieldDescriptor); + encoder.OpCode (ILOpCode.Call); + encoder.Token (getField); + encoder.OpCode (ILOpCode.Pop); + + encoder.OpCode (ILOpCode.Ldsfld); + encoder.Token (classRefReference); + encoder.LoadString (methodName); + encoder.LoadString (methodDescriptor); + encoder.OpCode (ILOpCode.Call); + encoder.Token (getMethod); + encoder.OpCode (ILOpCode.Pop); + + encoder.OpCode (ILOpCode.Ldsfld); + encoder.Token (ambiguousClassRef); + encoder.LoadString (ambiguousName); + encoder.LoadString (fieldDescriptor); + encoder.OpCode (ILOpCode.Call); + encoder.Token (getField); + encoder.OpCode (ILOpCode.Pop); + + encoder.OpCode (ILOpCode.Ldsfld); + encoder.Token (branchTargetClassRef); + encoder.LoadString (ambiguousName); + encoder.LoadString (fieldDescriptor); + encoder.OpCode (ILOpCode.Call); + encoder.Token (getField); + encoder.OpCode (ILOpCode.Pop); + + encoder.OpCode (ILOpCode.Ldsfld); + encoder.Token (frameworkClassRef); + encoder.LoadString (ambiguousName); + encoder.LoadString (fieldDescriptor); + encoder.OpCode (ILOpCode.Call); + encoder.Token (getField); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Ret); + })); + fixture.AddType ("Acme", "ContentValues", fieldStart, methodStart); + + var warnings = new List (); + JniRewriteResult result = Rewrite (fixture.Serialize (), Mapping ( + "acme.orig.ContentValues -> a.b.C:\n" + + " int size -> x\n" + + " void clear() -> y\n" + + " int value -> z\n" + + "acme.orig.Other -> a.b.O:\n" + + " int value -> q\n"), warnings); + + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + CollectionAssert.AreEqual (new [] { + "a/b/C", + "a/b/C", + "a/b/O", + "a/b/C", + "android/content/Context", + "android/view/View", + }, ValuesOf (LoadedStrings (peReader, reader, initializer))); + CollectionAssert.AreEqual (new [] { + "x", + "I", + "y", + "()V", + "value", + "I", + "value", + "I", + "value", + "I", + }, ValuesOf (LoadedStrings (peReader, reader, lookup))); + Assert.AreEqual (2, warnings.Count, "Each unsafe renamed cached class handle should produce one warning."); + Assert.IsTrue (warnings.All (warning => warning.Code == "XA4326")); + } + + [Test] + public void IdentityMappedLoadedStringDoesNotRequireARewrite () + { + var fixture = new JniFixtureBuilder (); + UserStringHandle className = fixture.String ("acme/orig/Identity"); + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + fixture.AddVoidMethod ("LoadClass", fixture.EmitLoadStringBody (className)); + fixture.AddType ("Acme", "Identity", fieldStart, methodStart); + byte [] image = fixture.Serialize (); + + JniRewriteResult result = Rewrite (image, Mapping ("acme.orig.Identity -> acme.orig.Identity:\n")); + + Assert.AreEqual (0, result.ReplacementCount); + Assert.AreSame (image, result.Image, "An identity mapping should not invoke the assembly rebuilder."); } [Test] @@ -330,6 +553,8 @@ public void RewritesAttributesAndLoadedStrings () MethodDefinitionHandle ctor = fixture.AddVoidMethod (".ctor", fixture.EmitReturnOnlyBody (), MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName); + fixture.Metadata.AddCustomAttribute (ctor, fixture.RegisterCtor3, + fixture.AttributeBlob (".ctor", callbackDescriptor, "n_ctor_Lacme_orig_Callback_Handler")); fixture.Metadata.AddCustomAttribute (ctor, fixture.JniConstructorSignatureCtor1, fixture.AttributeBlob (callbackDescriptor)); TypeDefinitionHandle myView = fixture.AddType ("Acme.Orig", "MyView", fieldStart, methodStart); @@ -374,6 +599,8 @@ public void RewritesAttributesAndLoadedStrings () AttributeStringArgs (reader, onClickAttributes, fixture.JniMethodSignatureCtor2)); CollectionAssert.AreEqual (new [] { rewrittenCallbackDescriptor }, AttributeStringArgs (reader, reader.GetMethodDefinition (ctor).GetCustomAttributes (), fixture.JniConstructorSignatureCtor1)); + CollectionAssert.AreEqual (new [] { ".ctor", rewrittenCallbackDescriptor, "n_ctor_Lacme_orig_Callback_Handler" }, + AttributeStringArgs (reader, reader.GetMethodDefinition (ctor).GetCustomAttributes (), fixture.RegisterCtor3)); CollectionAssert.AreEqual (new [] { "a." + rewrittenCallbackDescriptor, diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs index c555a569921..2c2d01bfe5f 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs @@ -5,6 +5,7 @@ using System.Reflection.Metadata; using System.Reflection.Metadata.Ecma335; using System.Reflection.PortableExecutable; +using Microsoft.Android.Build.Tasks; using Microsoft.Build.Utilities; namespace Xamarin.Android.Tasks.JniRemapping @@ -28,6 +29,8 @@ sealed class JniRewritePlanner readonly TaskLoggingHelper log; readonly Func renameClass; readonly Dictionary ownerJniNameCache = new (); + readonly Dictionary> staticJniClassAssignments = new (StringComparer.Ordinal); + readonly HashSet warnedUnsafeLookupSources = new (StringComparer.Ordinal); public JniRewritePlanner (PEReader peReader, MetadataReader reader, IJniNameMapping mapping, TaskLoggingHelper log) { @@ -40,6 +43,7 @@ public JniRewritePlanner (PEReader peReader, MetadataReader reader, IJniNameMapp public JniRewritePlan CreatePlan () { + IndexStaticJniClassAssignments (); var plan = new JniRewritePlan (); foreach (TypeDefinitionHandle typeHandle in reader.TypeDefinitions) { PlanType (plan, typeHandle); @@ -172,6 +176,9 @@ void PlanNameAndDescriptorAttribute (JniRewritePlan plan, CustomAttributeHandle string? jniDescriptor = args [descriptorIndex].Value as string; string? newName = TryFindRenamedMethodName (ownerJniName, jniMemberName, jniDescriptor); + if (jniMemberName == ".ctor" || jniMemberName == ".cctor") { + newName = null; + } string? newDescriptor = jniDescriptor != null && JniDescriptorText.TryRewriteDescriptor (jniDescriptor, renameClass, out string rewrittenDescriptor) ? rewrittenDescriptor : null; @@ -261,7 +268,8 @@ void PlanMethodBody (JniRewritePlan plan, MethodDefinitionHandle methodHandle, s IlInstruction instruction = instructions [i]; if (instruction.Code == (ushort) ILOpCode.Ldstr) { string value = ReadUserString (il, instruction.OperandOffset); - if (LdstrRewriter.TryRewrite (value, ownerJniName, mapping, out string rewritten)) { + if (LdstrRewriter.TryRewrite (value, ownerJniName, mapping, out string rewritten) && + !String.Equals (value, rewritten, StringComparison.Ordinal)) { plan.AddUserString (methodHandle, instruction.OperandOffset, rewritten); } } else if (TryGetJniLookupKind (il, instruction, out bool isField)) { @@ -278,9 +286,7 @@ void PlanLegacyJniLookup (JniRewritePlan plan, MethodDefinitionHandle methodHand int classIndex = PreviousNonNop (instructions, memberNameIndex - 1); if (classIndex < 0 || instructions [descriptorIndex].Code != (ushort) ILOpCode.Ldstr || - instructions [memberNameIndex].Code != (ushort) ILOpCode.Ldstr || - HasControlFlowEntry (instructions, controlFlowEntries, classIndex, callIndex) || - !TryResolveLegacyLookupClass (il, instructions, controlFlowEntries, classIndex, out string className)) { + instructions [memberNameIndex].Code != (ushort) ILOpCode.Ldstr) { return; } @@ -289,16 +295,26 @@ void PlanLegacyJniLookup (JniRewritePlan plan, MethodDefinitionHandle methodHand return; } string descriptor = ReadUserString (il, instructions [descriptorIndex].OperandOffset); + if (isField ? !JniDescriptorText.IsValidFieldDescriptor (descriptor) : !JniDescriptorText.IsValidMethodDescriptor (descriptor)) { + return; + } + + if (HasControlFlowEntry (instructions, controlFlowEntries, classIndex, callIndex) || + !TryResolveLegacyLookupClass (il, instructions, controlFlowEntries, classIndex, out string className)) { + WarnForUnsafeRenamedLookup (methodHandle, il, instructions, classIndex); + return; + } if (isField) { - if (JniDescriptorText.IsValidFieldDescriptor (descriptor) && - mapping.TryMapField (className, memberName, out string renamedField)) { + if (mapping.TryMapField (className, memberName, out string renamedField) && + !String.Equals (memberName, renamedField, StringComparison.Ordinal)) { plan.AddUserString (methodHandle, instructions [memberNameIndex].OperandOffset, renamedField); } - } else if (JniDescriptorText.IsValidMethodDescriptor (descriptor)) { + } else { JniDescriptorText.MethodDescriptorToJavaTypes (descriptor, out var javaParams, out string javaReturnType); string mappingName = R8Mapping.JniMemberNameToMappingName (memberName); - if (mapping.TryMapMethod (className, mappingName, javaParams, javaReturnType, out string renamedMethod)) { + if (mapping.TryMapMethod (className, mappingName, javaParams, javaReturnType, out string renamedMethod) && + !String.Equals (memberName, renamedMethod, StringComparison.Ordinal)) { plan.AddUserString (methodHandle, instructions [memberNameIndex].OperandOffset, renamedMethod); } } @@ -313,6 +329,18 @@ bool TryResolveLegacyLookupClass (byte [] il, List instructions, return TryReadFindClassName (il, instructions, controlFlowEntries, classIndex, classIndex, out className); } + if (TryGetStaticField (il, classInstruction, load: true, out string fieldKey)) { + if (staticJniClassAssignments.TryGetValue (fieldKey, out var assignments) && + assignments.Count == 1) { + string? assignedClassName = assignments [0].ClassName; + if (assignedClassName != null) { + className = assignedClassName; + return true; + } + } + return false; + } + if (!TryGetLocalIndex (il, classInstruction, load: true, out int localIndex)) { return false; } @@ -334,6 +362,181 @@ bool TryResolveLegacyLookupClass (byte [] il, List instructions, return false; } + void IndexStaticJniClassAssignments () + { + foreach (MethodDefinitionHandle methodHandle in reader.MethodDefinitions) { + MethodDefinition method = reader.GetMethodDefinition (methodHandle); + if (method.RelativeVirtualAddress == 0) { + continue; + } + + MethodBodyBlock body = peReader.GetMethodBody (method.RelativeVirtualAddress); + byte [] il = body.GetILBytes () ?? []; + var instructions = new List (); + IlInstructionScanner.Walk (il, (code, instructionOffset, operandOffset, operandSize) => + instructions.Add (new IlInstruction (code, instructionOffset, operandOffset, operandSize))); + HashSet controlFlowEntries = GetControlFlowEntryOffsets (body, il, instructions); + + for (int i = 0; i < instructions.Count; i++) { + if (!TryGetStaticField (il, instructions [i], load: false, out string fieldKey)) { + continue; + } + + string? className = null; + string? candidateClassName = null; + int findClassIndex = PreviousNonNop (instructions, i - 1); + if (findClassIndex >= 0 && IsJniEnvironmentMethod (il, instructions [findClassIndex], "FindClass")) { + int classNameIndex = PreviousNonNop (instructions, findClassIndex - 1); + if (classNameIndex >= 0 && instructions [classNameIndex].Code == (ushort) ILOpCode.Ldstr) { + candidateClassName = ReadUserString (il, instructions [classNameIndex].OperandOffset); + if (!HasControlFlowEntry (instructions, controlFlowEntries, classNameIndex, i)) { + className = candidateClassName; + } + } + } + + if (!staticJniClassAssignments.TryGetValue (fieldKey, out var assignments)) { + staticJniClassAssignments [fieldKey] = assignments = new List (); + } + assignments.Add (new StaticJniClassAssignment (className, candidateClassName)); + } + } + } + + void WarnForUnsafeRenamedLookup (MethodDefinitionHandle methodHandle, byte [] il, + List instructions, int classIndex) + { + IlInstruction classInstruction = instructions [classIndex]; + if (IsJniEnvironmentMethod (il, classInstruction, "FindClass")) { + int classNameIndex = PreviousNonNop (instructions, classIndex - 1); + if (classNameIndex >= 0 && + instructions [classNameIndex].Code == (ushort) ILOpCode.Ldstr && + IsRenamedClass (ReadUserString (il, instructions [classNameIndex].OperandOffset))) { + LogUnsafeLookupWarning ("D:" + MetadataTokens.GetToken (methodHandle) + ":" + classInstruction.InstructionOffset); + } + return; + } + + if (TryGetStaticField (il, classInstruction, load: true, out string fieldKey)) { + if (!staticJniClassAssignments.TryGetValue (fieldKey, out var assignments)) { + return; + } + foreach (StaticJniClassAssignment assignment in assignments) { + if (assignment.CandidateClassName != null && IsRenamedClass (assignment.CandidateClassName)) { + LogUnsafeLookupWarning ("F:" + fieldKey); + return; + } + } + return; + } + + if (!TryGetLocalIndex (il, classInstruction, load: true, out int localIndex)) { + return; + } + for (int storeIndex = 0; storeIndex < instructions.Count; storeIndex++) { + IlInstruction instruction = instructions [storeIndex]; + if (!TryGetLocalIndex (il, instruction, load: false, out int storedLocalIndex) || storedLocalIndex != localIndex) { + continue; + } + int findClassIndex = PreviousNonNop (instructions, storeIndex - 1); + if (findClassIndex < 0 || !IsJniEnvironmentMethod (il, instructions [findClassIndex], "FindClass")) { + continue; + } + int classNameIndex = PreviousNonNop (instructions, findClassIndex - 1); + if (classNameIndex >= 0 && + instructions [classNameIndex].Code == (ushort) ILOpCode.Ldstr && + IsRenamedClass (ReadUserString (il, instructions [classNameIndex].OperandOffset))) { + LogUnsafeLookupWarning ("L:" + MetadataTokens.GetToken (methodHandle) + ":" + localIndex); + return; + } + } + } + + bool IsRenamedClass (string className) + => mapping.TryMapClass (className, out string renamedClass) && + !String.Equals (className, renamedClass, StringComparison.Ordinal); + + void LogUnsafeLookupWarning (string sourceKey) + { + if (warnedUnsafeLookupSources.Add (sourceKey)) { + log.LogCodedWarning ("XA4326", Properties.Resources.XA4326); + } + } + + bool TryGetStaticField (byte [] il, IlInstruction instruction, bool load, out string fieldKey) + { + fieldKey = ""; + ushort expectedCode = load ? (ushort) ILOpCode.Ldsfld : (ushort) ILOpCode.Stsfld; + if (instruction.Code != expectedCode || instruction.OperandSize != sizeof (uint)) { + return false; + } + + EntityHandle fieldHandle = MetadataTokens.EntityHandle ((int) IlInstructionScanner.ReadUInt32 (il, instruction.OperandOffset)); + EntityHandle declaringTypeHandle; + BlobHandle signature; + switch (fieldHandle.Kind) { + case HandleKind.FieldDefinition: + FieldDefinition field = reader.GetFieldDefinition ((FieldDefinitionHandle) fieldHandle); + string fieldName = reader.GetString (field.Name); + signature = field.Signature; + declaringTypeHandle = field.GetDeclaringType (); + fieldKey = fieldName; + break; + case HandleKind.MemberReference: + MemberReference member = reader.GetMemberReference ((MemberReferenceHandle) fieldHandle); + string memberName = reader.GetString (member.Name); + signature = member.Signature; + declaringTypeHandle = member.Parent; + fieldKey = memberName; + break; + default: + return false; + } + + if (!TryGetTypeIdentity (declaringTypeHandle, out string declaringType)) { + fieldKey = ""; + return false; + } + fieldKey = declaringType + "\0" + fieldKey + "\0" + Convert.ToBase64String (reader.GetBlobBytes (signature)); + return true; + } + + bool TryGetTypeIdentity (EntityHandle typeHandle, out string identity) + { + switch (typeHandle.Kind) { + case HandleKind.TypeDefinition: + TypeDefinition definition = reader.GetTypeDefinition ((TypeDefinitionHandle) typeHandle); + string definitionName = reader.GetString (definition.Name); + TypeDefinitionHandle declaringType = definition.GetDeclaringType (); + if (!declaringType.IsNil) { + if (!TryGetTypeIdentity (declaringType, out string declaringIdentity)) { + identity = ""; + return false; + } + identity = declaringIdentity + "$" + definitionName; + return true; + } + identity = reader.GetString (definition.Namespace) + "." + definitionName; + return true; + case HandleKind.TypeReference: + TypeReference reference = reader.GetTypeReference ((TypeReferenceHandle) typeHandle); + string referenceName = reader.GetString (reference.Name); + if (reference.ResolutionScope.Kind == HandleKind.TypeReference) { + if (!TryGetTypeIdentity (reference.ResolutionScope, out string declaringIdentity)) { + identity = ""; + return false; + } + identity = declaringIdentity + "$" + referenceName; + return true; + } + identity = reader.GetString (reference.Namespace) + "." + referenceName; + return true; + default: + identity = ""; + return false; + } + } + bool TryReadFindClassName (byte [] il, List instructions, HashSet controlFlowEntries, int findClassIndex, int sequenceEndIndex, out string className) { @@ -574,5 +777,17 @@ public IlInstruction (ushort code, int instructionOffset, int operandOffset, int OperandSize = operandSize; } } + + readonly struct StaticJniClassAssignment + { + public string? ClassName { get; } + public string? CandidateClassName { get; } + + public StaticJniClassAssignment (string? className, string? candidateClassName) + { + ClassName = className; + CandidateClassName = candidateClassName; + } + } } } From 7fbf20aee19a72c070f6d1533971ace13321239d Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 2 Sep 2026 23:20:43 +0200 Subject: [PATCH 05/21] Address managed JNI rewrite review feedback Use platform-aware path identity for in-place rewrites, validate custom attribute prologs, and diagnose unsafe member-only mappings without polluting rewrite manifests or reverse scans. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/RewriteJniNamesForR8.cs | 9 +- .../Tasks/RewriteJniNamesForR8Tests.cs | 10 +++ .../JniRemapping/JniAssemblyRewriterTests.cs | 84 +++++++++++++++++++ .../CustomAttributeStringRewriter.cs | 3 + .../JniRemapping/JniRewritePlanner.cs | 31 +++++-- .../Utilities/JniRemapping/R8Mapping.cs | 27 ++++-- 6 files changed, 150 insertions(+), 14 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs index 21991e1eb5b..1345a9d909e 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/RewriteJniNamesForR8.cs @@ -26,6 +26,10 @@ namespace Xamarin.Android.Tasks /// public class RewriteJniNamesForR8 : AndroidTask { + static readonly StringComparison PathComparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + public override string TaskPrefix => "RJN"; [Required] @@ -104,7 +108,7 @@ void RewriteAssembly (string sourcePath, string destinationPath, R8Mapping mappi Log.LogDebugMessage ($"RewriteJniNamesForR8: '{Path.GetFileName (sourcePath)}' is strong-named; preserved its public-key identity and emitted a delay-signed linker input."); } - bool inPlace = String.Equals (Path.GetFullPath (sourcePath), Path.GetFullPath (destinationPath), StringComparison.Ordinal); + bool inPlace = AreSamePath (sourcePath, destinationPath); if (!inPlace || result.ReplacementCount != 0) { using var output = new MemoryStream (result.Image, writable: false); Files.CopyIfStreamChanged (output, destinationPath); @@ -114,6 +118,9 @@ void RewriteAssembly (string sourcePath, string destinationPath, R8Mapping mappi } } + internal static bool AreSamePath (string firstPath, string secondPath) + => String.Equals (Path.GetFullPath (firstPath), Path.GetFullPath (secondPath), PathComparison); + static void CopyAdjacentPdbUnchanged (string sourcePath, string destinationPath) { string pdbSource = Path.ChangeExtension (sourcePath, "pdb"); diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs index edc8f6f46e7..5a231133b1f 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RewriteJniNamesForR8Tests.cs @@ -190,6 +190,16 @@ public void LeavesInPlaceAssemblyWithNoReplacementsUntouched () Assert.AreEqual (originalWriteTime, File.GetLastWriteTimeUtc (assembly), "An in-place no-op must not write the assembly."); } + [Test] + public void DetectsInPlacePathsUsingPlatformCaseRules () + { + string upperCasePath = Path.Combine (Root, "temp", TestName, "Test.dll"); + string lowerCasePath = Path.Combine (Root, "temp", TestName, "test.dll"); + + Assert.AreEqual (Path.DirectorySeparatorChar == '\\', + RewriteJniNamesForR8.AreSamePath (upperCasePath, lowerCasePath)); + } + [Test] public void LeavesInPlaceAssemblyWithIdentityMappingUntouched () { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs index 41c88167bf4..bbdd5f43d38 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs @@ -142,6 +142,15 @@ static BlobHandle AddLegacyJniMethodSignature (JniFixtureBuilder fixture, bool f return fixture.Metadata.GetOrAddBlob (signature); } + [Test] + public void RejectsInvalidCustomAttributeProlog () + { + var exception = Assert.Throws (() => + CustomAttributeStringRewriter.TryRewrite (new byte [] { 0x00, 0x00 }, 0, (_, _) => null)); + + StringAssert.Contains ("expected 0x0001 prolog", exception.Message); + } + [Test] public void DoesNotRewriteUnrelatedBareMemberAndDescriptorStrings () { @@ -307,6 +316,81 @@ public void RewritesLegacyJniLookupsForTwoClassesAndBothMethodHandleKinds () Assert.AreEqual ("XA4326", warnings [0].Code); } + [TestCase (true)] + [TestCase (false)] + public void WarnsForUnsafeMemberOnlyRename (bool isField) + { + var fixture = new JniFixtureBuilder (); + BlobHandle findClassSignature = AddLegacyJniMethodSignature (fixture, findClass: true); + BlobHandle memberLookupSignature = AddLegacyJniMethodSignature (fixture, findClass: false); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + MethodDefinitionHandle findClass = fixture.Metadata.AddMethodDefinition ( + MethodAttributes.Public | MethodAttributes.Static | MethodAttributes.HideBySig, + MethodImplAttributes.Runtime, + fixture.Metadata.GetOrAddString ("FindClass"), + findClassSignature, + 0, + MetadataTokens.ParameterHandle (fixture.Metadata.GetRowCount (TableIndex.Param) + 1)); + MethodDefinitionHandle getMember = fixture.Metadata.AddMethodDefinition ( + MethodAttributes.Public | MethodAttributes.Static | MethodAttributes.HideBySig, + MethodImplAttributes.Runtime, + fixture.Metadata.GetOrAddString (isField ? "GetFieldID" : "GetMethodID"), + memberLookupSignature, + 0, + MetadataTokens.ParameterHandle (fixture.Metadata.GetRowCount (TableIndex.Param) + 1)); + fixture.AddType ("Android.Runtime", "JNIEnv", fieldStart, methodStart); + + UserStringHandle className = fixture.String ("acme/orig/Identity"); + UserStringHandle memberName = fixture.String ("value"); + string descriptorValue = isField ? "I" : "()V"; + UserStringHandle descriptor = fixture.String (descriptorValue); + var localSignature = new BlobBuilder (); + new BlobEncoder (localSignature).LocalVariableSignature (1).AddVariable ().Type ().Int32 (); + StandaloneSignatureHandle locals = fixture.Metadata.AddStandaloneSignature (fixture.Metadata.GetOrAddBlob (localSignature)); + var controlFlow = new ControlFlowBuilder (); + + fieldStart = fixture.NextFieldRid; + methodStart = fixture.NextMethodRid; + MethodDefinitionHandle lookup = fixture.AddVoidMethod ("Lookup", fixture.EmitBody (encoder => { + LabelHandle memberLookup = encoder.DefineLabel (); + encoder.LoadString (className); + encoder.OpCode (ILOpCode.Call); + encoder.Token (findClass); + encoder.StoreLocal (0); + encoder.Branch (ILOpCode.Br_s, memberLookup); + encoder.LoadString (className); + encoder.OpCode (ILOpCode.Call); + encoder.Token (findClass); + encoder.StoreLocal (0); + encoder.MarkLabel (memberLookup); + encoder.LoadLocal (0); + encoder.LoadString (memberName); + encoder.LoadString (descriptor); + encoder.OpCode (ILOpCode.Call); + encoder.Token (getMember); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Ret); + }, locals, controlFlow)); + fixture.AddType ("Acme", "MemberOnlyRename", fieldStart, methodStart); + + var warnings = new List (); + R8Mapping mapping = Mapping ( + "acme.orig.Identity -> acme.orig.Identity:\n" + + (isField ? " int value -> x\n" : " void value() -> x\n")); + JniRewriteResult result = Rewrite (fixture.Serialize (), mapping, warnings); + + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + CollectionAssert.AreEqual (new [] { "acme/orig/Identity", "acme/orig/Identity", "value", descriptorValue }, + ValuesOf (LoadedStrings (peReader, reader, lookup))); + Assert.AreEqual (1, warnings.Count, "An unsafe lookup must warn when only its member name is renamed."); + Assert.AreEqual ("XA4326", warnings [0].Code); + CollectionAssert.AreEqual (new [] { "C\tacme/orig/Identity" }, mapping.AccessedEntries, + "A warning-only lookup must not publish a member mapping that was not used for a rewrite."); + } + [Test] public void RewritesLegacyJniLookupsUsingAUniqueCachedStaticClassHandle () { diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs index ab3ff529cbd..bccc81e89c7 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs @@ -31,6 +31,9 @@ static class CustomAttributeStringRewriter if (originalContent.Length < 2) { throw new JniRewriteException ("Malformed custom attribute value blob: missing 2-byte prolog."); } + if (originalContent [0] != 0x01 || originalContent [1] != 0x00) { + throw new JniRewriteException ("Malformed custom attribute value blob: expected 0x0001 prolog."); + } using var ms = new MemoryStream (originalContent.Length); ms.Write (originalContent, 0, 2); diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs index 2c2d01bfe5f..45f1b0371ea 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs @@ -26,6 +26,7 @@ sealed class JniRewritePlanner readonly PEReader peReader; readonly MetadataReader reader; readonly IJniNameMapping mapping; + readonly R8Mapping? forwardMapping; readonly TaskLoggingHelper log; readonly Func renameClass; readonly Dictionary ownerJniNameCache = new (); @@ -37,6 +38,7 @@ public JniRewritePlanner (PEReader peReader, MetadataReader reader, IJniNameMapp this.peReader = peReader; this.reader = reader; this.mapping = mapping; + forwardMapping = mapping as R8Mapping; this.log = log; renameClass = className => mapping.TryMapClass (className, out string renamed) ? renamed : null; } @@ -301,7 +303,7 @@ void PlanLegacyJniLookup (JniRewritePlan plan, MethodDefinitionHandle methodHand if (HasControlFlowEntry (instructions, controlFlowEntries, classIndex, callIndex) || !TryResolveLegacyLookupClass (il, instructions, controlFlowEntries, classIndex, out string className)) { - WarnForUnsafeRenamedLookup (methodHandle, il, instructions, classIndex); + WarnForUnsafeRenamedLookup (methodHandle, il, instructions, classIndex, memberName, descriptor, isField); return; } @@ -404,14 +406,14 @@ void IndexStaticJniClassAssignments () } void WarnForUnsafeRenamedLookup (MethodDefinitionHandle methodHandle, byte [] il, - List instructions, int classIndex) + List instructions, int classIndex, string memberName, string descriptor, bool isField) { IlInstruction classInstruction = instructions [classIndex]; if (IsJniEnvironmentMethod (il, classInstruction, "FindClass")) { int classNameIndex = PreviousNonNop (instructions, classIndex - 1); if (classNameIndex >= 0 && instructions [classNameIndex].Code == (ushort) ILOpCode.Ldstr && - IsRenamedClass (ReadUserString (il, instructions [classNameIndex].OperandOffset))) { + WouldRenameLookupMember (ReadUserString (il, instructions [classNameIndex].OperandOffset), memberName, descriptor, isField)) { LogUnsafeLookupWarning ("D:" + MetadataTokens.GetToken (methodHandle) + ":" + classInstruction.InstructionOffset); } return; @@ -422,7 +424,8 @@ void WarnForUnsafeRenamedLookup (MethodDefinitionHandle methodHandle, byte [] il return; } foreach (StaticJniClassAssignment assignment in assignments) { - if (assignment.CandidateClassName != null && IsRenamedClass (assignment.CandidateClassName)) { + if (assignment.CandidateClassName != null && + WouldRenameLookupMember (assignment.CandidateClassName, memberName, descriptor, isField)) { LogUnsafeLookupWarning ("F:" + fieldKey); return; } @@ -445,16 +448,28 @@ void WarnForUnsafeRenamedLookup (MethodDefinitionHandle methodHandle, byte [] il int classNameIndex = PreviousNonNop (instructions, findClassIndex - 1); if (classNameIndex >= 0 && instructions [classNameIndex].Code == (ushort) ILOpCode.Ldstr && - IsRenamedClass (ReadUserString (il, instructions [classNameIndex].OperandOffset))) { + WouldRenameLookupMember (ReadUserString (il, instructions [classNameIndex].OperandOffset), memberName, descriptor, isField)) { LogUnsafeLookupWarning ("L:" + MetadataTokens.GetToken (methodHandle) + ":" + localIndex); return; } } } - bool IsRenamedClass (string className) - => mapping.TryMapClass (className, out string renamedClass) && - !String.Equals (className, renamedClass, StringComparison.Ordinal); + bool WouldRenameLookupMember (string className, string memberName, string descriptor, bool isField) + { + if (forwardMapping == null) { + return false; + } + if (isField) { + return forwardMapping.TryPeekRenamedField (className, memberName, out string renamedField) && + !String.Equals (memberName, renamedField, StringComparison.Ordinal); + } + + JniDescriptorText.MethodDescriptorToJavaTypes (descriptor, out var javaParams, out string javaReturnType); + string mappingName = R8Mapping.JniMemberNameToMappingName (memberName); + return forwardMapping.TryPeekRenamedMethod (className, mappingName, javaParams, javaReturnType, out string renamedMethod) && + !String.Equals (memberName, renamedMethod, StringComparison.Ordinal); + } void LogUnsafeLookupWarning (string sourceKey) { diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs index 95933b4d98b..d8506ecdb73 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -275,6 +275,17 @@ public bool TryGetOriginalMethodName (string originalJniClassName, string obfusc } public bool TryGetRenamedField (string owningJniClassName, string originalFieldName, out string obfuscatedFieldName) + { + if (!TryPeekRenamedField (owningJniClassName, originalFieldName, out obfuscatedFieldName)) { + return false; + } + RecordAccess ( + BuildClassEntry (owningJniClassName), + BuildFieldEntry (owningJniClassName, originalFieldName)); + return true; + } + + internal bool TryPeekRenamedField (string owningJniClassName, string originalFieldName, out string obfuscatedFieldName) { obfuscatedFieldName = ""; if (!fields.TryGetValue (owningJniClassName, out var classFields) || @@ -282,13 +293,22 @@ public bool TryGetRenamedField (string owningJniClassName, string originalFieldN return false; } obfuscatedFieldName = renamed; + return true; + } + + public bool TryGetRenamedMethod (string owningJniClassName, string javaMethodName, IReadOnlyList javaParameterTypes, string javaReturnType, out string obfuscatedMethodName) + { + if (!TryPeekRenamedMethod (owningJniClassName, javaMethodName, javaParameterTypes, javaReturnType, out obfuscatedMethodName)) { + return false; + } + string methodKey = BuildMethodKey (javaMethodName, javaParameterTypes, javaReturnType); RecordAccess ( BuildClassEntry (owningJniClassName), - BuildFieldEntry (owningJniClassName, originalFieldName)); + BuildMethodEntry (owningJniClassName, methodKey)); return true; } - public bool TryGetRenamedMethod (string owningJniClassName, string javaMethodName, IReadOnlyList javaParameterTypes, string javaReturnType, out string obfuscatedMethodName) + internal bool TryPeekRenamedMethod (string owningJniClassName, string javaMethodName, IReadOnlyList javaParameterTypes, string javaReturnType, out string obfuscatedMethodName) { obfuscatedMethodName = ""; if (!methods.TryGetValue (owningJniClassName, out var classMethods)) { @@ -299,9 +319,6 @@ public bool TryGetRenamedMethod (string owningJniClassName, string javaMethodNam return false; } obfuscatedMethodName = renamed; - RecordAccess ( - BuildClassEntry (owningJniClassName), - BuildMethodEntry (owningJniClassName, methodKey)); return true; } From a5076552cbb99d1700b0f9ee0f319df051596e20 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 17:12:41 +0200 Subject: [PATCH 06/21] Rewrite trimmable typemap JNI metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JniRemapping/JniAssemblyRewriterTests.cs | 279 ++++++++++++++++- .../CustomAttributeStringRewriter.cs | 68 ++++- .../JniRemapping/JniAssemblyRewriter.cs | 18 +- .../JniRemapping/JniRewritePlanner.cs | 280 +++++++++++++++++- .../Utilities/MetadataExtensions.cs | 16 +- 5 files changed, 645 insertions(+), 16 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs index bbdd5f43d38..5fa72f55932 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs @@ -72,6 +72,24 @@ static string FirstAttributeStringArg (MetadataReader reader, CustomAttributeHan return args.Count > 0 ? args [0] : null; } + static IReadOnlyList AttributeStringArrayArg (MetadataReader reader, CustomAttributeHandleCollection attributes, EntityHandle ctor) + { + foreach (CustomAttributeHandle handle in attributes) { + CustomAttribute attribute = reader.GetCustomAttribute (handle); + if (attribute.Constructor != ctor) { + continue; + } + + var decoded = attribute.DecodeValue (Xamarin.Android.Tasks.DummyCustomAttributeProvider.Instance); + var result = new List (); + foreach (var element in (ImmutableArray>) decoded.FixedArguments [0].Value) { + result.Add ((string) element.Value); + } + return result; + } + return []; + } + static List> LoadedStrings (PEReader peReader, MetadataReader reader, MethodDefinitionHandle method) { var result = new List> (); @@ -94,10 +112,13 @@ static List> LoadedStrings (PEReader peReader, Metadat return result; } - static void AssertTableRowCountsMatch (MetadataReader expected, MetadataReader actual) + static void AssertTableRowCountsMatch (MetadataReader expected, MetadataReader actual, params TableIndex [] except) { for (int i = 0; i < MetadataTokens.TableCount; i++) { var table = (TableIndex) i; + if (Array.IndexOf (except, table) >= 0) { + continue; + } Assert.AreEqual (expected.GetTableRowCount (table), actual.GetTableRowCount (table), $"Row count of table '{table}' changed."); } } @@ -700,6 +721,194 @@ public void RewritesAttributesAndLoadedStrings () CollectionAssert.AreEqual (new [] { "b:()V:n_Run" }, ValuesOf (LoadedStrings (peReader, reader, run))); } + [Test] + public void RewritesTrimmableTypeMapKeysAndAliases () + { + var fixture = new JniFixtureBuilder (); + fixture.Metadata.AddCustomAttribute (EntityHandle.AssemblyDefinition, fixture.TypeMapCtor3, + fixture.AttributeBlob ("acme/orig/MyView[1]", "Acme.Proxy, Fixture", "Acme.Target, Fixture")); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + TypeDefinitionHandle aliasHolder = fixture.AddType ("Acme", "AliasHolder", fieldStart, methodStart); + fixture.Metadata.AddCustomAttribute (aliasHolder, fixture.JavaPeerAliasesCtor1, + fixture.StringArrayAttributeBlob ("acme/orig/MyView[0]", "acme/orig/MyView[1]", "unmapped/Type[0]")); + + const string mappingText = "acme.orig.MyView -> a.b.C:\n"; + R8Mapping mapping = Mapping (mappingText); + JniRewriteResult result = Rewrite (fixture.Serialize (), mapping); + AssertReverseScanMatchesRewrite (result.Image, mapping, mappingText); + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + + CollectionAssert.AreEqual (new [] { "a/b/C[1]", "Acme.Proxy, Fixture", "Acme.Target, Fixture" }, + AttributeStringArgs (reader, reader.GetAssemblyDefinition ().GetCustomAttributes (), fixture.TypeMapCtor3)); + CollectionAssert.AreEqual (new [] { "a/b/C[0]", "a/b/C[1]", "unmapped/Type[0]" }, + AttributeStringArrayArg (reader, reader.GetTypeDefinition (aliasHolder).GetCustomAttributes (), fixture.JavaPeerAliasesCtor1)); + } + + [Test] + public void IdentifiesUtf8FieldRvaDataStructurally () + { + var fixture = new JniFixtureBuilder (); + FieldDefinitionHandle nameField = fixture.AddUtf8Field ("onClick"); + FieldDefinitionHandle signatureField = fixture.AddUtf8Field ("(Lacme/orig/Callback;)V"); + FieldDefinitionHandle embeddedNullField = fixture.AddUtf8Field ("onClick\0not-padding"); + + using var peReader = new PEReader (ImmutableArray.Create (fixture.Serialize ())); + MetadataReader reader = peReader.GetMetadataReader (); + FieldRvaTable table = FieldRvaTable.Read (peReader, reader); + + Assert.AreEqual (3, table.Entries.Count); + + FieldRvaEntry name = table.Get (nameField); + Assert.IsNotNull (name); + Assert.IsTrue (name.IsUtf8Datum, "A __utf8_N mapped field must be recognised structurally."); + Assert.AreEqual ("onClick", name.Utf8Value); + + FieldRvaEntry signature = table.Get (signatureField); + Assert.IsNotNull (signature); + Assert.IsTrue (signature.IsUtf8Datum); + Assert.AreEqual ("(Lacme/orig/Callback;)V", signature.Utf8Value); + + FieldRvaEntry embeddedNull = table.Get (embeddedNullField); + Assert.IsNotNull (embeddedNull); + Assert.IsFalse (embeddedNull.IsUtf8Datum, "Non-zero data after the first NUL is not rewrite padding."); + } + + [Test] + public void RewritesUtf8FieldRvaJniNamesAndSignatures () + { + var fixture = new JniFixtureBuilder (); + + FieldDefinitionHandle nameField = fixture.AddUtf8Field ("onClick"); + FieldDefinitionHandle signatureField = fixture.AddUtf8Field ("(Lacme/orig/Callback;)V"); + FieldDefinitionHandle classNameField = fixture.AddUtf8Field ("acme/orig/Callback"); + FieldDefinitionHandle longNameField = fixture.AddUtf8Field ("run"); + + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + int ctorBody = fixture.EmitBody (encoder => { + encoder.OpCode (ILOpCode.Ldarg_0); + encoder.LoadString (fixture.String ("acme/orig/MyView")); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Ret); + }); + fixture.AddVoidMethod (".ctor", ctorBody, + MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName); + + int registerBody = fixture.EmitBody (encoder => { + encoder.OpCode (ILOpCode.Ldsflda); + encoder.Token (nameField); + encoder.OpCode (ILOpCode.Ldsflda); + encoder.Token (signatureField); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Ldsflda); + encoder.Token (longNameField); + encoder.OpCode (ILOpCode.Ldsflda); + encoder.Token (signatureField); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Ret); + }); + fixture.AddVoidMethod ("RegisterNatives", registerBody); + + // A JavaPeerProxy-derived type carries its JNI identity in its .ctor's only ldstr. + fixture.AddType ("Acme.Orig", "MyViewProxy", fieldStart, methodStart, + TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class, fixture.JavaPeerProxyReference); + + byte [] source = fixture.Serialize (); + const string mappingText = + "acme.orig.MyView -> a.b.C:\n" + + " void onClick(acme.orig.Callback) -> a\n" + + " void run(acme.orig.Callback) -> aMuchLongerObfuscatedName\n" + + "acme.orig.Callback -> a.b.Cb:\n"; + R8Mapping mapping = Mapping (mappingText); + JniRewriteResult result = Rewrite (source, mapping); + using (var rewrittenReader = new PEReader (ImmutableArray.Create (result.Image))) { + MetadataReader rewrittenMetadata = rewrittenReader.GetMetadataReader (); + FieldRvaTable rewrittenFields = FieldRvaTable.Read (rewrittenReader, rewrittenMetadata); + Assert.IsTrue (rewrittenFields.Get (nameField)?.IsUtf8Datum, "Rewritten method-name FieldRVA data should remain structurally recognizable."); + Assert.IsTrue (rewrittenFields.Get (signatureField)?.IsUtf8Datum, "Rewritten signature FieldRVA data should remain structurally recognizable."); + } + AssertReverseScanMatchesRewrite (result.Image, mapping, mappingText); + + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + + Assert.AreEqual ("a", ReadUtf8Field (peReader, reader, nameField), "The method name is renamed using the owning proxy's JNI class."); + Assert.AreEqual ("(La/b/Cb;)V", ReadUtf8Field (peReader, reader, signatureField)); + Assert.AreEqual ("a/b/Cb", ReadUtf8Field (peReader, reader, classNameField), "An unreferenced datum that is a known class name is still renamed."); + Assert.AreEqual ("aMuchLongerObfuscatedName", ReadUtf8Field (peReader, reader, longNameField), "A longer datum is relocated into a wider __utf8_N slot."); + + // Growing a datum appends exactly one new sized type; no existing token moves. + using var sourceReader = new PEReader (ImmutableArray.Create (source)); + MetadataReader before = sourceReader.GetMetadataReader (); + Assert.AreEqual (before.GetTableRowCount (TableIndex.TypeDef) + 1, reader.GetTableRowCount (TableIndex.TypeDef)); + AssertTableRowCountsMatch (before, reader, TableIndex.TypeDef, TableIndex.ClassLayout, TableIndex.NestedClass); + } + + static string ReadUtf8Field (PEReader peReader, MetadataReader reader, FieldDefinitionHandle field) + { + FieldDefinition definition = reader.GetFieldDefinition (field); + int rva = definition.GetRelativeVirtualAddress (); + Assert.AreNotEqual (0, rva, "Field has no RVA."); + + PEMemoryBlock block = peReader.GetSectionData (rva); + var bytes = new List (); + BlobReader blob = block.GetReader (0, Math.Min (block.Length, 256)); + for (byte b = blob.ReadByte (); b != 0; b = blob.ReadByte ()) { + bytes.Add (b); + } + return System.Text.Encoding.UTF8.GetString (bytes.ToArray ()); + } + + [Test] + public void FailsWhenASharedUtf8DatumNeedsTwoDifferentNames () + { + var fixture = new JniFixtureBuilder (); + + FieldDefinitionHandle shared = fixture.AddUtf8Field ("go"); + FieldDefinitionHandle signature = fixture.AddUtf8Field ("()V"); + + AddProxy (fixture, "acme/orig/P1", shared, signature); + AddProxy (fixture, "acme/orig/P2", shared, signature); + + var exception = Assert.Throws (() => Rewrite (fixture.Serialize (), Mapping ( + "acme.orig.P1 -> a.b.P1:\n" + + " void go() -> z\n" + + "acme.orig.P2 -> a.b.P2:\n" + + " void go() -> q\n"))); + StringAssert.Contains ("shared", exception.Message.ToLowerInvariant ()); + } + + static void AddProxy (JniFixtureBuilder fixture, string jniName, FieldDefinitionHandle nameField, FieldDefinitionHandle signatureField) + { + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + + fixture.AddVoidMethod (".ctor", fixture.EmitBody (encoder => { + encoder.OpCode (ILOpCode.Ldarg_0); + encoder.LoadString (fixture.String (jniName)); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Ret); + }), MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName); + + fixture.AddVoidMethod ("RegisterNatives", fixture.EmitBody (encoder => { + encoder.OpCode (ILOpCode.Ldsflda); + encoder.Token (nameField); + encoder.OpCode (ILOpCode.Ldsflda); + encoder.Token (signatureField); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Ret); + })); + + fixture.AddType ("Acme.Orig", jniName.Replace ('/', '_'), fieldStart, methodStart, + TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class, fixture.JavaPeerProxyReference); + } + [Test] public void SharedLoadedStringGetsOwnerSpecificReplacements () { @@ -1080,6 +1289,74 @@ public void RewrittenAssemblyStillMatchesItsPortablePdb () } } + [Test] + public void PreservesMappedFieldDataThatIsNotAJniDatum () + { + var fixture = new JniFixtureBuilder (); + + // A plain C#-style array initializer blob: not a __utf8_N datum, so it must survive + // byte-for-byte. + var payload = new byte [] { 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04 }; + TypeDefinitionHandle enclosing = fixture.EnsurePrivateImplementationDetails (); + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + TypeDefinitionHandle arrayType = fixture.AddType (null, "__StaticArrayInitTypeSize=8", fieldStart, methodStart, + TypeAttributes.NestedPrivate | TypeAttributes.ExplicitLayout | TypeAttributes.Sealed | TypeAttributes.AnsiClass, + fixture.ValueTypeReference); + fixture.Metadata.AddTypeLayout (arrayType, packingSize: 1, size: (uint) payload.Length); + fixture.Metadata.AddNestedType (arrayType, enclosing); + + var signature = new BlobBuilder (); + new BlobEncoder (signature).FieldSignature ().Type (arrayType, isValueType: true); + int rva = fixture.MappedFieldData.Count; + fixture.MappedFieldData.WriteBytes (payload); + FieldDefinitionHandle dataField = fixture.Metadata.AddFieldDefinition ( + FieldAttributes.Static | FieldAttributes.Assembly | FieldAttributes.HasFieldRVA, + fixture.Metadata.GetOrAddString ("ArrayData"), fixture.Metadata.GetOrAddBlob (signature)); + fixture.Metadata.AddFieldRelativeVirtualAddress (dataField, rva); + + JniRewriteResult result = Rewrite (fixture.Serialize (), Mapping ("acme.orig.Nothing -> a.b.N:\n")); + + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + MetadataReader reader = peReader.GetMetadataReader (); + int newRva = reader.GetFieldDefinition (dataField).GetRelativeVirtualAddress (); + Assert.AreNotEqual (0, newRva); + CollectionAssert.AreEqual (payload, peReader.GetSectionData (newRva).GetReader (0, payload.Length).ReadBytes (payload.Length)); + } + + [Test] + public void RejectsFieldRvaValueTypeWithoutAnExplicitClassLayoutSize () + { + // A mapped value type with no ClassLayout row (or a zero size) cannot be sized safely: + // summing its instance fields would be a guess about the CLR's actual layout, and a + // wrong guess risks truncating - or reading past the end of - the mapped data. The + // rewriter must refuse rather than take that risk. + var fixture = new JniFixtureBuilder (); + + TypeDefinitionHandle enclosing = fixture.EnsurePrivateImplementationDetails (); + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + TypeDefinitionHandle unsizedType = fixture.AddType (null, "__UnsizedBlob", fieldStart, methodStart, + TypeAttributes.NestedPrivate | TypeAttributes.ExplicitLayout | TypeAttributes.Sealed | TypeAttributes.AnsiClass, + fixture.ValueTypeReference); + fixture.Metadata.AddNestedType (unsizedType, enclosing); + // Deliberately no fixture.Metadata.AddTypeLayout (...) call: the type has no + // ClassLayout row at all. + + var signature = new BlobBuilder (); + new BlobEncoder (signature).FieldSignature ().Type (unsizedType, isValueType: true); + int rva = fixture.MappedFieldData.Count; + fixture.MappedFieldData.WriteBytes (new byte [] { 0x01, 0x02, 0x03, 0x04 }); + FieldDefinitionHandle dataField = fixture.Metadata.AddFieldDefinition ( + FieldAttributes.Static | FieldAttributes.Assembly | FieldAttributes.HasFieldRVA, + fixture.Metadata.GetOrAddString ("UnsizedData"), fixture.Metadata.GetOrAddBlob (signature)); + fixture.Metadata.AddFieldRelativeVirtualAddress (dataField, rva); + + byte [] source = fixture.Serialize (); + var ex = Assert.Throws (() => Rewrite (source, Mapping ("acme.orig.Nothing -> a.b.N:\n"))); + StringAssert.Contains ("ClassLayout", ex.Message); + } + [Test] public void RewrittenAssemblyLoadsAndRunsInTheRuntime () { diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs index bccc81e89c7..48bd8706ed3 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/CustomAttributeStringRewriter.cs @@ -11,8 +11,10 @@ namespace Xamarin.Android.Tasks.JniRemapping /// (ECMA-335 II.23.3), leaving the prolog, any other fixed arguments, and every named /// argument byte-for-byte untouched. /// - /// This only supports (and only needs to support) the attributes this task rewrites: - /// Android.Runtime.RegisterAttribute and the Java.Interop.Jni*SignatureAttribute family. + /// This only supports (and only needs to support) the attributes this task rewrites - + /// Android.Runtime.RegisterAttribute, Java.Interop.JniTypeSignatureAttribute, + /// Java.Interop.JniMethodSignatureAttribute, Java.Interop.JniConstructorSignatureAttribute, + /// System.Runtime.InteropServices.TypeMapAttribute, and Java.Interop.JavaPeerAliasesAttribute. /// static class CustomAttributeStringRewriter { @@ -36,7 +38,7 @@ static class CustomAttributeStringRewriter } using var ms = new MemoryStream (originalContent.Length); - ms.Write (originalContent, 0, 2); + ms.Write (originalContent, 0, 2); // Prolog (0x0001), verbatim. int pos = 2; bool changed = false; @@ -48,6 +50,7 @@ static class CustomAttributeStringRewriter int argStart = pos; string? value; if (originalContent [pos] == 0xFF) { + // A "null string" SerString is encoded as a single 0xFF byte (ECMA-335 II.23.3). value = null; pos += 1; } else { @@ -71,6 +74,65 @@ static class CustomAttributeStringRewriter } } + // Remaining fixed arguments and NumNamed/NamedArg tail, copied verbatim. + ms.Write (originalContent, pos, originalContent.Length - pos); + + return changed ? ms.ToArray () : null; + } + + /// + /// Rewrites the elements of the first fixed argument when it is a string[]. + /// All bytes following the array are copied verbatim. + /// + public static byte []? TryRewriteStringArray (byte [] originalContent, Func rewriteElement) + { + if (originalContent.Length < 6) { + throw new JniRewriteException ("Malformed custom attribute value blob: missing string array length."); + } + + using var ms = new MemoryStream (originalContent.Length); + ms.Write (originalContent, 0, 6); // Prolog (uint16) and array length (int32), verbatim. + int count = BitConverter.ToInt32 (originalContent, 2); + if (count < -1) { + throw new JniRewriteException ($"Malformed custom attribute value blob: invalid string array length {count}."); + } + if (count == -1) { + return null; + } + + int pos = 6; + bool changed = false; + for (int i = 0; i < count; i++) { + if (pos >= originalContent.Length) { + throw new JniRewriteException ("Malformed custom attribute value blob: ran out of bytes while reading string array."); + } + + int elementStart = pos; + if (originalContent [pos] == 0xFF) { + pos++; + ms.WriteByte (0xFF); + continue; + } + + int prefixWidth = MetadataEncoding.ReadCompressedInteger (originalContent, pos, out int strByteLength); + pos += prefixWidth + strByteLength; + if (pos > originalContent.Length) { + throw new JniRewriteException ("Malformed custom attribute value blob: string array element extends past the end of the blob."); + } + + string value = Encoding.UTF8.GetString (originalContent, elementStart + prefixWidth, strByteLength); + string? newValue = rewriteElement (value); + if (newValue != null && !string.Equals (newValue, value, StringComparison.Ordinal)) { + changed = true; + byte [] utf8 = Encoding.UTF8.GetBytes (newValue); + byte [] prefix = MetadataEncoding.EncodeCompressedInteger (utf8.Length); + ms.Write (prefix, 0, prefix.Length); + ms.Write (utf8, 0, utf8.Length); + } else { + ms.Write (originalContent, elementStart, pos - elementStart); + } + } + ms.Write (originalContent, pos, originalContent.Length - pos); return changed ? ms.ToArray () : null; } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniAssemblyRewriter.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniAssemblyRewriter.cs index 0253b5aed5e..2cdef5eae57 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniAssemblyRewriter.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniAssemblyRewriter.cs @@ -22,9 +22,10 @@ public JniRewriteResult (byte [] image, int replacementCount, bool strongNameSig } /// - /// Rewrites JNI names embedded in Android.Runtime.RegisterAttribute, - /// the Java.Interop.Jni*SignatureAttribute family, and generated - /// JniPeerMembers/RegisterNatives ldstr strings according to an R8 mapping. + /// Rewrites the JNI names embedded in an assembly - Android.Runtime.RegisterAttribute, + /// the Java.Interop.Jni*SignatureAttribute family, the JniPeerMembers/RegisterNatives + /// ldstr strings, and the generated null-terminated UTF-8 JNI data stored in + /// FieldRVA - according to an R8 mapping. /// /// The rewrite runs in two passes. The first scans the source into an exact plan; the second /// reconstructs the whole assembly with MetadataBuilder, cloning every table row in its @@ -42,13 +43,15 @@ public static JniRewriteResult Rewrite (byte [] sourceImage, R8Mapping mapping, } MetadataReader reader = peReader.GetMetadataReader (); - JniRewritePlan plan = new JniRewritePlanner (peReader, reader, mapping, log).CreatePlan (); + FieldRvaTable fieldRvaTable = FieldRvaTable.Read (peReader, reader); + + JniRewritePlan plan = new JniRewritePlanner (peReader, reader, mapping, fieldRvaTable, log).CreatePlan (); if (plan.ReplacementCount == 0) { return new JniRewriteResult (sourceImage, 0, strongNameSignatureCleared: false); } - FieldRvaTable fieldRvaTable = FieldRvaTable.Read (peReader, reader); AssemblyRebuildResult rebuilt = new AssemblyRebuilder (peReader, reader, plan, fieldRvaTable).Build (); + return new JniRewriteResult (rebuilt.Image, plan.ReplacementCount, rebuilt.StrongNameSignatureCleared); } @@ -64,6 +67,9 @@ public static void ScanRewrittenAssembly (byte [] sourceImage, R8Mapping mapping } public static void ScanRewrittenAssembly (PEReader peReader, MetadataReader reader, R8Mapping mapping, TaskLoggingHelper log) - => new JniRewritePlanner (peReader, reader, mapping.CreateReverseMapping (), log).CreatePlan (); + { + FieldRvaTable fieldRvaTable = FieldRvaTable.Read (peReader, reader); + new JniRewritePlanner (peReader, reader, mapping.CreateReverseMapping (), fieldRvaTable, log).CreatePlan (); + } } } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs index 45f1b0371ea..2df3f778fc0 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Reflection; using System.Reflection.Metadata; using System.Reflection.Metadata.Ecma335; using System.Reflection.PortableExecutable; @@ -22,23 +23,52 @@ sealed class JniRewritePlanner const string JniMethodSignatureAttributeFullName = "Java.Interop.JniMethodSignatureAttribute"; const string JniConstructorSignatureAttributeFullName = "Java.Interop.JniConstructorSignatureAttribute"; const string JniEnvironmentFullName = "Android.Runtime.JNIEnv"; + const string JavaPeerAliasesAttributeFullName = "Java.Interop.JavaPeerAliasesAttribute"; + const string TypeMapAttributeFullName = "System.Runtime.InteropServices.TypeMapAttribute`1"; + + const string JavaPeerProxyNamespace = "Java.Interop"; + const string JavaPeerProxyName = "JavaPeerProxy"; + + enum Utf8Role + { + Unknown, + MethodName, + MethodSignature, + } + + readonly struct Utf8Use + { + public Utf8Role Role { get; } + public string? OwnerJniName { get; } + public string? PairedSignature { get; } + + public Utf8Use (Utf8Role role, string? ownerJniName, string? pairedSignature) + { + Role = role; + OwnerJniName = ownerJniName; + PairedSignature = pairedSignature; + } + } readonly PEReader peReader; readonly MetadataReader reader; readonly IJniNameMapping mapping; readonly R8Mapping? forwardMapping; + readonly FieldRvaTable fieldRvaTable; readonly TaskLoggingHelper log; readonly Func renameClass; readonly Dictionary ownerJniNameCache = new (); readonly Dictionary> staticJniClassAssignments = new (StringComparer.Ordinal); readonly HashSet warnedUnsafeLookupSources = new (StringComparer.Ordinal); + readonly Dictionary> utf8Uses = new (); - public JniRewritePlanner (PEReader peReader, MetadataReader reader, IJniNameMapping mapping, TaskLoggingHelper log) + public JniRewritePlanner (PEReader peReader, MetadataReader reader, IJniNameMapping mapping, FieldRvaTable fieldRvaTable, TaskLoggingHelper log) { this.peReader = peReader; this.reader = reader; this.mapping = mapping; forwardMapping = mapping as R8Mapping; + this.fieldRvaTable = fieldRvaTable; this.log = log; renameClass = className => mapping.TryMapClass (className, out string renamed) ? renamed : null; } @@ -47,9 +77,13 @@ public JniRewritePlan CreatePlan () { IndexStaticJniClassAssignments (); var plan = new JniRewritePlan (); + + PlanAssemblyAttributes (plan); foreach (TypeDefinitionHandle typeHandle in reader.TypeDefinitions) { PlanType (plan, typeHandle); } + + PlanUtf8FieldData (plan); return plan; } @@ -58,6 +92,7 @@ void PlanType (JniRewritePlan plan, TypeDefinitionHandle typeHandle) TypeDefinition typeDef = reader.GetTypeDefinition (typeHandle); string? ownerJniName = ResolveOwnerJniName (typeHandle); + PlanJavaPeerAliasesAttributes (plan, typeDef.GetCustomAttributes ()); PlanTypeLevelAttributes (plan, typeDef, ownerJniName); foreach (MethodDefinitionHandle methodHandle in typeDef.GetMethods ()) { @@ -78,9 +113,68 @@ void PlanType (JniRewritePlan plan, TypeDefinitionHandle typeHandle) } } + void PlanAssemblyAttributes (JniRewritePlan plan) + { + foreach (CustomAttributeHandle caHandle in reader.GetAssemblyDefinition ().GetCustomAttributes ()) { + CustomAttribute ca = reader.GetCustomAttribute (caHandle); + if (reader.GetCustomAttributeFullName (ca, log) != TypeMapAttributeFullName) { + continue; + } + + // TypeMapAttribute's first argument is the JNI map key. Its following + // System.Type arguments are also SerStrings, but must remain unchanged. + PlanCustomAttributeRewrite (plan, caHandle, ca, fixedArgCount: 1, + (i, value) => value != null ? TryRewriteTypeMapKey (value) : null); + } + } + + void PlanJavaPeerAliasesAttributes (JniRewritePlan plan, CustomAttributeHandleCollection attributes) + { + foreach (CustomAttributeHandle caHandle in attributes) { + CustomAttribute ca = reader.GetCustomAttribute (caHandle); + if (reader.GetCustomAttributeFullName (ca, log) != JavaPeerAliasesAttributeFullName) { + continue; + } + + BlobReader blobReader = reader.GetBlobReader (ca.Value); + byte [] originalContent = blobReader.ReadBytes (blobReader.Length); + byte []? newContent = CustomAttributeStringRewriter.TryRewriteStringArray (originalContent, TryRewriteTypeMapKey); + if (newContent != null) { + plan.AddCustomAttributeBlob (caHandle, newContent); + } + } + } + + string? TryRewriteTypeMapKey (string value) + { + int suffixStart = value.LastIndexOf ('['); + string suffix = ""; + string jniName = value; + if (suffixStart > 0 && value [value.Length - 1] == ']' && IsDecimalIndex (value, suffixStart + 1, value.Length - 1)) { + suffix = value.Substring (suffixStart); + jniName = value.Substring (0, suffixStart); + } + + return mapping.TryMapClass (jniName, out string renamed) ? renamed + suffix : null; + } + + static bool IsDecimalIndex (string value, int start, int end) + { + if (start == end) { + return false; + } + for (int i = start; i < end; i++) { + if (value [i] < '0' || value [i] > '9') { + return false; + } + } + return true; + } + /// - /// Resolves the JNI class name that owns a type from its own Register/JniTypeSignature - /// argument or, recursively, its enclosing type. + /// Resolves the JNI class name that "owns" a type: its own Register/JniTypeSignature + /// argument, the JNI name a generated JavaPeerProxy passes to its base constructor, + /// or (recursively) its enclosing type's. /// string? ResolveOwnerJniName (TypeDefinitionHandle typeHandle) { @@ -88,10 +182,11 @@ void PlanType (JniRewritePlan plan, TypeDefinitionHandle typeHandle) return cached; } + // Guard against a pathological/cyclical nesting chain while resolving. ownerJniNameCache [typeHandle] = null; TypeDefinition typeDef = reader.GetTypeDefinition (typeHandle); - string? result = TryGetTypeLevelJniName (typeDef); + string? result = TryGetTypeLevelJniName (typeDef) ?? TryGetJavaPeerProxyJniName (typeDef); if (result == null) { TypeDefinitionHandle declaring = typeDef.GetDeclaringType (); if (!declaring.IsNil) { @@ -120,6 +215,67 @@ void PlanType (JniRewritePlan plan, TypeDefinitionHandle typeHandle) return null; } + /// + /// The trimmable typemap generator emits one JavaPeerProxy subclass per Java peer + /// whose parameterless constructor passes the peer's JNI name to the base constructor as + /// its only ldstr. That is the type's JNI identity. + /// + string? TryGetJavaPeerProxyJniName (TypeDefinition typeDef) + { + if (!IsJavaPeerProxy (typeDef.BaseType)) { + return null; + } + + foreach (MethodDefinitionHandle methodHandle in typeDef.GetMethods ()) { + MethodDefinition method = reader.GetMethodDefinition (methodHandle); + if ((method.Attributes & MethodAttributes.RTSpecialName) == 0 || reader.GetString (method.Name) != ".ctor") { + continue; + } + if (method.RelativeVirtualAddress == 0) { + continue; + } + + string? found = null; + bool ambiguous = false; + byte [] il = GetILBytes (method); + IlInstructionScanner.Walk (il, (code, _, operandOffset, _) => { + if (code != (ushort) ILOpCode.Ldstr) { + return; + } + string value = ReadUserString (il, operandOffset); + if (found != null && found != value) { + ambiguous = true; + } + found ??= value; + }); + + if (!ambiguous && found != null && found.Length > 0) { + return found; + } + } + + return null; + } + + bool IsJavaPeerProxy (EntityHandle baseType) + { + if (baseType.IsNil) { + return false; + } + + if (baseType.Kind == HandleKind.TypeReference) { + TypeReference typeRef = reader.GetTypeReference ((TypeReferenceHandle) baseType); + return reader.GetString (typeRef.Name) == JavaPeerProxyName && reader.GetString (typeRef.Namespace) == JavaPeerProxyNamespace; + } + + if (baseType.Kind == HandleKind.TypeDefinition) { + TypeDefinition typeDef = reader.GetTypeDefinition ((TypeDefinitionHandle) baseType); + return reader.GetString (typeDef.Name) == JavaPeerProxyName && reader.GetString (typeDef.Namespace) == JavaPeerProxyNamespace; + } + + return false; + } + void PlanTypeLevelAttributes (JniRewritePlan plan, TypeDefinition typeDef, string? ownerJniName) { if (ownerJniName == null || !mapping.TryMapClass (ownerJniName, out string renamedClass)) { @@ -265,6 +421,7 @@ void PlanMethodBody (JniRewritePlan plan, MethodDefinitionHandle methodHandle, s IlInstructionScanner.Walk (il, (code, instructionOffset, operandOffset, operandSize) => instructions.Add (new IlInstruction (code, instructionOffset, operandOffset, operandSize))); HashSet controlFlowEntries = GetControlFlowEntryOffsets (body, il, instructions); + FieldDefinitionHandle pendingUtf8Name = default; for (int i = 0; i < instructions.Count; i++) { IlInstruction instruction = instructions [i]; @@ -277,6 +434,38 @@ void PlanMethodBody (JniRewritePlan plan, MethodDefinitionHandle methodHandle, s } else if (TryGetJniLookupKind (il, instruction, out bool isField)) { PlanLegacyJniLookup (plan, methodHandle, il, instructions, controlFlowEntries, i, isField); } + + if (instruction.Code != (ushort) ILOpCode.Ldsflda && instruction.Code != (ushort) ILOpCode.Ldsfld) { + pendingUtf8Name = default; + continue; + } + + FieldDefinitionHandle field = TryGetUtf8Field (il, instruction.OperandOffset); + if (field.IsNil) { + pendingUtf8Name = default; + continue; + } + + // The typemap generator emits `ldsflda ; ldsflda ` pairs when + // filling in a JniNativeMethod for RegisterNatives; that adjacency is what makes + // an otherwise ambiguous bare method name resolvable against the owning class. + if (pendingUtf8Name.IsNil) { + pendingUtf8Name = field; + continue; + } + + string? signature = GetUtf8Value (field); + if (signature != null && JniDescriptorText.IsValidMethodDescriptor (signature)) { + RecordUtf8Use (pendingUtf8Name, new Utf8Use (Utf8Role.MethodName, ownerJniName, signature)); + RecordUtf8Use (field, new Utf8Use (Utf8Role.MethodSignature, ownerJniName, null)); + } else { + RecordUtf8Use (pendingUtf8Name, new Utf8Use (Utf8Role.Unknown, null, null)); + RecordUtf8Use (field, new Utf8Use (Utf8Role.Unknown, null, null)); + } + pendingUtf8Name = default; + } + if (!pendingUtf8Name.IsNil) { + RecordUtf8Use (pendingUtf8Name, new Utf8Use (Utf8Role.Unknown, null, null)); } } @@ -768,6 +957,89 @@ static bool IsBareMemberName (string value) return true; } + void RecordUtf8Use (FieldDefinitionHandle field, Utf8Use use) + { + if (!utf8Uses.TryGetValue (field, out var uses)) { + utf8Uses [field] = uses = new List (); + } + uses.Add (use); + } + + FieldDefinitionHandle TryGetUtf8Field (byte [] il, int operandOffset) + { + uint token = IlInstructionScanner.ReadUInt32 (il, operandOffset); + if ((token & 0xFF000000) != 0x04000000) { + return default; // Not a FieldDefinition token. + } + + var handle = MetadataTokens.FieldDefinitionHandle ((int) (token & 0x00FFFFFF)); + FieldRvaEntry? entry = fieldRvaTable.Get (handle); + return entry != null && entry.IsUtf8Datum ? handle : default; + } + + string? GetUtf8Value (FieldDefinitionHandle field) => fieldRvaTable.Get (field)?.Utf8Value; + + void PlanUtf8FieldData (JniRewritePlan plan) + { + foreach (FieldRvaEntry entry in fieldRvaTable.Entries) { + string? value = entry.Utf8Value; + if (value == null) { + continue; + } + + string? resolved = null; + foreach (Utf8Use use in GetUses (entry.Field)) { + string? candidate = ComputeNewUtf8Value (value, use); + if (candidate == null) { + continue; + } + if (resolved != null && resolved != candidate) { + throw new JniRewriteException ( + $"The mapped UTF-8 JNI datum '{value}' is shared by more than one Java class, but the mapping renames it to both " + + $"'{resolved}' and '{candidate}'. Splitting a shared '{FieldRvaTable.Utf8FieldNamePrefix}' field would move metadata tokens, which this rewriter does not do."); + } + resolved ??= candidate; + } + + if (resolved != null && resolved != value) { + plan.AddUtf8FieldValue (entry.Field, resolved); + } + } + } + + IEnumerable GetUses (FieldDefinitionHandle field) + { + if (utf8Uses.TryGetValue (field, out var uses)) { + return uses; + } + return new [] { new Utf8Use (Utf8Role.Unknown, null, null) }; + } + + string? ComputeNewUtf8Value (string value, Utf8Use use) + { + if (use.Role == Utf8Role.MethodName && use.OwnerJniName != null && use.PairedSignature != null) { + JniDescriptorText.MethodDescriptorToJavaTypes (use.PairedSignature, out var javaParams, out string javaReturnType); + string mappingName = R8Mapping.JniMemberNameToMappingName (value); + return mapping.TryMapMethod (use.OwnerJniName, mappingName, javaParams, javaReturnType, out string renamed) ? renamed : null; + } + + if (JniDescriptorText.IsValidMethodDescriptor (value) || JniDescriptorText.IsValidFieldDescriptor (value)) { + return JniDescriptorText.TryRewriteDescriptor (value, renameClass, out string rewritten) ? rewritten : null; + } + + if (use.Role == Utf8Role.Unknown && mapping.TryMapClass (value, out string renamedClass)) { + return renamedClass; + } + + return null; + } + + byte [] GetILBytes (MethodDefinition method) + { + MethodBodyBlock body = peReader.GetMethodBody (method.RelativeVirtualAddress); + return body.GetILBytes () ?? []; + } + string ReadUserString (byte [] il, int operandOffset) { uint token = IlInstructionScanner.ReadUInt32 (il, operandOffset); diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/MetadataExtensions.cs b/src/Xamarin.Android.Build.Tasks/Utilities/MetadataExtensions.cs index 75427f3aa2a..cdea0e8e457 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/MetadataExtensions.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/MetadataExtensions.cs @@ -24,9 +24,21 @@ public static class MetadataExtensions var type = reader.GetTypeSpecification ((TypeSpecificationHandle)ctor.Parent); BlobReader blobReader = reader.GetBlobReader (type.Signature); SignatureTypeCode typeCode = blobReader.ReadSignatureTypeCode (); + if (typeCode != SignatureTypeCode.GenericTypeInstance) { + log.LogDebugMessage ($"Unsupported TypeSpecification signature: {typeCode}"); + return null; + } + blobReader.ReadByte (); // SignatureTypeKind.Class or SignatureTypeKind.ValueType. EntityHandle typeHandle = blobReader.ReadTypeHandle (); - TypeReference typeRef = reader.GetTypeReference ((TypeReferenceHandle)typeHandle); - return reader.GetString (typeRef.Namespace) + "." + reader.GetString (typeRef.Name); + if (typeHandle.Kind == HandleKind.TypeReference) { + TypeReference typeRef = reader.GetTypeReference ((TypeReferenceHandle)typeHandle); + return reader.GetString (typeRef.Namespace) + "." + reader.GetString (typeRef.Name); + } else if (typeHandle.Kind == HandleKind.TypeDefinition) { + TypeDefinition typeDef = reader.GetTypeDefinition ((TypeDefinitionHandle)typeHandle); + return reader.GetString (typeDef.Namespace) + "." + reader.GetString (typeDef.Name); + } + log.LogDebugMessage ($"Unsupported generic type handle kind: {typeHandle.Kind}"); + return null; } else { log.LogDebugMessage ($"Unsupported EntityHandle.Kind: {ctor.Parent.Kind}"); return null; From 520236a88b69fa4bab20c07cdc56c15dbebffc4d Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 21:09:27 +0200 Subject: [PATCH 07/21] Handle shared FieldRVA names conservatively Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JniRemapping/JniAssemblyRewriterTests.cs | 76 ++++++++++++++++++- .../JniRemapping/JniRewritePlanner.cs | 22 +++--- 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs index 5fa72f55932..9a22476a216 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs @@ -783,7 +783,6 @@ public void RewritesUtf8FieldRvaJniNamesAndSignatures () FieldDefinitionHandle nameField = fixture.AddUtf8Field ("onClick"); FieldDefinitionHandle signatureField = fixture.AddUtf8Field ("(Lacme/orig/Callback;)V"); - FieldDefinitionHandle classNameField = fixture.AddUtf8Field ("acme/orig/Callback"); FieldDefinitionHandle longNameField = fixture.AddUtf8Field ("run"); int fieldStart = fixture.NextFieldRid; @@ -839,7 +838,6 @@ public void RewritesUtf8FieldRvaJniNamesAndSignatures () Assert.AreEqual ("a", ReadUtf8Field (peReader, reader, nameField), "The method name is renamed using the owning proxy's JNI class."); Assert.AreEqual ("(La/b/Cb;)V", ReadUtf8Field (peReader, reader, signatureField)); - Assert.AreEqual ("a/b/Cb", ReadUtf8Field (peReader, reader, classNameField), "An unreferenced datum that is a known class name is still renamed."); Assert.AreEqual ("aMuchLongerObfuscatedName", ReadUtf8Field (peReader, reader, longNameField), "A longer datum is relocated into a wider __utf8_N slot."); // Growing a datum appends exactly one new sized type; no existing token moves. @@ -883,6 +881,62 @@ public void FailsWhenASharedUtf8DatumNeedsTwoDifferentNames () StringAssert.Contains ("shared", exception.Message.ToLowerInvariant ()); } + [Test] + public void FailsWhenASharedUtf8DatumMustRemainUnmappedForOneProxy () + { + var fixture = new JniFixtureBuilder (); + + FieldDefinitionHandle shared = fixture.AddUtf8Field ("go"); + FieldDefinitionHandle signature = fixture.AddUtf8Field ("()V"); + + AddProxy (fixture, "acme/orig/P1", shared, signature); + AddProxy (fixture, "acme/orig/P2", shared, signature); + + var exception = Assert.Throws (() => Rewrite (fixture.Serialize (), Mapping ( + "acme.orig.P1 -> a.b.P1:\n" + + " void go() -> z\n" + + "acme.orig.P2 -> a.b.P2:\n"))); + StringAssert.Contains ("shared", exception.Message.ToLowerInvariant ()); + StringAssert.Contains ("original value", exception.Message); + StringAssert.Contains ("'go'", exception.Message); + StringAssert.Contains ("'z'", exception.Message); + } + + [Test] + public void FailsWhenASharedUtf8DatumHasAnUnresolvedOwner () + { + var fixture = new JniFixtureBuilder (); + + FieldDefinitionHandle shared = fixture.AddUtf8Field ("go"); + FieldDefinitionHandle signature = fixture.AddUtf8Field ("()V"); + + AddProxy (fixture, "acme/orig/P1", shared, signature); + AddRegistrationTypeWithoutJniOwner (fixture, shared, signature); + + var exception = Assert.Throws (() => Rewrite (fixture.Serialize (), Mapping ( + "acme.orig.P1 -> a.b.P1:\n" + + " void go() -> z\n"))); + StringAssert.Contains ("shared", exception.Message.ToLowerInvariant ()); + StringAssert.Contains ("original value", exception.Message); + } + + [Test] + public void PreservesUnreferencedUtf8DatumThatMatchesAMappedClass () + { + var fixture = new JniFixtureBuilder (); + FieldDefinitionHandle field = fixture.AddUtf8Field ("acme/orig/Callback"); + byte [] image = fixture.Serialize (); + R8Mapping mapping = Mapping ("acme.orig.Callback -> a.b.Cb:\n"); + + JniRewriteResult result = Rewrite (image, mapping); + + Assert.AreSame (image, result.Image); + Assert.AreEqual (0, result.ReplacementCount); + CollectionAssert.IsEmpty (mapping.AccessedEntries); + using var peReader = new PEReader (ImmutableArray.Create (result.Image)); + Assert.AreEqual ("acme/orig/Callback", ReadUtf8Field (peReader, peReader.GetMetadataReader (), field)); + } + static void AddProxy (JniFixtureBuilder fixture, string jniName, FieldDefinitionHandle nameField, FieldDefinitionHandle signatureField) { int fieldStart = fixture.NextFieldRid; @@ -909,6 +963,24 @@ static void AddProxy (JniFixtureBuilder fixture, string jniName, FieldDefinition TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class, fixture.JavaPeerProxyReference); } + static void AddRegistrationTypeWithoutJniOwner (JniFixtureBuilder fixture, FieldDefinitionHandle nameField, FieldDefinitionHandle signatureField) + { + int fieldStart = fixture.NextFieldRid; + int methodStart = fixture.NextMethodRid; + + fixture.AddVoidMethod ("RegisterNatives", fixture.EmitBody (encoder => { + encoder.OpCode (ILOpCode.Ldsflda); + encoder.Token (nameField); + encoder.OpCode (ILOpCode.Ldsflda); + encoder.Token (signatureField); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Pop); + encoder.OpCode (ILOpCode.Ret); + })); + + fixture.AddType ("Acme.Orig", "UnknownOwner", fieldStart, methodStart); + } + [Test] public void SharedLoadedStringGetsOwnerSpecificReplacements () { diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs index 2df3f778fc0..dc8c7999663 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniRewritePlanner.cs @@ -995,8 +995,9 @@ void PlanUtf8FieldData (JniRewritePlan plan) } if (resolved != null && resolved != candidate) { throw new JniRewriteException ( - $"The mapped UTF-8 JNI datum '{value}' is shared by more than one Java class, but the mapping renames it to both " + - $"'{resolved}' and '{candidate}'. Splitting a shared '{FieldRvaTable.Utf8FieldNamePrefix}' field would move metadata tokens, which this rewriter does not do."); + $"The UTF-8 JNI datum '{value}' is shared by uses that require incompatible values '{resolved}' and '{candidate}'. " + + $"At least one use may require the original value because its owning Java class or member mapping could not be resolved. " + + $"Splitting a shared '{FieldRvaTable.Utf8FieldNamePrefix}' field would move metadata tokens, which this rewriter does not do."); } resolved ??= candidate; } @@ -1017,20 +1018,21 @@ IEnumerable GetUses (FieldDefinitionHandle field) string? ComputeNewUtf8Value (string value, Utf8Use use) { - if (use.Role == Utf8Role.MethodName && use.OwnerJniName != null && use.PairedSignature != null) { - JniDescriptorText.MethodDescriptorToJavaTypes (use.PairedSignature, out var javaParams, out string javaReturnType); - string mappingName = R8Mapping.JniMemberNameToMappingName (value); - return mapping.TryMapMethod (use.OwnerJniName, mappingName, javaParams, javaReturnType, out string renamed) ? renamed : null; + if (use.Role == Utf8Role.MethodName) { + if (use.OwnerJniName != null && use.PairedSignature != null) { + JniDescriptorText.MethodDescriptorToJavaTypes (use.PairedSignature, out var javaParams, out string javaReturnType); + string mappingName = R8Mapping.JniMemberNameToMappingName (value); + if (mapping.TryMapMethod (use.OwnerJniName, mappingName, javaParams, javaReturnType, out string renamed)) { + return renamed; + } + } + return value; } if (JniDescriptorText.IsValidMethodDescriptor (value) || JniDescriptorText.IsValidFieldDescriptor (value)) { return JniDescriptorText.TryRewriteDescriptor (value, renameClass, out string rewritten) ? rewritten : null; } - if (use.Role == Utf8Role.Unknown && mapping.TryMapClass (value, out string renamedClass)) { - return renamedClass; - } - return null; } From 3eeeaaa1c7ba6a357efa6e3d1b47f0d5fc4c009a Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 2 Sep 2026 00:22:41 +0200 Subject: [PATCH 08/21] Avoid sharing owner-specific JNI method names Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Generator/PEAssemblyBuilder.cs | 72 ++++++++--- .../Generator/TypeMapAssemblyEmitter.cs | 30 ++++- .../JniRemapping/JniAssemblyRewriterTests.cs | 80 ++++++++++++ .../TypeMapAssemblyGeneratorTests.cs | 117 ++++++++++++++---- 4 files changed, 254 insertions(+), 45 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PEAssemblyBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PEAssemblyBuilder.cs index 6434c515e27..2a0ba4da1c5 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PEAssemblyBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PEAssemblyBuilder.cs @@ -42,9 +42,10 @@ sealed class PEAssemblyBuilder // Avoids creating duplicate __utf8_N types when multiple fields share the same size. readonly Dictionary _sizedTypeCache = new (); - // Deduplication cache for UTF-8 string RVA fields. Strings like "()V" that repeat across - // many proxy types are stored once and shared via the same FieldDefinitionHandle. - readonly Dictionary _utf8FieldCache = new (StringComparer.Ordinal); + // JNI signatures are owner-independent and can safely share one RVA field. JNI method names + // are owner-specific after R8 rewriting, so each registration receives its own field. + readonly Dictionary _sharedUtf8FieldCache = new (StringComparer.Ordinal); + readonly Dictionary> _uniqueUtf8FieldCache = new (StringComparer.Ordinal); TypeDefinitionHandle _privateImplDetailsType; int _utf8FieldCounter; @@ -273,31 +274,57 @@ TypeReferenceHandle MakeTypeRefForManagedName (EntityHandle scope, string manage } /// - /// Emits deduplicated RVA fields containing the supplied null-terminated UTF-8 strings. + /// Emits RVA fields containing the supplied null-terminated UTF-8 strings. + /// are deduplicated, while every occurrence in + /// receives a separate field. /// Fields are grouped by size so each group is emitted contiguously on its sized helper /// type before any consuming types are emitted. /// - public void PrepareUtf8Fields (IEnumerable values) + public void PrepareUtf8Fields (IEnumerable sharedValues, IEnumerable uniqueValues) { - var valuesBySize = new SortedDictionary> (); - foreach (string value in values) { + var sharedValuesBySize = new SortedDictionary> (); + foreach (string value in sharedValues) { int size = System.Text.Encoding.UTF8.GetByteCount (value) + 1; - if (!valuesBySize.TryGetValue (size, out var valuesForSize)) { + if (!sharedValuesBySize.TryGetValue (size, out var valuesForSize)) { valuesForSize = new SortedSet (StringComparer.Ordinal); - valuesBySize.Add (size, valuesForSize); + sharedValuesBySize.Add (size, valuesForSize); } valuesForSize.Add (value); } - foreach (var group in valuesBySize) { - var sizedType = GetOrCreateSizedType (group.Key); - foreach (string value in group.Value) { - AddUtf8Field (value, sizedType); + var uniqueValuesBySize = new SortedDictionary> (); + foreach (string value in uniqueValues) { + int size = System.Text.Encoding.UTF8.GetByteCount (value) + 1; + if (!uniqueValuesBySize.TryGetValue (size, out var valuesForSize)) { + valuesForSize = new SortedDictionary (StringComparer.Ordinal); + uniqueValuesBySize.Add (size, valuesForSize); + } + valuesForSize.TryGetValue (value, out int count); + valuesForSize [value] = count + 1; + } + + var sizes = new SortedSet (sharedValuesBySize.Keys); + sizes.UnionWith (uniqueValuesBySize.Keys); + foreach (int size in sizes) { + var sizedType = GetOrCreateSizedType (size); + if (sharedValuesBySize.TryGetValue (size, out var sharedForSize)) { + foreach (string value in sharedForSize) { + _sharedUtf8FieldCache.Add (value, AddUtf8Field (value, sizedType)); + } + } + if (uniqueValuesBySize.TryGetValue (size, out var uniqueForSize)) { + foreach (var pair in uniqueForSize) { + var fields = new Queue (pair.Value); + for (int i = 0; i < pair.Value; i++) { + fields.Enqueue (AddUtf8Field (pair.Key, sizedType)); + } + _uniqueUtf8FieldCache.Add (pair.Key, fields); + } } } } - void AddUtf8Field (string value, TypeDefinitionHandle sizedType) + FieldDefinitionHandle AddUtf8Field (string value, TypeDefinitionHandle sizedType) { // Encode to null-terminated UTF-8 (all JNI names/signatures are ASCII). _sigBlob.Clear (); @@ -313,8 +340,7 @@ void AddUtf8Field (string value, TypeDefinitionHandle sizedType) Metadata.GetOrAddBlob (_sigBlob)); Metadata.AddFieldRelativeVirtualAddress (fieldHandle, rva); - - _utf8FieldCache [value] = fieldHandle; + return fieldHandle; } /// @@ -322,13 +348,25 @@ void AddUtf8Field (string value, TypeDefinitionHandle sizedType) /// public FieldDefinitionHandle GetUtf8Field (string value) { - if (_utf8FieldCache.TryGetValue (value, out var existing)) { + if (_sharedUtf8FieldCache.TryGetValue (value, out var existing)) { return existing; } throw new InvalidOperationException ($"UTF-8 field '{value}' was not prepared before type emission."); } + /// + /// Returns and consumes one previously prepared unique UTF-8 RVA field. + /// + public FieldDefinitionHandle GetUniqueUtf8Field (string value) + { + if (_uniqueUtf8FieldCache.TryGetValue (value, out var fields) && fields.Count > 0) { + return fields.Dequeue (); + } + + throw new InvalidOperationException ($"Unique UTF-8 field '{value}' was not prepared before type emission."); + } + void EnsurePrivateImplDetailsType () { if (!_privateImplDetailsType.IsNil) { diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs index 56a160a7914..8dd441ce965 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Reflection; using System.Reflection.Metadata; using System.Reflection.Metadata.Ecma335; @@ -195,7 +196,10 @@ void EmitCore (TypeMapAssemblyData model, bool useSharedTypemapUniverse) } EmitMemberReferences (); - _pe.PrepareUtf8Fields (EnumerateNativeRegistrationStrings (model.ProxyTypes)); + var validRegistrations = EnumerateValidNativeRegistrations (model.ProxyTypes); + _pe.PrepareUtf8Fields ( + validRegistrations.Select (registration => registration.JniSignature), + validRegistrations.Select (registration => registration.JniMethodName)); // Track wrapper targets → handles for RegisterNatives. var wrapperHandles = new Dictionary (); @@ -219,17 +223,30 @@ void EmitCore (TypeMapAssemblyData model, bool useSharedTypemapUniverse) _pe.EmitIgnoresAccessChecksToAttribute (model.IgnoresAccessChecksTo); } - static IEnumerable EnumerateNativeRegistrationStrings (IReadOnlyList proxies) + static List EnumerateValidNativeRegistrations (IReadOnlyList proxies) { + var wrapperTargets = new HashSet (); + foreach (var proxy in proxies) { + foreach (var method in proxy.UcoMethods) { + wrapperTargets.Add (UcoWrapperTargetData.From (proxy, method.WrapperName)); + } + foreach (var constructor in proxy.UcoConstructors) { + wrapperTargets.Add (UcoWrapperTargetData.From (proxy, constructor.WrapperName)); + } + } + + var registrations = new List (); foreach (var proxy in proxies) { if (!proxy.IsAcw) { continue; } foreach (var registration in proxy.NativeRegistrations) { - yield return registration.JniMethodName; - yield return registration.JniSignature; + if (wrapperTargets.Contains (registration.WrapperTarget)) { + registrations.Add (registration); + } } } + return registrations; } static List OrderProxiesForWrapperTargets (IReadOnlyList proxies) @@ -1631,11 +1648,12 @@ void EmitRegisterNatives (JavaPeerProxyData proxy, return; } - // Get the prepared, deduplicated RVA fields for each unique name/signature string. + // Method names are unique per registration because R8 member mappings are owner-specific. + // Signatures remain safely deduplicated because descriptor class mappings are owner-independent. var nameFields = new FieldDefinitionHandle [validRegs.Count]; var sigFields = new FieldDefinitionHandle [validRegs.Count]; for (int i = 0; i < validRegs.Count; i++) { - nameFields [i] = _pe.GetUtf8Field (validRegs [i].Reg.JniMethodName); + nameFields [i] = _pe.GetUniqueUtf8Field (validRegs [i].Reg.JniMethodName); sigFields [i] = _pe.GetUtf8Field (validRegs [i].Reg.JniSignature); } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs index 9a22476a216..53c43739348 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniAssemblyRewriterTests.cs @@ -7,6 +7,7 @@ using System.Reflection.Metadata; using System.Reflection.Metadata.Ecma335; using System.Reflection.PortableExecutable; +using Microsoft.Android.Sdk.TrimmableTypeMap; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using NUnit.Framework; @@ -881,6 +882,85 @@ public void FailsWhenASharedUtf8DatumNeedsTwoDifferentNames () StringAssert.Contains ("shared", exception.Message.ToLowerInvariant ()); } + [Test] + public void RewritesGeneratedTypeMapWithOwnerSpecificMethodNames () + { + byte [] source = GenerateTypeMapWithSharedMethodName (); + var warnings = new List (); + + JniRewriteResult result = Rewrite (source, Mapping ( + "test.First -> a.b.First:\n" + + " void n_Run() -> a\n" + + "test.Second -> a.b.Second:\n" + + " void n_Run() -> b\n"), warnings); + + CollectionAssert.AreEquivalent (new [] { "a", "b", "()V" }, ReadUtf8Values (result.Image)); + CollectionAssert.DoesNotContain (warnings.Select (warning => warning.Code).ToArray (), "XA4326"); + } + + [Test] + public void RewritesGeneratedTypeMapWithMappedAndUnmappedMethodNames () + { + byte [] source = GenerateTypeMapWithSharedMethodName (); + var warnings = new List (); + + JniRewriteResult result = Rewrite (source, Mapping ( + "test.First -> a.b.First:\n" + + " void n_Run() -> a\n" + + "test.Second -> test.Second:\n"), warnings); + + CollectionAssert.AreEquivalent (new [] { "a", "n_Run", "()V" }, ReadUtf8Values (result.Image)); + CollectionAssert.DoesNotContain (warnings.Select (warning => warning.Code).ToArray (), "XA4326"); + } + + static byte [] GenerateTypeMapWithSharedMethodName () + { + var peers = new [] { + CreatePeer ("test/First", "Test.First"), + CreatePeer ("test/Second", "Test.Second"), + }; + using var stream = new MemoryStream (); + new TypeMapAssemblyGenerator (new Version (11, 0, 0, 0)).Generate (peers, stream, "OwnerSpecificNames"); + return stream.ToArray (); + + static JavaPeerInfo CreatePeer (string javaName, string managedName) + { + int separator = managedName.LastIndexOf ('.'); + return new JavaPeerInfo { + JavaName = javaName, + CompatJniName = javaName, + ManagedTypeName = managedName, + ManagedTypeNamespace = managedName.Substring (0, separator), + ManagedTypeShortName = managedName.Substring (separator + 1), + AssemblyName = "TestAsm", + DoNotGenerateAcw = false, + ActivationCtor = new ActivationCtorInfo { + DeclaringTypeName = managedName, + DeclaringAssemblyName = "TestAsm", + Style = ActivationCtorStyle.XamarinAndroid, + }, + MarshalMethods = [ + new MarshalMethodInfo { + JniName = "run", + NativeCallbackName = "n_Run", + JniSignature = "()V", + ManagedMethodName = "Run", + }, + ], + }; + } + } + + static string [] ReadUtf8Values (byte [] image) + { + using var peReader = new PEReader (ImmutableArray.Create (image)); + MetadataReader reader = peReader.GetMetadataReader (); + return FieldRvaTable.Read (peReader, reader).Entries + .Select (entry => entry.Utf8Value) + .OfType () + .ToArray (); + } + [Test] public void FailsWhenASharedUtf8DatumMustRemainUnmappedForOneProxy () { diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs index 48e55b3edc5..422e8d82af5 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs @@ -1219,6 +1219,11 @@ static List ReadCallTokens (byte [] ilBytes) return ReadInlineMethodTokens (ilBytes, 0x28); } + static List ReadLoadStaticFieldAddressTokens (byte [] ilBytes) + { + return ReadInlineMethodTokens (ilBytes, 0x7F); + } + static List ReadInlineMethodTokens (byte [] ilBytes, byte opcode) { var tokens = new List (); @@ -1499,19 +1504,17 @@ public void Generate_ExportProxy_StructuredGenericArgumentThrows () } [Fact] - public void Generate_MultipleAcwProxies_DeduplicatesUtf8Strings () + public void Generate_MultipleAcwProxies_DeduplicatesSignaturesButNotMethodNames () { var peers = ScanFixtures (); - // Get all ACW peers — they likely share signatures like "()V" var acwPeers = peers.Where (p => !p.DoNotGenerateAcw && p.MarshalMethods.Count > 0).ToList (); Assert.True (acwPeers.Count >= 2, "Need at least 2 ACW peers to test deduplication"); + var model = ModelBuilder.Build (acwPeers, "DedupTest.dll", "DedupTest"); using var stream = GenerateAssembly (acwPeers, "DedupTest"); using var pe = new PEReader (stream); var reader = pe.GetMetadataReader (); - // Count fields with HasFieldRVA — these are our UTF-8 RVA fields. - // With deduplication, common strings like "()V" should appear only once. var rvaFields = reader.FieldDefinitions .Select (h => reader.GetFieldDefinition (h)) .Where (f => (f.Attributes & FieldAttributes.HasFieldRVA) != 0) @@ -1522,24 +1525,94 @@ public void Generate_MultipleAcwProxies_DeduplicatesUtf8Strings () Assert.StartsWith ("__utf8_", reader.GetString (declaringType.Name)); }); - // Collect all JNI method names and signatures from the ACW peers - var allStrings = acwPeers - .SelectMany (p => p.MarshalMethods) - .SelectMany (m => new [] { m.JniName, m.JniSignature }) - .ToList (); - var uniqueStrings = allStrings.Distinct ().Count (); - - // With dedup, RVA field count should equal unique string count, not total string count. - // Also include constructor registrations (nctor_*), so use <= for a safe assertion. - Assert.True (rvaFields.Count <= uniqueStrings + acwPeers.Count * 2, - $"Expected at most {uniqueStrings + acwPeers.Count * 2} RVA fields (unique strings + ctor names/sigs), " + - $"but found {rvaFields.Count}. Deduplication may not be working."); - - // The key assertion: fewer RVA fields than total strings means dedup is working - if (allStrings.Count > uniqueStrings) { - Assert.True (rvaFields.Count < allStrings.Count, - $"Expected fewer RVA fields ({rvaFields.Count}) than total strings ({allStrings.Count}) due to deduplication"); - } + var registrations = model.ProxyTypes.SelectMany (proxy => proxy.NativeRegistrations).ToList (); + int expectedFieldCount = registrations.Count + + registrations.Select (registration => registration.JniSignature).Distinct (StringComparer.Ordinal).Count (); + Assert.Equal (expectedFieldCount, rvaFields.Count); + } + + [Fact] + public void Generate_SharedMethodNameUsesDistinctFieldsWhileSignatureRemainsShared () + { + var first = MakeAcwPeer ("test/First", "Test.First", "TestAsm") with { + JavaConstructors = [], + MarshalMethods = [ + new MarshalMethodInfo { + JniName = "run", + NativeCallbackName = "n_Run", + JniSignature = "()V", + ManagedMethodName = "Run", + }, + ], + }; + var second = MakeAcwPeer ("test/Second", "Test.Second", "TestAsm") with { + JavaConstructors = [], + MarshalMethods = [ + new MarshalMethodInfo { + JniName = "run", + NativeCallbackName = "n_Run", + JniSignature = "()V", + ManagedMethodName = "Run", + }, + ], + }; + + using var stream = GenerateAssembly ([first, second], "OwnerSpecificNames"); + using var pe = new PEReader (stream); + var reader = pe.GetMetadataReader (); + + var firstFields = ReadRegisterNativesFieldTokens (pe, reader, "Test_First_Proxy"); + var secondFields = ReadRegisterNativesFieldTokens (pe, reader, "Test_Second_Proxy"); + + Assert.Equal (2, firstFields.Count); + Assert.Equal (2, secondFields.Count); + Assert.NotEqual (firstFields [0], secondFields [0]); + Assert.Equal (firstFields [1], secondFields [1]); + } + + [Fact] + public void Generate_RegistrationWithoutWrapperDoesNotConsumeUtf8Field () + { + var peer = MakeAcwPeer ("test/Valid", "Test.Valid", "TestAsm") with { + JavaConstructors = [], + MarshalMethods = [ + new MarshalMethodInfo { + JniName = "run", + NativeCallbackName = "n_Run", + JniSignature = "()V", + ManagedMethodName = "Run", + }, + ], + }; + var model = ModelBuilder.Build ([peer], "MissingWrapper.dll", "MissingWrapper"); + model.ProxyTypes.Single ().NativeRegistrations.Add (new NativeRegistrationData { + JniMethodName = "n_Missing", + JniSignature = "(I)V", + WrapperMethodName = "missing_uco", + WrapperTarget = new UcoWrapperTargetData { + TypeNamespace = "_TypeMap.Proxies", + TypeName = "Missing_Proxy", + MethodName = "missing_uco", + }, + }); + + using var stream = new MemoryStream (); + new TypeMapAssemblyEmitter (new Version (11, 0, 0, 0)).Emit (model, stream); + stream.Position = 0; + using var pe = new PEReader (stream); + var reader = pe.GetMetadataReader (); + + Assert.Equal (2, reader.GetTableRowCount (TableIndex.FieldRva)); + Assert.Equal (2, ReadRegisterNativesFieldTokens (pe, reader, "Test_Valid_Proxy").Count); + } + + static List ReadRegisterNativesFieldTokens (PEReader pe, MetadataReader reader, string proxyTypeName) + { + var proxy = FindProxyType (reader, proxyTypeName); + var method = reader.GetMethodDefinition (FindMethodDefinition (reader, proxy, "RegisterNatives")); + var ilBytes = pe.GetMethodBody (method.RelativeVirtualAddress).GetILBytes (); + Assert.NotNull (ilBytes); + return ReadLoadStaticFieldAddressTokens (ilBytes); } [Fact] From be9f396ebac097ba5557298bc6aa05cab4ac60c4 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 19:12:54 +0200 Subject: [PATCH 09/21] [r8-obfuscation] Enable R8 JNI name obfuscation for CoreCLR Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Documentation/docs-mobile/TOC.yml | 2 + .../building-apps/build-properties.md | 11 + Documentation/docs-mobile/messages/index.md | 1 + Documentation/docs-mobile/messages/xa4327.md | 42 +++ ...roid.Sdk.TypeMap.Trimmable.CoreCLR.targets | 67 ++++- ...soft.Android.Sdk.TypeMap.Trimmable.targets | 140 +++++++++- .../Properties/Resources.Designer.cs | 117 ++++++++ .../Properties/Resources.resx | 64 +++++ .../Tasks/GenerateProguardConfiguration.cs | 155 ++++++++++- src/Xamarin.Android.Build.Tasks/Tasks/R8.cs | 189 +++++++++++-- .../GenerateProguardConfigurationTests.cs | 222 ++++++++++++++++ .../Tasks/R8Tests.cs | 36 ++- .../TrimmableTypeMapBuildTests.cs | 251 ++++++++++++++++++ .../Xamarin.Android.Common.targets | 8 +- .../Xamarin.Android.D8.targets | 5 + 15 files changed, 1272 insertions(+), 38 deletions(-) create mode 100644 Documentation/docs-mobile/messages/xa4327.md create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateProguardConfigurationTests.cs diff --git a/Documentation/docs-mobile/TOC.yml b/Documentation/docs-mobile/TOC.yml index 80cde18b63c..8f5f6eeef8a 100644 --- a/Documentation/docs-mobile/TOC.yml +++ b/Documentation/docs-mobile/TOC.yml @@ -368,6 +368,8 @@ href: messages/xa4325.md - name: XA4326 href: messages/xa4326.md + - name: XA4327 + href: messages/xa4327.md - name: "XA5xxx: GCC and toolchain" items: - name: "XA5xxx: GCC and toolchain" diff --git a/Documentation/docs-mobile/building-apps/build-properties.md b/Documentation/docs-mobile/building-apps/build-properties.md index 475a57bbbdd..a5fc5014f04 100644 --- a/Documentation/docs-mobile/building-apps/build-properties.md +++ b/Documentation/docs-mobile/building-apps/build-properties.md @@ -468,6 +468,17 @@ removing the existing one(s) and adding your own AOT profiles. This property is `False` by default. +## AndroidEnableR8JniNameObfuscation + +A boolean property that enables R8 obfuscation of Java type, method, and field names +referenced by managed JNI metadata. The build uses an R8-generated mapping to rewrite +managed assemblies before trimming, then applies the same +mapping during the final R8 invocation. + +This property requires `AndroidLinkTool=r8`, +`AndroidTypeMapImplementation=trimmable`, the CoreCLR runtime, and +`PublishTrimmed=true`. +The default value is `False`. ## AndroidEnableRestrictToAttributes diff --git a/Documentation/docs-mobile/messages/index.md b/Documentation/docs-mobile/messages/index.md index 555dba0b2d1..7fb2a66e29a 100644 --- a/Documentation/docs-mobile/messages/index.md +++ b/Documentation/docs-mobile/messages/index.md @@ -256,6 +256,7 @@ Either change the value in the AndroidManifest.xml to match the $(SupportedOSPla + [XA4324](xa4324.md): [{arch}] Unable to delete source file '{file}'. + [XA4325](xa4325.md): Failed to rewrite managed JNI names for R8. {message} + [XA4326](xa4326.md): Unable to safely rewrite a JNI member lookup because its class handle does not have one structurally unambiguous `JNIEnv.FindClass` source. ++ [XA4327](xa4327.md): Failed to validate R8 JNI mapping data. {message} ## XA5xxx: GCC and toolchain diff --git a/Documentation/docs-mobile/messages/xa4327.md b/Documentation/docs-mobile/messages/xa4327.md new file mode 100644 index 00000000000..61179c1aff5 --- /dev/null +++ b/Documentation/docs-mobile/messages/xa4327.md @@ -0,0 +1,42 @@ +--- +title: .NET for Android error XA4327 +description: XA4327 error code +ms.date: 09/01/2026 +f1_keywords: + - "XA4327" +--- + +# .NET for Android error XA4327 + +## Example messages + +``` +error XA4327: Failed to validate R8 JNI mapping data. The R8 JNI rewrite manifest 'obj/Release/net11.0-android/r8-jni-rewrite-manifest.txt' was not found. +``` + +``` +error XA4327: Failed to validate R8 JNI mapping data. The final R8 mapping did not preserve the JNI seed mapping for class 'com/example/MyView'. +``` + +## Issue + +When R8 obfuscates Java type and member names, the build first generates a seed +mapping and rewrites the corresponding names in managed assemblies. The final R8 +invocation must preserve every rewritten name that remains reachable after +trimming. + +This error means that a required mapping or manifest could not be read, a linked +assembly could not be scanned, the final R8 mapping changed a seeded name, or +final R8 removed a JNI entry that remained reachable from managed code. + +## Solution + +Delete the project's `bin/` and `obj/` directories and rebuild to clear stale or +partially written intermediate files. Also review custom ProGuard configuration +for rules that rename or remove the type or member named in the error. + +If the failure persists without custom ProGuard rules, please +[report an issue][report-issue] and include the full XA4327 message, the R8 +mapping files, and, if possible, a project that reproduces the failure. + +[report-issue]: https://github.com/dotnet/android/issues/new/choose diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets index b12f80fdc8f..adf0089ffee 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets @@ -5,10 +5,11 @@ <_TrimmableRuntimeProviderJavaName Condition=" '$(_TrimmableRuntimeProviderJavaName)' == '' ">mono.MonoRuntimeProvider + <_CompileToDalvikDependsOnTargets>$(_CompileToDalvikDependsOnTargets);_GenerateProguardConfiguration - <_GenerateProguardAfterTargets Condition=" '$(_GenerateProguardAfterTargets)' == '' ">ILLink + <_GenerateProguardAfterTargets Condition=" '$(_GenerateProguardAfterTargets)' == '' ">_RunILLink @@ -23,6 +24,57 @@ + + + <_AndroidR8JniRewrittenAssemblyDirectory>$(IntermediateOutputPath)r8-jni-rewritten/ + <_AndroidR8JniRewriteStamp>$(_AndroidStampDirectory)_AndroidRewriteJniNamesBeforeILLink.stamp + + + <_AndroidR8JniOriginalManagedAssembly Remove="@(_AndroidR8JniOriginalManagedAssembly)" /> + <_AndroidR8JniHashedManagedAssembly Remove="@(_AndroidR8JniHashedManagedAssembly)" /> + <_AndroidR8JniRewrittenManagedAssembly Remove="@(_AndroidR8JniRewrittenManagedAssembly)" /> + <_AndroidR8JniExpectedRewriteOutput Remove="@(_AndroidR8JniExpectedRewriteOutput)" /> + <_AndroidR8JniMissingRewriteOutput Remove="@(_AndroidR8JniMissingRewriteOutput)" /> + <_AndroidR8JniOriginalManagedAssembly Include="@(ManagedAssemblyToLink)" /> + + + + + + <_AndroidR8JniRewrittenManagedAssembly Include="@(_AndroidR8JniHashedManagedAssembly->'$(_AndroidR8JniRewrittenAssemblyDirectory)%(Hash)/%(Filename)%(Extension)')" /> + <_AndroidR8JniExpectedRewriteOutput Include="@(_AndroidR8JniRewrittenManagedAssembly);$(_AndroidR8JniRewriteManifest)" /> + <_AndroidR8JniMissingRewriteOutput Include="@(_AndroidR8JniExpectedRewriteOutput)" Condition="!Exists('%(Identity)')" /> + + + + + + + + + + + + + + + <_AndroidR8JniExpectedRewriteOutput Remove="@(_AndroidR8JniExpectedRewriteOutput)" /> + <_AndroidR8JniMissingRewriteOutput Remove="@(_AndroidR8JniMissingRewriteOutput)" /> + + + + Inputs="@(_LinkedAssemblyForProguard);$(_AndroidR8JniSeedMapping);$(_AndroidR8JniRewriteManifest)" + Outputs="$(_ProguardProjectConfiguration);$(_AndroidR8JniReachabilityManifest)"> + OutputFile="$(_ProguardProjectConfiguration)" + R8MappingFile="$(_AndroidR8JniSeedMapping)" + R8RewriteManifestFile="$(_AndroidR8JniRewriteManifest)" + R8ReachabilityManifestFile="$(_AndroidR8JniReachabilityManifest)" /> + + + + + + @@ -32,20 +35,36 @@ <_PostTrimTypeMapJavaFilesList>$(_PostTrimTypeMapJavaBaseOutputDir)typemap/linked-java-files.txt <_PostTrimTypeMapFirstRuntimeIdentifier Condition=" '$(RuntimeIdentifiers)' != '' ">$([System.String]::Copy('$(RuntimeIdentifiers)').Split(';')[0]) <_PostTrimTypeMapFirstRuntimeIdentifier Condition=" '$(_PostTrimTypeMapFirstRuntimeIdentifier)' == '' ">$(RuntimeIdentifier) + <_AndroidR8JniManifestBaseOutputDir>$(IntermediateOutputPath) + <_AndroidR8JniManifestBaseOutputDir Condition=" '$(RuntimeIdentifiers)' != '' and '$(_OuterIntermediateOutputPath)' == '' and '$(RuntimeIdentifier)' == '' ">$(IntermediateOutputPath)$(_PostTrimTypeMapFirstRuntimeIdentifier)/ + <_AndroidR8JniManifestBaseOutputDir Condition=" '$(RuntimeIdentifiers)' != '' and '$(_OuterIntermediateOutputPath)' == '' and '$(RuntimeIdentifier)' != '' and '$(RuntimeIdentifier)' != '$(_PostTrimTypeMapFirstRuntimeIdentifier)' ">$(IntermediateOutputPath)../$(_PostTrimTypeMapFirstRuntimeIdentifier)/ <_TypeMapJavaStubsSourceDirectory Condition=" '$(_TypeMapJavaStubsSourceDirectory)' == '' and '$(_AndroidRuntime)' == 'CoreCLR' and '$(PublishTrimmed)' == 'true' ">$(_PostTrimTypeMapJavaOutputDirectory) + <_TypeMapJavaStubsSourceDirectory Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_TypeMapJavaOutputDirectory) <_TypeMapJavaStubsSourceDirectory Condition=" '$(_TypeMapJavaStubsSourceDirectory)' == '' ">$(_TypeMapJavaOutputDirectory) <_PostTrimTrimmableTypeMapJavaStamp>$(_PostTrimTypeMapJavaBaseOutputDir)stamp/_GeneratePostTrimTrimmableTypeMapJavaSources.stamp + <_AndroidR8JniSeedDirectory Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_TypeMapBaseOutputDir)r8-jni-seed/ + <_AndroidR8JniSeedAcwMap Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)acw-map.txt + <_AndroidR8JniSeedApplicationRegistration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)java/net/dot/android/ApplicationRegistration.java - <_PreTrimTypeMapAcwMapOutputFile Condition=" '$(_AndroidRuntime)' != 'CoreCLR' or '$(PublishTrimmed)' != 'true' ">$(IntermediateOutputPath)acw-map.txt - <_PreTrimTypeMapApplicationRegistrationOutputFile Condition=" '$(_AndroidRuntime)' != 'CoreCLR' or '$(PublishTrimmed)' != 'true' ">$(IntermediateOutputPath)android/src/net/dot/android/ApplicationRegistration.java + <_PreTrimTypeMapAcwMapOutputFile Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedAcwMap) + <_PreTrimTypeMapApplicationRegistrationOutputFile Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedApplicationRegistration) + <_PreTrimTypeMapAcwMapOutputFile Condition=" '$(_PreTrimTypeMapAcwMapOutputFile)' == '' and ('$(_AndroidRuntime)' != 'CoreCLR' or '$(PublishTrimmed)' != 'true') ">$(IntermediateOutputPath)acw-map.txt + <_PreTrimTypeMapApplicationRegistrationOutputFile Condition=" '$(_PreTrimTypeMapApplicationRegistrationOutputFile)' == '' and ('$(_AndroidRuntime)' != 'CoreCLR' or '$(PublishTrimmed)' != 'true') ">$(IntermediateOutputPath)android/src/net/dot/android/ApplicationRegistration.java <_TrimmableTypeMapOutputStamp>$(_TypeMapOutputDirectory)_GenerateTrimmableTypeMap.stamp + <_AndroidR8JniSeedMapping Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)mapping.txt + <_AndroidR8JniRewriteManifest Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniManifestBaseOutputDir)r8-jni-rewrite-manifest.txt + <_AndroidR8JniReachabilityManifest Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniManifestBaseOutputDir)r8-jni-reachability-manifest.txt + <_AndroidR8JniSeedApplicationConfiguration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)acw-keep.cfg + <_AndroidR8JniSeedXamarinConfiguration Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)xamarin.cfg + <_AndroidR8JniSeedJavaClassDirectory Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)classes/ + <_AndroidR8JniSeedJavaStamp Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_AndroidR8JniSeedDirectory)compile-java.stamp <_TrimmableRemoveRegisterFlag>$(_AndroidStampDirectory)_RemoveRegisterAttribute.stamp <_TrimmableRemoveRegisterTarget Condition=" '$(_AndroidRuntime)' == 'CoreCLR' ">_RemoveRegisterAttributeCoreClr <_TrimmableRemoveRegisterTarget Condition=" '$(_AndroidRuntime)' == 'NativeAOT' ">_RemoveRegisterAttributeNativeAot @@ -54,6 +73,7 @@ Both are touched only when their producing target actually runs, so _GenerateJavaStubs stays incremental while still reacting to post-trim JCW regeneration. --> <_TrimmableJavaSourceStamp Condition=" '$(_TrimmableJavaSourceStamp)' == '' and '$(_AndroidRuntime)' == 'CoreCLR' and '$(PublishTrimmed)' == 'true' ">$(_PostTrimTrimmableTypeMapJavaStamp) + <_TrimmableJavaSourceStamp Condition=" '$(_AndroidEnableR8JniNameRewriting)' == 'true' ">$(_TrimmableTypeMapOutputStamp) <_TrimmableJavaSourceStamp Condition=" '$(_TrimmableJavaSourceStamp)' == '' ">$(_TrimmableTypeMapOutputStamp) @@ -67,6 +87,120 @@ + + + <_AndroidR8JniSeedJavaSource Include="$(_TypeMapJavaOutputDirectory)/**/*.java" /> + <_AndroidR8JniSeedJavaSource Include="$(_AndroidR8JniSeedApplicationRegistration)" /> + + + + + + + + + + + + + + + + <_AndroidR8JniSeedClassFile Include="$(_AndroidR8JniSeedJavaClassDirectory)**\*.class" /> + <_AndroidR8JniSeedProguardConfiguration Include="@(_ProguardConfiguration)" /> + <_AndroidR8JniSeedProguardConfiguration Remove="$(IntermediateOutputPath)proguard\proguard_xamarin.cfg;$(_ProguardProjectConfiguration);$(IntermediateOutputPath)proguard\proguard_project_primary.cfg;$(IntermediateOutputPath)aapt_rules.txt" /> + <_AndroidR8JniSeedMapDiagnostics Condition=" '$(AndroidR8IgnoreWarnings)' == 'true' " Include="warning" To="info" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_AndroidR8JniSeedProguardConfiguration Include="$(ProguardConfigFiles)" Condition=" '$(ProguardConfigFiles)' != '' " /> + <_AndroidR8JniSeedProguardConfiguration + Include="@(ProguardConfiguration)" + Condition=" '$(ProguardConfigFiles)' == '' and '%(ProguardConfiguration.AndroidGeneratedProguardConfiguration)' != 'true' " /> <_AndroidR8JniSeedMapDiagnostics Condition=" '$(AndroidR8IgnoreWarnings)' == 'true' " Include="warning" To="info" /> 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 d6b65235290..4690826a9d9 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 @@ -65,8 +65,12 @@ public void GenerateSeedMappingAllowsAcwObfuscation () string applicationConfiguration = Path.Combine (path, "acw-keep.cfg"); string commonConfiguration = Path.Combine (path, "xamarin.cfg"); string customConfiguration = Path.Combine (path, "custom.cfg"); + string aarConfiguration = Path.Combine (path, "aar-proguard.txt"); File.WriteAllText (acwMap, "Managed.Peer;com.example.Peer"); File.WriteAllText (customConfiguration, "-dontwarn com.example.**"); + File.WriteAllText (aarConfiguration, "-dontwarn com.example.library.**"); + var aarConfigurationItem = new TaskItem (aarConfiguration); + aarConfigurationItem.SetMetadata ("OriginalFile", Path.Combine (path, "library.aar")); var task = new R8TestTask { BuildEngine = new MockBuildEngine (TestContext.Out), @@ -77,7 +81,7 @@ public void GenerateSeedMappingAllowsAcwObfuscation () ProguardGeneratedApplicationConfiguration = applicationConfiguration, ProguardCommonXamarinConfiguration = commonConfiguration, ProguardMappingFileOutput = Path.Combine (path, "mapping.txt"), - ProguardConfigurationFiles = new ITaskItem [] { new TaskItem (customConfiguration) }, + ProguardConfigurationFiles = new ITaskItem [] { new TaskItem (customConfiguration), aarConfigurationItem }, GenerateSeedMapping = true, EnableObfuscation = true, IgnoreWarnings = true, @@ -94,6 +98,7 @@ public void GenerateSeedMappingAllowsAcwObfuscation () string configuration = string.Join (Environment.NewLine, configurationFiles.Select (File.ReadAllText)); Assert.That (configurationFiles, Does.Contain (customConfiguration), "Seed R8 should honor user ProGuard rules."); + Assert.That (configurationFiles, Does.Contain (aarConfiguration), "Seed R8 should honor AAR consumer rules."); Assert.That (configurationFiles, Does.Contain (commonConfiguration), "Seed R8 should honor runtime keep rules."); Assert.That (configurationFiles, Does.Not.Contain (applicationConfiguration), "Seed R8 must not pass the ACW keep configuration."); FileAssert.DoesNotExist (applicationConfiguration, "Seed R8 must not generate obfuscation-blocking ACW keep rules."); diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index d6bf4962bee..243c13fb725 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -426,6 +426,9 @@ public R8JniLibraryPeer () { } }, }; library.SetRuntime (runtime); + library.OtherBuildItems.Add (new AndroidItem.ProguardConfiguration ("proguard.txt") { + TextContent = () => "-dontwarn com.example.library.**", + }); var app = new XamarinAndroidApplicationProject { IsRelease = true, @@ -440,6 +443,28 @@ public R8JniLibraryPeer () { } app.OtherBuildItems.Add (new AndroidItem.ProguardConfiguration ("r8-jni-rules.pro") { TextContent = () => proguardRule, }); + app.OtherBuildItems.Add (new AndroidItem.ProguardConfiguration ("generated-acw-keep.cfg") { + TextContent = () => $"-keep class {libraryJavaName.Replace ('/', '.')} {{ *; }}", + Metadata = { + { "AndroidGeneratedProguardConfiguration", "true" }, + }, + }); + app.Imports.Add (new Import ("CaptureR8JniSeedConfiguration.targets") { + TextContent = () => """ + + + + + + + """, + }); string testDirectory = Path.Combine ("temp", $"R8JniNameRewritingReferences_{runtime}_{Guid.NewGuid ():N}"); using var libraryBuilder = CreateDllBuilder (Path.Combine (testDirectory, library.ProjectName)); @@ -450,8 +475,14 @@ public R8JniLibraryPeer () { } var projectDirectory = Path.Combine (Root, appBuilder.ProjectDirectory); var seedMapping = FindSingleFile (projectDirectory, "mapping.txt", path => path.Contains ("r8-jni-seed", StringComparison.Ordinal)); var rewriteManifest = FindSingleFile (projectDirectory, "r8-jni-rewrite-manifest.txt"); - StringAssert.Contains ($"{libraryJavaName.Replace ('/', '.')} ->", File.ReadAllText (seedMapping)); + var seedConfigurationItems = File.ReadAllLines (FindSingleFile (projectDirectory, "configuration-items.txt")); + AssertR8MappingRenamesClass (seedMapping, libraryJavaName); StringAssert.Contains ($"C\t{libraryJavaName}", File.ReadAllText (rewriteManifest)); + Assert.That (seedConfigurationItems, Has.Some.EndsWith ("r8-jni-rules.pro"), "Seed R8 should receive user-authored rules."); + Assert.That (seedConfigurationItems, Has.Some.EndsWith ("proguard.txt"), "Seed R8 should receive AAR consumer rules."); + Assert.IsFalse (seedConfigurationItems.Any (path => + new [] { "proguard-android.txt", "proguard_xamarin.cfg", "proguard_project_references.cfg", "proguard_project_primary.cfg", "aapt_rules.txt", "generated-acw-keep.cfg" }.Contains (Path.GetFileName (path), StringComparer.Ordinal)), + "Seed R8 should not receive generated or baseline configurations that pin managed peers."); proguardRule = "-dontwarn com.example.UnusedTwo"; app.Touch ("r8-jni-rules.pro"); From c1fdd7e14107db598608f1745bd67afb1694e4fd Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 2 Sep 2026 02:02:22 +0200 Subject: [PATCH 12/21] Route rewritten JNI names to original Java sources Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Documentation/docs-mobile/messages/xa4327.md | 4 +- ...roid.Sdk.TypeMap.Trimmable.CoreCLR.targets | 4 +- .../Properties/Resources.Designer.cs | 9 ++ .../Properties/Resources.resx | 5 + .../Tasks/GenerateTrimmableTypeMap.cs | 68 +++++++++++- .../Tasks/GenerateTrimmableTypeMapTests.cs | 104 ++++++++++++++++++ .../Utilities/JniRemapping/R8Mapping.cs | 3 + 7 files changed, 192 insertions(+), 5 deletions(-) diff --git a/Documentation/docs-mobile/messages/xa4327.md b/Documentation/docs-mobile/messages/xa4327.md index 61179c1aff5..91143cea178 100644 --- a/Documentation/docs-mobile/messages/xa4327.md +++ b/Documentation/docs-mobile/messages/xa4327.md @@ -27,7 +27,9 @@ trimming. This error means that a required mapping or manifest could not be read, a linked assembly could not be scanned, the final R8 mapping changed a seeded name, or -final R8 removed a JNI entry that remained reachable from managed code. +final R8 removed a JNI entry that remained reachable from managed code. It can +also mean that an obfuscated post-trim JNI class could not be mapped uniquely +back to the original Java source generated before trimming. ## Solution diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets index adf0089ffee..07dccc01a5a 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets @@ -162,7 +162,7 @@ DependsOnTargets="_ComputePostTrimTrimmableTypeMapInputs;_InvalidatePostTrimTrimmableTypeMapStampIfOutputsMissing" AfterTargets="_ResolveAssemblies" BeforeTargets="_GenerateJavaStubs;_CompileJava;_CompileToDalvik" - Inputs="@(_PostTrimTrimmableTypeMapInputAssemblies);$(_ResolvedUserAssembliesHashFile);$(_AndroidBuildPropertiesCache);@(_AndroidMSBuildAllProjects)" + Inputs="@(_PostTrimTrimmableTypeMapInputAssemblies);$(_ResolvedUserAssembliesHashFile);$(_AndroidBuildPropertiesCache);@(_AndroidMSBuildAllProjects);$(_AndroidR8JniSeedMapping);$(_AndroidR8JniRewriteManifest)" Outputs="$(_PostTrimTrimmableTypeMapJavaStamp)"> + /// Looks up a localized string similar to Could not uniquely reverse-map generated JNI class '{0}' to its original Java source path.. + /// + public static string XA4327_JavaSourcePathMappingConflict { + get { + return ResourceManager.GetString("XA4327_JavaSourcePathMappingConflict", resourceCulture); + } + } + /// /// Looks up a localized string similar to Could not scan the linked assembly '{0}' for rewritten JNI references: {1}. /// diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx index dd7f6f622ca..57be1b08f3c 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx @@ -919,6 +919,11 @@ Remove the '{0}' reference from your project and add the '{1}' NuGet package ins The final R8 mapping file '{0}' was not generated, so the applied JNI names could not be validated. The following are literal names and should not be translated: R8, JNI {0} - The path to the final R8 mapping file. + + + Could not uniquely reverse-map generated JNI class '{0}' to its original Java source path. + The following literal names should not be translated: JNI, Java +{0} - The obfuscated JNI class name whose original Java source path could not be determined. Could not scan the linked assembly '{0}' for rewritten JNI references: {1} diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs index 1c2b02aa583..d286683661d 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs @@ -10,6 +10,7 @@ using Microsoft.Android.Sdk.TrimmableTypeMap; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; +using Xamarin.Android.Tasks.JniRemapping; using Xamarin.Android.Tools; namespace Xamarin.Android.Tasks; @@ -82,6 +83,8 @@ public void LogCustomJavaObjectWarning (string managedTypeName) => [Required] public string JavaSourceOutputDirectory { get; set; } = ""; public string? JavaSourceInputDirectory { get; set; } + public string? R8MappingFile { get; set; } + public string? R8RewriteManifestFile { get; set; } [Required] public string TargetFrameworkVersion { get; set; } = ""; @@ -138,6 +141,9 @@ public void LogCustomJavaObjectWarning (string managedTypeName) => [Output] public string[]? AdditionalProviderSources { get; set; } + R8Mapping? r8Mapping; + IJniNameMapping? reverseR8Mapping; + public override bool RunTask () { var systemRuntimeVersion = ParseTargetFrameworkVersion (TargetFrameworkVersion); @@ -162,6 +168,9 @@ public override bool RunTask () return false; } } + if (!LoadR8JavaSourcePathMapping ()) { + return false; + } Directory.CreateDirectory (OutputDirectory); string[]? priorJavaSnapshot = null; @@ -321,17 +330,49 @@ void WriteGeneratedAssembliesListFile (IReadOnlyList assemblies) Files.CopyIfStringChanged (text, GeneratedAssembliesListFile); } - ITaskItem [] CopyJavaSourcesFromInputDirectory (IReadOnlyList javaSources) + internal bool LoadR8JavaSourcePathMapping () + { + if (R8MappingFile.IsNullOrEmpty ()) { + return true; + } + if (!File.Exists (R8MappingFile)) { + LogR8JniMappingError (string.Format (Properties.Resources.XA4327_SeedMappingNotFound, R8MappingFile)); + return false; + } + if (R8RewriteManifestFile.IsNullOrEmpty () || !File.Exists (R8RewriteManifestFile)) { + LogR8JniMappingError (string.Format (Properties.Resources.XA4327_RewriteManifestNotFound, R8RewriteManifestFile)); + return false; + } + try { + r8Mapping = R8Mapping.Load (R8MappingFile); + r8Mapping.RestrictReverseLookupsTo (File.ReadLines (R8RewriteManifestFile)); + reverseR8Mapping = r8Mapping.CreateReverseMapping (); + return true; + } catch (FormatException ex) { + LogR8JniMappingError (string.Format (Properties.Resources.XA4327_MappingDataFailure, ex.Message)); + } catch (IOException ex) { + LogR8JniMappingError (string.Format (Properties.Resources.XA4327_MappingDataFailure, ex.Message)); + } catch (UnauthorizedAccessException ex) { + LogR8JniMappingError (string.Format (Properties.Resources.XA4327_MappingDataFailure, ex.Message)); + } + return false; + } + + internal ITaskItem [] CopyJavaSourcesFromInputDirectory (IReadOnlyList javaSources) { var items = new List (); foreach (var source in javaSources) { - string inputPath = Path.Combine (JavaSourceInputDirectory ?? "", source.RelativePath); + string? relativePath = GetOriginalJavaSourceRelativePath (source.RelativePath); + if (relativePath == null) { + continue; + } + string inputPath = Path.Combine (JavaSourceInputDirectory ?? "", relativePath); if (!File.Exists (inputPath)) { Log.LogCodedError ("XA4255", Properties.Resources.XA4255, inputPath); continue; } - string outputPath = Path.Combine (JavaSourceOutputDirectory, source.RelativePath); + string outputPath = Path.Combine (JavaSourceOutputDirectory, relativePath); string? dir = Path.GetDirectoryName (outputPath); if (!string.IsNullOrEmpty (dir)) { Directory.CreateDirectory (dir); @@ -344,6 +385,27 @@ ITaskItem [] CopyJavaSourcesFromInputDirectory (IReadOnlyList Log.LogCodedError ("XA4327", Properties.Resources.XA4327, detail); + ITaskItem [] WriteAssembliesToDisk (IReadOnlyList assemblies, IReadOnlyList assemblyPaths) { // Build a map from assembly name -> source path for timestamp comparison diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs index e4b82397080..59cbea2e27e 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using Microsoft.Android.Sdk.TrimmableTypeMap; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using NUnit.Framework; @@ -149,6 +150,81 @@ public void Execute_MissingJavaSource_DoesNotPruneExistingOutput () FileAssert.Exists (existingOutput, "A failing in-place update should preserve the last known-good linked Java source."); } + [Test] + public void CopyJavaSources_ReverseMapsObfuscatedNestedClassPath () + { + var path = Path.Combine (Root, "temp", TestName); + var inputDir = Path.Combine (path, "java"); + var outputDir = Path.Combine (path, "linked-java"); + var originalRelativePath = Path.Combine ("com", "example", "Outer$Inner.java"); + var inputPath = Path.Combine (inputDir, originalRelativePath); + var inputPathDirectory = Path.GetDirectoryName (inputPath); + if (inputPathDirectory is null) { + throw new InvalidOperationException ("Could not determine the Java input directory."); + } + Directory.CreateDirectory (inputPathDirectory); + File.WriteAllText (inputPath, "original"); + var task = CreateJavaSourceCopyTask ( + inputDir, + outputDir, + "com.example.Outer$Inner -> g:\n", + "C\tcom/example/Outer$Inner\n"); + + Assert.IsTrue (task.LoadR8JavaSourcePathMapping ()); + var outputs = task.CopyJavaSourcesFromInputDirectory (new [] { new GeneratedJavaSource ("g.java", "") }); + + Assert.That (outputs, Has.Exactly (1).Property ("ItemSpec").EqualTo (Path.Combine (outputDir, originalRelativePath))); + Assert.AreEqual ("original", File.ReadAllText (Path.Combine (outputDir, originalRelativePath))); + FileAssert.DoesNotExist (Path.Combine (outputDir, "g.java")); + } + + [TestCase ("C\tcom/example/First\nC\tcom/example/Second\n", TestName = "CopyJavaSources_MergedClassIsAmbiguous")] + [TestCase ("C\tcom/example/Unrelated\n", TestName = "CopyJavaSources_MissingRequiredReverseEntry")] + public void CopyJavaSources_InvalidReverseMappingUsesXA4327 (string manifest) + { + var path = Path.Combine (Root, "temp", TestName); + var errors = new List (); + var task = CreateJavaSourceCopyTask ( + Path.Combine (path, "java"), + Path.Combine (path, "linked-java"), + "com.example.First -> g:\ncom.example.Second -> g:\n", + manifest, + errors); + + Assert.IsTrue (task.LoadR8JavaSourcePathMapping ()); + var outputs = task.CopyJavaSourcesFromInputDirectory (new [] { new GeneratedJavaSource ("g.java", "") }); + + Assert.IsEmpty (outputs); + Assert.That (errors, Has.Exactly (1).Property ("Code").EqualTo ("XA4327")); + } + + [Test] + public void CopyJavaSources_PreservesUnmappedPath () + { + var path = Path.Combine (Root, "temp", TestName); + var inputDir = Path.Combine (path, "java"); + var outputDir = Path.Combine (path, "linked-java"); + var relativePath = Path.Combine ("android", "runtime", "FrameworkPeer.java"); + var inputPath = Path.Combine (inputDir, relativePath); + var inputPathDirectory = Path.GetDirectoryName (inputPath); + if (inputPathDirectory is null) { + throw new InvalidOperationException ("Could not determine the Java input directory."); + } + Directory.CreateDirectory (inputPathDirectory); + File.WriteAllText (inputPath, "framework"); + var task = CreateJavaSourceCopyTask ( + inputDir, + outputDir, + "com.example.Other -> h:\n", + "C\tcom/example/Other\n"); + + Assert.IsTrue (task.LoadR8JavaSourcePathMapping ()); + var outputs = task.CopyJavaSourcesFromInputDirectory (new [] { new GeneratedJavaSource (relativePath, "") }); + + Assert.That (outputs, Has.Exactly (1).Property ("ItemSpec").EqualTo (Path.Combine (outputDir, relativePath))); + Assert.AreEqual ("framework", File.ReadAllText (Path.Combine (outputDir, relativePath))); + } + [Test] public void Execute_WritesGeneratedAssembliesListFile () { @@ -410,6 +486,34 @@ GenerateTrimmableTypeMap CreateTask (ITaskItem [] assemblies, string outputDir, }; } + GenerateTrimmableTypeMap CreateJavaSourceCopyTask ( + string inputDir, + string outputDir, + string mapping, + string manifest, + IList? errors = null) + { + string? root = Path.GetDirectoryName (inputDir); + if (root is null) { + throw new InvalidOperationException ("Could not determine the test root directory."); + } + string mappingFile = Path.Combine (root, "mapping.txt"); + string manifestFile = Path.Combine (root, "rewrite-manifest.txt"); + Directory.CreateDirectory (root); + File.WriteAllText (mappingFile, mapping); + File.WriteAllText (manifestFile, manifest); + return new GenerateTrimmableTypeMap { + BuildEngine = new MockBuildEngine (TestContext.Out, errors: errors), + ResolvedAssemblies = [], + OutputDirectory = Path.Combine (root, "typemap"), + JavaSourceInputDirectory = inputDir, + JavaSourceOutputDirectory = outputDir, + R8MappingFile = mappingFile, + R8RewriteManifestFile = manifestFile, + TargetFrameworkVersion = "v11.0", + }; + } + static ITaskItem? FindMonoAndroidDll () { var frameworkDir = TestEnvironment.MonoAndroidFrameworkDirectory; diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs index d8506ecdb73..5eea545b47d 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -266,6 +266,9 @@ public bool TryGetOriginalClass (string obfuscatedJniClassName, out string origi return false; } + internal bool ContainsObfuscatedClass (string obfuscatedJniClassName) + => originalClasses.ContainsKey (obfuscatedJniClassName); + public bool TryGetOriginalMethodName (string originalJniClassName, string obfuscatedMethodName, IReadOnlyList originalJavaParameterTypes, string originalJavaReturnType, out string originalMethodName) { originalMethodName = ""; From 445c87dbcc5ae9b8150f80c715fc4c3c6196a8ec Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 2 Sep 2026 02:02:22 +0200 Subject: [PATCH 13/21] Allow final R8 to apply JNI mappings Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Xamarin.Android.Build.Tasks/Tasks/R8.cs | 42 +++++++ .../Tasks/R8Tests.cs | 110 ++++++++++++++++++ .../TrimmableTypeMapBuildTests.cs | 12 ++ .../Xamarin.Android.Common.targets | 4 +- 4 files changed, 167 insertions(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs index 133374e991e..d7dcaa748b9 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs @@ -312,6 +312,10 @@ protected override string CreateResponseFile () option, file, DescribeProguardSource (item)); continue; } + if (EnableObfuscation && + string.Equals (item.GetMetadata ("AndroidSdkBaselineProguardConfiguration"), bool.TrueString, StringComparison.OrdinalIgnoreCase)) { + file = CreateR8JniBaselineConfiguration (file); + } WriteArg (response, "--pg-conf"); WriteArg (response, file); } @@ -320,6 +324,44 @@ protected override string CreateResponseFile () return responseFile; } + string CreateR8JniBaselineConfiguration (string path) + { + string content = File.ReadAllText (path); + string filtered = RemoveNativeMethodKeepRule (content); + if (string.Equals (content, filtered, StringComparison.Ordinal)) { + return path; + } + string temp = Path.GetTempFileName (); + tempFiles.Add (temp); + File.WriteAllText (temp, filtered, Files.UTF8withoutBOM); + return temp; + } + + internal static string RemoveNativeMethodKeepRule (string content) + { + bool endsWithNewLine = content.EndsWith ("\n", StringComparison.Ordinal) || content.EndsWith ("\r", StringComparison.Ordinal); + string [] lines = content.Replace ("\r\n", "\n").Replace ('\r', '\n').Split ('\n'); + var filtered = new List (lines.Length); + for (int i = 0; i < lines.Length; i++) { + if (i + 2 < lines.Length && + string.Equals (lines [i].Trim (), "-keepclasseswithmembernames,includedescriptorclasses class * {", StringComparison.Ordinal) && + string.Equals (lines [i + 1].Trim (), "native ;", StringComparison.Ordinal) && + string.Equals (lines [i + 2].Trim (), "}", StringComparison.Ordinal)) { + if (filtered.Count > 0 && filtered [filtered.Count - 1].TrimStart ().StartsWith ("# For native methods,", StringComparison.Ordinal)) { + filtered.RemoveAt (filtered.Count - 1); + } + i += 2; + continue; + } + filtered.Add (lines [i]); + } + if (endsWithNewLine && filtered.Count > 0 && filtered [filtered.Count - 1].Length == 0) { + filtered.RemoveAt (filtered.Count - 1); + } + string result = string.Join ("\n", filtered); + return endsWithNewLine ? result + "\n" : result; + } + string GetRequiredSeedMappingOutput () { string? output = ProguardMappingFileOutput; 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 4690826a9d9..9e5a35bfecf 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 @@ -152,6 +152,116 @@ public void ValidateAppliedMappingUsesXA4327 () } } + [Test] + public void R8JniObfuscationFiltersOnlySdkBaselineNativeKeepRule () + { + string path = Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ("N")); + Directory.CreateDirectory (path); + string responseFile = ""; + try { + string seedMapping = Path.Combine (path, "mapping.txt"); + string baseline = Path.Combine (path, "proguard-android.txt"); + string user = Path.Combine (path, "user.pro"); + string generated = Path.Combine (path, "proguard_project_references.cfg"); + File.WriteAllText (seedMapping, "com.example.Peer -> a:\n"); + string nativeKeepRule = """ + -keepclasseswithmembernames,includedescriptorclasses class * { + native ; + } + """; + File.WriteAllText (baseline, $"-dontwarn before\n{nativeKeepRule}\n-dontwarn after\n"); + File.WriteAllText (user, nativeKeepRule + "\n"); + File.WriteAllText (generated, "-keep,allowobfuscation class com.example.Peer\n"); + var baselineItem = new TaskItem (baseline); + baselineItem.SetMetadata ("AndroidSdkBaselineProguardConfiguration", "true"); + var task = CreateR8TestTask ( + path, + new ITaskItem [] { baselineItem, new TaskItem (user), new TaskItem (generated) }, + enableObfuscation: true, + seedMapping); + + task.TestGenerateCommandLineCommands (); + responseFile = task.ResponseFilePath; + string [] configurationFiles = GetConfigurationFiles (responseFile); + string filteredBaseline = configurationFiles.Single (file => file != user && file != generated && File.ReadAllText (file).Contains ("-dontwarn before")); + string allConfiguration = string.Join ("\n", configurationFiles.Select (File.ReadAllText)); + + Assert.AreNotEqual (baseline, filteredBaseline); + Assert.AreEqual ("-dontwarn before\n-dontwarn after\n", File.ReadAllText (filteredBaseline)); + Assert.That (configurationFiles, Does.Contain (user), "User rules with identical text must remain untouched."); + StringAssert.Contains ("-applymapping", allConfiguration); + StringAssert.Contains ("-keep,allowobfuscation class com.example.Peer", allConfiguration); + Assert.AreEqual (1, allConfiguration.Split (new [] { "native ;" }, StringSplitOptions.None).Length - 1, + "Only the user-authored native rule should remain."); + + DeleteTemporaryConfigurations (configurationFiles, user, generated); + } finally { + if (File.Exists (responseFile)) { + File.Delete (responseFile); + } + Directory.Delete (path, recursive: true); + } + } + + [Test] + public void R8WithoutJniObfuscationPassesSdkBaselineUnchanged () + { + string path = Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ("N")); + Directory.CreateDirectory (path); + string responseFile = ""; + try { + string baseline = Path.Combine (path, "proguard-android.txt"); + string content = "-keepclasseswithmembernames,includedescriptorclasses class * {\n native ;\n}\n"; + File.WriteAllText (baseline, content); + var baselineItem = new TaskItem (baseline); + baselineItem.SetMetadata ("AndroidSdkBaselineProguardConfiguration", "true"); + var task = CreateR8TestTask (path, new ITaskItem [] { baselineItem }, enableObfuscation: false); + + task.TestGenerateCommandLineCommands (); + responseFile = task.ResponseFilePath; + string [] configurationFiles = GetConfigurationFiles (responseFile); + + Assert.That (configurationFiles, Does.Contain (baseline)); + Assert.AreEqual (content, File.ReadAllText (baseline)); + } finally { + if (File.Exists (responseFile)) { + File.Delete (responseFile); + } + Directory.Delete (path, recursive: true); + } + } + + static R8TestTask CreateR8TestTask (string path, ITaskItem [] configurations, bool enableObfuscation, string? seedMapping = null) + => new R8TestTask { + BuildEngine = new MockBuildEngine (TestContext.Out), + JarPath = "r8.jar", + JavaPlatformJarPath = "android.jar", + OutputDirectory = path, + ProguardMappingFileInput = seedMapping, + ProguardConfigurationFiles = configurations, + EnableShrinking = true, + EnableObfuscation = enableObfuscation, + }; + + static string [] GetConfigurationFiles (string responseFile) + { + string [] response = File.ReadAllLines (responseFile); + return response + .Select ((argument, index) => (argument, index)) + .Where (entry => entry.argument == "--pg-conf") + .Select (entry => response [entry.index + 1]) + .ToArray (); + } + + static void DeleteTemporaryConfigurations (IEnumerable configurationFiles, params string [] retainedFiles) + { + foreach (string configurationFile in configurationFiles) { + if (!retainedFiles.Contains (configurationFile, StringComparer.Ordinal)) { + File.Delete (configurationFile); + } + } + } + internal class R8TestTask : R8 { public string ResponseFilePath { get; private set; } = ""; diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index 243c13fb725..357aba34c15 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -462,6 +462,15 @@ public R8JniLibraryPeer () { } Overwrite="true" WriteOnlyWhenDifferent="true" /> + + + """, }); @@ -476,6 +485,7 @@ public R8JniLibraryPeer () { } var seedMapping = FindSingleFile (projectDirectory, "mapping.txt", path => path.Contains ("r8-jni-seed", StringComparison.Ordinal)); var rewriteManifest = FindSingleFile (projectDirectory, "r8-jni-rewrite-manifest.txt"); var seedConfigurationItems = File.ReadAllLines (FindSingleFile (projectDirectory, "configuration-items.txt")); + var finalConfigurationItems = File.ReadAllLines (FindSingleFile (projectDirectory, "final-configuration-items.txt")); AssertR8MappingRenamesClass (seedMapping, libraryJavaName); StringAssert.Contains ($"C\t{libraryJavaName}", File.ReadAllText (rewriteManifest)); Assert.That (seedConfigurationItems, Has.Some.EndsWith ("r8-jni-rules.pro"), "Seed R8 should receive user-authored rules."); @@ -483,6 +493,8 @@ public R8JniLibraryPeer () { } Assert.IsFalse (seedConfigurationItems.Any (path => new [] { "proguard-android.txt", "proguard_xamarin.cfg", "proguard_project_references.cfg", "proguard_project_primary.cfg", "aapt_rules.txt", "generated-acw-keep.cfg" }.Contains (Path.GetFileName (path), StringComparer.Ordinal)), "Seed R8 should not receive generated or baseline configurations that pin managed peers."); + Assert.That (finalConfigurationItems, Does.Contain ("proguard-android.txt|true"), + "Final R8 should identify only the SDK baseline by explicit provenance metadata."); proguardRule = "-dontwarn com.example.UnusedTwo"; app.Touch ("r8-jni-rules.pro"); diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index 13c2442cd19..5ab4792537f 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -2041,7 +2041,9 @@ because xbuild doesn't support framework reference assemblies. <_ProguardConfiguration Include="$(ProguardConfigFiles)" /> - <_ProguardConfiguration Include="$(MSBuildThisFileDirectory)proguard-android.txt" /> + <_ProguardConfiguration Include="$(MSBuildThisFileDirectory)proguard-android.txt"> + true + <_ProguardConfiguration Include="$(IntermediateOutputPath)proguard\proguard_xamarin.cfg" Condition=" '$(AndroidLinkTool)' != '' " /> <_ProguardConfiguration Include="$(_ProguardProjectConfiguration)" Condition=" '$(AndroidLinkTool)' != '' " /> <_ProguardConfiguration Include="$(IntermediateOutputPath)proguard\proguard_project_primary.cfg" Condition=" '$(AndroidLinkTool)' != '' " /> From 205ad642d5a98e19d527fcc49d81e90a736c1d5c Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 2 Sep 2026 03:49:30 +0200 Subject: [PATCH 14/21] Keep manifest entry names stable in seed R8 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Android/Xamarin.Android.Aapt2.targets | 1 + ...soft.Android.Sdk.TypeMap.Trimmable.targets | 70 +++++++++++++++++- src/Xamarin.Android.Build.Tasks/Tasks/R8.cs | 51 +++++++++++++ .../Tasks/R8Tests.cs | 72 ++++++++++++++++++- .../TrimmableTypeMapBuildTests.cs | 26 +++++-- 5 files changed, 212 insertions(+), 8 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/MSBuild/Xamarin/Android/Xamarin.Android.Aapt2.targets b/src/Xamarin.Android.Build.Tasks/MSBuild/Xamarin/Android/Xamarin.Android.Aapt2.targets index 403b50d7ef3..94a4eff2ac7 100644 --- a/src/Xamarin.Android.Build.Tasks/MSBuild/Xamarin/Android/Xamarin.Android.Aapt2.targets +++ b/src/Xamarin.Android.Build.Tasks/MSBuild/Xamarin/Android/Xamarin.Android.Aapt2.targets @@ -247,6 +247,7 @@ Copyright (C) 2011-2012 Xamarin. All rights reserved. true + true diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index 3af97116519..53c978b4bd3 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -120,18 +120,84 @@ + + + <_AndroidR8JniAaptManifest>$(_TypeMapBaseOutputDir)AndroidManifest.xml + <_AndroidR8JniAaptManifest Condition=" '$(AndroidManifestMerger)' == 'manifestmerger.jar' ">$(IntermediateOutputPath)android/AndroidManifest.xml + <_AndroidR8JniAaptProguardConfiguration>$(_AndroidR8JniSeedDirectory)aapt_rules.txt + <_AndroidR8JniAaptResourcePackage>$(_AndroidR8JniSeedDirectory)aapt-resources.apk + <_AndroidR8JniAaptProtobufFormat Condition=" '$(AndroidPackageFormat)' == 'aab' ">true + <_AndroidR8JniAaptProtobufFormat Condition=" '$(_AndroidR8JniAaptProtobufFormat)' == '' ">false + + + <_AndroidR8JniAaptProguardConfigurationInput Include="@(_AndroidMSBuildAllProjects)" /> + <_AndroidR8JniAaptProguardConfigurationInput Include="$(_AndroidBuildPropertiesCache)" /> + <_AndroidR8JniAaptProguardConfigurationInput Include="$(_AndroidR8JniAaptManifest)" /> + <_AndroidR8JniAaptProguardConfigurationInput Include="@(_CompiledFlatFiles)" /> + <_AndroidR8JniAaptProguardConfigurationInput Include="@(_LibraryResourceDirectoryStamps)" /> + <_AndroidR8JniAaptProguardConfigurationInput Include="$(JavaPlatformJarPath)" /> + <_AndroidR8JniAaptProguardConfigurationInput Include="$(_AndroidAapt2VersionFile)" /> + + + + + + + + + + + + + + DependsOnTargets="_AndroidGenerateR8JniAaptProguardConfiguration"> <_AndroidR8JniSeedClassFile Include="$(_AndroidR8JniSeedJavaClassDirectory)**\*.class" /> + <_AndroidR8JniSeedAaptProguardConfiguration + Include="$(_AndroidR8JniAaptProguardConfiguration)" + Condition=" '$(ProguardConfigFiles)' == '' and Exists('$(_AndroidR8JniAaptProguardConfiguration)') "> + true + true + + reachability, primary, and Xamarin configurations cannot leak into this pass. AAPT + rules are retained because final R8 must preserve names referenced by binary resources. --> <_AndroidR8JniSeedProguardConfiguration Include="$(ProguardConfigFiles)" Condition=" '$(ProguardConfigFiles)' != '' " /> <_AndroidR8JniSeedProguardConfiguration Include="@(ProguardConfiguration)" Condition=" '$(ProguardConfigFiles)' == '' and '%(ProguardConfiguration.AndroidGeneratedProguardConfiguration)' != 'true' " /> + <_AndroidR8JniSeedProguardConfiguration + Include="@(_AndroidR8JniSeedAaptProguardConfiguration)" + Condition=" '$(ProguardConfigFiles)' == '' " /> <_AndroidR8JniSeedMapDiagnostics Condition=" '$(AndroidR8IgnoreWarnings)' == 'true' " Include="warning" To="info" /> diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs index d7dcaa748b9..e4bbd4ee108 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs @@ -312,6 +312,10 @@ protected override string CreateResponseFile () option, file, DescribeProguardSource (item)); continue; } + if (GenerateSeedMapping && + string.Equals (item.GetMetadata ("AndroidAaptProguardConfiguration"), bool.TrueString, StringComparison.OrdinalIgnoreCase)) { + file = CreateR8JniSeedAaptConfiguration (file); + } if (EnableObfuscation && string.Equals (item.GetMetadata ("AndroidSdkBaselineProguardConfiguration"), bool.TrueString, StringComparison.OrdinalIgnoreCase)) { file = CreateR8JniBaselineConfiguration (file); @@ -324,6 +328,53 @@ protected override string CreateResponseFile () return responseFile; } + string CreateR8JniSeedAaptConfiguration (string path) + { + string content = KeepAaptManifestRules (File.ReadAllText (path)); + string temp = Path.GetTempFileName (); + tempFiles.Add (temp); + File.WriteAllText (temp, content, Files.UTF8withoutBOM); + return temp; + } + + internal static string KeepAaptManifestRules (string content) + { + bool endsWithNewLine = content.EndsWith ("\n", StringComparison.Ordinal) || content.EndsWith ("\r", StringComparison.Ordinal); + string [] lines = content.Replace ("\r\n", "\n").Replace ('\r', '\n').Split ('\n'); + var filtered = new List (lines.Length); + bool keepSection = true; + bool foundReference = false; + foreach (string line in lines) { + if (line.StartsWith ("# Referenced at ", StringComparison.Ordinal)) { + foundReference = true; + keepSection = IsAaptManifestReference (line); + } + if (keepSection) { + filtered.Add (line); + } + } + if (!foundReference) { + return content; + } + if (endsWithNewLine && filtered.Count > 0 && filtered [filtered.Count - 1].Length == 0) { + filtered.RemoveAt (filtered.Count - 1); + } + string result = string.Join ("\n", filtered); + return endsWithNewLine ? result + "\n" : result; + } + + static bool IsAaptManifestReference (string line) + { + const string prefix = "# Referenced at "; + string path = line.Substring (prefix.Length); + int lineNumberSeparator = path.LastIndexOf (':'); + if (lineNumberSeparator >= 0 && Int32.TryParse (path.Substring (lineNumberSeparator + 1), out _)) { + path = path.Substring (0, lineNumberSeparator); + } + path = path.Replace ('\\', Path.DirectorySeparatorChar).Replace ('/', Path.DirectorySeparatorChar); + return string.Equals (Path.GetFileName (path), "AndroidManifest.xml", StringComparison.OrdinalIgnoreCase); + } + string CreateR8JniBaselineConfiguration (string path) { string content = File.ReadAllText (path); 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 9e5a35bfecf..52ce48e999b 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 @@ -66,11 +66,22 @@ public void GenerateSeedMappingAllowsAcwObfuscation () string commonConfiguration = Path.Combine (path, "xamarin.cfg"); string customConfiguration = Path.Combine (path, "custom.cfg"); string aarConfiguration = Path.Combine (path, "aar-proguard.txt"); + string aaptConfiguration = Path.Combine (path, "aapt-rules.txt"); File.WriteAllText (acwMap, "Managed.Peer;com.example.Peer"); File.WriteAllText (customConfiguration, "-dontwarn com.example.**"); File.WriteAllText (aarConfiguration, "-dontwarn com.example.library.**"); + File.WriteAllText (aaptConfiguration, """ + #Auto Generated file. Do not Edit. + # Referenced at obj/manifest/AndroidManifest.xml:10 + -keep class com.example.MainActivity { (); } + # Referenced at res/layout/main.xml:1 + -keep class com.example.CustomView { (...); } + """); var aarConfigurationItem = new TaskItem (aarConfiguration); aarConfigurationItem.SetMetadata ("OriginalFile", Path.Combine (path, "library.aar")); + var aaptConfigurationItem = new TaskItem (aaptConfiguration); + aaptConfigurationItem.SetMetadata ("AndroidGeneratedProguardConfiguration", "true"); + aaptConfigurationItem.SetMetadata ("AndroidAaptProguardConfiguration", "true"); var task = new R8TestTask { BuildEngine = new MockBuildEngine (TestContext.Out), @@ -81,7 +92,7 @@ public void GenerateSeedMappingAllowsAcwObfuscation () ProguardGeneratedApplicationConfiguration = applicationConfiguration, ProguardCommonXamarinConfiguration = commonConfiguration, ProguardMappingFileOutput = Path.Combine (path, "mapping.txt"), - ProguardConfigurationFiles = new ITaskItem [] { new TaskItem (customConfiguration), aarConfigurationItem }, + ProguardConfigurationFiles = new ITaskItem [] { new TaskItem (customConfiguration), aarConfigurationItem, aaptConfigurationItem }, GenerateSeedMapping = true, EnableObfuscation = true, IgnoreWarnings = true, @@ -103,6 +114,8 @@ public void GenerateSeedMappingAllowsAcwObfuscation () Assert.That (configurationFiles, Does.Not.Contain (applicationConfiguration), "Seed R8 must not pass the ACW keep configuration."); FileAssert.DoesNotExist (applicationConfiguration, "Seed R8 must not generate obfuscation-blocking ACW keep rules."); StringAssert.Contains ("-keep class mono.MonoRuntimeProvider", configuration); + StringAssert.Contains ("-keep class com.example.MainActivity", configuration); + StringAssert.DoesNotContain ("-keep class com.example.CustomView", configuration); StringAssert.DoesNotContain ("-keep class com.example.Peer", configuration); StringAssert.DoesNotContain ("-dontobfuscate", configuration); Assert.That (response, Does.Not.Contain ("--no-minification")); @@ -152,6 +165,63 @@ public void ValidateAppliedMappingUsesXA4327 () } } + [Test] + public void KeepAaptManifestRulesPreservesOnlyManifestSections () + { + const string input = """ + #Auto Generated file. Do not Edit. + # Data from obj/manifest/aapt_rules.txt + # Referenced at C:\project\obj\manifest\AndroidManifest.xml:10 + -keep class com.example.MainActivity { (); } + # Referenced at C:\project\res\layout\main.xml:1 + -keep class com.example.CustomView { (...); } + """; + + Assert.AreEqual (""" + #Auto Generated file. Do not Edit. + # Data from obj/manifest/aapt_rules.txt + # Referenced at C:\project\obj\manifest\AndroidManifest.xml:10 + -keep class com.example.MainActivity { (); } + """, R8.KeepAaptManifestRules (input)); + Assert.AreEqual ("-keep class com.example.Fallback\n", R8.KeepAaptManifestRules ("-keep class com.example.Fallback\n"), + "AAPT format changes should preserve the original configuration rather than silently dropping all rules."); + } + + [Test] + public void ValidateAppliedMappingAllowsIdentityManifestEntry () + { + string path = Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ("N")); + Directory.CreateDirectory (path); + try { + string seedMapping = Path.Combine (path, "seed-mapping.txt"); + string finalMapping = Path.Combine (path, "final-mapping.txt"); + string rewriteManifest = Path.Combine (path, "rewrite-manifest.txt"); + string reachabilityManifest = Path.Combine (path, "reachability-manifest.txt"); + const string mapping = """ + com.example.MainActivity -> com.example.MainActivity: + com.example.Peer -> a: + """; + File.WriteAllText (seedMapping, mapping); + File.WriteAllText (finalMapping, mapping); + File.WriteAllText (rewriteManifest, "C\tcom/example/MainActivity\nC\tcom/example/Peer\n"); + File.WriteAllText (reachabilityManifest, ""); + + var errors = new List (); + var task = new R8 { + BuildEngine = new MockBuildEngine (TestContext.Out, errors), + ProguardMappingFileInput = seedMapping, + ProguardMappingFileOutput = finalMapping, + ProguardMappingRequiredEntriesFile = rewriteManifest, + ProguardMappingRequiredReachabilityEntriesFile = reachabilityManifest, + }; + + Assert.IsTrue (task.ValidateAppliedMapping (), "Matching identity and obfuscated mappings should pass validation."); + Assert.IsEmpty (errors); + } finally { + Directory.Delete (path, recursive: true); + } + } + [Test] public void R8JniObfuscationFiltersOnlySdkBaselineNativeKeepRule () { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index 357aba34c15..0adfbfd7817 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -266,6 +266,8 @@ public R8JniPeer () { } Assert.That (hashDirectory, Does.Match ("^[0-9a-fA-F]{16}$"), $"Rewritten assembly staging should include a source-path hash: {rewrittenAssembly}"); } AssertR8MappingRenamesClass (seedMapping, originalJavaName); + AssertR8MappingKeepsClassName (seedMapping, $"{proj.PackageName}/MainActivity"); + AssertR8MappingKeepsClassName (finalMapping, $"{proj.PackageName}/MainActivity"); StringAssert.DoesNotContain ($"{userJavaName} ->", File.ReadAllText (seedMapping), "User Java sources are kept by final R8 and should not receive seed-only names."); StringAssert.Contains ($"C\t{originalJavaName}", File.ReadAllText (rewriteManifest)); Assert.That (new FileInfo (reachabilityManifest).Length, Is.GreaterThan (0), "The clean build should record post-link JNI reachability."); @@ -326,6 +328,7 @@ public R8JniPeer () { } StringAssert.DoesNotContain ($"{originalJavaName.Replace ('/', '.')} ->", File.ReadAllText (seedMapping)); StringAssert.Contains ($"{changedJavaName.Replace ('/', '.')} ->", File.ReadAllText (seedMapping)); AssertR8MappingRenamesClass (seedMapping, changedJavaName); + AssertR8MappingKeepsClassName (seedMapping, $"{proj.PackageName}/MainActivity"); StringAssert.Contains ($"C\t{changedJavaName}", File.ReadAllText (rewriteManifest)); proj.SetProperty ("AndroidEnableR8JniNameObfuscation", "false"); @@ -384,6 +387,7 @@ public R8JniMultiAbiPeer () { } .ToArray (); StringAssert.Contains ($"{javaName.Replace ('/', '.')} ->", File.ReadAllText (seedMapping)); + AssertR8MappingKeepsClassName (seedMapping, $"{proj.PackageName}/MainActivity"); foreach (var runtimeIdentifier in new [] { "android-arm64", "android-x64" }) { Assert.That (rewrittenAssemblies, Has.Some.Contains (runtimeIdentifier), $"The {runtimeIdentifier} inner build should have isolated rewritten assemblies."); Assert.That (rewriteManifests, Has.Some.Contains (runtimeIdentifier), $"The {runtimeIdentifier} inner build should have a rewrite manifest."); @@ -458,7 +462,7 @@ public R8JniLibraryPeer () { } @@ -485,13 +489,16 @@ public R8JniLibraryPeer () { } var seedMapping = FindSingleFile (projectDirectory, "mapping.txt", path => path.Contains ("r8-jni-seed", StringComparison.Ordinal)); var rewriteManifest = FindSingleFile (projectDirectory, "r8-jni-rewrite-manifest.txt"); var seedConfigurationItems = File.ReadAllLines (FindSingleFile (projectDirectory, "configuration-items.txt")); + var seedConfigurationPaths = seedConfigurationItems.Select (item => item.Split ('|') [0]).ToArray (); var finalConfigurationItems = File.ReadAllLines (FindSingleFile (projectDirectory, "final-configuration-items.txt")); AssertR8MappingRenamesClass (seedMapping, libraryJavaName); StringAssert.Contains ($"C\t{libraryJavaName}", File.ReadAllText (rewriteManifest)); - Assert.That (seedConfigurationItems, Has.Some.EndsWith ("r8-jni-rules.pro"), "Seed R8 should receive user-authored rules."); - Assert.That (seedConfigurationItems, Has.Some.EndsWith ("proguard.txt"), "Seed R8 should receive AAR consumer rules."); - Assert.IsFalse (seedConfigurationItems.Any (path => - new [] { "proguard-android.txt", "proguard_xamarin.cfg", "proguard_project_references.cfg", "proguard_project_primary.cfg", "aapt_rules.txt", "generated-acw-keep.cfg" }.Contains (Path.GetFileName (path), StringComparer.Ordinal)), + Assert.That (seedConfigurationPaths, Has.Some.EndsWith ("r8-jni-rules.pro"), "Seed R8 should receive user-authored rules."); + Assert.That (seedConfigurationPaths, Has.Some.EndsWith ("proguard.txt"), "Seed R8 should receive AAR consumer rules."); + Assert.That (seedConfigurationItems, Has.Some.EndsWith ("aapt_rules.txt|true|true"), + "Seed R8 should receive AAPT keep rules with explicit generated and AAPT provenance."); + Assert.IsFalse (seedConfigurationPaths.Any (path => + new [] { "proguard-android.txt", "proguard_xamarin.cfg", "proguard_project_references.cfg", "proguard_project_primary.cfg", "generated-acw-keep.cfg" }.Contains (Path.GetFileName (path), StringComparer.Ordinal)), "Seed R8 should not receive generated or baseline configurations that pin managed peers."); Assert.That (finalConfigurationItems, Does.Contain ("proguard-android.txt|true"), "Final R8 should identify only the SDK baseline by explicit provenance metadata."); @@ -1643,6 +1650,15 @@ static void AssertR8MappingRenamesClass (string mappingFile, string originalJniN Assert.AreNotEqual (originalName, match.Groups ["renamed"].Value, $"Seed R8 should rename {originalName}."); } + static void AssertR8MappingKeepsClassName (string mappingFile, string originalJniName) + { + string originalName = originalJniName.Replace ('/', '.'); + StringAssert.Contains ( + $"{originalName} -> {originalName}:", + File.ReadAllText (mappingFile), + $"R8 should preserve the binary Android resource entry point {originalName}."); + } + DynamicCodeSupportProfile BuildDynamicCodeSupportProfile (string typemapImplementation, bool? dynamicCodeSupport) { var dynamicCodeSuffix = dynamicCodeSupport.HasValue ? $"_{dynamicCodeSupport.Value.ToString ().ToLowerInvariant ()}" : ""; From fba24bd643669532aa79017015efabeeda14bcd2 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 2 Sep 2026 05:08:14 +0200 Subject: [PATCH 15/21] Replace early AAPT seed rule generation Generate identity keep rules directly from the merged text manifest so seed R8 does not require resources to be linked early. Keep final AAPT resource rules on their normal late path and cover manifest normalization, provenance, and resource independence.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Documentation/docs-mobile/messages/xa4327.md | 3 +- ...soft.Android.Sdk.TypeMap.Trimmable.targets | 80 ++++-------- .../Properties/Resources.Designer.cs | 18 +++ .../Properties/Resources.resx | 11 ++ ...erateR8JniManifestProguardConfiguration.cs | 92 ++++++++++++++ src/Xamarin.Android.Build.Tasks/Tasks/R8.cs | 51 -------- ...R8JniManifestProguardConfigurationTests.cs | 120 ++++++++++++++++++ .../Tasks/R8Tests.cs | 40 +----- .../TrimmableTypeMapBuildTests.cs | 33 ++++- 9 files changed, 306 insertions(+), 142 deletions(-) create mode 100644 src/Xamarin.Android.Build.Tasks/Tasks/GenerateR8JniManifestProguardConfiguration.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateR8JniManifestProguardConfigurationTests.cs diff --git a/Documentation/docs-mobile/messages/xa4327.md b/Documentation/docs-mobile/messages/xa4327.md index 91143cea178..8dcbe8617e3 100644 --- a/Documentation/docs-mobile/messages/xa4327.md +++ b/Documentation/docs-mobile/messages/xa4327.md @@ -25,7 +25,8 @@ mapping and rewrites the corresponding names in managed assemblies. The final R8 invocation must preserve every rewritten name that remains reachable after trimming. -This error means that a required mapping or manifest could not be read, a linked +This error means that a required mapping or manifest could not be read, the +merged Android manifest could not be used to generate seed keep rules, a linked assembly could not be scanned, the final R8 mapping changed a seeded name, or final R8 removed a JNI entry that remained reachable from managed code. It can also mean that an obfuscated post-trim JNI class could not be mapped uniquely diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index 53c978b4bd3..713fb697088 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -5,6 +5,7 @@ + @@ -120,83 +121,54 @@ - + DependsOnTargets="_AndroidCompileR8JniSeedJava;_ManifestMerger"> - <_AndroidR8JniAaptManifest>$(_TypeMapBaseOutputDir)AndroidManifest.xml - <_AndroidR8JniAaptManifest Condition=" '$(AndroidManifestMerger)' == 'manifestmerger.jar' ">$(IntermediateOutputPath)android/AndroidManifest.xml - <_AndroidR8JniAaptProguardConfiguration>$(_AndroidR8JniSeedDirectory)aapt_rules.txt - <_AndroidR8JniAaptResourcePackage>$(_AndroidR8JniSeedDirectory)aapt-resources.apk - <_AndroidR8JniAaptProtobufFormat Condition=" '$(AndroidPackageFormat)' == 'aab' ">true - <_AndroidR8JniAaptProtobufFormat Condition=" '$(_AndroidR8JniAaptProtobufFormat)' == '' ">false + <_AndroidR8JniMergedManifest>$(_TypeMapBaseOutputDir)AndroidManifest.xml + <_AndroidR8JniMergedManifest Condition=" '$(AndroidManifestMerger)' == 'manifestmerger.jar' ">$(IntermediateOutputPath)android/AndroidManifest.xml + <_AndroidR8JniManifestProguardConfiguration>$(_AndroidR8JniSeedDirectory)manifest_rules.txt - - <_AndroidR8JniAaptProguardConfigurationInput Include="@(_AndroidMSBuildAllProjects)" /> - <_AndroidR8JniAaptProguardConfigurationInput Include="$(_AndroidBuildPropertiesCache)" /> - <_AndroidR8JniAaptProguardConfigurationInput Include="$(_AndroidR8JniAaptManifest)" /> - <_AndroidR8JniAaptProguardConfigurationInput Include="@(_CompiledFlatFiles)" /> - <_AndroidR8JniAaptProguardConfigurationInput Include="@(_LibraryResourceDirectoryStamps)" /> - <_AndroidR8JniAaptProguardConfigurationInput Include="$(JavaPlatformJarPath)" /> - <_AndroidR8JniAaptProguardConfigurationInput Include="$(_AndroidAapt2VersionFile)" /> - - - + + DependsOnTargets="_AndroidGenerateR8JniManifestProguardConfigurationInputs" + Inputs="$(_AndroidR8JniMergedManifest)" + Outputs="$(_AndroidR8JniManifestProguardConfiguration)"> - + - - + + DependsOnTargets="_AndroidGenerateR8JniManifestProguardConfiguration"> <_AndroidR8JniSeedClassFile Include="$(_AndroidR8JniSeedJavaClassDirectory)**\*.class" /> - <_AndroidR8JniSeedAaptProguardConfiguration - Include="$(_AndroidR8JniAaptProguardConfiguration)" - Condition=" '$(ProguardConfigFiles)' == '' and Exists('$(_AndroidR8JniAaptProguardConfiguration)') "> + <_AndroidR8JniSeedManifestProguardConfiguration + Include="$(_AndroidR8JniManifestProguardConfiguration)" + Condition=" '$(ProguardConfigFiles)' == '' and Exists('$(_AndroidR8JniManifestProguardConfiguration)') "> true - true - + true + + reachability, primary, and Xamarin configurations cannot leak into this pass. Manifest + entry rules are retained because final R8 must preserve names referenced by the binary + manifest. --> <_AndroidR8JniSeedProguardConfiguration Include="$(ProguardConfigFiles)" Condition=" '$(ProguardConfigFiles)' != '' " /> <_AndroidR8JniSeedProguardConfiguration Include="@(ProguardConfiguration)" Condition=" '$(ProguardConfigFiles)' == '' and '%(ProguardConfiguration.AndroidGeneratedProguardConfiguration)' != 'true' " /> <_AndroidR8JniSeedProguardConfiguration - Include="@(_AndroidR8JniSeedAaptProguardConfiguration)" + Include="@(_AndroidR8JniSeedManifestProguardConfiguration)" Condition=" '$(ProguardConfigFiles)' == '' " /> <_AndroidR8JniSeedMapDiagnostics Condition=" '$(AndroidR8IgnoreWarnings)' == 'true' " Include="warning" To="info" /> diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs index 5829068128a..fd8c2ff33d6 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs @@ -2012,6 +2012,24 @@ public static string XA4327_JavaSourcePathMappingConflict { } } + /// + /// Looks up a localized string similar to The merged Android manifest '{0}' does not contain a package name.. + /// + public static string XA4327_ManifestPackageMissing { + get { + return ResourceManager.GetString("XA4327_ManifestPackageMissing", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not read the merged Android manifest '{0}': {1}. + /// + public static string XA4327_ManifestReadFailure { + get { + return ResourceManager.GetString("XA4327_ManifestReadFailure", resourceCulture); + } + } + /// /// Looks up a localized string similar to Could not scan the linked assembly '{0}' for rewritten JNI references: {1}. /// diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx index 57be1b08f3c..053614fbac5 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx @@ -924,6 +924,17 @@ Remove the '{0}' reference from your project and add the '{1}' NuGet package ins Could not uniquely reverse-map generated JNI class '{0}' to its original Java source path. The following literal names should not be translated: JNI, Java {0} - The obfuscated JNI class name whose original Java source path could not be determined. + + + The merged Android manifest '{0}' does not contain a package name. + The following literal name should not be translated: Android +{0} - The path to the merged Android manifest. + + + Could not read the merged Android manifest '{0}': {1} + The following literal name should not be translated: Android +{0} - The path to the merged Android manifest. +{1} - The underlying message describing why the manifest could not be read. It is not localized. Could not scan the linked assembly '{0}' for rewritten JNI references: {1} diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateR8JniManifestProguardConfiguration.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateR8JniManifestProguardConfiguration.cs new file mode 100644 index 00000000000..3d4828bd7c1 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateR8JniManifestProguardConfiguration.cs @@ -0,0 +1,92 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Build.Framework; + +using Microsoft.Android.Build.Tasks; + +namespace Xamarin.Android.Tasks; + +public sealed class GenerateR8JniManifestProguardConfiguration : AndroidTask +{ + static readonly XNamespace AndroidNamespace = "http://schemas.android.com/apk/res/android"; + + public override string TaskPrefix => "GRJMPC"; + + [Required] + public string AndroidManifestFile { get; set; } = ""; + + [Required] + public string OutputFile { get; set; } = ""; + + public override bool RunTask () + { + XDocument manifest; + try { + manifest = XDocument.Load (AndroidManifestFile, LoadOptions.None); + } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is XmlException) { + LogR8JniMappingError (string.Format (Properties.Resources.XA4327_ManifestReadFailure, AndroidManifestFile, ex.Message)); + return false; + } + + XElement? root = manifest.Root; + string? packageName = root?.Attribute ("package")?.Value; + if (root?.Name.LocalName != "manifest" || packageName.IsNullOrWhiteSpace ()) { + LogR8JniMappingError (string.Format (Properties.Resources.XA4327_ManifestPackageMissing, AndroidManifestFile)); + return false; + } + + var classes = new SortedSet (StringComparer.Ordinal); + foreach (XElement element in root.DescendantsAndSelf ()) { + switch (element.Name.LocalName) { + case "application": + AddClass (classes, packageName, element, "name"); + AddClass (classes, packageName, element, "backupAgent"); + AddClass (classes, packageName, element, "appComponentFactory"); + AddClass (classes, packageName, element, "zygotePreloadName"); + break; + case "activity": + case "service": + case "receiver": + case "provider": + case "instrumentation": + case "process": + AddClass (classes, packageName, element, "name"); + break; + case "activity-alias": + AddClass (classes, packageName, element, "targetActivity"); + break; + } + } + + string content = string.Join ("\n", classes.Select (name => $"-keep class {name} {{ (); }}")); + if (content.Length > 0) { + content += "\n"; + } + File.WriteAllText (OutputFile, content, Files.UTF8withoutBOM); + return !Log.HasLoggedErrors; + } + + static void AddClass (ISet classes, string packageName, XElement element, string attributeName) + { + string? value = element.Attribute (AndroidNamespace + attributeName)?.Value; + if (value.IsNullOrWhiteSpace () || value [0] == '@' || value [0] == '?') { + return; + } + if (value [0] == '.') { + classes.Add (packageName + value); + } else if (value.IndexOf ('.') < 0) { + classes.Add (packageName + "." + value); + } else { + classes.Add (value); + } + } + + void LogR8JniMappingError (string detail) => + Log.LogCodedError ("XA4327", Properties.Resources.XA4327, detail); +} diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs index e4bbd4ee108..d7dcaa748b9 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs @@ -312,10 +312,6 @@ protected override string CreateResponseFile () option, file, DescribeProguardSource (item)); continue; } - if (GenerateSeedMapping && - string.Equals (item.GetMetadata ("AndroidAaptProguardConfiguration"), bool.TrueString, StringComparison.OrdinalIgnoreCase)) { - file = CreateR8JniSeedAaptConfiguration (file); - } if (EnableObfuscation && string.Equals (item.GetMetadata ("AndroidSdkBaselineProguardConfiguration"), bool.TrueString, StringComparison.OrdinalIgnoreCase)) { file = CreateR8JniBaselineConfiguration (file); @@ -328,53 +324,6 @@ protected override string CreateResponseFile () return responseFile; } - string CreateR8JniSeedAaptConfiguration (string path) - { - string content = KeepAaptManifestRules (File.ReadAllText (path)); - string temp = Path.GetTempFileName (); - tempFiles.Add (temp); - File.WriteAllText (temp, content, Files.UTF8withoutBOM); - return temp; - } - - internal static string KeepAaptManifestRules (string content) - { - bool endsWithNewLine = content.EndsWith ("\n", StringComparison.Ordinal) || content.EndsWith ("\r", StringComparison.Ordinal); - string [] lines = content.Replace ("\r\n", "\n").Replace ('\r', '\n').Split ('\n'); - var filtered = new List (lines.Length); - bool keepSection = true; - bool foundReference = false; - foreach (string line in lines) { - if (line.StartsWith ("# Referenced at ", StringComparison.Ordinal)) { - foundReference = true; - keepSection = IsAaptManifestReference (line); - } - if (keepSection) { - filtered.Add (line); - } - } - if (!foundReference) { - return content; - } - if (endsWithNewLine && filtered.Count > 0 && filtered [filtered.Count - 1].Length == 0) { - filtered.RemoveAt (filtered.Count - 1); - } - string result = string.Join ("\n", filtered); - return endsWithNewLine ? result + "\n" : result; - } - - static bool IsAaptManifestReference (string line) - { - const string prefix = "# Referenced at "; - string path = line.Substring (prefix.Length); - int lineNumberSeparator = path.LastIndexOf (':'); - if (lineNumberSeparator >= 0 && Int32.TryParse (path.Substring (lineNumberSeparator + 1), out _)) { - path = path.Substring (0, lineNumberSeparator); - } - path = path.Replace ('\\', Path.DirectorySeparatorChar).Replace ('/', Path.DirectorySeparatorChar); - return string.Equals (Path.GetFileName (path), "AndroidManifest.xml", StringComparison.OrdinalIgnoreCase); - } - string CreateR8JniBaselineConfiguration (string path) { string content = File.ReadAllText (path); diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateR8JniManifestProguardConfigurationTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateR8JniManifestProguardConfigurationTests.cs new file mode 100644 index 00000000000..a1c28632526 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateR8JniManifestProguardConfigurationTests.cs @@ -0,0 +1,120 @@ +#nullable enable + +using System.Collections.Generic; +using System.IO; +using Microsoft.Build.Framework; +using NUnit.Framework; +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Build.Tests; + +[TestFixture] +public class GenerateR8JniManifestProguardConfigurationTests : BaseTest +{ + string temp = ""; + string manifest = ""; + string output = ""; + + [SetUp] + public void SetUp () + { + temp = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (temp); + manifest = Path.Combine (temp, "AndroidManifest.xml"); + output = Path.Combine (temp, "manifest_rules.txt"); + } + + [TearDown] + public void TearDown () + { + Directory.Delete (temp, recursive: true); + } + + [Test] + public void WritesDeterministicManifestClassRulesWithoutResolvingResources () + { + File.WriteAllText (manifest, """ + + + + + + + + + + + + + + + """); + + var task = CreateTask (); + Assert.IsTrue (task.Execute (), "Task should succeed without resolving @drawable/icon."); + Assert.AreEqual (""" + -keep class com.example.App { (); } + -keep class com.example.AppProcess { (); } + -keep class com.example.Backup { (); } + -keep class com.example.Instrumentation { (); } + -keep class com.example.MainActivity { (); } + -keep class com.example.Receiver { (); } + -keep class com.example.Service { (); } + -keep class com.example.Zygote { (); } + -keep class other.Factory { (); } + -keep class other.Provider { (); } + """ + "\n", File.ReadAllText (output)); + StringAssert.DoesNotContain ("\r", File.ReadAllText (output), "Manifest rules should use deterministic LF line endings."); + } + + [TestCase (".Relative", "com.example.Relative")] + [TestCase ("Relative", "com.example.Relative")] + [TestCase ("other.FullyQualified", "other.FullyQualified")] + public void NormalizesManifestClassName (string name, string expected) + { + File.WriteAllText (manifest, $$""" + + + + + + """); + + Assert.IsTrue (CreateTask ().Execute ()); + Assert.AreEqual ($"-keep class {expected} {{ (); }}\n", File.ReadAllText (output)); + } + + [Test] + public void InvalidManifestUsesXA4327 () + { + File.WriteAllText (manifest, ""); + var errors = new List (); + + Assert.IsFalse (CreateTask (errors).Execute ()); + Assert.That (errors, Has.Count.EqualTo (1)); + Assert.AreEqual ("XA4327", errors [0].Code); + } + + [Test] + public void MissingPackageUsesXA4327 () + { + File.WriteAllText (manifest, ""); + var errors = new List (); + + Assert.IsFalse (CreateTask (errors).Execute ()); + Assert.That (errors, Has.Count.EqualTo (1)); + Assert.AreEqual ("XA4327", errors [0].Code); + } + + GenerateR8JniManifestProguardConfiguration CreateTask (IList? errors = null) => + new GenerateR8JniManifestProguardConfiguration { + BuildEngine = errors == null ? new MockBuildEngine (TestContext.Out) : new MockBuildEngine (TestContext.Out, errors), + AndroidManifestFile = manifest, + OutputFile = output, + }; +} 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 52ce48e999b..c54fd83b01f 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 @@ -66,22 +66,18 @@ public void GenerateSeedMappingAllowsAcwObfuscation () string commonConfiguration = Path.Combine (path, "xamarin.cfg"); string customConfiguration = Path.Combine (path, "custom.cfg"); string aarConfiguration = Path.Combine (path, "aar-proguard.txt"); - string aaptConfiguration = Path.Combine (path, "aapt-rules.txt"); + string manifestConfiguration = Path.Combine (path, "manifest-rules.txt"); File.WriteAllText (acwMap, "Managed.Peer;com.example.Peer"); File.WriteAllText (customConfiguration, "-dontwarn com.example.**"); File.WriteAllText (aarConfiguration, "-dontwarn com.example.library.**"); - File.WriteAllText (aaptConfiguration, """ - #Auto Generated file. Do not Edit. - # Referenced at obj/manifest/AndroidManifest.xml:10 + File.WriteAllText (manifestConfiguration, """ -keep class com.example.MainActivity { (); } - # Referenced at res/layout/main.xml:1 - -keep class com.example.CustomView { (...); } """); var aarConfigurationItem = new TaskItem (aarConfiguration); aarConfigurationItem.SetMetadata ("OriginalFile", Path.Combine (path, "library.aar")); - var aaptConfigurationItem = new TaskItem (aaptConfiguration); - aaptConfigurationItem.SetMetadata ("AndroidGeneratedProguardConfiguration", "true"); - aaptConfigurationItem.SetMetadata ("AndroidAaptProguardConfiguration", "true"); + var manifestConfigurationItem = new TaskItem (manifestConfiguration); + manifestConfigurationItem.SetMetadata ("AndroidGeneratedProguardConfiguration", "true"); + manifestConfigurationItem.SetMetadata ("AndroidManifestProguardConfiguration", "true"); var task = new R8TestTask { BuildEngine = new MockBuildEngine (TestContext.Out), @@ -92,7 +88,7 @@ public void GenerateSeedMappingAllowsAcwObfuscation () ProguardGeneratedApplicationConfiguration = applicationConfiguration, ProguardCommonXamarinConfiguration = commonConfiguration, ProguardMappingFileOutput = Path.Combine (path, "mapping.txt"), - ProguardConfigurationFiles = new ITaskItem [] { new TaskItem (customConfiguration), aarConfigurationItem, aaptConfigurationItem }, + ProguardConfigurationFiles = new ITaskItem [] { new TaskItem (customConfiguration), aarConfigurationItem, manifestConfigurationItem }, GenerateSeedMapping = true, EnableObfuscation = true, IgnoreWarnings = true, @@ -111,11 +107,11 @@ public void GenerateSeedMappingAllowsAcwObfuscation () Assert.That (configurationFiles, Does.Contain (customConfiguration), "Seed R8 should honor user ProGuard rules."); Assert.That (configurationFiles, Does.Contain (aarConfiguration), "Seed R8 should honor AAR consumer rules."); Assert.That (configurationFiles, Does.Contain (commonConfiguration), "Seed R8 should honor runtime keep rules."); + Assert.That (configurationFiles, Does.Contain (manifestConfiguration), "Seed R8 should preserve manifest entry names."); Assert.That (configurationFiles, Does.Not.Contain (applicationConfiguration), "Seed R8 must not pass the ACW keep configuration."); FileAssert.DoesNotExist (applicationConfiguration, "Seed R8 must not generate obfuscation-blocking ACW keep rules."); StringAssert.Contains ("-keep class mono.MonoRuntimeProvider", configuration); StringAssert.Contains ("-keep class com.example.MainActivity", configuration); - StringAssert.DoesNotContain ("-keep class com.example.CustomView", configuration); StringAssert.DoesNotContain ("-keep class com.example.Peer", configuration); StringAssert.DoesNotContain ("-dontobfuscate", configuration); Assert.That (response, Does.Not.Contain ("--no-minification")); @@ -165,28 +161,6 @@ public void ValidateAppliedMappingUsesXA4327 () } } - [Test] - public void KeepAaptManifestRulesPreservesOnlyManifestSections () - { - const string input = """ - #Auto Generated file. Do not Edit. - # Data from obj/manifest/aapt_rules.txt - # Referenced at C:\project\obj\manifest\AndroidManifest.xml:10 - -keep class com.example.MainActivity { (); } - # Referenced at C:\project\res\layout\main.xml:1 - -keep class com.example.CustomView { (...); } - """; - - Assert.AreEqual (""" - #Auto Generated file. Do not Edit. - # Data from obj/manifest/aapt_rules.txt - # Referenced at C:\project\obj\manifest\AndroidManifest.xml:10 - -keep class com.example.MainActivity { (); } - """, R8.KeepAaptManifestRules (input)); - Assert.AreEqual ("-keep class com.example.Fallback\n", R8.KeepAaptManifestRules ("-keep class com.example.Fallback\n"), - "AAPT format changes should preserve the original configuration rather than silently dropping all rules."); - } - [Test] public void ValidateAppliedMappingAllowsIdentityManifestEntry () { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index 0adfbfd7817..4baac12fe49 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -453,6 +453,26 @@ public R8JniLibraryPeer () { } { "AndroidGeneratedProguardConfiguration", "true" }, }, }); + app.AndroidJavaSources.Add (new BuildItem (AndroidBuildActions.AndroidJavaSource, "R8JniLayoutView.java") { + TextContent = () => """ + package com.example; + public class R8JniLayoutView extends android.view.View { + public R8JniLayoutView (android.content.Context context, android.util.AttributeSet attrs) { + super (context, attrs); + } + } + """, + Encoding = Encoding.ASCII, + Metadata = { { "Bind", "False" } }, + }); + app.AndroidResources.Add (new AndroidItem.AndroidResource ("Resources\\layout\\r8_jni_layout_view.axml") { + TextContent = () => """ + + + """, + }); app.Imports.Add (new Import ("CaptureR8JniSeedConfiguration.targets") { TextContent = () => """ @@ -462,7 +482,7 @@ public R8JniLibraryPeer () { } @@ -491,12 +511,19 @@ public R8JniLibraryPeer () { } var seedConfigurationItems = File.ReadAllLines (FindSingleFile (projectDirectory, "configuration-items.txt")); var seedConfigurationPaths = seedConfigurationItems.Select (item => item.Split ('|') [0]).ToArray (); var finalConfigurationItems = File.ReadAllLines (FindSingleFile (projectDirectory, "final-configuration-items.txt")); + var manifestRules = FindSingleFile (projectDirectory, "manifest_rules.txt"); + var finalAaptRules = FindSingleFile (projectDirectory, "aapt_rules.txt", path => !path.Contains ("r8-jni-seed", StringComparison.Ordinal)); AssertR8MappingRenamesClass (seedMapping, libraryJavaName); StringAssert.Contains ($"C\t{libraryJavaName}", File.ReadAllText (rewriteManifest)); Assert.That (seedConfigurationPaths, Has.Some.EndsWith ("r8-jni-rules.pro"), "Seed R8 should receive user-authored rules."); Assert.That (seedConfigurationPaths, Has.Some.EndsWith ("proguard.txt"), "Seed R8 should receive AAR consumer rules."); - Assert.That (seedConfigurationItems, Has.Some.EndsWith ("aapt_rules.txt|true|true"), - "Seed R8 should receive AAPT keep rules with explicit generated and AAPT provenance."); + Assert.That (seedConfigurationItems, Has.Some.EndsWith ("manifest_rules.txt|true||true"), + "Seed R8 should receive manifest-only keep rules with explicit generated and manifest provenance."); + StringAssert.Contains ($"-keep class {app.PackageName}.MainActivity", File.ReadAllText (manifestRules)); + StringAssert.DoesNotContain ("com.example.R8JniLayoutView", File.ReadAllText (manifestRules), + "Seed manifest rules must not include resource custom views."); + StringAssert.Contains ("-keep class com.example.R8JniLayoutView", File.ReadAllText (finalAaptRules), + "Final AAPT rules should retain resource custom-view rules."); Assert.IsFalse (seedConfigurationPaths.Any (path => new [] { "proguard-android.txt", "proguard_xamarin.cfg", "proguard_project_references.cfg", "proguard_project_primary.cfg", "generated-acw-keep.cfg" }.Contains (Path.GetFileName (path), StringComparer.Ordinal)), "Seed R8 should not receive generated or baseline configurations that pin managed peers."); From 3b99969ba7050d82b1c36a2a74800be09a582a5c Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 2 Sep 2026 05:10:12 +0200 Subject: [PATCH 16/21] Clarify activity alias manifest rules Assert that an activity alias identity is not treated as a Java class while its target activity remains protected. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../GenerateR8JniManifestProguardConfigurationTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateR8JniManifestProguardConfigurationTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateR8JniManifestProguardConfigurationTests.cs index a1c28632526..48ae99a5243 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateR8JniManifestProguardConfigurationTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateR8JniManifestProguardConfigurationTests.cs @@ -57,6 +57,7 @@ public void WritesDeterministicManifestClassRulesWithoutResolvingResources () var task = CreateTask (); Assert.IsTrue (task.Execute (), "Task should succeed without resolving @drawable/icon."); + string rules = File.ReadAllText (output); Assert.AreEqual (""" -keep class com.example.App { (); } -keep class com.example.AppProcess { (); } @@ -68,8 +69,9 @@ public void WritesDeterministicManifestClassRulesWithoutResolvingResources () -keep class com.example.Zygote { (); } -keep class other.Factory { (); } -keep class other.Provider { (); } - """ + "\n", File.ReadAllText (output)); - StringAssert.DoesNotContain ("\r", File.ReadAllText (output), "Manifest rules should use deterministic LF line endings."); + """ + "\n", rules); + StringAssert.DoesNotContain ("com.example.Alias", rules, "An activity alias name is not a Java class."); + StringAssert.DoesNotContain ("\r", rules, "Manifest rules should use deterministic LF line endings."); } [TestCase (".Relative", "com.example.Relative")] From 52e59cfc02b4e5eadddc2d240b78a1af15dbbca4 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 2 Sep 2026 06:22:18 +0200 Subject: [PATCH 17/21] Fix final R8 generated keep routing Prepare the manifest merger directory before seed manifest rule generation, and keep generated ACW pinning rules out of final JNI-obfuscating R8 passes while retaining final AAPT and mapped reachability rules. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...soft.Android.Sdk.TypeMap.Trimmable.targets | 7 +- src/Xamarin.Android.Build.Tasks/Tasks/R8.cs | 24 ++++-- .../Tasks/R8Tests.cs | 75 ++++++++++++++++++- .../TrimmableTypeMapBuildTests.cs | 53 ++++++++++++- .../Xamarin.Android.Common.targets | 5 +- 5 files changed, 150 insertions(+), 14 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index 713fb697088..db9765d4208 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -121,9 +121,14 @@ + + + + + DependsOnTargets="_AndroidCompileR8JniSeedJava;_AndroidPrepareR8JniManifestMergerDirectory;_ManifestMerger"> <_AndroidR8JniMergedManifest>$(_TypeMapBaseOutputDir)AndroidManifest.xml <_AndroidR8JniMergedManifest Condition=" '$(AndroidManifestMerger)' == 'manifestmerger.jar' ">$(IntermediateOutputPath)android/AndroidManifest.xml diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs index d7dcaa748b9..2a0f1ba0b88 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs @@ -244,13 +244,13 @@ protected override string CreateResponseFile () WriteArg (response, ProguardCommonXamarinConfiguration); } } else if (EnableShrinking) { - if (UseTrimmableNativeAotProguardConfiguration && !ProguardGeneratedApplicationConfiguration.IsNullOrEmpty ()) { - // ACW keep rules come from the DGML/acw-map-driven proguard_project_references.cfg on - // the trimmable path. User-authored AndroidJavaSource (Bind != true) has no managed peer - // and is absent from that map, so keep it here explicitly; otherwise R8 shrinks it away - // (e.g. dropping large unreferenced sources so an app that needs multidex no longer does). + if ((UseTrimmableNativeAotProguardConfiguration || EnableObfuscation) && + !ProguardGeneratedApplicationConfiguration.IsNullOrEmpty ()) { + // Managed ACW keep rules come from the mapped proguard_project_references.cfg on + // trimmable JNI-rewriting paths. User-authored AndroidJavaSource (Bind != true) has no + // managed peer and is absent from that map, so keep it here explicitly. using (var appcfg = File.CreateText (ProguardGeneratedApplicationConfiguration)) { - appcfg.WriteLine ("# ACW keep rules are generated from NativeAOT ILC metadata."); + appcfg.WriteLine ("# Managed ACW keep rules are generated separately."); foreach (var java in GetUserJavaTypes ()) { appcfg.WriteLine ($"-keep class {java} {{ *; }}"); } @@ -302,6 +302,9 @@ protected override string CreateResponseFile () } if (ProguardConfigurationFiles != null) { foreach (var item in ProguardConfigurationFiles) { + if (!GenerateSeedMapping && EnableObfuscation && IsGeneratedAcwKeepConfiguration (item)) { + continue; + } var file = item.ItemSpec; if (!File.Exists (file)) { Log.LogCodedWarning ("XA4304", file, 0, Properties.Resources.XA4304, file); @@ -324,6 +327,15 @@ protected override string CreateResponseFile () return responseFile; } + static bool IsGeneratedAcwKeepConfiguration (ITaskItem item) + { + if (!string.Equals (item.GetMetadata ("AndroidGeneratedProguardConfiguration"), bool.TrueString, StringComparison.OrdinalIgnoreCase)) { + return false; + } + return !string.Equals (item.GetMetadata ("AndroidAaptProguardConfiguration"), bool.TrueString, StringComparison.OrdinalIgnoreCase) && + !string.Equals (item.GetMetadata ("AndroidR8JniMappedProguardConfiguration"), bool.TrueString, StringComparison.OrdinalIgnoreCase); + } + string CreateR8JniBaselineConfiguration (string path) { string content = File.ReadAllText (path); 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 c54fd83b01f..fafbb967773 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 @@ -206,7 +206,10 @@ public void R8JniObfuscationFiltersOnlySdkBaselineNativeKeepRule () string seedMapping = Path.Combine (path, "mapping.txt"); string baseline = Path.Combine (path, "proguard-android.txt"); string user = Path.Combine (path, "user.pro"); + string aar = Path.Combine (path, "aar-consumer.pro"); string generated = Path.Combine (path, "proguard_project_references.cfg"); + string generatedAcwKeep = Path.Combine (path, "generated-acw-keep.cfg"); + string aapt = Path.Combine (path, "aapt_rules.txt"); File.WriteAllText (seedMapping, "com.example.Peer -> a:\n"); string nativeKeepRule = """ -keepclasseswithmembernames,includedescriptorclasses class * { @@ -215,30 +218,89 @@ public void R8JniObfuscationFiltersOnlySdkBaselineNativeKeepRule () """; File.WriteAllText (baseline, $"-dontwarn before\n{nativeKeepRule}\n-dontwarn after\n"); File.WriteAllText (user, nativeKeepRule + "\n"); + File.WriteAllText (aar, "-dontwarn com.example.library.**\n"); File.WriteAllText (generated, "-keep,allowobfuscation class com.example.Peer\n"); + File.WriteAllText (generatedAcwKeep, "-keep class com.example.Peer { *; }\n"); + File.WriteAllText (aapt, "-keep class com.example.MainActivity { (); }\n"); var baselineItem = new TaskItem (baseline); baselineItem.SetMetadata ("AndroidSdkBaselineProguardConfiguration", "true"); + var aarItem = new TaskItem (aar); + aarItem.SetMetadata ("OriginalFile", Path.Combine (path, "library.aar")); + var generatedItem = new TaskItem (generated); + generatedItem.SetMetadata ("AndroidGeneratedProguardConfiguration", "true"); + generatedItem.SetMetadata ("AndroidR8JniMappedProguardConfiguration", "true"); + var generatedAcwKeepItem = new TaskItem (generatedAcwKeep); + generatedAcwKeepItem.SetMetadata ("AndroidGeneratedProguardConfiguration", "true"); + var aaptItem = new TaskItem (aapt); + aaptItem.SetMetadata ("AndroidGeneratedProguardConfiguration", "true"); + aaptItem.SetMetadata ("AndroidAaptProguardConfiguration", "true"); var task = CreateR8TestTask ( path, - new ITaskItem [] { baselineItem, new TaskItem (user), new TaskItem (generated) }, + new ITaskItem [] { baselineItem, new TaskItem (user), aarItem, generatedItem, generatedAcwKeepItem, aaptItem }, enableObfuscation: true, seedMapping); task.TestGenerateCommandLineCommands (); responseFile = task.ResponseFilePath; string [] configurationFiles = GetConfigurationFiles (responseFile); - string filteredBaseline = configurationFiles.Single (file => file != user && file != generated && File.ReadAllText (file).Contains ("-dontwarn before")); + string filteredBaseline = configurationFiles.Single (file => + file != user && file != aar && file != generated && file != aapt && + File.ReadAllText (file).Contains ("-dontwarn before")); string allConfiguration = string.Join ("\n", configurationFiles.Select (File.ReadAllText)); Assert.AreNotEqual (baseline, filteredBaseline); Assert.AreEqual ("-dontwarn before\n-dontwarn after\n", File.ReadAllText (filteredBaseline)); Assert.That (configurationFiles, Does.Contain (user), "User rules with identical text must remain untouched."); + Assert.That (configurationFiles, Does.Contain (aar), "AAR consumer rules must remain untouched."); + Assert.That (configurationFiles, Does.Contain (generated), "Mapped linked-assembly rules must reach final R8."); + Assert.That (configurationFiles, Does.Contain (aapt), "Final AAPT manifest and resource rules must reach final R8."); + Assert.That (configurationFiles, Does.Not.Contain (generatedAcwKeep), "Generated legacy ACW keep rules must not reach final R8."); StringAssert.Contains ("-applymapping", allConfiguration); StringAssert.Contains ("-keep,allowobfuscation class com.example.Peer", allConfiguration); + StringAssert.Contains ("-keep class com.example.MainActivity", allConfiguration); Assert.AreEqual (1, allConfiguration.Split (new [] { "native ;" }, StringSplitOptions.None).Length - 1, "Only the user-authored native rule should remain."); - DeleteTemporaryConfigurations (configurationFiles, user, generated); + DeleteTemporaryConfigurations (configurationFiles, user, aar, generated, aapt); + } finally { + if (File.Exists (responseFile)) { + File.Delete (responseFile); + } + Directory.Delete (path, recursive: true); + } + } + + [TestCase (false, true)] + [TestCase (true, false)] + public void GeneratedApplicationConfigurationOmitsManagedAcwsOnlyForJniObfuscation (bool enableObfuscation, bool expectManagedAcw) + { + string path = Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ("N")); + Directory.CreateDirectory (path); + string responseFile = ""; + try { + string acwMap = Path.Combine (path, "acw-map.txt"); + string javaSource = Path.Combine (path, "UserJava.java"); + string applicationConfiguration = Path.Combine (path, "proguard_project_primary.cfg"); + File.WriteAllText (acwMap, "Managed.Peer;com.example.ManagedPeer\n"); + File.WriteAllText (javaSource, "package com.example; public class UserJava {}\n"); + var task = new R8TestTask { + BuildEngine = new MockBuildEngine (TestContext.Out), + AcwMapFile = acwMap, + EnableObfuscation = enableObfuscation, + EnableShrinking = true, + JarPath = "r8.jar", + JavaSourceFiles = new ITaskItem [] { new TaskItem (javaSource) }, + JavaPlatformJarPath = "android.jar", + OutputDirectory = path, + ProguardGeneratedApplicationConfiguration = applicationConfiguration, + }; + + task.TestGenerateCommandLineCommands (); + responseFile = task.ResponseFilePath; + string configuration = File.ReadAllText (applicationConfiguration); + + Assert.AreEqual (expectManagedAcw, configuration.Contains ("-keep class com.example.ManagedPeer", StringComparison.Ordinal)); + StringAssert.Contains ("-keep class com.example.UserJava { *; }", configuration); } finally { if (File.Exists (responseFile)) { File.Delete (responseFile); @@ -255,17 +317,22 @@ public void R8WithoutJniObfuscationPassesSdkBaselineUnchanged () string responseFile = ""; try { string baseline = Path.Combine (path, "proguard-android.txt"); + string generatedAcwKeep = Path.Combine (path, "generated-acw-keep.cfg"); string content = "-keepclasseswithmembernames,includedescriptorclasses class * {\n native ;\n}\n"; File.WriteAllText (baseline, content); + File.WriteAllText (generatedAcwKeep, "-keep class com.example.Peer { *; }\n"); var baselineItem = new TaskItem (baseline); baselineItem.SetMetadata ("AndroidSdkBaselineProguardConfiguration", "true"); - var task = CreateR8TestTask (path, new ITaskItem [] { baselineItem }, enableObfuscation: false); + var generatedAcwKeepItem = new TaskItem (generatedAcwKeep); + generatedAcwKeepItem.SetMetadata ("AndroidGeneratedProguardConfiguration", "true"); + var task = CreateR8TestTask (path, new ITaskItem [] { baselineItem, generatedAcwKeepItem }, enableObfuscation: false); task.TestGenerateCommandLineCommands (); responseFile = task.ResponseFilePath; string [] configurationFiles = GetConfigurationFiles (responseFile); Assert.That (configurationFiles, Does.Contain (baseline)); + Assert.That (configurationFiles, Does.Contain (generatedAcwKeep), "Feature-off builds must retain generated ACW keep rules."); Assert.AreEqual (content, File.ReadAllText (baseline)); } finally { if (File.Exists (responseFile)) { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index 4baac12fe49..4eb66992d3a 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -247,6 +247,7 @@ public R8JniPeer () { } proj.SetProperty ("AndroidTypeMapImplementation", "trimmable"); proj.SetProperty ("AndroidEnableR8JniNameObfuscation", "true"); proj.SetProperty ("AndroidCreateProguardMappingFile", "false"); + proj.Imports.Add (CreateR8JniManifestMergerDirectoryAssertionImport ()); using var builder = CreateApkBuilder (Path.Combine ("temp", $"R8JniNameRewriting_{runtime}_{Guid.NewGuid ():N}")); Assert.IsTrue (builder.Build (proj), "Clean R8 JNI name-rewriting build should have succeeded."); @@ -374,6 +375,7 @@ public R8JniMultiAbiPeer () { } proj.SetProperty ("AndroidPackageFormat", "apk"); proj.SetProperty ("AndroidTypeMapImplementation", "trimmable"); proj.SetProperty ("AndroidEnableR8JniNameObfuscation", "true"); + proj.Imports.Add (CreateR8JniManifestMergerDirectoryAssertionImport ()); using var builder = CreateApkBuilder (Path.Combine ("temp", $"R8JniNameRewritingMultiAbi_{runtime}_{Guid.NewGuid ():N}")); Assert.IsTrue (builder.Build (proj), "Multi-ABI R8 JNI name-rewriting build should have succeeded."); @@ -491,7 +493,7 @@ public R8JniLayoutView (android.content.Context context, android.util.AttributeS BeforeTargets="_CompileToDalvik"> @@ -507,13 +509,19 @@ public R8JniLayoutView (android.content.Context context, android.util.AttributeS var projectDirectory = Path.Combine (Root, appBuilder.ProjectDirectory); var seedMapping = FindSingleFile (projectDirectory, "mapping.txt", path => path.Contains ("r8-jni-seed", StringComparison.Ordinal)); + var finalMapping = FindSingleFile (projectDirectory, "r8-jni-final-mapping.txt"); var rewriteManifest = FindSingleFile (projectDirectory, "r8-jni-rewrite-manifest.txt"); var seedConfigurationItems = File.ReadAllLines (FindSingleFile (projectDirectory, "configuration-items.txt")); var seedConfigurationPaths = seedConfigurationItems.Select (item => item.Split ('|') [0]).ToArray (); var finalConfigurationItems = File.ReadAllLines (FindSingleFile (projectDirectory, "final-configuration-items.txt")); var manifestRules = FindSingleFile (projectDirectory, "manifest_rules.txt"); var finalAaptRules = FindSingleFile (projectDirectory, "aapt_rules.txt", path => !path.Contains ("r8-jni-seed", StringComparison.Ordinal)); + var mappedProjectRules = FindSingleFile (projectDirectory, "proguard_project_references.cfg"); + var primaryRules = FindSingleFile (projectDirectory, "proguard_project_primary.cfg"); AssertR8MappingRenamesClass (seedMapping, libraryJavaName); + AssertR8MappingRenamesClass (finalMapping, libraryJavaName); + AssertR8MappingContainsMember (finalMapping, libraryJavaName, "nctor"); + AssertR8MappingContainsMember (finalMapping, $"{app.PackageName}/MainActivity", "n_OnCreate"); StringAssert.Contains ($"C\t{libraryJavaName}", File.ReadAllText (rewriteManifest)); Assert.That (seedConfigurationPaths, Has.Some.EndsWith ("r8-jni-rules.pro"), "Seed R8 should receive user-authored rules."); Assert.That (seedConfigurationPaths, Has.Some.EndsWith ("proguard.txt"), "Seed R8 should receive AAR consumer rules."); @@ -527,8 +535,20 @@ public R8JniLayoutView (android.content.Context context, android.util.AttributeS Assert.IsFalse (seedConfigurationPaths.Any (path => new [] { "proguard-android.txt", "proguard_xamarin.cfg", "proguard_project_references.cfg", "proguard_project_primary.cfg", "generated-acw-keep.cfg" }.Contains (Path.GetFileName (path), StringComparer.Ordinal)), "Seed R8 should not receive generated or baseline configurations that pin managed peers."); - Assert.That (finalConfigurationItems, Does.Contain ("proguard-android.txt|true"), + Assert.That (finalConfigurationItems, Does.Contain ("proguard-android.txt|true|||"), "Final R8 should identify only the SDK baseline by explicit provenance metadata."); + Assert.That (finalConfigurationItems, Has.Some.EqualTo ("generated-acw-keep.cfg||true||"), + "Generated ACW keep rules should carry semantic provenance so R8 can exclude them."); + Assert.That (finalConfigurationItems, Has.Some.EqualTo ("aapt_rules.txt||true|true|"), + "Final AAPT rules should carry specific provenance so R8 retains them."); + Assert.That (finalConfigurationItems, Has.Some.EqualTo ("proguard_project_references.cfg||true||true"), + "Mapped linked-assembly rules should carry specific provenance so R8 retains them."); + StringAssert.Contains ($"-keep,allowobfuscation class {libraryJavaName.Replace ('/', '.')}", File.ReadAllText (mappedProjectRules)); + StringAssert.Contains ("-keepclassmembers,allowobfuscation", File.ReadAllText (mappedProjectRules)); + Assert.That ( + File.ReadAllLines (primaryRules).Where (line => line.StartsWith ("-keep class ", StringComparison.Ordinal)), + Is.EqualTo (new [] { "-keep class com.example.R8JniLayoutView { *; }" }), + "The final primary configuration must pin only user-authored Java without a managed peer."); proguardRule = "-dontwarn com.example.UnusedTwo"; app.Touch ("r8-jni-rules.pro"); @@ -1686,6 +1706,35 @@ static void AssertR8MappingKeepsClassName (string mappingFile, string originalJn $"R8 should preserve the binary Android resource entry point {originalName}."); } + static void AssertR8MappingContainsMember (string mappingFile, string originalJniName, string memberName) + { + string originalName = originalJniName.Replace ('/', '.'); + var classMapping = Regex.Match ( + File.ReadAllText (mappingFile), + $"^{Regex.Escape (originalName)} -> [^:]+:\\r?\\n(?(?: .*\\r?\\n)*)", + RegexOptions.Multiline); + Assert.IsTrue (classMapping.Success, $"Expected {mappingFile} to contain a mapping for {originalName}."); + Assert.That ( + classMapping.Groups ["members"].Value, + Does.Match ($@"\b{Regex.Escape (memberName)}\([^)]*\) -> "), + $"Expected {mappingFile} to retain and map {originalName}.{memberName}."); + } + + static Import CreateR8JniManifestMergerDirectoryAssertionImport () + => new Import ("AssertR8JniManifestMergerDirectory.targets") { + TextContent = () => """ + + + + + + """, + }; + DynamicCodeSupportProfile BuildDynamicCodeSupportProfile (string typemapImplementation, bool? dynamicCodeSupport) { var dynamicCodeSuffix = dynamicCodeSupport.HasValue ? $"_{dynamicCodeSupport.Value.ToString ().ToLowerInvariant ()}" : ""; diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index 5ab4792537f..ccd85e32efd 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -2045,7 +2045,10 @@ because xbuild doesn't support framework reference assemblies. true <_ProguardConfiguration Include="$(IntermediateOutputPath)proguard\proguard_xamarin.cfg" Condition=" '$(AndroidLinkTool)' != '' " /> - <_ProguardConfiguration Include="$(_ProguardProjectConfiguration)" Condition=" '$(AndroidLinkTool)' != '' " /> + <_ProguardConfiguration Include="$(_ProguardProjectConfiguration)" Condition=" '$(AndroidLinkTool)' != '' "> + true + true + <_ProguardConfiguration Include="$(IntermediateOutputPath)proguard\proguard_project_primary.cfg" Condition=" '$(AndroidLinkTool)' != '' " /> <_ProguardConfiguration Include="@(ProguardConfiguration)" /> From 134519e6cba52105e0d93451f91a65c878a046bf Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 2 Sep 2026 07:21:06 +0200 Subject: [PATCH 18/21] Prepare per-RID manifest merger inputs Copy the shared trimmable manifest into each inner build before seed manifest merging, and validate final R8 output through its configured public mapping path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...icrosoft.Android.Sdk.TypeMap.Trimmable.targets | 9 +++++++-- .../TrimmableTypeMapBuildTests.cs | 15 +++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index db9765d4208..4cecafbb3f3 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -121,14 +121,19 @@ - + + DependsOnTargets="_AndroidCompileR8JniSeedJava;_AndroidPrepareR8JniManifestMergerInputs;_ManifestMerger"> <_AndroidR8JniMergedManifest>$(_TypeMapBaseOutputDir)AndroidManifest.xml <_AndroidR8JniMergedManifest Condition=" '$(AndroidManifestMerger)' == 'manifestmerger.jar' ">$(IntermediateOutputPath)android/AndroidManifest.xml diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index 4eb66992d3a..b72b612bd7c 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -247,7 +247,7 @@ public R8JniPeer () { } proj.SetProperty ("AndroidTypeMapImplementation", "trimmable"); proj.SetProperty ("AndroidEnableR8JniNameObfuscation", "true"); proj.SetProperty ("AndroidCreateProguardMappingFile", "false"); - proj.Imports.Add (CreateR8JniManifestMergerDirectoryAssertionImport ()); + proj.Imports.Add (CreateR8JniManifestMergerInputsAssertionImport ()); using var builder = CreateApkBuilder (Path.Combine ("temp", $"R8JniNameRewriting_{runtime}_{Guid.NewGuid ():N}")); Assert.IsTrue (builder.Build (proj), "Clean R8 JNI name-rewriting build should have succeeded."); @@ -375,7 +375,7 @@ public R8JniMultiAbiPeer () { } proj.SetProperty ("AndroidPackageFormat", "apk"); proj.SetProperty ("AndroidTypeMapImplementation", "trimmable"); proj.SetProperty ("AndroidEnableR8JniNameObfuscation", "true"); - proj.Imports.Add (CreateR8JniManifestMergerDirectoryAssertionImport ()); + proj.Imports.Add (CreateR8JniManifestMergerInputsAssertionImport ()); using var builder = CreateApkBuilder (Path.Combine ("temp", $"R8JniNameRewritingMultiAbi_{runtime}_{Guid.NewGuid ():N}")); Assert.IsTrue (builder.Build (proj), "Multi-ABI R8 JNI name-rewriting build should have succeeded."); @@ -509,7 +509,7 @@ public R8JniLayoutView (android.content.Context context, android.util.AttributeS var projectDirectory = Path.Combine (Root, appBuilder.ProjectDirectory); var seedMapping = FindSingleFile (projectDirectory, "mapping.txt", path => path.Contains ("r8-jni-seed", StringComparison.Ordinal)); - var finalMapping = FindSingleFile (projectDirectory, "r8-jni-final-mapping.txt"); + var finalMapping = FindSingleFile (projectDirectory, "mapping.txt", path => !path.Contains ("r8-jni-seed", StringComparison.Ordinal)); var rewriteManifest = FindSingleFile (projectDirectory, "r8-jni-rewrite-manifest.txt"); var seedConfigurationItems = File.ReadAllLines (FindSingleFile (projectDirectory, "configuration-items.txt")); var seedConfigurationPaths = seedConfigurationItems.Select (item => item.Split ('|') [0]).ToArray (); @@ -520,8 +520,8 @@ public R8JniLayoutView (android.content.Context context, android.util.AttributeS var primaryRules = FindSingleFile (projectDirectory, "proguard_project_primary.cfg"); AssertR8MappingRenamesClass (seedMapping, libraryJavaName); AssertR8MappingRenamesClass (finalMapping, libraryJavaName); - AssertR8MappingContainsMember (finalMapping, libraryJavaName, "nctor"); - AssertR8MappingContainsMember (finalMapping, $"{app.PackageName}/MainActivity", "n_OnCreate"); + AssertR8MappingContainsMember (finalMapping, libraryJavaName, "nctor_0"); + AssertR8MappingContainsMember (finalMapping, $"{app.PackageName}/MainActivity", "n_OnCreate_Landroid_os_Bundle_"); StringAssert.Contains ($"C\t{libraryJavaName}", File.ReadAllText (rewriteManifest)); Assert.That (seedConfigurationPaths, Has.Some.EndsWith ("r8-jni-rules.pro"), "Seed R8 should receive user-authored rules."); Assert.That (seedConfigurationPaths, Has.Some.EndsWith ("proguard.txt"), "Seed R8 should receive AAR consumer rules."); @@ -1720,7 +1720,7 @@ static void AssertR8MappingContainsMember (string mappingFile, string originalJn $"Expected {mappingFile} to retain and map {originalName}.{memberName}."); } - static Import CreateR8JniManifestMergerDirectoryAssertionImport () + static Import CreateR8JniManifestMergerInputsAssertionImport () => new Import ("AssertR8JniManifestMergerDirectory.targets") { TextContent = () => """ @@ -1730,6 +1730,9 @@ static Import CreateR8JniManifestMergerDirectoryAssertionImport () + """, From 77b3dc43ae5730e6450216875708ab8753b3758e Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 2 Sep 2026 08:27:09 +0200 Subject: [PATCH 19/21] Fix R8 JNI incremental validation tests Allow blank records when validating generated reachability manifests, and resolve the final AAPT rules through their explicit configuration provenance instead of filesystem name uniqueness. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TrimmableTypeMapBuildTests.cs | 20 +++++++++++++------ .../Utilities/JniRemapping/R8MappingTests.cs | 12 +++++++++++ .../Utilities/JniRemapping/R8Mapping.cs | 3 +++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index b72b612bd7c..340c68d5f19 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -493,7 +493,7 @@ public R8JniLayoutView (android.content.Context context, android.util.AttributeS BeforeTargets="_CompileToDalvik"> @@ -514,10 +514,16 @@ public R8JniLayoutView (android.content.Context context, android.util.AttributeS var seedConfigurationItems = File.ReadAllLines (FindSingleFile (projectDirectory, "configuration-items.txt")); var seedConfigurationPaths = seedConfigurationItems.Select (item => item.Split ('|') [0]).ToArray (); var finalConfigurationItems = File.ReadAllLines (FindSingleFile (projectDirectory, "final-configuration-items.txt")); + var finalAaptConfiguration = finalConfigurationItems + .Select (item => item.Split ('|')) + .Single (metadata => metadata.Length == 5 && metadata [2] == "true" && metadata [3] == "true"); var manifestRules = FindSingleFile (projectDirectory, "manifest_rules.txt"); - var finalAaptRules = FindSingleFile (projectDirectory, "aapt_rules.txt", path => !path.Contains ("r8-jni-seed", StringComparison.Ordinal)); + var finalAaptRules = Path.IsPathRooted (finalAaptConfiguration [0]) + ? finalAaptConfiguration [0] + : Path.Combine (projectDirectory, finalAaptConfiguration [0]); var mappedProjectRules = FindSingleFile (projectDirectory, "proguard_project_references.cfg"); var primaryRules = FindSingleFile (projectDirectory, "proguard_project_primary.cfg"); + FileAssert.Exists (finalAaptRules, "The final configured AAPT rules should exist."); AssertR8MappingRenamesClass (seedMapping, libraryJavaName); AssertR8MappingRenamesClass (finalMapping, libraryJavaName); AssertR8MappingContainsMember (finalMapping, libraryJavaName, "nctor_0"); @@ -532,16 +538,18 @@ public R8JniLayoutView (android.content.Context context, android.util.AttributeS "Seed manifest rules must not include resource custom views."); StringAssert.Contains ("-keep class com.example.R8JniLayoutView", File.ReadAllText (finalAaptRules), "Final AAPT rules should retain resource custom-view rules."); + StringAssert.Contains ("#Auto Generated file", File.ReadAllText (finalAaptRules), + "The final configured AAPT file should wrap the generated manifest and resource rules."); Assert.IsFalse (seedConfigurationPaths.Any (path => new [] { "proguard-android.txt", "proguard_xamarin.cfg", "proguard_project_references.cfg", "proguard_project_primary.cfg", "generated-acw-keep.cfg" }.Contains (Path.GetFileName (path), StringComparer.Ordinal)), "Seed R8 should not receive generated or baseline configurations that pin managed peers."); - Assert.That (finalConfigurationItems, Does.Contain ("proguard-android.txt|true|||"), + Assert.That (finalConfigurationItems, Has.Some.EndsWith ("proguard-android.txt|true|||"), "Final R8 should identify only the SDK baseline by explicit provenance metadata."); - Assert.That (finalConfigurationItems, Has.Some.EqualTo ("generated-acw-keep.cfg||true||"), + Assert.That (finalConfigurationItems, Has.Some.EndsWith ("generated-acw-keep.cfg||true||"), "Generated ACW keep rules should carry semantic provenance so R8 can exclude them."); - Assert.That (finalConfigurationItems, Has.Some.EqualTo ("aapt_rules.txt||true|true|"), + Assert.That (finalConfigurationItems, Has.Some.EndsWith ("aapt_rules.txt||true|true|"), "Final AAPT rules should carry specific provenance so R8 retains them."); - Assert.That (finalConfigurationItems, Has.Some.EqualTo ("proguard_project_references.cfg||true||true"), + Assert.That (finalConfigurationItems, Has.Some.EndsWith ("proguard_project_references.cfg||true||true"), "Mapped linked-assembly rules should carry specific provenance so R8 retains them."); StringAssert.Contains ($"-keep,allowobfuscation class {libraryJavaName.Replace ('/', '.')}", File.ReadAllText (mappedProjectRules)); StringAssert.Contains ("-keepclassmembers,allowobfuscation", File.ReadAllText (mappedProjectRules)); diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs index 8753eccdaa5..f3aba5bb98d 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs @@ -431,6 +431,18 @@ void kept() -> a })); } + [Test] + public void ReachabilityManifestIgnoresEmptyLines () + { + R8Mapping seed = R8Mapping.Parse (new StringReader ("acme.orig.MyView -> a.b.C:\n")); + R8Mapping final = R8Mapping.Parse (new StringReader ("acme.orig.MyView -> a.b.C:\n")); + + CollectionAssert.IsEmpty (seed.GetReachabilityConflicts (final, new [] { + "C\tacme/orig/MyView", + "", + })); + } + [TestCase ("F\tacme/orig/MyView\tcount")] [TestCase ("M\tacme/orig/MyView\tonClick():void")] public void MemberOnlyManifestReportsRemovedDeclaringClass (string requiredEntry) diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs index 5eea545b47d..343a479b492 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -421,6 +421,9 @@ public IEnumerable GetReachabilityConflicts (R8Mapping finalMapping, IEn { var reportedRemovedClasses = new HashSet (StringComparer.Ordinal); foreach (string requiredEntry in requiredEntries) { + if (requiredEntry.Length == 0) { + continue; + } string [] parts = requiredEntry.Split ('\t'); switch (parts.Length > 0 ? parts [0] : "") { case "C" when parts.Length == 2: From 86bb28f2963d015fba49908a9f890d49bcff02fa Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 2 Sep 2026 12:17:51 +0200 Subject: [PATCH 20/21] Handle R8 mapping metadata in integration assertions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TrimmableTypeMapBuildTests.cs | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index 340c68d5f19..4670b189573 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -568,6 +568,23 @@ public R8JniLayoutView (android.content.Context context, android.util.AttributeS appBuilder.Output.AssertTargetIsNotSkipped ("_CompileToDalvik"); } + [Test] + public void R8MappingMemberAssertion_AllowsSourceFileMetadata () + { + var mappingFile = Path.GetTempFileName (); + try { + File.WriteAllText (mappingFile, """ + com.example.R8JniLibraryPeer -> f: + # {"id":"sourceFile","fileName":"R8JniLibraryPeer.java"} + void nctor_0() -> a + """); + + AssertR8MappingContainsMember (mappingFile, "com/example/R8JniLibraryPeer", "nctor_0"); + } finally { + File.Delete (mappingFile); + } + } + [Test] public void Build_WithTrimmableTypeMap_MissingJavaListPreservesGeneratedJava () { @@ -1717,15 +1734,20 @@ static void AssertR8MappingKeepsClassName (string mappingFile, string originalJn static void AssertR8MappingContainsMember (string mappingFile, string originalJniName, string memberName) { string originalName = originalJniName.Replace ('/', '.'); - var classMapping = Regex.Match ( - File.ReadAllText (mappingFile), - $"^{Regex.Escape (originalName)} -> [^:]+:\\r?\\n(?(?: .*\\r?\\n)*)", - RegexOptions.Multiline); - Assert.IsTrue (classMapping.Success, $"Expected {mappingFile} to contain a mapping for {originalName}."); - Assert.That ( - classMapping.Groups ["members"].Value, - Does.Match ($@"\b{Regex.Escape (memberName)}\([^)]*\) -> "), - $"Expected {mappingFile} to retain and map {originalName}.{memberName}."); + var lines = File.ReadAllLines (mappingFile); + string classHeader = $"{originalName} -> "; + int classIndex = Array.FindIndex (lines, line => + line.StartsWith (classHeader, StringComparison.Ordinal) && + line.EndsWith (":", StringComparison.Ordinal)); + Assert.That (classIndex, Is.GreaterThanOrEqualTo (0), $"Expected {mappingFile} to contain a mapping for {originalName}."); + + var classMapping = lines + .Skip (classIndex + 1) + .TakeWhile (line => line.StartsWith (" ", StringComparison.Ordinal) || line.StartsWith ("#", StringComparison.Ordinal)) + .ToArray (); + Assert.IsTrue ( + classMapping.Any (line => Regex.IsMatch (line, $@"\b{Regex.Escape (memberName)}\([^)]*\) -> ")), + $"Expected {mappingFile} to retain and map {originalName}.{memberName}. Matching class section:\n{string.Join ("\n", classMapping)}"); } static Import CreateR8JniManifestMergerInputsAssertionImport () From 6a05a22e27d99cdb3225251297437a94697ee77a Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 2 Sep 2026 19:52:06 +0200 Subject: [PATCH 21/21] Normalize reverse-mapped Java source paths Convert manifest-style JNI paths to the native directory separator at the filesystem copy boundary. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/GenerateTrimmableTypeMap.cs | 4 ++++ .../Tasks/GenerateTrimmableTypeMapTests.cs | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs index d286683661d..cb05b9488a7 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs @@ -366,6 +366,7 @@ internal ITaskItem [] CopyJavaSourcesFromInputDirectory (IReadOnlyList relativePath.Replace ('/', directorySeparator); + string? GetOriginalJavaSourceRelativePath (string generatedRelativePath) { if (r8Mapping == null || reverseR8Mapping == null || !generatedRelativePath.EndsWith (".java", StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs index 59cbea2e27e..74b69236fbc 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs @@ -178,6 +178,15 @@ public void CopyJavaSources_ReverseMapsObfuscatedNestedClassPath () FileAssert.DoesNotExist (Path.Combine (outputDir, "g.java")); } + [TestCase ('/', "com/example/Outer$Inner.java")] + [TestCase ('\\', "com\\example\\Outer$Inner.java")] + public void NormalizeJavaSourceRelativePath_UsesDirectorySeparator (char directorySeparator, string expected) + { + Assert.AreEqual ( + expected, + GenerateTrimmableTypeMap.NormalizeJavaSourceRelativePath ("com/example/Outer$Inner.java", directorySeparator)); + } + [TestCase ("C\tcom/example/First\nC\tcom/example/Second\n", TestName = "CopyJavaSources_MergedClassIsAmbiguous")] [TestCase ("C\tcom/example/Unrelated\n", TestName = "CopyJavaSources_MissingRequiredReverseEntry")] public void CopyJavaSources_InvalidReverseMappingUsesXA4327 (string manifest)