Files
android-ui-dogfooding/main.odin
T
IllusionandClaude Opus 5 5776c4924d fix: don't start the app from main() on android; use the real inset retry
main() must not run real_main on android. The library's DT_INIT points at Odin's
_odin_entry_point, which the loader runs the moment NativeActivity dlopens
libmain.so -- before the glue calls android_main, so context.user_ptr is nil and
backend_init faults writing app.onAppCmd. The proc still has to exist: package
main requires it and it is the desktop entry point.

The insets retry now uses request_render_at. A plain request_render made before
the build is consumed by the frame it is running in, so it schedules nothing and
the loop parks with insets unresolved; it only appeared to work because the
first frames redraw anyway while the tree settles.

Guards the JNI-only paths (show_toast, refresh_insets) behind the android
subtarget so the shared real_main is usable on desktop. refresh_insets returns
true there rather than false: zero insets are correct where there are no system
bars, and returning false would retry every frame forever.

Also declares appCategory=game, and enables the engine's frame timings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 20:30:45 +02:00

203 lines
6.8 KiB
Odin

package main
import "shared:android"
import "base:runtime"
import "core:time"
import engine "shared:zephr"
import "jni"
device_model: string
device_sdk: i32
toast_ctx: Context
main_activity: Activity
Edge_Insets :: struct {
left, top, right, bottom: f32
}
insets: Edge_Insets
insets_valid: bool
main :: proc() {
// Android must not start the app from here. This library's DT_INIT points at
// Odin's _odin_entry_point, which the dynamic loader runs the moment
// NativeActivity dlopens libmain.so -- long before the glue spawns its
// thread and calls android_main, and so before context.user_ptr holds the
// android_app. Running real_main here reaches backend_init with a nil app
// and faults writing app.onAppCmd.
//
// The proc still has to exist: package main requires it, and it is the
// desktop entry point.
when ODIN_PLATFORM_SUBTARGET != .Android {
real_main()
}
}
real_main :: proc() {
engine.init("", "", {480, 1000}, false, "")
defer engine.deinit()
_, font_ok := engine.ui_text_register_font(#load("./assets/Roboto-VariableFont_wdth_wght.ttf"))
ensure(font_ok)
// Material Symbols, subset to the glyphs the app bar uses: 1.3KB of a 356KB
// font. Its codepoints are Private Use Area, so nothing else claims them and
// the shaper's per-codepoint fallback routes them here on its own. ☰ and ⋮
// were rendering as tofu because Roboto has no glyph for either.
_, icon_font_ok := engine.ui_text_register_font(#load("./assets/material_icons.ttf"))
ensure(icon_font_ok)
m3_init_dark()
// Only draw when something actually changed. Without this the loop renders
// flat out forever, which on a phone is straight battery burn.
engine.set_render_on_change(true)
// Rolling work-vs-swap timings to logcat, to tell a slow frame apart from a
// frame that is merely waiting on vsync.
engine.set_frame_stats(true)
for !engine.should_quit() {
engine.frame_start()
defer engine.frame_end()
e := engine.iter_events()
for e != nil {
#partial switch e.type {
case .WINDOW_CLOSED:
engine.quit()
case .WINDOW_RESIZED:
insets_valid = false
}
e = engine.iter_events()
}
if !insets_valid {
insets_valid = refresh_insets()
if !insets_valid {
// The decor view attaches asynchronously and emits no event when
// it does, so the retry has to drive itself.
//
// Deliberately request_render_at, not request_render: a plain
// request made here -- before the build -- is consumed by the
// very frame it is running in, so it schedules nothing and the
// loop parks with insets still unresolved. A timed wake is the
// one that survives into the next frame. It only works today
// because the first frames redraw anyway while the tree settles.
engine.request_render_at(engine.get_time() + 16 * time.Millisecond)
}
}
window_size := engine.get_window_size()
engine.ui_begin_build(window_size)
engine.ui_text_set_default_pixel_size(16) // dp
engine.ui_push_text_color(0xFFFFFF)
draw_ui()
engine.ui_pop_text_color()
engine.ui_end_build()
engine.ui_draw(engine.get_projection())
}
}
@export
android_main :: proc "c" (state: ^android.android_app) {
context = runtime.default_context()
context.user_ptr = state
// Static field reads only -- safe from the engine's thread. Anything
// touching the UI toolkit (Toast, dialogs) needs the main looper instead.
if jni.init(state) {
device_model, _ = build_model()
device_sdk, _ = build_sdk_int()
toast_ctx = Context(jni.activity(state))
main_activity = Activity(jni.activity(state))
}
real_main()
}
draw_ui :: proc() {
m3_demo_screen()
}
// Toast needs a Java looper on this thread. Preparing one is a one-time cost
// and the native ALooper the engine polls is unaffected -- they're separate.
show_toast :: proc(msg: string) {
// The bindings all fail closed without a VM, so this would be harmless off
// Android -- just a pointless chain of JNI calls on every tap. Compile it
// out instead of relying on that.
when ODIN_PLATFORM_SUBTARGET == .Android {
if l, ok := looper_my_looper(); ok && l == nil {
looper_prepare()
}
toast, ok := toast_make_text(toast_ctx, msg, 0) // 0 = Toast.LENGTH_SHORT
if !ok {
return
}
toast_show(toast)
}
}
// Walks Activity -> Window -> decor View -> WindowInsets. Returns false while
// the decor view is still unattached, which is normal for the first few frames.
refresh_insets :: proc() -> bool {
// Returning true, not false: zero insets are the correct answer where there
// are no system bars, and the caller retries every frame until this succeeds
// -- requesting a render each time. Failing here would pin the desktop loop
// awake forever, which is exactly what render_on_change is meant to avoid.
when ODIN_PLATFORM_SUBTARGET != .Android {
return true
}
// This runs every frame until it succeeds, and each call below returns a
// local ref. Without a frame to scope them the ref table fills and aborts.
env := jni.env()
if !jni.push_frame(env) {
return false
}
defer jni.pop_frame(env)
win := activity_get_window(main_activity) or_return
decor := window_get_decor_view(win) or_return
wi := view_get_root_window_insets(decor) or_return
if wi == nil {
return false
}
// API 30+ typed insets. Below that both calls fail and we fall through.
if mask, ok := insets_type_system_bars(); ok {
if cut, got_cut := insets_type_display_cutout(); got_cut {
mask |= cut
}
if ins, got := window_insets_get(wi, mask); got && ins != nil {
l := insets_left(ins) or_return
t := insets_top(ins) or_return
r := insets_right(ins) or_return
b := insets_bottom(ins) or_return
insets = to_points({f32(l), f32(t), f32(r), f32(b)})
return true
}
}
l := window_insets_sys_left(wi) or_return
t := window_insets_sys_top(wi) or_return
r := window_insets_sys_right(wi) or_return
b := window_insets_sys_bottom(wi) or_return
insets = to_points({f32(l), f32(t), f32(r), f32(b)})
return true
}
// WindowInsets are reported in physical pixels, but layout is in dp.
to_points :: proc(v: Edge_Insets) -> Edge_Insets {
s := engine.get_backing_scale()
if s <= 0 {
s = 1
}
return {v.left / s, v.top / s, v.right / s, v.bottom / s}
}