From 39cd8f213ad3b145bc1528f12620cff1604bf6ff Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 16:18:15 +0200 Subject: [PATCH 1/7] Add R8 JNI mapping primitives Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JniRemapping/JniDescriptorTextTests.cs | 81 ++ .../JniRemapping/LdstrRewriterTests.cs | 109 +++ .../Utilities/JniRemapping/R8MappingTests.cs | 412 +++++++++++ .../JniRemapping/JniDescriptorText.cs | 222 ++++++ .../Utilities/JniRemapping/LdstrRewriter.cs | 170 +++++ .../Utilities/JniRemapping/R8Mapping.cs | 699 ++++++++++++++++++ 6 files changed, 1693 insertions(+) create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/LdstrRewriterTests.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/LdstrRewriter.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs new file mode 100644 index 00000000000..57c5abe05ee --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs @@ -0,0 +1,81 @@ +using System; +using NUnit.Framework; +using Xamarin.Android.Tasks.JniRemapping; + +namespace Xamarin.Android.Build.Tests +{ + [TestFixture] + [Parallelizable (ParallelScope.Children)] + public class JniDescriptorTextTests : BaseTest + { + static string? Rename (string cls) => cls == "acme/orig/MyView" ? "a/b/C" : null; + + [Test] + public void RewritesObjectParameterAndReturnTypes () + { + bool changed = JniDescriptorText.TryRewriteDescriptor ("(Lacme/orig/MyView;I)Lacme/orig/MyView;", Rename, out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("(La/b/C;I)La/b/C;", rewritten); + } + + [Test] + public void RewritesArrayOfObjectType () + { + bool changed = JniDescriptorText.TryRewriteDescriptor ("[Lacme/orig/MyView;", Rename, out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("[La/b/C;", rewritten); + } + + [Test] + public void LeavesUnrelatedTypesAndPrimitivesAlone () + { + bool changed = JniDescriptorText.TryRewriteDescriptor ("(Landroid/view/View;[I)V", Rename, out string rewritten); + + Assert.IsFalse (changed); + Assert.AreEqual ("(Landroid/view/View;[I)V", rewritten); + } + + [TestCase ("()V", true)] + [TestCase ("(Ljava/lang/Object;)Z", true)] + [TestCase ("(I)I", true)] + [TestCase ("(V)V", false)] + [TestCase ("()[V", false)] + [TestCase ("I", false)] + [TestCase ("Ljava/lang/Object;", false)] + [TestCase ("not a descriptor", false)] + public void ValidatesMethodDescriptors (string descriptor, bool expected) + { + Assert.AreEqual (expected, JniDescriptorText.IsValidMethodDescriptor (descriptor)); + } + + [TestCase ("I", true)] + [TestCase ("[I", true)] + [TestCase ("Ljava/lang/Object;", true)] + [TestCase ("V", false)] + [TestCase ("[V", false)] + [TestCase ("()V", false)] + [TestCase ("", false)] + public void ValidatesFieldDescriptors (string descriptor, bool expected) + { + Assert.AreEqual (expected, JniDescriptorText.IsValidFieldDescriptor (descriptor)); + } + + [Test] + public void ConvertsMethodDescriptorToJavaParameterTypes () + { + var parameters = JniDescriptorText.MethodDescriptorToJavaParameterTypes ("(Landroid/os/Bundle;I[Ljava/lang/String;)V"); + + CollectionAssert.AreEqual (new [] { "android.os.Bundle", "int", "java.lang.String[]" }, parameters); + } + + [Test] + public void ConvertsSingleTypeTokenToJavaSource () + { + Assert.AreEqual ("boolean", JniDescriptorText.JniTypeTokenToJavaSource ("Z")); + Assert.AreEqual ("int[]", JniDescriptorText.JniTypeTokenToJavaSource ("[I")); + Assert.AreEqual ("java.lang.Object", JniDescriptorText.JniTypeTokenToJavaSource ("Ljava/lang/Object;")); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/LdstrRewriterTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/LdstrRewriterTests.cs new file mode 100644 index 00000000000..ff6cc05e6cc --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/LdstrRewriterTests.cs @@ -0,0 +1,109 @@ +using System.IO; +using NUnit.Framework; +using Xamarin.Android.Tasks.JniRemapping; + +namespace Xamarin.Android.Build.Tests +{ + [TestFixture] + [Parallelizable (ParallelScope.Children)] + public class LdstrRewriterTests : BaseTest + { + static R8Mapping BuildMapping () => R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " void onClick(android.view.View) -> a\n" + + " int someField -> x\n" + + "acme.orig.Marker -> a.b.D:\n")); + + [Test] + public void RewritesJniPeerMembersEncodedMethodId () + { + bool changed = LdstrRewriter.TryRewrite ("onClick.(Landroid/view/View;)V", "acme/orig/MyView", BuildMapping (), out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("a.(Landroid/view/View;)V", rewritten); + } + + [Test] + public void RewritesJniPeerMembersEncodedFieldId () + { + bool changed = LdstrRewriter.TryRewrite ("someField.I", "acme/orig/MyView", BuildMapping (), out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("x.I", rewritten); + } + + [Test] + public void RewritesBareConstructorDescriptorEmbeddedTypesOnly () + { + bool changed = LdstrRewriter.TryRewrite ("(Lacme/orig/Marker;)V", "acme/orig/MyView", BuildMapping (), out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("(La/b/D;)V", rewritten); + } + + [Test] + public void UnchangedBareDescriptorIsReportedAsNotChanged () + { + bool changed = LdstrRewriter.TryRewrite ("()V", "acme/orig/MyView", BuildMapping (), out string rewritten); + + Assert.IsFalse (changed); + Assert.AreEqual ("()V", rewritten); + } + + [Test] + public void RewritesSingleRegisterNativesLine () + { + bool changed = LdstrRewriter.TryRewrite ("onClick:(Landroid/view/View;)V:cb", "acme/orig/MyView", BuildMapping (), out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("a:(Landroid/view/View;)V:cb", rewritten); + } + + [Test] + public void RewritesMultilineRegisterNativesBlockWithoutTrailingNewline () + { + string original = "onClick:(Landroid/view/View;)V:cb1\nunrelated:()V:cb2"; + bool changed = LdstrRewriter.TryRewrite (original, "acme/orig/MyView", BuildMapping (), out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("a:(Landroid/view/View;)V:cb1\nunrelated:()V:cb2", rewritten); + } + + [Test] + public void RewritesMultilineRegisterNativesBlockWithTrailingNewline () + { + string original = "onClick:(Landroid/view/View;)V:cb1\n"; + bool changed = LdstrRewriter.TryRewrite (original, "acme/orig/MyView", BuildMapping (), out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("a:(Landroid/view/View;)V:cb1\n", rewritten); + } + + [Test] + public void RewritesExactClassNameString () + { + bool changed = LdstrRewriter.TryRewrite ("acme/orig/Marker", null, BuildMapping (), out string rewritten); + + Assert.IsTrue (changed); + Assert.AreEqual ("a/b/D", rewritten); + } + + [Test] + public void LeavesUnrelatedDotNetStringsAlone () + { + bool changed = LdstrRewriter.TryRewrite ("System.String", "acme/orig/MyView", BuildMapping (), out string rewritten); + + Assert.IsFalse (changed); + Assert.AreEqual ("System.String", rewritten); + } + + [Test] + public void LeavesUnknownMemberIdAloneWhenOwnerUnknown () + { + bool changed = LdstrRewriter.TryRewrite ("someField.I", null, BuildMapping (), out string rewritten); + + Assert.IsFalse (changed); + Assert.AreEqual ("someField.I", rewritten); + } + } +} 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 new file mode 100644 index 00000000000..fdf12957c34 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs @@ -0,0 +1,412 @@ +using System; +using System.IO; +using NUnit.Framework; +using Xamarin.Android.Tasks.JniRemapping; + +namespace Xamarin.Android.Build.Tests +{ + [TestFixture] + [Parallelizable (ParallelScope.Children)] + public class R8MappingTests : BaseTest + { + [Test] + public void ParsesSimpleClassAndFieldMapping () + { + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " int someField -> x\n")); + + Assert.IsTrue (mapping.TryGetRenamedClass ("acme/orig/MyView", out string renamedClass)); + Assert.AreEqual ("a/b/C", renamedClass); + + Assert.IsTrue (mapping.TryGetRenamedField ("acme/orig/MyView", "someField", out string renamedField)); + Assert.AreEqual ("x", renamedField); + } + + [Test] + public void LooksUpOriginalClassAndMethodNames () + { + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " void first(int) -> x\n" + + " void first(java.lang.String) -> x\n" + + " void second() -> x\n")); + + Assert.IsTrue (mapping.TryGetOriginalClass ("a/b/C", out string originalClass)); + Assert.AreEqual ("acme/orig/MyView", originalClass); + CollectionAssert.AreEquivalent (new [] { "first", "second" }, mapping.GetOriginalMethodNames (originalClass, "x")); + Assert.IsTrue (mapping.TryGetOriginalMethodName (originalClass, "x", new [] { "int" }, "void", out string first)); + Assert.AreEqual ("first", first); + Assert.IsTrue (mapping.TryGetOriginalMethodName (originalClass, "x", Array.Empty (), "void", out string second)); + Assert.AreEqual ("second", second); + Assert.IsFalse (mapping.TryGetOriginalClass ("a/b/Missing", out _)); + } + + [Test] + public void ParsesMethodMappingWithParameters () + { + var mapping = R8Mapping.Parse (new StringReader ( + """ + acme.orig.MyView -> a.b.C: + void onClick(android.view.View) -> a + void onClick(android.view.View,int) -> b + + """)); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onClick", new [] { "android.view.View" }, "void", out string renamed1)); + Assert.AreEqual ("a", renamed1); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onClick", new [] { "android.view.View", "int" }, "void", out string renamed2)); + Assert.AreEqual ("b", renamed2); + } + + [Test] + public void ParsesMethodMappingWithLeadingAndTrailingLineRanges () + { + // "startLine:endLine:returnType name(params):origStartLine:origEndLine -> obfuscated" + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " 4:10:void onCreate(android.os.Bundle):23:29 -> a\n")); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onCreate", new [] { "android.os.Bundle" }, "void", out string renamed)); + Assert.AreEqual ("a", renamed); + } + + [Test] + public void ParsesMethodMappingWithASingleTrailingLineNumber () + { + // R8 sometimes collapses the trailing original range to a single line number when + // start and end coincide: "startLine:endLine:returnType name(params):originalLine -> obfuscated". + var mapping = R8Mapping.Parse (new StringReader ( + "example.Foo -> a.b.C:\n" + + "acme.orig.MyView -> a.b.D:\n" + + " 4:4:void run(example.Foo):2 -> a\n")); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "run", new [] { "example.Foo" }, "void", out string renamed)); + Assert.AreEqual ("a", renamed); + } + + [Test] + public void ParsesMethodMappingWithOnlyATrailingLineRange () + { + // No leading "startLine:endLine:" prefix, only the trailing ":originalStart:originalEnd". + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " void onPause():23:29 -> a\n")); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onPause", Array.Empty (), "void", out string renamed)); + Assert.AreEqual ("a", renamed); + } + + [Test] + public void ParsesNoArgMethodMapping () + { + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " void onStart() -> a\n")); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onStart", Array.Empty (), "void", out string renamed)); + Assert.AreEqual ("a", renamed); + } + + [Test] + public void KeepsDollarInNestedClassNames () + { + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView$Inner -> a.b.C$D:\n")); + + Assert.IsTrue (mapping.TryGetRenamedClass ("acme/orig/MyView$Inner", out string renamed)); + Assert.AreEqual ("a/b/C$D", renamed); + } + + [Test] + public void TranslatesConstructorNameForLookup () + { + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " void (int) -> \n")); + + // JVM never renames , but the lookup key must still translate ".ctor" -> "". + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", R8Mapping.JniMemberNameToMappingName (".ctor"), new [] { "int" }, "void", out string renamed)); + Assert.AreEqual ("", renamed); + } + + [Test] + public void NameOnlyLookupSucceedsWhenUnambiguous () + { + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " void onStart(int) -> a\n" + + " void onStart(int,int) -> a\n")); // Both overloads map to the same name. + + Assert.IsTrue (mapping.TryGetRenamedMethodByNameOnly ("acme/orig/MyView", "onStart", out string renamed)); + Assert.AreEqual ("a", renamed); + } + + [Test] + public void NameOnlyLookupFailsWhenAmbiguous () + { + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " void onStart(int) -> a\n" + + " void onStart(int,int) -> b\n")); // Different renames - ambiguous without a descriptor. + + Assert.IsFalse (mapping.TryGetRenamedMethodByNameOnly ("acme/orig/MyView", "onStart", out _)); + } + + [Test] + public void UnknownClassOrMemberReturnsFalse () + { + var mapping = R8Mapping.Parse (new StringReader ("acme.orig.MyView -> a.b.C:\n")); + + Assert.IsFalse (mapping.TryGetRenamedClass ("acme/orig/Other", out _)); + Assert.IsFalse (mapping.TryGetRenamedField ("acme/orig/MyView", "missing", out _)); + Assert.IsFalse (mapping.TryGetRenamedMethod ("acme/orig/MyView", "missing", Array.Empty (), "void", out _)); + } + + [Test] + public void IgnoresCommentsAndBlankLines () + { + var mapping = R8Mapping.Parse (new StringReader ( + "# a comment\n" + + "\n" + + "acme.orig.MyView -> a.b.C:\n" + + "\n" + + " int someField -> x\n")); + + Assert.IsTrue (mapping.TryGetRenamedClass ("acme/orig/MyView", out string renamed)); + Assert.AreEqual ("a/b/C", renamed); + } + + [Test] + public void IgnoresIndentedMetadataComments () + { + // R8 emits indented "# {...}" comments under member lines to carry extra metadata + // (e.g. inlining/source-position info); these must not be mistaken for member lines. + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " void onCreate(android.os.Bundle) -> a\n" + + " # {'id':'com.android.tools.r8.synthesized'}\n" + + " int someField -> x\n")); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onCreate", new [] { "android.os.Bundle" }, "void", out string renamedMethod)); + Assert.AreEqual ("a", renamedMethod); + + Assert.IsTrue (mapping.TryGetRenamedField ("acme/orig/MyView", "someField", out string renamedField)); + Assert.AreEqual ("x", renamedField); + } + + [Test] + public void IgnoresQualifiedInlineCallFrameMappings () + { + var mapping = R8Mapping.Parse (new StringReader ( + "androidx.collection.LongSparseArray -> a.b.C:\n" + + " 299:299:void androidx.collection.LongSparseArrayKt.commonGc(androidx.collection.LongSparseArray) -> keyAt\n" + + " 299:299:long keyAt(int):183 -> keyAt\n" + + " 307:307:void androidx.collection.LongSparseArrayKt.commonGc(androidx.collection.LongSparseArray) -> indexOfKey\n" + + " 307:307:int indexOfKey(long):209 -> indexOfKey\n")); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("androidx/collection/LongSparseArray", "keyAt", new [] { "int" }, "long", out string keyAt)); + Assert.AreEqual ("keyAt", keyAt); + Assert.IsTrue (mapping.TryGetRenamedMethod ("androidx/collection/LongSparseArray", "indexOfKey", new [] { "long" }, "int", out string indexOfKey)); + Assert.AreEqual ("indexOfKey", indexOfKey); + Assert.IsFalse (mapping.TryGetRenamedMethod ("androidx/collection/LongSparseArray", "androidx.collection.LongSparseArrayKt.commonGc", new [] { "androidx.collection.LongSparseArray" }, "void", out _)); + } + + [Test] + public void TreatsMethodInlinedIntoMultipleDestinationsAsAmbiguous () + { + var mapping = R8Mapping.Parse (new StringReader ( + "androidx.collection.SimpleArrayMap -> a.b.C:\n" + + " 299:299:java.lang.Object getOrDefaultInternal(java.lang.Object,java.lang.Object) -> get\n" + + " 299:299:java.lang.Object get(java.lang.Object):278 -> get\n" + + " 299:299:java.lang.Object getOrDefaultInternal(java.lang.Object,java.lang.Object) -> getOrDefault\n" + + " 299:299:java.lang.Object getOrDefault(java.lang.Object,java.lang.Object):294 -> getOrDefault\n")); + + Assert.IsFalse (mapping.TryGetRenamedMethod ("androidx/collection/SimpleArrayMap", "getOrDefaultInternal", new [] { "java.lang.Object", "java.lang.Object" }, "java.lang.Object", out _)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("androidx/collection/SimpleArrayMap", "get", new [] { "java.lang.Object" }, "java.lang.Object", out string get)); + Assert.AreEqual ("get", get); + Assert.IsTrue (mapping.TryGetRenamedMethod ("androidx/collection/SimpleArrayMap", "getOrDefault", new [] { "java.lang.Object", "java.lang.Object" }, "java.lang.Object", out string getOrDefault)); + Assert.AreEqual ("getOrDefault", getOrDefault); + } + + [Test] + public void ThrowsOnMemberLineBeforeAnyClassLine () + { + Assert.Throws (() => R8Mapping.Parse (new StringReader (" int someField -> x\n"))); + } + + [Test] + public void ReportsNamesThatDifferBetweenSeedAndFinalMappings () + { + R8Mapping seed = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> a.b.C: + int count -> a + void onClick(android.view.View) -> b + void removed() -> c + + """)); + R8Mapping final = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> x.y.Z: + int count -> d + void onClick(android.view.View) -> e + acme.final.Only -> q.r.S: + + """)); + + CollectionAssert.AreEqual (new [] { + "class 'acme/orig/MyView': seed name 'a/b/C', final name 'x/y/Z'", + "field 'acme/orig/MyView.count': seed name 'a', final name 'd'", + "method 'acme/orig/MyView.onClick(android.view.View):void': seed name 'b', final name 'e'", + }, seed.GetCompatibilityConflicts (final, new [] { + "C\tacme/orig/MyView", + "F\tacme/orig/MyView\tcount", + "M\tacme/orig/MyView\tonClick(android.view.View):void", + })); + } + + [Test] + public void IgnoresMappingsRemovedByFinalShrinking () + { + R8Mapping seed = R8Mapping.Parse (new StringReader (""" + acme.orig.Kept -> a.b.C: + void kept() -> a + void removed() -> b + acme.orig.Removed -> a.b.D: + int value -> a + void removed() -> b + + """)); + R8Mapping final = R8Mapping.Parse (new StringReader (""" + acme.orig.Kept -> a.b.C: + void kept() -> a + acme.orig.Removed -> R8$$REMOVED$$CLASS$$0: + int value -> z + void removed() -> z + + """)); + + CollectionAssert.IsEmpty (seed.GetCompatibilityConflicts (final, new [] { + "C\tacme/orig/Removed", + "F\tacme/orig/Removed\tvalue", + "M\tacme/orig/Removed\tremoved():void", + "M\tacme/orig/Kept\tremoved():void", + })); + } + + [Test] + public void TracksMappingsUsedByManagedRewriting () + { + R8Mapping mapping = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> a.b.C: + int count -> a + void onClick(android.view.View) -> b + + """)); + + Assert.IsTrue (mapping.TryGetRenamedClass ("acme/orig/MyView", out _)); + Assert.IsTrue (mapping.TryGetRenamedField ("acme/orig/MyView", "count", out _)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "onClick", new [] { "android.view.View" }, "void", out _)); + + CollectionAssert.AreEquivalent (new [] { + "C\tacme/orig/MyView", + "F\tacme/orig/MyView\tcount", + "M\tacme/orig/MyView\tonClick(android.view.View):void", + }, mapping.AccessedEntries); + } + + [Test] + public void ReportsPostLinkEntriesRemovedByFinalR8 () + { + R8Mapping seed = R8Mapping.Parse (new StringReader (""" + acme.orig.Missing -> a.b.A: + acme.orig.Removed -> a.b.B: + acme.orig.Members -> a.b.C: + int keptField -> a + int missingField -> b + void kept() -> a + void missing() -> b + + """)); + R8Mapping final = R8Mapping.Parse (new StringReader (""" + acme.orig.Removed -> R8$$REMOVED$$CLASS$$0: + acme.orig.Members -> a.b.C: + int keptField -> a + void kept() -> a + + """)); + + CollectionAssert.AreEqual (new [] { + "class 'acme/orig/Missing'", + "class 'acme/orig/Removed'", + "field 'acme/orig/Members.missingField'", + "method 'acme/orig/Members.missing():void'", + }, seed.GetReachabilityConflicts (final, new [] { + "C\tacme/orig/Missing", + "C\tacme/orig/Removed", + "C\tacme/orig/Members", + "F\tacme/orig/Members\tkeptField", + "F\tacme/orig/Members\tmissingField", + "M\tacme/orig/Members\tkept():void", + "M\tacme/orig/Members\tmissing():void", + })); + } + + [Test] + public void LooksUpOriginalFieldNameOnlyWhenUnambiguous () + { + R8Mapping mapping = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> a.b.C: + int first -> a + int second -> b + int ambiguous1 -> c + int ambiguous2 -> c + + """)); + + Assert.IsTrue (mapping.TryGetOriginalFieldName ("acme/orig/MyView", "a", out string original)); + Assert.AreEqual ("first", original); + Assert.IsFalse (mapping.TryGetOriginalFieldName ("acme/orig/MyView", "c", out _)); + } + + [Test] + public void ReverseMappingRecordsEveryAmbiguousMemberCandidate () + { + R8Mapping mapping = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> a.b.C: + int first -> a + java.lang.String second -> a + void run() -> b + void invoke(int) -> b + + """)); + IJniNameMapping reverse = mapping.CreateReverseMapping (); + + Assert.IsTrue (reverse.TryMapField ("a/b/C", "a", out _)); + Assert.IsTrue (reverse.TryMapMethodByNameOnly ("a/b/C", "b", out _)); + CollectionAssert.AreEquivalent (new [] { + "C\tacme/orig/MyView", + "F\tacme/orig/MyView\tfirst", + "F\tacme/orig/MyView\tsecond", + "M\tacme/orig/MyView\tinvoke(int):void", + "M\tacme/orig/MyView\trun():void", + }, mapping.AccessedEntries); + } + + [Test] + public void DistinguishesMethodsByReturnType () + { + R8Mapping mapping = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> a.b.C: + java.lang.Object value() -> a + java.lang.String value() -> b + + """)); + + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "value", [], "java.lang.Object", out string objectMethod)); + Assert.AreEqual ("a", objectMethod); + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "value", [], "java.lang.String", out string stringMethod)); + Assert.AreEqual ("b", stringMethod); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs new file mode 100644 index 00000000000..6ba1975616f --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs @@ -0,0 +1,222 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + /// + /// Small, self-contained helpers for scanning and rewriting JNI type names, descriptors, and + /// the various encoded string forms that Java.Interop / the generator embed in compiled + /// assemblies (JniPeerMembers "name.descriptor" ids, RegisterNatives lines, etc). + /// + /// These deliberately duplicate a subset of Microsoft.Android.Sdk.TrimmableTypeMap's + /// JniSignatureHelper (which is internal to that assembly and not visible here) rather than + /// exposing it across assembly boundaries. + /// + static class JniDescriptorText + { + /// + /// Rewrites every embedded reference-type name ("Lfoo/bar/Baz;") within a JNI type or + /// method descriptor using . Returns false (and the + /// original text unmodified) if nothing needed to change. + /// + public static bool TryRewriteDescriptor (string descriptor, Func renameClass, out string rewritten) + { + var sb = new StringBuilder (descriptor.Length); + bool changed = false; + int i = 0; + while (i < descriptor.Length) { + int start = i; + if (!TryScanSingleToken (descriptor, ref i, allowVoid: true)) { + // Not a type token (e.g. '(' or ')' bracket of a method descriptor) - copy verbatim. + sb.Append (descriptor [start]); + i = start + 1; + continue; + } + + string token = descriptor.Substring (start, i - start); + if (TryRewriteSingleTypeToken (token, renameClass, out string newToken)) { + changed = true; + sb.Append (newToken); + } else { + sb.Append (token); + } + } + + rewritten = changed ? sb.ToString () : descriptor; + return changed; + } + + /// + /// Rewrites a single JNI type token ("I", "[I", "Lfoo/Bar;", "[Lfoo/Bar;", ...) if it is an + /// object/array-of-object reference whose class name has a rename entry. + /// + static bool TryRewriteSingleTypeToken (string token, Func renameClass, out string rewritten) + { + rewritten = token; + int arrayDepth = 0; + while (arrayDepth < token.Length && token [arrayDepth] == '[') { + arrayDepth++; + } + + if (arrayDepth >= token.Length || token [arrayDepth] != 'L') { + return false; // Primitive (or malformed) - nothing to rename. + } + + // token[arrayDepth] == 'L', token ends with ';'. + string className = token.Substring (arrayDepth + 1, token.Length - arrayDepth - 2); + string? renamed = renameClass (className); + if (renamed == null || renamed == className) { + return false; + } + + rewritten = token.Substring (0, arrayDepth) + "L" + renamed + ";"; + return true; + } + + /// + /// Scans a single JNI type descriptor token ("I", "[[I", "Lfoo/Bar;", ...) starting at + /// , advancing past it. Returns false (without + /// advancing) if the character at cannot start a type token. + /// + static bool TryScanSingleToken (string s, ref int i, bool allowVoid) + { + int start = i; + int j = i; + while (j < s.Length && s [j] == '[') { + j++; + } + + if (j >= s.Length) { + return false; + } + + switch (s [j]) { + case 'V': + if (!allowVoid || j != start) { + return false; + } + i = j + 1; + return true; + case 'Z': case 'B': case 'C': case 'S': case 'I': case 'J': case 'F': case 'D': + i = j + 1; + return true; + case 'L': + int end = s.IndexOf (';', j + 1); + if (end < 0) { + return false; + } + i = end + 1; + return true; + default: + return false; + } + } + + /// + /// Parses a JNI method descriptor "(param1param2...)ret" into its parameter type tokens + /// and return type token. + /// + public static bool TryParseMethodDescriptor (string descriptor, out List parameterTypes, out string returnType) + { + parameterTypes = new List (); + returnType = ""; + + if (descriptor.Length == 0 || descriptor [0] != '(') { + return false; + } + + int i = 1; + while (i < descriptor.Length && descriptor [i] != ')') { + int start = i; + if (!TryScanSingleToken (descriptor, ref i, allowVoid: false)) { + return false; + } + parameterTypes.Add (descriptor.Substring (start, i - start)); + } + + if (i >= descriptor.Length || descriptor [i] != ')') { + return false; + } + i++; + + int retStart = i; + if (!TryScanSingleToken (descriptor, ref i, allowVoid: true) || i != descriptor.Length) { + return false; + } + + returnType = descriptor.Substring (retStart); + return true; + } + + /// + /// True if is a syntactically valid JNI method descriptor, + /// e.g. "(Ljava/lang/Object;)Z" or "()V". + /// + public static bool IsValidMethodDescriptor (string descriptor) + => TryParseMethodDescriptor (descriptor, out _, out _); + + /// + /// True if is a single, complete JNI field/type descriptor, + /// e.g. "I", "[I", or "Ljava/lang/Object;" (and nothing else follows it). + /// + public static bool IsValidFieldDescriptor (string descriptor) + { + int i = 0; + return descriptor.Length > 0 && TryScanSingleToken (descriptor, ref i, allowVoid: false) && i == descriptor.Length; + } + + /// + /// Converts a JNI type token ("I", "[Lfoo/Bar;", "Ljava/lang/String;") to its Java *source* + /// form ("int", "foo.Bar[]", "java.lang.String") as used in mapping.txt member lines. + /// + public static string JniTypeTokenToJavaSource (string token) + { + int arrayDepth = 0; + while (arrayDepth < token.Length && token [arrayDepth] == '[') { + arrayDepth++; + } + + string elementJavaName = token [arrayDepth] switch { + 'V' => "void", + 'Z' => "boolean", + 'B' => "byte", + 'C' => "char", + 'S' => "short", + 'I' => "int", + 'J' => "long", + 'F' => "float", + 'D' => "double", + 'L' => token.Substring (arrayDepth + 1, token.Length - arrayDepth - 2).Replace ('/', '.'), + _ => throw new ArgumentException ($"Malformed JNI type token '{token}'.", nameof (token)), + }; + + return elementJavaName + string.Concat (System.Linq.Enumerable.Repeat ("[]", arrayDepth)); + } + + /// + /// Splits a JNI method descriptor's parameter list into Java-source-form parameter types, + /// e.g. "(Landroid/os/Bundle;I)V" -> ["android.os.Bundle", "int"]. + /// + public static List MethodDescriptorToJavaParameterTypes (string descriptor) + { + MethodDescriptorToJavaTypes (descriptor, out var parameterTypes, out _); + return parameterTypes; + } + + public static void MethodDescriptorToJavaTypes (string descriptor, out List parameterTypes, out string returnType) + { + if (!TryParseMethodDescriptor (descriptor, out var jniParameterTypes, out string jniReturnType)) { + throw new ArgumentException ($"Malformed JNI method descriptor '{descriptor}'.", nameof (descriptor)); + } + + parameterTypes = new List (jniParameterTypes.Count); + foreach (string parameterType in jniParameterTypes) { + parameterTypes.Add (JniTypeTokenToJavaSource (parameterType)); + } + returnType = JniTypeTokenToJavaSource (jniReturnType); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/LdstrRewriter.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/LdstrRewriter.cs new file mode 100644 index 00000000000..21d02d6f450 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/LdstrRewriter.cs @@ -0,0 +1,170 @@ +#nullable enable + +using System; +using System.Collections.Generic; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + /// + /// Classifies and rewrites the various forms of JNI-name-bearing string literals that + /// Java.Interop / the binding generator embed in IL as ldstr operands: + /// + /// - RegisterNatives / FastRegisterNativeMembers blocks: one or more lines of + /// "name:descriptor:connector[:callbackDeclaringType]", separated by '\n' (no trailing + /// newline required). + /// - JniPeerMembers encoded member ids: "name.descriptor" (e.g. "equals.(Ljava/lang/Object;)Z" + /// or "eventTypes.I"), or a bare descriptor for constructors (e.g. "()V"). + /// - Bare JNI class names (e.g. "java/lang/Object"). + /// + /// Anything else (ordinary .NET strings that merely happen to contain '.' or ':') is left + /// untouched. + /// + static class LdstrRewriter + { + public static bool TryRewrite (string value, string? ownerJniName, IJniNameMapping mapping, out string rewritten) + { + Func renameClass = className => mapping.TryMapClass (className, out string renamed) ? renamed : null; + + if (value.IndexOf ('\n') >= 0) { + return TryRewriteMultilineRegisterNatives (value, ownerJniName, mapping, renameClass, out rewritten); + } + + if (value.IndexOf (':') >= 0 && TryRewriteRegisterNativesLine (value, ownerJniName, mapping, renameClass, out rewritten)) { + return true; + } + + if (TryRewriteJniPeerMemberId (value, ownerJniName, mapping, renameClass, out rewritten)) { + return true; + } + + if (JniDescriptorText.IsValidMethodDescriptor (value) || JniDescriptorText.IsValidFieldDescriptor (value)) { + return JniDescriptorText.TryRewriteDescriptor (value, renameClass, out rewritten); + } + + if (mapping.TryMapClass (value, out string renamedWhole)) { + rewritten = renamedWhole; + return true; + } + + rewritten = value; + return false; + } + + static bool TryRewriteMultilineRegisterNatives (string value, string? ownerJniName, IJniNameMapping mapping, Func renameClass, out string rewritten) + { + string [] lines = value.Split ('\n'); + bool changed = false; + + for (int i = 0; i < lines.Length; i++) { + if (lines [i].Length == 0) { + continue; + } + if (TryRewriteRegisterNativesLine (lines [i], ownerJniName, mapping, renameClass, out string newLine)) { + lines [i] = newLine; + changed = true; + } + } + + rewritten = changed ? string.Join ("\n", lines) : value; + return changed; + } + + /// + /// Rewrites a single "name:descriptor:connector[:callbackDeclaringType]" line, as used by + /// AndroidRuntime.RegisterNativeMembers / FastRegisterNativeMembers. + /// + static bool TryRewriteRegisterNativesLine (string line, string? ownerJniName, IJniNameMapping mapping, Func renameClass, out string rewritten) + { + rewritten = line; + + int firstColon = line.IndexOf (':'); + if (firstColon < 0) { + return false; + } + int secondColon = line.IndexOf (':', firstColon + 1); + if (secondColon < 0) { + return false; + } + + string name = line.Substring (0, firstColon); + string descriptor = line.Substring (firstColon + 1, secondColon - firstColon - 1); + string rest = line.Substring (secondColon); // Includes the leading ':'. + + if (!JniDescriptorText.IsValidMethodDescriptor (descriptor)) { + return false; + } + + bool changed = false; + string newName = name; + if (ownerJniName != null) { + JniDescriptorText.MethodDescriptorToJavaTypes (descriptor, out var javaParams, out string javaReturnType); + string mappingName = R8Mapping.JniMemberNameToMappingName (name); + if (mapping.TryMapMethod (ownerJniName, mappingName, javaParams, javaReturnType, out string renamedMethod)) { + newName = renamedMethod; + changed = true; + } + } + + bool descriptorChanged = JniDescriptorText.TryRewriteDescriptor (descriptor, renameClass, out string newDescriptor); + changed |= descriptorChanged; + + if (!changed) { + return false; + } + + rewritten = newName + ":" + newDescriptor + rest; + return true; + } + + /// + /// Rewrites a JniPeerMembers encoded member id: "name.descriptor" for a method + /// (descriptor starts with '(') or a field (descriptor is a single type token). + /// + static bool TryRewriteJniPeerMemberId (string value, string? ownerJniName, IJniNameMapping mapping, Func renameClass, out string rewritten) + { + rewritten = value; + + int dot = value.IndexOf ('.'); + if (dot <= 0 || dot == value.Length - 1) { + return false; + } + + string name = value.Substring (0, dot); + string descriptor = value.Substring (dot + 1); + + bool isMethod = descriptor.Length > 0 && descriptor [0] == '('; + if (isMethod ? !JniDescriptorText.IsValidMethodDescriptor (descriptor) : !JniDescriptorText.IsValidFieldDescriptor (descriptor)) { + return false; + } + + bool changed = false; + string newName = name; + + if (ownerJniName != null) { + if (isMethod) { + JniDescriptorText.MethodDescriptorToJavaTypes (descriptor, out var javaParams, out string javaReturnType); + string mappingName = R8Mapping.JniMemberNameToMappingName (name); + if (mapping.TryMapMethod (ownerJniName, mappingName, javaParams, javaReturnType, out string renamedMethod)) { + newName = renamedMethod; + changed = true; + } + } else { + if (mapping.TryMapField (ownerJniName, name, out string renamedField)) { + newName = renamedField; + changed = true; + } + } + } + + bool descriptorChanged = JniDescriptorText.TryRewriteDescriptor (descriptor, renameClass, out string newDescriptor); + changed |= descriptorChanged; + + if (!changed) { + return false; + } + + rewritten = newName + "." + newDescriptor; + return true; + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs new file mode 100644 index 00000000000..9607ea00122 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -0,0 +1,699 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; + +namespace Xamarin.Android.Tasks.JniRemapping +{ + interface IJniNameMapping + { + bool TryMapClass (string className, out string mappedClassName); + bool TryMapField (string owningClassName, string fieldName, out string mappedFieldName); + bool TryMapMethod (string owningClassName, string methodName, IReadOnlyList javaParameterTypes, string javaReturnType, out string mappedMethodName); + bool TryMapMethodByNameOnly (string owningClassName, string methodName, out string mappedMethodName); + } + + /// + /// A parsed R8/ProGuard mapping.txt file, exposing the class, field, and method + /// renames it describes using JNI-style ('/'-separated, '$' for nested classes) names. + /// + sealed class R8Mapping : IJniNameMapping + { + // Original JNI class name -> obfuscated JNI class name. + readonly Dictionary classes = new Dictionary (StringComparer.Ordinal); + + // Obfuscated JNI class name -> original JNI class name. + readonly Dictionary originalClasses = new Dictionary (StringComparer.Ordinal); + + // Original JNI class name -> (original field name -> obfuscated field name). + readonly Dictionary> fields = new Dictionary> (StringComparer.Ordinal); + + // Original JNI class name -> ("name(javaParam,javaParam,...):javaReturn" -> obfuscated method name). + readonly Dictionary> methods = new Dictionary> (StringComparer.Ordinal); + + // Reverse member indexes are scoped by original class. Fields retain every candidate because + // R8 may reuse an obfuscated field name for fields with different JVM descriptors. + readonly Dictionary>> originalFields = new Dictionary>> (StringComparer.Ordinal); + readonly Dictionary> originalMethods = new Dictionary> (StringComparer.Ordinal); + readonly HashSet accessedEntries = new HashSet (StringComparer.Ordinal); + HashSet? allowedReverseEntries; + + public IEnumerable AccessedEntries => accessedEntries; + + bool IJniNameMapping.TryMapClass (string className, out string mappedClassName) + => TryGetRenamedClass (className, out mappedClassName); + + bool IJniNameMapping.TryMapField (string owningClassName, string fieldName, out string mappedFieldName) + => TryGetRenamedField (owningClassName, fieldName, out mappedFieldName); + + bool IJniNameMapping.TryMapMethod (string owningClassName, string methodName, IReadOnlyList javaParameterTypes, string javaReturnType, out string mappedMethodName) + => TryGetRenamedMethod (owningClassName, methodName, javaParameterTypes, javaReturnType, out mappedMethodName); + + bool IJniNameMapping.TryMapMethodByNameOnly (string owningClassName, string methodName, out string mappedMethodName) + => TryGetRenamedMethodByNameOnly (owningClassName, methodName, out mappedMethodName); + + internal IJniNameMapping CreateReverseMapping () => new ReverseR8Mapping (this); + + internal void RestrictReverseLookupsTo (IEnumerable manifestEntries) + => allowedReverseEntries = new HashSet (manifestEntries, StringComparer.Ordinal); + + bool IsReverseEntryAllowed (string entry) + => allowedReverseEntries == null || allowedReverseEntries.Contains (entry); + + public static R8Mapping Load (string path) + { + using var reader = new StreamReader (path); + return Parse (reader); + } + + public static R8Mapping Parse (TextReader reader) + { + var mapping = new R8Mapping (); + string? currentOriginalClass = null; + int lineNumber = 0; + string? line; + + while ((line = reader.ReadLine ()) != null) { + lineNumber++; + + if (line.Length == 0) { + continue; + } + + bool indented = line [0] == ' ' || line [0] == '\t'; + string trimmed = line.Trim (); + if (trimmed.Length == 0 || trimmed [0] == '#') { + // R8 emits indented "# {...}" metadata comments (e.g. inline source position + // info) under a member line; these are not member mappings. + continue; + } + + if (!indented) { + if (!TryParseClassLine (trimmed, out string originalClass, out string obfuscatedClass)) { + throw new FormatException ($"mapping.txt:{lineNumber}: expected a class mapping line ('original -> obfuscated:'), got '{line}'."); + } + + currentOriginalClass = JavaNameToJni (originalClass); + string currentObfuscatedClass = JavaNameToJni (obfuscatedClass); + mapping.classes [currentOriginalClass] = currentObfuscatedClass; + mapping.originalClasses [currentObfuscatedClass] = currentOriginalClass; + continue; + } + + if (currentOriginalClass == null) { + throw new FormatException ($"mapping.txt:{lineNumber}: member mapping line found before any class mapping line: '{line}'."); + } + + if (!TryParseMemberLine (trimmed, out string memberName, out string []? javaParameterTypes, out string? javaReturnType, out string obfuscatedName)) { + throw new FormatException ($"mapping.txt:{lineNumber}: could not parse member mapping line: '{line}'."); + } + + if (javaParameterTypes == null) { + // Field. + if (!mapping.fields.TryGetValue (currentOriginalClass, out var classFields)) { + mapping.fields [currentOriginalClass] = classFields = new Dictionary (StringComparer.Ordinal); + } + classFields [memberName] = obfuscatedName; + } else { + // R8 emits fully-qualified source methods as inline call-frame records beneath + // the destination method. They are retrace metadata, not member mappings for + // the current class, and one source method may appear under many destinations. + if (memberName.IndexOf ('.') >= 0) { + continue; + } + + // Method. + string key = BuildMethodKey (memberName, javaParameterTypes, javaReturnType ?? ""); + if (!mapping.methods.TryGetValue (currentOriginalClass, out var classMethods)) { + mapping.methods [currentOriginalClass] = classMethods = new Dictionary (StringComparer.Ordinal); + } + if (classMethods.TryGetValue (key, out string? existing) && existing != obfuscatedName) { + // An optimized method can be inlined into several surviving methods. R8 + // then emits one retrace record per destination, so there is no single + // runtime name to use for this source member. + classMethods [key] = ""; + continue; + } + if (existing == null) { + classMethods [key] = obfuscatedName; + } + } + } + + mapping.BuildReverseMemberIndexes (); + return mapping; + } + + void BuildReverseMemberIndexes () + { + foreach (var classEntry in fields) { + var reverse = new Dictionary> (StringComparer.Ordinal); + originalFields [classEntry.Key] = reverse; + foreach (var field in classEntry.Value) { + if (!reverse.TryGetValue (field.Value, out var originalNames)) { + reverse [field.Value] = originalNames = new List (); + } + originalNames.Add (field.Key); + } + } + + foreach (var classEntry in methods) { + var reverseMethods = new Dictionary (StringComparer.Ordinal); + originalMethods [classEntry.Key] = reverseMethods; + foreach (var method in classEntry.Value) { + if (method.Value.Length == 0) { + continue; + } + int parameterStart = method.Key.IndexOf ('('); + string originalName = parameterStart < 0 ? method.Key : method.Key.Substring (0, parameterStart); + string signature = parameterStart < 0 ? "()" : method.Key.Substring (parameterStart); + AddUnambiguousReverseEntry (reverseMethods, method.Value + signature, originalName); + } + } + } + + static void AddUnambiguousReverseEntry (Dictionary entries, string key, string value) + { + if (entries.TryGetValue (key, out string? existing) && !String.Equals (existing, value, StringComparison.Ordinal)) { + entries [key] = ""; + } else if (existing == null) { + entries [key] = value; + } + } + + /// + /// Builds the lookup key used for methods: the JNI/Java member name plus its + /// parameter types (in Java source form, e.g. "int", "android.os.Bundle", "java.lang.String[]"). + /// + internal static string BuildMethodKey (string javaMethodName, IReadOnlyList javaParameterTypes, string javaReturnType) + => javaMethodName + "(" + string.Join (",", javaParameterTypes) + "):" + javaReturnType; + + /// + /// Translates a JNI member name (as it appears in a RegisterAttribute / encoded JniPeerMembers + /// string) to the corresponding name used in a mapping.txt member line. + /// + internal static string JniMemberNameToMappingName (string jniMemberName) + => jniMemberName switch { + ".ctor" => "", + ".cctor" => "", + _ => jniMemberName, + }; + + public bool TryGetRenamedClass (string originalJniClassName, out string obfuscatedJniClassName) + { + if (classes.TryGetValue (originalJniClassName, out string? renamed)) { + obfuscatedJniClassName = renamed; + accessedEntries.Add (BuildClassEntry (originalJniClassName)); + return true; + } + obfuscatedJniClassName = ""; + return false; + } + + public bool TryGetOriginalClass (string obfuscatedJniClassName, out string originalJniClassName) + { + if (originalClasses.TryGetValue (obfuscatedJniClassName, out string? original)) { + originalJniClassName = original; + return true; + } + originalJniClassName = ""; + return false; + } + + public IEnumerable GetOriginalMethodNames (string originalJniClassName, string obfuscatedMethodName) + { + if (!methods.TryGetValue (originalJniClassName, out var classMethods)) { + yield break; + } + var seen = new HashSet (StringComparer.Ordinal); + foreach (var entry in classMethods) { + if (!String.Equals (entry.Value, obfuscatedMethodName, StringComparison.Ordinal)) { + continue; + } + int parameters = entry.Key.IndexOf ('('); + string name = parameters < 0 ? entry.Key : entry.Key.Substring (0, parameters); + if (seen.Add (name)) { + yield return name; + } + } + } + + public bool TryGetOriginalMethodName (string originalJniClassName, string obfuscatedMethodName, IReadOnlyList originalJavaParameterTypes, string originalJavaReturnType, out string originalMethodName) + { + originalMethodName = ""; + return originalMethods.TryGetValue (originalJniClassName, out var classMethods) && + classMethods.TryGetValue (BuildMethodKey (obfuscatedMethodName, originalJavaParameterTypes, originalJavaReturnType), out originalMethodName) && + originalMethodName.Length != 0; + } + + public bool TryGetOriginalFieldName (string originalJniClassName, string obfuscatedFieldName, out string originalFieldName) + { + originalFieldName = ""; + return originalFields.TryGetValue (originalJniClassName, out var classFields) && + classFields.TryGetValue (obfuscatedFieldName, out var originalNames) && + originalNames.Count == 1 && + (originalFieldName = originalNames [0]).Length != 0; + } + + public bool TryGetRenamedField (string owningJniClassName, string originalFieldName, out string obfuscatedFieldName) + { + obfuscatedFieldName = ""; + if (!fields.TryGetValue (owningJniClassName, out var classFields) || + !classFields.TryGetValue (originalFieldName, out string? renamed)) { + return false; + } + obfuscatedFieldName = renamed; + accessedEntries.Add (BuildFieldEntry (owningJniClassName, originalFieldName)); + return true; + } + + public bool TryGetRenamedMethod (string owningJniClassName, string javaMethodName, IReadOnlyList javaParameterTypes, string javaReturnType, out string obfuscatedMethodName) + { + obfuscatedMethodName = ""; + if (!methods.TryGetValue (owningJniClassName, out var classMethods)) { + return false; + } + string methodKey = BuildMethodKey (javaMethodName, javaParameterTypes, javaReturnType); + if (!classMethods.TryGetValue (methodKey, out string? renamed) || renamed.Length == 0) { + return false; + } + obfuscatedMethodName = renamed; + accessedEntries.Add (BuildMethodEntry (owningJniClassName, methodKey)); + return true; + } + + /// + /// Best-effort lookup used when only a member name (no parameter types) is available: + /// succeeds only if the name is unambiguous (a single overload) within the class. + /// + public bool TryGetRenamedMethodByNameOnly (string owningJniClassName, string javaMethodName, out string obfuscatedMethodName) + { + obfuscatedMethodName = ""; + if (!methods.TryGetValue (owningJniClassName, out var classMethods)) { + return false; + } + + string? match = null; + string prefix = javaMethodName + "("; + foreach (var kvp in classMethods) { + if (!kvp.Key.StartsWith (prefix, StringComparison.Ordinal)) { + continue; + } + if (kvp.Value.Length == 0) { + return false; + } + if (match != null && match != kvp.Value) { + return false; // Ambiguous - multiple differently-renamed overloads. + } + match = kvp.Value; + } + + if (match == null) { + return false; + } + + foreach (var kvp in classMethods) { + if (kvp.Key.StartsWith (prefix, StringComparison.Ordinal) && kvp.Value == match) { + accessedEntries.Add (BuildMethodEntry (owningJniClassName, kvp.Key)); + } + } + obfuscatedMethodName = match; + return true; + } + + /// + /// Reports required mappings that are present in both this seed mapping and + /// , but whose obfuscated names differ. Entries absent from the + /// final mapping are intentionally ignored because ILLink, ILC, or final R8 shrinking may have + /// removed them. + /// + public IEnumerable GetCompatibilityConflicts (R8Mapping finalMapping, IEnumerable requiredEntries) + { + foreach (string requiredEntry in requiredEntries) { + string [] parts = requiredEntry.Split ('\t'); + switch (parts.Length > 0 ? parts [0] : "") { + case "C" when parts.Length == 2: + if (classes.TryGetValue (parts [1], out string? seedClassName) && + finalMapping.classes.TryGetValue (parts [1], out string? finalClassName) && + !IsRemovedClassName (finalClassName) && + !String.Equals (seedClassName, finalClassName, StringComparison.Ordinal)) { + yield return $"class '{parts [1]}': seed name '{seedClassName}', final name '{finalClassName}'"; + } + break; + case "F" when parts.Length == 3: + if (!finalMapping.IsRemovedClass (parts [1]) && + fields.TryGetValue (parts [1], out var seedFields) && + seedFields.TryGetValue (parts [2], out string? seedFieldName) && + finalMapping.fields.TryGetValue (parts [1], out var finalFields) && + finalFields.TryGetValue (parts [2], out string? finalFieldName) && + !String.Equals (seedFieldName, finalFieldName, StringComparison.Ordinal)) { + yield return $"field '{parts [1]}.{parts [2]}': seed name '{seedFieldName}', final name '{finalFieldName}'"; + } + break; + case "M" when parts.Length == 3: + if (!finalMapping.IsRemovedClass (parts [1]) && + methods.TryGetValue (parts [1], out var seedMethods) && + seedMethods.TryGetValue (parts [2], out string? seedMethodName) && + seedMethodName.Length != 0 && + finalMapping.methods.TryGetValue (parts [1], out var finalMethods) && + finalMethods.TryGetValue (parts [2], out string? finalMethodName) && + finalMethodName.Length != 0 && + !String.Equals (seedMethodName, finalMethodName, StringComparison.Ordinal)) { + yield return $"method '{parts [1]}.{parts [2]}': seed name '{seedMethodName}', final name '{finalMethodName}'"; + } + break; + default: + throw new FormatException ($"Invalid R8 JNI rewrite manifest entry '{requiredEntry}'."); + } + } + } + + /// + /// Reports entries that post-link analysis says must remain reachable but which final R8 + /// removed. A required member is only reported separately when its declaring class survived. + /// + public IEnumerable GetReachabilityConflicts (R8Mapping finalMapping, IEnumerable requiredEntries) + { + foreach (string requiredEntry in requiredEntries) { + string [] parts = requiredEntry.Split ('\t'); + switch (parts.Length > 0 ? parts [0] : "") { + case "C" when parts.Length == 2: + if (!classes.ContainsKey (parts [1])) { + throw new FormatException ($"R8 reachability manifest class '{parts [1]}' is absent from the seed mapping."); + } + if (!finalMapping.classes.TryGetValue (parts [1], out string? finalClassName) || IsRemovedClassName (finalClassName)) { + yield return $"class '{parts [1]}'"; + } + break; + case "F" when parts.Length == 3: + if (!fields.TryGetValue (parts [1], out var seedFields) || !seedFields.ContainsKey (parts [2])) { + throw new FormatException ($"R8 reachability manifest field '{parts [1]}.{parts [2]}' is absent from the seed mapping."); + } + if (!finalMapping.IsLiveClass (parts [1])) { + continue; + } + if (!finalMapping.fields.TryGetValue (parts [1], out var finalFields) || !finalFields.ContainsKey (parts [2])) { + yield return $"field '{parts [1]}.{parts [2]}'"; + } + break; + case "M" when parts.Length == 3: + if (!methods.TryGetValue (parts [1], out var seedMethods) || !seedMethods.ContainsKey (parts [2])) { + throw new FormatException ($"R8 reachability manifest method '{parts [1]}.{parts [2]}' is absent from the seed mapping."); + } + if (!finalMapping.IsLiveClass (parts [1])) { + continue; + } + if (!finalMapping.methods.TryGetValue (parts [1], out var finalMethods) || + !finalMethods.TryGetValue (parts [2], out string? finalMethodName) || + finalMethodName.Length == 0) { + yield return $"method '{parts [1]}.{parts [2]}'"; + } + break; + default: + throw new FormatException ($"Invalid R8 JNI reachability manifest entry '{requiredEntry}'."); + } + } + } + + internal static string BuildClassEntry (string className) => $"C\t{className}"; + internal static string BuildFieldEntry (string className, string fieldName) => $"F\t{className}\t{fieldName}"; + internal static string BuildMethodEntry (string className, string methodKey) => $"M\t{className}\t{methodKey}"; + + internal static string CreateManifestContent (IEnumerable entries) + { + var sortedEntries = new List (entries); + sortedEntries.Sort (StringComparer.Ordinal); + using var writer = new StringWriter (); + foreach (string entry in sortedEntries) { + writer.WriteLine (entry); + } + return writer.ToString (); + } + + sealed class ReverseR8Mapping : IJniNameMapping + { + readonly R8Mapping mapping; + + public ReverseR8Mapping (R8Mapping mapping) + { + this.mapping = mapping; + } + + public bool TryMapClass (string className, out string mappedClassName) + { + if (!TryGetAllowedOriginalClass (className, out mappedClassName)) { + return false; + } + mapping.accessedEntries.Add (BuildClassEntry (mappedClassName)); + return true; + } + + public bool TryMapField (string owningClassName, string fieldName, out string mappedFieldName) + { + mappedFieldName = ""; + if (!TryGetAllowedOriginalClass (owningClassName, out string originalClassName) || + !mapping.originalFields.TryGetValue (originalClassName, out var classFields) || + !classFields.TryGetValue (fieldName, out var originalNames) || + originalNames.Count == 0) { + return false; + } + string? firstOriginalName = null; + foreach (string originalName in originalNames) { + string entry = BuildFieldEntry (originalClassName, originalName); + if (!mapping.IsReverseEntryAllowed (entry)) { + continue; + } + firstOriginalName ??= originalName; + mapping.accessedEntries.Add (entry); + } + if (firstOriginalName == null) { + return false; + } + mapping.accessedEntries.Add (BuildClassEntry (originalClassName)); + mappedFieldName = firstOriginalName; + return true; + } + + public bool TryMapMethod (string owningClassName, string methodName, IReadOnlyList javaParameterTypes, string javaReturnType, out string mappedMethodName) + { + mappedMethodName = ""; + if (!TryGetAllowedOriginalClass (owningClassName, out string originalClassName)) { + return false; + } + + var originalParameterTypes = new List (javaParameterTypes.Count); + foreach (string parameterType in javaParameterTypes) { + originalParameterTypes.Add (GetOriginalJavaType (parameterType)); + } + string originalReturnType = GetOriginalJavaType (javaReturnType); + if (!mapping.TryGetOriginalMethodName (originalClassName, methodName, originalParameterTypes, originalReturnType, out mappedMethodName)) { + return false; + } + string entry = BuildMethodEntry ( + originalClassName, + BuildMethodKey (mappedMethodName, originalParameterTypes, originalReturnType)); + if (!mapping.IsReverseEntryAllowed (entry)) { + mappedMethodName = ""; + return false; + } + mapping.accessedEntries.Add (BuildClassEntry (originalClassName)); + mapping.accessedEntries.Add (entry); + return true; + } + + public bool TryMapMethodByNameOnly (string owningClassName, string methodName, out string mappedMethodName) + { + mappedMethodName = ""; + if (!TryGetAllowedOriginalClass (owningClassName, out string originalClassName) || + !mapping.methods.TryGetValue (originalClassName, out var classMethods)) { + return false; + } + + string? firstOriginalName = null; + foreach (var entry in classMethods) { + if (!String.Equals (entry.Value, methodName, StringComparison.Ordinal)) { + continue; + } + string manifestEntry = BuildMethodEntry (originalClassName, entry.Key); + if (!mapping.IsReverseEntryAllowed (manifestEntry)) { + continue; + } + int parameterStart = entry.Key.IndexOf ('('); + firstOriginalName ??= parameterStart < 0 ? entry.Key : entry.Key.Substring (0, parameterStart); + mapping.accessedEntries.Add (manifestEntry); + } + if (firstOriginalName == null) { + return false; + } + mapping.accessedEntries.Add (BuildClassEntry (originalClassName)); + mappedMethodName = firstOriginalName; + return true; + } + + string GetOriginalJavaType (string javaType) + { + int suffixStart = javaType.IndexOf ('['); + string suffix = suffixStart < 0 ? "" : javaType.Substring (suffixStart); + string elementType = suffixStart < 0 ? javaType : javaType.Substring (0, suffixStart); + string jniType = JavaNameToJni (elementType); + return TryGetAllowedOriginalClass (jniType, out string originalJniType) + ? originalJniType.Replace ('/', '.') + suffix + : javaType; + } + + bool TryGetAllowedOriginalClass (string obfuscatedJniClassName, out string originalJniClassName) + { + if (!mapping.TryGetOriginalClass (obfuscatedJniClassName, out originalJniClassName) || + !mapping.IsReverseEntryAllowed (BuildClassEntry (originalJniClassName))) { + originalJniClassName = ""; + return false; + } + return true; + } + } + + static bool IsRemovedClassName (string className) + => className.StartsWith ("R8$$REMOVED$$CLASS$$", StringComparison.Ordinal); + + bool IsRemovedClass (string originalClassName) + => classes.TryGetValue (originalClassName, out string? className) && IsRemovedClassName (className); + + bool IsLiveClass (string originalClassName) + => classes.TryGetValue (originalClassName, out string? className) && !IsRemovedClassName (className); + + static string JavaNameToJni (string javaBinaryName) => javaBinaryName.Replace ('.', '/'); + + static bool TryParseClassLine (string trimmed, out string originalClass, out string obfuscatedClass) + { + originalClass = ""; + obfuscatedClass = ""; + + if (!trimmed.EndsWith (":", StringComparison.Ordinal)) { + return false; + } + + const string arrow = " -> "; + int arrowIndex = trimmed.IndexOf (arrow, StringComparison.Ordinal); + if (arrowIndex < 0) { + return false; + } + + originalClass = trimmed.Substring (0, arrowIndex); + obfuscatedClass = trimmed.Substring (arrowIndex + arrow.Length, trimmed.Length - arrowIndex - arrow.Length - 1); + return originalClass.Length > 0 && obfuscatedClass.Length > 0; + } + + static bool TryParseMemberLine (string trimmed, out string name, out string []? javaParameterTypes, out string? javaReturnType, out string obfuscatedName) + { + name = ""; + javaParameterTypes = null; + javaReturnType = null; + obfuscatedName = ""; + + const string arrow = " -> "; + int arrowIndex = trimmed.LastIndexOf (arrow, StringComparison.Ordinal); + if (arrowIndex < 0) { + return false; + } + + string left = trimmed.Substring (0, arrowIndex); + obfuscatedName = trimmed.Substring (arrowIndex + arrow.Length).Trim (); + if (obfuscatedName.Length == 0) { + return false; + } + + left = StripLeadingLineRange (left); + left = StripTrailingLineRange (left); + + int parenOpen = left.IndexOf ('('); + if (parenOpen >= 0 && left.EndsWith (")", StringComparison.Ordinal)) { + string beforeParen = left.Substring (0, parenOpen); + string paramList = left.Substring (parenOpen + 1, left.Length - parenOpen - 2); + + int lastSpace = beforeParen.LastIndexOf (' '); + if (lastSpace < 0) { + return false; + } + + name = beforeParen.Substring (lastSpace + 1); + javaReturnType = beforeParen.Substring (0, lastSpace); + javaParameterTypes = paramList.Length == 0 + ? Array.Empty () + : paramList.Split (','); + return name.Length > 0; + } else { + int lastSpace = left.LastIndexOf (' '); + if (lastSpace < 0) { + return false; + } + + name = left.Substring (lastSpace + 1); + javaParameterTypes = null; + javaReturnType = null; + return name.Length > 0; + } + } + + /// + /// Strips a leading "startLine:endLine:" prefix used on some method mapping lines, e.g. + /// "4:10:void onCreate(...)" -> "void onCreate(...)". + /// + static string StripLeadingLineRange (string s) + { + int i = 0; + while (i < s.Length && char.IsDigit (s [i])) { + i++; + } + if (i == 0 || i >= s.Length || s [i] != ':') { + return s; + } + + int secondStart = i + 1; + int j = secondStart; + while (j < s.Length && char.IsDigit (s [j])) { + j++; + } + if (j == secondStart || j >= s.Length || s [j] != ':') { + return s; + } + + return s.Substring (j + 1); + } + + /// + /// Strips a trailing ":originalStartLine[:originalEndLine]" suffix used on some method + /// mapping lines, e.g. "void onCreate(...):23:29" -> "void onCreate(...)" (two original + /// line numbers) and "void run(...):2" -> "void run(...)" (a single original line number, + /// emitted when the original range collapses to one line). + /// + static string StripTrailingLineRange (string s) + { + if (s.Length == 0 || !char.IsDigit (s [s.Length - 1])) { + return s; + } + + int i = s.Length - 1; + while (i >= 0 && char.IsDigit (s [i])) { + i--; + } + if (i < 0 || s [i] != ':') { + return s; + } + + int lastColon = i; + + // Look for a second, earlier number - ":originalStartLine:originalEndLine". + int j = i - 1; + while (j >= 0 && char.IsDigit (s [j])) { + j--; + } + if (j >= 0 && j != i - 1 && s [j] == ':') { + return s.Substring (0, j); + } + + // Only one trailing number - ":originalStartLine". + return s.Substring (0, lastColon); + } + } +} From 98fd51329015f870c15d12d258573e581a487e66 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 16:26:47 +0200 Subject: [PATCH 2/7] Reject malformed JNI object descriptors Require non-empty slash-separated JNI class names and reject Java source names or array markers embedded in object descriptors. Validate standalone type tokens before conversion so malformed input consistently throws ArgumentException. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JniRemapping/JniDescriptorTextTests.cs | 19 +++++++++++ .../JniRemapping/JniDescriptorText.cs | 33 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs index 57c5abe05ee..867ea4449a9 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs @@ -42,6 +42,10 @@ public void LeavesUnrelatedTypesAndPrimitivesAlone () [TestCase ("(I)I", true)] [TestCase ("(V)V", false)] [TestCase ("()[V", false)] + [TestCase ("(L;)V", false)] + [TestCase ("(Ljava.lang.Object;)V", false)] + [TestCase ("(Lfoo[Bar;)V", false)] + [TestCase ("(Lfoo//Bar;)V", false)] [TestCase ("I", false)] [TestCase ("Ljava/lang/Object;", false)] [TestCase ("not a descriptor", false)] @@ -55,6 +59,11 @@ public void ValidatesMethodDescriptors (string descriptor, bool expected) [TestCase ("Ljava/lang/Object;", true)] [TestCase ("V", false)] [TestCase ("[V", false)] + [TestCase ("L;", false)] + [TestCase ("[L;", false)] + [TestCase ("Ljava.lang.Object;", false)] + [TestCase ("Lfoo[Bar;", false)] + [TestCase ("Lfoo//Bar;", false)] [TestCase ("()V", false)] [TestCase ("", false)] public void ValidatesFieldDescriptors (string descriptor, bool expected) @@ -77,5 +86,15 @@ public void ConvertsSingleTypeTokenToJavaSource () Assert.AreEqual ("int[]", JniDescriptorText.JniTypeTokenToJavaSource ("[I")); Assert.AreEqual ("java.lang.Object", JniDescriptorText.JniTypeTokenToJavaSource ("Ljava/lang/Object;")); } + + [TestCase ("")] + [TestCase ("[")] + [TestCase ("L;")] + [TestCase ("Ljava.lang.Object;")] + [TestCase ("Lfoo[Bar;")] + public void RejectsMalformedSingleTypeToken (string token) + { + Assert.Throws (() => JniDescriptorText.JniTypeTokenToJavaSource (token)); + } } } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs index 6ba1975616f..9f8f74db0c6 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs @@ -105,7 +105,7 @@ static bool TryScanSingleToken (string s, ref int i, bool allowVoid) return true; case 'L': int end = s.IndexOf (';', j + 1); - if (end < 0) { + if (end < 0 || !IsValidJniClassName (s, j + 1, end)) { return false; } i = end + 1; @@ -115,6 +115,32 @@ static bool TryScanSingleToken (string s, ref int i, bool allowVoid) } } + static bool IsValidJniClassName (string value, int start, int end) + { + if (start == end) { + return false; + } + + bool segmentHasCharacters = false; + for (int i = start; i < end; i++) { + switch (value [i]) { + case '/': + if (!segmentHasCharacters) { + return false; + } + segmentHasCharacters = false; + break; + case '.': + case '[': + return false; + default: + segmentHasCharacters = true; + break; + } + } + return segmentHasCharacters; + } + /// /// Parses a JNI method descriptor "(param1param2...)ret" into its parameter type tokens /// and return type token. @@ -174,6 +200,11 @@ public static bool IsValidFieldDescriptor (string descriptor) /// public static string JniTypeTokenToJavaSource (string token) { + int tokenEnd = 0; + if (!TryScanSingleToken (token, ref tokenEnd, allowVoid: true) || tokenEnd != token.Length) { + throw new ArgumentException ($"Malformed JNI type token '{token}'.", nameof (token)); + } + int arrayDepth = 0; while (arrayDepth < token.Length && token [arrayDepth] == '[') { arrayDepth++; From fef6416a2773b5ec213c8beadd0774d6682b8a7d Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 16:43:40 +0200 Subject: [PATCH 3/7] Address R8 mapping review feedback Fail reverse field and name-only method lookups when multiple allowed original names remain. Make manifest newlines deterministic and avoid LINQ allocation when formatting JNI array types. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Utilities/JniRemapping/R8MappingTests.cs | 42 ++++++++++++++++--- .../JniRemapping/JniDescriptorText.cs | 11 ++++- .../Utilities/JniRemapping/R8Mapping.cs | 27 +++++++++--- 3 files changed, 68 insertions(+), 12 deletions(-) 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 fdf12957c34..34bff66570c 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 @@ -370,7 +370,7 @@ int ambiguous2 -> c } [Test] - public void ReverseMappingRecordsEveryAmbiguousMemberCandidate () + public void ReverseMappingRejectsAmbiguousMemberCandidates () { R8Mapping mapping = R8Mapping.Parse (new StringReader (""" acme.orig.MyView -> a.b.C: @@ -382,17 +382,49 @@ void invoke(int) -> b """)); IJniNameMapping reverse = mapping.CreateReverseMapping (); - Assert.IsTrue (reverse.TryMapField ("a/b/C", "a", out _)); - Assert.IsTrue (reverse.TryMapMethodByNameOnly ("a/b/C", "b", out _)); + Assert.IsFalse (reverse.TryMapField ("a/b/C", "a", out _)); + Assert.IsFalse (reverse.TryMapMethodByNameOnly ("a/b/C", "b", out _)); + CollectionAssert.IsEmpty (mapping.AccessedEntries); + } + + [Test] + public void ReverseMappingUsesSingleAllowedMemberCandidate () + { + R8Mapping mapping = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> a.b.C: + int first -> a + java.lang.String second -> a + void run() -> b + void invoke(int) -> b + + """)); + mapping.RestrictReverseLookupsTo (new [] { + "C\tacme/orig/MyView", + "F\tacme/orig/MyView\tsecond", + "M\tacme/orig/MyView\tinvoke(int):void", + }); + IJniNameMapping reverse = mapping.CreateReverseMapping (); + + Assert.IsTrue (reverse.TryMapField ("a/b/C", "a", out string originalField)); + Assert.AreEqual ("second", originalField); + Assert.IsTrue (reverse.TryMapMethodByNameOnly ("a/b/C", "b", out string originalMethod)); + Assert.AreEqual ("invoke", originalMethod); CollectionAssert.AreEquivalent (new [] { "C\tacme/orig/MyView", - "F\tacme/orig/MyView\tfirst", "F\tacme/orig/MyView\tsecond", "M\tacme/orig/MyView\tinvoke(int):void", - "M\tacme/orig/MyView\trun():void", }, mapping.AccessedEntries); } + [Test] + public void CreatesDeterministicManifestContent () + { + Assert.AreEqual ("C\tacme/orig/A\nC\tacme/orig/B\n", R8Mapping.CreateManifestContent (new [] { + "C\tacme/orig/B", + "C\tacme/orig/A", + })); + } + [Test] public void DistinguishesMethodsByReturnType () { diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs index 9f8f74db0c6..8998841ffde 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs @@ -224,7 +224,16 @@ public static string JniTypeTokenToJavaSource (string token) _ => throw new ArgumentException ($"Malformed JNI type token '{token}'.", nameof (token)), }; - return elementJavaName + string.Concat (System.Linq.Enumerable.Repeat ("[]", arrayDepth)); + if (arrayDepth == 0) { + return elementJavaName; + } + + var result = new StringBuilder (elementJavaName.Length + arrayDepth * 2); + result.Append (elementJavaName); + for (int i = 0; i < arrayDepth; i++) { + result.Append ("[]"); + } + return result.ToString (); } /// diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs index 9607ea00122..feeb6dd941d 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -424,7 +424,9 @@ internal static string CreateManifestContent (IEnumerable entries) { var sortedEntries = new List (entries); sortedEntries.Sort (StringComparer.Ordinal); - using var writer = new StringWriter (); + using var writer = new StringWriter { + NewLine = "\n", + }; foreach (string entry in sortedEntries) { writer.WriteLine (entry); } @@ -459,18 +461,23 @@ public bool TryMapField (string owningClassName, string fieldName, out string ma return false; } string? firstOriginalName = null; + string? allowedEntry = null; foreach (string originalName in originalNames) { string entry = BuildFieldEntry (originalClassName, originalName); if (!mapping.IsReverseEntryAllowed (entry)) { continue; } - firstOriginalName ??= originalName; - mapping.accessedEntries.Add (entry); + if (firstOriginalName != null) { + return false; + } + firstOriginalName = originalName; + allowedEntry = entry; } - if (firstOriginalName == null) { + if (firstOriginalName == null || allowedEntry == null) { return false; } mapping.accessedEntries.Add (BuildClassEntry (originalClassName)); + mapping.accessedEntries.Add (allowedEntry); mappedFieldName = firstOriginalName; return true; } @@ -511,6 +518,7 @@ public bool TryMapMethodByNameOnly (string owningClassName, string methodName, o } string? firstOriginalName = null; + var allowedEntries = new List (); foreach (var entry in classMethods) { if (!String.Equals (entry.Value, methodName, StringComparison.Ordinal)) { continue; @@ -520,13 +528,20 @@ public bool TryMapMethodByNameOnly (string owningClassName, string methodName, o continue; } int parameterStart = entry.Key.IndexOf ('('); - firstOriginalName ??= parameterStart < 0 ? entry.Key : entry.Key.Substring (0, parameterStart); - mapping.accessedEntries.Add (manifestEntry); + string originalName = parameterStart < 0 ? entry.Key : entry.Key.Substring (0, parameterStart); + if (firstOriginalName != null && !String.Equals (firstOriginalName, originalName, StringComparison.Ordinal)) { + return false; + } + firstOriginalName = originalName; + allowedEntries.Add (manifestEntry); } if (firstOriginalName == null) { return false; } mapping.accessedEntries.Add (BuildClassEntry (originalClassName)); + foreach (string entry in allowedEntries) { + mapping.accessedEntries.Add (entry); + } mappedMethodName = firstOriginalName; return true; } From eda2d7ae96ac5d5ad36d7b2b4eec0dfbeb5bd955 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 16:54:05 +0200 Subject: [PATCH 4/7] Address mapping primitives review Report source paths in mapping parse errors, make access tracking thread-safe with snapshot semantics, remove unused reverse lookup APIs, and avoid redundant JNI token validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Utilities/JniRemapping/R8MappingTests.cs | 57 ++++++--- .../JniRemapping/JniDescriptorText.cs | 19 ++- .../Utilities/JniRemapping/R8Mapping.cs | 114 ++++++++++-------- 3 files changed, 119 insertions(+), 71 deletions(-) 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 34bff66570c..581dac8be06 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 @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Threading.Tasks; using NUnit.Framework; using Xamarin.Android.Tasks.JniRemapping; @@ -34,7 +35,6 @@ public void LooksUpOriginalClassAndMethodNames () Assert.IsTrue (mapping.TryGetOriginalClass ("a/b/C", out string originalClass)); Assert.AreEqual ("acme/orig/MyView", originalClass); - CollectionAssert.AreEquivalent (new [] { "first", "second" }, mapping.GetOriginalMethodNames (originalClass, "x")); Assert.IsTrue (mapping.TryGetOriginalMethodName (originalClass, "x", new [] { "int" }, "void", out string first)); Assert.AreEqual ("first", first); Assert.IsTrue (mapping.TryGetOriginalMethodName (originalClass, "x", Array.Empty (), "void", out string second)); @@ -236,6 +236,19 @@ public void ThrowsOnMemberLineBeforeAnyClassLine () Assert.Throws (() => R8Mapping.Parse (new StringReader (" int someField -> x\n"))); } + [Test] + public void LoadReportsSourcePathInFormatError () + { + string directory = Path.Combine (Root, "temp", TestName); + string path = Path.Combine (directory, "seed.map"); + Directory.CreateDirectory (directory); + File.WriteAllText (path, "not a mapping"); + + FormatException error = Assert.Throws (() => R8Mapping.Load (path)); + + Assert.That (error.Message, Does.StartWith ($"{path}:1:")); + } + [Test] public void ReportsNamesThatDifferBetweenSeedAndFinalMappings () { @@ -315,6 +328,31 @@ void onClick(android.view.View) -> b }, mapping.AccessedEntries); } + [Test] + public void AccessedEntriesIsAThreadSafeSnapshot () + { + R8Mapping mapping = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> a.b.C: + int count -> a + void onClick(android.view.View) -> b + + """)); + var emptySnapshot = mapping.AccessedEntries; + + Parallel.For (0, 100, iteration => { + mapping.TryGetRenamedClass ("acme/orig/MyView", out _); + mapping.TryGetRenamedField ("acme/orig/MyView", "count", out _); + mapping.TryGetRenamedMethod ("acme/orig/MyView", "onClick", new [] { "android.view.View" }, "void", out _); + }); + + CollectionAssert.IsEmpty (emptySnapshot); + CollectionAssert.AreEquivalent (new [] { + "C\tacme/orig/MyView", + "F\tacme/orig/MyView\tcount", + "M\tacme/orig/MyView\tonClick(android.view.View):void", + }, mapping.AccessedEntries); + } + [Test] public void ReportsPostLinkEntriesRemovedByFinalR8 () { @@ -352,23 +390,6 @@ void kept() -> a })); } - [Test] - public void LooksUpOriginalFieldNameOnlyWhenUnambiguous () - { - R8Mapping mapping = R8Mapping.Parse (new StringReader (""" - acme.orig.MyView -> a.b.C: - int first -> a - int second -> b - int ambiguous1 -> c - int ambiguous2 -> c - - """)); - - Assert.IsTrue (mapping.TryGetOriginalFieldName ("acme/orig/MyView", "a", out string original)); - Assert.AreEqual ("first", original); - Assert.IsFalse (mapping.TryGetOriginalFieldName ("acme/orig/MyView", "c", out _)); - } - [Test] public void ReverseMappingRejectsAmbiguousMemberCandidates () { diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs index 8998841ffde..b60d63a6d65 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs @@ -132,7 +132,7 @@ static bool IsValidJniClassName (string value, int start, int end) break; case '.': case '[': - return false; + return false; default: segmentHasCharacters = true; break; @@ -210,6 +210,11 @@ public static string JniTypeTokenToJavaSource (string token) arrayDepth++; } + return JniTypeTokenToJavaSource (token, arrayDepth); + } + + static string JniTypeTokenToJavaSource (string token, int arrayDepth) + { string elementJavaName = token [arrayDepth] switch { 'V' => "void", 'Z' => "boolean", @@ -254,9 +259,17 @@ public static void MethodDescriptorToJavaTypes (string descriptor, out List (jniParameterTypes.Count); foreach (string parameterType in jniParameterTypes) { - parameterTypes.Add (JniTypeTokenToJavaSource (parameterType)); + int arrayDepth = 0; + while (parameterType [arrayDepth] == '[') { + arrayDepth++; + } + parameterTypes.Add (JniTypeTokenToJavaSource (parameterType, arrayDepth)); + } + int returnArrayDepth = 0; + while (jniReturnType [returnArrayDepth] == '[') { + returnArrayDepth++; } - returnType = JniTypeTokenToJavaSource (jniReturnType); + returnType = JniTypeTokenToJavaSource (jniReturnType, returnArrayDepth); } } } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs index feeb6dd941d..7764cc718af 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -32,14 +32,21 @@ sealed class R8Mapping : IJniNameMapping // Original JNI class name -> ("name(javaParam,javaParam,...):javaReturn" -> obfuscated method name). readonly Dictionary> methods = new Dictionary> (StringComparer.Ordinal); - // Reverse member indexes are scoped by original class. Fields retain every candidate because - // R8 may reuse an obfuscated field name for fields with different JVM descriptors. + // Reverse member indexes are scoped by original class. Fields retain every candidate so + // reverse lookup can fail closed unless manifest filtering leaves exactly one candidate. readonly Dictionary>> originalFields = new Dictionary>> (StringComparer.Ordinal); readonly Dictionary> originalMethods = new Dictionary> (StringComparer.Ordinal); readonly HashSet accessedEntries = new HashSet (StringComparer.Ordinal); + readonly object accessedEntriesLock = new object (); HashSet? allowedReverseEntries; - public IEnumerable AccessedEntries => accessedEntries; + public IReadOnlyCollection AccessedEntries { + get { + lock (accessedEntriesLock) { + return new List (accessedEntries); + } + } + } bool IJniNameMapping.TryMapClass (string className, out string mappedClassName) => TryGetRenamedClass (className, out mappedClassName); @@ -64,10 +71,13 @@ bool IsReverseEntryAllowed (string entry) public static R8Mapping Load (string path) { using var reader = new StreamReader (path); - return Parse (reader); + return Parse (reader, path); } public static R8Mapping Parse (TextReader reader) + => Parse (reader, "mapping.txt"); + + static R8Mapping Parse (TextReader reader, string sourceName) { var mapping = new R8Mapping (); string? currentOriginalClass = null; @@ -91,7 +101,7 @@ public static R8Mapping Parse (TextReader reader) if (!indented) { if (!TryParseClassLine (trimmed, out string originalClass, out string obfuscatedClass)) { - throw new FormatException ($"mapping.txt:{lineNumber}: expected a class mapping line ('original -> obfuscated:'), got '{line}'."); + throw new FormatException ($"{sourceName}:{lineNumber}: expected a class mapping line ('original -> obfuscated:'), got '{line}'."); } currentOriginalClass = JavaNameToJni (originalClass); @@ -102,11 +112,11 @@ public static R8Mapping Parse (TextReader reader) } if (currentOriginalClass == null) { - throw new FormatException ($"mapping.txt:{lineNumber}: member mapping line found before any class mapping line: '{line}'."); + throw new FormatException ($"{sourceName}:{lineNumber}: member mapping line found before any class mapping line: '{line}'."); } if (!TryParseMemberLine (trimmed, out string memberName, out string []? javaParameterTypes, out string? javaReturnType, out string obfuscatedName)) { - throw new FormatException ($"mapping.txt:{lineNumber}: could not parse member mapping line: '{line}'."); + throw new FormatException ($"{sourceName}:{lineNumber}: could not parse member mapping line: '{line}'."); } if (javaParameterTypes == null) { @@ -166,8 +176,8 @@ void BuildReverseMemberIndexes () continue; } int parameterStart = method.Key.IndexOf ('('); - string originalName = parameterStart < 0 ? method.Key : method.Key.Substring (0, parameterStart); - string signature = parameterStart < 0 ? "()" : method.Key.Substring (parameterStart); + string originalName = method.Key.Substring (0, parameterStart); + string signature = method.Key.Substring (parameterStart); AddUnambiguousReverseEntry (reverseMethods, method.Value + signature, originalName); } } @@ -204,7 +214,7 @@ public bool TryGetRenamedClass (string originalJniClassName, out string obfuscat { if (classes.TryGetValue (originalJniClassName, out string? renamed)) { obfuscatedJniClassName = renamed; - accessedEntries.Add (BuildClassEntry (originalJniClassName)); + RecordAccess (BuildClassEntry (originalJniClassName)); return true; } obfuscatedJniClassName = ""; @@ -221,24 +231,6 @@ public bool TryGetOriginalClass (string obfuscatedJniClassName, out string origi return false; } - public IEnumerable GetOriginalMethodNames (string originalJniClassName, string obfuscatedMethodName) - { - if (!methods.TryGetValue (originalJniClassName, out var classMethods)) { - yield break; - } - var seen = new HashSet (StringComparer.Ordinal); - foreach (var entry in classMethods) { - if (!String.Equals (entry.Value, obfuscatedMethodName, StringComparison.Ordinal)) { - continue; - } - int parameters = entry.Key.IndexOf ('('); - string name = parameters < 0 ? entry.Key : entry.Key.Substring (0, parameters); - if (seen.Add (name)) { - yield return name; - } - } - } - public bool TryGetOriginalMethodName (string originalJniClassName, string obfuscatedMethodName, IReadOnlyList originalJavaParameterTypes, string originalJavaReturnType, out string originalMethodName) { originalMethodName = ""; @@ -247,15 +239,6 @@ public bool TryGetOriginalMethodName (string originalJniClassName, string obfusc originalMethodName.Length != 0; } - public bool TryGetOriginalFieldName (string originalJniClassName, string obfuscatedFieldName, out string originalFieldName) - { - originalFieldName = ""; - return originalFields.TryGetValue (originalJniClassName, out var classFields) && - classFields.TryGetValue (obfuscatedFieldName, out var originalNames) && - originalNames.Count == 1 && - (originalFieldName = originalNames [0]).Length != 0; - } - public bool TryGetRenamedField (string owningJniClassName, string originalFieldName, out string obfuscatedFieldName) { obfuscatedFieldName = ""; @@ -264,7 +247,7 @@ public bool TryGetRenamedField (string owningJniClassName, string originalFieldN return false; } obfuscatedFieldName = renamed; - accessedEntries.Add (BuildFieldEntry (owningJniClassName, originalFieldName)); + RecordAccess (BuildFieldEntry (owningJniClassName, originalFieldName)); return true; } @@ -279,7 +262,7 @@ public bool TryGetRenamedMethod (string owningJniClassName, string javaMethodNam return false; } obfuscatedMethodName = renamed; - accessedEntries.Add (BuildMethodEntry (owningJniClassName, methodKey)); + RecordAccess (BuildMethodEntry (owningJniClassName, methodKey)); return true; } @@ -313,11 +296,13 @@ public bool TryGetRenamedMethodByNameOnly (string owningJniClassName, string jav return false; } + var accessedMethods = new List (); foreach (var kvp in classMethods) { if (kvp.Key.StartsWith (prefix, StringComparison.Ordinal) && kvp.Value == match) { - accessedEntries.Add (BuildMethodEntry (owningJniClassName, kvp.Key)); + accessedMethods.Add (BuildMethodEntry (owningJniClassName, kvp.Key)); } } + RecordAccess (accessedMethods); obfuscatedMethodName = match; return true; } @@ -433,6 +418,40 @@ internal static string CreateManifestContent (IEnumerable entries) return writer.ToString (); } + void RecordAccess (string entry) + { + lock (accessedEntriesLock) { + accessedEntries.Add (entry); + } + } + + void RecordAccess (IEnumerable entries) + { + lock (accessedEntriesLock) { + foreach (string entry in entries) { + accessedEntries.Add (entry); + } + } + } + + void RecordAccess (string classEntry, string memberEntry) + { + lock (accessedEntriesLock) { + accessedEntries.Add (classEntry); + accessedEntries.Add (memberEntry); + } + } + + void RecordAccess (string classEntry, IEnumerable memberEntries) + { + lock (accessedEntriesLock) { + accessedEntries.Add (classEntry); + foreach (string entry in memberEntries) { + accessedEntries.Add (entry); + } + } + } + sealed class ReverseR8Mapping : IJniNameMapping { readonly R8Mapping mapping; @@ -447,7 +466,7 @@ public bool TryMapClass (string className, out string mappedClassName) if (!TryGetAllowedOriginalClass (className, out mappedClassName)) { return false; } - mapping.accessedEntries.Add (BuildClassEntry (mappedClassName)); + mapping.RecordAccess (BuildClassEntry (mappedClassName)); return true; } @@ -476,8 +495,7 @@ public bool TryMapField (string owningClassName, string fieldName, out string ma if (firstOriginalName == null || allowedEntry == null) { return false; } - mapping.accessedEntries.Add (BuildClassEntry (originalClassName)); - mapping.accessedEntries.Add (allowedEntry); + mapping.RecordAccess (BuildClassEntry (originalClassName), allowedEntry); mappedFieldName = firstOriginalName; return true; } @@ -504,8 +522,7 @@ public bool TryMapMethod (string owningClassName, string methodName, IReadOnlyLi mappedMethodName = ""; return false; } - mapping.accessedEntries.Add (BuildClassEntry (originalClassName)); - mapping.accessedEntries.Add (entry); + mapping.RecordAccess (BuildClassEntry (originalClassName), entry); return true; } @@ -528,7 +545,7 @@ public bool TryMapMethodByNameOnly (string owningClassName, string methodName, o continue; } int parameterStart = entry.Key.IndexOf ('('); - string originalName = parameterStart < 0 ? entry.Key : entry.Key.Substring (0, parameterStart); + string originalName = entry.Key.Substring (0, parameterStart); if (firstOriginalName != null && !String.Equals (firstOriginalName, originalName, StringComparison.Ordinal)) { return false; } @@ -538,10 +555,7 @@ public bool TryMapMethodByNameOnly (string owningClassName, string methodName, o if (firstOriginalName == null) { return false; } - mapping.accessedEntries.Add (BuildClassEntry (originalClassName)); - foreach (string entry in allowedEntries) { - mapping.accessedEntries.Add (entry); - } + mapping.RecordAccess (BuildClassEntry (originalClassName), allowedEntries); mappedMethodName = firstOriginalName; return true; } From 27dc06a0f698b39c867d7ae8f387fe3d8d5c1e97 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 17:50:33 +0200 Subject: [PATCH 5/7] Handle merged classes and inline frames Preserve every original class candidate for R8 class merging and disambiguate reverse mappings through the rewrite manifest. Parse positional mapping groups as retrace stacks so only the final residual frame is callable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Utilities/JniRemapping/R8MappingTests.cs | 75 ++++++++++++ .../Utilities/JniRemapping/R8Mapping.cs | 114 +++++++++++++----- 2 files changed, 156 insertions(+), 33 deletions(-) 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 581dac8be06..408e77e1fa6 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 @@ -213,6 +213,20 @@ public void IgnoresQualifiedInlineCallFrameMappings () Assert.IsFalse (mapping.TryGetRenamedMethod ("androidx/collection/LongSparseArray", "androidx.collection.LongSparseArrayKt.commonGc", new [] { "androidx.collection.LongSparseArray" }, "void", out _)); } + [Test] + public void IgnoresSameClassInlineCallFrameMappings () + { + var mapping = R8Mapping.Parse (new StringReader ( + "acme.orig.MyView -> a.b.C:\n" + + " 4:4:void inlined():23:23 -> a\n" + + " 4:4:void caller():42:42 -> a\n" + + " # {'id':'com.android.tools.r8.synthesized'}\n")); + + Assert.IsFalse (mapping.TryGetRenamedMethod ("acme/orig/MyView", "inlined", [], "void", out _)); + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "caller", [], "void", out string caller)); + Assert.AreEqual ("a", caller); + } + [Test] public void TreatsMethodInlinedIntoMultipleDestinationsAsAmbiguous () { @@ -437,6 +451,67 @@ void invoke(int) -> b }, mapping.AccessedEntries); } + [Test] + public void ReverseMappingRejectsAmbiguousMergedClassCandidates () + { + R8Mapping mapping = R8Mapping.Parse (new StringReader (""" + acme.orig.First -> a.b.C: + int first -> a + acme.orig.Second -> a.b.C: + int second -> a + + """)); + IJniNameMapping reverse = mapping.CreateReverseMapping (); + + Assert.IsFalse (mapping.TryGetOriginalClass ("a/b/C", out _)); + Assert.IsFalse (reverse.TryMapClass ("a/b/C", out _)); + Assert.IsFalse (reverse.TryMapField ("a/b/C", "a", out _)); + CollectionAssert.IsEmpty (mapping.AccessedEntries); + } + + [Test] + public void ReverseMappingUsesSingleAllowedMergedClassCandidate () + { + R8Mapping mapping = R8Mapping.Parse (new StringReader (""" + acme.orig.First -> a.b.C: + int first -> a + acme.orig.Second -> a.b.C: + int second -> a + + """)); + mapping.RestrictReverseLookupsTo (new [] { + "C\tacme/orig/Second", + "F\tacme/orig/Second\tsecond", + }); + IJniNameMapping reverse = mapping.CreateReverseMapping (); + + Assert.IsTrue (reverse.TryMapClass ("a/b/C", out string originalClass)); + Assert.AreEqual ("acme/orig/Second", originalClass); + Assert.IsTrue (reverse.TryMapField ("a/b/C", "a", out string originalField)); + Assert.AreEqual ("second", originalField); + CollectionAssert.AreEquivalent (new [] { + "C\tacme/orig/Second", + "F\tacme/orig/Second\tsecond", + }, mapping.AccessedEntries); + } + + [Test] + public void ReverseMappingRejectsMergedClassWithNoAllowedCandidate () + { + R8Mapping mapping = R8Mapping.Parse (new StringReader (""" + acme.orig.First -> a.b.C: + acme.orig.Second -> a.b.C: + + """)); + mapping.RestrictReverseLookupsTo (new [] { + "C\tacme/orig/Unrelated", + }); + IJniNameMapping reverse = mapping.CreateReverseMapping (); + + Assert.IsFalse (reverse.TryMapClass ("a/b/C", out _)); + CollectionAssert.IsEmpty (mapping.AccessedEntries); + } + [Test] public void CreatesDeterministicManifestContent () { diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs index 7764cc718af..3de6b4864f9 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -23,8 +23,9 @@ sealed class R8Mapping : IJniNameMapping // Original JNI class name -> obfuscated JNI class name. readonly Dictionary classes = new Dictionary (StringComparer.Ordinal); - // Obfuscated JNI class name -> original JNI class name. - readonly Dictionary originalClasses = new Dictionary (StringComparer.Ordinal); + // Obfuscated JNI class name -> original JNI class names. R8 class merging can map several + // original classes to one residual class, so reverse lookup must disambiguate this list. + readonly Dictionary> originalClasses = new Dictionary> (StringComparer.Ordinal); // Original JNI class name -> (original field name -> obfuscated field name). readonly Dictionary> fields = new Dictionary> (StringComparer.Ordinal); @@ -81,6 +82,9 @@ static R8Mapping Parse (TextReader reader, string sourceName) { var mapping = new R8Mapping (); string? currentOriginalClass = null; + string? pendingPositionRange = null; + string? pendingObfuscatedName = null; + string? pendingMethodKey = null; int lineNumber = 0; string? line; @@ -100,6 +104,7 @@ static R8Mapping Parse (TextReader reader, string sourceName) } if (!indented) { + FlushPendingMethodMapping (mapping, currentOriginalClass, ref pendingPositionRange, ref pendingObfuscatedName, ref pendingMethodKey); if (!TryParseClassLine (trimmed, out string originalClass, out string obfuscatedClass)) { throw new FormatException ($"{sourceName}:{lineNumber}: expected a class mapping line ('original -> obfuscated:'), got '{line}'."); } @@ -107,7 +112,12 @@ static R8Mapping Parse (TextReader reader, string sourceName) currentOriginalClass = JavaNameToJni (originalClass); string currentObfuscatedClass = JavaNameToJni (obfuscatedClass); mapping.classes [currentOriginalClass] = currentObfuscatedClass; - mapping.originalClasses [currentObfuscatedClass] = currentOriginalClass; + if (!mapping.originalClasses.TryGetValue (currentObfuscatedClass, out var originalClassNames)) { + mapping.originalClasses [currentObfuscatedClass] = originalClassNames = new List (); + } + if (!originalClassNames.Contains (currentOriginalClass)) { + originalClassNames.Add (currentOriginalClass); + } continue; } @@ -115,46 +125,71 @@ static R8Mapping Parse (TextReader reader, string sourceName) throw new FormatException ($"{sourceName}:{lineNumber}: member mapping line found before any class mapping line: '{line}'."); } - if (!TryParseMemberLine (trimmed, out string memberName, out string []? javaParameterTypes, out string? javaReturnType, out string obfuscatedName)) { + if (!TryParseMemberLine (trimmed, out string memberName, out string []? javaParameterTypes, out string? javaReturnType, out string obfuscatedName, out string? positionRange)) { throw new FormatException ($"{sourceName}:{lineNumber}: could not parse member mapping line: '{line}'."); } if (javaParameterTypes == null) { + FlushPendingMethodMapping (mapping, currentOriginalClass, ref pendingPositionRange, ref pendingObfuscatedName, ref pendingMethodKey); // Field. if (!mapping.fields.TryGetValue (currentOriginalClass, out var classFields)) { mapping.fields [currentOriginalClass] = classFields = new Dictionary (StringComparer.Ordinal); } classFields [memberName] = obfuscatedName; } else { - // R8 emits fully-qualified source methods as inline call-frame records beneath - // the destination method. They are retrace metadata, not member mappings for - // the current class, and one source method may appear under many destinations. - if (memberName.IndexOf ('.') >= 0) { - continue; - } - - // Method. string key = BuildMethodKey (memberName, javaParameterTypes, javaReturnType ?? ""); - if (!mapping.methods.TryGetValue (currentOriginalClass, out var classMethods)) { - mapping.methods [currentOriginalClass] = classMethods = new Dictionary (StringComparer.Ordinal); - } - if (classMethods.TryGetValue (key, out string? existing) && existing != obfuscatedName) { - // An optimized method can be inlined into several surviving methods. R8 - // then emits one retrace record per destination, so there is no single - // runtime name to use for this source member. - classMethods [key] = ""; - continue; - } - if (existing == null) { - classMethods [key] = obfuscatedName; + if (positionRange == null) { + FlushPendingMethodMapping (mapping, currentOriginalClass, ref pendingPositionRange, ref pendingObfuscatedName, ref pendingMethodKey); + if (memberName.IndexOf ('.') < 0) { + mapping.AddMethodMapping (currentOriginalClass, key, obfuscatedName); + } + } else { + bool continuesPositionGroup = + String.Equals (pendingPositionRange, positionRange, StringComparison.Ordinal) && + String.Equals (pendingObfuscatedName, obfuscatedName, StringComparison.Ordinal); + if (!continuesPositionGroup) { + FlushPendingMethodMapping (mapping, currentOriginalClass, ref pendingPositionRange, ref pendingObfuscatedName, ref pendingMethodKey); + pendingPositionRange = positionRange; + pendingObfuscatedName = obfuscatedName; + } + + // Positional records with the same residual range and name form an inline + // stack. Only the final, unqualified record names the callable residual + // method; preceding records exist solely for retrace. + pendingMethodKey = memberName.IndexOf ('.') < 0 ? key : null; } } } + FlushPendingMethodMapping (mapping, currentOriginalClass, ref pendingPositionRange, ref pendingObfuscatedName, ref pendingMethodKey); mapping.BuildReverseMemberIndexes (); return mapping; } + void AddMethodMapping (string originalClass, string key, string obfuscatedName) + { + if (!methods.TryGetValue (originalClass, out var classMethods)) { + methods [originalClass] = classMethods = new Dictionary (StringComparer.Ordinal); + } + if (classMethods.TryGetValue (key, out string? existing) && existing != obfuscatedName) { + // An optimized method can be inlined into several surviving methods. R8 then + // emits one retrace record per destination, so there is no single runtime name. + classMethods [key] = ""; + } else if (existing == null) { + classMethods [key] = obfuscatedName; + } + } + + static void FlushPendingMethodMapping (R8Mapping mapping, string? originalClass, ref string? positionRange, ref string? obfuscatedName, ref string? methodKey) + { + if (originalClass != null && obfuscatedName != null && methodKey != null) { + mapping.AddMethodMapping (originalClass, methodKey, obfuscatedName); + } + positionRange = null; + obfuscatedName = null; + methodKey = null; + } + void BuildReverseMemberIndexes () { foreach (var classEntry in fields) { @@ -223,8 +258,8 @@ public bool TryGetRenamedClass (string originalJniClassName, out string obfuscat public bool TryGetOriginalClass (string obfuscatedJniClassName, out string originalJniClassName) { - if (originalClasses.TryGetValue (obfuscatedJniClassName, out string? original)) { - originalJniClassName = original; + if (originalClasses.TryGetValue (obfuscatedJniClassName, out var originalClassNames) && originalClassNames.Count == 1) { + originalJniClassName = originalClassNames [0]; return true; } originalJniClassName = ""; @@ -573,12 +608,22 @@ string GetOriginalJavaType (string javaType) bool TryGetAllowedOriginalClass (string obfuscatedJniClassName, out string originalJniClassName) { - if (!mapping.TryGetOriginalClass (obfuscatedJniClassName, out originalJniClassName) || - !mapping.IsReverseEntryAllowed (BuildClassEntry (originalJniClassName))) { - originalJniClassName = ""; + originalJniClassName = ""; + if (!mapping.originalClasses.TryGetValue (obfuscatedJniClassName, out var originalClassNames)) { return false; } - return true; + + foreach (string candidate in originalClassNames) { + if (!mapping.IsReverseEntryAllowed (BuildClassEntry (candidate))) { + continue; + } + if (originalJniClassName.Length != 0) { + originalJniClassName = ""; + return false; + } + originalJniClassName = candidate; + } + return originalJniClassName.Length != 0; } } @@ -613,12 +658,13 @@ static bool TryParseClassLine (string trimmed, out string originalClass, out str return originalClass.Length > 0 && obfuscatedClass.Length > 0; } - static bool TryParseMemberLine (string trimmed, out string name, out string []? javaParameterTypes, out string? javaReturnType, out string obfuscatedName) + static bool TryParseMemberLine (string trimmed, out string name, out string []? javaParameterTypes, out string? javaReturnType, out string obfuscatedName, out string? positionRange) { name = ""; javaParameterTypes = null; javaReturnType = null; obfuscatedName = ""; + positionRange = null; const string arrow = " -> "; int arrowIndex = trimmed.LastIndexOf (arrow, StringComparison.Ordinal); @@ -632,7 +678,7 @@ static bool TryParseMemberLine (string trimmed, out string name, out string []? return false; } - left = StripLeadingLineRange (left); + left = StripLeadingLineRange (left, out positionRange); left = StripTrailingLineRange (left); int parenOpen = left.IndexOf ('('); @@ -668,8 +714,9 @@ static bool TryParseMemberLine (string trimmed, out string name, out string []? /// Strips a leading "startLine:endLine:" prefix used on some method mapping lines, e.g. /// "4:10:void onCreate(...)" -> "void onCreate(...)". /// - static string StripLeadingLineRange (string s) + static string StripLeadingLineRange (string s, out string? positionRange) { + positionRange = null; int i = 0; while (i < s.Length && char.IsDigit (s [i])) { i++; @@ -687,6 +734,7 @@ static string StripLeadingLineRange (string s) return s; } + positionRange = s.Substring (0, j); return s.Substring (j + 1); } From 1263c29b3ced231da1c5e7a9514f3ca96be12b10 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 20:26:03 +0200 Subject: [PATCH 6/7] Track R8 member declaring class access Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JniRemapping/LdstrRewriterTests.cs | 13 ++++ .../Utilities/JniRemapping/R8MappingTests.cs | 59 ++++++++++++++++++- .../Utilities/JniRemapping/R8Mapping.cs | 20 +++++-- 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/LdstrRewriterTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/LdstrRewriterTests.cs index ff6cc05e6cc..0d5b5c7f9e7 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/LdstrRewriterTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/LdstrRewriterTests.cs @@ -32,6 +32,19 @@ public void RewritesJniPeerMembersEncodedFieldId () Assert.AreEqual ("x.I", rewritten); } + [Test] + public void RewrittenFieldIdRoundTripsThroughAccessManifest () + { + R8Mapping mapping = BuildMapping (); + Assert.IsTrue (LdstrRewriter.TryRewrite ("someField.I", "acme/orig/MyView", mapping, out string rewritten)); + mapping.RestrictReverseLookupsTo (mapping.AccessedEntries); + IJniNameMapping reverse = mapping.CreateReverseMapping (); + + Assert.AreEqual ("x.I", rewritten); + Assert.IsTrue (reverse.TryMapField ("a/b/C", "x", out string originalField)); + Assert.AreEqual ("someField", originalField); + } + [Test] public void RewritesBareConstructorDescriptorEmbeddedTypesOnly () { 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 408e77e1fa6..ac914263b06 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 @@ -281,7 +281,7 @@ void onClick(android.view.View) -> e """)); - CollectionAssert.AreEqual (new [] { + CollectionAssert.AreEquivalent (new [] { "class 'acme/orig/MyView': seed name 'a/b/C', final name 'x/y/Z'", "field 'acme/orig/MyView.count': seed name 'a', final name 'd'", "method 'acme/orig/MyView.onClick(android.view.View):void': seed name 'b', final name 'e'", @@ -342,6 +342,33 @@ void onClick(android.view.View) -> b }, mapping.AccessedEntries); } + [TestCase ("field", "F\tacme/orig/MyView\tcount")] + [TestCase ("method", "M\tacme/orig/MyView\tonClick(android.view.View):void")] + [TestCase ("name-only method", "M\tacme/orig/MyView\tonStart():void")] + public void MemberOnlyLookupTracksOwningClass (string lookupKind, string expectedMemberEntry) + { + R8Mapping mapping = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> a.b.C: + int count -> a + void onClick(android.view.View) -> b + void onStart() -> c + + """)); + + bool found = lookupKind switch { + "field" => mapping.TryGetRenamedField ("acme/orig/MyView", "count", out _), + "method" => mapping.TryGetRenamedMethod ("acme/orig/MyView", "onClick", new [] { "android.view.View" }, "void", out _), + "name-only method" => mapping.TryGetRenamedMethodByNameOnly ("acme/orig/MyView", "onStart", out _), + _ => throw new InvalidOperationException ($"Unknown lookup kind '{lookupKind}'."), + }; + + Assert.IsTrue (found); + CollectionAssert.AreEqual (new [] { + "C\tacme/orig/MyView", + expectedMemberEntry, + }, mapping.AccessedEntries); + } + [Test] public void AccessedEntriesIsAThreadSafeSnapshot () { @@ -404,6 +431,21 @@ void kept() -> a })); } + [TestCase ("F\tacme/orig/MyView\tcount")] + [TestCase ("M\tacme/orig/MyView\tonClick():void")] + public void MemberOnlyManifestReportsRemovedDeclaringClass (string requiredEntry) + { + R8Mapping seed = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> a.b.C: + int count -> a + void onClick() -> b + + """)); + R8Mapping final = R8Mapping.Parse (new StringReader ("")); + + CollectionAssert.AreEqual (new [] { "class 'acme/orig/MyView'" }, seed.GetReachabilityConflicts (final, new [] { requiredEntry })); + } + [Test] public void ReverseMappingRejectsAmbiguousMemberCandidates () { @@ -521,6 +563,21 @@ public void CreatesDeterministicManifestContent () })); } + [Test] + public void NestedDescriptorTypeMatchesMappingKey () + { + R8Mapping mapping = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> a.b.C: + void m(acme.Outer$Inner) -> a + + """)); + var parameterTypes = JniDescriptorText.MethodDescriptorToJavaParameterTypes ("(Lacme/Outer$Inner;)V"); + + CollectionAssert.AreEqual (new [] { "acme.Outer$Inner" }, parameterTypes); + Assert.IsTrue (mapping.TryGetRenamedMethod ("acme/orig/MyView", "m", parameterTypes, "void", out string renamed)); + Assert.AreEqual ("a", renamed); + } + [Test] public void DistinguishesMethodsByReturnType () { diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs index 3de6b4864f9..a0fa5da470c 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -282,7 +282,9 @@ public bool TryGetRenamedField (string owningJniClassName, string originalFieldN return false; } obfuscatedFieldName = renamed; - RecordAccess (BuildFieldEntry (owningJniClassName, originalFieldName)); + RecordAccess ( + BuildClassEntry (owningJniClassName), + BuildFieldEntry (owningJniClassName, originalFieldName)); return true; } @@ -297,7 +299,9 @@ public bool TryGetRenamedMethod (string owningJniClassName, string javaMethodNam return false; } obfuscatedMethodName = renamed; - RecordAccess (BuildMethodEntry (owningJniClassName, methodKey)); + RecordAccess ( + BuildClassEntry (owningJniClassName), + BuildMethodEntry (owningJniClassName, methodKey)); return true; } @@ -337,7 +341,7 @@ public bool TryGetRenamedMethodByNameOnly (string owningJniClassName, string jav accessedMethods.Add (BuildMethodEntry (owningJniClassName, kvp.Key)); } } - RecordAccess (accessedMethods); + RecordAccess (BuildClassEntry (owningJniClassName), accessedMethods); obfuscatedMethodName = match; return true; } @@ -395,6 +399,7 @@ public IEnumerable GetCompatibilityConflicts (R8Mapping finalMapping, IE /// public IEnumerable GetReachabilityConflicts (R8Mapping finalMapping, IEnumerable requiredEntries) { + var reportedRemovedClasses = new HashSet (StringComparer.Ordinal); foreach (string requiredEntry in requiredEntries) { string [] parts = requiredEntry.Split ('\t'); switch (parts.Length > 0 ? parts [0] : "") { @@ -402,7 +407,8 @@ public IEnumerable GetReachabilityConflicts (R8Mapping finalMapping, IEn if (!classes.ContainsKey (parts [1])) { throw new FormatException ($"R8 reachability manifest class '{parts [1]}' is absent from the seed mapping."); } - if (!finalMapping.classes.TryGetValue (parts [1], out string? finalClassName) || IsRemovedClassName (finalClassName)) { + if ((!finalMapping.classes.TryGetValue (parts [1], out string? finalClassName) || IsRemovedClassName (finalClassName)) && + reportedRemovedClasses.Add (parts [1])) { yield return $"class '{parts [1]}'"; } break; @@ -411,6 +417,9 @@ public IEnumerable GetReachabilityConflicts (R8Mapping finalMapping, IEn throw new FormatException ($"R8 reachability manifest field '{parts [1]}.{parts [2]}' is absent from the seed mapping."); } if (!finalMapping.IsLiveClass (parts [1])) { + if (reportedRemovedClasses.Add (parts [1])) { + yield return $"class '{parts [1]}'"; + } continue; } if (!finalMapping.fields.TryGetValue (parts [1], out var finalFields) || !finalFields.ContainsKey (parts [2])) { @@ -422,6 +431,9 @@ public IEnumerable GetReachabilityConflicts (R8Mapping finalMapping, IEn throw new FormatException ($"R8 reachability manifest method '{parts [1]}.{parts [2]}' is absent from the seed mapping."); } if (!finalMapping.IsLiveClass (parts [1])) { + if (reportedRemovedClasses.Add (parts [1])) { + yield return $"class '{parts [1]}'"; + } continue; } if (!finalMapping.methods.TryGetValue (parts [1], out var finalMethods) || From ab0cb0ab17762466199676c46e8117ba28b81a27 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 2 Sep 2026 10:49:23 +0200 Subject: [PATCH 7/7] Preserve primitive types in reverse R8 mapping Avoid treating Java primitive source names as package-less residual class names during reverse method lookup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Utilities/JniRemapping/R8MappingTests.cs | 26 +++++++++++++++++++ .../Utilities/JniRemapping/R8Mapping.cs | 8 ++++++ 2 files changed, 34 insertions(+) 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 ac914263b06..8753eccdaa5 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 @@ -537,6 +537,32 @@ int second -> a }, mapping.AccessedEntries); } + [Test] + public void ReverseMethodMappingPreservesPrimitiveTypesThatCollideWithClassNames () + { + R8Mapping mapping = R8Mapping.Parse (new StringReader (""" + acme.orig.MyView -> a.b.C: + void consume(int) -> a + acme.orig.IntCollision -> int: + acme.orig.VoidCollision -> void: + + """)); + mapping.RestrictReverseLookupsTo (new [] { + "C\tacme/orig/MyView", + "C\tacme/orig/IntCollision", + "C\tacme/orig/VoidCollision", + "M\tacme/orig/MyView\tconsume(int):void", + }); + IJniNameMapping reverse = mapping.CreateReverseMapping (); + + Assert.IsTrue (reverse.TryMapMethod ("a/b/C", "a", new [] { "int" }, "void", out string originalMethod)); + Assert.AreEqual ("consume", originalMethod); + CollectionAssert.AreEquivalent (new [] { + "C\tacme/orig/MyView", + "M\tacme/orig/MyView\tconsume(int):void", + }, mapping.AccessedEntries); + } + [Test] public void ReverseMappingRejectsMergedClassWithNoAllowedCandidate () { diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs index a0fa5da470c..95933b4d98b 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -612,12 +612,20 @@ string GetOriginalJavaType (string javaType) int suffixStart = javaType.IndexOf ('['); string suffix = suffixStart < 0 ? "" : javaType.Substring (suffixStart); string elementType = suffixStart < 0 ? javaType : javaType.Substring (0, suffixStart); + if (IsPrimitiveJavaType (elementType)) { + return javaType; + } string jniType = JavaNameToJni (elementType); return TryGetAllowedOriginalClass (jniType, out string originalJniType) ? originalJniType.Replace ('/', '.') + suffix : javaType; } + static bool IsPrimitiveJavaType (string javaType) => javaType switch { + "boolean" or "byte" or "char" or "short" or "int" or "long" or "float" or "double" or "void" => true, + _ => false, + }; + bool TryGetAllowedOriginalClass (string obfuscatedJniClassName, out string originalJniClassName) { originalJniClassName = "";