diff --git a/Documentation/docs-mobile/TOC.yml b/Documentation/docs-mobile/TOC.yml index 52a705a10c7..090ebd91ad5 100644 --- a/Documentation/docs-mobile/TOC.yml +++ b/Documentation/docs-mobile/TOC.yml @@ -328,6 +328,8 @@ href: messages/xa4248.md - name: XA4249 href: messages/xa4249.md + - name: XA4258 + href: messages/xa4258.md - name: XA4301 href: messages/xa4301.md - name: XA4302 diff --git a/Documentation/docs-mobile/messages/index.md b/Documentation/docs-mobile/messages/index.md index da9d83c8d20..503544cd6d6 100644 --- a/Documentation/docs-mobile/messages/index.md +++ b/Documentation/docs-mobile/messages/index.md @@ -227,6 +227,7 @@ Either change the value in the AndroidManifest.xml to match the $(SupportedOSPla + [XA4254](xa4254.md): Trimmable type map Java source input directory '{input}' and output directory '{output}' must be different. + [XA4255](xa4255.md): Generated trimmable type map Java source '{path}' was not found. + [XA4256](xa4256.md): Skipping Java peer type '{type}' from assembly '{assembly}' because referenced type '{referencedType}' from assembly '{referencedAssembly}' could not be resolved in '{path}'. This type will not be included in the trimmable type map. ++ [XA4258](xa4258.md): Java name '{name}' contains reserved Java identifier '{identifier}'. Change the package or type name. + XA4300: Native library '{library}' will not be bundled because it has an unsupported ABI. + [XA4301](xa4301.md): Apk already contains the item `xxx`. + [XA4302](xa4302.md): Unhandled exception merging \`AndroidManifest.xml\`: {ex} diff --git a/Documentation/docs-mobile/messages/xa4258.md b/Documentation/docs-mobile/messages/xa4258.md new file mode 100644 index 00000000000..c8239e58b6f --- /dev/null +++ b/Documentation/docs-mobile/messages/xa4258.md @@ -0,0 +1,25 @@ +--- +title: .NET for Android error XA4258 +description: XA4258 error code +ms.date: 08/07/2026 +f1_keywords: + - "XA4258" +--- + +# .NET for Android error XA4258 + +## Example message + +```text +error XA4258: Java name 'com.example.for' contains reserved Java identifier 'for'. Change the package or type name. +``` + +## Issue + +A Java package or type name contains a Java keyword or restricted type identifier. Java does not provide a way to escape keywords. Restricted type identifiers are also rejected to keep generated Java source compatible when .NET for Android moves to a newer Java source level. + +This can originate from the `$(ApplicationId)` MSBuild property, the `package` attribute in `AndroidManifest.xml`, a managed type name, or an explicit Java name supplied by an attribute such as `[Register]` or `[JniTypeSignature]`. + +## Solution + +Change the package or type name so no segment is a Java keyword. For a type name, also avoid restricted type identifiers such as `record`. diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs index 1973c5680db..18985fe2dd3 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs @@ -244,6 +244,10 @@ internal static void ValidateJniName (string jniName) segmentStart = i + 1; } } + + if (JavaNameValidator.TryGetInvalidJniNameSegment (jniName, out var invalidIdentifier)) { + throw new ArgumentException ($"JNI name '{jniName}' contains reserved Java identifier '{invalidIdentifier}'.", nameof (jniName)); + } } /// diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/ITrimmableTypeMapLogger.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/ITrimmableTypeMapLogger.cs index 1d947153cef..ae25273fa8a 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/ITrimmableTypeMapLogger.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/ITrimmableTypeMapLogger.cs @@ -21,6 +21,7 @@ void LogUnresolvableJavaPeerSkippedWarning ( string unresolvedAssemblyName, string unresolvedAssemblyPath); void LogJniAddNativeMethodRegistrationAttributeError (string managedTypeName); + void LogInvalidJavaNameError (string javaName, string invalidIdentifier); void LogCustomJavaObjectError (string managedTypeName); void LogCustomJavaObjectWarning (string managedTypeName); } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/JavaNameValidator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/JavaNameValidator.cs new file mode 100644 index 00000000000..fe5f2933c9c --- /dev/null +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/JavaNameValidator.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap; + +internal static class JavaNameValidator +{ + // Java SE 21 reserved keywords and literals: + // https://docs.oracle.com/javase/specs/jls/se21/html/jls-3.html#jls-3.9 + static readonly HashSet JavaKeywords = new (StringComparer.Ordinal) { + "_", + "abstract", "assert", "boolean", "break", "byte", + "case", "catch", "char", "class", "const", "continue", + "default", "do", "double", + "else", "enum", "extends", + "false", "final", "finally", "float", "for", + "goto", + "if", "implements", "import", "instanceof", "int", "interface", + "long", + "native", "new", "null", + "package", "private", "protected", "public", + "return", + "short", "static", "strictfp", "super", "switch", "synchronized", + "this", "throw", "throws", "transient", "true", "try", + "void", "volatile", + "while", + }; + + // TypeIdentifier additionally excludes these contextual keywords: + // https://docs.oracle.com/javase/specs/jls/se21/html/jls-3.html#jls-TypeIdentifier + static readonly HashSet RestrictedTypeIdentifiers = new (StringComparer.Ordinal) { + "permits", "record", "sealed", "var", "yield", + }; + + internal static bool IsInvalidIdentifier (string identifier, bool isTypeName) => + JavaKeywords.Contains (identifier) || isTypeName && RestrictedTypeIdentifiers.Contains (identifier); + + internal static bool TryGetInvalidPackageSegment (string packageName, char separator, out string invalidSegment) + { + foreach (var segment in packageName.Split (separator)) { + if (JavaKeywords.Contains (segment)) { + invalidSegment = segment; + return true; + } + } + + invalidSegment = ""; + return false; + } + + internal static bool TryGetInvalidJniNameSegment (string jniName, out string invalidSegment) + { + var segments = jniName.Split ('/'); + for (int i = 0; i < segments.Length - 1; i++) { + if (JavaKeywords.Contains (segments [i])) { + invalidSegment = segments [i]; + return true; + } + } + + string typeName = segments [segments.Length - 1]; + if (IsInvalidIdentifier (typeName, isTypeName: true)) { + invalidSegment = typeName; + return true; + } + + invalidSegment = ""; + return false; + } + + internal static bool TryGetInvalidJniSourceTypeSegment (string jniName, out string invalidSegment) + { + if (TryGetInvalidJniNameSegment (jniName, out invalidSegment)) { + return true; + } + + // '$' becomes '.' when a JNI binary name is emitted as a Java source type reference. + string typeName = jniName.Substring (jniName.LastIndexOf ('/') + 1); + foreach (var segment in typeName.Split ('$')) { + if (IsInvalidIdentifier (segment, isTypeName: true)) { + invalidSegment = segment; + return true; + } + } + + return false; + } + + internal static bool TryGetInvalidJniTypeSegment (string jniType, out string typeName, out string invalidSegment) + { + int typeStart = 0; + while (typeStart < jniType.Length && jniType [typeStart] == '[') { + typeStart++; + } + + if (typeStart < jniType.Length - 1 && jniType [typeStart] == 'L' && jniType [jniType.Length - 1] == ';') { + typeName = jniType.Substring (typeStart + 1, jniType.Length - typeStart - 2); + return TryGetInvalidJniSourceTypeSegment (typeName, out invalidSegment); + } + + typeName = ""; + invalidSegment = ""; + return false; + } + + internal static bool TryGetInvalidJavaSourceTypeSegment (string javaType, out string invalidSegment) + { + string typeName = javaType; + while (typeName.EndsWith ("[]", StringComparison.Ordinal)) { + typeName = typeName.Substring (0, typeName.Length - 2); + } + if (typeName is "boolean" or "byte" or "char" or "short" or "int" or "long" or "float" or "double" or "void") { + invalidSegment = ""; + return false; + } + + var segments = typeName.Split ('.'); + for (int i = 0; i < segments.Length; i++) { + bool isTypeName = i == segments.Length - 1; + if (IsInvalidIdentifier (segments [i], isTypeName)) { + invalidSegment = segments [i]; + return true; + } + } + + invalidSegment = ""; + return false; + } +} diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs index 6372b9ffc33..be49c266d47 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs @@ -50,6 +50,9 @@ public TrimmableTypeMapResult Execute ( RootManifestReferencedTypes (allPeers, PrepareManifestForRooting (manifestTemplate, manifestConfig), manifestConfig?.ApplicationJavaClass); PropagateDeferredRegistrationToBaseClasses (allPeers); PropagateCannotRegisterToDescendants (allPeers); + if (!ValidateJavaNames (allPeers, manifestConfig?.ApplicationJavaClass)) { + return new TrimmableTypeMapResult ([], [], allPeers); + } var generatedAssemblies = generateTypeMapAssemblies ? GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion, useSharedTypemapUniverse) @@ -70,6 +73,90 @@ public TrimmableTypeMapResult Execute ( return new TrimmableTypeMapResult (generatedAssemblies, generatedJavaSources, allPeers, manifest, appRegTypes); } + internal bool ValidateJavaNames (IReadOnlyList peers, string? applicationJavaClass = null) + { + bool valid = true; + var reportedNames = new HashSet (StringComparer.Ordinal); + if (applicationJavaClass is not null && + JavaNameValidator.TryGetInvalidJavaSourceTypeSegment (applicationJavaClass, out var invalidApplicationIdentifier)) { + ReportInvalidName (applicationJavaClass, invalidApplicationIdentifier); + } + foreach (var peer in peers) { + if (!ShouldGenerateJcw (peer)) { + continue; + } + ReportInvalidJniName (peer.JavaName); + if (peer.CannotRegisterInStaticConstructor && + JavaNameValidator.TryGetInvalidJniSourceTypeSegment (peer.JavaName, out var invalidIdentifier)) { + ReportInvalidName (peer.JavaName, invalidIdentifier); + } + if (peer.BaseJavaName is not null) { + ReportInvalidJniSourceType (peer.BaseJavaName); + } + foreach (var interfaceName in peer.ImplementedInterfaceJavaNames) { + ReportInvalidJniSourceType (interfaceName); + } + foreach (var constructor in peer.JavaConstructors) { + ValidateJniSignature (constructor.JniSignature); + } + foreach (var method in peer.MarshalMethods) { + if (!method.IsConstructor) { + ValidateJniSignature (method.JniSignature); + } + if (method.ThrownNames is not null) { + foreach (var thrownName in method.ThrownNames) { + if (JavaNameValidator.TryGetInvalidJavaSourceTypeSegment (thrownName, out invalidIdentifier)) { + ReportInvalidName (thrownName, invalidIdentifier); + } + } + } + } + foreach (var field in peer.JavaFields) { + if (JavaNameValidator.TryGetInvalidJavaSourceTypeSegment (field.JavaTypeName, out invalidIdentifier)) { + ReportInvalidName (field.JavaTypeName, invalidIdentifier); + } + } + } + return valid; + + void ValidateJniSignature (string jniSignature) + { + foreach (var parameter in JniSignatureHelper.ParseParameters (jniSignature)) { + ReportInvalidJniType (parameter.JniType); + } + ReportInvalidJniType (JniSignatureHelper.ParseReturnTypeString (jniSignature)); + } + + void ReportInvalidJniName (string jniName) + { + if (JavaNameValidator.TryGetInvalidJniNameSegment (jniName, out var invalidIdentifier)) { + ReportInvalidName (jniName, invalidIdentifier); + } + } + + void ReportInvalidJniSourceType (string jniName) + { + if (JavaNameValidator.TryGetInvalidJniSourceTypeSegment (jniName, out var invalidIdentifier)) { + ReportInvalidName (jniName, invalidIdentifier); + } + } + + void ReportInvalidJniType (string jniType) + { + if (JavaNameValidator.TryGetInvalidJniTypeSegment (jniType, out var typeName, out var invalidIdentifier)) { + ReportInvalidName (typeName, invalidIdentifier); + } + } + + void ReportInvalidName (string name, string invalidIdentifier) + { + if (reportedNames.Add (name)) { + logger.LogInvalidJavaNameError (name, invalidIdentifier); + valid = false; + } + } + } + internal static List CollectApplicationRegistrationTypes (List allPeers) { var appRegTypes = new List (); diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs index a707010778b..08128be638b 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs @@ -1641,6 +1641,15 @@ public static string XA4257 { } } + /// + /// Looks up a localized string similar to Java name '{0}' contains reserved Java identifier '{1}'. Change the package or type name.. + /// + public static string XA4258 { + get { + return ResourceManager.GetString("XA4258", resourceCulture); + } + } + /// /// Looks up a localized string similar to Native library '{0}' will not be bundled because it has an unsupported ABI. Move this file to a directory with a valid Android ABI name such as 'libs/armeabi-v7a/'.. /// diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx index 63edb803fd0..f2f830bd0e3 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx @@ -1215,6 +1215,12 @@ To use a custom JDK path for a command line build, set the 'JavaSdkDirectory' MS {2} - Fully-qualified managed referenced type name that could not be resolved {3} - Assembly expected to contain the unresolved type {4} - Full path to the resolved assembly file that was expected to contain the unresolved type + + + Java name '{0}' contains reserved Java identifier '{1}'. Change the package or type name. + The following are literal names and should not be translated: Java. +{0} - Java package or type name +{1} - Java reserved keyword or restricted identifier Command '{0}' failed.\n{1} diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs index 4b896fe913b..7cffd69bb6a 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs @@ -57,6 +57,8 @@ public void LogUnresolvableJavaPeerSkippedWarning ( log.LogCodedWarning ("XA4257", Properties.Resources.XA4257, managedTypeName, assemblyName, unresolvedTypeName, unresolvedAssemblyName, unresolvedAssemblyPath); public void LogJniAddNativeMethodRegistrationAttributeError (string managedTypeName) => log.LogCodedError ("XA4251", Properties.Resources.XA4251, managedTypeName); + public void LogInvalidJavaNameError (string javaName, string invalidIdentifier) => + log.LogCodedError ("XA4258", Properties.Resources.XA4258, javaName, invalidIdentifier); public void LogCustomJavaObjectError (string managedTypeName) => log.LogError ("{0}", $"XA4212: {string.Format (Properties.Resources.XA4212, managedTypeName)}"); public void LogCustomJavaObjectWarning (string managedTypeName) => @@ -220,6 +222,9 @@ public override bool RunTask () packageNamingPolicy: PackageNamingPolicy, generateTypeMapAssemblies: GenerateTypeMapAssemblies, errorOnCustomJavaObject: ErrorOnCustomJavaObject); + if (Log.HasLoggedErrors) { + return false; + } if (GenerateTypeMapAssemblies) { GeneratedAssemblies = WriteAssembliesToDisk (result.GeneratedAssemblies, assemblyInputs.Select (i => i.Path).ToList ()); diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GetAndroidPackageName.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GetAndroidPackageName.cs index 0e41f8069f6..8cb7ef4e54b 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GetAndroidPackageName.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GetAndroidPackageName.cs @@ -29,6 +29,7 @@ using System.Linq; using System.Xml; using Microsoft.Build.Framework; +using Microsoft.Android.Sdk.TrimmableTypeMap; using Xamarin.Android.Tools; using Microsoft.Android.Build.Tasks; @@ -70,6 +71,10 @@ public override bool RunTask () PackageName = AndroidAppManifest.CanonicalizePackageName (AssemblyName); } + if (JavaNameValidator.TryGetInvalidPackageSegment (PackageName, '.', out var invalidIdentifier)) { + Log.LogCodedError ("XA4258", Properties.Resources.XA4258, PackageName, invalidIdentifier); + } + Log.LogDebugMessage ($" PackageName: {PackageName}"); return !Log.HasLoggedErrors; diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GetAndroidPackageNameTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GetAndroidPackageNameTests.cs new file mode 100644 index 00000000000..e645f4c4986 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GetAndroidPackageNameTests.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using Microsoft.Build.Framework; +using NUnit.Framework; +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Build.Tests { + + [TestFixture] + [Parallelizable (ParallelScope.Children)] + public class GetAndroidPackageNameTests { + + [TestCase ("com.example.for", "for")] + [TestCase ("com.class.example", "class")] + [TestCase ("com.example.true", "true")] + public void ReservedJavaIdentifier_FailsWithXA4258 (string packageName, string invalidIdentifier) + { + var errors = new List (); + var task = new GetAndroidPackageName { + BuildEngine = new MockBuildEngine (TestContext.Out, errors), + AssemblyName = "Example", + PackageName = packageName, + }; + + Assert.IsFalse (task.Execute ()); + Assert.That (errors, Has.Exactly (1).Matches (error => + error.Code == "XA4258" && + error.Message.Contains (packageName) && + error.Message.Contains (invalidIdentifier))); + } + + [Test] + public void ContextualKeywordInPackage_Succeeds () + { + var errors = new List (); + var task = new GetAndroidPackageName { + BuildEngine = new MockBuildEngine (TestContext.Out, errors), + AssemblyName = "Example", + PackageName = "com.example.record", + }; + + Assert.IsTrue (task.Execute ()); + Assert.IsEmpty (errors); + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj index ab09fe0b6a1..2be97ec0043 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj @@ -110,6 +110,9 @@ Utilities\NullableAttributes.cs + + Utilities\JavaNameValidator.cs + Mono.Android\UsesLibraryAttribute.cs diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs new file mode 100644 index 00000000000..2ca4c28ab0d --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +public class JavaNameValidatorTests +{ + public static IEnumerable ReservedIdentifiers { + get { + string [] identifiers = [ + "_", + "abstract", "assert", "boolean", "break", "byte", + "case", "catch", "char", "class", "const", "continue", + "default", "do", "double", + "else", "enum", "extends", + "false", "final", "finally", "float", "for", + "goto", + "if", "implements", "import", "instanceof", "int", "interface", + "long", + "native", "new", "null", + "package", "private", "protected", "public", + "return", + "short", "static", "strictfp", "super", "switch", "synchronized", + "this", "throw", "throws", "transient", "true", "try", + "void", "volatile", + "while", + ]; + + foreach (var identifier in identifiers) { + yield return [identifier]; + } + } + } + + public static IEnumerable RestrictedTypeIdentifiers { + get { + yield return ["permits"]; + yield return ["record"]; + yield return ["sealed"]; + yield return ["var"]; + yield return ["yield"]; + } + } + + [Theory] + [MemberData (nameof (ReservedIdentifiers))] + public void TryGetInvalidPackageSegment_ReservedIdentifier_ReturnsTrue (string identifier) + { + Assert.True (JavaNameValidator.TryGetInvalidPackageSegment ($"com.{identifier}.example", '.', out var actual)); + Assert.Equal (identifier, actual); + } + + [Theory] + [MemberData (nameof (ReservedIdentifiers))] + public void TryGetInvalidJniNameSegment_ReservedPackageIdentifier_ReturnsTrue (string identifier) + { + Assert.True (JavaNameValidator.TryGetInvalidJniNameSegment ($"com/{identifier}/Example", out var actual)); + Assert.Equal (identifier, actual); + } + + [Theory] + [MemberData (nameof (ReservedIdentifiers))] + public void TryGetInvalidJniNameSegment_ReservedTypeIdentifier_ReturnsTrue (string identifier) + { + Assert.True (JavaNameValidator.TryGetInvalidJniNameSegment ($"com/example/{identifier}", out var actual)); + Assert.Equal (identifier, actual); + } + + [Theory] + [InlineData ("com/example/Outer$for", "for")] + [InlineData ("com/example/Outer$record", "record")] + [InlineData ("com/example/for$Nested", "for")] + public void TryGetInvalidJniSourceTypeSegment_ReservedNestedTypeIdentifier_ReturnsTrue (string jniName, string expected) + { + Assert.False (JavaNameValidator.TryGetInvalidJniNameSegment (jniName, out _)); + Assert.True (JavaNameValidator.TryGetInvalidJniSourceTypeSegment (jniName, out var actual)); + Assert.Equal (expected, actual); + } + + [Theory] + [MemberData (nameof (RestrictedTypeIdentifiers))] + public void RestrictedTypeIdentifier_IsValidInPackageButInvalidAsType (string identifier) + { + Assert.False (JavaNameValidator.TryGetInvalidPackageSegment ($"com.example.{identifier}", '.', out var packageIdentifier)); + Assert.Equal ("", packageIdentifier); + Assert.False (JavaNameValidator.TryGetInvalidJniNameSegment ($"com/{identifier}/Example", out var jniPackageIdentifier)); + Assert.Equal ("", jniPackageIdentifier); + Assert.True (JavaNameValidator.TryGetInvalidJniNameSegment ($"com/example/{identifier}", out var typeIdentifier)); + Assert.Equal (identifier, typeIdentifier); + } + + [Theory] + [InlineData ("module")] + [InlineData ("open")] + [InlineData ("requires")] + [InlineData ("exports")] + [InlineData ("opens")] + [InlineData ("to")] + [InlineData ("uses")] + [InlineData ("provides")] + [InlineData ("with")] + [InlineData ("transitive")] + public void ModuleContextualKeyword_IsValidIdentifier (string identifier) + { + Assert.False (JavaNameValidator.TryGetInvalidPackageSegment ($"com.example.{identifier}", '.', out _)); + Assert.False (JavaNameValidator.TryGetInvalidJniNameSegment ($"com/{identifier}/Example", out _)); + Assert.False (JavaNameValidator.TryGetInvalidJniNameSegment ($"com/example/{identifier}", out _)); + } + + [Fact] + public void ValidNames_ReturnFalseAndEmptyIdentifier () + { + Assert.False (JavaNameValidator.TryGetInvalidPackageSegment ("com.example.app", '.', out var packageIdentifier)); + Assert.Equal ("", packageIdentifier); + Assert.False (JavaNameValidator.TryGetInvalidJniNameSegment ("com/example/MainActivity", out var jniIdentifier)); + Assert.Equal ("", jniIdentifier); + Assert.False (JavaNameValidator.TryGetInvalidJniNameSegment ("com/example/Outer$Inner", out var nestedIdentifier)); + Assert.Equal ("", nestedIdentifier); + Assert.False (JavaNameValidator.TryGetInvalidJniSourceTypeSegment ("com/example/Outer$Inner", out nestedIdentifier)); + Assert.Equal ("", nestedIdentifier); + } + + [Theory] + [InlineData ("Lcom/example/Outer$for;", "com/example/Outer$for", "for")] + [InlineData ("[[Lcom/example/Outer$record;", "com/example/Outer$record", "record")] + public void TryGetInvalidJniTypeSegment_ReservedTypeIdentifier_ReturnsTrue (string jniType, string expectedTypeName, string expectedIdentifier) + { + Assert.True (JavaNameValidator.TryGetInvalidJniTypeSegment (jniType, out var typeName, out var invalidIdentifier)); + Assert.Equal (expectedTypeName, typeName); + Assert.Equal (expectedIdentifier, invalidIdentifier); + } + + [Theory] + [InlineData ("com.example.Outer.for", "for")] + [InlineData ("com.example.record", "record")] + [InlineData ("com.record.Example", null)] + [InlineData ("int[]", null)] + public void TryGetInvalidJavaSourceTypeSegment_ValidatesEmittedTypeName (string javaType, string? expectedIdentifier) + { + bool invalid = JavaNameValidator.TryGetInvalidJavaSourceTypeSegment (javaType, out var invalidIdentifier); + Assert.Equal (expectedIdentifier is not null, invalid); + Assert.Equal (expectedIdentifier ?? "", invalidIdentifier); + } +} diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs index d1bdbf1f4d8..055b3c399e7 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs @@ -130,6 +130,43 @@ public void Generate_ClickableView_UsesDotsForNestedInterfaceName () Assert.DoesNotContain ("View$OnClickListener", java); } + [Fact] + public void Generate_DeclaredDollarKeyword_PreservesDollar () + { + var type = new JavaPeerInfo { + JavaName = "com/example/Outer$for", + CompatJniName = "com/example/Outer$for", + ManagedTypeName = "Example.Type", + ManagedTypeNamespace = "Example", + ManagedTypeShortName = "Type", + AssemblyName = "Example", + }; + + var java = GenerateToString (type); + + Assert.Contains ("public class Outer$for\n", java); + } + + [Fact] + public void Generate_ReferencedDollarKeyword_UsesSourceDots () + { + var type = new JavaPeerInfo { + JavaName = "com/example/Derived", + CompatJniName = "com/example/Derived", + ManagedTypeName = "Example.Derived", + ManagedTypeNamespace = "Example", + ManagedTypeShortName = "Derived", + AssemblyName = "Example", + BaseJavaName = "com/example/Outer$for", + ImplementedInterfaceJavaNames = ["com/example/Outer$record"], + }; + + var java = GenerateToString (type); + + Assert.Contains ("\textends com.example.Outer.for\n", java); + Assert.Contains ("\t\tcom.example.Outer.record", java); + } + [Fact] public void Generate_ApplicationSubclass_WithApplicationJavaClass_ExtendsThatClass () { @@ -394,6 +431,9 @@ public class JniNameValidation [InlineData ("C:\\Windows\\System32")] [InlineData ("com/Ex:ample")] [InlineData ("/absolute/path")] + [InlineData ("com/for/Example")] + [InlineData ("com/example/for")] + [InlineData ("com/example/record")] public void ValidateJniName_InvalidName_Throws (string badJniName) { Assert.Throws (() => JniSignatureHelper.ValidateJniName (badJniName)); @@ -405,6 +445,8 @@ public void ValidateJniName_InvalidName_Throws (string badJniName) [InlineData ("SingleSegment")] [InlineData ("com/example/_Private")] [InlineData ("com/example/$Generated")] + [InlineData ("com/example/Outer$for")] + [InlineData ("com/example/Outer$record")] public void ValidateJniName_ValidName_DoesNotThrow (string validJniName) { JniSignatureHelper.ValidateJniName (validJniName); diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs index 5b16bd0d004..567df31d17f 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs @@ -51,12 +51,226 @@ public void LogUnresolvableJavaPeerSkippedWarning ( $"'{unresolvedTypeName}' from '{unresolvedAssemblyName}' at '{unresolvedAssemblyPath}' could not be resolved."); public void LogJniAddNativeMethodRegistrationAttributeError (string managedTypeName) => logMessages.Add ($"XA4251: Type '{managedTypeName}' uses [JniAddNativeMethodRegistrationAttribute], which is not supported by the trimmable type map."); + public void LogInvalidJavaNameError (string javaName, string invalidIdentifier) => + logMessages.Add ($"XA4258: Java name '{javaName}' contains reserved Java identifier '{invalidIdentifier}'."); public void LogCustomJavaObjectError (string managedTypeName) => logMessages.Add ($"XA4212: Type `{managedTypeName}` implements `Android.Runtime.IJavaObject` but does not inherit `Java.Lang.Object` or `Java.Lang.Throwable`. This is not supported."); public void LogCustomJavaObjectWarning (string managedTypeName) => warnings?.Add ($"XA4212: Type `{managedTypeName}` implements `Android.Runtime.IJavaObject` but does not inherit `Java.Lang.Object` or `Java.Lang.Throwable`. This is not supported."); } + [Theory] + [InlineData ("com/for/Example", "for")] + [InlineData ("com/example/for", "for")] + [InlineData ("com/example/record", "record")] + public void ValidateJavaNames_ReservedIdentifier_LogsError (string javaName, string invalidIdentifier) + { + var peers = new List { + new JavaPeerInfo { + JavaName = javaName, + CompatJniName = javaName, + ManagedTypeName = "Example.Type", + ManagedTypeNamespace = "Example", + ManagedTypeShortName = "Type", + AssemblyName = "Example", + }, + }; + + Assert.False (CreateGenerator ().ValidateJavaNames (peers)); + Assert.Contains (logMessages, message => message.Contains ($"XA4258: Java name '{javaName}' contains reserved Java identifier '{invalidIdentifier}'.")); + } + + [Fact] + public void ValidateJavaNames_ContextualKeywordInPackage_IsValid () + { + var peers = new List { + new JavaPeerInfo { + JavaName = "com/record/Example", + CompatJniName = "com/record/Example", + ManagedTypeName = "Example.Type", + ManagedTypeNamespace = "Example", + ManagedTypeShortName = "Type", + AssemblyName = "Example", + }, + }; + + Assert.True (CreateGenerator ().ValidateJavaNames (peers)); + Assert.DoesNotContain (logMessages, message => message.Contains ("XA4258")); + } + + [Theory] + [InlineData ("com/example/Outer$for", null, "for")] + [InlineData ("com/example/Outer$record", null, "record")] + [InlineData (null, "com/example/Outer$for", "for")] + [InlineData (null, "com/example/Outer$record", "record")] + public void ValidateJavaNames_ReservedReferencedTypeIdentifier_LogsError (string? baseJavaName, string? interfaceName, string invalidIdentifier) + { + var interfaces = interfaceName is null ? [] : new [] { interfaceName }; + var referencedName = baseJavaName ?? interfaceName; + var peers = new List { + new JavaPeerInfo { + JavaName = "com/example/Derived", + CompatJniName = "com/example/Derived", + ManagedTypeName = "Example.Derived", + ManagedTypeNamespace = "Example", + ManagedTypeShortName = "Derived", + AssemblyName = "Example", + BaseJavaName = baseJavaName, + ImplementedInterfaceJavaNames = interfaces, + }, + }; + + Assert.False (CreateGenerator ().ValidateJavaNames (peers)); + Assert.Contains (logMessages, message => message.Contains ($"XA4258: Java name '{referencedName}' contains reserved Java identifier '{invalidIdentifier}'.")); + } + + [Theory] + [InlineData ("com/example/Outer$for")] + [InlineData ("com/example/Outer$record")] + public void ValidateJavaNames_DollarInDeclaredTypeName_IsValid (string javaName) + { + var peers = new List { + new JavaPeerInfo { + JavaName = javaName, + CompatJniName = javaName, + ManagedTypeName = "Example.Type", + ManagedTypeNamespace = "Example", + ManagedTypeShortName = "Type", + AssemblyName = "Example", + }, + }; + + Assert.True (CreateGenerator ().ValidateJavaNames (peers)); + Assert.DoesNotContain (logMessages, message => message.Contains ("XA4258")); + } + + [Theory] + [InlineData ("com/example/Outer$for", "for")] + [InlineData ("com/example/Outer$record", "record")] + public void ValidateJavaNames_ReservedDeferredRegistrationTypeIdentifier_LogsError (string javaName, string invalidIdentifier) + { + var peers = new List { + new JavaPeerInfo { + JavaName = javaName, + CompatJniName = javaName, + ManagedTypeName = "Example.Application", + ManagedTypeNamespace = "Example", + ManagedTypeShortName = "Application", + AssemblyName = "Example", + CannotRegisterInStaticConstructor = true, + }, + }; + + Assert.False (CreateGenerator ().ValidateJavaNames (peers)); + Assert.Contains (logMessages, message => message.Contains ($"XA4258: Java name '{javaName}' contains reserved Java identifier '{invalidIdentifier}'.")); + } + + [Fact] + public void ValidateJavaNames_ReservedEmittedTypeReferences_LogErrors () + { + var peers = new List { + new JavaPeerInfo { + JavaName = "com/example/Derived", + CompatJniName = "com/example/Derived", + ManagedTypeName = "Example.Derived", + ManagedTypeNamespace = "Example", + ManagedTypeShortName = "Derived", + AssemblyName = "Example", + JavaConstructors = [ + new JavaConstructorInfo { + JniSignature = "(Lcom/example/Outer$for;)V", + ConstructorIndex = 0, + }, + ], + MarshalMethods = [ + new MarshalMethodInfo { + JniName = "method", + JniSignature = "([Lcom/example/Outer$record;)Lcom/example/Outer$yield;", + ManagedMethodName = "Method", + NativeCallbackName = "n_method", + ThrownNames = ["com.example.Outer.permits"], + }, + ], + JavaFields = [ + new JavaFieldInfo { + FieldName = "VALUE", + JavaTypeName = "com.example.Outer.sealed", + InitializerMethodName = "getValue", + Visibility = "public", + }, + ], + }, + }; + + Assert.False (CreateGenerator ().ValidateJavaNames (peers)); + Assert.Contains (logMessages, message => message.Contains ("Java name 'com/example/Outer$for' contains reserved Java identifier 'for'.")); + Assert.Contains (logMessages, message => message.Contains ("Java name 'com/example/Outer$record' contains reserved Java identifier 'record'.")); + Assert.Contains (logMessages, message => message.Contains ("Java name 'com/example/Outer$yield' contains reserved Java identifier 'yield'.")); + Assert.Contains (logMessages, message => message.Contains ("Java name 'com.example.Outer.permits' contains reserved Java identifier 'permits'.")); + Assert.Contains (logMessages, message => message.Contains ("Java name 'com.example.Outer.sealed' contains reserved Java identifier 'sealed'.")); + } + + [Fact] + public void ValidateJavaNames_AfterDeferredRegistrationPropagation_ValidatesRegistrationName () + { + var basePeer = new JavaPeerInfo { + JavaName = "com/example/Outer$for", + CompatJniName = "com/example/Outer$for", + ManagedTypeName = "Example.BaseApplication", + ManagedTypeNamespace = "Example", + ManagedTypeShortName = "BaseApplication", + AssemblyName = "Example", + }; + var applicationPeer = new JavaPeerInfo { + JavaName = "com/example/Application", + CompatJniName = "com/example/Application", + ManagedTypeName = "Example.Application", + ManagedTypeNamespace = "Example", + ManagedTypeShortName = "Application", + AssemblyName = "Example", + BaseJavaName = basePeer.JavaName, + CannotRegisterInStaticConstructor = true, + }; + var peers = new List { basePeer, applicationPeer }; + + TrimmableTypeMapGenerator.PropagateDeferredRegistrationToBaseClasses (peers); + + Assert.True (basePeer.CannotRegisterInStaticConstructor); + Assert.False (CreateGenerator ().ValidateJavaNames (peers)); + Assert.Contains (logMessages, message => message.Contains ("Java name 'com/example/Outer$for' contains reserved Java identifier 'for'.")); + } + + [Fact] + public void ValidateJavaNames_ReservedApplicationJavaClassIdentifier_LogsError () + { + Assert.False (CreateGenerator ().ValidateJavaNames ([], "com.example.Outer.for")); + Assert.Contains (logMessages, message => message.Contains ("Java name 'com.example.Outer.for' contains reserved Java identifier 'for'.")); + } + + [Theory] + [InlineData (true, false, "com/for/Example")] + [InlineData (false, true, "com/for/Example")] + [InlineData (true, false, "com/example/Outer$record")] + [InlineData (false, true, "com/example/Outer$record")] + public void ValidateJavaNames_TypeWithoutJcw_IsNotValidated (bool doNotGenerateAcw, bool isInterface, string javaName) + { + var peers = new List { + new JavaPeerInfo { + JavaName = javaName, + CompatJniName = javaName, + ManagedTypeName = "Example.Type", + ManagedTypeNamespace = "Example", + ManagedTypeShortName = "Type", + AssemblyName = "Example", + DoNotGenerateAcw = doNotGenerateAcw, + IsInterface = isInterface, + }, + }; + + Assert.True (CreateGenerator ().ValidateJavaNames (peers)); + Assert.DoesNotContain (logMessages, message => message.Contains ("XA4258")); + } + [Fact] public void Execute_EmptyAssemblyList_ReturnsEmptyResults () {