bundling: Android APK pipeline moved into sx; android.sx state-on-plat

Week 7 of /Users/agra/.claude/plans/lets-plan-to-move-splendid-pumpkin.md
plus the android.sx refactor + three sx-compiler fixes hit along the way
to get chess on Pixel 7 Pro responding to touch end-to-end.

library/modules/platform/bundle.sx now covers the Android APK shape
alongside macOS / iOS-sim / iOS-device. `android_bundle_main` discovers
the SDK ($ANDROID_HOME / $ANDROID_SDK_ROOT / $HOME/Library/Android/sdk),
picks the highest-versioned build-tools + platforms via
`process.run("ls .. | sort -V | tail -1")`, stages
`<apk>.stage/lib/arm64-v8a/<libfoo.so>`, synthesizes
AndroidManifest.xml (NativeActivity vs `#jni_main` Activity branch),
writes each `#jni_main` decl's Java source under
`<stage>/java/<pkg>/<Cls>.java`, runs javac --release 11 + d8 to
produce classes.dex, aapt2-links the unaligned APK, appends lib/ +
classes.dex + each registered asset tree via zip, zipalign + ensure
debug keystore via keytool + apksigner sign.

Compiler-side accessors (src/ir/compiler_hooks.zig + library/modules/compiler.sx):
- is_android predicate.
- set_manifest_path / manifest_path + set_keystore_path / keystore_path.
- jni_main_count / jni_main_foreign_path_at(i) /
  jni_main_java_source_at(i) surface the `#jni_main` emissions that
  the Zig createApk previously consumed directly.
- main.zig wires manifest_path, keystore_path, and the per-decl
  (foreign_path, java_source) parallel slices into BuildConfig before
  invoking the post-link callback.

CLI `--apk <path>` keeps working as a transitional alias: it now feeds
bundle_path so the existing auto-`post_link_module = "platform.bundle"`
shim fires the same way as `--bundle`. main.zig no longer calls
target.createApk directly.

Deletions in src/target.zig: createApk, compileJniMainSources,
buildJniMainManifest, buildAndroidManifest, ensureDebugKeystore,
libNameFromSoBasename, plus helpers splitForeignPath / discoverJavac /
discoverAndroidSdk / findHighestSubdir / runProcess / runProcessIn
(~400 lines). git grep returns only the obituary comment.

library/modules/platform/android.sx refactor (chess Android dependency):
- Module-level globals retired (g_app_window, g_egl_*, g_viewport_*,
  g_dpi_scale, g_should_stop, g_render_thread*, g_user_main_fn,
  g_touch_*) → AndroidPlatform struct fields.
- All sx_android_* helpers take `plat: *AndroidPlatform` as first arg.
  Render thread receives plat via pthread_create's arg.
- New `logical_w: f32 = 0.0` field. Consumers set it before init() to
  define the design width in points; `recompute_scale` derives
  `dpi_scale = pixel_w / logical_w` (or 1.0 if unset). Called on
  init / set_viewport / egl_init. drain_touches divides incoming
  physical pixel coords by dpi_scale so chess sees logical-space
  positions matching its layout. Touch lands on the right squares.

Three sx-compiler bugs hit + fixed along the way:

1. Top-level `inline if OS == .X { decls }` body decls were silently
   dropped because scanDecls/lowerDecls had no .if_expr arm. New
   `flattenComptimeConditionals` pre-pass in src/imports.zig
   (threaded via ComptimeContext from core.zig) hoists matching arms
   recursively. Regression at examples/124-inline-if-hoist-toplevel.sx.

2. Parser rejected `#import` / `#framework` inside inline-if bodies
   because parseStmt in src/parser.zig only had arms for `#insert`.
   Added the missing arms. Regression at
   examples/123-inline-if-import-in-body.sx (landed earlier).

3. JNI `Call<T>Method` switches in src/ir/emit_llvm.zig (instance /
   nonvirtual / static) were missing `.f32` rows — jfloat returns
   (e.g. MotionEvent.getX/getY) fell into the silent-undef else arm.
   Chess's sx_android_push_touch(plat, getAction(), getX(), getY())
   delivered garbage f32 coords to the touch ring, so taps landed
   nowhere recognisable. Added `.f32 => Jni.Call{Static,Nonvirtual,}FloatMethod`
   rows to all three switches; lifted unsupported-type detection
   from emit_llvm into lowerForeignMethodCall with proper
   source-spanned diagnostics (`isJniReturnTypeSupported`). Regressions
   at examples/ffi-jni-call-10-jfloat-return.sx,
   examples/ffi-jni-class-09-multi-float-args.sx,
   examples/ffi-jni-call-11-unsupported-return-diag.sx.

Stale-snapshot drift in tests/expected/ffi-objc-call-03-selector-sharing.ir
and ffi-objc-call-06-sret-return.ir picks up the new BuildOptions
accessor extern decls (is_android, set_manifest_path,
set_keystore_path, jni_main_count, jni_main_foreign_path_at,
jni_main_java_source_at). Verified diff is dead-decl-only.

Chess on Pixel 7 Pro: tap on e2 white pawn -> yellow selection +
green dots on legal e3/e4 targets; tap on e4 -> board updates with
1. e4, "Black to move" + "1. e4" in info panel.

zig build && zig build test && bash tests/run_examples.sh -> 145/145
green. bash tests/cross_compile.sh -> 7/7 green.
This commit is contained in:
agra
2026-05-23 01:28:32 +03:00
parent 5cc62e63c3
commit 632e64512b
26 changed files with 1437 additions and 567 deletions

View File

