ffi #jni_main slice 2: AOT pipeline — .java + javac + d8 → classes.dex in APK

Compilation.lowering_jni_main_decls is populated by lowerToIR (iterating
foreign_class_map for is_main && !is_foreign && runtime==jni_class,
deduped by foreign_path); each entry carries the pre-rendered Java source
from jni_java_emit.emitJavaSource.

createApk extended: when the emission list is non-empty, write each
.java under <stage>/java/<pkg>/<Class>.java, javac --release 11 to
<stage>/classes/, d8 --release --lib <android_jar> --output <stage>
to produce <stage>/classes.dex, then zip the .dex into the unaligned
APK at root level. javac discovery: $JAVA_HOME/bin/javac first, then
`which javac`.

Manifest still hardcodes android.app.NativeActivity (slice 3 wires the
user's class name + android:hasCode="true"), so the bundled .dex is
present but unreferenced at runtime. End-to-end verified via dexdump on
the smoke example's APK — Lco/swipelab/sxjnimain/SxApp; extending
NativeActivity shows up in classes.dex. Non-#jni_main APK builds
(99-android-egl-clear.sx) produce the same shape as before.

Cross-compile tuple added for examples/ffi-jni-main-01-emit.sx
(compile-only — APK exercise is manual).
This commit is contained in:
agra
2026-05-20 14:42:03 +03:00
parent 7ea7ad778e
commit 1ae43495c2
7 changed files with 236 additions and 6 deletions

View File

@@ -0,0 +1,38 @@
// `#jni_main` pipeline slice 2 (PLAN-FFI.md): the compiler renders a
// `.java` source for a `#jni_main #jni_class("...")` declaration, runs
// `javac` + `d8`, and bundles `classes.dex` into the APK.
//
// Slice 2 only wires the plumbing — the manifest still points at
// `android.app.NativeActivity`, so the user's class isn't loaded at
// runtime. Slice 3 (manifest synthesis) and slice 4 (RegisterNatives)
// land in follow-up commits.
//
// Build to inspect APK contents (requires Android SDK + JDK):
// /Users/agra/projects/sx/zig-out/bin/sx build --target android \
// --apk /tmp/sxjnimain.apk --bundle-id co.swipelab.sxjnimain \
// -o /tmp/libsxjnimain.so examples/ffi-jni-main-01-emit.sx
// unzip -l /tmp/sxjnimain.apk | grep classes.dex
//
// Cross-compile test (compile-only): see tests/cross_compile.sh's
// `android | examples/ffi-jni-main-01-emit.sx` tuple. APK creation
// itself isn't exercised by cross_compile.sh — only that the example
// lowers and links cleanly with `#jni_main` in scope.
#import "modules/std.sx";
#import "modules/compiler.sx";
// `#jni_main` flags this as the launchable Android Activity class. The
// empty body intentionally has zero methods — slice 2 just verifies the
// .java/.dex pipeline; `onCreate` overriding lands once slice 4 wires
// `RegisterNatives` so the `sx_<method>` symbols actually resolve.
SxApp :: #jni_main #jni_class("co/swipelab/sxjnimain/SxApp") { }
main :: () -> s32 { 0; }
// Android NDK entry symbol — kept as a 3-line trampoline so this example
// passes `--target android` builds via `tests/cross_compile.sh`.
android_main :: (app: *void) {
inline if OS == .android {
main();
}
}

View File

@@ -10,6 +10,7 @@ const target_mod = @import("target.zig");
const Node = ast.Node;
pub const TargetConfig = target_mod.TargetConfig;
pub const JniMainEmission = target_mod.JniMainEmission;
pub const Compilation = struct {
allocator: std.mem.Allocator,
@@ -32,6 +33,10 @@ pub const Compilation = struct {
/// E.g. the JNI env TL runtime when `#jni_env` is used. Merged with
/// AST sources in `collectCImportSources`.
lowering_extra_c_sources: std.ArrayList(c_import.CImportInfo) = .empty,
/// `#jni_main #jni_class("...")` declarations whose Java sources were
/// rendered during lowering. Read by the APK pipeline (`createApk`)
/// to write `.java` files + run `javac` + `d8` + bundle `classes.dex`.
lowering_jni_main_decls: std.ArrayList(JniMainEmission) = .empty,
pub fn init(allocator: std.mem.Allocator, io: std.Io, file_path: []const u8, source: [:0]const u8, target_config: TargetConfig, stdlib_paths: []const []const u8) Compilation {
return .{
@@ -215,9 +220,53 @@ pub const Compilation = struct {
}
}
try self.collectJniMainEmissions(&lowering);
return module;
}
/// Walk `lowering.foreign_class_map` and render Java sources for every
/// `#jni_main #jni_class("...")` declaration. Renders happen here so the
/// AST + class-registry snapshot stay confined to the lowering pass; the
/// downstream APK pipeline only needs `{foreign_path, java_source}` pairs.
fn collectJniMainEmissions(self: *Compilation, lowering: *ir.Lowering) !void {
// `foreign_class_map` registers each decl under bare + qualified names —
// dedupe by foreign_path so a single decl emits one .java.
var seen = std.StringHashMap(void).init(self.allocator);
defer seen.deinit();
// Class registry passed to jni_java_emit for `*Foo` cross-class refs
// and `#extends Alias` resolution.
var registry = std.StringHashMap([]const u8).init(self.allocator);
defer registry.deinit();
var it_reg = lowering.foreign_class_map.iterator();
while (it_reg.next()) |entry| {
try registry.put(entry.key_ptr.*, entry.value_ptr.*.foreign_path);
}
var it = lowering.foreign_class_map.iterator();
while (it.next()) |entry| {
const fcd = entry.value_ptr.*;
if (!fcd.is_main) continue;
if (fcd.is_foreign) continue;
if (fcd.runtime != .jni_class) continue;
if (seen.contains(fcd.foreign_path)) continue;
try seen.put(fcd.foreign_path, {});
const java_source = try ir.jni_java_emit.emitJavaSource(self.allocator, fcd, .{ .classes = &registry });
try self.lowering_jni_main_decls.append(self.allocator, .{
.foreign_path = try self.allocator.dupe(u8, fcd.foreign_path),
.java_source = java_source,
});
}
}
/// Java sources rendered from `#jni_main #jni_class("...")` decls during
/// lowering. Empty unless `lowerToIR` has run.
pub fn getJniMainEmissions(self: *const Compilation) []const JniMainEmission {
return self.lowering_jni_main_decls.items;
}
pub fn renderErrors(self: *const Compilation) void {
self.diagnostics.renderDebug();
}

View File

@@ -612,7 +612,7 @@ fn compileWithTimer(allocator: std.mem.Allocator, io: std.Io, input_path: []cons
// Wrap into an .apk if requested (Android).
if (merged_config.apk_path) |ap| {
timer.mark();
sx.target.createApk(allocator, io, final_output, merged_config) catch std.process.exit(1);
sx.target.createApk(allocator, io, final_output, merged_config, comp.getJniMainEmissions()) catch std.process.exit(1);
timer.record("apk");
std.debug.print("apk: {s}\n", .{ap});
}

View File

@@ -2,6 +2,18 @@ const std = @import("std");
const llvm = @import("llvm_api.zig");
const c = llvm.c;
/// One `#jni_main #jni_class("...")` declaration's Java-source emission.
/// Populated by lowering and consumed by `createApk` to write a `.java`
/// file under `<stage>/java/`, compile it via `javac`, and bundle the
/// resulting `classes.dex` into the APK.
pub const JniMainEmission = struct {
/// foreign_path of the source decl (e.g. "co/swipelab/sxmain/SxApp").
/// Splits into package + class name for `<stage>/java/<pkg>/<Class>.java`.
foreign_path: []const u8,
/// Pre-rendered Java source bytes (from `jni_java_emit.emitJavaSource`).
java_source: []const u8,
};
pub const TargetConfig = struct {
/// Target triple (e.g. "aarch64-apple-darwin"). Null = host default.
triple: ?[*:0]const u8 = null,
@@ -269,14 +281,125 @@ fn findHighestSubdir(allocator: std.mem.Allocator, io: std.Io, root: []const u8,
return try std.fmt.allocPrint(allocator, "{s}/{s}", .{ parent, name });
}
/// Write each `JniMainEmission`'s `.java` source under `<stage>/java/<pkg>/`,
/// invoke `javac` to compile to `<stage>/classes/`, then `d8` to produce
/// `<stage>/classes.dex`. The caller bundles `classes.dex` into the APK.
///
/// `javac` is discovered via `$JAVA_HOME/bin/javac` first, then via PATH; if
/// neither resolves, an error is reported pointing at the missing tool. The
/// `--release 11` target keeps the emitted class file version low enough for
/// every shipping d8 to consume without surprise.
fn compileJniMainSources(
allocator: std.mem.Allocator,
io: std.Io,
stage: []const u8,
emissions: []const JniMainEmission,
android_jar: []const u8,
d8: []const u8,
) !void {
const cwd = std.Io.Dir.cwd();
const java_root = try std.fmt.allocPrint(allocator, "{s}/java", .{stage});
const classes_root = try std.fmt.allocPrint(allocator, "{s}/classes", .{stage});
try cwd.createDirPath(io, java_root);
try cwd.createDirPath(io, classes_root);
var java_paths = std.ArrayList([]const u8).empty;
var class_paths = std.ArrayList([]const u8).empty;
for (emissions) |em| {
const split = splitForeignPath(em.foreign_path);
const pkg_dir = if (split.pkg.len > 0)
try std.fmt.allocPrint(allocator, "{s}/{s}", .{ java_root, split.pkg })
else
try allocator.dupe(u8, java_root);
try cwd.createDirPath(io, pkg_dir);
const java_path = try std.fmt.allocPrint(allocator, "{s}/{s}.java", .{ pkg_dir, split.cls });
try cwd.writeFile(io, .{ .sub_path = java_path, .data = em.java_source });
try java_paths.append(allocator, java_path);
const class_path = if (split.pkg.len > 0)
try std.fmt.allocPrint(allocator, "{s}/{s}/{s}.class", .{ classes_root, split.pkg, split.cls })
else
try std.fmt.allocPrint(allocator, "{s}/{s}.class", .{ classes_root, split.cls });
try class_paths.append(allocator, class_path);
}
const javac = try discoverJavac(allocator, io);
var javac_argv = std.ArrayList([]const u8).empty;
try javac_argv.appendSlice(allocator, &.{
javac, "-d", classes_root,
"-classpath", android_jar,
"--release", "11",
});
for (java_paths.items) |p| try javac_argv.append(allocator, p);
try runProcess(allocator, io, try javac_argv.toOwnedSlice(allocator));
var d8_argv = std.ArrayList([]const u8).empty;
try d8_argv.appendSlice(allocator, &.{
d8, "--release",
"--lib", android_jar,
"--output", stage,
});
for (class_paths.items) |p| try d8_argv.append(allocator, p);
try runProcess(allocator, io, try d8_argv.toOwnedSlice(allocator));
}
/// Split a JNI foreign path like `co/swipelab/sxmain/SxApp` into
/// `{ pkg = "co/swipelab/sxmain", cls = "SxApp" }`. A path with no `/` is
/// the default Java package (`{ pkg = "", cls = path }`).
const PathParts = struct { pkg: []const u8, cls: []const u8 };
fn splitForeignPath(foreign_path: []const u8) PathParts {
const last_slash = std.mem.lastIndexOfScalar(u8, foreign_path, '/') orelse {
return .{ .pkg = "", .cls = foreign_path };
};
return .{
.pkg = foreign_path[0..last_slash],
.cls = foreign_path[last_slash + 1 ..],
};
}
/// Locate `javac`. Honors `$JAVA_HOME/bin/javac` first (the Android Studio
/// JDK install on macOS sets this), then falls back to PATH lookup via
/// `which`. Returns an absolute path so subsequent `runProcess` calls work
/// regardless of the CWD passed via `runProcessIn`.
fn discoverJavac(allocator: std.mem.Allocator, io: std.Io) ![]const u8 {
if (std.c.getenv("JAVA_HOME")) |env| {
const home = std.mem.span(env);
const candidate = try std.fmt.allocPrint(allocator, "{s}/bin/javac", .{home});
if (std.Io.Dir.cwd().statFile(io, candidate, .{})) |_| {
return candidate;
} else |_| {
allocator.free(candidate);
}
}
const which = std.process.run(allocator, io, .{ .argv = &.{ "/usr/bin/which", "javac" } }) catch |e| {
std.debug.print("error: failed to locate javac via PATH: {}\n", .{e});
return error.JavacNotFound;
};
defer allocator.free(which.stderr);
errdefer allocator.free(which.stdout);
if (which.term != .exited or which.term.exited != 0) {
std.debug.print("error: javac not on PATH and $JAVA_HOME unset \u{2014} install a JDK (Android Studio bundles one at $ANDROID_STUDIO/Contents/jre)\n", .{});
allocator.free(which.stdout);
return error.JavacNotFound;
}
const trimmed = std.mem.trimEnd(u8, which.stdout, " \t\r\n");
const out = try allocator.dupe(u8, trimmed);
allocator.free(which.stdout);
return out;
}
/// Wrap a linked Android `.so` into a debug-signed APK. Steps:
/// 1. Place the .so under `lib/arm64-v8a/` in a staging directory.
/// 2. Generate (or copy) AndroidManifest.xml.
/// 3. aapt2 link → empty APK with resources/manifest.
/// 4. Append the lib/ tree via `zip`.
/// 5. zipalign → aligned APK.
/// 6. apksigner → final signed APK at `target_config.apk_path`.
pub fn createApk(allocator: std.mem.Allocator, io: std.Io, so_path: []const u8, target_config: TargetConfig) !void {
/// 3. (Optional) Compile `#jni_main` Java sources to classes.dex.
/// 4. aapt2 link → empty APK with resources/manifest.
/// 5. Append the lib/ tree via `zip`.
/// 6. (Optional) Append classes.dex if step 3 produced one.
/// 7. zipalign → aligned APK.
/// 8. apksigner → final signed APK at `target_config.apk_path`.
pub fn createApk(allocator: std.mem.Allocator, io: std.Io, so_path: []const u8, target_config: TargetConfig, jni_main_decls: []const JniMainEmission) !void {
const apk_path = target_config.apk_path orelse return error.NoApkPath;
const bundle_id = target_config.bundle_id orelse {
std.debug.print("error: --apk requires --bundle-id (e.g. co.swipelab.myapp)\n", .{});
@@ -291,6 +414,7 @@ pub fn createApk(allocator: std.mem.Allocator, io: std.Io, so_path: []const u8,
const aapt2 = try std.fmt.allocPrint(allocator, "{s}/aapt2", .{build_tools});
const zipalign = try std.fmt.allocPrint(allocator, "{s}/zipalign", .{build_tools});
const apksigner = try std.fmt.allocPrint(allocator, "{s}/apksigner", .{build_tools});
const d8 = try std.fmt.allocPrint(allocator, "{s}/d8", .{build_tools});
// Staging dir alongside the apk output.
const stage = try std.fmt.allocPrint(allocator, "{s}.stage", .{apk_path});
@@ -317,6 +441,15 @@ pub fn createApk(allocator: std.mem.Allocator, io: std.Io, so_path: []const u8,
break :blk generated;
};
// `#jni_main #jni_class("...")` decls: write .java files, compile with
// javac, produce classes.dex via d8. Slice 2 of the #jni_main pipeline:
// the .dex is bundled but the manifest still points at NativeActivity,
// so the .dex is not yet referenced at runtime (slice 3 wires it).
const has_dex = jni_main_decls.len > 0;
if (has_dex) {
try compileJniMainSources(allocator, io, stage, jni_main_decls, android_jar, d8);
}
// aapt2 link → unaligned apk with manifest + resources (none for now).
const unaligned = try std.fmt.allocPrint(allocator, "{s}.unaligned", .{apk_path});
try runProcess(allocator, io, &.{
@@ -331,6 +464,10 @@ pub fn createApk(allocator: std.mem.Allocator, io: std.Io, so_path: []const u8,
// and zip is on every macOS/Linux host by default.
try runProcessIn(allocator, io, stage, &.{ "zip", "-q", "-r", unaligned, "lib/" });
if (has_dex) {
try runProcessIn(allocator, io, stage, &.{ "zip", "-q", unaligned, "classes.dex" });
}
// Bundle the project's `./assets/` directory (if present) at the APK's
// top level so AAssetManager_open(path) at runtime can read them.
// Resolves relative to the user's CWD at invocation time — matches the

View File

@@ -32,6 +32,10 @@ TUPLES=(
# { #jni_call(...) }` must strip its body before lowering on iOS so
# emit_llvm doesn't try to use libjvm symbols the iOS SDK lacks.
"ios-sim|examples/ffi-jni-call-02-void.sx"
# #jni_main pipeline slice 2: an example carrying a `#jni_main
# #jni_class(...)` decl must continue to lower + link cleanly for
# android even without an APK build (compile-only check).
"android|examples/ffi-jni-main-01-emit.sx"
)
PASS=0

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@