Skip to content

Commit 796c30d

Browse files
[TrimmableTypeMap] Add JCW Java source generator
Add JcwJavaSourceGenerator that produces Java Callable Wrapper .java source files from scanned JavaPeerInfo records: - Package declarations, class declarations with extends/implements - Static initializer with registerNatives call - Constructors with super() delegation and activation guards - Method overrides delegating to native callbacks - Native method declarations for JNI registration - Uses raw string literals (C# $$""") for readable Java templates - Comprehensive test coverage for all generation scenarios Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f2a2cfa commit 796c30d

2 files changed

Lines changed: 405 additions & 45 deletions

File tree

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
5+
namespace Microsoft.Android.Sdk.TrimmableTypeMap;
6+
7+
/// <summary>
8+
/// Generates JCW (Java Callable Wrapper) .java source files from scanned <see cref="JavaPeerInfo"/> records.
9+
/// Only processes ACW types (where <see cref="JavaPeerInfo.DoNotGenerateAcw"/> is false).
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>Each generated .java file looks like this (pseudo-Java):</para>
13+
/// <code>
14+
/// package com.example;
15+
///
16+
/// public class MainActivity
17+
/// extends android.app.Activity
18+
/// implements
19+
/// mono.android.IGCUserPeer,
20+
/// android.view.View.OnClickListener
21+
/// {
22+
/// static {
23+
/// mono.android.Runtime.registerNatives (MainActivity.class);
24+
/// }
25+
///
26+
/// public MainActivity (android.content.Context p0)
27+
/// {
28+
/// super (p0);
29+
/// if (getClass () == MainActivity.class) nctor_0 (p0);
30+
/// }
31+
/// private native void nctor_0 (android.content.Context p0);
32+
///
33+
/// @Override
34+
/// public void onCreate (android.os.Bundle p0)
35+
/// {
36+
/// n_onCreate (p0);
37+
/// }
38+
/// public native void n_onCreate (android.os.Bundle p0);
39+
/// }
40+
/// </code>
41+
/// </remarks>
42+
sealed class JcwJavaSourceGenerator
43+
{
44+
/// <summary>
45+
/// Generates .java source files for all ACW types and writes them to the output directory.
46+
/// Returns the list of generated file paths.
47+
/// </summary>
48+
public IReadOnlyList<string> Generate (IReadOnlyList<JavaPeerInfo> types, string outputDirectory)
49+
{
50+
if (types is null) {
51+
throw new ArgumentNullException (nameof (types));
52+
}
53+
if (outputDirectory is null) {
54+
throw new ArgumentNullException (nameof (outputDirectory));
55+
}
56+
57+
var generatedFiles = new List<string> ();
58+
59+
foreach (var type in types) {
60+
if (type.DoNotGenerateAcw || type.IsInterface) {
61+
continue;
62+
}
63+
64+
string filePath = GetOutputFilePath (type, outputDirectory);
65+
string? dir = Path.GetDirectoryName (filePath);
66+
if (dir != null) {
67+
Directory.CreateDirectory (dir);
68+
}
69+
70+
using var writer = new StreamWriter (filePath);
71+
Generate (type, writer);
72+
generatedFiles.Add (filePath);
73+
}
74+
75+
return generatedFiles;
76+
}
77+
78+
/// <summary>
79+
/// Generates a single .java source file for the given type.
80+
/// </summary>
81+
internal void Generate (JavaPeerInfo type, TextWriter writer)
82+
{
83+
writer.NewLine = "\n";
84+
WritePackageDeclaration (type, writer);
85+
WriteClassDeclaration (type, writer);
86+
WriteStaticInitializer (type, writer);
87+
WriteConstructors (type, writer);
88+
WriteMethods (type, writer);
89+
WriteClassClose (writer);
90+
}
91+
92+
static string GetOutputFilePath (JavaPeerInfo type, string outputDirectory)
93+
{
94+
ValidateJniName (type.JavaName);
95+
string relativePath = type.JavaName + ".java";
96+
return Path.Combine (outputDirectory, relativePath);
97+
}
98+
99+
/// <summary>
100+
/// Validates that the JNI name is well-formed: non-empty, each segment separated by '/'
101+
/// contains only valid Java identifier characters (letters, digits, '_', '$').
102+
/// This also prevents path traversal (e.g., ".." segments, rooted paths, backslashes).
103+
/// </summary>
104+
static void ValidateJniName (string jniName)
105+
{
106+
if (string.IsNullOrEmpty (jniName)) {
107+
throw new ArgumentException ("JNI name must not be null or empty.", nameof (jniName));
108+
}
109+
110+
int segmentStart = 0;
111+
for (int i = 0; i <= jniName.Length; i++) {
112+
if (i == jniName.Length || jniName [i] == '/') {
113+
if (i == segmentStart) {
114+
throw new ArgumentException ($"JNI name '{jniName}' has an empty segment.", nameof (jniName));
115+
}
116+
117+
// First char of a segment must not be a digit
118+
char first = jniName [segmentStart];
119+
if (first >= '0' && first <= '9') {
120+
throw new ArgumentException ($"JNI name '{jniName}' has a segment starting with a digit.", nameof (jniName));
121+
}
122+
123+
// All chars in the segment must be valid Java identifier chars
124+
for (int j = segmentStart; j < i; j++) {
125+
char c = jniName [j];
126+
bool valid = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
127+
(c >= '0' && c <= '9') || c == '_' || c == '$';
128+
if (!valid) {
129+
throw new ArgumentException ($"JNI name '{jniName}' contains invalid character '{c}'.", nameof (jniName));
130+
}
131+
}
132+
133+
segmentStart = i + 1;
134+
}
135+
}
136+
}
137+
138+
static void WritePackageDeclaration (JavaPeerInfo type, TextWriter writer)
139+
{
140+
string? package = GetJavaPackageName (type.JavaName);
141+
if (package != null) {
142+
writer.Write ("package ");
143+
writer.Write (package);
144+
writer.WriteLine (';');
145+
writer.WriteLine ();
146+
}
147+
}
148+
149+
static void WriteClassDeclaration (JavaPeerInfo type, TextWriter writer)
150+
{
151+
string abstractModifier = type.IsAbstract && !type.IsInterface ? "abstract " : "";
152+
string className = GetJavaSimpleName (type.JavaName);
153+
154+
writer.Write ($"public {abstractModifier}class {className}\n");
155+
156+
// extends clause
157+
if (type.BaseJavaName != null) {
158+
writer.WriteLine ($"\textends {JniNameToJavaName (type.BaseJavaName)}");
159+
}
160+
161+
// implements clause — always includes IGCUserPeer, plus any implemented interfaces
162+
writer.Write ("\timplements\n\t\tmono.android.IGCUserPeer");
163+
164+
foreach (var iface in type.ImplementedInterfaceJavaNames) {
165+
writer.Write ($",\n\t\t{JniNameToJavaName (iface)}");
166+
}
167+
168+
writer.WriteLine ();
169+
writer.WriteLine ('{');
170+
}
171+
172+
static void WriteStaticInitializer (JavaPeerInfo type, TextWriter writer)
173+
{
174+
string className = GetJavaSimpleName (type.JavaName);
175+
writer.Write ($$"""
176+
static {
177+
mono.android.Runtime.registerNatives ({{className}}.class);
178+
}
179+
180+
181+
""");
182+
}
183+
184+
static void WriteConstructors (JavaPeerInfo type, TextWriter writer)
185+
{
186+
string simpleClassName = GetJavaSimpleName (type.JavaName);
187+
188+
foreach (var ctor in type.JavaConstructors) {
189+
string parameters = FormatParameterList (ctor.Parameters);
190+
string superArgs = ctor.SuperArgumentsString ?? FormatArgumentList (ctor.Parameters);
191+
string args = FormatArgumentList (ctor.Parameters);
192+
193+
writer.Write ($$"""
194+
public {{simpleClassName}} ({{parameters}})
195+
{
196+
super ({{superArgs}});
197+
if (getClass () == {{simpleClassName}}.class) nctor_{{ctor.ConstructorIndex}} ({{args}});
198+
}
199+
200+
201+
""");
202+
}
203+
204+
// Write native constructor declarations
205+
foreach (var ctor in type.JavaConstructors) {
206+
string parameters = FormatParameterList (ctor.Parameters);
207+
writer.WriteLine ($"\tprivate native void nctor_{ctor.ConstructorIndex} ({parameters});");
208+
}
209+
210+
if (type.JavaConstructors.Count > 0) {
211+
writer.WriteLine ();
212+
}
213+
}
214+
215+
static void WriteMethods (JavaPeerInfo type, TextWriter writer)
216+
{
217+
foreach (var method in type.MarshalMethods) {
218+
if (method.IsConstructor) {
219+
continue;
220+
}
221+
222+
string javaReturnType = JniTypeToJava (method.JniReturnType);
223+
bool isVoid = method.JniReturnType == "V";
224+
string parameters = FormatParameterList (method.Parameters);
225+
string args = FormatArgumentList (method.Parameters);
226+
string returnPrefix = isVoid ? "" : "return ";
227+
228+
// throws clause for [Export] methods
229+
string throwsClause = "";
230+
if (method.ThrownNames != null && method.ThrownNames.Count > 0) {
231+
throwsClause = $"\n\t\tthrows {string.Join (", ", method.ThrownNames)}";
232+
}
233+
234+
if (method.Connector != null) {
235+
writer.Write ($$"""
236+
237+
@Override
238+
public {{javaReturnType}} {{method.JniName}} ({{parameters}}){{throwsClause}}
239+
{
240+
{{returnPrefix}}{{method.NativeCallbackName}} ({{args}});
241+
}
242+
public native {{javaReturnType}} {{method.NativeCallbackName}} ({{parameters}});
243+
244+
""");
245+
} else {
246+
writer.Write ($$"""
247+
248+
public {{javaReturnType}} {{method.JniName}} ({{parameters}}){{throwsClause}}
249+
{
250+
{{returnPrefix}}{{method.NativeCallbackName}} ({{args}});
251+
}
252+
public native {{javaReturnType}} {{method.NativeCallbackName}} ({{parameters}});
253+
254+
""");
255+
}
256+
}
257+
}
258+
259+
static void WriteClassClose (TextWriter writer)
260+
{
261+
writer.WriteLine ('}');
262+
}
263+
264+
static string FormatParameterList (IReadOnlyList<JniParameterInfo> parameters)
265+
{
266+
if (parameters.Count == 0) {
267+
return "";
268+
}
269+
270+
var sb = new System.Text.StringBuilder ();
271+
for (int i = 0; i < parameters.Count; i++) {
272+
if (i > 0) {
273+
sb.Append (", ");
274+
}
275+
sb.Append (JniTypeToJava (parameters [i].JniType));
276+
sb.Append (" p");
277+
sb.Append (i);
278+
}
279+
return sb.ToString ();
280+
}
281+
282+
static string FormatArgumentList (IReadOnlyList<JniParameterInfo> parameters)
283+
{
284+
if (parameters.Count == 0) {
285+
return "";
286+
}
287+
288+
var sb = new System.Text.StringBuilder ();
289+
for (int i = 0; i < parameters.Count; i++) {
290+
if (i > 0) {
291+
sb.Append (", ");
292+
}
293+
sb.Append ('p');
294+
sb.Append (i);
295+
}
296+
return sb.ToString ();
297+
}
298+
299+
/// <summary>
300+
/// Converts a JNI type name to a Java source type name.
301+
/// e.g., "android/app/Activity" → "android.app.Activity"
302+
/// </summary>
303+
internal static string JniNameToJavaName (string jniName)
304+
{
305+
return jniName.Replace ('/', '.');
306+
}
307+
308+
/// <summary>
309+
/// Extracts the Java package name from a JNI type name.
310+
/// e.g., "com/example/MainActivity" → "com.example"
311+
/// Returns null for types without a package.
312+
/// </summary>
313+
internal static string? GetJavaPackageName (string jniName)
314+
{
315+
int lastSlash = jniName.LastIndexOf ('/');
316+
if (lastSlash < 0) {
317+
return null;
318+
}
319+
return jniName.Substring (0, lastSlash).Replace ('/', '.');
320+
}
321+
322+
/// <summary>
323+
/// Extracts the simple Java class name from a JNI type name.
324+
/// e.g., "com/example/MainActivity" → "MainActivity"
325+
/// e.g., "com/example/Outer$Inner" → "Outer$Inner" (preserves nesting separator)
326+
/// </summary>
327+
internal static string GetJavaSimpleName (string jniName)
328+
{
329+
int lastSlash = jniName.LastIndexOf ('/');
330+
return lastSlash >= 0 ? jniName.Substring (lastSlash + 1) : jniName;
331+
}
332+
333+
/// <summary>
334+
/// Converts a JNI type descriptor to a Java source type.
335+
/// e.g., "V" → "void", "I" → "int", "Landroid/os/Bundle;" → "android.os.Bundle"
336+
/// </summary>
337+
internal static string JniTypeToJava (string jniType)
338+
{
339+
if (jniType.Length == 1) {
340+
return jniType [0] switch {
341+
'V' => "void",
342+
'Z' => "boolean",
343+
'B' => "byte",
344+
'C' => "char",
345+
'S' => "short",
346+
'I' => "int",
347+
'J' => "long",
348+
'F' => "float",
349+
'D' => "double",
350+
_ => throw new ArgumentException ($"Unknown JNI primitive type: {jniType}"),
351+
};
352+
}
353+
354+
// Array types: "[I" → "int[]", "[Ljava/lang/String;" → "java.lang.String[]"
355+
if (jniType [0] == '[') {
356+
return JniTypeToJava (jniType.Substring (1)) + "[]";
357+
}
358+
359+
// Object types: "Landroid/os/Bundle;" → "android.os.Bundle"
360+
if (jniType [0] == 'L' && jniType [jniType.Length - 1] == ';') {
361+
return JniNameToJavaName (jniType.Substring (1, jniType.Length - 2));
362+
}
363+
364+
throw new ArgumentException ($"Unknown JNI type descriptor: {jniType}");
365+
}
366+
}

0 commit comments

Comments
 (0)