@@ -59,6 +59,7 @@ BuildOptions :: struct #compiler {
is_ios :: (self: BuildOptions) -> bool;
is_ios_device :: (self: BuildOptions) -> bool;
is_ios_simulator :: (self: BuildOptions) -> bool;
is_android :: (self: BuildOptions) -> bool;
// Framework list accessors. The bundler walks `framework_count() *
// framework_at(i)` to find each `-framework` name and recursively
@@ -70,6 +71,24 @@ BuildOptions :: struct #compiler {
framework_at :: (self: BuildOptions, i: s64) -> string;
framework_path_count :: (self: BuildOptions) -> s64;
framework_path_at :: (self: BuildOptions, i: s64) -> string;
// Android APK bundling parameters. `manifest_path` overrides the
// bundler's auto-generated AndroidManifest.xml; `keystore_path`
// overrides the default `$HOME/.android/debug.keystore`. Accessors
// return "" when unset.
set_manifest_path :: (self: BuildOptions, path: [:0]u8);
set_keystore_path :: (self: BuildOptions, path: [:0]u8);
manifest_path :: (self: BuildOptions) -> string;
keystore_path :: (self: BuildOptions) -> string;
// `#jni_main #jni_class("path") { ... }` decls collected during
// lowering. The Android bundler walks `0..jni_main_count()` and
// for each entry writes a `.java` file at
// `<stage>/java/<foreign_path>.java`, compiles via javac + d8, and
// bundles the resulting classes.dex into the APK.
jni_main_count :: (self: BuildOptions) -> s64;
jni_main_foreign_path_at :: (self: BuildOptions, i: s64) -> string;
jni_main_java_source_at :: (self: BuildOptions, i: s64) -> string;
}
build_options :: () -> BuildOptions #compiler;

View File

