use super::types::{AndroidHandleKind, AndroidPlatform};
use crate::compat::atomic::Ordering;
use crate::compat::{format, lock, String, ToString, Vec};
use crate::core::PlatformFamily;
use crate::platform::clipboard::RichClipboardBackend;
use crate::platform::types::{Display, DisplayId};
use crate::platform::{Platform, PlatformCapabilities, WidgetTriggerEvent, WidgetTriggerKind};
impl AndroidPlatform {}
const FRAME_INTERVAL_MS: u64 = crate::platform::types::FRAME_INTERVAL_MS;
#[cfg(feature = "android-jni")]
static DENSITY_CACHE: std::sync::OnceLock<std::sync::Mutex<Option<(std::time::Instant, f32)>>> =
std::sync::OnceLock::new();
#[cfg(feature = "android-jni")]
fn cached_density_scale() -> f32 {
const DENSITY_TTL: std::time::Duration = std::time::Duration::from_secs(1);
let cache = DENSITY_CACHE.get_or_init(|| std::sync::Mutex::new(None));
if let Ok(guard) = cache.lock() {
if let Some((sampled_at, value)) = *guard {
if sampled_at.elapsed() < DENSITY_TTL {
return value;
}
}
}
let value = crate::platform::android_jni::display_info().map(|info| info.scale).unwrap_or(1.0);
if let Ok(mut guard) = cache.lock() {
*guard = Some((std::time::Instant::now(), value));
}
value
}
#[cfg(feature = "android-jni")]
static SYSTEM_THEME_CACHE: crate::platform::environment_cache::EnvironmentFactCache<Option<bool>> =
crate::compat::OnceLock::new();
#[cfg(feature = "android-jni")]
static HIGH_CONTRAST_CACHE: crate::platform::environment_cache::EnvironmentFactCache<Option<bool>> =
crate::compat::OnceLock::new();
#[cfg(feature = "android-jni")]
static REDUCE_MOTION_CACHE: crate::platform::environment_cache::EnvironmentFactCache<Option<bool>> =
crate::compat::OnceLock::new();
#[cfg(feature = "android-jni")]
static LOCALE_CACHE: crate::platform::environment_cache::EnvironmentFactCache<
Option<&'static str>,
> = crate::compat::OnceLock::new();
impl Platform for AndroidPlatform {
crate::impl_platform_state_properties!();
fn as_any(&self) -> &dyn crate::compat::Any {
self
}
fn backend_name(&self) -> &'static str {
"android-state-backend"
}
fn family(&self) -> PlatformFamily {
PlatformFamily::Mobile
}
fn capabilities(&self) -> PlatformCapabilities {
#[cfg(feature = "android-jni")]
let clipboard = crate::platform::android_jni::clipboard_ready();
#[cfg(not(feature = "android-jni"))]
let clipboard = false;
PlatformCapabilities {
clipboard,
dpi_scaling: {
#[cfg(feature = "android-jni")]
{
crate::platform::android_jni::native_view_creation_ready()
}
#[cfg(not(feature = "android-jni"))]
{
false
}
},
ime: {
#[cfg(feature = "android-jni")]
{
crate::platform::android_jni::native_view_creation_ready()
}
#[cfg(not(feature = "android-jni"))]
{
false
}
},
system_fonts: cfg!(target_os = "android"),
file_dialog: {
#[cfg(feature = "android-jni")]
{
crate::platform::android_jni::picker_ready()
}
#[cfg(not(feature = "android-jni"))]
{
false
}
},
..crate::platform::types::default_capabilities_for(self.family())
}
}
fn total_memory_mb(&self) -> Option<u64> {
crate::platform::os_probes::total_memory_mb()
}
fn dpi_scale_factor(&self) -> f32 {
#[cfg(feature = "android-jni")]
{
cached_density_scale()
}
#[cfg(not(feature = "android-jni"))]
{
1.0
}
}
fn text_scale(&self) -> f32 {
#[cfg(feature = "android-jni")]
{
crate::platform::android_jni::font_scale().unwrap_or(1.0)
}
#[cfg(not(feature = "android-jni"))]
{
1.0
}
}
fn system_theme(&self) -> Option<bool> {
#[cfg(feature = "android-jni")]
{
crate::platform::environment_cache::cached_environment_fact(
&SYSTEM_THEME_CACHE,
crate::platform::android_jni::system_theme,
)
}
#[cfg(not(feature = "android-jni"))]
{
None
}
}
fn high_contrast(&self) -> Option<bool> {
#[cfg(feature = "android-jni")]
{
crate::platform::environment_cache::cached_environment_fact(
&HIGH_CONTRAST_CACHE,
crate::platform::android_jni::high_contrast,
)
}
#[cfg(not(feature = "android-jni"))]
{
None
}
}
fn reduce_motion(&self) -> Option<bool> {
#[cfg(feature = "android-jni")]
{
crate::platform::environment_cache::cached_environment_fact(
&REDUCE_MOTION_CACHE,
crate::platform::android_jni::reduce_motion,
)
}
#[cfg(not(feature = "android-jni"))]
{
None
}
}
fn system_locale(&self) -> Option<&'static str> {
#[cfg(feature = "android-jni")]
{
crate::platform::environment_cache::cached_environment_fact(&LOCALE_CACHE, || {
crate::platform::locale::intern(crate::platform::android_jni::system_locale())
})
}
#[cfg(not(feature = "android-jni"))]
{
None
}
}
fn environment_generation(&self) -> u64 {
crate::platform::locale::environment_revision(self)
}
fn system_fonts(&self) -> Vec<crate::platform::types::SystemFont> {
#[cfg(target_os = "android")]
{
let mut fonts: Vec<crate::platform::types::SystemFont> = Vec::new();
let Ok(entries) = std::fs::read_dir("/system/fonts") else {
return fonts;
};
for entry in entries.flatten() {
let path = entry.path();
let extension = path.extension().and_then(|value| value.to_str()).unwrap_or("");
if !extension.eq_ignore_ascii_case("ttf")
&& !extension.eq_ignore_ascii_case("otf")
&& !extension.eq_ignore_ascii_case("ttc")
{
continue;
}
let Some(family) = path.file_stem().and_then(|value| value.to_str()) else {
continue;
};
if family.is_empty()
|| fonts.iter().any(|known| known.family.eq_ignore_ascii_case(family))
{
continue;
}
fonts.push(crate::platform::types::SystemFont {
family: family.to_string(),
style: String::new(),
source: Some(path),
});
}
fonts
}
#[cfg(not(target_os = "android"))]
{
Vec::new()
}
}
fn safe_area_insets(
&self,
_window_id: crate::core::ObjectId,
) -> Option<crate::platform::types::SafeAreaInsets> {
#[cfg(feature = "android-jni")]
{
crate::platform::android_jni::window_insets().map(|(top, bottom, left, right)| {
crate::platform::types::SafeAreaInsets { top, bottom, left, right }
})
}
#[cfg(not(feature = "android-jni"))]
{
None
}
}
fn displays(&self) -> Vec<Display> {
#[cfg(feature = "android-jni")]
{
crate::platform::android_jni::display_info()
.map(|info| {
crate::compat::vec![Display {
id: DisplayId::new(info.id),
bounds: crate::core::Rect::new(0, 0, info.width, info.height),
work_area: crate::core::Rect::new(0, 0, info.work_width, info.work_height),
scale: info.scale,
is_primary: info.is_primary,
}]
})
.unwrap_or_default()
}
#[cfg(not(feature = "android-jni"))]
{
Vec::new()
}
}
fn display_info(&self, display: DisplayId) -> Option<crate::platform::types::DisplayInfo> {
let display_item = self.displays().into_iter().find(|item| item.id == display)?;
Some(crate::platform::types::DisplayInfo {
refresh_rate_mhz: None,
orientation: Some(if display_item.bounds.width >= display_item.bounds.height {
crate::core::Orientation::Horizontal
} else {
crate::core::Orientation::Vertical
}),
color_space: None,
hdr: None,
})
}
fn display_of_window(&self, window_id: crate::core::ObjectId) -> Option<DisplayId> {
let host = if self.kind_of(window_id).is_some() {
window_id
} else {
#[cfg(not(alloc_frugal))]
{
crate::widget::runtime::host_window_for(window_id).unwrap_or(window_id)
}
#[cfg(alloc_frugal)]
{
window_id
}
};
if !matches!(self.kind_of(host), Some(AndroidHandleKind::Window)) {
return None;
}
self.displays().first().map(|display| display.id)
}
fn display_generation(&self) -> u64 {
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
let Some(display) = self.displays().first().copied() else {
return 0;
};
let mut hash = FNV_OFFSET;
for part in [
display.id.get(),
display.bounds.width as u64,
display.bounds.height as u64,
display.work_area.width as u64,
display.work_area.height as u64,
display.scale.to_bits() as u64,
display.is_primary as u64,
] {
hash ^= part;
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
fn is_on_battery(&self) -> bool {
crate::platform::os_probes::is_on_battery()
}
fn process_memory_utilization(&self) -> Option<f32> {
crate::platform::os_probes::process_memory_utilization()
}
fn process_cpu_utilization(&self) -> Option<f32> {
crate::platform::os_probes::process_cpu_utilization()
}
fn spawn_print_job(&self, job_file: &std::path::Path) -> Result<(), String> {
#[cfg(feature = "android-jni")]
{
crate::platform::android_jni::print_file(job_file)
}
#[cfg(not(feature = "android-jni"))]
{
Err(format!(
"Android printing requires the JNI bridge (`android-jni`); job file '{}' was not \
printed",
job_file.display()
))
}
}
fn has_print_support(&self) -> bool {
#[cfg(feature = "android-jni")]
{
crate::platform::android_jni::print_available()
}
#[cfg(not(feature = "android-jni"))]
{
false
}
}
#[cfg(feature = "mobile-api")]
fn mobile_extension(&self) -> Option<&dyn crate::platform::contract::MobilePlatformExtension> {
Some(self)
}
fn init(&self) {
let _ = self.android_runtime_marker();
self.runtime.initialized.store(true, Ordering::SeqCst);
}
fn run(&self) {
if !self.runtime.initialized.load(Ordering::SeqCst) {
self.init();
}
self.runtime.running.store(true, Ordering::SeqCst);
while self.runtime.running.load(Ordering::SeqCst) {
let outcome = crate::drive_frame(FRAME_INTERVAL_MS as u32);
crate::platform::frame_wait::pause_between_frames(outcome.needs_another_frame);
}
}
fn quit(&self) -> bool {
self.runtime.running.store(false, Ordering::SeqCst);
crate::platform::frame_wait::wake();
true
}
fn destroy_widget(&self, widget_id: u64) -> bool {
lock(&self.menus).purge(widget_id);
self.state.destroy_widget(widget_id)
}
fn create_window(&self, title: &str, x: i32, y: i32, width: u32, height: u32) -> u64 {
if self.state.count_kind(AndroidHandleKind::Window) > 0 {
log::warn!(
"[android] create_window: this backend hosts one Activity window; the second
request returns 0 (BLUE28 B28.43)"
);
return 0;
}
self.insert_widget(AndroidHandleKind::Window, title, x, y, width, height)
}
fn supports_multiple_windows(&self) -> bool {
false
}
fn request_path(&self, purpose: crate::platform::PathPurpose, _title: &str) -> bool {
#[cfg(feature = "android-jni")]
{
if !matches!(purpose, crate::platform::PathPurpose::OpenFile) {
log::warn!(
"[android] request_path: only OpenFile is available; the document picker has
no save/directory mode in this bridge"
);
return false;
}
crate::platform::android_jni::launch_file_dialog("*/*")
}
#[cfg(not(feature = "android-jni"))]
{
let _ = purpose;
false
}
}
fn take_path_choice(&self) -> Option<crate::platform::PathChoice> {
#[cfg(feature = "android-jni")]
{
crate::platform::android_jni::take_path_choice()
}
#[cfg(not(feature = "android-jni"))]
{
None
}
}
fn create_menu_bar(&self, parent: u64, x: i32, y: i32, width: u32, height: u32) -> u64 {
if !matches!(self.kind_of(parent), Some(AndroidHandleKind::Window)) {
return 0;
}
self.insert_widget(AndroidHandleKind::MenuBar, "MenuBar", x, y, width, height)
}
fn create_menu(&self, parent: u64, text: &str, x: i32, y: i32, width: u32, height: u32) -> u64 {
if !matches!(
self.kind_of(parent),
Some(AndroidHandleKind::MenuBar) | Some(AndroidHandleKind::Menu)
) {
return 0;
}
self.insert_widget(AndroidHandleKind::Menu, text, x, y, width, height)
}
fn attach_menu_bar_to_window(&self, window: u64, menu_bar: u64) -> bool {
if !matches!(self.kind_of(window), Some(AndroidHandleKind::Window)) {
return false;
}
if !matches!(self.kind_of(menu_bar), Some(AndroidHandleKind::MenuBar)) {
return false;
}
let mut menus = lock(&self.menus);
menus.attached_menu_bar.insert(window, menu_bar);
true
}
fn menu_add_item(&self, parent_menu: u64, text: &str, shortcut: Option<&str>) -> u64 {
if !matches!(self.kind_of(parent_menu), Some(AndroidHandleKind::Menu)) {
return 0;
}
let id = self.insert_widget(AndroidHandleKind::MenuItem, text, 0, 0, 0, 0);
let display_text = match shortcut {
Some(shortcut) => format!("{} ({})", text, shortcut),
None => text.to_string(),
};
self.state.set_text(id, &display_text);
let mut menus = lock(&self.menus);
menus.menu_children.entry(parent_menu).or_default().push(id);
id
}
fn poll_menu_triggered(&self) -> Option<u64> {
let mut menus = lock(&self.menus);
menus.pending_menu_events.pop_front()
}
fn inject_menu_trigger(&self, menu_item_id: u64) -> bool {
if !matches!(self.kind_of(menu_item_id), Some(AndroidHandleKind::MenuItem)) {
return false;
}
let mut menus = lock(&self.menus);
menus.pending_menu_events.push_back(menu_item_id);
true
}
fn poll_widget_triggered(&self) -> Option<u64> {
self.state.pop_widget_trigger()
}
fn poll_widget_trigger_event(&self) -> Option<WidgetTriggerEvent> {
self.state.pop_widget_trigger_event()
}
fn inject_widget_trigger_event(&self, widget_id: u64, kind: WidgetTriggerKind) -> bool {
self.state.inject_widget_trigger_event(widget_id, kind)
}
fn mount_surface(&self, _parent: u64, id: u64, rect: crate::core::Rect) -> bool {
self.state.mount_surface_record(id, rect)
}
fn resize_surface(&self, id: u64, rect: crate::core::Rect) -> bool {
self.state.resize_surface_record(id, rect)
}
fn unmount_surface(&self, id: u64) -> bool {
self.state.unmount_surface_record(id)
}
fn window_client_size(&self, window_id: u64) -> Option<(u32, u32)> {
crate::window_client_size(window_id).or_else(|| self.state.window_size(window_id))
}
fn queue_resize_trigger(&self, window_id: u64, width: u32, height: u32) -> bool {
crate::queue_resize_trigger(window_id, width, height)
}
fn invalidate_surface(&self, id: u64) -> bool {
self.state.invalidate_surface_record(id)
}
fn take_pending_repaint(&self) -> Option<u64> {
self.state.take_pending_repaint()
}
fn supports_surfaces(&self) -> bool {
true
}
fn set_clipboard_text(&self, text: &str) -> bool {
#[cfg(feature = "android-jni")]
{
crate::platform::android_jni::set_clipboard_text(text)
.unwrap_or_else(|| self.state.set_clipboard_text(text))
}
#[cfg(not(feature = "android-jni"))]
{
self.state.set_clipboard_text(text)
}
}
fn get_clipboard_text(&self) -> String {
#[cfg(feature = "android-jni")]
{
crate::platform::android_jni::clipboard_text()
.unwrap_or_else(|| self.state.clipboard_text())
}
#[cfg(not(feature = "android-jni"))]
{
self.state.clipboard_text()
}
}
fn clipboard_text(&self) -> Option<String> {
#[cfg(feature = "android-jni")]
{
crate::platform::android_jni::clipboard_text()
}
#[cfg(not(feature = "android-jni"))]
{
let text = self.state.clipboard_text();
if text.is_empty() {
None
} else {
Some(text)
}
}
}
fn clipboard_backend(&self) -> Option<&dyn RichClipboardBackend> {
#[cfg(feature = "android-jni")]
{
if crate::platform::android_jni::clipboard_ready() {
static CLIPBOARD: std::sync::OnceLock<
crate::platform::android_jni::AndroidClipboard,
> = std::sync::OnceLock::new();
Some(CLIPBOARD.get_or_init(crate::platform::android_jni::AndroidClipboard::new))
} else {
None
}
}
#[cfg(not(feature = "android-jni"))]
{
None
}
}
#[cfg(feature = "android-jni")]
fn ime_bridge(&self) -> Option<&dyn crate::platform::ime::ImeBridge> {
if crate::platform::android_jni::native_view_creation_ready() {
Some(crate::platform::ime_android::AndroidImeBridge::bridge())
} else {
None
}
}
}
impl crate::platform::contract::MobilePlatformExtension for AndroidPlatform {
fn mobile_backend(&self) -> crate::platform::MobileBackend {
crate::platform::MobileBackend::Android
}
fn attach_to_native_view(&self, native_handle: usize) -> bool {
#[cfg(feature = "android-jni")]
{
if native_handle == 0 || !crate::platform::android_jni::is_initialized() {
return false;
}
let context_obj: jni::objects::JObject<'_> =
unsafe { jni::objects::JObject::from_raw(native_handle as jni::sys::jobject) };
crate::platform::android_jni::with_jni_env(|env| {
crate::platform::android_jni::set_activity_context(env, &context_obj)
})
.unwrap_or(false)
}
#[cfg(not(feature = "android-jni"))]
{
let _ = native_handle;
false
}
}
fn orientation(&self) -> Option<crate::platform::ScreenOrientation> {
crate::platform::lifecycle::orientation()
}
fn orientation_generation(&self) -> u64 {
self.display_generation().wrapping_add(crate::platform::lifecycle::rotation_generation())
}
fn lifecycle_state(&self) -> Option<crate::platform::MobileLifecycleState> {
crate::platform::lifecycle::lifecycle_state()
}
fn suspend(&self) -> bool {
crate::platform::lifecycle::set_state(crate::platform::MobileLifecycleState::Background)
}
fn resume(&self) -> bool {
crate::platform::lifecycle::set_state(crate::platform::MobileLifecycleState::Active)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn android_platform_window_creation() {
let platform = AndroidPlatform::new();
platform.init();
let window_id = platform.create_window("Test Window", 0, 0, 320, 568);
assert_ne!(window_id, 0);
assert_eq!(platform.backend_name(), "android-state-backend");
assert_eq!(platform.family(), PlatformFamily::Mobile);
}
#[test]
fn control_members_report_absence_for_every_parent() {
let platform = AndroidPlatform::new();
platform.init();
assert_eq!(platform.create_button(999, "Button", 0, 0, 80, 44), 0);
let window_id = platform.create_window("Window", 0, 0, 320, 568);
assert_ne!(window_id, 0);
assert_eq!(platform.create_button(window_id, "Button", 0, 0, 80, 44), 0);
}
#[test]
fn combo_box_data_members_report_absence() {
let platform = AndroidPlatform::new();
platform.init();
let window_id = platform.create_window("Window", 0, 0, 320, 568);
assert_eq!(platform.create_combo_box(window_id, 0, 0, 200, 40), 0);
assert!(!platform.combo_box_add_item(window_id, "Item 1"));
assert_eq!(platform.combo_box_item_count(window_id), 0);
assert!(!platform.combo_box_clear_items(window_id));
}
#[test]
fn android_menu_requires_correct_parent_kind() {
let platform = AndroidPlatform::new();
platform.init();
let window = platform.create_window("Window", 0, 0, 320, 568);
let button = platform.create_button(window, "Button", 0, 0, 80, 44);
assert_eq!(platform.create_menu_bar(button, 0, 0, 320, 24), 0);
let menu_bar = platform.create_menu_bar(window, 0, 0, 320, 24);
assert_ne!(menu_bar, 0);
assert_eq!(platform.create_menu(window, "Bad", 0, 0, 80, 24), 0);
assert_eq!(platform.create_menu(button, "Bad", 0, 0, 80, 24), 0);
let menu = platform.create_menu(menu_bar, "File", 0, 0, 80, 24);
assert_ne!(menu, 0);
let submenu = platform.create_menu(menu, "Recent", 0, 0, 80, 24);
assert_ne!(submenu, 0, "a menu may nest under another menu");
assert_eq!(platform.menu_add_item(window, "Bad", None), 0);
assert_eq!(platform.menu_add_item(menu_bar, "Bad", None), 0);
let item = platform.menu_add_item(menu, "Open", Some("Ctrl+O"));
assert_ne!(item, 0);
assert!(!platform.inject_menu_trigger(window));
assert!(!platform.inject_menu_trigger(menu_bar));
assert!(platform.inject_menu_trigger(item));
assert_eq!(platform.poll_menu_triggered(), Some(item));
assert!(!platform.attach_menu_bar_to_window(9999, menu_bar));
assert!(!platform.attach_menu_bar_to_window(window, item));
assert!(platform.attach_menu_bar_to_window(window, menu_bar));
}
#[test]
fn android_menu_item_retains_shortcut() {
let platform = AndroidPlatform::new();
platform.init();
let window = platform.create_window("Window", 0, 0, 320, 568);
let menu_bar = platform.create_menu_bar(window, 0, 0, 320, 24);
let menu = platform.create_menu(menu_bar, "File", 0, 0, 80, 24);
let plain = platform.menu_add_item(menu, "Open", None);
assert_eq!(platform.get_widget_text(plain), "Open");
let with_shortcut = platform.menu_add_item(menu, "Save", Some("Ctrl+S"));
assert_eq!(platform.get_widget_text(with_shortcut), "Save (Ctrl+S)");
}
#[test]
fn android_hosts_widget_surfaces_and_queues_repaints() {
let platform = AndroidPlatform::new();
let dyn_platform: &dyn Platform = &platform;
assert!(dyn_platform.supports_surfaces());
let window = dyn_platform.create_window("Window", 0, 0, 412, 915);
let rect = crate::core::Rect::new(0, 0, 120, 44);
assert!(dyn_platform.mount_surface(window, window, rect));
assert_eq!(platform.state.surface_rect(window), Some(rect));
assert!(dyn_platform.invalidate_surface(window));
assert_eq!(dyn_platform.take_pending_repaint(), Some(window));
assert_eq!(dyn_platform.take_pending_repaint(), None, "draining empties the queue");
let moved = crate::core::Rect::new(8, 8, 200, 80);
assert!(dyn_platform.resize_surface(window, moved));
assert_eq!(platform.state.surface_rect(window), Some(moved));
assert!(dyn_platform.unmount_surface(window));
assert_eq!(platform.state.surface_rect(window), None);
}
#[test]
fn android_refuses_a_surface_for_an_unknown_widget() {
let platform = AndroidPlatform::new();
assert!(!platform.mount_surface(1, 9_999, crate::core::Rect::new(0, 0, 10, 10)));
assert!(!platform.invalidate_surface(9_999));
assert_eq!(platform.state.mounted_surface_count(), 0);
}
}