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:
38
examples/ffi-jni-main-01-emit.sx
Normal file
38
examples/ffi-jni-main-01-emit.sx
Normal 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();
|
||||
}
|
||||
}
|
||||
49
src/core.zig
49
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();
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
147
src/target.zig
147
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 `<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
|
||||
|
||||
@@ -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
|
||||
|
||||
1
tests/expected/ffi-jni-main-01-emit.exit
Normal file
1
tests/expected/ffi-jni-main-01-emit.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
1
tests/expected/ffi-jni-main-01-emit.txt
Normal file
1
tests/expected/ffi-jni-main-01-emit.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user