@@ -19,6 +19,13 @@
// (action,x,y) tuple onto a mutex-guarded queue. `poll_events`
// drains the queue into the platform's standard `Event` shape.
//
// State model: every piece of mutable Android-backend state lives on
// `AndroidPlatform` (the EGL handles, the ANativeWindow, the render
// thread, the touch ring, the frame closure, the user main fn, the
// touch mutex). Module-level globals are out — they shadow consumer
// globals on `#import` and produced an integer/float-shadowing render
// regression on chess before we eliminated them.
//
// Vulkan-compatible: same ANativeWindow drives `vkCreate*SurfaceKHR`
// without changing the lifecycle.
@@ -117,44 +124,75 @@ eglDestroyContext :: (d: *void, ctx: *void) -> u32 #foreign;
eglDestroySurface :: (d: *void, surface: *void) -> u32 #foreign;
eglTerminate :: (d: *void) -> u32 #foreign;
// ── Module-level state ──────────────────────────────────────────────────
// ── Touch ring ──────────────────────────────────────────────────────────
g_activity : *void = null; // global ref to the SxApp jobject (saved env can't outlive scope)
g_app_window : *void = null; // ANativeWindow from surfaceCreated
g_egl_display : *void = null;
g_egl_context : *void = null;
g_egl_surface : *void = null;
g_egl_config : *void = null;
g_viewport_w : s32 = 0;
g_viewport_h : s32 = 0;
// Defaults to 1.0 until a proper density query lands. Chess's pipeline
// uses `viewport_w/h` as the layout space and `dpi_scale` to scale
// rendering; mismatches cause layout drift / shrinking.
g_dpi_scale : f32 = 1.0;
g_should_stop : bool = false;
g_render_thread_started : bool = false;
g_render_thread : u64 = 0;
g_frame_fn : Closure() = ---;
g_frame_fn_set : bool = false;
// Touch event queue. Single-producer (Java UI thread) / single-consumer
// (render thread); a small ring buffer guarded by a pthread mutex is
// enough — chess only generates touches on user interaction so contention
// is rare.
TouchEvent :: struct {
action: s32;
x: f32;
y: f32;
}
g_touch_queue : [64]TouchEvent = ---;
g_touch_head : u32 = 0;
g_touch_tail : u32 = 0;
// pthread_mutex_t is 40 bytes on bionic (NDK 26+); over-size to 64 for safety.
g_touch_mutex_storage : [64]u8 = ---;
g_touch_mutex_inited : bool = false;
// ── AndroidPlatform ─────────────────────────────────────────────────────
//
// Every per-instance piece of state — EGL handles, ANativeWindow, render
// thread, touch ring + mutex, frame closure, user main fn — lives here.
// No module-level globals: a previous shape had `g_viewport_w : s32` at
// module scope, which silently shadowed chess's own
// `g_viewport_w : f32` on `#import` and caused the renderer to receive
// a logical width cast to s32 instead of the physical pixel width.
//
// `logical_w` is the consumer's design width in points (e.g. chess sets
// 414 to match an iPhone 12 layout). `begin_frame` derives `dpi_scale`
// from `pixel_w / logical_w` so the renderer sees the design canvas
// regardless of physical density. Touch coords are divided by the same
// scale before delivery so layout-side hit-testing matches.
// ── #jni_main Activity ──────────────────────────────────────────────────
AndroidPlatform :: struct {
title: [:0]u8 = "";
width: s32 = 0;
height: s32 = 0;
// Set by consumer code BEFORE `init` if a fixed design width is
// wanted (chess uses 414). When 0, the platform reports
// viewport = pixel (1:1 logical/physical) — same as the legacy
// hardcoded `dpi_scale = 1.0` behaviour.
logical_w: f32 = 0.0;
// ANativeWindow + EGL state (set up by sx_android_attach_window +
// egl_init on the render thread).
app_window: *void = null;
egl_display: *void = null;
egl_context: *void = null;
egl_surface: *void = null;
egl_config: *void = null;
// Pixel-size from ANativeWindow_get{Width,Height}; derived dpi_scale
// (pixel_w / logical_w when logical_w > 0, else 1.0).
pixel_w: s32 = 0;
pixel_h: s32 = 0;
dpi_scale: f32 = 1.0;
// Render thread lifecycle. `user_main_fn` is the consumer's `main`
// entry, invoked once EGL is current. `should_stop` is checked in
// the frame loop; `stop()` flips it.
render_thread: u64 = 0;
render_thread_started: bool = false;
user_main_fn: () -> void = ---;
should_stop: bool = false;
frame_closure: ?Closure() = null;
events: List(Event) = .{};
// Touch ring: single-producer (Java UI thread via sx_android_push_touch)
// / single-consumer (render thread via sx_android_drain_touches).
// pthread_mutex_t is 40 bytes on bionic (NDK 26+); over-size to 64
// for safety. `touch_mutex_inited` tracks lazy init on first use.
touch_queue: [64]TouchEvent = ---;
touch_head: u32 = 0;
touch_tail: u32 = 0;
touch_mutex_storage: [64]u8 = ---;
touch_mutex_inited: bool = false;
}
// ── User-facing helpers for the consumer's `#jni_main` Activity ────────
//
@@ -164,11 +202,12 @@ g_touch_mutex_inited : bool = false;
// those methods call:
//
// - `sx_android_forward_assets(env, activity)` from onCreate.
// - `sx_android_attach_window(env, holder)` from surfaceCreated.
// - `sx_android_detach_window()` from surfaceDestroyed.
// - `sx_android_set_viewport(w, h)` from surfaceChanged.
// - `sx_android_start_render_thread(main_fn)` once the surface is up.
// - `sx_android_push_touch(action, x, y)` from onTouchEvent.
// - `sx_android_attach_window(plat, env, holder)` from surfaceCreated.
// - `sx_android_detach_window(plat)` from surfaceDestroyed.
// - `sx_android_set_viewport(plat, w, h)` from surfaceChanged.
// - `sx_android_start_render_thread(plat, main_fn)` once the surface
// is up.
// - `sx_android_push_touch(plat, action, x, y)` from onTouchEvent.
// Extract the AAssetManager from the Activity and install it into the
// C file_utils so `read_file_bytes` can route through `AAssetManager_open`.
@@ -181,67 +220,69 @@ sx_android_forward_assets :: (env: *void, activity: *JContext) {
}
}
// Extract the ANativeWindow from a SurfaceHolder. Call this from your
// Activity's `surfaceCreated`. The window stays valid until
// `sx_android_detach_window` runs (typically in `surfaceDestroyed`).
sx_android_attach_window :: (env: *void, holder: *SurfaceHolder) {
// Extract the ANativeWindow from a SurfaceHolder and stash it on `plat`.
// Call this from your Activity's `surfaceCreated`. The window stays
// valid until `sx_android_detach_window` runs (typically in
// `surfaceDestroyed`).
sx_android_attach_window :: (plat: *AndroidPlatform, env: *void, holder: *SurfaceHolder) {
#jni_env(env) {
surface := holder.getSurface();
g_app_window = ANativeWindow_fromSurface(env, xx surface);
plat.app_window = ANativeWindow_fromSurface(env, xx surface);
}
}
sx_android_detach_window :: () {
if g_app_window != null {
ANativeWindow_release(g_app_window);
g_app_window = null;
sx_android_detach_window :: (plat: *AndroidPlatform) {
if plat.app_window != null {
ANativeWindow_release(plat.app_window);
plat.app_window = null;
}
}
sx_android_set_viewport :: (w: s32, h: s32) {
g_viewport_w = w;
g_viewport_h = h;
sx_android_set_viewport :: (plat: *AndroidPlatform, w: s32, h: s32) {
plat.pixel_w = w;
plat.pixel_h = h;
sx_android_recompute_scale(plat);
}
// Start the render thread that brings up EGL on `g_app_window` and calls
// the user-supplied `entry_fn` (typically the user's `main`). Safe to
// call once after `sx_android_attach_window` has set the window.
sx_android_start_render_thread :: (entry_fn: () -> void) {
if g_render_thread_started { return; }
g_user_main_fn = entry_fn;
pthread_create(@g_render_thread, null, sx_android_render_thread_entry, null);
g_render_thread_started = true;
// Start the render thread that brings up EGL on `plat.app_window` and
// calls `entry_fn` (typically the consumer's `main`). Safe to call once
// after `sx_android_attach_window` has set the window.
sx_android_start_render_thread :: (plat: *AndroidPlatform, entry_fn: () -> void) {
if plat.render_thread_started { return; }
plat.user_main_fn = entry_fn;
pthread_create(@plat.render_thread, null, sx_android_render_thread_entry, xx plat);
plat.render_thread_started = true;
}
g_user_main_fn : () -> void = null;
sx_android_render_thread_entry :: (arg: *void) -> *void {
while g_app_window == null and !g_should_stop {
plat : *AndroidPlatform = xx arg;
while plat.app_window == null and !plat.should_stop {
usleep(1000);
}
if g_should_stop { return null; }
if plat.should_stop { return null; }
if !sx_android_egl_init() {
if !sx_android_egl_init(plat) {
__android_log_print(6, "sxapp".ptr, "EGL bootstrap failed\n".ptr);
return null;
}
if g_user_main_fn != null {
g_user_main_fn();
if plat.user_main_fn != null {
fn := plat.user_main_fn;
fn();
}
null;
}
// Bring up EGL on g_app_window. Sets g_egl_display / g_egl_context /
// g_egl_surface and makes the context current. Returns false on any
// failure — caller bails on the render thread.
sx_android_egl_init :: () -> bool {
g_egl_display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if g_egl_display == EGL_NO_DISPLAY { return false; }
// Bring up EGL on `plat.app_window`. Sets the egl_* fields and makes
// the context current. Returns false on any failure — caller bails on
// the render thread.
sx_android_egl_init :: (plat: *AndroidPlatform) -> bool {
plat.egl_display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if plat.egl_display == EGL_NO_DISPLAY { return false; }
major : s32 = 0;
minor : s32 = 0;
if eglInitialize(g_egl_display, @major, @minor) == EGL_FALSE { return false; }
if eglInitialize(plat.egl_display, @major, @minor) == EGL_FALSE { return false; }
cfg_attrs : [13]s32 = .{
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT,
@@ -253,50 +294,65 @@ sx_android_egl_init :: () -> bool {
EGL_NONE,
};
num_cfg : s32 = 0;
if eglChooseConfig(g_egl_display, @cfg_attrs[0], @g_egl_config, 1, @num_cfg) == EGL_FALSE { return false; }
if eglChooseConfig(plat.egl_display, @cfg_attrs[0], @plat.egl_config, 1, @num_cfg) == EGL_FALSE { return false; }
if num_cfg < 1 { return false; }
visual_id : s32 = 0;
eglGetConfigAttrib(g_egl_display, g_egl_config, EGL_NATIVE_VISUAL_ID, @visual_id);
ANativeWindow_setBuffersGeometry(g_app_window, 0, 0, visual_id);
eglGetConfigAttrib(plat.egl_display, plat.egl_config, EGL_NATIVE_VISUAL_ID, @visual_id);
ANativeWindow_setBuffersGeometry(plat.app_window, 0, 0, visual_id);
ctx_attrs : [3]s32 = .{ EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE };
g_egl_context = eglCreateContext(g_egl_display, g_egl_config, EGL_NO_CONTEXT, @ctx_attrs[0]);
if g_egl_context == EGL_NO_CONTEXT { return false; }
plat.egl_context = eglCreateContext(plat.egl_display, plat.egl_config, EGL_NO_CONTEXT, @ctx_attrs[0]);
if plat.egl_context == EGL_NO_CONTEXT { return false; }
g_egl_surface = eglCreateWindowSurface(g_egl_display, g_egl_config, g_app_window, null);
if g_egl_surface == EGL_NO_SURFACE { return false; }
plat.egl_surface = eglCreateWindowSurface(plat.egl_display, plat.egl_config, plat.app_window, null);
if plat.egl_surface == EGL_NO_SURFACE { return false; }
if eglMakeCurrent(g_egl_display, g_egl_surface, g_egl_surface, g_egl_context) == EGL_FALSE { return false; }
if eglMakeCurrent(plat.egl_display, plat.egl_surface, plat.egl_surface, plat.egl_context) == EGL_FALSE { return false; }
g_viewport_w = ANativeWindow_getWidth(g_app_window);
g_viewport_h = ANativeWindow_getHeight(g_app_window);
plat.pixel_w = ANativeWindow_getWidth(plat.app_window);
plat.pixel_h = ANativeWindow_getHeight(plat.app_window);
sx_android_recompute_scale(plat);
true;
}
// Recompute `dpi_scale` from `pixel_w / logical_w`. Called on viewport
// changes and after EGL bringup populates pixel_w/h. Falls back to 1.0
// when consumer didn't set logical_w (1:1 logical/physical mode).
sx_android_recompute_scale :: (plat: *AndroidPlatform) {
if plat.logical_w > 0.0 and plat.pixel_w > 0 {
plat.dpi_scale = xx plat.pixel_w / plat.logical_w;
} else {
plat.dpi_scale = 1.0;
}
}
// ── Touch event queue ───────────────────────────────────────────────────
sx_android_push_touch :: (action: s32, x: f32, y: f32) {
sx_android_ensure_touch_mutex();
pthread_mutex_lock(xx @g_touch_mutex_storage[0]);
next := (g_touch_tail + 1) % 64;
if next != g_touch_head { // drop on full
g_touch_queue[g_touch_tail] = TouchEvent.{ action = action, x = x, y = y };
g_touch_tail = next;
sx_android_push_touch :: (plat: *AndroidPlatform, action: s32, x: f32, y: f32) {
sx_android_ensure_touch_mutex(plat);
pthread_mutex_lock(xx @plat.touch_mutex_storage[0]);
next := (plat.touch_tail + 1) % 64;
if next != plat.touch_head { // drop on full
plat.touch_queue[plat.touch_tail] = TouchEvent.{ action = action, x = x, y = y };
plat.touch_tail = next;
}
pthread_mutex_unlock(xx @g_touch_mutex_storage[0]);
pthread_mutex_unlock(xx @plat.touch_mutex_storage[0]);
}
sx_android_drain_touches :: (out: *List(Event)) {
sx_android_ensure_touch_mutex();
pthread_mutex_lock(xx @g_touch_mutex_storage[0]);
while g_touch_head != g_touch_tail {
t := g_touch_queue[g_touch_head];
g_touch_head = (g_touch_head + 1) % 64;
sx_android_drain_touches :: (plat: *AndroidPlatform, out: *List(Event)) {
sx_android_ensure_touch_mutex(plat);
pthread_mutex_lock(xx @plat.touch_mutex_storage[0]);
inv : f32 = if plat.dpi_scale > 0.0 then 1.0 / plat.dpi_scale else 1.0;
while plat.touch_head != plat.touch_tail {
t := plat.touch_queue[plat.touch_head];
plat.touch_head = (plat.touch_head + 1) % 64;
// MotionEvent actions: 0=DOWN, 1=UP, 2=MOVE. Map onto chess's
// existing mouse Event variants — touch becomes a left-button
// mouse on the same screen coords; delta unused on Android.
pos : Point = .{ x = t.x, y = t.y };
// mouse on the same screen coords. Coords come in as physical
// pixels; divide by dpi_scale so layout-side hit-testing
// matches its own logical-coord frames.
pos : Point = .{ x = t.x * inv, y = t.y * inv };
if t.action == 0 {
out.append(.mouse_down(.{ position = pos, button = .left }));
} else if t.action == 1 {
@@ -305,59 +361,75 @@ sx_android_drain_touches :: (out: *List(Event)) {
out.append(.mouse_moved(.{ position = pos, delta = .{ x = 0, y = 0 } }));
}
}
pthread_mutex_unlock(xx @g_touch_mutex_storage[0]);
pthread_mutex_unlock(xx @plat.touch_mutex_storage[0]);
}
sx_android_ensure_touch_mutex :: () {
if g_touch_mutex_inited { return; }
pthread_mutex_init(xx @g_touch_mutex_storage[0], null);
g_touch_mutex_inited = true;
sx_android_ensure_touch_mutex :: (plat: *AndroidPlatform) {
if plat.touch_mutex_inited { return; }
pthread_mutex_init(xx @plat.touch_mutex_storage[0], null);
plat.touch_mutex_inited = true;
}
// ── AndroidPlatform ─────────────────────────────────────────────────────
AndroidPlatform :: struct {
title: [:0]u8 = "";
width: s32 = 0;
height: s32 = 0;
events: List(Event) = .{};
}
// ── Platform impl ───────────────────────────────────────────────────────
impl Platform for AndroidPlatform {
init :: (self: *AndroidPlatform, title: [:0]u8, w: s32, h: s32) -> bool {
self.title = title;
self.width = w;
self.height = h;
sx_android_recompute_scale(self);
true;
}
// NOTE: method order must match the `Platform` protocol declaration
// in modules/platform/api.sx. The vtable is built in impl source
// order; mismatched order silently routes calls to the wrong method
// (chess on Android lost touch entirely because poll_events sat in
// begin_frame's slot, etc.).
run_frame_loop :: (self: *AndroidPlatform, frame_fn: Closure()) {
self.frame_closure = frame_fn;
// `frame_fn` is expected to call `g_plat.end_frame()` which does
// the `eglSwapBuffers` — don't swap again here or the back buffer
// is presented twice per render, alternating with the previous
// frame's contents → visible flicker.
while !self.should_stop {
frame_fn();
usleep(1000);
}
}
poll_events :: (self: *AndroidPlatform) -> []Event {
self.events.len = 0;
sx_android_drain_touches(self, @self.events);
result : []Event = ---;
result.ptr = self.events.items;
result.len = self.events.len;
result;
}
begin_frame :: (self: *AndroidPlatform) -> FrameContext {
// viewport_* is the logical canvas the renderer + layout see —
// pixel_w/h divided by dpi_scale. With logical_w unset (1.0
// scale) viewport == pixel.
inv : f32 = if self.dpi_scale > 0.0 then 1.0 / self.dpi_scale else 1.0;
FrameContext.{
viewport_w = xx g_viewport_w,
viewport_h = xx g_viewport_h,
pixel_w = g_viewport_w,
pixel_h = g_viewport_h,
dpi_scale = g_dpi_scale,
viewport_w = xx self.pixel_w * inv,
viewport_h = xx self.pixel_h * inv,
pixel_w = self.pixel_w,
pixel_h = self.pixel_h,
dpi_scale = self.dpi_scale,
delta_time = 0.016,
target_present_time = 0.0,
};
}
end_frame :: (self: *AndroidPlatform) {
if g_egl_display != null and g_egl_surface != null {
eglSwapBuffers(g_egl_display, g_egl_surface);
if self.egl_display != null and self.egl_surface != null {
eglSwapBuffers(self.egl_display, self.egl_surface);
}
}
poll_events :: (self: *AndroidPlatform) -> []Event {
self.events.len = 0;
sx_android_drain_touches(@self.events);
result : []Event = ---;
result.ptr = self.events.items;
result.len = self.events.len;
result;
}
safe_insets :: (self: *AndroidPlatform) -> EdgeInsets {
EdgeInsets.{};
}
@@ -368,30 +440,17 @@ impl Platform for AndroidPlatform {
show_keyboard :: (self: *AndroidPlatform) { }
hide_keyboard :: (self: *AndroidPlatform) { }
run_frame_loop :: (self: *AndroidPlatform, frame_fn: Closure()) {
g_frame_fn = frame_fn;
g_frame_fn_set = true;
// `frame_fn` is expected to call `g_plat.end_frame()` which does
// the `eglSwapBuffers` — don't swap again here or the back buffer
// is presented twice per render, alternating with the previous
// frame's contents → visible flicker.
while !g_should_stop {
frame_fn();
usleep(1000);
}
}
stop :: (self: *AndroidPlatform) {
g_should_stop = true;
self.should_stop = true;
}
shutdown :: (self: *AndroidPlatform) {
if g_egl_display != null {
eglMakeCurrent(g_egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
if g_egl_surface != null { eglDestroySurface(g_egl_display, g_egl_surface); g_egl_surface = null; }
if g_egl_context != null { eglDestroyContext(g_egl_display, g_egl_context); g_egl_context = null; }
eglTerminate(g_egl_display);
g_egl_display = null;
if self.egl_display != null {
eglMakeCurrent(self.egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
if self.egl_surface != null { eglDestroySurface(self.egl_display, self.egl_surface); self.egl_surface = null; }
if self.egl_context != null { eglDestroyContext(self.egl_display, self.egl_context); self.egl_context = null; }
eglTerminate(self.egl_display);
self.egl_display = null;
}
}
}

View File

@@ -43,6 +43,10 @@ bundle_main :: () -> bool {
return false;
}
if opts.is_android() {
return android_bundle_main(opts, binary, bundle, bid);
}
// Device builds without a real identity will be rejected by the
// device, so fail fast with a clear hint — matches what the legacy
// Zig path did at the top of createBundle.
@@ -517,3 +521,604 @@ codesign :: (bundle: string, identity: string, ent_path: string) -> bool {
out("error: codesign spawn failed\n");
false;
}
// =====================================================================
// Android APK pipeline.
//
// Same shape as the legacy Zig `createApk`:
// 1. Discover SDK root + highest build-tools / platforms version.
// 2. Stage `<apk>.stage/lib/arm64-v8a/<libfoo.so>`.
// 3. Use the user-supplied AndroidManifest.xml or synthesize one
// (NativeActivity shape when no `#jni_main` decl; Activity-bound
// shape pointing at the user's `#jni_main` class otherwise).
// 4. For each `#jni_main` decl: write `<stage>/java/<pkg>/<Cls>.java`,
// compile via `javac --release 11 -classpath android.jar`, then
// dex via `d8 --release --lib android.jar --output <stage>`.
// 5. `aapt2 link -I android.jar --manifest <m> -o <apk>.unaligned`.
// 6. `zip <unaligned> lib/` (from stage cwd) + `zip classes.dex` if
// a dex was produced + zip each registered asset dir.
// 7. `zipalign -f 4 <unaligned> <aligned>`.
// 8. Ensure debug keystore (via `keytool`) at $HOME/.android or
// `set_keystore_path()` override.
// 9. `apksigner sign --ks ... --out <apk> <aligned>`.
// =====================================================================
// Resolve a relative path against the current working directory at call
// time, so it survives a later `cd` into a stage dir. Absolute paths
// (leading `/`) are returned unchanged. Empty input is preserved.
absolutify :: (path: string) -> string {
if path.len == 0 { return path; }
if path[0] == 47 { return path; }
if r := run(str_to_cstr("pwd")) {
if r.exit_code != 0 { return path; }
cwd := r.stdout;
// Strip trailing newline that `pwd` emits.
if cwd.len > 0 {
if cwd[cwd.len - 1] == 10 { cwd = substr(cwd, 0, cwd.len - 1); }
}
if cwd.len == 0 { return path; }
return path_join(cwd, path);
}
path;
}
android_bundle_main :: (opts: BuildOptions, binary: string, apk_path: string, bundle_id: string) -> bool {
// The bundler `cd`s into the stage dir for `zip` steps, so any
// relative path the caller gave us would resolve against the wrong
// cwd. Pin everything to absolute paths up front.
apk_path = absolutify(apk_path);
binary = absolutify(binary);
sdk := discover_android_sdk();
if sdk.len == 0 {
out("error: cannot locate Android SDK \xe2\x80\x94 set $ANDROID_HOME\n");
return false;
}
build_tools := find_highest_subdir(path_join(sdk, "build-tools"));
if build_tools.len == 0 {
out("error: no build-tools under ");
out(sdk);
out("/build-tools\n");
return false;
}
platform_dir := find_highest_subdir(path_join(sdk, "platforms"));
if platform_dir.len == 0 {
out("error: no platforms under ");
out(sdk);
out("/platforms\n");
return false;
}
android_jar := path_join(platform_dir, "android.jar");
aapt2_path := path_join(build_tools, "aapt2");
zipalign_path := path_join(build_tools, "zipalign");
apksigner_path := path_join(build_tools, "apksigner");
d8_path := path_join(build_tools, "d8");
// Staging dir alongside the apk output.
stage := concat(apk_path, ".stage");
lib_dir := path_join(stage, "lib/arm64-v8a");
// Clean previous stage. `rm -rf` via shell until fs.sx grows
// `delete_dir_all`.
rm_cmd := concat("rm -rf \"", stage);
rm_cmd = concat(rm_cmd, "\"");
if r := run(str_to_cstr(rm_cmd)) {
if r.exit_code != 0 {
out("error: apk: failed to clean stage dir\n");
return false;
}
}
if !create_dir_all(str_to_cstr(lib_dir)) {
out("error: apk: cannot create stage lib dir\n");
return false;
}
// libsxhello.so must literally start with "lib" for Android's
// loader. The user's -o path already does (build_options enforces
// it). Copy by basename into the staging lib dir.
so_basename := basename(binary);
so_dest := path_join(lib_dir, so_basename);
if !copy_file(str_to_cstr(binary), str_to_cstr(so_dest)) {
out("error: apk: failed to copy .so into stage\n");
return false;
}
// Manifest: user-supplied or auto-generated.
manifest := opts.manifest_path();
manifest_used := "";
lib_name := lib_name_from_so_basename(so_basename);
if manifest.len > 0 {
manifest_used = manifest;
} else {
generated_xml := build_android_manifest(opts, bundle_id, lib_name);
generated_path := path_join(stage, "AndroidManifest.xml");
if !write_file(str_to_cstr(generated_path), generated_xml) {
out("error: apk: failed to write AndroidManifest.xml\n");
return false;
}
manifest_used = generated_path;
}
// Compile each `#jni_main` decl's Java source.
jm_count := opts.jni_main_count();
if jm_count > 0 {
if !compile_jni_main_sources(opts, stage, android_jar, d8_path) {
return false;
}
}
// aapt2 link → unaligned apk with manifest + resources.
unaligned := concat(apk_path, ".unaligned");
aapt_cmd := concat("\"", aapt2_path);
aapt_cmd = concat(aapt_cmd, "\" link -I \"");
aapt_cmd = concat(aapt_cmd, android_jar);
aapt_cmd = concat(aapt_cmd, "\" --manifest \"");
aapt_cmd = concat(aapt_cmd, manifest_used);
aapt_cmd = concat(aapt_cmd, "\" -o \"");
aapt_cmd = concat(aapt_cmd, unaligned);
aapt_cmd = concat(aapt_cmd, "\" 2>&1");
if r := run(str_to_cstr(aapt_cmd)) {
if r.exit_code != 0 {
out("error: aapt2 link failed:\n");
out(r.stdout);
return false;
}
} else {
out("error: aapt2 spawn failed\n");
return false;
}
// Append lib/ tree. Using the `zip` command rather than re-encoding
// the APK from scratch because aapt2 doesn't include arbitrary
// directories and zip is on every macOS/Linux host by default.
// Need to cd into stage so the relative `lib/` path is preserved
// in the zip archive.
if !run_in_dir(stage, concat("zip -q -r \"", concat(unaligned, "\" lib/"))) {
return false;
}
if jm_count > 0 {
if !run_in_dir(stage, concat("zip -q \"", concat(unaligned, "\" classes.dex"))) {
return false;
}
}
// Asset dirs go in at their `dest` path inside the APK. The Zig
// path used a hardcoded `assets/` walk; the sx form respects every
// `add_asset_dir(src, dest)` pair the user registered.
asset_count := opts.asset_dir_count();
j : s64 = 0;
while j < asset_count {
src := opts.asset_dir_src_at(j);
dest := opts.asset_dir_dest_at(j);
if !zip_asset_dir(src, dest, unaligned) {
return false;
}
j += 1;
}
// zipalign → aligned apk.
aligned := concat(apk_path, ".aligned");
align_cmd := concat("\"", zipalign_path);
align_cmd = concat(align_cmd, "\" -f 4 \"");
align_cmd = concat(align_cmd, unaligned);
align_cmd = concat(align_cmd, "\" \"");
align_cmd = concat(align_cmd, aligned);
align_cmd = concat(align_cmd, "\" 2>&1");
if r := run(str_to_cstr(align_cmd)) {
if r.exit_code != 0 {
out("error: zipalign failed:\n");
out(r.stdout);
return false;
}
} else {
out("error: zipalign spawn failed\n");
return false;
}
// Debug keystore (auto-generated on first use) + apksigner.
keystore := opts.keystore_path();
if keystore.len == 0 {
if home := env("HOME") {
keystore = path_join(home, ".android/debug.keystore");
} else {
out("error: apk: cannot locate $HOME for default keystore\n");
return false;
}
}
if !ensure_debug_keystore(keystore) {
return false;
}
sign_cmd := concat("\"", apksigner_path);
sign_cmd = concat(sign_cmd, "\" sign --ks \"");
sign_cmd = concat(sign_cmd, keystore);
sign_cmd = concat(sign_cmd, "\" --ks-pass pass:android --key-pass pass:android --ks-key-alias androiddebugkey --out \"");
sign_cmd = concat(sign_cmd, apk_path);
sign_cmd = concat(sign_cmd, "\" \"");
sign_cmd = concat(sign_cmd, aligned);
sign_cmd = concat(sign_cmd, "\" 2>&1");
if r := run(str_to_cstr(sign_cmd)) {
if r.exit_code != 0 {
out("error: apksigner failed:\n");
out(r.stdout);
return false;
}
} else {
out("error: apksigner spawn failed\n");
return false;
}
// Clean up intermediates (keep stage/ in case users want to
// inspect it).
delete_file(str_to_cstr(unaligned));
delete_file(str_to_cstr(aligned));
run(str_to_cstr(concat("rm -rf \"", concat(stage, "\""))));
out("apk: ");
out(apk_path);
out("\n");
true;
}
// ── Android helpers ──────────────────────────────────────────────────
// Run `cmd` under a `cd <dir> && ...` shell wrapping. process.run
// doesn't have a cwd arg in Phase 1A, so we compose it via the shell.
// Output is folded via `2>&1` so failures hand the user one stream.
run_in_dir :: (dir: string, cmd: string) -> bool {
wrapped := concat("cd \"", dir);
wrapped = concat(wrapped, "\" && ");
wrapped = concat(wrapped, cmd);
wrapped = concat(wrapped, " 2>&1");
if r := run(str_to_cstr(wrapped)) {
if r.exit_code != 0 {
out("error: ");
out(cmd);
out(" failed:\n");
out(r.stdout);
return false;
}
return true;
}
out("error: shell spawn failed\n");
false;
}
// Discover the Android SDK root. Honors $ANDROID_HOME /
// $ANDROID_SDK_ROOT, otherwise picks the default install location on
// macOS ($HOME/Library/Android/sdk).
discover_android_sdk :: () -> string {
if h := env("ANDROID_HOME") { return h; }
if h := env("ANDROID_SDK_ROOT") { return h; }
if home := env("HOME") {
candidate := path_join(home, "Library/Android/sdk");
if exists(str_to_cstr(candidate)) { return candidate; }
}
"";
}
// Pick the lexicographically-highest subdir of `parent`. Equivalent to
// `ls -1 <parent> | sort -V | tail -1`. Returns the full path or "".
find_highest_subdir :: (parent: string) -> string {
cmd := concat("ls -1 \"", parent);
cmd = concat(cmd, "\" 2>/dev/null | sort -V | tail -1");
if r := run(str_to_cstr(cmd)) {
if r.exit_code != 0 { return ""; }
name := r.stdout;
// Strip trailing whitespace.
while name.len > 0 {
last := name[name.len - 1];
if last == 10 { name = substr(name, 0, name.len - 1); }
else if last == 13 { name = substr(name, 0, name.len - 1); }
else if last == 32 { name = substr(name, 0, name.len - 1); }
else if last == 9 { name = substr(name, 0, name.len - 1); }
else { break; }
}
if name.len == 0 { return ""; }
return path_join(parent, name);
}
"";
}
// `libfoo.so` → `foo`. Android's `android.app.lib_name` meta-data
// wants the trimmed name; the loader prepends `lib` and appends `.so`
// at runtime.
lib_name_from_so_basename :: (basename: string) -> string {
name := basename;
if name.len > 3 {
if name[0] == 108 { // 'l'
if name[1] == 105 { // 'i'
if name[2] == 98 { // 'b'
name = substr(name, 3, name.len - 3);
}
}
}
}
if name.len > 3 {
last3 := name.len - 3;
if name[last3] == 46 { // '.'
if name[last3 + 1] == 115 { // 's'
if name[last3 + 2] == 111 { // 'o'
name = substr(name, 0, last3);
}
}
}
}
name;
}
// AndroidManifest.xml synthesizer. When the program declares a
// `#jni_main` class, the manifest points its `<activity
// android:name>` at the user's class and flips
// `android:hasCode="true"` so Android loads the bundled classes.dex.
// Otherwise it falls back to the legacy NativeActivity shape with an
// `android.app.lib_name` meta-data entry pointing at the .so.
build_android_manifest :: (opts: BuildOptions, package: string, lib_name: string) -> string {
pkg_esc := xml_escape(package);
lib_esc := xml_escape(lib_name);
if opts.jni_main_count() > 0 {
// First `#jni_main` decl drives the Activity. The foreign_path
// uses `/` separators; Java fully-qualified class names use
// `.` so we rewrite.
foreign := opts.jni_main_foreign_path_at(0);
cls := slash_to_dot(foreign);
cls_esc := xml_escape(cls);
return format(#string MANIFEST
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="{}"
android:versionCode="1"
android:versionName="1.0">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
<application android:label="{}" android:hasCode="true">
<activity
android:name="{}"
android:exported="true"
android:label="{}"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar.Fullscreen"
android:configChanges="orientation|keyboardHidden|screenSize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
MANIFEST, pkg_esc, lib_esc, cls_esc, lib_esc);
}
// NativeActivity fallback — the .so provides ANativeActivity_onCreate.
format(#string MANIFEST
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="{}"
android:versionCode="1"
android:versionName="1.0">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
<application android:label="{}" android:hasCode="false">
<activity
android:name="android.app.NativeActivity"
android:exported="true"
android:label="{}"
android:configChanges="orientation|keyboardHidden|screenSize">
<meta-data android:name="android.app.lib_name" android:value="{}" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
MANIFEST, pkg_esc, lib_esc, lib_esc, lib_esc);
}
// `co/swipelab/sxchess/SxApp` → `co.swipelab.sxchess.SxApp`.
slash_to_dot :: (path: string) -> string {
buf := cstring(path.len);
i := 0;
while i < path.len {
c := path[i];
buf[i] = if c == 47 then 46 else c; // 47 = '/', 46 = '.'
i += 1;
}
buf;
}
// Last `/`-separated component of a forward-slash path (used to split
// JNI foreign paths into pkg + class). `co/swipelab/Foo` → `Foo`.
// `Foo` → `Foo`. `dir_part` returns the part before the last slash
// (or "" if none).
last_slash_component :: (path: string) -> string {
i := path.len;
while i > 0 {
if path[i - 1] == 47 { return substr(path, i, path.len - i); }
i -= 1;
}
path;
}
dir_part :: (path: string) -> string {
i := path.len;
while i > 0 {
if path[i - 1] == 47 { return substr(path, 0, i - 1); }
i -= 1;
}
"";
}
// Write each `#jni_main` decl's `.java` source, then compile to
// classes via `javac --release 11 -classpath <android.jar>`, then dex
// the resulting class files via `d8 --release --lib <android.jar>
// --output <stage>` so `<stage>/classes.dex` lands where the
// orchestrator can zip it into the APK.
compile_jni_main_sources :: (opts: BuildOptions, stage: string, android_jar: string, d8_path: string) -> bool {
java_root := path_join(stage, "java");
classes_root := path_join(stage, "classes");
if !create_dir_all(str_to_cstr(java_root)) {
out("error: apk: cannot create java root\n");
return false;
}
if !create_dir_all(str_to_cstr(classes_root)) {
out("error: apk: cannot create classes root\n");
return false;
}
javac := discover_javac();
if javac.len == 0 {
out("error: javac not on PATH and $JAVA_HOME unset \xe2\x80\x94 install a JDK (Android Studio bundles one at $ANDROID_STUDIO/Contents/jre)\n");
return false;
}
// Compose javac + d8 arg lists by walking jni_main_decls. Each
// decl: write `<java_root>/<pkg>/<Cls>.java`, append java path to
// javac argv + class path to d8 argv.
javac_files := "";
d8_files := "";
count := opts.jni_main_count();
i : s64 = 0;
while i < count {
foreign := opts.jni_main_foreign_path_at(i);
java_source := opts.jni_main_java_source_at(i);
pkg := dir_part(foreign);
cls := last_slash_component(foreign);
pkg_dir := if pkg.len > 0 then path_join(java_root, pkg) else java_root;
if !create_dir_all(str_to_cstr(pkg_dir)) {
out("error: apk: cannot create java pkg dir\n");
return false;
}
java_path := path_join(pkg_dir, concat(cls, ".java"));
if !write_file(str_to_cstr(java_path), java_source) {
out("error: apk: cannot write .java for ");
out(foreign);
out("\n");
return false;
}
if javac_files.len > 0 { javac_files = concat(javac_files, " "); }
javac_files = concat(javac_files, concat("\"", concat(java_path, "\"")));
class_subpath := if pkg.len > 0 then path_join(pkg, concat(cls, ".class")) else concat(cls, ".class");
class_path := path_join(classes_root, class_subpath);
if d8_files.len > 0 { d8_files = concat(d8_files, " "); }
d8_files = concat(d8_files, concat("\"", concat(class_path, "\"")));
i += 1;
}
javac_cmd := concat("\"", javac);
javac_cmd = concat(javac_cmd, "\" -d \"");
javac_cmd = concat(javac_cmd, classes_root);
javac_cmd = concat(javac_cmd, "\" -classpath \"");
javac_cmd = concat(javac_cmd, android_jar);
javac_cmd = concat(javac_cmd, "\" --release 11 ");
javac_cmd = concat(javac_cmd, javac_files);
javac_cmd = concat(javac_cmd, " 2>&1");
if r := run(str_to_cstr(javac_cmd)) {
if r.exit_code != 0 {
out("error: javac failed:\n");
out(r.stdout);
return false;
}
} else {
out("error: javac spawn failed\n");
return false;
}
d8_cmd := concat("\"", d8_path);
d8_cmd = concat(d8_cmd, "\" --release --lib \"");
d8_cmd = concat(d8_cmd, android_jar);
d8_cmd = concat(d8_cmd, "\" --output \"");
d8_cmd = concat(d8_cmd, stage);
d8_cmd = concat(d8_cmd, "\" ");
d8_cmd = concat(d8_cmd, d8_files);
d8_cmd = concat(d8_cmd, " 2>&1");
if r := run(str_to_cstr(d8_cmd)) {
if r.exit_code != 0 {
out("error: d8 failed:\n");
out(r.stdout);
return false;
}
} else {
out("error: d8 spawn failed\n");
return false;
}
true;
}
// Locate `javac`. Honors `$JAVA_HOME/bin/javac` first (Android
// Studio's bundled JDK sets this on macOS), then falls back to a PATH
// lookup via `command -v`.
discover_javac :: () -> string {
if jh := env("JAVA_HOME") {
cand := path_join(jh, "bin/javac");
if exists(str_to_cstr(cand)) { return cand; }
}
if path := find_executable("javac") { return path; }
"";
}
// Zip the contents of `<src>` into the APK at `<dest>/`. Uses a
// staging copy under `.sx-tmp/apk-assets/<dest>` so we can run `zip
// -r` from a temporary cwd that produces clean entries (no `../`
// noise). Missing src is treated as "nothing to do" so projects can
// register optional asset trees.
zip_asset_dir :: (src: string, dest: string, apk: string) -> bool {
if !exists(str_to_cstr(src)) { return true; }
asset_root := str_to_cstr(".sx-tmp/apk-assets");
create_dir_all(asset_root);
rm_cmd := concat("rm -rf .sx-tmp/apk-assets/", dest);
run(str_to_cstr(rm_cmd));
parent := if dir_part(dest).len > 0 then concat(".sx-tmp/apk-assets/", dir_part(dest)) else ".sx-tmp/apk-assets";
if !create_dir_all(str_to_cstr(parent)) {
out("error: apk: cannot create asset stage dir\n");
return false;
}
cp_cmd := concat("cp -R \"", src);
cp_cmd = concat(cp_cmd, "\" \".sx-tmp/apk-assets/");
cp_cmd = concat(cp_cmd, dest);
cp_cmd = concat(cp_cmd, "\" 2>&1");
if r := run(str_to_cstr(cp_cmd)) {
if r.exit_code != 0 {
out("error: cp -R asset dir failed:\n");
out(r.stdout);
return false;
}
} else {
out("error: cp -R asset dir spawn failed\n");
return false;
}
// Make apk path absolute-ish for the cd shell wrapping. The user
// typically gives a relative path; resolve via $(pwd) into an
// absolute one so `cd .sx-tmp/apk-assets && zip <apk>` still
// references the right file.
abs_apk := if apk.len > 0 then (if apk[0] == 47 then apk else concat("$(pwd)/", apk)) else apk;
zip_cmd := concat("zip -q -r \"", abs_apk);
zip_cmd = concat(zip_cmd, "\" \"");
zip_cmd = concat(zip_cmd, dest);
zip_cmd = concat(zip_cmd, "\"");
if !run_in_dir(".sx-tmp/apk-assets", zip_cmd) { return false; }
true;
}
// Generate the Android debug keystore on first use. The defaults
// match what Android Studio creates: alias `androiddebugkey`, password
// `android` for both store and key, RSA-2048, 10000-day validity.
ensure_debug_keystore :: (keystore_path: string) -> bool {
if exists(str_to_cstr(keystore_path)) { return true; }
// mkdir -p the parent dir if needed.
parent := dir_part(keystore_path);
if parent.len > 0 {
create_dir_all(str_to_cstr(parent));
}
cmd := concat("keytool -genkeypair -keystore \"", keystore_path);
cmd = concat(cmd, "\" -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname \"CN=Android Debug,O=Android,C=US\" 2>&1");
if r := run(str_to_cstr(cmd)) {
if r.exit_code != 0 {
out("error: keytool failed:\n");
out(r.stdout);
return false;
}
return true;
}
out("error: keytool spawn failed\n");
false;
}