From 0e6898b0aefd96bd4292232f193fb6f1f120f305 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 7 Aug 2026 15:30:45 -0500 Subject: [PATCH 1/4] [CoreCLR] Validate Java package and type names Report XA4258 before javac when an application package or Java peer type uses a reserved Java identifier. Add Java SE 21 references, message documentation, and focused unit coverage. Fixes #7489 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Documentation/docs-mobile/TOC.yml | 2 + Documentation/docs-mobile/messages/index.md | 1 + Documentation/docs-mobile/messages/xa4258.md | 25 ++++ .../Generator/JniSignatureHelper.cs | 6 + .../ITrimmableTypeMapLogger.cs | 1 + .../JavaNameValidator.cs | 65 +++++++++++ .../TrimmableTypeMapGenerator.cs | 16 +++ .../Properties/Resources.Designer.cs | 9 ++ .../Properties/Resources.resx | 6 + .../Tasks/GenerateTrimmableTypeMap.cs | 5 + .../Tasks/GetAndroidPackageName.cs | 5 + .../Tasks/GetAndroidPackageNameTests.cs | 45 ++++++++ .../Xamarin.Android.Build.Tasks.csproj | 3 + .../Generator/JavaNameValidatorTests.cs | 107 ++++++++++++++++++ .../Generator/JcwJavaSourceGeneratorTests.cs | 3 + .../TrimmableTypeMapGeneratorTests.cs | 41 +++++++ 16 files changed, 340 insertions(+) create mode 100644 Documentation/docs-mobile/messages/xa4258.md create mode 100644 src/Microsoft.Android.Sdk.TrimmableTypeMap/JavaNameValidator.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GetAndroidPackageNameTests.cs create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs 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..f8525cbf1a2 --- /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 these identifiers, so the generated Java source cannot be compiled. + +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..e7efc81d791 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs @@ -241,6 +241,12 @@ internal static void ValidateJniName (string jniName) } } + string segment = jniName.Substring (segmentStart, i - segmentStart); + bool isTypeName = i == jniName.Length; + if (JavaNameValidator.IsInvalidIdentifier (segment, isTypeName)) { + throw new ArgumentException ($"JNI name '{jniName}' contains reserved Java identifier '{segment}'.", nameof (jniName)); + } + segmentStart = i + 1; } } 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..a8d0c88b157 --- /dev/null +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/JavaNameValidator.cs @@ -0,0 +1,65 @@ +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; i++) { + string segment = segments [i]; + if (IsInvalidIdentifier (segment, i == segments.Length - 1)) { + invalidSegment = segment; + 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..5a7de3810d6 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs @@ -45,6 +45,9 @@ public TrimmableTypeMapResult Execute ( logger.LogNoJavaPeerTypesFound (); return new TrimmableTypeMapResult ([], [], allPeers); } + if (!ValidateJavaNames (allPeers)) { + return new TrimmableTypeMapResult ([], [], allPeers); + } MarkFrameworkAssemblyPeers (allPeers, frameworkAssemblyNames); RootManifestReferencedTypes (allPeers, PrepareManifestForRooting (manifestTemplate, manifestConfig), manifestConfig?.ApplicationJavaClass); @@ -70,6 +73,19 @@ public TrimmableTypeMapResult Execute ( return new TrimmableTypeMapResult (generatedAssemblies, generatedJavaSources, allPeers, manifest, appRegTypes); } + internal bool ValidateJavaNames (IReadOnlyList peers) + { + bool valid = true; + var reportedNames = new HashSet (StringComparer.Ordinal); + foreach (var peer in peers) { + if (JavaNameValidator.TryGetInvalidJniNameSegment (peer.JavaName, out var invalidIdentifier) && reportedNames.Add (peer.JavaName)) { + logger.LogInvalidJavaNameError (peer.JavaName, invalidIdentifier); + valid = false; + } + } + return valid; + } + 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..11c0ff4e843 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs @@ -0,0 +1,107 @@ +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] + [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); + } +} diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs index d1bdbf1f4d8..d1f599a236f 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs @@ -394,6 +394,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)); diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs index 5b16bd0d004..a711b9f6287 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs @@ -51,12 +51,53 @@ 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")); + } + [Fact] public void Execute_EmptyAssemblyList_ReturnsEmptyResults () { From 09c833d9a95e9aa04b38aa4b502880593f54e605 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Tue, 11 Aug 2026 10:08:35 -0500 Subject: [PATCH 2/4] Fix Java name validation contexts Validate only peers that emit Java callable wrappers and recognize reserved identifiers in nested Java source names. Keep restricted type identifiers blocked proactively for compatibility with future Java source-level upgrades. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fbbf94e-aaf0-4374-8eb7-b6fbacb5098f --- Documentation/docs-mobile/messages/xa4258.md | 2 +- .../Generator/JniSignatureHelper.cs | 10 +++---- .../JavaNameValidator.cs | 13 +++++++--- .../TrimmableTypeMapGenerator.cs | 3 +++ .../Generator/JavaNameValidatorTests.cs | 12 +++++++++ .../Generator/JcwJavaSourceGeneratorTests.cs | 2 ++ .../TrimmableTypeMapGeneratorTests.cs | 26 +++++++++++++++++++ 7 files changed, 58 insertions(+), 10 deletions(-) diff --git a/Documentation/docs-mobile/messages/xa4258.md b/Documentation/docs-mobile/messages/xa4258.md index f8525cbf1a2..c8239e58b6f 100644 --- a/Documentation/docs-mobile/messages/xa4258.md +++ b/Documentation/docs-mobile/messages/xa4258.md @@ -16,7 +16,7 @@ error XA4258: Java name 'com.example.for' contains reserved Java identifier 'for ## Issue -A Java package or type name contains a Java keyword or restricted type identifier. Java does not provide a way to escape these identifiers, so the generated Java source cannot be compiled. +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]`. diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs index e7efc81d791..18985fe2dd3 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs @@ -241,15 +241,13 @@ internal static void ValidateJniName (string jniName) } } - string segment = jniName.Substring (segmentStart, i - segmentStart); - bool isTypeName = i == jniName.Length; - if (JavaNameValidator.IsInvalidIdentifier (segment, isTypeName)) { - throw new ArgumentException ($"JNI name '{jniName}' contains reserved Java identifier '{segment}'.", nameof (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/JavaNameValidator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/JavaNameValidator.cs index a8d0c88b157..47c7de5e679 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/JavaNameValidator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/JavaNameValidator.cs @@ -51,9 +51,16 @@ internal static bool TryGetInvalidPackageSegment (string packageName, char separ internal static bool TryGetInvalidJniNameSegment (string jniName, out string invalidSegment) { var segments = jniName.Split ('/'); - for (int i = 0; i < segments.Length; i++) { - string segment = segments [i]; - if (IsInvalidIdentifier (segment, i == segments.Length - 1)) { + for (int i = 0; i < segments.Length - 1; i++) { + if (JavaKeywords.Contains (segments [i])) { + invalidSegment = segments [i]; + return true; + } + } + + // '$' separates nested types in a JNI binary name and becomes '.' in Java source. + foreach (var segment in segments [segments.Length - 1].Split ('$')) { + if (IsInvalidIdentifier (segment, isTypeName: true)) { invalidSegment = segment; return true; } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs index 5a7de3810d6..d0c2226832c 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs @@ -78,6 +78,9 @@ internal bool ValidateJavaNames (IReadOnlyList peers) bool valid = true; var reportedNames = new HashSet (StringComparer.Ordinal); foreach (var peer in peers) { + if (!ShouldGenerateJcw (peer)) { + continue; + } if (JavaNameValidator.TryGetInvalidJniNameSegment (peer.JavaName, out var invalidIdentifier) && reportedNames.Add (peer.JavaName)) { logger.LogInvalidJavaNameError (peer.JavaName, invalidIdentifier); valid = false; diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs index 11c0ff4e843..7e1b39e8b86 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs @@ -66,6 +66,16 @@ public void TryGetInvalidJniNameSegment_ReservedTypeIdentifier_ReturnsTrue (stri 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 TryGetInvalidJniNameSegment_ReservedNestedTypeIdentifier_ReturnsTrue (string jniName, string expected) + { + Assert.True (JavaNameValidator.TryGetInvalidJniNameSegment (jniName, out var actual)); + Assert.Equal (expected, actual); + } + [Theory] [MemberData (nameof (RestrictedTypeIdentifiers))] public void RestrictedTypeIdentifier_IsValidInPackageButInvalidAsType (string identifier) @@ -103,5 +113,7 @@ public void ValidNames_ReturnFalseAndEmptyIdentifier () 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); } } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs index d1f599a236f..d0a420378bc 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs @@ -397,6 +397,8 @@ public class JniNameValidation [InlineData ("com/for/Example")] [InlineData ("com/example/for")] [InlineData ("com/example/record")] + [InlineData ("com/example/Outer$for")] + [InlineData ("com/example/Outer$record")] public void ValidateJniName_InvalidName_Throws (string badJniName) { Assert.Throws (() => JniSignatureHelper.ValidateJniName (badJniName)); diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs index a711b9f6287..05bea9922dd 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs @@ -63,6 +63,8 @@ public void LogCustomJavaObjectWarning (string managedTypeName) => [InlineData ("com/for/Example", "for")] [InlineData ("com/example/for", "for")] [InlineData ("com/example/record", "record")] + [InlineData ("com/example/Outer$for", "for")] + [InlineData ("com/example/Outer$record", "record")] public void ValidateJavaNames_ReservedIdentifier_LogsError (string javaName, string invalidIdentifier) { var peers = new List { @@ -98,6 +100,30 @@ public void ValidateJavaNames_ContextualKeywordInPackage_IsValid () Assert.DoesNotContain (logMessages, message => message.Contains ("XA4258")); } + [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 () { From cd94933ef2c2809198a8d677cf42e21919b63ba2 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Tue, 11 Aug 2026 10:45:59 -0500 Subject: [PATCH 3/4] Fix nested Java name validation contexts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fbbf94e-aaf0-4374-8eb7-b6fbacb5098f --- .../JavaNameValidator.cs | 22 +++++- .../TrimmableTypeMapGenerator.cs | 19 +++++ .../Generator/JavaNameValidatorTests.cs | 7 +- .../Generator/JcwJavaSourceGeneratorTests.cs | 41 ++++++++++- .../TrimmableTypeMapGeneratorTests.cs | 69 ++++++++++++++++++- 5 files changed, 149 insertions(+), 9 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/JavaNameValidator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/JavaNameValidator.cs index 47c7de5e679..05e0268f14a 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/JavaNameValidator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/JavaNameValidator.cs @@ -58,15 +58,31 @@ internal static bool TryGetInvalidJniNameSegment (string jniName, out string inv } } - // '$' separates nested types in a JNI binary name and becomes '.' in Java source. - foreach (var segment in segments [segments.Length - 1].Split ('$')) { + 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; } } - invalidSegment = ""; return false; } } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs index d0c2226832c..26e49c5b458 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs @@ -85,6 +85,25 @@ internal bool ValidateJavaNames (IReadOnlyList peers) logger.LogInvalidJavaNameError (peer.JavaName, invalidIdentifier); valid = false; } + if (peer.CannotRegisterInStaticConstructor && + JavaNameValidator.TryGetInvalidJniSourceTypeSegment (peer.JavaName, out invalidIdentifier) && + reportedNames.Add (peer.JavaName)) { + logger.LogInvalidJavaNameError (peer.JavaName, invalidIdentifier); + valid = false; + } + if (peer.BaseJavaName is not null && + JavaNameValidator.TryGetInvalidJniSourceTypeSegment (peer.BaseJavaName, out invalidIdentifier) && + reportedNames.Add (peer.BaseJavaName)) { + logger.LogInvalidJavaNameError (peer.BaseJavaName, invalidIdentifier); + valid = false; + } + foreach (var interfaceName in peer.ImplementedInterfaceJavaNames) { + if (JavaNameValidator.TryGetInvalidJniSourceTypeSegment (interfaceName, out invalidIdentifier) && + reportedNames.Add (interfaceName)) { + logger.LogInvalidJavaNameError (interfaceName, invalidIdentifier); + valid = false; + } + } } return valid; } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs index 7e1b39e8b86..95d036db570 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs @@ -70,9 +70,10 @@ public void TryGetInvalidJniNameSegment_ReservedTypeIdentifier_ReturnsTrue (stri [InlineData ("com/example/Outer$for", "for")] [InlineData ("com/example/Outer$record", "record")] [InlineData ("com/example/for$Nested", "for")] - public void TryGetInvalidJniNameSegment_ReservedNestedTypeIdentifier_ReturnsTrue (string jniName, string expected) + public void TryGetInvalidJniSourceTypeSegment_ReservedNestedTypeIdentifier_ReturnsTrue (string jniName, string expected) { - Assert.True (JavaNameValidator.TryGetInvalidJniNameSegment (jniName, out var actual)); + Assert.False (JavaNameValidator.TryGetInvalidJniNameSegment (jniName, out _)); + Assert.True (JavaNameValidator.TryGetInvalidJniSourceTypeSegment (jniName, out var actual)); Assert.Equal (expected, actual); } @@ -115,5 +116,7 @@ public void ValidNames_ReturnFalseAndEmptyIdentifier () 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); } } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs index d0a420378bc..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 () { @@ -397,8 +434,6 @@ public class JniNameValidation [InlineData ("com/for/Example")] [InlineData ("com/example/for")] [InlineData ("com/example/record")] - [InlineData ("com/example/Outer$for")] - [InlineData ("com/example/Outer$record")] public void ValidateJniName_InvalidName_Throws (string badJniName) { Assert.Throws (() => JniSignatureHelper.ValidateJniName (badJniName)); @@ -410,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 05bea9922dd..78ffd363006 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs @@ -63,8 +63,6 @@ public void LogCustomJavaObjectWarning (string managedTypeName) => [InlineData ("com/for/Example", "for")] [InlineData ("com/example/for", "for")] [InlineData ("com/example/record", "record")] - [InlineData ("com/example/Outer$for", "for")] - [InlineData ("com/example/Outer$record", "record")] public void ValidateJavaNames_ReservedIdentifier_LogsError (string javaName, string invalidIdentifier) { var peers = new List { @@ -100,6 +98,73 @@ public void ValidateJavaNames_ContextualKeywordInPackage_IsValid () 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}'.")); + } + [Theory] [InlineData (true, false, "com/for/Example")] [InlineData (false, true, "com/for/Example")] From 20fd8b5e4a1a8aa4b15fab2183106a7248c77453 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Tue, 11 Aug 2026 12:40:59 -0500 Subject: [PATCH 4/4] Validate all emitted Java type references Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fbbf94e-aaf0-4374-8eb7-b6fbacb5098f --- .../JavaNameValidator.cs | 41 +++++++++ .../TrimmableTypeMapGenerator.cs | 91 ++++++++++++++----- .../Generator/JavaNameValidatorTests.cs | 22 +++++ .../TrimmableTypeMapGeneratorTests.cs | 82 +++++++++++++++++ 4 files changed, 215 insertions(+), 21 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/JavaNameValidator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/JavaNameValidator.cs index 05e0268f14a..fe5f2933c9c 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/JavaNameValidator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/JavaNameValidator.cs @@ -85,4 +85,45 @@ internal static bool TryGetInvalidJniSourceTypeSegment (string jniName, out stri 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 26e49c5b458..be49c266d47 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs @@ -45,14 +45,14 @@ public TrimmableTypeMapResult Execute ( logger.LogNoJavaPeerTypesFound (); return new TrimmableTypeMapResult ([], [], allPeers); } - if (!ValidateJavaNames (allPeers)) { - return new TrimmableTypeMapResult ([], [], allPeers); - } MarkFrameworkAssemblyPeers (allPeers, frameworkAssemblyNames); 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) @@ -73,39 +73,88 @@ public TrimmableTypeMapResult Execute ( return new TrimmableTypeMapResult (generatedAssemblies, generatedJavaSources, allPeers, manifest, appRegTypes); } - internal bool ValidateJavaNames (IReadOnlyList peers) + 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; } - if (JavaNameValidator.TryGetInvalidJniNameSegment (peer.JavaName, out var invalidIdentifier) && reportedNames.Add (peer.JavaName)) { - logger.LogInvalidJavaNameError (peer.JavaName, invalidIdentifier); - valid = false; - } + ReportInvalidJniName (peer.JavaName); if (peer.CannotRegisterInStaticConstructor && - JavaNameValidator.TryGetInvalidJniSourceTypeSegment (peer.JavaName, out invalidIdentifier) && - reportedNames.Add (peer.JavaName)) { - logger.LogInvalidJavaNameError (peer.JavaName, invalidIdentifier); - valid = false; + JavaNameValidator.TryGetInvalidJniSourceTypeSegment (peer.JavaName, out var invalidIdentifier)) { + ReportInvalidName (peer.JavaName, invalidIdentifier); } - if (peer.BaseJavaName is not null && - JavaNameValidator.TryGetInvalidJniSourceTypeSegment (peer.BaseJavaName, out invalidIdentifier) && - reportedNames.Add (peer.BaseJavaName)) { - logger.LogInvalidJavaNameError (peer.BaseJavaName, invalidIdentifier); - valid = false; + if (peer.BaseJavaName is not null) { + ReportInvalidJniSourceType (peer.BaseJavaName); } foreach (var interfaceName in peer.ImplementedInterfaceJavaNames) { - if (JavaNameValidator.TryGetInvalidJniSourceTypeSegment (interfaceName, out invalidIdentifier) && - reportedNames.Add (interfaceName)) { - logger.LogInvalidJavaNameError (interfaceName, invalidIdentifier); - valid = false; + 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) diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs index 95d036db570..2ca4c28ab0d 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JavaNameValidatorTests.cs @@ -119,4 +119,26 @@ public void ValidNames_ReturnFalseAndEmptyIdentifier () 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/TrimmableTypeMapGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs index 78ffd363006..567df31d17f 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs @@ -165,6 +165,88 @@ public void ValidateJavaNames_ReservedDeferredRegistrationTypeIdentifier_LogsErr 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")]