From b9faadafaf467756403408519fbccc998c257688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B6=82=E9=98=B3=E5=B7=9D?= Date: Thu, 8 Oct 2026 17:53:54 +0800 Subject: [PATCH 1/4] android: One GPUI window per host, not one per process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host path opened a single window and re-attached every incoming surface to it. That is right for one Activity, and wrong the moment a host stacks two: both Activities' `SurfaceView`s end up driving the same panel, so walking back to the first shows whatever the second left behind. A host that wants ordinary → AI → ordinary → AI cannot build it on that. Surfaces now arrive tagged with a `HostId` the host assigns per Activity, and each distinct id gets a window of its own. A returning id re-attaches to the window it left; a new one opens another. `host_destroyed` drops a window when its Activity finishes for good. Several things made this more than bookkeeping: `Platform::open_window` answered every request with `primary`, since GPUI asks for "a window" with no way to name a surface. A second `cx.open_window` would have rendered the new panel into the first host's surface. The platform now carries a pending slot the host sets just before asking GPUI to open. The one-shot `launch` closure could build exactly one window. UI construction moves to `on_open_window(Fn(HostId, &mut App))`, called once per host with the id passed in rather than read back out of a global; `launch` keeps the process-wide setup it always did. Later windows go through `ApplicationHandle::update`. `Resumed` / `Paused` act on their own host only. Stacking two Activities interleaves as `A.onPause -> B.onResume -> A.onStop`, so a global resume woke A back up right after it paused, purely because B's surface had not arrived yet to take over as attached. Input and drawing follow the *attached* window rather than `primary`. Android tears down a covered Activity's surface, so at most one host is attached at a time — which is also why the memory a second window adds is a renderer and an atlas, not a swapchain. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/android/host.rs | 233 +++++++++++++++++++++++++++++++--------- src/android/platform.rs | 30 +++++- 2 files changed, 212 insertions(+), 51 deletions(-) diff --git a/src/android/host.rs b/src/android/host.rs index ec706de1..96bb263d 100644 --- a/src/android/host.rs +++ b/src/android/host.rs @@ -27,7 +27,14 @@ //! [`super::jni::set_host_activity`] from a JNI entry point, then [`start`] (a no-op //! after the first call). //! 2. From `SurfaceHolder.Callback`: [`surface_created`] in both `surfaceCreated` and -//! `surfaceChanged`, [`surface_destroyed`] in `surfaceDestroyed`. +//! `surfaceChanged`, [`surface_destroyed`] in `surfaceDestroyed`, and +//! [`host_destroyed`] from `onDestroy` when the Activity is finishing. +//! +//! Each of those takes a [`HostId`] the host assigns per Activity instance. Every +//! distinct id gets a GPUI window of its own — built by the [`on_open_window`] +//! 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`]. //! 4. From `onTouchEvent`, `dispatchKeyEvent` and the `InputConnection`: //! [`motion_event`], [`key`], [`ime_event`]. @@ -92,12 +99,23 @@ unsafe impl Send for LooperPtr {} enum Command { SurfaceCreated { + host: HostId, window: NativeWindow, scale: f32, }, - SurfaceDestroyed, - Resumed, - Paused, + SurfaceDestroyed { + host: HostId, + }, + /// The Activity is gone for good — drop its window and everything it holds. + HostDestroyed { + host: HostId, + }, + Resumed { + host: HostId, + }, + Paused { + host: HostId, + }, /// 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), @@ -121,7 +139,21 @@ pub struct Pointer { pub y: f32, } +/// Identifies one host surface owner — an Activity instance, typically. +/// +/// The host picks the value and must keep it stable across the Activity's +/// `surfaceDestroyed` / `surfaceCreated` cycles: that is what tells us "this is the +/// same Activity coming back" rather than a new one, and so which window to re-attach. +pub type HostId = u64; + type Launch = Box; + +/// Builds the UI for one host window. Called once per [`HostId`], on the render thread, +/// with the platform primed to hand GPUI that host's surface. +type OpenWindow = Arc; + +/// Registered by [`on_open_window`]. +static OPEN_WINDOW: Mutex> = Mutex::new(None); /// Applied to the `Application` before `run_embedded`, so a host can register things /// that must exist before the first frame — an asset source, most importantly. type Configure = Box Application + Send>; @@ -136,6 +168,13 @@ struct HostApp { /// Keeps the `App` alive: `Platform::run` returns immediately on this path, so /// nothing else holds the `Rc`. handle: Option, + /// One window per host, kept across that host's surface teardowns so a returning + /// Activity finds its own panel rather than whatever ran last. + windows: std::collections::HashMap>, + /// The host whose surface is live. Android destroys a covered Activity's surface, + /// so at most one is attached at a time — which makes it the only sensible target + /// for input and the only window worth drawing. + attached: Option, } fn post(command: Command) { @@ -206,6 +245,8 @@ fn render_thread(launch: Launch) { let mut app = HostApp { launch: Some(launch), handle: None, + windows: std::collections::HashMap::new(), + attached: None, }; loop { @@ -216,35 +257,60 @@ fn render_thread(launch: Launch) { let commands: Vec = COMMANDS.lock().expect("poisoned").drain(..).collect(); for command in commands { match command { - Command::SurfaceCreated { window, scale } => { - on_surface_created(&platform, window, scale, &mut app); + Command::SurfaceCreated { + host, + window, + scale, + } => { + on_surface_created(&platform, host, window, scale, &mut app); } - Command::SurfaceDestroyed => { - if let Some(win) = platform.primary_window() { + Command::SurfaceDestroyed { host } => { + if let Some(win) = app.windows.get(&host) { // Keeps the renderer (and its atlas) alive; only the surface goes. win.term_window(); } - CURRENT_SURFACE.store(std::ptr::null_mut(), Ordering::SeqCst); + if app.attached == Some(host) { + app.attached = None; + CURRENT_SURFACE.store(std::ptr::null_mut(), Ordering::SeqCst); + } SURFACE_RELEASED.store(true, Ordering::SeqCst); } - Command::Resumed => { + Command::HostDestroyed { host } => { + if let Some(win) = app.windows.remove(&host) { + win.term_window(); + platform.close_window(win.id()); + log::info!( + "gpui-main: host {host} closed, {} window(s) left", + app.windows.len() + ); + } + if app.attached == Some(host) { + app.attached = None; + CURRENT_SURFACE.store(std::ptr::null_mut(), Ordering::SeqCst); + } + } + Command::Resumed { host } => { platform.did_become_active(); - if let Some(win) = platform.primary_window() { + // Only this host's window. Stacking two Activities interleaves as + // `A.onPause → B.onResume → A.onStop`, so a global "resume" would + // 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) { win.set_active(true); } } - Command::Paused => { + Command::Paused { host } => { platform.did_enter_background(); - if let Some(win) = platform.primary_window() { + if let Some(win) = app.windows.get(&host) { win.set_active(false); } } - Command::Touch(point) => match platform.primary_window() { + Command::Touch(point) => match attached_window(&app) { Some(win) => win.handle_touch(point), - None => log::warn!("gpui-main: touch dropped — no window"), + None => log::warn!("gpui-main: touch dropped — no attached window"), }, Command::Key(event) => { - if let Some(win) = platform.primary_window() { + if let Some(win) = attached_window(&app) { win.handle_key_event(event); } } @@ -258,7 +324,7 @@ fn render_thread(launch: Launch) { // callback turns it into a forced render. super::frame_source::schedule_frame(); } - let window = platform.primary_window(); + let window = attached_window(&app); let can_draw = window.as_ref().is_some_and(|win| win.is_active()); if can_draw && super::frame_source::take_frame() { if let Some(win) = &window { @@ -282,8 +348,15 @@ fn render_thread(launch: Launch) { } } +/// The window the live surface belongs to, or `None` while nothing is attached. +fn attached_window(app: &HostApp) -> Option> { + app.attached + .and_then(|host| app.windows.get(&host).cloned()) +} + fn on_surface_created( platform: &Arc, + host: HostId, window: NativeWindow, scale: f32, app: &mut HostApp, @@ -291,23 +364,25 @@ fn on_surface_created( SURFACE_RELEASED.store(false, Ordering::SeqCst); let incoming = window.ptr().as_ptr(); - if CURRENT_SURFACE.load(Ordering::SeqCst) == incoming { + if app.attached == Some(host) && CURRENT_SURFACE.load(Ordering::SeqCst) == incoming { // `surfaceChanged` for a surface we are already rendering into: the size may // have changed, but the Vulkan surface must not be rebuilt. - if let Some(existing) = platform.primary_window() { + if let Some(existing) = app.windows.get(&host) { existing.handle_resize(); } return; } - if let Some(existing) = platform.primary_window() { - // Recreated Activity (or a resumed one): re-attach the new surface to the - // renderer we already have. GPUI's scene cache and the texture atlas survive. + if let Some(existing) = app.windows.get(&host).cloned() { + // 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) { Ok(()) => { CURRENT_SURFACE.store(incoming, Ordering::SeqCst); - log::info!("gpui-main: surface re-attached to existing window"); + app.attached = Some(host); + log::info!("gpui-main: host {host} re-attached to its window"); // The Java `InputProxy` died with the old Activity while GPUI still // considers the same field focused; ask the new Activity for the IME. super::jni::restore_keyboard(); @@ -318,33 +393,86 @@ fn on_surface_created( return; } - // First surface: create the window, then let the host build its UI. - match platform.open_window(window, scale, false) { - Ok(win) => { - CURRENT_SURFACE.store(incoming, Ordering::SeqCst); - win.set_active(true); - log::info!("gpui-main: first window opened"); - if let Some(launch) = app.launch.take() { - // Without an `AndroidApp` to drive, `AndroidPlatform::run` invokes the - // callback immediately and returns — the shape `run_embedded` expects. - // The handle is what keeps the `App` alive afterwards. - let mut application = - Application::with_platform(SharedPlatform::new(Arc::clone(platform)).into_rc()); - if let Some(configure) = CONFIGURE.lock().expect("poisoned").take() { - application = configure(application); - } - app.handle = Some(application.run_embedded(launch)); - } + // A host we have not seen: it gets a window of its own. + let win = match platform.open_window(window, scale, false) { + Ok(win) => win, + Err(err) => { + log::error!("gpui-main: open_window failed: {err:#}"); + return; } - Err(err) => log::error!("gpui-main: open_window failed: {err:#}"), + }; + CURRENT_SURFACE.store(incoming, Ordering::SeqCst); + win.set_active(true); + app.windows.insert(host, Arc::clone(&win)); + app.attached = Some(host); + + if app.handle.is_none() { + // Without an `AndroidApp` to drive, `AndroidPlatform::run` invokes the + // callback immediately and returns — the shape `run_embedded` expects. + // The handle is what keeps the `App` alive afterwards. + let Some(launch) = app.launch.take() else { + log::error!("gpui-main: no launch closure for the first window"); + return; + }; + let mut application = + Application::with_platform(SharedPlatform::new(Arc::clone(platform)).into_rc()); + if let Some(configure) = CONFIGURE.lock().expect("poisoned").take() { + application = configure(application); + } + app.handle = Some(application.run_embedded(launch)); + log::info!("gpui-main: GPUI app built for host {host}"); } + + // Name the window before asking GPUI to open one: `Platform::open_window` has no + // argument saying *which* surface, and would otherwise answer with `primary` — + // every host after the first would then render into the first host's surface. + platform.set_pending_window(Arc::clone(&win)); + let open = OPEN_WINDOW.lock().expect("poisoned").clone(); + match (open, app.handle.as_ref()) { + (Some(open), Some(handle)) => { + handle.update(|cx| open(host, cx)); + log::info!( + "gpui-main: window opened for host {host} ({} total)", + app.windows.len() + ); + } + _ => log::warn!("gpui-main: no on_open_window callback; host {host} stays blank"), + } + // Not consumed (no callback, or it opened nothing) — do not leave it for the next + // `cx.open_window`, whoever that turns out to be. + platform.take_pending_window(); } /// Hand a `Surface` to the render thread. Call from both `surfaceCreated` and /// `surfaceChanged`; a repeat of the surface already held is treated as a resize. -pub fn surface_created(window: NativeWindow, scale: f32) { +pub fn surface_created(host: HostId, window: NativeWindow, scale: f32) { SCALE_BITS.store(scale.to_bits(), Ordering::Relaxed); - post(Command::SurfaceCreated { window, scale }); + post(Command::SurfaceCreated { + host, + window, + scale, + }); +} + +/// Build the UI for a host window. Called once per [`HostId`] on the render thread, +/// with the platform primed so the `cx.open_window` inside lands on that host's +/// surface — see [`AndroidPlatform::set_pending_window`]. +/// +/// Register before the first [`surface_created`]; without it a host's surface opens a +/// native window but GPUI never draws into it. +pub fn on_open_window(callback: F) +where + F: Fn(HostId, &mut App) + Send + Sync + 'static, +{ + *OPEN_WINDOW.lock().expect("poisoned") = Some(Arc::new(callback)); +} + +/// Drop everything a host owns: its GPUI window, and with it that host's panel. +/// +/// Call from `Activity.onDestroy` when the Activity is finishing for good — not on a +/// configuration change, where the same [`HostId`] is expected back. +pub fn host_destroyed(host: HostId) { + post(Command::HostDestroyed { host }); } /// Take the surface back and **block until the render thread has let go of it**. @@ -352,9 +480,14 @@ pub fn surface_created(window: NativeWindow, scale: f32) { /// Call this from `SurfaceHolder.Callback.surfaceDestroyed` before returning to the /// framework, otherwise the `Surface` is torn down underneath a thread that may be /// inside `ANativeWindow_lock`. -pub fn surface_destroyed() { - post(Command::SurfaceDestroyed); - +pub fn surface_destroyed(host: HostId) { + post(Command::SurfaceDestroyed { host }); + + // One global flag serves every host because `SurfaceHolder.Callback` fires on the + // Java UI thread: while this blocks, no other Activity can reach `surface_created` + // to clear it. Waiting per host would mean polling a map from a blocked UI thread + // for no gain. + // // Bounded wait: a stuck render thread must not turn into an ANR. 2 s is far below // the 5 s input-dispatch timeout while being far above a worst-case frame. let deadline = std::time::Instant::now() + Duration::from_secs(2); @@ -447,10 +580,10 @@ pub fn ime_event(session: u64, kind: i32, text: String, start: usize, end: usize }); } -pub fn resumed() { - post(Command::Resumed); +pub fn resumed(host: HostId) { + post(Command::Resumed { host }); } -pub fn paused() { - post(Command::Paused); +pub fn paused(host: HostId) { + post(Command::Paused { host }); } diff --git a/src/android/platform.rs b/src/android/platform.rs index 0cdea3aa..3c0abd4b 100644 --- a/src/android/platform.rs +++ b/src/android/platform.rs @@ -133,6 +133,15 @@ struct AndroidPlatformState { // ── windows / displays ──────────────────────────────────────────────────── windows: WindowList, + /// The window `Platform::open_window` should hand to GPUI next. + /// + /// GPUI asks for "a window" with no way to say *which* native surface it means, + /// and on Android the surface always exists first. With one window that was fine — + /// `open_window` could just answer `primary`. Hosts that put several Activities on + /// screen (each with its own `SurfaceView`) need each `cx.open_window` to land on + /// the surface that Activity brought, so the host sets this immediately before + /// asking GPUI to open, and `open_window` takes it. + pending_window: Option>, displays: DisplayList, // ── text ────────────────────────────────────────────────────────────────── @@ -404,6 +413,7 @@ impl AndroidPlatform { dispatcher, gpu_context: Rc::new(RefCell::new(None)), windows: WindowList::default(), + pending_window: None, displays, text_system, clipboard: AndroidClipboard::default(), @@ -595,6 +605,18 @@ impl AndroidPlatform { Ok(window) } + /// Name the window the next [`Platform::open_window`] should return. + /// + /// Cleared by that call. See [`AndroidPlatformState::pending_window`]. + pub fn set_pending_window(&self, window: Arc) { + self.state.lock().pending_window = Some(window); + } + + /// Take the window named by [`Self::set_pending_window`], if any. + pub fn take_pending_window(&self) -> Option> { + self.state.lock().pending_window.take() + } + /// Remove and return the window identified by `id`. pub fn close_window(&self, id: u64) -> Option> { self.state.lock().windows.remove(id) @@ -1073,7 +1095,13 @@ impl Platform for AndroidPlatform { // MainEvent::InitWindow, not by the application. If a window // already exists we wrap it in an AndroidPlatformWindow and hand // it to GPUI. - let window = self.primary_window().ok_or_else(|| { + // + // `take_pending_window` is what lets a host open more than one: it names the + // window this call is for. Without it every `cx.open_window` would answer with + // `primary`, so a second Activity's panel would render into the first + // Activity's surface. + let window = self.take_pending_window().or_else(|| self.primary_window()); + let window = window.ok_or_else(|| { anyhow::anyhow!( "AndroidPlatform::open_window — no native window available yet. \ Call this from the on_init_window callback after the surface is ready." From d1d67bd89f8e1baaa4bd077e4c435be273427c9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B6=82=E9=98=B3=E5=B7=9D?= Date: Thu, 8 Oct 2026 17:53:54 +0800 Subject: [PATCH 2/4] android: Load on API < 29 by not linking android_get_device_api_level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ndk_sys::android_get_device_api_level()` only entered libc in Android 10 (API 29). Rust has no counterpart to the NDK headers' `__INTRODUCED_IN` guards, so building against a lower platform still emits a *strong* undefined reference rather than falling back to the header's inline implementation. Every consumer of this crate therefore failed to `dlopen` below API 29 — reproduced on an OPPO R11s (Android 8.1): `cannot locate symbol "android_get_device_api_level"`. The only caller is the high-refresh-rate path, whose whole purpose is to degrade gracefully on old devices. Read `ro.build.version.sdk` instead: available since API 1 and needs no new symbol. Verified against the NDK's API 26 stub libraries — all 231 strong undefined symbols now resolve, where before exactly one did not. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/android/window.rs | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/android/window.rs b/src/android/window.rs index 765dc1da..316269e2 100644 --- a/src/android/window.rs +++ b/src/android/window.rs @@ -138,12 +138,44 @@ fn load_set_frame_rate_with_change_strategy() -> Option i32 { + static LEVEL: OnceLock = OnceLock::new(); + *LEVEL.get_or_init(|| { + let mut value = [0u8; libc::PROP_VALUE_MAX as usize]; + let len = unsafe { + libc::__system_property_get( + b"ro.build.version.sdk\0".as_ptr().cast(), + value.as_mut_ptr().cast(), + ) + }; + if len <= 0 { + // Unreadable: report the lowest level so we skip the high-refresh request + // rather than wrongly assume support. + return 0; + } + std::str::from_utf8(&value[..len as usize]) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0) + }) +} + /// Request 120 Hz refresh rate from the native window when the platform exposes the API. /// /// The NDK entry points are resolved with `dlsym` so this library still loads /// on pre-API-30/31 devices where the symbols do not exist. fn request_high_frame_rate(window: &NativeWindow) { - let api_level = unsafe { ndk_sys::android_get_device_api_level() }; + let api_level = device_api_level(); let native_window = window.ptr().as_ptr(); let status = if api_level >= 31 { From e870c4668800f1eb4aad9b11a4776d953b3bfe2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B6=82=E9=98=B3=E5=B7=9D?= Date: Thu, 8 Oct 2026 17:53:55 +0800 Subject: [PATCH 3/4] android: Fall back to GL when Vulkan cannot build our pipelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wgpu picks an adapter by creating a device and configuring a surface, which says nothing about whether the driver can build our pipelines — and on Android it often cannot. An Adreno 512 (Android 8.1, OPPO R11s) passes that probe, then logs `Shader compilation failed` while `WgpuRenderer::new` builds its pipelines and loses the device: every frame after that was black, and re-attaching the window after Home panicked on the dead device. A GL without unaligned buffer bindings rejects `path_rasterization` the same way, except that the validation error reaches wgpu's default handler and panics the render thread, because `WgpuRenderer::new` builds its pipelines before installing its own handler. Fresh contexts are now built by us on an explicit backend set, and the first renderer is created inside Validation / OutOfMemory / Internal error scopes. Any scope error or device loss counts as a failed attempt: the context is dropped, releasing the window, and GL is tried next; if GL fails too, `open_window` fails cleanly instead of panicking. The host can remember the answer instead of failing on Vulkan every launch (689 ms on the R11s, against 68 ms going straight to GL): - `host::set_gpu_backend(GpuBackend::Gl)` skips Vulkan for the next fresh context. - `host::on_gpu_outcome` reports once per fresh context what it ended up on — `Vulkan`, `Gl` or `GlAfterVulkanFailed` — and `Failed` once per host whose window cannot be opened, or cannot be re-attached when the host comes back. Re-attach failures used to only log, leaving the page black with nothing telling the host to switch away. - Failed hosts are remembered until destroyed and their later surfaces ignored: `surfaceChanged` follows `surfaceCreated`, so every failure ran — and was reported — twice. Also stop forcing `Mailbox` when replacing a surface. `replace_surface` applies a preferred present mode without checking the surface caps (unlike `WgpuRenderer::new`), and GL only offers `Fifo`: the swap left the surface unconfigured and the next frame panicked in `get_current_texture_view`. `None` keeps the mode negotiated when the renderer was created. Verified on the R11s: Vulkan fails, GL is used, the chat renders; three Home/return cycles and new pages render without errors; the second launch goes straight to GL. On the emulator with GL forced, the rejected pipeline reports `Failed` once instead of panicking. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/android/host.rs | 85 +++++++++++++++++++++- src/android/window.rs | 160 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 240 insertions(+), 5 deletions(-) diff --git a/src/android/host.rs b/src/android/host.rs index 96bb263d..a9a50c57 100644 --- a/src/android/host.rs +++ b/src/android/host.rs @@ -152,6 +152,68 @@ type Launch = Box; /// with the platform primed to hand GPUI that host's surface. type OpenWindow = Arc; +/// Which backend a fresh GPU context is built on. See [`set_gpu_backend`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GpuBackend { + /// Vulkan first, GL if Vulkan cannot build our pipelines. + Auto, + /// Straight to GL, skipping the Vulkan attempt. + Gl, +} + +/// How building the shared GPU context turned out. See [`on_gpu_outcome`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GpuOutcome { + Vulkan, + Gl, + /// Vulkan was tried and could not build our pipelines; now on GL. + GlAfterVulkanFailed, + /// A host's window could not be opened (or re-attached): it gets nothing on screen. + Failed, +} + +static GPU_BACKEND_GL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +type GpuOutcomeCallback = Arc; +static GPU_OUTCOME: Mutex> = Mutex::new(None); + +/// Pick the backend for the next fresh GPU context. Call before the first surface. +/// +/// Which backend works is a property of the device's driver, so a host that has +/// already seen [`GpuOutcome::GlAfterVulkanFailed`] can remember it and pass +/// [`GpuBackend::Gl`] on later launches instead of failing on Vulkan every time. +pub fn set_gpu_backend(backend: GpuBackend) { + GPU_BACKEND_GL.store(backend == GpuBackend::Gl, Ordering::SeqCst); +} + +pub(crate) fn gpu_backend() -> GpuBackend { + if GPU_BACKEND_GL.load(Ordering::SeqCst) { + GpuBackend::Gl + } else { + GpuBackend::Auto + } +} + +/// Called on the render thread once per fresh GPU context, and with +/// [`GpuOutcome::Failed`] whenever a host's window cannot be opened, or cannot be +/// re-attached when the host comes back (once per host). The outcome is about +/// the device, not a host; on `Failed` the host being opened is the one whose +/// Activity called `set_host_activity` last. +pub fn on_gpu_outcome(callback: F) +where + F: Fn(GpuOutcome) + Send + Sync + 'static, +{ + *GPU_OUTCOME.lock().expect("poisoned") = Some(Arc::new(callback)); +} + +pub(crate) fn report_gpu_outcome(outcome: GpuOutcome) { + log::info!("gpui-main: GPU outcome: {outcome:?}"); + let callback = GPU_OUTCOME.lock().expect("poisoned").clone(); + if let Some(callback) = callback { + callback(outcome); + } +} + /// Registered by [`on_open_window`]. static OPEN_WINDOW: Mutex> = Mutex::new(None); /// Applied to the `Application` before `run_embedded`, so a host can register things @@ -175,6 +237,11 @@ struct HostApp { /// so at most one is attached at a time — which makes it the only sensible target /// for input and the only window worth drawing. attached: Option, + /// Hosts whose window could not be opened or re-attached. Reported once as + /// [`GpuOutcome::Failed`]; the host swaps that page for another UI, so its later + /// surfaces (`surfaceChanged` follows `surfaceCreated`) are ignored rather than + /// failing — and reporting — all over again. + failed: std::collections::HashSet, } fn post(command: Command) { @@ -247,6 +314,7 @@ fn render_thread(launch: Launch) { handle: None, windows: std::collections::HashMap::new(), attached: None, + failed: std::collections::HashSet::new(), }; loop { @@ -276,6 +344,7 @@ fn render_thread(launch: Launch) { SURFACE_RELEASED.store(true, Ordering::SeqCst); } Command::HostDestroyed { host } => { + app.failed.remove(&host); if let Some(win) = app.windows.remove(&host) { win.term_window(); platform.close_window(win.id()); @@ -363,6 +432,11 @@ fn on_surface_created( ) { SURFACE_RELEASED.store(false, Ordering::SeqCst); + if app.failed.contains(&host) { + log::info!("gpui-main: host {host} already failed; ignoring its surface"); + return; + } + let incoming = window.ptr().as_ptr(); if app.attached == Some(host) && CURRENT_SURFACE.load(Ordering::SeqCst) == incoming { // `surfaceChanged` for a surface we are already rendering into: the size may @@ -387,7 +461,14 @@ fn on_surface_created( // considers the same field focused; ask the new Activity for the IME. super::jni::restore_keyboard(); } - Err(err) => log::error!("gpui-main: init_window failed: {err:#}"), + Err(err) => { + // Without this the page stays black: the window keeps its old, + // now-unusable surface and nothing tells the host to switch away. + log::error!("gpui-main: init_window failed: {err:#}"); + app.failed.insert(host); + report_gpu_outcome(GpuOutcome::Failed); + return; + } } existing.set_active(true); return; @@ -398,6 +479,8 @@ fn on_surface_created( Ok(win) => win, Err(err) => { log::error!("gpui-main: open_window failed: {err:#}"); + app.failed.insert(host); + report_gpu_outcome(GpuOutcome::Failed); return; } }; diff --git a/src/android/window.rs b/src/android/window.rs index 316269e2..298c2fbc 100644 --- a/src/android/window.rs +++ b/src/android/window.rs @@ -53,7 +53,7 @@ use std::ptr::NonNull; use std::rc::Rc; use std::sync::{Arc, OnceLock}; -use super::{AndroidKeyEvent, Bounds, DevicePixels, Pixels, Point, Size, TouchPoint}; +use super::{host, AndroidKeyEvent, Bounds, DevicePixels, Pixels, Point, Size, TouchPoint}; /// Lightweight, owned window handle for wgpu surface creation. /// Stores the raw ANativeWindow pointer and implements the traits @@ -532,7 +532,12 @@ impl AndroidWindow { let config = WgpuSurfaceConfig { size: gpui::size(gpui::DevicePixels(width), gpui::DevicePixels(height)), transparent, - preferred_present_mode: Some(wgpu::PresentMode::Mailbox), + // `None` keeps the mode negotiated when the renderer was created. + // `replace_surface` applies a preferred mode without checking the + // surface caps (unlike `WgpuRenderer::new`), and GL only offers + // `Fifo`: asking for `Mailbox` here leaves the surface unconfigured + // and the next frame panics in `get_current_texture_view`. + preferred_present_mode: None, }; let instance = state .gpu_context @@ -1184,13 +1189,160 @@ impl AndroidWindow { ) -> Result { let raw = Self::raw_window(native_window); - let config = WgpuSurfaceConfig { + let config = || WgpuSurfaceConfig { size: gpui::size(gpui::DevicePixels(width), gpui::DevicePixels(height)), transparent, preferred_present_mode: Some(wgpu::PresentMode::Mailbox), }; - WgpuRenderer::new(gpu_context, &raw, config, None) + if gpu_context.borrow().is_some() { + // The shared context already works for this process; just add a surface. + return WgpuRenderer::new(gpu_context, &raw, config(), None); + } + + // A fresh context. wgpu picks an adapter by creating a device and configuring + // a surface, which says nothing about whether the driver can build our + // pipelines — and on Android it often cannot: an Adreno 512 (Android 8.1) + // loses the Vulkan device while compiling our shaders, and a GL without + // unaligned buffer bindings rejects `path_rasterization`. So every attempt + // builds the renderer inside an error scope and only counts if nothing failed. + if host::gpu_backend() == host::GpuBackend::Gl { + // The host remembers this device cannot run our pipelines on Vulkan. + let renderer = + Self::try_fresh_renderer(&gpu_context, &raw, config(), wgpu::Backends::GL)?; + host::report_gpu_outcome(host::GpuOutcome::Gl); + return Ok(renderer); + } + + match Self::try_fresh_renderer( + &gpu_context, + &raw, + config(), + wgpu::Backends::VULKAN | wgpu::Backends::GL, + ) { + Ok(renderer) => { + // Report what wgpu actually picked: devices without Vulkan get GL here. + let on_vulkan = gpu_context.borrow().as_ref().is_some_and(|ctx| { + matches!( + ctx.backend(), + gpui_wgpu::WgpuBackend::Native(wgpu::Backend::Vulkan) + ) + }); + host::report_gpu_outcome(if on_vulkan { + host::GpuOutcome::Vulkan + } else { + host::GpuOutcome::Gl + }); + Ok(renderer) + } + Err(FreshRendererError { + backend: wgpu::Backend::Vulkan, + error, + }) => { + log::warn!( + "AndroidWindow: Vulkan cannot build our pipelines ({error:#}) — retrying on GL" + ); + let renderer = + Self::try_fresh_renderer(&gpu_context, &raw, config(), wgpu::Backends::GL)?; + host::report_gpu_outcome(host::GpuOutcome::GlAfterVulkanFailed); + Ok(renderer) + } + // wgpu already chose GL (no usable Vulkan): nothing else to try. + Err(err) => Err(err.error), + } + } + + /// Build a fresh shared context on `backends` and a renderer on it, counting it + /// only if nothing failed along the way. On failure the context is dropped again + /// — releasing its surface, so another backend can attach to the same window. + /// + /// The error scope matters: `WgpuRenderer::new` builds its pipelines before it + /// installs its own error handler, so a rejected pipeline would otherwise reach + /// wgpu's default handler, which panics the render thread. + fn try_fresh_renderer( + gpu_context: &GpuContext, + raw: &RawAndroidWindow, + config: WgpuSurfaceConfig, + backends: wgpu::Backends, + ) -> std::result::Result { + let context = Self::create_context(raw, backends).map_err(|error| FreshRendererError { + backend: wgpu::Backend::Noop, + error, + })?; + let backend = match context.backend() { + gpui_wgpu::WgpuBackend::Native(backend) => backend, + _ => wgpu::Backend::Gl, + }; + let device = Arc::clone(&context.device); + *gpu_context.borrow_mut() = Some(context); + + let scopes = [ + device.push_error_scope(wgpu::ErrorFilter::Validation), + device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), + device.push_error_scope(wgpu::ErrorFilter::Internal), + ]; + let renderer = WgpuRenderer::new(Rc::clone(gpu_context), raw, config, None); + let mut scope_error = None; + for scope in scopes.into_iter().rev() { + if let Some(error) = gpui::block_on(scope.pop()) { + scope_error.get_or_insert(error); + } + } + let lost = gpu_context + .borrow() + .as_ref() + .is_some_and(|ctx| ctx.device_lost()); + + let failure = match (renderer, scope_error, lost) { + (Ok(renderer), None, false) => return Ok(renderer), + (Err(error), _, _) => error, + (Ok(_), Some(error), _) => anyhow::anyhow!("{error}"), + (Ok(_), None, true) => anyhow::anyhow!("GPU device lost while building pipelines"), + }; + // The failed renderer is already dropped above; drop the context with it. + *gpu_context.borrow_mut() = None; + Err(FreshRendererError { + backend, + error: failure, + }) + } + + /// A `WgpuContext` restricted to `backends`, probed against `raw`. + fn create_context( + raw: &RawAndroidWindow, + backends: wgpu::Backends, + ) -> Result { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends, + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: Some(Box::new(raw.clone())), + }); + let target = wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: None, + raw_window_handle: raw + .window_handle() + .map_err(|e| anyhow::anyhow!("window handle unavailable: {e}"))? + .as_raw(), + }; + // SAFETY: the native window outlives this probe surface. Dropping it + // releases the window before the renderer creates its own surface. + let surface = unsafe { instance.create_surface_unsafe(target) } + .context("failed to create a probe surface")?; + gpui_wgpu::WgpuContext::new(instance, &surface, None) + } +} + +/// Why [`AndroidWindow::try_fresh_renderer`] failed, and on which backend. +struct FreshRendererError { + backend: wgpu::Backend, + error: anyhow::Error, +} + +impl From for anyhow::Error { + fn from(err: FreshRendererError) -> Self { + err.error } } From 91ca1887f4656f9ec2221e618f4c8511ea76c5d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B6=82=E9=98=B3=E5=B7=9D?= Date: Thu, 8 Oct 2026 17:53:55 +0800 Subject: [PATCH 4/4] android: Hide only the keyboard on back, keep the field's owner open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IME back used to send event 4, which `drain()` turns into an Escape keystroke, so hiding the keyboard over a search overlay also closed the overlay and dropped its query. Back now sends a new event 5: the composition is finished and the dismissed keyboard remembered, with no Escape. Event 4 (the IME's "done") is unchanged, so hosts with their own proxy keep working. GPUI asks for the keyboard only when a text input *gains* focus, and the platform cannot blur it, so after a back-press the field stays focused with no keyboard and tapping it again would change nothing. A tap landing on the focused field's row — judged by its caret, since the input handler exposes text geometry rather than the field's frame — now brings the keyboard back, as on a native `EditText`. Drags and taps elsewhere do not. `GpuiInputActivity` sends event 5 on back. Verified on the emulator: back hides only the keyboard (overlay and query kept), tapping the field shows it again, then back closes the overlay and back exits. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../dev/gpui/mobile/GpuiInputActivity.java | 2 +- src/android/jni.rs | 31 +++++++ src/android/text_input.rs | 8 ++ src/android/window.rs | 80 ++++++++++++++++--- 4 files changed, 110 insertions(+), 11 deletions(-) diff --git a/example/android/gradle/app/src/main/java/dev/gpui/mobile/GpuiInputActivity.java b/example/android/gradle/app/src/main/java/dev/gpui/mobile/GpuiInputActivity.java index 96880abf..ac451299 100644 --- a/example/android/gradle/app/src/main/java/dev/gpui/mobile/GpuiInputActivity.java +++ b/example/android/gradle/app/src/main/java/dev/gpui/mobile/GpuiInputActivity.java @@ -129,7 +129,7 @@ private void endEdit() { @Override public boolean onKeyPreIme(int code, KeyEvent event) { if (code == KeyEvent.KEYCODE_BACK && event.getAction() == KeyEvent.ACTION_UP) { - nativeIme(session, 4, "", 0, 0); + nativeIme(session, 5, "", 0, 0); } return super.onKeyPreIme(code, event); } diff --git a/src/android/jni.rs b/src/android/jni.rs index 3ecfe2ed..c190e491 100644 --- a/src/android/jni.rs +++ b/src/android/jni.rs @@ -1291,6 +1291,35 @@ pub fn set_system_chrome(style: &crate::SystemChromeStyle) { /// the IME back; see [`restore_keyboard`]. static SHOWN_KEYBOARD: std::sync::Mutex> = std::sync::Mutex::new(None); +/// The keyboard the user hid (back) while its text input kept focus. +/// +/// GPUI asks for the keyboard only when a text input *gains* focus, and the platform +/// cannot blur it, so after a back-press the field stays focused with no keyboard and +/// tapping it again changes nothing. [`super::window`] uses this to bring the keyboard +/// back when a tap lands on the focused field. Cleared by any explicit show or hide. +static DISMISSED_KEYBOARD: std::sync::Mutex> = + std::sync::Mutex::new(None); + +/// IME event 5: the system already hid the keyboard; remember it for a tap to undo. +pub(crate) fn keyboard_hidden_by_user() { + let shown = SHOWN_KEYBOARD.lock().expect("poisoned").take(); + if shown.is_some() { + *DISMISSED_KEYBOARD.lock().expect("poisoned") = shown; + } +} + +pub(crate) fn keyboard_dismissed() -> bool { + DISMISSED_KEYBOARD.lock().expect("poisoned").is_some() +} + +/// Show the keyboard [`keyboard_hidden_by_user`] took away again. +pub(crate) fn reshow_dismissed_keyboard() { + let dismissed = DISMISSED_KEYBOARD.lock().expect("poisoned").take(); + if let Some(keyboard_type) = dismissed { + show_keyboard_android(keyboard_type); + } +} + /// Re-request the keyboard on the current Activity if one was showing. /// /// Called by [`super::host`] once a recreated Activity's surface is attached. A fresh @@ -1309,6 +1338,7 @@ pub(crate) fn restore_keyboard() { /// supports composing text, commits and Unicode surrounding-text deletion. pub fn show_keyboard_android(keyboard_type: crate::KeyboardType) { *SHOWN_KEYBOARD.lock().expect("poisoned") = Some(keyboard_type); + *DISMISSED_KEYBOARD.lock().expect("poisoned") = None; let kind = match keyboard_type { crate::KeyboardType::Default => 0, crate::KeyboardType::EmailAddress => 1, @@ -1342,6 +1372,7 @@ pub fn show_keyboard_android(keyboard_type: crate::KeyboardType) { /// Invalidates queued IME callbacks and clears the native composition buffer. pub fn hide_keyboard_android() { *SHOWN_KEYBOARD.lock().expect("poisoned") = None; + *DISMISSED_KEYBOARD.lock().expect("poisoned") = None; let session = super::text_input::new_session(); let _ = with_env(|env| { let activity = activity(env)?; diff --git a/src/android/text_input.rs b/src/android/text_input.rs index 756928d7..9fb76393 100644 --- a/src/android/text_input.rs +++ b/src/android/text_input.rs @@ -46,6 +46,14 @@ pub(super) fn drain( if event.session != SESSION.load(Ordering::Acquire) { continue; } + if event.kind == 5 { + // The user hid the keyboard (back) but the input keeps focus: unlike 4 this + // is not an Escape, which would close whatever owns the field (a search + // overlay, with its query). See `jni::keyboard_hidden_by_user`. + finish_composition(slot); + super::jni::keyboard_hidden_by_user(); + continue; + } if event.kind == 4 { finish_composition(slot); input(PlatformInput::KeyDown(KeyDownEvent { diff --git a/src/android/window.rs b/src/android/window.rs index 298c2fbc..3b9ee101 100644 --- a/src/android/window.rs +++ b/src/android/window.rs @@ -55,6 +55,40 @@ use std::sync::{Arc, OnceLock}; use super::{host, AndroidKeyEvent, Bounds, DevicePixels, Pixels, Point, Size, TouchPoint}; +/// A touch that ends within this distance of where it started is a tap, not a drag. +fn is_tap_distance(start: gpui::Point, end: gpui::Point) -> bool { + const SLOP: f32 = 10.; + let (dx, dy) = (f32::from(end.x - start.x), f32::from(end.y - start.y)); + dx * dx + dy * dy <= SLOP * SLOP +} + +/// Whether `position` is on the row of the focused text input, judged by its caret. +/// +/// The input handler exposes text geometry, not the field's frame; the caret's line is +/// a close enough stand-in for a single-line field, and stops a tap anywhere else on +/// screen from popping the keyboard back up. +fn tap_hits_focused_input( + slot: &Rc>>, + position: gpui::Point, +) -> bool { + // No RefCell borrow may span the handler's GPUI update; same pattern as `text_input`. + let Some(mut handler) = slot.borrow_mut().take() else { + return false; + }; + let caret = handler + .selected_text_range(false) + .and_then(|selection| handler.bounds_for_range(selection.range)); + let mut current = slot.borrow_mut(); + if current.is_none() { + *current = Some(handler); + } + const ROW_SLOP: f32 = 16.; + caret.is_some_and(|caret| { + let y = f32::from(position.y); + y >= f32::from(caret.top()) - ROW_SLOP && y <= f32::from(caret.bottom()) + ROW_SLOP + }) +} + /// Lightweight, owned window handle for wgpu surface creation. /// Stores the raw ANativeWindow pointer and implements the traits /// required by `WgpuRenderer::new` (`Clone + Debug + Send + Sync + 'static`). @@ -1606,18 +1640,44 @@ impl PlatformWindow for AndroidPlatformWindow { fn on_input(&self, callback: Box DispatchEventResult>) { let input_handler = Rc::clone(&self.input_handler); let mut callback = callback; - let callback: Box DispatchEventResult> = Box::new( - move |event| { - if matches!(&event, gpui::PlatformInput::Touch(touch) if touch.phase == gpui::TouchPhase::Started) - { - // Apply any last IME update to the old input before a tap can - // change focus. A new native session rejects late IME callbacks. - super::text_input::drain(&input_handler, &mut callback); - super::text_input::finish_composition(&input_handler); + // Where the current single-finger touch started, to tell a tap from a drag. + let mut tap_start: Option<(gpui::TouchId, gpui::Point)> = None; + let callback: Box DispatchEventResult> = + Box::new(move |event| { + if let gpui::PlatformInput::Touch(touch) = &event { + match touch.phase { + gpui::TouchPhase::Started => { + // Apply any last IME update to the old input before a tap can + // change focus. A new native session rejects late IME callbacks. + super::text_input::drain(&input_handler, &mut callback); + super::text_input::finish_composition(&input_handler); + tap_start = Some((touch.id, touch.position)); + } + gpui::TouchPhase::Moved => { + if tap_start.is_some_and(|(id, start)| { + id == touch.id && !is_tap_distance(start, touch.position) + }) { + tap_start = None; + } + } + gpui::TouchPhase::Ended => { + // The user hid the keyboard with back while the field kept + // focus (see `jni::keyboard_hidden_by_user`): tapping that + // field again must bring the keyboard back, as on a native + // EditText. Checked before GPUI handles the tap, against the + // input that is focused now. + if tap_start.take().is_some_and(|(id, _)| id == touch.id) + && super::jni::keyboard_dismissed() + && tap_hits_focused_input(&input_handler, touch.position) + { + super::jni::reshow_dismissed_keyboard(); + } + } + gpui::TouchPhase::Cancelled => tap_start = None, + } } callback(event) - }, - ); + }); // Bridge AndroidWindow touch/key callbacks → gpui::PlatformInput. // // PlatformWindow gives us Box (not Send).