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..867ea4449a9 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/JniDescriptorTextTests.cs @@ -0,0 +1,100 @@ +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 ("(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)] + 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 ("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) + { + 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;")); + } + + [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/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..0d5b5c7f9e7 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/LdstrRewriterTests.cs @@ -0,0 +1,122 @@ +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 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 () + { + 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..8753eccdaa5 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/JniRemapping/R8MappingTests.cs @@ -0,0 +1,623 @@ +using System; +using System.IO; +using System.Threading.Tasks; +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); + 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 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 () + { + 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 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 () + { + 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.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'", + }, 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); + } + + [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 () + { + 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 () + { + 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", + })); + } + + [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 () + { + 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.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\tsecond", + "M\tacme/orig/MyView\tinvoke(int):void", + }, 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 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 () + { + 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 () + { + 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 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 () + { + 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..b60d63a6d65 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/JniDescriptorText.cs @@ -0,0 +1,275 @@ +#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 || !IsValidJniClassName (s, j + 1, end)) { + return false; + } + i = end + 1; + return true; + default: + return false; + } + } + + 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. + /// + 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 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++; + } + + return JniTypeTokenToJavaSource (token, arrayDepth); + } + + static string JniTypeTokenToJavaSource (string token, int 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)), + }; + + 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 (); + } + + /// + /// 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) { + int arrayDepth = 0; + while (parameterType [arrayDepth] == '[') { + arrayDepth++; + } + parameterTypes.Add (JniTypeTokenToJavaSource (parameterType, arrayDepth)); + } + int returnArrayDepth = 0; + while (jniReturnType [returnArrayDepth] == '[') { + returnArrayDepth++; + } + returnType = JniTypeTokenToJavaSource (jniReturnType, returnArrayDepth); + } + } +} 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..95933b4d98b --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/JniRemapping/R8Mapping.cs @@ -0,0 +1,796 @@ +#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 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); + + // 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 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 IReadOnlyCollection AccessedEntries { + get { + lock (accessedEntriesLock) { + return new List (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, 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; + string? pendingPositionRange = null; + string? pendingObfuscatedName = null; + string? pendingMethodKey = 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) { + 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}'."); + } + + currentOriginalClass = JavaNameToJni (originalClass); + string currentObfuscatedClass = JavaNameToJni (obfuscatedClass); + mapping.classes [currentOriginalClass] = currentObfuscatedClass; + if (!mapping.originalClasses.TryGetValue (currentObfuscatedClass, out var originalClassNames)) { + mapping.originalClasses [currentObfuscatedClass] = originalClassNames = new List (); + } + if (!originalClassNames.Contains (currentOriginalClass)) { + originalClassNames.Add (currentOriginalClass); + } + continue; + } + + if (currentOriginalClass == null) { + 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, 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 { + string key = BuildMethodKey (memberName, javaParameterTypes, javaReturnType ?? ""); + 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) { + 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 = method.Key.Substring (0, parameterStart); + string signature = 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; + RecordAccess (BuildClassEntry (originalJniClassName)); + return true; + } + obfuscatedJniClassName = ""; + return false; + } + + public bool TryGetOriginalClass (string obfuscatedJniClassName, out string originalJniClassName) + { + if (originalClasses.TryGetValue (obfuscatedJniClassName, out var originalClassNames) && originalClassNames.Count == 1) { + originalJniClassName = originalClassNames [0]; + return true; + } + originalJniClassName = ""; + return false; + } + + 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 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; + RecordAccess ( + BuildClassEntry (owningJniClassName), + 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; + RecordAccess ( + BuildClassEntry (owningJniClassName), + 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; + } + + var accessedMethods = new List (); + foreach (var kvp in classMethods) { + if (kvp.Key.StartsWith (prefix, StringComparison.Ordinal) && kvp.Value == match) { + accessedMethods.Add (BuildMethodEntry (owningJniClassName, kvp.Key)); + } + } + RecordAccess (BuildClassEntry (owningJniClassName), accessedMethods); + 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) + { + var reportedRemovedClasses = new HashSet (StringComparer.Ordinal); + 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)) && + reportedRemovedClasses.Add (parts [1])) { + 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])) { + if (reportedRemovedClasses.Add (parts [1])) { + yield return $"class '{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])) { + if (reportedRemovedClasses.Add (parts [1])) { + yield return $"class '{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 { + NewLine = "\n", + }; + foreach (string entry in sortedEntries) { + writer.WriteLine (entry); + } + 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; + + public ReverseR8Mapping (R8Mapping mapping) + { + this.mapping = mapping; + } + + public bool TryMapClass (string className, out string mappedClassName) + { + if (!TryGetAllowedOriginalClass (className, out mappedClassName)) { + return false; + } + mapping.RecordAccess (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; + string? allowedEntry = null; + foreach (string originalName in originalNames) { + string entry = BuildFieldEntry (originalClassName, originalName); + if (!mapping.IsReverseEntryAllowed (entry)) { + continue; + } + if (firstOriginalName != null) { + return false; + } + firstOriginalName = originalName; + allowedEntry = entry; + } + if (firstOriginalName == null || allowedEntry == null) { + return false; + } + mapping.RecordAccess (BuildClassEntry (originalClassName), allowedEntry); + 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.RecordAccess (BuildClassEntry (originalClassName), 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; + var allowedEntries = new List (); + 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 ('('); + string originalName = 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.RecordAccess (BuildClassEntry (originalClassName), allowedEntries); + 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); + 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 = ""; + if (!mapping.originalClasses.TryGetValue (obfuscatedJniClassName, out var originalClassNames)) { + return false; + } + + foreach (string candidate in originalClassNames) { + if (!mapping.IsReverseEntryAllowed (BuildClassEntry (candidate))) { + continue; + } + if (originalJniClassName.Length != 0) { + originalJniClassName = ""; + return false; + } + originalJniClassName = candidate; + } + return originalJniClassName.Length != 0; + } + } + + 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, out string? positionRange) + { + name = ""; + javaParameterTypes = null; + javaReturnType = null; + obfuscatedName = ""; + positionRange = null; + + 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, out positionRange); + 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, out string? positionRange) + { + positionRange = null; + 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; + } + + positionRange = s.Substring (0, j); + 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); + } + } +}