// Pure-NDK NativeActivity backend for Android. // // Linking is per-target via the consumer's build.sx — for Android we // don't need explicit framework adds because `sx build --target android` // already links -llog -landroid -lEGL -lGLESv3 and bundles // native_app_glue.c. The file compiles cleanly on every target because // every NDK-touching path is gated by `inline if OS == .android`. // // Entry-point contract (deliberately kept symmetric with iOS): // - The user writes both `main` and `android_main` at top level. // - The user's `android_main(app)` calls `sx_android_bootstrap(app)` // once (to stash the NDK app pointer in this module's globals), // then calls `main()`. The body of `android_main` should be // `inline if OS == .android`-gated; on non-Android targets it // compiles to dead code, harmless because nothing references the // symbol. // - User's `main` constructs an `AndroidPlatform`, calls `init`, // then `run_frame_loop(closure)` which drives the looper until // destroyRequested. This file never names `main`. #import "modules/std.sx"; #import "modules/compiler.sx"; #import "modules/ui/types.sx"; #import "modules/ui/events.sx"; #import "modules/platform/types.sx"; #import "modules/platform/api.sx"; // JNI bridge for system-bar inset queries. The .c lives in the library's // vendor area; the compiler resolves the `#source` path through the // stdlib search list so consumers don't need to vendor a copy. #import c { #source "vendors/sx_android_jni/sx_android_jni.c"; }; // ── Foreign declarations ──────────────────────────────────────────────── __android_log_print :: (prio: s32, tag: *u8, fmt: *u8) -> s32 #foreign; usleep :: (us: u32) -> s32 #foreign; ALooper_pollOnce :: (ms: s32, outFd: *s32, outEvents: *s32, outData: **void) -> s32 #foreign; ANativeWindow_getWidth :: (window: *void) -> s32 #foreign; ANativeWindow_getHeight :: (window: *void) -> s32 #foreign; AConfiguration_getDensity :: (config: *void) -> s32 #foreign; // Input event APIs (libandroid). Touch motion arrives via the queued // AInputEvent stream that native_app_glue pumps through `onInputEvent`. AInputEvent_getType :: (event: *void) -> s32 #foreign; AMotionEvent_getAction :: (event: *void) -> s32 #foreign; AMotionEvent_getX :: (event: *void, pointer_index: u64) -> f32 #foreign; AMotionEvent_getY :: (event: *void, pointer_index: u64) -> f32 #foreign; // Glue bridge from vendors/sx_android_jni/sx_android_jni.c. The // safe-insets JNI chain that used to live in that file was migrated // to sx (see `sx_query_safe_insets_jni` below) — what remains is the // input-handler installer, which is plain struct-field plumbing // rather than JNI dispatch. sx_android_install_input_handler :: (app: *void, handler: (*void, *void) -> s32) -> void #foreign; // JavaVM vtable indirection — used to attach the calling thread to // the JVM and recover a `JNIEnv*` for it. `#jni_call` only handles // `JNIEnv*` dispatch (a different vtable), so the JavaVM hop is // hand-rolled here. // // Slot indices match `JNIInvokeInterface_` in ``: // 3 DestroyJavaVM, 4 AttachCurrentThread, 5 DetachCurrentThread, // 6 GetEnv, 7 AttachCurrentThreadAsDaemon. JNI_VERSION_1_6 :: 0x00010006; // Byte offsets into `ANativeActivity` on 64-bit Android (the only // Android ABI we target). `vm` is the JavaVM*, `clazz` is the // activity's jobject. The C struct layout in // `` is the source of truth; these // offsets MUST track that. ANATIVEACTIVITY_VM_OFFSET :: 8; ANATIVEACTIVITY_CLAZZ_OFFSET :: 24; // Load a `*void` field at `base + byte_offset`. Plumbing for raw // struct access from foreign pointers. sx_load_ptr_at :: (base: *void, byte_offset: usize) -> *void { addr : usize = xx base; slot : **void = xx (addr + byte_offset); slot.*; } // Load a JavaVM function pointer at the given vtable slot index. // `vm` is the `JavaVM*` (which points to `JNIInvokeInterface*`), so // the indirection is `*vm + slot * sizeof(ptr)`. sx_load_javavm_fn :: (vm: *void, slot: usize) -> *void { vtable_pp : **void = xx vm; vtable : *void = vtable_pp.*; addr : usize = xx vtable; fn_slot : **void = xx (addr + slot * 8); fn_slot.*; } // Attach the current thread to the JVM if needed, hand back a // `JNIEnv*`. `out_attached` is set to true when this call had to do // the attach (caller should match with `sx_android_detach_env`). // Returns null on failure (no VM, or attach refused). sx_android_get_env :: (activity: *void, out_attached: *bool) -> *void { inline if OS != .android { return null; } out_attached.* = false; if activity == null { return null; } vm := sx_load_ptr_at(activity, ANATIVEACTIVITY_VM_OFFSET); if vm == null { return null; } env : *void = null; get_env_fn : (*void, **void, s32) -> s32 = xx sx_load_javavm_fn(vm, 6); if get_env_fn(vm, @env, xx JNI_VERSION_1_6) == 0 { return env; } attach_fn : (*void, **void, *void) -> s32 = xx sx_load_javavm_fn(vm, 4); if attach_fn(vm, @env, null) != 0 { return null; } out_attached.* = true; env; } sx_android_detach_env :: (activity: *void) { inline if OS != .android { return; } if activity == null { return; } vm := sx_load_ptr_at(activity, ANATIVEACTIVITY_VM_OFFSET); if vm == null { return; } detach_fn : (*void) -> s32 = xx sx_load_javavm_fn(vm, 5); detach_fn(vm); } // Read the activity's `clazz` jobject (the Java-side activity // reference). Wrapper around the raw byte-offset load so the call // site reads naturally. sx_android_activity_clazz :: (activity: *void) -> *void { inline if OS != .android { return null; } if activity == null { return null; } sx_load_ptr_at(activity, ANATIVEACTIVITY_CLAZZ_OFFSET); } // Declarative JNI class bindings for the safe-insets dispatch chain // live in a named sub-module so they don't collide with consumer-side // types (e.g. `modules/ui/view.sx`'s `View` protocol). The compiler // registers the foreign-class decls inside under both qualified // (`Jni.Activity`) and bare (`Activity`) names — receiver types use // the qualified form, cross-class refs in method signatures use the // bare form. Jni :: #import "modules/platform/android_jni.sx"; // sx-side reimplementation of the JNI dispatch chain. Caller provides // an already-attached `JNIEnv*` and the activity's `clazz` jobject // (cast to `*Jni.Activity` so the method-call DSL can route it through // the foreign-class registry). Outputs physical-pixel insets. sx_query_safe_insets_jni :: (env: *void, activity: *Jni.Activity, top: *s32, left: *s32, bottom: *s32, right: *s32) -> void { inline if OS != .android { return; } top.* = 0; left.* = 0; bottom.* = 0; right.* = 0; if activity == null { return; } #jni_env(env) { window := activity.getWindow(); if window == null { return; } decor := window.getDecorView(); if decor == null { return; } insets := decor.getRootWindowInsets(); if insets == null { return; } top.* = insets.getSystemWindowInsetTop(); left.* = insets.getSystemWindowInsetLeft(); bottom.* = insets.getSystemWindowInsetBottom(); right.* = insets.getSystemWindowInsetRight(); } } // EGL — display/surface/context/config are opaque to us. eglGetDisplay :: (display_id: *void) -> *void #foreign; eglInitialize :: (display: *void, major: *s32, minor: *s32) -> u32 #foreign; eglChooseConfig :: (display: *void, attrib_list: *s32, configs: **void, config_size: s32, num_config: *s32) -> u32 #foreign; eglCreateContext :: (display: *void, config: *void, share: *void, attrib_list: *s32) -> *void #foreign; eglCreateWindowSurface:: (display: *void, config: *void, window: *void, attrib_list: *s32) -> *void #foreign; eglMakeCurrent :: (display: *void, draw: *void, read: *void, ctx: *void) -> u32 #foreign; eglSwapBuffers :: (display: *void, surface: *void) -> u32 #foreign; eglDestroySurface :: (display: *void, surface: *void) -> u32 #foreign; eglDestroyContext :: (display: *void, ctx: *void) -> u32 #foreign; eglTerminate :: (display: *void) -> u32 #foreign; clock_gettime :: (clk_id: s32, ts: *void) -> s32 #foreign; // ── Constants ────────────────────────────────────────────────────────── EGL_NONE :s32: 0x3038; EGL_SURFACE_TYPE :s32: 0x3033; EGL_WINDOW_BIT :s32: 0x0004; EGL_RENDERABLE_TYPE :s32: 0x3040; EGL_OPENGL_ES2_BIT :s32: 0x0004; EGL_OPENGL_ES3_BIT :s32: 0x0040; EGL_BLUE_SIZE :s32: 0x3022; EGL_GREEN_SIZE :s32: 0x3023; EGL_RED_SIZE :s32: 0x3024; EGL_DEPTH_SIZE :s32: 0x3025; EGL_CONTEXT_CLIENT_VERSION :s32: 0x3098; // AInputEvent / AMotionEvent constants used by the input handler. AINPUT_EVENT_TYPE_MOTION :s32: 2; AMOTION_EVENT_ACTION_MASK :s32: 0xff; AMOTION_EVENT_ACTION_DOWN :s32: 0; AMOTION_EVENT_ACTION_UP :s32: 1; AMOTION_EVENT_ACTION_MOVE :s32: 2; AMOTION_EVENT_ACTION_CANCEL :s32: 3; // CLOCK_MONOTONIC = 1 on linux/bionic. Used for delta_time + target_present_time. CLOCK_MONOTONIC :s32: 1; // android_app + android_poll_source field offsets (NDK 29, arm64). // Recompute via offsetof() on a host with the NDK headers if you suspect // the layout has changed (see examples/99-android-egl-clear.sx's notes). APP_OFF_window :s64: 72; APP_OFF_destroyRequested :s64: 100; APP_OFF_config :s64: 32; // AConfiguration* — follows activity (24). SRC_OFF_process :s64: 16; // id(4) + pad(4) + app*(8) = process fn-ptr // timespec on linux/aarch64: tv_sec (s64) + tv_nsec (s64). TimeSpec :: struct { sec: s64; nsec: s64; } // ── Globals ──────────────────────────────────────────────────────────── // `g_android_app` is populated by `sx_android_bootstrap` before // `AndroidPlatform.run_frame_loop` reads it. `g_android_plat` is set // by `AndroidPlatform.init` so future hooks (signal handlers, JNI // callbacks) can find the live platform. g_android_app : *void = null; g_android_plat : *AndroidPlatform = null; // `app->activity` (ANativeActivity*) at byte 24. // `activity->assetManager` at byte 64 inside ANativeActivity. APP_OFF_activity :s64: 24; ACTIVITY_OFF_assetManager :s64: 64; ACTIVITY_OFF_internalData :s64: 32; // AAssetManager handle the user's `android_main` stashes via the // bootstrap. Consumers that want to read bundled APK assets (font.ttf, // piece sprites, level data, ...) read this and feed it into their own // file-IO shim — e.g. chess wires it through `vendors/file_utils.c` // which routes `read_file_bytes` through AAssetManager_open on Android. g_android_asset_manager : *void = null; g_android_internal_path : *u8 = null; g_android_config : *void = null; g_android_activity : *void = null; // ── Bootstrap (called by user's `android_main`) ──────────────────────── // Stashes the NDK app pointer the OS handed to `android_main(app)` so // the rest of the platform module can find it. Single responsibility — // the user's `android_main` calls this once, then calls their own // `main()` to enter the normal cross-platform setup flow. // // Also extracts `AAssetManager*` and `internalDataPath` from the // ANativeActivity that the app pointer carries — these are the two // pieces consumers need to touch APK assets / per-app storage on // Android. Reachable as `g_android_asset_manager` / `g_android_internal_path`. sx_android_bootstrap :: (app: *void) { inline if OS == .android { g_android_app = app; base : s64 = xx app; activity_pp : **void = xx (base + APP_OFF_activity); activity_ptr := activity_pp.*; g_android_activity = activity_ptr; if activity_ptr != null { act_base : s64 = xx activity_ptr; mgr_pp : **void = xx (act_base + ACTIVITY_OFF_assetManager); g_android_asset_manager = mgr_pp.*; path_pp : **u8 = xx (act_base + ACTIVITY_OFF_internalData); g_android_internal_path = path_pp.*; } cfg_pp : **void = xx (base + APP_OFF_config); g_android_config = cfg_pp.*; } } // ── Helpers ──────────────────────────────────────────────────────────── read_ptr :: (base: s64, off: s64) -> *void { inline if OS != .android { return null; } p : **void = xx (base + off); p.*; } read_s32 :: (base: s64, off: s64) -> s32 { inline if OS != .android { return 0; } p : *s32 = xx (base + off); p.*; } monotonic_seconds :: () -> f64 { inline if OS != .android { return 0.0; } ts : TimeSpec = .{}; clock_gettime(CLOCK_MONOTONIC, xx @ts); (xx ts.sec) + (xx ts.nsec) / 1000000000.0; } // ── Platform implementation ──────────────────────────────────────────── AndroidPlatform :: struct { // EGL state — created when ANativeWindow arrives via the event loop. egl_display: *void = null; egl_surface: *void = null; egl_context: *void = null; egl_config: *void = null; // Latest known dimensions reported by the window. Refreshed each frame // from ANativeWindow_getWidth/Height — cheap and avoids missing resize // events when the user rotates the device. pixel_w: s32 = 0; pixel_h: s32 = 0; dpi_scale: f32 = 1.0; delta_time: f32 = 0.016; last_frame_time: f64 = 0.0; // User's per-frame closure stored when run_frame_loop is called. // Optional sentinel-shape — `null` means "no closure yet". frame_closure: ?Closure() = null; events: List(Event) = .{}; last_touch: Point = .{}; touch_active: bool = false; stop_requested: bool = false; safe_top: f32 = 0.0; safe_left: f32 = 0.0; safe_bottom: f32 = 0.0; safe_right: f32 = 0.0; safe_insets_queried: bool = false; keyboard_visible: bool = false; keyboard_height: f32 = 0.0; } impl Platform for AndroidPlatform { // title/w/h are advisory only — the OS owns the surface dimensions. // We register the platform globally so android_main path can find it. init :: (self: *AndroidPlatform, title: [:0]u8, w: s32, h: s32) -> bool { inline if OS != .android { return false; } g_android_plat = self; if g_android_config != null { density := AConfiguration_getDensity(g_android_config); if density > 0 { self.dpi_scale = xx density / 160.0; } } true; } run_frame_loop :: (self: *AndroidPlatform, frame_fn: Closure()) { inline if OS == .android { self.frame_closure = frame_fn; if g_android_app != null { sx_android_install_input_handler(g_android_app, sx_android_input_event); } android_run_loop(self); } } poll_events :: (self: *AndroidPlatform) -> []Event { out : []Event = .{ ptr = xx self.events.items, len = self.events.len }; // Drain after exposing the slice — the user iterates synchronously // before returning to the run loop, and new touches won't arrive // until the next ALooper_pollOnce drains the input queue. Setting // len=0 keeps the backing buffer alive so the next append reuses // the same allocation. self.events.len = 0; out; } begin_frame :: (self: *AndroidPlatform) -> FrameContext { viewport_w_f : f32 = xx self.pixel_w; viewport_h_f : f32 = xx self.pixel_h; if self.dpi_scale > 0.0 { viewport_w_f = viewport_w_f / self.dpi_scale; viewport_h_f = viewport_h_f / self.dpi_scale; } FrameContext.{ viewport_w = viewport_w_f, viewport_h = viewport_h_f, pixel_w = self.pixel_w, pixel_h = self.pixel_h, dpi_scale = self.dpi_scale, delta_time = self.delta_time, target_present_time = 0.0, }; } end_frame :: (self: *AndroidPlatform) { inline if OS == .android { if self.egl_surface != null { eglSwapBuffers(self.egl_display, self.egl_surface); } } } safe_insets :: (self: *AndroidPlatform) -> EdgeInsets { inline if OS == .android { // Query once after EGL is up — getRootWindowInsets() returns // null until the window has been attached, so calling at // bootstrap is too early. if !self.safe_insets_queried and g_android_activity != null and self.egl_surface != null { t : s32 = 0; l : s32 = 0; b : s32 = 0; r : s32 = 0; attached : bool = false; env := sx_android_get_env(g_android_activity, @attached); if env != null { clazz : *Jni.Activity = xx sx_android_activity_clazz(g_android_activity); sx_query_safe_insets_jni(env, clazz, @t, @l, @b, @r); if attached { sx_android_detach_env(g_android_activity); } } inv : f32 = if self.dpi_scale > 0.0 then 1.0 / self.dpi_scale else 1.0; self.safe_top = xx t * inv; self.safe_left = xx l * inv; self.safe_bottom = xx b * inv; self.safe_right = xx r * inv; self.safe_insets_queried = true; } } EdgeInsets.{ top = self.safe_top, left = self.safe_left, bottom = self.safe_bottom, right = self.safe_right, }; } keyboard :: (self: *AndroidPlatform) -> KeyboardState { KeyboardState.{ visible = self.keyboard_visible, height = self.keyboard_height, }; } show_keyboard :: (self: *AndroidPlatform) { // TODO: InputMethodManager.showSoftInput via JNI. } hide_keyboard :: (self: *AndroidPlatform) { // TODO: InputMethodManager.hideSoftInputFromWindow via JNI. } stop :: (self: *AndroidPlatform) { self.stop_requested = true; } shutdown :: (self: *AndroidPlatform) { inline if OS == .android { if self.egl_display != null { eglMakeCurrent(self.egl_display, null, null, null); if self.egl_surface != null { eglDestroySurface(self.egl_display, self.egl_surface); } if self.egl_context != null { eglDestroyContext(self.egl_display, self.egl_context); } eglTerminate(self.egl_display); self.egl_display = null; self.egl_surface = null; self.egl_context = null; } } } } // ── Internal: input bridge ───────────────────────────────────────────── // // native_app_glue's process_input loop calls this once per AInputEvent // pulled off the input queue. Single touch only for now — point-down / // move / point-up — translated to sx mouse_down / mouse_moved / // mouse_up so the existing handle_event chain (drag, scroll, square // selection) Just Works. Coordinates from AMotionEvent_get{X,Y} are // physical pixels; divide by dpi_scale before publishing so the // layout-side hit-testing matches its own logical-coord frames. // // Returns 1 (consumed) for motion events, 0 for everything else so // native_app_glue still routes key events through the default handler. sx_android_input_event :: (app: *void, event: *void) -> s32 { inline if OS != .android { return 0; } if event == null { return 0; } if g_android_plat == null { return 0; } if AInputEvent_getType(event) != AINPUT_EVENT_TYPE_MOTION { return 0; } plat := g_android_plat; raw_action := AMotionEvent_getAction(event); action := raw_action & AMOTION_EVENT_ACTION_MASK; px := AMotionEvent_getX(event, 0); py := AMotionEvent_getY(event, 0); inv : f32 = if plat.dpi_scale > 0.0 then 1.0 / plat.dpi_scale else 1.0; pos : Point = .{ x = px * inv, y = py * inv }; if action == AMOTION_EVENT_ACTION_DOWN { plat.events.append(.mouse_down(.{ position = pos, button = .left })); plat.last_touch = pos; plat.touch_active = true; } else if action == AMOTION_EVENT_ACTION_MOVE { delta : Point = .{ x = pos.x - plat.last_touch.x, y = pos.y - plat.last_touch.y, }; plat.events.append(.mouse_moved(.{ position = pos, delta = delta })); plat.last_touch = pos; } else if action == AMOTION_EVENT_ACTION_UP { plat.events.append(.mouse_up(.{ position = pos, button = .left })); plat.touch_active = false; } else if action == AMOTION_EVENT_ACTION_CANCEL { plat.events.append(.mouse_up(.{ position = pos, button = .left })); plat.touch_active = false; } 1; } // ── Internal: event loop + EGL bringup ───────────────────────────────── android_run_loop :: (self: *AndroidPlatform) { inline if OS != .android { return; } if g_android_app == null { return; } app_base : s64 = xx g_android_app; out_fd : s32 = 0; out_events : s32 = 0; out_data : *void = null; self.last_frame_time = monotonic_seconds(); while self.stop_requested == false { // Drain pending events. Non-blocking pollOnce is mandatory on // Pixel 7 Pro + Android 16 — pollOnce(-1) blows the stack inside // Looper::pollOnce there (see examples/99-android-egl-clear.sx's // notes for the investigation). ret := ALooper_pollOnce(0, @out_fd, @out_events, @out_data); if ret >= 0 { if out_data != null { src_base : s64 = xx out_data; process_addr := read_ptr(src_base, SRC_OFF_process); if process_addr != null { process_fn : (*void, *void) -> void = xx process_addr; process_fn(g_android_app, out_data); } } } if read_s32(app_base, APP_OFF_destroyRequested) != 0 { break; } window := read_ptr(app_base, APP_OFF_window); if window != null and self.egl_surface == null { if android_setup_egl(self, window) == false { break; } } if self.egl_surface != null { // Refresh dimensions every tick — handles rotations cleanly. self.pixel_w = ANativeWindow_getWidth(window); self.pixel_h = ANativeWindow_getHeight(window); now := monotonic_seconds(); dt : f64 = now - self.last_frame_time; if dt > 0.0 { self.delta_time = xx dt; } self.last_frame_time = now; // `if let`-style unwrap: only invoke if the closure was set. // User's frame closure is expected to call plat.end_frame() // which swaps — matches uikit.sx's begin/end contract. if frame_fn := self.frame_closure { frame_fn(); } } usleep(16000); } } android_setup_egl :: (self: *AndroidPlatform, window: *void) -> bool { inline if OS != .android { return false; } self.egl_display = eglGetDisplay(null); if self.egl_display == null { return false; } major : s32 = 0; minor : s32 = 0; if eglInitialize(self.egl_display, @major, @minor) == 0 { return false; } attribs : [13]s32 = .{ EGL_SURFACE_TYPE, EGL_WINDOW_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, EGL_BLUE_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_RED_SIZE, 8, EGL_DEPTH_SIZE, 0, EGL_NONE, }; num_config : s32 = 0; if eglChooseConfig(self.egl_display, @attribs[0], @self.egl_config, 1, @num_config) == 0 or num_config < 1 { return false; } ctx_attribs : [3]s32 = .{ EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE }; self.egl_context = eglCreateContext(self.egl_display, self.egl_config, null, @ctx_attribs[0]); if self.egl_context == null { return false; } self.egl_surface = eglCreateWindowSurface(self.egl_display, self.egl_config, window, null); if self.egl_surface == null { return false; } if eglMakeCurrent(self.egl_display, self.egl_surface, self.egl_surface, self.egl_context) == 0 { return false; } self.pixel_w = ANativeWindow_getWidth(window); self.pixel_h = ANativeWindow_getHeight(window); true; }