diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JcwJavaSourceGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JcwJavaSourceGenerator.cs new file mode 100644 index 00000000000..8a0d02b49ac --- /dev/null +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JcwJavaSourceGenerator.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap; + +/// +/// Generates JCW (Java Callable Wrapper) .java source files from scanned records. +/// Only processes ACW types (where is false). +/// +/// +/// Each generated .java file looks like this (pseudo-Java): +/// +/// package com.example; +/// +/// public class MainActivity +/// extends android.app.Activity +/// implements +/// mono.android.IGCUserPeer, +/// android.view.View.OnClickListener +/// { +/// static { +/// mono.android.Runtime.registerNatives (MainActivity.class); +/// } +/// +/// public MainActivity (android.content.Context p0) +/// { +/// super (p0); +/// if (getClass () == MainActivity.class) nctor_0 (p0); +/// } +/// private native void nctor_0 (android.content.Context p0); +/// +/// @Override +/// public void onCreate (android.os.Bundle p0) +/// { +/// n_onCreate (p0); +/// } +/// public native void n_onCreate (android.os.Bundle p0); +/// } +/// +/// +sealed class JcwJavaSourceGenerator +{ + /// + /// Generates .java source files for all ACW types and writes them to the output directory. + /// Returns the list of generated file paths. + /// + public IReadOnlyList Generate (IReadOnlyList types, string outputDirectory) + { + if (types is null) { + throw new ArgumentNullException (nameof (types)); + } + if (outputDirectory is null) { + throw new ArgumentNullException (nameof (outputDirectory)); + } + + var generatedFiles = new List (); + + foreach (var type in types) { + if (type.DoNotGenerateAcw || type.IsInterface) { + continue; + } + + string filePath = GetOutputFilePath (type, outputDirectory); + string? dir = Path.GetDirectoryName (filePath); + if (dir != null) { + Directory.CreateDirectory (dir); + } + + using var writer = new StreamWriter (filePath); + Generate (type, writer); + generatedFiles.Add (filePath); + } + + return generatedFiles; + } + + /// + /// Generates a single .java source file for the given type. + /// + internal void Generate (JavaPeerInfo type, TextWriter writer) + { + writer.NewLine = "\n"; + WritePackageDeclaration (type, writer); + WriteClassDeclaration (type, writer); + WriteStaticInitializer (type, writer); + WriteConstructors (type, writer); + WriteMethods (type, writer); + WriteGCUserPeerMethods (writer); + WriteClassClose (writer); + } + + static string GetOutputFilePath (JavaPeerInfo type, string outputDirectory) + { + JniSignatureHelper.ValidateJniName (type.JavaName); + string relativePath = type.JavaName + ".java"; + return Path.Combine (outputDirectory, relativePath); + } + + /// + /// Validates that the JNI name is well-formed: non-empty, each segment separated by '/' + /// contains only valid Java identifier characters (letters, digits, '_', '$'). + /// This also prevents path traversal (e.g., ".." segments, rooted paths, backslashes). + /// + static void WritePackageDeclaration (JavaPeerInfo type, TextWriter writer) + { + string? package = JniSignatureHelper.GetJavaPackageName (type.JavaName); + if (package != null) { + writer.Write ("package "); + writer.Write (package); + writer.WriteLine (';'); + writer.WriteLine (); + } + } + + static void WriteClassDeclaration (JavaPeerInfo type, TextWriter writer) + { + string abstractModifier = type.IsAbstract && !type.IsInterface ? "abstract " : ""; + string className = JniSignatureHelper.GetJavaSimpleName (type.JavaName); + + writer.Write ($"public {abstractModifier}class {className}\n"); + + // extends clause + if (type.BaseJavaName != null) { + writer.WriteLine ($"\textends {JniSignatureHelper.JniNameToJavaName (type.BaseJavaName)}"); + } + + // implements clause — always includes IGCUserPeer, plus any implemented interfaces + writer.Write ("\timplements\n\t\tmono.android.IGCUserPeer"); + + foreach (var iface in type.ImplementedInterfaceJavaNames) { + writer.Write ($",\n\t\t{JniSignatureHelper.JniNameToJavaName (iface)}"); + } + + writer.WriteLine (); + writer.WriteLine ('{'); + } + + static void WriteStaticInitializer (JavaPeerInfo type, TextWriter writer) + { + string className = JniSignatureHelper.GetJavaSimpleName (type.JavaName); + writer.Write ($$""" + static { + mono.android.Runtime.registerNatives ({{className}}.class); + } + + +"""); + } + + static void WriteConstructors (JavaPeerInfo type, TextWriter writer) + { + string simpleClassName = JniSignatureHelper.GetJavaSimpleName (type.JavaName); + + foreach (var ctor in type.JavaConstructors) { + string parameters = FormatParameterList (ctor.Parameters); + string superArgs = ctor.SuperArgumentsString ?? FormatArgumentList (ctor.Parameters); + string args = FormatArgumentList (ctor.Parameters); + + writer.Write ($$""" + public {{simpleClassName}} ({{parameters}}) + { + super ({{superArgs}}); + if (getClass () == {{simpleClassName}}.class) nctor_{{ctor.ConstructorIndex}} ({{args}}); + } + + +"""); + } + + // Write native constructor declarations + foreach (var ctor in type.JavaConstructors) { + string parameters = FormatParameterList (ctor.Parameters); + writer.WriteLine ($"\tprivate native void nctor_{ctor.ConstructorIndex} ({parameters});"); + } + + if (type.JavaConstructors.Count > 0) { + writer.WriteLine (); + } + } + + static void WriteMethods (JavaPeerInfo type, TextWriter writer) + { + foreach (var method in type.MarshalMethods) { + if (method.IsConstructor) { + continue; + } + + string javaReturnType = JniSignatureHelper.JniTypeToJava (method.JniReturnType); + bool isVoid = method.JniReturnType == "V"; + string parameters = FormatParameterList (method.Parameters); + string args = FormatArgumentList (method.Parameters); + string returnPrefix = isVoid ? "" : "return "; + + // throws clause for [Export] methods + string throwsClause = ""; + if (method.ThrownNames != null && method.ThrownNames.Count > 0) { + throwsClause = $"\n\t\tthrows {string.Join (", ", method.ThrownNames)}"; + } + + if (method.Connector != null) { + writer.Write ($$""" + + @Override + public {{javaReturnType}} {{method.JniName}} ({{parameters}}){{throwsClause}} + { + {{returnPrefix}}{{method.NativeCallbackName}} ({{args}}); + } + public native {{javaReturnType}} {{method.NativeCallbackName}} ({{parameters}}); + +"""); + } else { + writer.Write ($$""" + + public {{javaReturnType}} {{method.JniName}} ({{parameters}}){{throwsClause}} + { + {{returnPrefix}}{{method.NativeCallbackName}} ({{args}}); + } + public native {{javaReturnType}} {{method.NativeCallbackName}} ({{parameters}}); + +"""); + } + } + } + + static void WriteGCUserPeerMethods (TextWriter writer) + { + writer.Write (""" + + private java.util.ArrayList refList; + public void monodroidAddReference (java.lang.Object obj) + { + if (refList == null) + refList = new java.util.ArrayList (); + refList.add (obj); + } + + public void monodroidClearReferences () + { + if (refList != null) + refList.clear (); + } + +"""); + } + + static void WriteClassClose (TextWriter writer) + { + writer.WriteLine ('}'); + } + + static string FormatParameterList (IReadOnlyList parameters) + { + if (parameters.Count == 0) { + return ""; + } + + var sb = new System.Text.StringBuilder (); + for (int i = 0; i < parameters.Count; i++) { + if (i > 0) { + sb.Append (", "); + } + sb.Append (JniSignatureHelper.JniTypeToJava (parameters [i].JniType)); + sb.Append (" p"); + sb.Append (i); + } + return sb.ToString (); + } + + static string FormatArgumentList (IReadOnlyList parameters) + { + if (parameters.Count == 0) { + return ""; + } + + var sb = new System.Text.StringBuilder (); + for (int i = 0; i < parameters.Count; i++) { + if (i > 0) { + sb.Append (", "); + } + sb.Append ('p'); + sb.Append (i); + } + return sb.ToString (); + } + +} diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs index 0e2b867c4f9..0e9ac52f93e 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs @@ -123,4 +123,109 @@ public static void EncodeClrType (SignatureTypeEncoder encoder, JniParamKind kin default: throw new ArgumentException ($"Cannot encode JNI param kind {kind} as CLR type"); } } + + /// + /// Validates that a JNI type name has the expected structure (e.g., "com/example/MyClass"). + /// + internal static void ValidateJniName (string jniName) + { + if (string.IsNullOrEmpty (jniName)) { + throw new ArgumentException ("JNI name must not be null or empty.", nameof (jniName)); + } + + int segmentStart = 0; + for (int i = 0; i <= jniName.Length; i++) { + if (i == jniName.Length || jniName [i] == '/') { + if (i == segmentStart) { + throw new ArgumentException ($"JNI name '{jniName}' has an empty segment.", nameof (jniName)); + } + + // First char of a segment must not be a digit + char first = jniName [segmentStart]; + if (first >= '0' && first <= '9') { + throw new ArgumentException ($"JNI name '{jniName}' has a segment starting with a digit.", nameof (jniName)); + } + + // All chars in the segment must be valid Java identifier chars + for (int j = segmentStart; j < i; j++) { + char c = jniName [j]; + bool valid = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '$'; + if (!valid) { + throw new ArgumentException ($"JNI name '{jniName}' contains invalid character '{c}'.", nameof (jniName)); + } + } + + segmentStart = i + 1; + } + } + } + + /// + /// Converts a JNI type name to a Java source type name. + /// e.g., "android/app/Activity" \u2192 "android.app.Activity" + /// + internal static string JniNameToJavaName (string jniName) + { + return jniName.Replace ('/', '.'); + } + + /// + /// Extracts the Java package name from a JNI type name. + /// e.g., "com/example/MainActivity" \u2192 "com.example" + /// Returns null for types without a package. + /// + internal static string? GetJavaPackageName (string jniName) + { + int lastSlash = jniName.LastIndexOf ('/'); + if (lastSlash < 0) { + return null; + } + return jniName.Substring (0, lastSlash).Replace ('/', '.'); + } + + /// + /// Extracts the simple Java class name from a JNI type name. + /// e.g., "com/example/MainActivity" \u2192 "MainActivity" + /// e.g., "com/example/Outer$Inner" \u2192 "Outer$Inner" (preserves nesting separator) + /// + internal static string GetJavaSimpleName (string jniName) + { + int lastSlash = jniName.LastIndexOf ('/'); + return lastSlash >= 0 ? jniName.Substring (lastSlash + 1) : jniName; + } + + /// + /// Converts a JNI type descriptor to a Java source type. + /// e.g., "V" \u2192 "void", "I" \u2192 "int", "Landroid/os/Bundle;" \u2192 "android.os.Bundle" + /// + internal static string JniTypeToJava (string jniType) + { + if (jniType.Length == 1) { + return jniType [0] switch { + 'V' => "void", + 'Z' => "boolean", + 'B' => "byte", + 'C' => "char", + 'S' => "short", + 'I' => "int", + 'J' => "long", + 'F' => "float", + 'D' => "double", + _ => throw new ArgumentException ($"Unknown JNI primitive type: {jniType}"), + }; + } + + // Array types: "[I" \u2192 "int[]", "[Ljava/lang/String;" \u2192 "java.lang.String[]" + if (jniType [0] == '[') { + return JniTypeToJava (jniType.Substring (1)) + "[]"; + } + + // Object types: "Landroid/os/Bundle;" \u2192 "android.os.Bundle" + if (jniType [0] == 'L' && jniType [jniType.Length - 1] == ';') { + return JniNameToJavaName (jniType.Substring (1, jniType.Length - 2)); + } + + throw new ArgumentException ($"Unknown JNI type descriptor: {jniType}"); + } } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs index e76c3810ea7..e8d0c5d6ba3 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs @@ -43,6 +43,19 @@ sealed record JavaPeerInfo /// public required string AssemblyName { get; init; } + /// + /// JNI name of the base Java type, e.g., "android/app/Activity" for a type + /// that extends Activity. Null for java/lang/Object or types without a Java base. + /// Needed by JCW Java source generation ("extends" clause). + /// + public string? BaseJavaName { get; init; } + + /// + /// JNI names of Java interfaces this type implements, e.g., ["android/view/View$OnClickListener"]. + /// Needed by JCW Java source generation ("implements" clause). + /// + public IReadOnlyList ImplementedInterfaceJavaNames { get; init; } = Array.Empty (); + public bool IsInterface { get; init; } public bool IsAbstract { get; init; } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs index ca9057ed747..eabc73f7ee9 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs @@ -197,6 +197,12 @@ void ScanAssembly (AssemblyIndex index, Dictionary results var isUnconditional = attrInfo is not null; string? invokerTypeName = null; + // Resolve base Java type name + var baseJavaName = ResolveBaseJavaName (typeDef, index, results); + + // Resolve implemented Java interface names + var implementedInterfaces = ResolveImplementedInterfaceJavaNames (typeDef, index); + // Collect marshal methods (including constructors) in a single pass over methods var marshalMethods = CollectMarshalMethods (typeDef, index); @@ -215,6 +221,8 @@ void ScanAssembly (AssemblyIndex index, Dictionary results ManagedTypeNamespace = ExtractNamespace (fullName), ManagedTypeShortName = ExtractShortName (fullName), AssemblyName = index.AssemblyName, + BaseJavaName = baseJavaName, + ImplementedInterfaceJavaNames = implementedInterfaces, IsInterface = isInterface, IsAbstract = isAbstract, DoNotGenerateAcw = doNotGenerateAcw, @@ -283,6 +291,51 @@ static void AddMarshalMethod (List methods, RegisterInfo regi }); } + string? ResolveBaseJavaName (TypeDefinition typeDef, AssemblyIndex index, Dictionary results) + { + var baseInfo = GetBaseTypeInfo (typeDef, index); + if (baseInfo is null) { + return null; + } + + var (baseTypeName, baseAssemblyName) = baseInfo.Value; + + // First try [Register] attribute + var registerJniName = ResolveRegisterJniName (baseTypeName, baseAssemblyName); + if (registerJniName is not null) { + return registerJniName; + } + + // Fall back to already-scanned results (component-attributed or CRC64-computed peers) + if (results.TryGetValue (baseTypeName, out var basePeer)) { + return basePeer.JavaName; + } + + return null; + } + + List ResolveImplementedInterfaceJavaNames (TypeDefinition typeDef, AssemblyIndex index) + { + var result = new List (); + var interfaceImpls = typeDef.GetInterfaceImplementations (); + + foreach (var implHandle in interfaceImpls) { + var impl = index.Reader.GetInterfaceImplementation (implHandle); + var ifaceJniName = ResolveInterfaceJniName (impl.Interface, index); + if (ifaceJniName is not null) { + result.Add (ifaceJniName); + } + } + + return result; + } + + string? ResolveInterfaceJniName (EntityHandle interfaceHandle, AssemblyIndex index) + { + var resolved = ResolveEntityHandle (interfaceHandle, index); + return resolved is not null ? ResolveRegisterJniName (resolved.Value.typeName, resolved.Value.assemblyName) : null; + } + static bool TryGetMethodRegisterInfo (MethodDefinition methodDef, AssemblyIndex index, out RegisterInfo? registerInfo, out ExportInfo? exportInfo) { exportInfo = null; diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/FixtureTestBase.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/FixtureTestBase.cs index 4a168b636a9..201db530238 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/FixtureTestBase.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/FixtureTestBase.cs @@ -33,10 +33,16 @@ protected static JavaPeerInfo FindFixtureByJavaName (string javaName) return peer; } - protected static void CleanUpDir (string path) + protected static string CreateTempDir () { - var dir = Path.GetDirectoryName (path); - if (dir != null && Directory.Exists (dir)) + var dir = Path.Combine (Path.GetTempPath (), $"typemap-test-{Guid.NewGuid ():N}"); + Directory.CreateDirectory (dir); + return dir; + } + + protected static void DeleteTempDir (string dir) + { + if (Directory.Exists (dir)) try { Directory.Delete (dir, true); } catch { } } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs new file mode 100644 index 00000000000..049f4dbbeed --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs @@ -0,0 +1,333 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Xunit; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +public class JcwJavaSourceGeneratorTests : FixtureTestBase +{ + static string GenerateToString (JavaPeerInfo type) + { + var generator = new JcwJavaSourceGenerator (); + using var writer = new StringWriter (); + generator.Generate (type, writer); + return writer.ToString (); + } + + static string GenerateFixture (string javaName) + { + var peer = FindFixtureByJavaName (javaName); + return GenerateToString (peer); + } + + + public class JniNameConversion + { + + [Theory] + [InlineData ("android/app/Activity", "android.app.Activity")] + [InlineData ("java/lang/Object", "java.lang.Object")] + [InlineData ("android/view/View$OnClickListener", "android.view.View$OnClickListener")] + public void JniNameToJavaName_ConvertsCorrectly (string jniName, string expected) + { + Assert.Equal (expected, JniSignatureHelper.JniNameToJavaName (jniName)); + } + + [Theory] + [InlineData ("com/example/MainActivity", "com.example")] + [InlineData ("java/lang/Object", "java.lang")] + [InlineData ("TopLevelClass", null)] + public void GetJavaPackageName_ExtractsCorrectly (string jniName, string? expected) + { + Assert.Equal (expected, JniSignatureHelper.GetJavaPackageName (jniName)); + } + + [Theory] + [InlineData ("V", "void")] + [InlineData ("Z", "boolean")] + [InlineData ("B", "byte")] + [InlineData ("I", "int")] + [InlineData ("J", "long")] + [InlineData ("F", "float")] + [InlineData ("D", "double")] + [InlineData ("Landroid/os/Bundle;", "android.os.Bundle")] + [InlineData ("[I", "int[]")] + [InlineData ("[Ljava/lang/String;", "java.lang.String[]")] + public void JniTypeToJava_ConvertsCorrectly (string jniType, string expected) + { + Assert.Equal (expected, JniSignatureHelper.JniTypeToJava (jniType)); + } + + } + + public class Filtering : IDisposable + { + readonly string _outputDir = CreateTempDir (); + public void Dispose () => DeleteTempDir (_outputDir); + + [Fact] + public void Generate_SkipsMcwTypes () + { + var peers = ScanFixtures (); + var generator = new JcwJavaSourceGenerator (); + var files = generator.Generate (peers, _outputDir); + Assert.DoesNotContain (files, f => f.EndsWith ("java/lang/Object.java")); + Assert.DoesNotContain (files, f => f.EndsWith ("android/app/Activity.java")); + Assert.Contains (files, f => f.Replace ('\\', '/').Contains ("my/app/MainActivity.java")); + } + + } + + public class ClassDeclaration + { + + [Fact] + public void Generate_MainActivity_HasClassDeclaration () + { + var java = GenerateFixture ("my/app/MainActivity"); + Assert.Contains ("public class MainActivity\n", java); + Assert.Contains ("\textends android.app.Activity\n", java); + Assert.Contains ("\t\tmono.android.IGCUserPeer\n", java); + } + + [Fact] + public void Generate_MainActivity_HasIGCUserPeerMethods () + { + var java = GenerateFixture ("my/app/MainActivity"); + Assert.Contains ("private java.util.ArrayList refList;", java); + Assert.Contains ("public void monodroidAddReference (java.lang.Object obj)", java); + Assert.Contains ("public void monodroidClearReferences ()", java); + } + + [Fact] + public void Generate_AbstractType_HasAbstractModifier () + { + var java = GenerateFixture ("my/app/AbstractBase"); + Assert.Contains ("public abstract class AbstractBase\n", java); + } + + } + + public class StaticInitializer + { + + [Fact] + public void Generate_AcwType_HasRegisterNativesStaticBlock () + { + var java = GenerateFixture ("my/app/MainActivity"); + Assert.Contains ("static {\n", java); + Assert.Contains ("mono.android.Runtime.registerNatives (MainActivity.class);\n", java); + } + + } + + public class Constructor + { + + [Fact] + public void Generate_CustomView_HasExpectedConstructorElements () + { + var java = GenerateFixture ("my/app/CustomView"); + Assert.Contains ("public CustomView ()\n", java); + Assert.Contains ("public CustomView (android.content.Context p0)\n", java); + Assert.Contains ("private native void nctor_0 ();\n", java); + Assert.Contains ("private native void nctor_1 (android.content.Context p0);\n", java); + Assert.Contains ("if (getClass () == CustomView.class) nctor_0 ();\n", java); + } + + [Fact] + public void Generate_Constructor_WithSuperArgumentsString_UsesCustomSuperArgs () + { + // [Export] constructors with SuperArgumentsString should use it in super() call + var type = new JavaPeerInfo { + JavaName = "my/app/CustomService", + ManagedTypeName = "MyApp.CustomService", + ManagedTypeNamespace = "MyApp", + ManagedTypeShortName = "CustomService", + AssemblyName = "App", + BaseJavaName = "android/app/Service", + JavaConstructors = new List { + new JavaConstructorInfo { + JniSignature = "(Landroid/content/Context;I)V", + ConstructorIndex = 0, + Parameters = new List { + new JniParameterInfo { JniType = "Landroid/content/Context;" }, + new JniParameterInfo { JniType = "I" }, + }, + SuperArgumentsString = "p0", + }, + }, + }; + + var java = GenerateToString (type); + Assert.Contains ("super (p0);", java); + Assert.DoesNotContain ("super (p0, p1);", java); + } + + [Fact] + public void Generate_Constructor_WithEmptySuperArgumentsString_EmptySuper () + { + // Empty string means super() with no arguments + var type = new JavaPeerInfo { + JavaName = "my/app/MyWidget", + ManagedTypeName = "MyApp.MyWidget", + ManagedTypeNamespace = "MyApp", + ManagedTypeShortName = "MyWidget", + AssemblyName = "App", + BaseJavaName = "android/appwidget/AppWidgetProvider", + JavaConstructors = new List { + new JavaConstructorInfo { + JniSignature = "(Landroid/content/Context;)V", + ConstructorIndex = 0, + Parameters = new List { + new JniParameterInfo { JniType = "Landroid/content/Context;" }, + }, + SuperArgumentsString = "", + }, + }, + }; + + var java = GenerateToString (type); + Assert.Contains ("super ();", java); + Assert.DoesNotContain ("super (p0);", java); + } + + [Fact] + public void Generate_Constructor_WithoutSuperArgumentsString_ForwardsAllParams () + { + // null SuperArgumentsString means forward all params (default behavior) + var type = new JavaPeerInfo { + JavaName = "my/app/MyView", + ManagedTypeName = "MyApp.MyView", + ManagedTypeNamespace = "MyApp", + ManagedTypeShortName = "MyView", + AssemblyName = "App", + BaseJavaName = "android/view/View", + JavaConstructors = new List { + new JavaConstructorInfo { + JniSignature = "(Landroid/content/Context;Landroid/util/AttributeSet;)V", + ConstructorIndex = 0, + Parameters = new List { + new JniParameterInfo { JniType = "Landroid/content/Context;" }, + new JniParameterInfo { JniType = "Landroid/util/AttributeSet;" }, + }, + }, + }, + }; + + var java = GenerateToString (type); + Assert.Contains ("super (p0, p1);", java); + } + + } + + public class Method + { + + [Fact] + public void Generate_MarshalMethod_HasOverrideAndNativeDeclaration () + { + var java = GenerateFixture ("my/app/MainActivity"); + Assert.Contains ("@Override\n", java); + Assert.Contains ("public void onCreate (android.os.Bundle p0)\n", java); + Assert.Contains ("n_OnCreate (p0);\n", java); + Assert.Contains ("public native void n_OnCreate (android.os.Bundle p0);\n", java); + } + + } + + public class NestedType + { + + [Fact] + public void Generate_NestedType_HasCorrectPackageAndClassName () + { + var java = GenerateFixture ("my/app/Outer$Inner"); + Assert.Contains ("package my.app;\n", java); + Assert.Contains ("public class Outer$Inner\n", java); + } + + } + + public class OutputFilePath : IDisposable + { + readonly string _outputDir = CreateTempDir (); + public void Dispose () => DeleteTempDir (_outputDir); + + [Fact] + public void Generate_CreatesCorrectFileStructure () + { + var peers = ScanFixtures (); + var generator = new JcwJavaSourceGenerator (); + var files = generator.Generate (peers, _outputDir); + Assert.NotEmpty (files); + + foreach (var file in files) { + Assert.StartsWith (_outputDir, file); + Assert.True (File.Exists (file), $"Generated file should exist: {file}"); + Assert.EndsWith (".java", file); + } + } + + [Theory] + [InlineData ("")] + [InlineData ("com//Example")] + [InlineData ("/com/Example")] + [InlineData ("com/Example/")] + [InlineData ("com/1Invalid")] + [InlineData ("com/../etc/passwd")] + [InlineData ("com\\..\\.\\secret")] + [InlineData ("C:\\Windows\\System32")] + [InlineData ("com/Ex:ample")] + [InlineData ("/absolute/path")] + public void Generate_InvalidJniName_Throws (string badJniName) + { + var peer = MakeAcwPeer (badJniName, "Test.Bad", "TestApp"); + var generator = new JcwJavaSourceGenerator (); + Assert.Throws (() => generator.Generate (new [] { peer }, _outputDir)); + } + + [Theory] + [InlineData ("com/example/MainActivity")] + [InlineData ("my/app/Outer$Inner")] + [InlineData ("SingleSegment")] + [InlineData ("com/example/_Private")] + [InlineData ("com/example/$Generated")] + public void Generate_ValidJniName_DoesNotThrow (string validJniName) + { + var peer = MakeAcwPeer (validJniName, "Test.Valid", "TestApp"); + var generator = new JcwJavaSourceGenerator (); + generator.Generate (new [] { peer }, _outputDir); + } + + } + + public class ExportWithThrowsClause + { + + [Fact] + public void Generate_ExportWithThrows_HasThrowsClause () + { + var java = GenerateFixture ("my/app/ExportWithThrows"); + Assert.Contains ("throws java.io.IOException, java.lang.IllegalStateException\n", java); + } + + } + + public class MethodReturnTypesAndParams + { + + [Fact] + public void Generate_TouchHandler_HasExpectedMethodSignatures () + { + var java = GenerateFixture ("my/app/TouchHandler"); + Assert.Contains ("public boolean onTouch (android.view.View p0, int p1)\n", java); + Assert.Contains ("public void onScroll (int p0, float p1, long p2, double p3)\n", java); + Assert.Contains ("public java.lang.String getText ()\n", java); + Assert.Contains ("public void setItems (java.lang.String[] p0)\n", java); + } + + } +} \ No newline at end of file diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs index b2282243273..cbb65cc2af2 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs @@ -127,65 +127,65 @@ public void Generate_ProxyType_HasCtorAndCreateInstance () } -public class AcwProxy : IDisposable -{ -readonly string _outputDir = CreateTempDir (); -public void Dispose () => DeleteTempDir (_outputDir); + public class AcwProxy : IDisposable + { + readonly string _outputDir = CreateTempDir (); + public void Dispose () => DeleteTempDir (_outputDir); -[Fact] -public void Generate_AcwProxy_HasRegisterNativesAndUcoMethods () -{ -var peers = ScanFixtures (); -var acwPeer = peers.First (p => p.JavaName == "my/app/TouchHandler"); -var path = GenerateAssembly (new [] { acwPeer }, _outputDir, "AcwTest"); -var (pe, reader) = OpenAssembly (path); -using (pe) { -var proxy = reader.TypeDefinitions -.Select (h => reader.GetTypeDefinition (h)) -.First (t => reader.GetString (t.Name) == "MyApp_TouchHandler_Proxy"); - -var methods = proxy.GetMethods () -.Select (h => reader.GetMethodDefinition (h)) -.Select (m => reader.GetString (m.Name)) -.ToList (); - -Assert.Contains ("RegisterNatives", methods); -Assert.Contains (methods, m => m.StartsWith ("n_") && m.EndsWith ("_uco_0")); -} -} - -[Fact] -public void Generate_AcwProxy_HasUnmanagedCallersOnlyAttribute () -{ -var peers = ScanFixtures (); -var acwPeer = peers.First (p => p.JavaName == "my/app/TouchHandler"); -var path = GenerateAssembly (new [] { acwPeer }, _outputDir, "UcoTest"); -var (pe, reader) = OpenAssembly (path); -using (pe) { -var proxy = reader.TypeDefinitions -.Select (h => reader.GetTypeDefinition (h)) -.First (t => reader.GetString (t.Name) == "MyApp_TouchHandler_Proxy"); - -var ucoMethod = proxy.GetMethods () -.Select (h => reader.GetMethodDefinition (h)) -.First (m => reader.GetString (m.Name).Contains ("_uco_")); - -var attrNames = ucoMethod.GetCustomAttributes () -.Select (h => reader.GetCustomAttribute (h)) -.Select (a => { -var ctorHandle = (MemberReferenceHandle) a.Constructor; -var ctor = reader.GetMemberReference (ctorHandle); -var typeRef = reader.GetTypeReference ((TypeReferenceHandle) ctor.Parent); -return $"{reader.GetString (typeRef.Namespace)}.{reader.GetString (typeRef.Name)}"; -}) -.ToList (); -Assert.Contains ("System.Runtime.InteropServices.UnmanagedCallersOnlyAttribute", attrNames); -} -} - -} - -public class IgnoresAccessChecksTo : IDisposable + [Fact] + public void Generate_AcwProxy_HasRegisterNativesAndUcoMethods () + { + var peers = ScanFixtures (); + var acwPeer = peers.First (p => p.JavaName == "my/app/TouchHandler"); + var path = GenerateAssembly (new [] { acwPeer }, _outputDir, "AcwTest"); + var (pe, reader) = OpenAssembly (path); + using (pe) { + var proxy = reader.TypeDefinitions + .Select (h => reader.GetTypeDefinition (h)) + .First (t => reader.GetString (t.Name) == "MyApp_TouchHandler_Proxy"); + + var methods = proxy.GetMethods () + .Select (h => reader.GetMethodDefinition (h)) + .Select (m => reader.GetString (m.Name)) + .ToList (); + + Assert.Contains ("RegisterNatives", methods); + Assert.Contains (methods, m => m.StartsWith ("n_") && m.EndsWith ("_uco_0")); + } + } + + [Fact] + public void Generate_AcwProxy_HasUnmanagedCallersOnlyAttribute () + { + var peers = ScanFixtures (); + var acwPeer = peers.First (p => p.JavaName == "my/app/TouchHandler"); + var path = GenerateAssembly (new [] { acwPeer }, _outputDir, "UcoTest"); + var (pe, reader) = OpenAssembly (path); + using (pe) { + var proxy = reader.TypeDefinitions + .Select (h => reader.GetTypeDefinition (h)) + .First (t => reader.GetString (t.Name) == "MyApp_TouchHandler_Proxy"); + + var ucoMethod = proxy.GetMethods () + .Select (h => reader.GetMethodDefinition (h)) + .First (m => reader.GetString (m.Name).Contains ("_uco_")); + + var attrNames = ucoMethod.GetCustomAttributes () + .Select (h => reader.GetCustomAttribute (h)) + .Select (a => { + var ctorHandle = (MemberReferenceHandle) a.Constructor; + var ctor = reader.GetMemberReference (ctorHandle); + var typeRef = reader.GetTypeReference ((TypeReferenceHandle) ctor.Parent); + return $"{reader.GetString (typeRef.Namespace)}.{reader.GetString (typeRef.Name)}"; + }) + .ToList (); + Assert.Contains ("System.Runtime.InteropServices.UnmanagedCallersOnlyAttribute", attrNames); + } + } + + } + + public class IgnoresAccessChecksTo : IDisposable { readonly string _outputDir = CreateTempDir (); public void Dispose () => DeleteTempDir (_outputDir); @@ -286,68 +286,68 @@ public void Generate_EmptyPeerList_ProducesValidAssembly () } -public class JniSignatureHelperTests -{ + public class JniSignatureHelperTests + { -[Theory] -[InlineData ("()V", 0)] -[InlineData ("(I)V", 1)] -[InlineData ("(Landroid/os/Bundle;)V", 1)] -[InlineData ("(IFJ)V", 3)] -[InlineData ("(ZLandroid/view/View;I)Z", 3)] -[InlineData ("([Ljava/lang/String;)V", 1)] -public void ParseParameterTypes_ParsesCorrectCount (string signature, int expectedCount) -{ -var actual = JniSignatureHelper.ParseParameterTypes (signature); -Assert.Equal (expectedCount, actual.Count); -} - -[Theory] -[InlineData ("(Z)V", JniParamKind.Boolean)] -[InlineData ("(Ljava/lang/String;)V", JniParamKind.Object)] -public void ParseParameterTypes_SingleParam_MapsToCorrectKind (string signature, JniParamKind expectedKind) -{ -var types = JniSignatureHelper.ParseParameterTypes (signature); -Assert.Single (types); -Assert.Equal (expectedKind, types [0]); -} - -[Theory] -[InlineData ("()V", JniParamKind.Void)] -[InlineData ("()I", JniParamKind.Int)] -[InlineData ("()Z", JniParamKind.Boolean)] -[InlineData ("()Ljava/lang/String;", JniParamKind.Object)] -public void ParseReturnType_MapsToCorrectKind (string signature, JniParamKind expectedKind) -{ -Assert.Equal (expectedKind, JniSignatureHelper.ParseReturnType (signature)); -} + [Theory] + [InlineData ("()V", 0)] + [InlineData ("(I)V", 1)] + [InlineData ("(Landroid/os/Bundle;)V", 1)] + [InlineData ("(IFJ)V", 3)] + [InlineData ("(ZLandroid/view/View;I)Z", 3)] + [InlineData ("([Ljava/lang/String;)V", 1)] + public void ParseParameterTypes_ParsesCorrectCount (string signature, int expectedCount) + { + var actual = JniSignatureHelper.ParseParameterTypes (signature); + Assert.Equal (expectedCount, actual.Count); + } -} + [Theory] + [InlineData ("(Z)V", JniParamKind.Boolean)] + [InlineData ("(Ljava/lang/String;)V", JniParamKind.Object)] + public void ParseParameterTypes_SingleParam_MapsToCorrectKind (string signature, JniParamKind expectedKind) + { + var types = JniSignatureHelper.ParseParameterTypes (signature); + Assert.Single (types); + Assert.Equal (expectedKind, types [0]); + } -public class NegativeEdgeCase -{ + [Theory] + [InlineData ("()V", JniParamKind.Void)] + [InlineData ("()I", JniParamKind.Int)] + [InlineData ("()Z", JniParamKind.Boolean)] + [InlineData ("()Ljava/lang/String;", JniParamKind.Object)] + public void ParseReturnType_MapsToCorrectKind (string signature, JniParamKind expectedKind) + { + Assert.Equal (expectedKind, JniSignatureHelper.ParseReturnType (signature)); + } -[Fact] -public void ParseParameterTypes_EmptyString_ReturnsEmptyList () -{ -Assert.Empty (JniSignatureHelper.ParseParameterTypes ("")); -} + } -[Fact] -public void ParseParameterTypes_InvalidSignature_Throws () -{ -Assert.ThrowsAny (() => JniSignatureHelper.ParseParameterTypes ("not-a-sig")); -} + public class NegativeEdgeCase + { -[Fact] -public void ParseParameterTypes_UnterminatedSignature_ReturnsEmptyList () -{ -Assert.Empty (JniSignatureHelper.ParseParameterTypes ("(")); -} + [Fact] + public void ParseParameterTypes_EmptyString_ReturnsEmptyList () + { + Assert.Empty (JniSignatureHelper.ParseParameterTypes ("")); + } -} + [Fact] + public void ParseParameterTypes_InvalidSignature_Throws () + { + Assert.ThrowsAny (() => JniSignatureHelper.ParseParameterTypes ("not-a-sig")); + } + + [Fact] + public void ParseParameterTypes_UnterminatedSignature_ReturnsEmptyList () + { + Assert.Empty (JniSignatureHelper.ParseParameterTypes ("(")); + } + + } -public class CreateInstancePaths : IDisposable + public class CreateInstancePaths : IDisposable { readonly string _outputDir = CreateTempDir (); public void Dispose () => DeleteTempDir (_outputDir);