From 5b4cc6f1a6455a4373825a65aaa14175a2c4f832 Mon Sep 17 00:00:00 2001 From: tangledcircuit Date: Sun, 12 Jul 2026 18:26:48 +0000 Subject: [PATCH 1/5] fix(android): unbox NaN-boxed UI callbacks + missing text/android link symbols Button presses crashed after UI open (RenderThread destroyed-mutex / SIGSEGV) because invoke* cast NaN-box bits to pointers. Match iOS/macOS: use js_nanbox_get_pointer in callback, pointer, drag_drop, state onChange, and app activate/terminate paths. Leave render_for_each raw bitcast (documented non-nanbox). Also export perry_ui_text_create_with_id / perry_ui_set_text on Android (parity with other platforms for Text(id)/setText), and link -landroid so ANativeWindow_fromSurface resolves at dlopen. Verified on Cuttlefish x86_64 (Start/Pause/Reset stopwatch) and phone arm64. --- crates/perry-ui-android/src/app.rs | 6 ++- crates/perry-ui-android/src/callback.rs | 13 +++--- crates/perry-ui-android/src/drag_drop.rs | 5 ++- .../perry-ui-android/src/ffi/basic_widgets.rs | 42 +++++++++++++++++++ crates/perry-ui-android/src/pointer.rs | 3 +- crates/perry-ui-android/src/state.rs | 4 +- .../commands/compile/link/build_and_run.rs | 6 ++- 7 files changed, 67 insertions(+), 12 deletions(-) diff --git a/crates/perry-ui-android/src/app.rs b/crates/perry-ui-android/src/app.rs index 857b6a758e..9681fbcd62 100644 --- a/crates/perry-ui-android/src/app.rs +++ b/crates/perry-ui-android/src/app.rs @@ -359,6 +359,7 @@ pub fn add_keyboard_shortcut(_key_ptr: *const u8, _modifiers: f64, _callback: f6 extern "C" { fn js_closure_call1(closure: *const u8, arg: f64) -> f64; + fn js_nanbox_get_pointer(value: f64) -> i64; } thread_local! { @@ -406,7 +407,8 @@ pub fn on_terminate(callback: f64) { pub fn handle_activate() { ON_ACTIVATE_CALLBACK.with(|c| { if let Some(callback) = *c.borrow() { - let ptr = callback.to_bits() as *const u8; + // Unbox NaN-boxed closure (same as callback.rs / iOS/macOS). + let ptr = unsafe { js_nanbox_get_pointer(callback) } as *const u8; unsafe { js_closure_call1(ptr, 0.0); } @@ -418,7 +420,7 @@ pub fn handle_activate() { pub fn handle_terminate() { ON_TERMINATE_CALLBACK.with(|c| { if let Some(callback) = *c.borrow() { - let ptr = callback.to_bits() as *const u8; + let ptr = unsafe { js_nanbox_get_pointer(callback) } as *const u8; unsafe { js_closure_call1(ptr, 0.0); } diff --git a/crates/perry-ui-android/src/callback.rs b/crates/perry-ui-android/src/callback.rs index 3f6463b6a3..8ded2b76e1 100644 --- a/crates/perry-ui-android/src/callback.rs +++ b/crates/perry-ui-android/src/callback.rs @@ -97,7 +97,10 @@ pub fn invoke0(key: i64) { guard.as_ref().and_then(|m| m.get(&key).copied()) }; if let Some(closure_f64) = closure_f64 { - let closure_ptr = closure_f64.to_bits() as *const u8; + // Must unbox the NaN-boxed closure — same as iOS/macOS. + // Using to_bits() as a raw pointer leaves the 0x7ffd tag in the + // high bits and crashes (often later on RenderThread via heap corruption). + let closure_ptr = unsafe { js_nanbox_get_pointer(closure_f64) } as *const u8; unsafe { __android_log_print( 3, @@ -133,7 +136,7 @@ pub fn invoke1(key: i64, arg: f64) { guard.as_ref().and_then(|m| m.get(&key).copied()) }; if let Some(closure_f64) = closure_f64 { - let closure_ptr = closure_f64.to_bits() as *const u8; + let closure_ptr = unsafe { js_nanbox_get_pointer(closure_f64) } as *const u8; unsafe { js_closure_call1(closure_ptr, arg); } @@ -147,7 +150,7 @@ pub fn invoke2(key: i64, arg1: f64, arg2: f64) { guard.as_ref().and_then(|m| m.get(&key).copied()) }; if let Some(closure_f64) = closure_f64 { - let closure_ptr = closure_f64.to_bits() as *const u8; + let closure_ptr = unsafe { js_nanbox_get_pointer(closure_f64) } as *const u8; unsafe { js_closure_call2(closure_ptr, arg1, arg2); } @@ -162,7 +165,7 @@ pub fn invoke4(key: i64, arg0: f64, arg1: f64, arg2: f64, arg3: f64) { guard.as_ref().and_then(|m| m.get(&key).copied()) }; if let Some(closure_f64) = closure_f64 { - let closure_ptr = closure_f64.to_bits() as *const u8; + let closure_ptr = unsafe { js_nanbox_get_pointer(closure_f64) } as *const u8; unsafe { js_closure_call4(closure_ptr, arg0, arg1, arg2, arg3); } @@ -179,7 +182,7 @@ pub fn invoke_with_string_array(key: i64, paths: &[String]) { guard.as_ref().and_then(|m| m.get(&key).copied()) }; if let Some(closure_f64) = closure_f64 { - let closure_ptr = closure_f64.to_bits() as *const u8; + let closure_ptr = unsafe { js_nanbox_get_pointer(closure_f64) } as *const u8; unsafe { let mut arr = js_array_alloc(paths.len() as u32); for p in paths { diff --git a/crates/perry-ui-android/src/drag_drop.rs b/crates/perry-ui-android/src/drag_drop.rs index 84a0c56664..efd1b89bd2 100644 --- a/crates/perry-ui-android/src/drag_drop.rs +++ b/crates/perry-ui-android/src/drag_drop.rs @@ -79,6 +79,7 @@ extern "C" { fn js_nanbox_string(ptr: i64) -> f64; fn js_closure_call0(closure: *const u8) -> f64; fn js_closure_call1(closure: *const u8, arg: f64) -> f64; + fn js_nanbox_get_pointer(value: f64) -> i64; // Converts an arbitrary JS value to a runtime `StringHeader` (typed here as // `*const u8` so it feeds `str_from_header`, the same way `clipboard.rs` // treats runtime string pointers). Defined in @@ -242,7 +243,7 @@ pub extern "C" fn Java_com_perry_app_PerryBridge_nativeInvokeDropCallback( let Some(closure_f64) = callback::get(key as i64) else { return; }; - let closure_ptr = closure_f64.to_bits() as *const u8; + let closure_ptr = unsafe { js_nanbox_get_pointer(closure_f64) } as *const u8; if closure_ptr.is_null() { return; } @@ -302,7 +303,7 @@ pub extern "C" fn Java_com_perry_app_PerryBridge_nativeInvokeDragProvider<'local /// or returned `undefined`/`null`. fn drag_provider_payload(key: i64) -> Option { let closure_f64 = callback::get(key)?; - let closure_ptr = closure_f64.to_bits() as *const u8; + let closure_ptr = unsafe { js_nanbox_get_pointer(closure_f64) } as *const u8; if closure_ptr.is_null() { return None; } diff --git a/crates/perry-ui-android/src/ffi/basic_widgets.rs b/crates/perry-ui-android/src/ffi/basic_widgets.rs index 32cb2a116f..1b1288f82e 100644 --- a/crates/perry-ui-android/src/ffi/basic_widgets.rs +++ b/crates/perry-ui-android/src/ffi/basic_widgets.rs @@ -30,6 +30,48 @@ pub extern "C" fn perry_ui_text_create(text_ptr: i64) -> i64 { }) } +/// `Text(content, id)` — create + register for `setText(id, value)`. +/// Other platforms export this; Android was missing it, so dlopen of apps +/// that use reactive Text ids failed with: +/// `cannot locate symbol "perry_ui_text_create_with_id"`. +#[no_mangle] +pub extern "C" fn perry_ui_text_create_with_id(text_ptr: i64, id_ptr: i64) -> i64 { + catch_panic("perry_ui_text_create_with_id", || { + let handle = widgets::text::create(text_ptr as *const u8); + if id_ptr != 0 { + let id = app::str_from_header(id_ptr as *const u8); + widgets::text_registry::register_text_id_handler( + handle, + id.as_ptr(), + id.len(), + ); + } + handle + }) +} + +/// Direct `setText(id, value)` entry for the `import { setText }` surface. +#[no_mangle] +pub extern "C" fn perry_ui_set_text(id_ptr: i64, value_ptr: i64) { + if id_ptr == 0 { + return; + } + catch_panic_void("perry_ui_set_text", || { + let id = app::str_from_header(id_ptr as *const u8); + let val = if value_ptr == 0 { + "" + } else { + app::str_from_header(value_ptr as *const u8) + }; + widgets::text_registry::set_text_handler( + id.as_ptr(), + id.len(), + val.as_ptr(), + val.len(), + ); + }); +} + #[no_mangle] pub extern "C" fn perry_ui_button_create(label_ptr: i64, on_press: f64) -> i64 { catch_panic("perry_ui_button_create", || { diff --git a/crates/perry-ui-android/src/pointer.rs b/crates/perry-ui-android/src/pointer.rs index 941c8827bb..d3fba56209 100644 --- a/crates/perry-ui-android/src/pointer.rs +++ b/crates/perry-ui-android/src/pointer.rs @@ -20,6 +20,7 @@ use jni::objects::JValue; extern "C" { fn js_closure_call1(closure: *const u8, arg: f64) -> f64; + fn js_nanbox_get_pointer(value: f64) -> i64; fn js_pointer_event_new(x: f64, y: f64, button: u32, pointer_type: u32) -> f64; } @@ -42,7 +43,7 @@ pub extern "C" fn Java_com_perry_app_PerryBridge_nativeInvokePointerCallback( let Some(closure_f64) = closure_f64 else { return; }; - let closure_ptr = closure_f64.to_bits() as *const u8; + let closure_ptr = unsafe { js_nanbox_get_pointer(closure_f64) } as *const u8; if closure_ptr.is_null() { return; } diff --git a/crates/perry-ui-android/src/state.rs b/crates/perry-ui-android/src/state.rs index a2045791c0..36185741a3 100644 --- a/crates/perry-ui-android/src/state.rs +++ b/crates/perry-ui-android/src/state.rs @@ -227,7 +227,9 @@ pub fn state_set(handle: i64, value: f64) { .unwrap_or_default() }); for callback_f64 in callbacks_snapshot { - let closure_ptr = callback_f64.to_bits() as *const u8; + // onChange callbacks are NaN-boxed closures (same as button path). + // Do not use to_bits() as a raw pointer — leaves the 0x7ffd tag. + let closure_ptr = unsafe { js_nanbox_get_pointer(callback_f64) } as *const u8; unsafe { js_closure_call1(closure_ptr, value); } diff --git a/crates/perry/src/commands/compile/link/build_and_run.rs b/crates/perry/src/commands/compile/link/build_and_run.rs index 386f3bd7ad..0ca372cc70 100644 --- a/crates/perry/src/commands/compile/link/build_and_run.rs +++ b/crates/perry/src/commands/compile/link/build_and_run.rs @@ -831,10 +831,14 @@ pub(crate) fn build_and_run_link( cmd.arg("-ltime_service_ndk"); } else if is_android { // Android system libraries + // libandroid provides ANativeWindow_fromSurface etc. used by perry-ui-android + // (camera / surface paths). Without -landroid the .so links clean but + // dlopen fails at runtime: cannot locate symbol "ANativeWindow_fromSurface". cmd.arg("-Wl,--allow-multiple-definition") .arg("-lm") .arg("-ldl") - .arg("-llog"); + .arg("-llog") + .arg("-landroid"); // Stub for JNI_GetCreatedJavaVMs: the jni-sys crate declares this extern // symbol, but Android has no libjvm.so and libnativehelper.so is only From 15f113af4d5f44e2a480f42de4edfdfe2259c405 Mon Sep 17 00:00:00 2001 From: tangledcircuit Date: Sun, 12 Jul 2026 19:22:04 +0000 Subject: [PATCH 2/5] fix(android): address review nits on NaN-box unbox paths Add unbox comments on remaining invoke* helpers and null-check lifecycle callback pointers after js_nanbox_get_pointer. --- crates/perry-ui-android/src/app.rs | 7 +++++++ crates/perry-ui-android/src/callback.rs | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/crates/perry-ui-android/src/app.rs b/crates/perry-ui-android/src/app.rs index 9681fbcd62..caa0618e53 100644 --- a/crates/perry-ui-android/src/app.rs +++ b/crates/perry-ui-android/src/app.rs @@ -409,6 +409,9 @@ pub fn handle_activate() { if let Some(callback) = *c.borrow() { // Unbox NaN-boxed closure (same as callback.rs / iOS/macOS). let ptr = unsafe { js_nanbox_get_pointer(callback) } as *const u8; + if ptr.is_null() { + return; + } unsafe { js_closure_call1(ptr, 0.0); } @@ -420,7 +423,11 @@ pub fn handle_activate() { pub fn handle_terminate() { ON_TERMINATE_CALLBACK.with(|c| { if let Some(callback) = *c.borrow() { + // Unbox NaN-boxed closure — see invoke0 / callback.rs for details. let ptr = unsafe { js_nanbox_get_pointer(callback) } as *const u8; + if ptr.is_null() { + return; + } unsafe { js_closure_call1(ptr, 0.0); } diff --git a/crates/perry-ui-android/src/callback.rs b/crates/perry-ui-android/src/callback.rs index 8ded2b76e1..57239a7022 100644 --- a/crates/perry-ui-android/src/callback.rs +++ b/crates/perry-ui-android/src/callback.rs @@ -136,6 +136,7 @@ pub fn invoke1(key: i64, arg: f64) { guard.as_ref().and_then(|m| m.get(&key).copied()) }; if let Some(closure_f64) = closure_f64 { + // Unbox NaN-boxed closure — see invoke0 for details. let closure_ptr = unsafe { js_nanbox_get_pointer(closure_f64) } as *const u8; unsafe { js_closure_call1(closure_ptr, arg); @@ -150,6 +151,7 @@ pub fn invoke2(key: i64, arg1: f64, arg2: f64) { guard.as_ref().and_then(|m| m.get(&key).copied()) }; if let Some(closure_f64) = closure_f64 { + // Unbox NaN-boxed closure — see invoke0 for details. let closure_ptr = unsafe { js_nanbox_get_pointer(closure_f64) } as *const u8; unsafe { js_closure_call2(closure_ptr, arg1, arg2); @@ -165,6 +167,7 @@ pub fn invoke4(key: i64, arg0: f64, arg1: f64, arg2: f64, arg3: f64) { guard.as_ref().and_then(|m| m.get(&key).copied()) }; if let Some(closure_f64) = closure_f64 { + // Unbox NaN-boxed closure — see invoke0 for details. let closure_ptr = unsafe { js_nanbox_get_pointer(closure_f64) } as *const u8; unsafe { js_closure_call4(closure_ptr, arg0, arg1, arg2, arg3); @@ -182,6 +185,7 @@ pub fn invoke_with_string_array(key: i64, paths: &[String]) { guard.as_ref().and_then(|m| m.get(&key).copied()) }; if let Some(closure_f64) = closure_f64 { + // Unbox NaN-boxed closure — see invoke0 for details. let closure_ptr = unsafe { js_nanbox_get_pointer(closure_f64) } as *const u8; unsafe { let mut arr = js_array_alloc(paths.len() as u32); From dfae6cc267dc09ea7a0a3ca8cad8ed6d093c41f1 Mon Sep 17 00:00:00 2001 From: tangledcircuit Date: Sun, 12 Jul 2026 21:03:54 +0000 Subject: [PATCH 3/5] fix(android): decode NaN-boxed widget handles in style FFIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit declare-function styling calls pass POINTER-tagged f64 handles (0x7FFD…payload). i64 parameters left the real handle in xmm while C read rdi garbage, so set_background_color never found widgets. - decode_js_handle_f64 for POINTER + INT32 tags - style FFIs take handle: f64 - MaterialButton backgroundTintList so fills actually show --- crates/perry-ui-android/src/ffi/image_nav.rs | 40 +++--- .../perry-ui-android/src/ffi/tabbar_layout.rs | 15 +- .../perry-ui-android/src/ffi/text_scroll.rs | 20 +-- crates/perry-ui-android/src/widgets/mod.rs | 129 +++++++++++++++++- 4 files changed, 171 insertions(+), 33 deletions(-) diff --git a/crates/perry-ui-android/src/ffi/image_nav.rs b/crates/perry-ui-android/src/ffi/image_nav.rs index 29343a808e..3a433fc4c7 100644 --- a/crates/perry-ui-android/src/ffi/image_nav.rs +++ b/crates/perry-ui-android/src/ffi/image_nav.rs @@ -98,8 +98,9 @@ pub extern "C" fn perry_ui_widget_set_control_size(handle: i64, size: i64) { } #[no_mangle] -pub extern "C" fn perry_ui_widget_set_corner_radius(handle: i64, radius: f64) { - widgets::set_corner_radius(handle, radius); +pub extern "C" fn perry_ui_widget_set_corner_radius(handle: f64, radius: f64) { + let h = widgets::decode_js_handle_f64(handle); + widgets::set_corner_radius(h, radius); } /// Set drop shadow via Material `setElevation` + (API 28+) @@ -107,7 +108,7 @@ pub extern "C" fn perry_ui_widget_set_corner_radius(handle: i64, radius: f64) { /// `widgets::set_shadow` for the full mapping rationale (issue #185 Phase B). #[no_mangle] pub extern "C" fn perry_ui_widget_set_shadow( - handle: i64, + handle: f64, r: f64, g: f64, b: f64, @@ -116,23 +117,25 @@ pub extern "C" fn perry_ui_widget_set_shadow( offset_x: f64, offset_y: f64, ) { - widgets::set_shadow(handle, r, g, b, a, blur, offset_x, offset_y); + let h = widgets::decode_js_handle_f64(handle); + widgets::set_shadow(h, r, g, b, a, blur, offset_x, offset_y); } #[no_mangle] pub extern "C" fn perry_ui_widget_set_background_color( - handle: i64, + handle: f64, r: f64, g: f64, b: f64, a: f64, ) { - widgets::set_background_color(handle, r, g, b, a); + let h = widgets::decode_js_handle_f64(handle); + widgets::set_background_color(h, r, g, b, a); } #[no_mangle] pub extern "C" fn perry_ui_widget_set_background_gradient( - handle: i64, + handle: f64, r1: f64, g1: f64, b1: f64, @@ -143,40 +146,45 @@ pub extern "C" fn perry_ui_widget_set_background_gradient( a2: f64, direction: f64, ) { - widgets::set_background_gradient(handle, r1, g1, b1, a1, r2, g2, b2, a2, direction); + let h = widgets::decode_js_handle_f64(handle); + widgets::set_background_gradient(h, r1, g1, b1, a1, r2, g2, b2, a2, direction); } #[no_mangle] -pub extern "C" fn perry_ui_widget_set_border_color(handle: i64, r: f64, g: f64, b: f64, a: f64) { +pub extern "C" fn perry_ui_widget_set_border_color(handle: f64, r: f64, g: f64, b: f64, a: f64) { catch_panic_void("perry_ui_widget_set_border_color", || { - widgets::set_border_color(handle, r, g, b, a) + let h = widgets::decode_js_handle_f64(handle); + widgets::set_border_color(h, r, g, b, a) }); } #[no_mangle] -pub extern "C" fn perry_ui_widget_set_border_width(handle: i64, width: f64) { +pub extern "C" fn perry_ui_widget_set_border_width(handle: f64, width: f64) { catch_panic_void("perry_ui_widget_set_border_width", || { - widgets::set_border_width(handle, width) + let h = widgets::decode_js_handle_f64(handle); + widgets::set_border_width(h, width) }); } #[no_mangle] pub extern "C" fn perry_ui_widget_set_edge_insets( - handle: i64, + handle: f64, top: f64, left: f64, bottom: f64, right: f64, ) { catch_panic_void("perry_ui_widget_set_edge_insets", || { - widgets::set_edge_insets(handle, top, left, bottom, right) + let h = widgets::decode_js_handle_f64(handle); + widgets::set_edge_insets(h, top, left, bottom, right) }); } #[no_mangle] -pub extern "C" fn perry_ui_widget_set_opacity(handle: i64, alpha: f64) { +pub extern "C" fn perry_ui_widget_set_opacity(handle: f64, alpha: f64) { catch_panic_void("perry_ui_widget_set_opacity", || { - widgets::set_opacity(handle, alpha) + let h = widgets::decode_js_handle_f64(handle); + widgets::set_opacity(h, alpha) }); } diff --git a/crates/perry-ui-android/src/ffi/tabbar_layout.rs b/crates/perry-ui-android/src/ffi/tabbar_layout.rs index 37806f167b..7078a9ac82 100644 --- a/crates/perry-ui-android/src/ffi/tabbar_layout.rs +++ b/crates/perry-ui-android/src/ffi/tabbar_layout.rs @@ -35,9 +35,10 @@ pub extern "C" fn perry_ui_tabbar_set_selected(tabbar_handle: i64, index: i64) { // ============================================================================= #[no_mangle] -pub extern "C" fn perry_ui_button_set_text_color(handle: i64, r: f64, g: f64, b: f64, a: f64) { +pub extern "C" fn perry_ui_button_set_text_color(handle: f64, r: f64, g: f64, b: f64, a: f64) { catch_panic_void("perry_ui_button_set_text_color", || { - widgets::button::set_text_color(handle, r, g, b, a) + let h = widgets::decode_js_handle_f64(handle); + widgets::button::set_text_color(h, r, g, b, a) }); } @@ -99,16 +100,18 @@ pub extern "C" fn perry_ui_widget_set_hugging(handle: i64, priority: f64) { // ============================================================================= #[no_mangle] -pub extern "C" fn perry_ui_widget_set_width(handle: i64, width: f64) { +pub extern "C" fn perry_ui_widget_set_width(handle: f64, width: f64) { catch_panic_void("perry_ui_widget_set_width", || { - widgets::set_width(handle, width) + let h = widgets::decode_js_handle_f64(handle); + widgets::set_width(h, width) }); } #[no_mangle] -pub extern "C" fn perry_ui_widget_set_height(handle: i64, height: f64) { +pub extern "C" fn perry_ui_widget_set_height(handle: f64, height: f64) { catch_panic_void("perry_ui_widget_set_height", || { - widgets::set_height(handle, height) + let h = widgets::decode_js_handle_f64(handle); + widgets::set_height(h, height) }); } diff --git a/crates/perry-ui-android/src/ffi/text_scroll.rs b/crates/perry-ui-android/src/ffi/text_scroll.rs index 61eb59df28..0b599b7e3a 100644 --- a/crates/perry-ui-android/src/ffi/text_scroll.rs +++ b/crates/perry-ui-android/src/ffi/text_scroll.rs @@ -141,18 +141,21 @@ pub extern "C" fn perry_system_get_app_icon(_path: i64) -> i64 { // ============================================================================= #[no_mangle] -pub extern "C" fn perry_ui_text_set_color(handle: i64, r: f64, g: f64, b: f64, a: f64) { - widgets::text::set_color(handle, r, g, b, a); +pub extern "C" fn perry_ui_text_set_color(handle: f64, r: f64, g: f64, b: f64, a: f64) { + let h = widgets::decode_js_handle_f64(handle); + widgets::text::set_color(h, r, g, b, a); } #[no_mangle] -pub extern "C" fn perry_ui_text_set_font_size(handle: i64, size: f64) { - widgets::text::set_font_size(handle, size); +pub extern "C" fn perry_ui_text_set_font_size(handle: f64, size: f64) { + let h = widgets::decode_js_handle_f64(handle); + widgets::text::set_font_size(h, size); } #[no_mangle] -pub extern "C" fn perry_ui_text_set_font_weight(handle: i64, size: f64, weight: f64) { - widgets::text::set_font_weight(handle, size, weight); +pub extern "C" fn perry_ui_text_set_font_weight(handle: f64, size: f64, weight: f64) { + let h = widgets::decode_js_handle_f64(handle); + widgets::text::set_font_weight(h, size, weight); } #[no_mangle] @@ -191,8 +194,9 @@ pub extern "C" fn perry_ui_text_set_text_alignment(handle: i64, alignment: i64) } #[no_mangle] -pub extern "C" fn perry_ui_button_set_bordered(handle: i64, bordered: f64) { - widgets::button::set_bordered(handle, bordered != 0.0); +pub extern "C" fn perry_ui_button_set_bordered(handle: f64, bordered: f64) { + let h = widgets::decode_js_handle_f64(handle); + widgets::button::set_bordered(h, bordered != 0.0); } #[no_mangle] diff --git a/crates/perry-ui-android/src/widgets/mod.rs b/crates/perry-ui-android/src/widgets/mod.rs index 651d875eb0..dcd0a33df9 100644 --- a/crates/perry-ui-android/src/widgets/mod.rs +++ b/crates/perry-ui-android/src/widgets/mod.rs @@ -50,6 +50,56 @@ extern "C" { /// the native thread can be accessed from UI-thread callbacks. static WIDGETS: Mutex> = Mutex::new(Vec::new()); +/// Perry JS ABI is NaN-boxed doubles. Widget handles from register_widget +/// are returned to TS as POINTER-tagged NaNs (0x7FFD_…_payload) with the +/// plain 1-based id in the lower 48 bits — same encoding as heap pointers, +/// but the payload is a small integer index, not a heap address. +/// +/// `declare function` style FFIs must take `handle: f64` (System V float +/// regs) and decode here. An `i64` parameter leaves the real handle in an +/// xmm register while C reads garbage from rdi. +pub fn decode_js_handle_f64(v: f64) -> i64 { + let bits = v.to_bits(); + const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; + const INT32_TAG: u64 = 0x7FFE_0000_0000_0000; + const TAG_MASK: u64 = 0xFFFF_0000_0000_0000; + const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; + let tag = bits & TAG_MASK; + if tag == POINTER_TAG { + return (bits & POINTER_MASK) as i64; + } + if tag == INT32_TAG { + return (bits as u32) as i32 as i64; + } + if v.is_finite() && v >= 1.0 && v < 1_000_000.0 && (v - v.trunc()).abs() < 1e-9 { + return v as i64; + } + 0 +} + +fn coerce_handle_i64(handle: i64) -> i64 { + if handle > 0 && handle < 1_000_000 { + return handle; + } + let bits = handle as u64; + const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; + const INT32_TAG: u64 = 0x7FFE_0000_0000_0000; + const TAG_MASK: u64 = 0xFFFF_0000_0000_0000; + const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; + let tag = bits & TAG_MASK; + if tag == POINTER_TAG { + return (bits & POINTER_MASK) as i64; + } + if tag == INT32_TAG { + return (bits as u32) as i32 as i64; + } + let as_f = f64::from_bits(bits); + if as_f.is_finite() && as_f >= 1.0 && as_f < 1_000_000.0 && (as_f - as_f.trunc()).abs() < 1e-9 { + return as_f as i64; + } + handle +} + /// Store an Android View and return its handle (1-based i64). pub fn register_widget(view: GlobalRef) -> i64 { let mut widgets = WIDGETS.lock().unwrap(); @@ -59,6 +109,10 @@ pub fn register_widget(view: GlobalRef) -> i64 { /// Retrieve the JNI GlobalRef for a given widget handle. pub fn get_widget(handle: i64) -> Option { + let handle = coerce_handle_i64(handle); + if handle <= 0 { + return None; + } let widgets = WIDGETS.lock().unwrap(); let idx = (handle - 1) as usize; widgets.get(idx).cloned() @@ -404,13 +458,41 @@ pub fn set_corner_radius(handle: i64, radius: f64) { /// Set background color using GradientDrawable for compatibility with corner radius. /// If the view already has a GradientDrawable, updates its color (preserving corner radius). +/// +/// Material / AppCompat buttons also need backgroundTint cleared or the tint +/// paints over (or instead of) the drawable we set — which left daisy-perry +/// looking like default gray Material chips forever. pub fn set_background_color(handle: i64, r: f64, g: f64, b: f64, a: f64) { + unsafe { + __android_log_print( + 3, + b"PerryStyle\0".as_ptr(), + b"set_bg decoded_handle=%lld r=%.3f g=%.3f b=%.3f a=%.3f\0".as_ptr(), + handle, + r, + g, + b, + a, + ); + } if let Some(view_ref) = get_widget(handle) { let mut env = jni_bridge::get_env(); - let _ = env.push_local_frame(16); + let _ = env.push_local_frame(24); let color = argb_color(a, r, g, b); - // Try to reuse existing GradientDrawable (preserving corner radius) + // Clear backgroundTint so MaterialButton doesn't paint over our drawable. + // Signature: setBackgroundTintList(ColorStateList?) — pass null. + let _ = env.call_method( + view_ref.as_obj(), + "setBackgroundTintList", + "(Landroid/content/res/ColorStateList;)V", + &[JValue::Object(&JObject::null())], + ); + if env.exception_check().unwrap_or(false) { + let _ = env.exception_clear(); + } + + // Prefer GradientDrawable so corner radius can share the same bg. let mut reused = false; if let Ok(bg) = env.call_method( view_ref.as_obj(), @@ -431,11 +513,12 @@ pub fn set_background_color(handle: i64, r: f64, g: f64, b: f64, a: f64) { } } if !reused { - // Create GradientDrawable so a later set_corner_radius can reuse it let gd = env .new_object("android/graphics/drawable/GradientDrawable", "()V", &[]) .expect("GradientDrawable"); let _ = env.call_method(&gd, "setColor", "(I)V", &[JValue::Int(color)]); + // solid shape rect + let _ = env.call_method(&gd, "setShape", "(I)V", &[JValue::Int(0)]); let _ = env.call_method( view_ref.as_obj(), "setBackground", @@ -443,9 +526,49 @@ pub fn set_background_color(handle: i64, r: f64, g: f64, b: f64, a: f64) { &[JValue::Object(&gd)], ); } + + // MaterialButton: the real paint path is backgroundTintList. + // Set it to our solid color so the button actually shows DaisyUI fills. + if let Ok(csl) = env.call_static_method( + "android/content/res/ColorStateList", + "valueOf", + "(I)Landroid/content/res/ColorStateList;", + &[JValue::Int(color)], + ) { + if let Ok(csl_obj) = csl.l() { + let _ = env.call_method( + view_ref.as_obj(), + "setBackgroundTintList", + "(Landroid/content/res/ColorStateList;)V", + &[JValue::Object(&csl_obj)], + ); + } + } + if env.exception_check().unwrap_or(false) { + let _ = env.exception_clear(); + } + + unsafe { + __android_log_print( + 3, + b"PerryStyle\0".as_ptr(), + b"set_bg applied handle=%lld color=0x%08x\0".as_ptr(), + handle, + color as u32, + ); + } unsafe { env.pop_local_frame(&JObject::null()); } + } else { + unsafe { + __android_log_print( + 6, + b"PerryStyle\0".as_ptr(), + b"set_bg MISSING handle=%lld\0".as_ptr(), + handle, + ); + } } } From 6cbe5409ba2b9ac429d903fc05ada0100e1f5fe7 Mon Sep 17 00:00:00 2001 From: tangledcircuit Date: Sun, 12 Jul 2026 23:01:34 +0000 Subject: [PATCH 4/5] fix(android): safe preferencesGet + stop drawing under status bar preferencesGet used getString first on SharedPreferences. When a key was stored as float (preferencesSet with a number), Android threw ClassCastException and left a pending JNI exception, which poisoned the rest of nativeMain (text/hstack create panics, half the UI missing). Read via getAll and branch on String/Float/Double/Integer/Long, clearing any leftover exception before return. Also stop the activity template from using FLAG_LAYOUT_NO_LIMITS without real safe-area insets: pad the host FrameLayout by status_bar_height so content is not under system icons on Android 15 / OnePlus. --- crates/perry-ui-android/src/system.rs | 159 ++++++++++++------ .../main/java/com/perry/app/PerryActivity.kt | 29 +++- 2 files changed, 134 insertions(+), 54 deletions(-) diff --git a/crates/perry-ui-android/src/system.rs b/crates/perry-ui-android/src/system.rs index f9ab751553..6e1f3ff647 100644 --- a/crates/perry-ui-android/src/system.rs +++ b/crates/perry-ui-android/src/system.rs @@ -166,72 +166,133 @@ pub fn preferences_set(key_ptr: *const u8, value: f64) { } /// Get a preference value from SharedPreferences. +/// +/// IMPORTANT: Android SharedPreferences stores typed values. Calling +/// `getString` on a float key (or `getFloat` on a string key) throws +/// ClassCastException and leaves a *pending* JNI exception. That poisons +/// every subsequent JNI call in `nativeMain` (text/hstack create panics, +/// half the UI vanishes). Always read via `getAll` and branch on type, and +/// clear any leftover exception before returning. pub fn preferences_get(key_ptr: *const u8) -> f64 { let key = str_from_header(key_ptr); let mut env = jni_bridge::get_env(); - let _ = env.push_local_frame(16); + let _ = env.push_local_frame(24); - let activity = crate::widgets::get_activity(&mut env); - let pref_name = env.new_string("perry_prefs").expect("pref name"); - let prefs = env - .call_method( - &activity, - "getSharedPreferences", - "(Ljava/lang/String;I)Landroid/content/SharedPreferences;", - &[JValue::Object(&pref_name), JValue::Int(0)], - ) - .expect("getSharedPreferences") - .l() - .expect("prefs"); + let finish = |env: &mut jni::JNIEnv, result: f64| -> f64 { + if env.exception_check().unwrap_or(false) { + let _ = env.exception_clear(); + } + unsafe { + env.pop_local_frame(&jni::objects::JObject::null()); + } + result + }; - let jkey = env.new_string(key).expect("key string"); + let activity = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + crate::widgets::get_activity(&mut env) + })) { + Ok(a) => a, + Err(_) => return finish(&mut env, 0.0), + }; - // Try getString first, fallback to getFloat - let str_result = env.call_method( - &prefs, - "getString", - "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", - &[ - JValue::Object(&jkey), - JValue::Object(&jni::objects::JObject::null()), - ], - ); + let pref_name = match env.new_string("perry_prefs") { + Ok(s) => s, + Err(_) => return finish(&mut env, 0.0), + }; + let prefs = match env.call_method( + &activity, + "getSharedPreferences", + "(Ljava/lang/String;I)Landroid/content/SharedPreferences;", + &[JValue::Object(&pref_name), JValue::Int(0)], + ) { + Ok(v) => match v.l() { + Ok(o) => o, + Err(_) => return finish(&mut env, 0.0), + }, + Err(_) => return finish(&mut env, 0.0), + }; - if let Ok(val) = str_result { - if let Ok(obj) = val.l() { - if !obj.is_null() { - let jstr: jni::objects::JString = obj.into(); - let s: String = env.get_string(&jstr).expect("get string").into(); - let bytes = s.as_bytes(); - let ptr = unsafe { js_string_from_bytes(bytes.as_ptr(), bytes.len()) }; - let result = unsafe { js_nanbox_string(ptr) }; - unsafe { - env.pop_local_frame(&jni::objects::JObject::null()); - } - return result; + let jkey = match env.new_string(key) { + Ok(s) => s, + Err(_) => return finish(&mut env, 0.0), + }; + + // getAll avoids ClassCastException from typed getters. + let map = match env.call_method(&prefs, "getAll", "()Ljava/util/Map;", &[]) { + Ok(v) => match v.l() { + Ok(o) => o, + Err(_) => return finish(&mut env, 0.0), + }, + Err(_) => return finish(&mut env, 0.0), + }; + + let entry = match env.call_method( + &map, + "get", + "(Ljava/lang/Object;)Ljava/lang/Object;", + &[JValue::Object(&jkey)], + ) { + Ok(v) => match v.l() { + Ok(o) => o, + Err(_) => return finish(&mut env, 0.0), + }, + Err(_) => return finish(&mut env, 0.0), + }; + + if entry.is_null() { + return finish(&mut env, 0.0); + } + + // Branch on runtime type without throwing. + if let Ok(true) = env.is_instance_of(&entry, "java/lang/String") { + let jstr: jni::objects::JString = entry.into(); + if let Ok(java_str) = env.get_string(&jstr) { + let s: String = java_str.into(); + let bytes = s.as_bytes(); + let ptr = unsafe { js_string_from_bytes(bytes.as_ptr(), bytes.len()) }; + let result = unsafe { js_nanbox_string(ptr) }; + return finish(&mut env, result); + } + return finish(&mut env, 0.0); + } + + if let Ok(true) = env.is_instance_of(&entry, "java/lang/Float") { + if let Ok(f) = env.call_method(&entry, "floatValue", "()F", &[]) { + if let Ok(v) = f.f() { + return finish(&mut env, v as f64); } } + return finish(&mut env, 0.0); } - // Try getFloat - let float_result = env.call_method( - &prefs, - "getFloat", - "(Ljava/lang/String;F)F", - &[JValue::Object(&jkey), JValue::Float(0.0)], - ); + if let Ok(true) = env.is_instance_of(&entry, "java/lang/Double") { + if let Ok(d) = env.call_method(&entry, "doubleValue", "()D", &[]) { + if let Ok(v) = d.d() { + return finish(&mut env, v); + } + } + return finish(&mut env, 0.0); + } - unsafe { - env.pop_local_frame(&jni::objects::JObject::null()); + if let Ok(true) = env.is_instance_of(&entry, "java/lang/Integer") { + if let Ok(i) = env.call_method(&entry, "intValue", "()I", &[]) { + if let Ok(v) = i.i() { + return finish(&mut env, v as f64); + } + } + return finish(&mut env, 0.0); } - if let Ok(val) = float_result { - if let Ok(f) = val.f() { - return f as f64; + if let Ok(true) = env.is_instance_of(&entry, "java/lang/Long") { + if let Ok(l) = env.call_method(&entry, "longValue", "()J", &[]) { + if let Ok(v) = l.j() { + return finish(&mut env, v as f64); + } } + return finish(&mut env, 0.0); } - 0.0 + finish(&mut env, 0.0) } /// Save a value to the keychain (SharedPreferences with private mode). diff --git a/crates/perry-ui-android/template/app/src/main/java/com/perry/app/PerryActivity.kt b/crates/perry-ui-android/template/app/src/main/java/com/perry/app/PerryActivity.kt index 2f2ff244ef..e8dfae3459 100644 --- a/crates/perry-ui-android/template/app/src/main/java/com/perry/app/PerryActivity.kt +++ b/crates/perry-ui-android/template/app/src/main/java/com/perry/app/PerryActivity.kt @@ -34,13 +34,32 @@ class PerryActivity : Activity() { // Switch from splash theme to normal theme before inflating layout setTheme(android.R.style.Theme_Material_Light_NoActionBar) - // Go edge-to-edge (content under status/nav bars, matching iOS behavior) - window.setFlags( - android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, - android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS - ) + // Keep content below status / nav bars. + // Android 15 defaults to edge-to-edge. FLAG_LAYOUT_NO_LIMITS draws + // under the status bar; getSafeAreaInsets() on Android still returns + // zeros, so pad the host FrameLayout by status_bar_height. + if (android.os.Build.VERSION.SDK_INT >= 30) { + @Suppress("DEPRECATION") + window.setDecorFitsSystemWindows(true) + } + window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) + window.statusBarColor = android.graphics.Color.WHITE + window.navigationBarColor = android.graphics.Color.WHITE + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = + android.view.View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + } rootLayout = FrameLayout(this) + val statusPad = try { + val resId = resources.getIdentifier("status_bar_height", "dimen", "android") + if (resId > 0) resources.getDimensionPixelSize(resId) + else (24 * resources.displayMetrics.density).toInt() + } catch (_: Exception) { + (24 * resources.displayMetrics.density).toInt() + } + rootLayout.setPadding(0, statusPad, 0, 0) setContentView(rootLayout) // Store device locale in SharedPreferences so preferencesGet("AppleLanguages") works From dde8f1d5f7269a175c3aff25725d3ec220bdd1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 13 Jul 2026 09:25:21 +0200 Subject: [PATCH 5/5] fix(android): pad host from real window insets; rustfmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups on the review: - `cargo fmt` drift in `ffi/basic_widgets.rs` (the two new text-registry call sites) — this is what the `lint` gate was failing on. - The host `FrameLayout` padded unconditionally by `status_bar_height`. That is only correct in the edge-to-edge regime. The template targets SDK 35, so on Android 15+ `setDecorFitsSystemWindows(true)` is a no-op and nothing insets the content — the pad is needed. But on API 30-34 that call is honored (and below 30 the decor fits by default), so the content view is already inset and the extra pad is just blank space at the top. Pad from the insets actually dispatched to the host instead of branching on SDK_INT: the decor consumes them when it fits system windows (so we pad by zero) and passes them through when it does not. Also picks up the nav bar, display cutouts and landscape, which a top-only status_bar_height pad missed. Verified by building the rendered template with AGP 8.8.2 / SDK 35. --- .../perry-ui-android/src/ffi/basic_widgets.rs | 13 ++------ .../main/java/com/perry/app/PerryActivity.kt | 33 ++++++++++++------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/crates/perry-ui-android/src/ffi/basic_widgets.rs b/crates/perry-ui-android/src/ffi/basic_widgets.rs index 1b1288f82e..4a63c21b6b 100644 --- a/crates/perry-ui-android/src/ffi/basic_widgets.rs +++ b/crates/perry-ui-android/src/ffi/basic_widgets.rs @@ -40,11 +40,7 @@ pub extern "C" fn perry_ui_text_create_with_id(text_ptr: i64, id_ptr: i64) -> i6 let handle = widgets::text::create(text_ptr as *const u8); if id_ptr != 0 { let id = app::str_from_header(id_ptr as *const u8); - widgets::text_registry::register_text_id_handler( - handle, - id.as_ptr(), - id.len(), - ); + widgets::text_registry::register_text_id_handler(handle, id.as_ptr(), id.len()); } handle }) @@ -63,12 +59,7 @@ pub extern "C" fn perry_ui_set_text(id_ptr: i64, value_ptr: i64) { } else { app::str_from_header(value_ptr as *const u8) }; - widgets::text_registry::set_text_handler( - id.as_ptr(), - id.len(), - val.as_ptr(), - val.len(), - ); + widgets::text_registry::set_text_handler(id.as_ptr(), id.len(), val.as_ptr(), val.len()); }); } diff --git a/crates/perry-ui-android/template/app/src/main/java/com/perry/app/PerryActivity.kt b/crates/perry-ui-android/template/app/src/main/java/com/perry/app/PerryActivity.kt index e8dfae3459..300f415c4c 100644 --- a/crates/perry-ui-android/template/app/src/main/java/com/perry/app/PerryActivity.kt +++ b/crates/perry-ui-android/template/app/src/main/java/com/perry/app/PerryActivity.kt @@ -7,6 +7,8 @@ import android.content.pm.PackageManager import android.os.Bundle import android.widget.FrameLayout import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat /** * Minimal Activity that hosts a Perry-compiled native UI. @@ -34,10 +36,20 @@ class PerryActivity : Activity() { // Switch from splash theme to normal theme before inflating layout setTheme(android.R.style.Theme_Material_Light_NoActionBar) - // Keep content below status / nav bars. - // Android 15 defaults to edge-to-edge. FLAG_LAYOUT_NO_LIMITS draws - // under the status bar; getSafeAreaInsets() on Android still returns - // zeros, so pad the host FrameLayout by status_bar_height. + // Keep content below the status / nav bars. Two layout regimes are in + // play and the host padding has to follow whichever one is live: + // * API < 35 — the decor view fits system windows (explicitly on + // 30-34, by default below that), so the content view is already + // inset and any padding we add here is just blank space. + // * API >= 35 — we target SDK 35, so Android 15 forces edge-to-edge + // and setDecorFitsSystemWindows() is a no-op. Nothing insets the + // content; without padding the UI draws under the system bars. + // So pad from the insets we are actually handed rather than branching + // on SDK_INT: the decor consumes them in the first regime (leaving us + // to pad by zero) and passes them through in the second. This also + // covers the nav bar, display cutouts and landscape, which a top-only + // status_bar_height pad does not. getSafeAreaInsets() is no help here + // — it still returns zeros on Android. if (android.os.Build.VERSION.SDK_INT >= 30) { @Suppress("DEPRECATION") window.setDecorFitsSystemWindows(true) @@ -52,14 +64,13 @@ class PerryActivity : Activity() { } rootLayout = FrameLayout(this) - val statusPad = try { - val resId = resources.getIdentifier("status_bar_height", "dimen", "android") - if (resId > 0) resources.getDimensionPixelSize(resId) - else (24 * resources.displayMetrics.density).toInt() - } catch (_: Exception) { - (24 * resources.displayMetrics.density).toInt() + ViewCompat.setOnApplyWindowInsetsListener(rootLayout) { view, windowInsets -> + val bars = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + view.setPadding(bars.left, bars.top, bars.right, bars.bottom) + WindowInsetsCompat.CONSUMED } - rootLayout.setPadding(0, statusPad, 0, 0) setContentView(rootLayout) // Store device locale in SharedPreferences so preferencesGet("AppleLanguages") works