From 1ae43495c2d7ed6670a94f808efb081c4cd9ed16 Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 20 May 2026 14:42:03 +0300 Subject: [PATCH] =?UTF-8?q?ffi=20#jni=5Fmain=20slice=202:=20AOT=20pipeline?= =?UTF-8?q?=20=E2=80=94=20.java=20+=20javac=20+=20d8=20=E2=86=92=20classes?= =?UTF-8?q?.dex=20in=20APK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /java//.java, javac --release 11 to /classes/, d8 --release --lib --output to produce /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). --- examples/ffi-jni-main-01-emit.sx | 38 ++++++ src/core.zig | 49 ++++++++ src/main.zig | 2 +- src/target.zig | 147 ++++++++++++++++++++++- tests/cross_compile.sh | 4 + tests/expected/ffi-jni-main-01-emit.exit | 1 + tests/expected/ffi-jni-main-01-emit.txt | 1 + 7 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 examples/ffi-jni-main-01-emit.sx create mode 100644 tests/expected/ffi-jni-main-01-emit.exit create mode 100644 tests/expected/ffi-jni-main-01-emit.txt diff --git a/examples/ffi-jni-main-01-emit.sx b/examples/ffi-jni-main-01-emit.sx new file mode 100644 index 0000000..6ddb979 --- /dev/null +++ b/examples/ffi-jni-main-01-emit.sx @@ -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_` 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(); + } +} diff --git a/src/core.zig b/src/core.zig index 9c58e3f..e1c0b62 100644 --- a/src/core.zig +++ b/src/core.zig @@ -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 = ®istry }); + 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(); } diff --git a/src/main.zig b/src/main.zig index 8f22065..5474000 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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}); } diff --git a/src/target.zig b/src/target.zig index 2b33cc8..b47a95e 100644 --- a/src/target.zig +++ b/src/target.zig @@ -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 `/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 `/java//.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 `/java//`, +/// invoke `javac` to compile to `/classes/`, then `d8` to produce +/// `/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 diff --git a/tests/cross_compile.sh b/tests/cross_compile.sh index edaf99e..f69c11e 100755 --- a/tests/cross_compile.sh +++ b/tests/cross_compile.sh @@ -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 diff --git a/tests/expected/ffi-jni-main-01-emit.exit b/tests/expected/ffi-jni-main-01-emit.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/ffi-jni-main-01-emit.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/ffi-jni-main-01-emit.txt b/tests/expected/ffi-jni-main-01-emit.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/expected/ffi-jni-main-01-emit.txt @@ -0,0 +1 @@ +