From ec5c89dfe3ab399bcc90a01f03cf89873cb750b2 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Fri, 9 Oct 2026 09:52:46 +0000 Subject: [PATCH 1/3] android: Follow the system night mode on the host-driven path The host-driven entry point has no AndroidApp, and night mode was only read through its AssetManager, so a host-driven window stayed Light whatever the system used. Platform::window_appearance always answered Dark on either path. Night mode is now read from the application context's Resources when there is no AndroidApp. The first set_host_activity keeps that context for the life of the process. Its configuration follows system-wide changes whatever the Activity in front handles itself, whereas an Activity that handles uiMode keeps a stale configuration in its AssetManager. The host path applies it when a window first opens, when a recreated Activity's surface is attached, on resume, and, for every host's window, from the new host::configuration_changed(), which an Activity that handles uiMode must call from onConfigurationChanged. window_appearance reports the same night mode. Co-Authored-By: Claude Opus 5.5 --- src/android/host.rs | 25 ++++++- src/android/jni.rs | 144 +++++++++++++++++++++++++++++----------- src/android/platform.rs | 6 +- 3 files changed, 136 insertions(+), 39 deletions(-) diff --git a/src/android/host.rs b/src/android/host.rs index f6b9c53..8db3bcd 100644 --- a/src/android/host.rs +++ b/src/android/host.rs @@ -38,7 +38,9 @@ //! callback — so a host may stack several AI screens and each returns to its own //! panel. Reuse the same id across an Activity's configuration changes; pick a fresh //! one for a new instance. -//! 3. From `onResume` / `onPause`: [`resumed`] / [`paused`]. +//! 3. From `onResume` / `onPause`: [`resumed`] / [`paused`]. An Activity that +//! lists `uiMode` in `configChanges` must also call [`configuration_changed`] from +//! `onConfigurationChanged`, or its window keeps the old night mode until resumed. //! 4. From `onTouchEvent`, `dispatchKeyEvent` and the `InputConnection`: //! [`motion_event`], [`key`], [`ime_event`]. //! @@ -121,6 +123,7 @@ enum Command { Paused { host: HostId, }, + ConfigurationChanged, /// Input arrives on the Java UI thread but GPUI may only be touched from the /// render thread, so both go through the queue like everything else. Touch(TouchPoint), @@ -370,9 +373,18 @@ fn render_thread(launch: Launch) { // wake A back up right after it paused, purely because B's surface // has not arrived yet to take over as attached. if let Some(win) = app.windows.get(&host) { + // Night mode may have changed while another app was in front. + super::jni::sync_appearance(win); win.set_active(true); } } + Command::ConfigurationChanged => { + platform.notify_keyboard_layout_change(); + // Night mode is process-wide: stacked hosts' windows follow too. + for win in app.windows.values() { + super::jni::sync_appearance(win); + } + } Command::Paused { host } => { platform.did_enter_background(); if let Some(win) = app.windows.get(&host) { @@ -473,6 +485,9 @@ fn on_surface_created( return; } } + // A recreation is how an Activity that does not handle `uiMode` itself + // learns about a night mode change. + super::jni::sync_appearance(&existing); existing.set_active(true); return; } @@ -488,6 +503,7 @@ fn on_surface_created( } }; CURRENT_SURFACE.store(incoming, Ordering::SeqCst); + super::jni::sync_appearance(&win); win.set_active(true); app.windows.insert(host, Arc::clone(&win)); app.attached = Some(host); @@ -668,6 +684,13 @@ pub fn ime_event(session: u64, kind: i32, text: String, start: usize, end: usize }); } +/// Call from `Activity.onConfigurationChanged`, for an Activity that handles +/// configuration changes (`uiMode` for night mode) instead of being recreated. +/// Applies the system night mode to every host's window. +pub fn configuration_changed() { + post(Command::ConfigurationChanged); +} + pub fn resumed(host: HostId) { post(Command::Resumed { host }); } diff --git a/src/android/jni.rs b/src/android/jni.rs index 03ff1df..d774ec5 100644 --- a/src/android/jni.rs +++ b/src/android/jni.rs @@ -330,8 +330,21 @@ fn register_host_activity(env: &mut jni::Env<'_>, activity: &JObject<'_>) -> Res slot.previous = slot.current.take(); slot.current = Some(global); drop(slot); - if HOST_ASSETS.get().is_none() { - match host_assets(env, activity) { + if HOST_APPLICATION.get().is_none() { + match application_context(env, activity) { + Ok(context) => { + let _ = HOST_APPLICATION.set(context); + } + Err(err) => { + if env.exception_check() { + env.exception_clear(); + } + log::warn!("set_host_activity: no application context: {err}"); + } + } + } + if let (None, Some(context)) = (HOST_ASSETS.get(), HOST_APPLICATION.get()) { + match host_assets(env, context) { Ok(assets) => { let _ = HOST_ASSETS.set(assets); super::platform::load_deferred_emoji_font(); @@ -347,9 +360,33 @@ fn register_host_activity(env: &mut jni::Env<'_>, activity: &JObject<'_>) -> Res Ok(()) } +/// The application context, taken from the first host Activity and kept for the +/// life of the process. Its configuration follows system-wide changes such as night +/// mode, whichever Activity is in front and whatever it handles itself. +static HOST_APPLICATION: OnceLock>> = OnceLock::new(); + +fn application_context( + env: &mut jni::Env<'_>, + activity: &JObject<'_>, +) -> Result>, String> { + let context = env + .call_method( + activity, + jni::jni_str!("getApplicationContext"), + jni::jni_sig!("()Landroid/content/Context;"), + &[], + ) + .and_then(|value| value.l()) + .e()?; + if context.is_null() { + return Err("getApplicationContext returned null".into()); + } + env.new_global_ref(&context).e() +} + /// The application's `AssetManager`, which `android-activity` provides on the -/// other path. Taken once, from the first host Activity's application context, and -/// kept for the life of the process. +/// other path. Taken once, from the host application context, and kept for the +/// life of the process. static HOST_ASSETS: OnceLock = OnceLock::new(); struct HostAssets { @@ -358,31 +395,22 @@ struct HostAssets { native: ndk::asset::AssetManager, } -fn host_assets(env: &mut jni::Env<'_>, activity: &JObject<'_>) -> Result { - // A local frame frees `context` and `assets` even when the caller stays in - // native code, e.g. on a native-attached thread. - env.with_local_frame(2, |env| { - Ok::<_, jni::errors::Error>(host_assets_in_frame(env, activity)) +fn host_assets(env: &mut jni::Env<'_>, context: &JObject<'_>) -> Result { + // A local frame frees `assets` even when the caller stays in native code, e.g. + // on a native-attached thread. + env.with_local_frame(1, |env| { + Ok::<_, jni::errors::Error>(host_assets_in_frame(env, context)) }) .e()? } fn host_assets_in_frame( env: &mut jni::Env<'_>, - activity: &JObject<'_>, + context: &JObject<'_>, ) -> Result { - let context = env - .call_method( - activity, - jni::jni_str!("getApplicationContext"), - jni::jni_sig!("()Landroid/content/Context;"), - &[], - ) - .and_then(|value| value.l()) - .e()?; let assets = env .call_method( - &context, + context, jni::jni_str!("getAssets"), jni::jni_sig!("()Landroid/content/res/AssetManager;"), &[], @@ -495,25 +523,73 @@ const AMOTION_EVENT_ACTION_UP: u32 = 1; const AMOTION_EVENT_ACTION_MOVE: u32 = 2; const AMOTION_EVENT_ACTION_CANCEL: u32 = 3; -// ── night mode query via NDK Configuration ─────────────────────────────────── +// ── night mode query ───────────────────────────────────────────────────────── -/// Query the current night mode using the NDK Configuration API. +/// Query the current night mode. /// /// Returns `true` if the system is in dark mode. pub fn query_night_mode_via_jni() -> bool { - let app = match android_app() { - Some(app) => app, - None => return false, + let is_dark = if let Some(app) = android_app() { + // Build an ndk::configuration::Configuration from the app's asset manager. + let config = ndk::configuration::Configuration::from_asset_manager(&app.asset_manager()); + config.ui_mode_night() == ndk::configuration::UiModeNight::Yes + } else { + // No AndroidApp on the host-driven path: ask the application's Resources. + // An Activity that handles `uiMode` itself keeps a stale configuration in its + // AssetManager. + host_ui_mode().is_ok_and(|ui_mode| ui_mode & UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES) }; - // Build an ndk::configuration::Configuration from the app's asset manager. - let config = ndk::configuration::Configuration::from_asset_manager(&app.asset_manager()); - let is_dark = config.ui_mode_night() == ndk::configuration::UiModeNight::Yes; - - log::debug!("query_night_mode (ndk): is_dark={}", is_dark); + log::debug!("query_night_mode: is_dark={}", is_dark); is_dark } +/// `Configuration.UI_MODE_NIGHT_MASK` / `UI_MODE_NIGHT_YES`. +const UI_MODE_NIGHT_MASK: i32 = 0x30; +const UI_MODE_NIGHT_YES: i32 = 0x20; + +/// `getResources().getConfiguration().uiMode` of the application context. +fn host_ui_mode() -> Result { + let context = HOST_APPLICATION + .get() + .ok_or("set_host_activity has not been called")?; + with_env(|env| { + let result = (|| { + let resources = env + .call_method( + context, + jni::jni_str!("getResources"), + jni::jni_sig!("()Landroid/content/res/Resources;"), + &[], + )? + .l()?; + let config = env + .call_method( + &resources, + jni::jni_str!("getConfiguration"), + jni::jni_sig!("()Landroid/content/res/Configuration;"), + &[], + )? + .l()?; + env.get_field(&config, jni::jni_str!("uiMode"), jni::jni_sig!("I"))? + .i() + })(); + if result.is_err() { + env.exception_clear(); + } + result.e() + }) +} + +/// Apply the system night mode to a window. +pub(crate) fn sync_appearance(window: &crate::android::window::AndroidWindow) { + window.set_appearance(if query_night_mode_via_jni() { + crate::android::window::WindowAppearance::Dark + } else { + crate::android::window::WindowAppearance::Light + }); +} + // ── input event processing ──────────────────────────────────────────────────── /// Process input events from the `AndroidApp` and dispatch them to the window. @@ -869,14 +945,8 @@ pub fn run_event_loop(app: &AndroidApp) { log::debug!("deferred: ConfigChanged"); if let Some(platform) = PLATFORM.get() { platform.notify_keyboard_layout_change(); - let is_dark = query_night_mode_via_jni(); if let Some(win) = platform.primary_window() { - let appearance = if is_dark { - crate::android::window::WindowAppearance::Dark - } else { - crate::android::window::WindowAppearance::Light - }; - win.set_appearance(appearance); + sync_appearance(&win); } } } diff --git a/src/android/platform.rs b/src/android/platform.rs index eaafa6e..2bf5744 100644 --- a/src/android/platform.rs +++ b/src/android/platform.rs @@ -1178,7 +1178,11 @@ impl Platform for AndroidPlatform { } fn window_appearance(&self) -> WindowAppearance { - WindowAppearance::Dark + if crate::android::jni::query_night_mode_via_jni() { + WindowAppearance::Dark + } else { + WindowAppearance::Light + } } fn open_url(&self, url: &str) { From fa0075b137437b34daeaf6a0d8d4a33686423384 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Tue, 13 Oct 2026 12:55:02 +0000 Subject: [PATCH 2/3] android: Keep night mode in the platform, and sync it where windows open Review fixes for #30: - AndroidPlatform::open_window and the new reinit_window apply the night mode, so the android-activity InitWindow path no longer starts Light while window_appearance says Dark. host.rs drops its call-site syncs. - The host path reads uiMode from the current host Activity's Resources, which carry an app-local night mode (AppCompatDelegate). The application context is no longer kept; the AssetManager is fetched as on main. - host::configuration_changed takes newConfig.uiMode, so it does not depend on when the process configuration is updated. - The platform stores the appearance it last applied. window_appearance returns it with no JNI call, and agrees with every window's appearance. It has its own lock: GPUI reads it from main-thread tasks, which flush_main_thread_tasks() runs with the platform state locked. A config change reads the night mode once for all windows. - A failed uiMode read logs a warning instead of silently meaning Light. It runs in a local frame, since the render thread never returns to Java. Co-Authored-By: Claude Opus 5.5 --- src/android/host.rs | 36 ++++++------ src/android/jni.rs | 123 ++++++++++++++++++---------------------- src/android/platform.rs | 60 +++++++++++++++----- src/android/window.rs | 21 ++++--- 4 files changed, 131 insertions(+), 109 deletions(-) diff --git a/src/android/host.rs b/src/android/host.rs index 8db3bcd..816684a 100644 --- a/src/android/host.rs +++ b/src/android/host.rs @@ -40,7 +40,8 @@ //! one for a new instance. //! 3. From `onResume` / `onPause`: [`resumed`] / [`paused`]. An Activity that //! lists `uiMode` in `configChanges` must also call [`configuration_changed`] from -//! `onConfigurationChanged`, or its window keeps the old night mode until resumed. +//! `onConfigurationChanged` with `newConfig.uiMode`, or its window keeps the old +//! night mode until resumed. //! 4. From `onTouchEvent`, `dispatchKeyEvent` and the `InputConnection`: //! [`motion_event`], [`key`], [`ime_event`]. //! @@ -123,7 +124,9 @@ enum Command { Paused { host: HostId, }, - ConfigurationChanged, + ConfigurationChanged { + ui_mode: i32, + }, /// Input arrives on the Java UI thread but GPUI may only be touched from the /// render thread, so both go through the queue like everything else. Touch(TouchPoint), @@ -373,17 +376,15 @@ fn render_thread(launch: Launch) { // wake A back up right after it paused, purely because B's surface // has not arrived yet to take over as attached. if let Some(win) = app.windows.get(&host) { - // Night mode may have changed while another app was in front. - super::jni::sync_appearance(win); win.set_active(true); } + // Night mode may have changed while another app was in front. + platform.refresh_appearance(); } - Command::ConfigurationChanged => { + Command::ConfigurationChanged { ui_mode } => { platform.notify_keyboard_layout_change(); - // Night mode is process-wide: stacked hosts' windows follow too. - for win in app.windows.values() { - super::jni::sync_appearance(win); - } + // Stacked hosts' windows follow too. + platform.set_appearance(super::jni::appearance_from_ui_mode(ui_mode)); } Command::Paused { host } => { platform.did_enter_background(); @@ -466,8 +467,7 @@ fn on_surface_created( // This host has been here before — a recreated or resumed Activity. Re-attach // its own window, so it comes back to the panel it left rather than whichever // one happened to run last. - let gpu = platform.gpu_context(); - match existing.init_window(window, gpu) { + match platform.reinit_window(&existing, window) { Ok(()) => { CURRENT_SURFACE.store(incoming, Ordering::SeqCst); app.attached = Some(host); @@ -485,9 +485,6 @@ fn on_surface_created( return; } } - // A recreation is how an Activity that does not handle `uiMode` itself - // learns about a night mode change. - super::jni::sync_appearance(&existing); existing.set_active(true); return; } @@ -503,7 +500,6 @@ fn on_surface_created( } }; CURRENT_SURFACE.store(incoming, Ordering::SeqCst); - super::jni::sync_appearance(&win); win.set_active(true); app.windows.insert(host, Arc::clone(&win)); app.attached = Some(host); @@ -684,11 +680,11 @@ pub fn ime_event(session: u64, kind: i32, text: String, start: usize, end: usize }); } -/// Call from `Activity.onConfigurationChanged`, for an Activity that handles -/// configuration changes (`uiMode` for night mode) instead of being recreated. -/// Applies the system night mode to every host's window. -pub fn configuration_changed() { - post(Command::ConfigurationChanged); +/// Call from `Activity.onConfigurationChanged` with `newConfig.uiMode`, for an +/// Activity that handles configuration changes (`uiMode` for night mode) instead of +/// being recreated. Applies that night mode to every host's window. +pub fn configuration_changed(ui_mode: i32) { + post(Command::ConfigurationChanged { ui_mode }); } pub fn resumed(host: HostId) { diff --git a/src/android/jni.rs b/src/android/jni.rs index d774ec5..97f9d03 100644 --- a/src/android/jni.rs +++ b/src/android/jni.rs @@ -330,21 +330,8 @@ fn register_host_activity(env: &mut jni::Env<'_>, activity: &JObject<'_>) -> Res slot.previous = slot.current.take(); slot.current = Some(global); drop(slot); - if HOST_APPLICATION.get().is_none() { - match application_context(env, activity) { - Ok(context) => { - let _ = HOST_APPLICATION.set(context); - } - Err(err) => { - if env.exception_check() { - env.exception_clear(); - } - log::warn!("set_host_activity: no application context: {err}"); - } - } - } - if let (None, Some(context)) = (HOST_ASSETS.get(), HOST_APPLICATION.get()) { - match host_assets(env, context) { + if HOST_ASSETS.get().is_none() { + match host_assets(env, activity) { Ok(assets) => { let _ = HOST_ASSETS.set(assets); super::platform::load_deferred_emoji_font(); @@ -360,33 +347,9 @@ fn register_host_activity(env: &mut jni::Env<'_>, activity: &JObject<'_>) -> Res Ok(()) } -/// The application context, taken from the first host Activity and kept for the -/// life of the process. Its configuration follows system-wide changes such as night -/// mode, whichever Activity is in front and whatever it handles itself. -static HOST_APPLICATION: OnceLock>> = OnceLock::new(); - -fn application_context( - env: &mut jni::Env<'_>, - activity: &JObject<'_>, -) -> Result>, String> { - let context = env - .call_method( - activity, - jni::jni_str!("getApplicationContext"), - jni::jni_sig!("()Landroid/content/Context;"), - &[], - ) - .and_then(|value| value.l()) - .e()?; - if context.is_null() { - return Err("getApplicationContext returned null".into()); - } - env.new_global_ref(&context).e() -} - /// The application's `AssetManager`, which `android-activity` provides on the -/// other path. Taken once, from the host application context, and kept for the -/// life of the process. +/// other path. Taken once, from the first host Activity's application context, and +/// kept for the life of the process. static HOST_ASSETS: OnceLock = OnceLock::new(); struct HostAssets { @@ -395,22 +358,31 @@ struct HostAssets { native: ndk::asset::AssetManager, } -fn host_assets(env: &mut jni::Env<'_>, context: &JObject<'_>) -> Result { - // A local frame frees `assets` even when the caller stays in native code, e.g. - // on a native-attached thread. - env.with_local_frame(1, |env| { - Ok::<_, jni::errors::Error>(host_assets_in_frame(env, context)) +fn host_assets(env: &mut jni::Env<'_>, activity: &JObject<'_>) -> Result { + // A local frame frees `context` and `assets` even when the caller stays in + // native code, e.g. on a native-attached thread. + env.with_local_frame(2, |env| { + Ok::<_, jni::errors::Error>(host_assets_in_frame(env, activity)) }) .e()? } fn host_assets_in_frame( env: &mut jni::Env<'_>, - context: &JObject<'_>, + activity: &JObject<'_>, ) -> Result { + let context = env + .call_method( + activity, + jni::jni_str!("getApplicationContext"), + jni::jni_sig!("()Landroid/content/Context;"), + &[], + ) + .and_then(|value| value.l()) + .e()?; let assets = env .call_method( - context, + &context, jni::jni_str!("getAssets"), jni::jni_sig!("()Landroid/content/res/AssetManager;"), &[], @@ -534,10 +506,15 @@ pub fn query_night_mode_via_jni() -> bool { let config = ndk::configuration::Configuration::from_asset_manager(&app.asset_manager()); config.ui_mode_night() == ndk::configuration::UiModeNight::Yes } else { - // No AndroidApp on the host-driven path: ask the application's Resources. - // An Activity that handles `uiMode` itself keeps a stale configuration in its - // AssetManager. - host_ui_mode().is_ok_and(|ui_mode| ui_mode & UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES) + // No AndroidApp on the host-driven path: ask the host Activity's Resources, + // which also carry an app-local night mode (`AppCompatDelegate`). + match host_ui_mode() { + Ok(ui_mode) => is_night(ui_mode), + Err(err) => { + log::warn!("query_night_mode: cannot read uiMode, assuming Light: {err}"); + false + } + } }; log::debug!("query_night_mode: is_dark={}", is_dark); @@ -548,16 +525,20 @@ pub fn query_night_mode_via_jni() -> bool { const UI_MODE_NIGHT_MASK: i32 = 0x30; const UI_MODE_NIGHT_YES: i32 = 0x20; -/// `getResources().getConfiguration().uiMode` of the application context. +fn is_night(ui_mode: i32) -> bool { + ui_mode & UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES +} + +/// `getResources().getConfiguration().uiMode` of the current host Activity. fn host_ui_mode() -> Result { - let context = HOST_APPLICATION - .get() - .ok_or("set_host_activity has not been called")?; with_env(|env| { - let result = (|| { + let activity = activity(env)?; + // A local frame frees `resources` and `config`: the render thread never + // returns to Java. + let result = env.with_local_frame(2, |env| { let resources = env .call_method( - context, + &activity, jni::jni_str!("getResources"), jni::jni_sig!("()Landroid/content/res/Resources;"), &[], @@ -573,7 +554,7 @@ fn host_ui_mode() -> Result { .l()?; env.get_field(&config, jni::jni_str!("uiMode"), jni::jni_sig!("I"))? .i() - })(); + }); if result.is_err() { env.exception_clear(); } @@ -581,13 +562,22 @@ fn host_ui_mode() -> Result { }) } -/// Apply the system night mode to a window. -pub(crate) fn sync_appearance(window: &crate::android::window::AndroidWindow) { - window.set_appearance(if query_night_mode_via_jni() { +/// The system night mode as a window appearance. +pub(crate) fn query_appearance() -> crate::android::window::WindowAppearance { + appearance(query_night_mode_via_jni()) +} + +/// The appearance a `Configuration.uiMode` asks for. +pub(crate) fn appearance_from_ui_mode(ui_mode: i32) -> crate::android::window::WindowAppearance { + appearance(is_night(ui_mode)) +} + +fn appearance(is_dark: bool) -> crate::android::window::WindowAppearance { + if is_dark { crate::android::window::WindowAppearance::Dark } else { crate::android::window::WindowAppearance::Light - }); + } } // ── input event processing ──────────────────────────────────────────────────── @@ -884,8 +874,7 @@ pub fn run_event_loop(app: &AndroidApp) { .unwrap_or(1.0); if let Some(existing) = platform.primary_window() { - let gpu_ctx = platform.gpu_context(); - match existing.init_window(native_window, gpu_ctx) { + match platform.reinit_window(&existing, native_window) { Ok(()) => { log::info!("InitWindow: reinitialised existing window"); } @@ -945,9 +934,7 @@ pub fn run_event_loop(app: &AndroidApp) { log::debug!("deferred: ConfigChanged"); if let Some(platform) = PLATFORM.get() { platform.notify_keyboard_layout_change(); - if let Some(win) = platform.primary_window() { - sync_appearance(&win); - } + platform.refresh_appearance(); } } diff --git a/src/android/platform.rs b/src/android/platform.rs index 2bf5744..419f3db 100644 --- a/src/android/platform.rs +++ b/src/android/platform.rs @@ -218,6 +218,11 @@ pub struct AndroidPlatform { state: Mutex, /// Set to `true` when `quit()` is called; the main loop checks this. should_quit: AtomicBool, + /// The system night mode, as last applied to every window. What + /// `Platform::window_appearance` answers, so it agrees with each window's + /// `appearance()` and costs no JNI call. Not in `state`: GPUI asks for it from + /// main-thread tasks, which `flush_main_thread_tasks` runs with `state` locked. + appearance: Mutex, } /// Check whether a TrueType/OpenType font file contains CBDT (Color Bitmap @@ -486,6 +491,7 @@ impl AndroidPlatform { preferred_backend: AndroidBackend::Vulkan, }), should_quit: AtomicBool::new(false), + appearance: Mutex::default(), } } @@ -646,22 +652,54 @@ impl AndroidPlatform { scale_factor: f32, transparent: bool, ) -> Result> { - let mut state = self.state.lock(); - let window = AndroidWindow::new( - native_window, - Rc::clone(&state.gpu_context), - scale_factor, - transparent, - )?; - state.windows.push(Arc::clone(&window)); + let window = { + let mut state = self.state.lock(); + let window = AndroidWindow::new( + native_window, + Rc::clone(&state.gpu_context), + scale_factor, + transparent, + )?; + state.windows.push(Arc::clone(&window)); + window + }; log::info!( "AndroidPlatform::open_window — id={:#x} scale={:.1}", window.id(), scale_factor ); + self.refresh_appearance(); Ok(window) } + /// Give an existing window a new surface, as when its Activity is recreated, and + /// re-read night mode, which may have changed while it had none. + pub fn reinit_window( + &self, + window: &AndroidWindow, + native_window: ndk::native_window::NativeWindow, + ) -> Result<()> { + window.init_window(native_window, self.gpu_context())?; + self.refresh_appearance(); + Ok(()) + } + + /// Read the system night mode and apply it to every window. + pub fn refresh_appearance(&self) { + self.set_appearance(super::jni::query_appearance()); + } + + /// Apply `appearance` to every window, and answer it from + /// `Platform::window_appearance` from now on. + pub fn set_appearance(&self, appearance: super::window::WindowAppearance) { + *self.appearance.lock() = appearance; + let windows: Vec<_> = self.state.lock().windows.iter().cloned().collect(); + // Outside the lock: a window's appearance callback calls into GPUI. + for window in windows { + window.set_appearance(appearance); + } + } + /// Name the window the next [`Platform::open_window`] should return. /// /// Cleared by that call. See [`AndroidPlatformState::pending_window`]. @@ -1178,11 +1216,7 @@ impl Platform for AndroidPlatform { } fn window_appearance(&self) -> WindowAppearance { - if crate::android::jni::query_night_mode_via_jni() { - WindowAppearance::Dark - } else { - WindowAppearance::Light - } + (*self.appearance.lock()).into() } fn open_url(&self, url: &str) { diff --git a/src/android/window.rs b/src/android/window.rs index efdcee0..c9b0076 100644 --- a/src/android/window.rs +++ b/src/android/window.rs @@ -278,6 +278,18 @@ pub enum WindowAppearance { HighContrastDark, } +impl From for gpui::WindowAppearance { + fn from(appearance: WindowAppearance) -> Self { + match appearance { + WindowAppearance::Dark => gpui::WindowAppearance::Dark, + WindowAppearance::HighContrastDark => gpui::WindowAppearance::VibrantDark, + WindowAppearance::Light | WindowAppearance::HighContrastLight => { + gpui::WindowAppearance::Light + } + } + } +} + // ── window state (interior-mutable) ────────────────────────────────────────── /// Safe area insets in physical (device) pixels. @@ -1501,14 +1513,7 @@ impl PlatformWindow for AndroidPlatformWindow { } fn appearance(&self) -> gpui::WindowAppearance { - let local_appearance = self.window.appearance(); - match local_appearance { - WindowAppearance::Dark => gpui::WindowAppearance::Dark, - WindowAppearance::HighContrastDark => gpui::WindowAppearance::VibrantDark, - WindowAppearance::Light | WindowAppearance::HighContrastLight => { - gpui::WindowAppearance::Light - } - } + self.window.appearance().into() } fn display(&self) -> Option> { From 1dcc3b16df8bde05b8b9d44c3767814fa05d29af Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 14 Oct 2026 12:05:27 +0800 Subject: [PATCH 3/3] Read host night mode from the application context --- src/android/jni.rs | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/android/jni.rs b/src/android/jni.rs index 97f9d03..8312d81 100644 --- a/src/android/jni.rs +++ b/src/android/jni.rs @@ -292,6 +292,10 @@ static HOST_ACTIVITY: Mutex = Mutex::new(HostActivity { previous: None, }); +/// Application context kept independently of the newest Activity. Its Resources +/// follow system configuration, without an Activity's local night-mode override. +static HOST_CONTEXT: OnceLock>> = OnceLock::new(); + struct HostActivity { current: Option>>, previous: Option>>, @@ -326,6 +330,22 @@ fn register_host_activity(env: &mut jni::Env<'_>, activity: &JObject<'_>) -> Res vm.get_raw() as *mut c_void, std::sync::atomic::Ordering::SeqCst, ); + if HOST_CONTEXT.get().is_none() { + let context = env + .with_local_frame(1, |env| { + let context = env + .call_method( + activity, + jni::jni_str!("getApplicationContext"), + jni::jni_sig!("()Landroid/content/Context;"), + &[], + )? + .l()?; + env.new_global_ref(&context) + }) + .e()?; + let _ = HOST_CONTEXT.set(context); + } let mut slot = HOST_ACTIVITY.lock().expect("poisoned"); slot.previous = slot.current.take(); slot.current = Some(global); @@ -506,8 +526,8 @@ pub fn query_night_mode_via_jni() -> bool { let config = ndk::configuration::Configuration::from_asset_manager(&app.asset_manager()); config.ui_mode_night() == ndk::configuration::UiModeNight::Yes } else { - // No AndroidApp on the host-driven path: ask the host Activity's Resources, - // which also carry an app-local night mode (`AppCompatDelegate`). + // No AndroidApp on the host-driven path: query the application context, + // independent of the last Activity registered or its local night mode. match host_ui_mode() { Ok(ui_mode) => is_night(ui_mode), Err(err) => { @@ -529,16 +549,18 @@ fn is_night(ui_mode: i32) -> bool { ui_mode & UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES } -/// `getResources().getConfiguration().uiMode` of the current host Activity. +/// `getResources().getConfiguration().uiMode` of the application context. fn host_ui_mode() -> Result { with_env(|env| { - let activity = activity(env)?; + let context = HOST_CONTEXT + .get() + .ok_or("Application context not available")?; // A local frame frees `resources` and `config`: the render thread never // returns to Java. let result = env.with_local_frame(2, |env| { let resources = env .call_method( - &activity, + context, jni::jni_str!("getResources"), jni::jni_sig!("()Landroid/content/res/Resources;"), &[],