diff --git a/crates/perry-ui-android/src/app.rs b/crates/perry-ui-android/src/app.rs index 857b6a758e..caa0618e53 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,11 @@ 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; + if ptr.is_null() { + return; + } unsafe { js_closure_call1(ptr, 0.0); } @@ -418,7 +423,11 @@ 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; + // 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 3f6463b6a3..57239a7022 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,8 @@ 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; + // 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); } @@ -147,7 +151,8 @@ 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; + // 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); } @@ -162,7 +167,8 @@ 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; + // 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); } @@ -179,7 +185,8 @@ 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; + // 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); 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..4a63c21b6b 100644 --- a/crates/perry-ui-android/src/ffi/basic_widgets.rs +++ b/crates/perry-ui-android/src/ffi/basic_widgets.rs @@ -30,6 +30,39 @@ 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/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/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-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/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, + ); + } } } 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..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,13 +36,41 @@ 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 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) + } + 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) + 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 + } setContentView(rootLayout) // Store device locale in SharedPreferences so preferencesGet("AppleLanguages") works 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