Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
using System;
using System.Collections.Generic;
using System.IO;

namespace Microsoft.Android.Sdk.TrimmableTypeMap;

/// <summary>
/// Generates JCW (Java Callable Wrapper) .java source files from scanned <see cref="JavaPeerInfo"/> records.
/// Only processes ACW types (where <see cref="JavaPeerInfo.DoNotGenerateAcw"/> is false).
/// </summary>
/// <remarks>
/// <para>Each generated .java file looks like this (pseudo-Java):</para>
/// <code>
/// 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);
/// }
/// </code>
/// </remarks>
sealed class JcwJavaSourceGenerator
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JcwJavaSourceGenerator is declared without an access modifier (internal), but the test project instantiates it directly. There is no InternalsVisibleTo for Microsoft.Android.Sdk.TrimmableTypeMap.Tests, so this will make the tests (and any external caller) unable to access the type. Consider making the generator public, or add an InternalsVisibleTo attribute for the test assembly if you want to keep the API internal.

Suggested change
sealed class JcwJavaSourceGenerator
public sealed class JcwJavaSourceGenerator

Copilot uses AI. Check for mistakes.
{
/// <summary>
/// Generates .java source files for all ACW types and writes them to the output directory.
/// Returns the list of generated file paths.
/// </summary>
public IReadOnlyList<string> Generate (IReadOnlyList<JavaPeerInfo> 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<string> ();

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;
}

/// <summary>
/// Generates a single .java source file for the given type.
/// </summary>
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);
}

/// <summary>
/// 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).
/// </summary>
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<JniParameterInfo> 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<JniParameterInfo> 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 ();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}

/// <summary>
/// Validates that a JNI type name has the expected structure (e.g., "com/example/MyClass").
/// </summary>
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;
}
}
}

/// <summary>
/// Converts a JNI type name to a Java source type name.
/// e.g., "android/app/Activity" \u2192 "android.app.Activity"
/// </summary>
internal static string JniNameToJavaName (string jniName)
{
return jniName.Replace ('/', '.');
}

/// <summary>
/// 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.
/// </summary>
internal static string? GetJavaPackageName (string jniName)
{
int lastSlash = jniName.LastIndexOf ('/');
if (lastSlash < 0) {
return null;
}
return jniName.Substring (0, lastSlash).Replace ('/', '.');
}

/// <summary>
/// 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)
/// </summary>
internal static string GetJavaSimpleName (string jniName)
{
int lastSlash = jniName.LastIndexOf ('/');
return lastSlash >= 0 ? jniName.Substring (lastSlash + 1) : jniName;
}

/// <summary>
/// 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"
/// </summary>
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}");
}
}
Loading