Skip to content

Commit 59a9a77

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 b6371e0 commit 59a9a77

File tree

2 files changed

+374
-0
lines changed

2 files changed

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

tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,45 @@ public void Generate_CreatesCorrectFileStructure ()
311311
}
312312
}
313313

314+
[Theory]
315+
[InlineData ("")]
316+
[InlineData ("com//Example")]
317+
[InlineData ("/com/Example")]
318+
[InlineData ("com/Example/")]
319+
[InlineData ("com/1Invalid")]
320+
[InlineData ("com/../etc/passwd")]
321+
[InlineData ("com\\..\\.\\secret")]
322+
[InlineData ("C:\\Windows\\System32")]
323+
[InlineData ("com/Ex:ample")]
324+
[InlineData ("/absolute/path")]
325+
public void Generate_InvalidJniName_Throws (string badJniName)
326+
{
327+
var peer = MakeAcwPeer (badJniName, "Test.Bad", "TestApp");
328+
var generator = new JcwJavaSourceGenerator ();
329+
var outputDir = Path.Combine (Path.GetTempPath (), $"jcw-test-{Guid.NewGuid ():N}");
330+
Assert.Throws<ArgumentException> (() => generator.Generate (new [] { peer }, outputDir));
331+
}
332+
333+
[Theory]
334+
[InlineData ("com/example/MainActivity")]
335+
[InlineData ("my/app/Outer$Inner")]
336+
[InlineData ("SingleSegment")]
337+
[InlineData ("com/example/_Private")]
338+
[InlineData ("com/example/$Generated")]
339+
public void Generate_ValidJniName_DoesNotThrow (string validJniName)
340+
{
341+
var peer = MakeAcwPeer (validJniName, "Test.Valid", "TestApp");
342+
var generator = new JcwJavaSourceGenerator ();
343+
var outputDir = Path.Combine (Path.GetTempPath (), $"jcw-test-{Guid.NewGuid ():N}");
344+
try {
345+
generator.Generate (new [] { peer }, outputDir);
346+
} finally {
347+
if (Directory.Exists (outputDir)) {
348+
Directory.Delete (outputDir, true);
349+
}
350+
}
351+
}
352+
314353
}
315354

316355
public class ExportWithThrowsClause

0 commit comments

Comments
 (0)