Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ android {

buildTypes {
getByName("release") {
// FilePickerActivity is resolved by fully-qualified name over JNI from
// Rust (loki-file-access). R8/ProGuard would rename or strip it and
// break file open/save, so minification stays OFF.
// The Java shims (FilePickerActivity, ImeInsetsListener) are resolved
// by fully-qualified name over JNI from Rust (loki-file-access), and
// ImeInsetsListener's native callback is bound via RegisterNatives.
// R8/ProGuard would rename or strip these — breaking file open/save
// and the soft-keyboard visibility signal — so minification stays OFF.
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("upload")
}
Expand Down
42 changes: 38 additions & 4 deletions docs/patches.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,29 @@ app's hidden `SafeAreaResizeSensor` catches that tick and re-queries
the returned `bottom` grows to the keyboard height. The settle window exists
because Android reports/animates the IME inset a frame or two after the
visibility request (it is the real animation duration, not an arbitrary sleep).
Limitation: a system-back / swipe-down dismissal is not reported by the OS, so
the expanded bottom padding (harmless chrome, no content overlap) persists until
the next focus change or tap re-syncs — the same OS gap the re-trigger note above
documents. Android-only; on desktop `ime_settle_until` stays `None`.
Android-only; on desktop `ime_settle_until` stays `None`. (The former limitation
— a system-back / swipe-down dismissal not re-syncing the bottom padding — is now
closed by the user-driven IME visibility signal below.)

**User-driven soft-keyboard collapse signal (PATCH(loki), 2026-07-01):** the
re-sync above only fires when *the app* drives the keyboard (focus change / caret
tap). When the **user** collapses (or re-summons) the keyboard — system back
button, swipe-down gesture, or the keyboard's own hide key — a `NativeActivity`
gets no winit event and its surface is not resized, so `ime_active` went stale and
the bottom safe area stayed reserved for a keyboard that is gone. This is now
closed with a real Android inset callback: `loki-file-access`'s
`ImeInsetsListener` (a Java shim) is installed on the decor view via
`install_ime_listener` and fires `View.OnApplyWindowInsetsListener` on **every**
IME transition, reading `WindowInsets.Type.ime()` visibility (API 30+; a no-op
below, matching the query fallback). Its native callback — bound with
`RegisterNatives`, so it is independent of the host `.so` name — is bridged in
`android_main` to `blitz_shell::notify_ime_visibility_changed`, which (in
`ime_android.rs`) records the new visibility and wakes the event loop with a
`Poll`. `WindowState::poll` drains it: it mirrors `ime_active` and calls
`arm_ime_settle`, reusing the exact re-sync path above so the bottom safe area
converges to the settled keyboard height (0 on a collapse). The listener passes
insets through (`v.onApplyWindowInsets`), so it does not consume or alter the
system's inset handling. Android-only.

**Scroll re-sync on resize (PATCH(loki), 2026-06-12):** `resync_scroll_geometry`
calls `doc.resolve()` and then re-dispatches `onscroll` (via
Expand Down Expand Up @@ -429,6 +448,21 @@ keyboard on a `NativeActivity` whose surface does not resize. `ime()` is API 30+
— the same level as `getInsets(int)` — so the existing `None` fallback already
covers older API levels.

**Adds (PATCH(loki), 2026-07-01):** `install_ime_listener(activity_ptr)` and
`set_ime_visibility_listener(cb)` — a soft-keyboard visibility signal. The former
loads the `ImeInsetsListener` Java shim (in `android/`) through the *application*
class loader (a native thread's default `FindClass` sees only framework classes),
binds its `nativeOnImeInsetsChanged` callback via `RegisterNatives` (so it does
not depend on the host `.so` name), and calls its static `install`, which sets a
decor-view `OnApplyWindowInsetsListener` on the UI thread. The listener reports
every IME visibility change — including the user-initiated dismiss/re-summon the
OS never surfaces to a `NativeActivity` — to the registered closure. loki-text
wires this to `blitz_shell::notify_ime_visibility_changed`. See the blitz-shell
"user-driven soft-keyboard collapse signal" note. The shim is dexed alongside
`FilePickerActivity` (build.rs / build-android.sh / build-aab.sh /
build-android.ps1) and, being JNI-referenced, requires minification to stay off
(already the case).

**Root cause:** loki-file-access 0.1.2 was designed for desktop and WASM; the
Android implementation was scaffolded but never exercised on a real NativeActivity
build before this patch.
Expand Down
12 changes: 12 additions & 0 deletions loki-text/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ fn android_main(android_app: android_activity::AndroidApp) {
crate::recent_documents::set_android_data_dir(data_path);
}
blitz_shell::set_android_app(android_app);
// Bridge Android soft-keyboard visibility back to the editor. A
// NativeActivity is never told when the *user* dismisses the keyboard
// (back button, swipe-down gesture, hide key), so the bottom safe area
// would stay reserved for a keyboard that is gone. loki-file-access installs
// a decor-view inset listener that reports every IME visibility change;
// blitz-shell re-queries the safe area in response (converging to 0 on a
// collapse). Register the bridge before installing the listener so the first
// callback is not dropped.
loki_file_access::set_ime_visibility_listener(Box::new(|visible| {
blitz_shell::notify_ime_visibility_changed(visible);
}));
loki_file_access::install_ime_listener(blitz_shell::current_android_app().activity_as_ptr());
log::info!("android_main: i18n init");
loki_i18n::init();
log::info!("android_main: launching dioxus");
Expand Down
69 changes: 69 additions & 0 deletions patches/blitz-shell/src/ime_android.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: Apache-2.0

//! PATCH(loki): Android soft-keyboard visibility bridge.
//!
//! On a `NativeActivity` the OS never tells the app when the *user* dismisses
//! (or re-summons) the soft keyboard — the back button, the swipe-down gesture,
//! and the keyboard's own hide key are all invisible to winit / Blitz / Dioxus,
//! and the surface is not resized. Without a signal the app keeps the bottom
//! safe area reserved for a keyboard that is gone (dead space below the
//! document), and the `WindowState::ime_active` mirror goes stale so the next
//! caret tap needlessly takes the `force_show` re-summon path.
//!
//! `loki-file-access` installs a decor-view `OnApplyWindowInsetsListener` that
//! fires on every IME transition and calls [`notify_ime_visibility_changed`].
//! That callback arrives on the Android UI thread; this module hops it onto the
//! winit event loop by storing the new visibility and waking the loop with a
//! `Poll`. [`take_pending`] is then drained in `WindowState::poll`, which syncs
//! `ime_active` and arms the existing IME-settle re-sync so the bottom safe
//! area converges to the new keyboard height (0 on a collapse).

use std::sync::Mutex;
use std::sync::atomic::{AtomicU8, Ordering};

use winit::event_loop::EventLoopProxy;
use winit::window::WindowId;

use crate::event::BlitzShellEvent;

/// Target woken by the `Poll` sent on an IME-visibility change. `None` until a
/// window registers itself via [`register`] during `View::init`.
static TARGET: Mutex<Option<(EventLoopProxy<BlitzShellEvent>, WindowId)>> = Mutex::new(None);

/// Pending IME-visibility change awaiting drain by `poll`:
/// `0` = none, `1` = hidden, `2` = visible.
static PENDING: AtomicU8 = AtomicU8::new(0);

/// Record the event-loop proxy + window to wake when the platform reports an
/// IME-visibility change. Called once when the window is created.
pub(crate) fn register(proxy: EventLoopProxy<BlitzShellEvent>, window_id: WindowId) {
if let Ok(mut guard) = TARGET.lock() {
*guard = Some((proxy, window_id));
}
}

/// Record a platform IME-visibility change and wake the event loop so `poll`
/// can react.
///
/// Safe to call from any thread — it is invoked from the Android UI thread by
/// the JNI inset listener. A no-op (aside from storing the pending value) if no
/// window has registered yet.
pub fn notify_ime_visibility_changed(visible: bool) {
PENDING.store(if visible { 2 } else { 1 }, Ordering::SeqCst);
if let Ok(guard) = TARGET.lock()
&& let Some((proxy, window_id)) = guard.as_ref()
{
let _ = proxy.send_event(BlitzShellEvent::Poll {
window_id: *window_id,
});
}
}

/// Drain a pending IME-visibility change, if any, returning the new visibility.
pub(crate) fn take_pending() -> Option<bool> {
match PENDING.swap(0, Ordering::SeqCst) {
1 => Some(false),
2 => Some(true),
_ => None,
}
}
4 changes: 4 additions & 0 deletions patches/blitz-shell/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
mod application;
mod convert_events;
mod event;
#[cfg(target_os = "android")]
mod ime_android;
mod net;
mod tooltip;
mod window;
Expand All @@ -20,6 +22,8 @@ mod accessibility;

pub use crate::application::BlitzApplication;
pub use crate::event::BlitzShellEvent;
#[cfg(target_os = "android")]
pub use crate::ime_android::notify_ime_visibility_changed;
pub use crate::net::BlitzShellNetCallback;
pub use crate::window::{View, WindowConfig};

Expand Down
20 changes: 20 additions & 0 deletions patches/blitz-shell/src/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ impl<Rend: WindowRenderer> View<Rend> {
winit_window.set_title(&title);
}

// PATCH(loki): let the Android IME-visibility bridge wake this window
// when the user shows/hides the soft keyboard outside our control
// (back button, swipe-down gesture, keyboard hide key). See
// `ime_android` and the `take_pending` drain in `poll`.
#[cfg(target_os = "android")]
crate::ime_android::register(proxy.clone(), winit_window.id());

Self {
renderer: config.renderer,
waker: None,
Expand Down Expand Up @@ -317,6 +324,19 @@ impl<Rend: WindowRenderer> View<Rend> {
}

pub fn poll(&mut self) -> bool {
// PATCH(loki): the user toggled the soft keyboard outside our control
// (back button, swipe-down gesture, keyboard hide key). The Android
// inset listener recorded the new visibility and woke us; mirror it into
// `ime_active` so a later caret tap does not needlessly re-summon a
// still-visible keyboard, and open a settle window so the bottom safe
// area re-queries across the keyboard animation (converging to 0 on a
// collapse). Same downstream path as an app-driven show/hide.
#[cfg(target_os = "android")]
if let Some(visible) = crate::ime_android::take_pending() {
self.ime_active = visible;
self.arm_ime_settle();
}

// PATCH(loki): during the post-IME settle window, re-query the safe-area
// insets so the app's bottom padding follows the soft keyboard as the
// platform finishes reporting and animating the IME inset. The re-sync
Expand Down
63 changes: 63 additions & 0 deletions patches/loki-file-access/android/ImeInsetsListener.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 AppThere

package io.github.appthere.lokifileaccess;

import android.app.Activity;
import android.os.Build;
import android.view.View;
import android.view.WindowInsets;

/**
* Bridges Android soft-keyboard (IME) visibility changes to native code.
*
* <p>On a {@code NativeActivity} the OS never reports when the user dismisses or
* re-summons the soft keyboard (back button, swipe-down gesture, keyboard hide
* key), and the surface is not resized. This listener observes the decor view's
* window insets — which include the IME inset on API 30+ — and calls
* {@code nativeOnImeInsetsChanged} on every visibility transition so the Rust
* side can re-reserve (or release) the bottom safe area.
*
* <p>The native method is bound from Rust via {@code RegisterNatives}, so no
* {@code System.loadLibrary} is required here and the binding is independent of
* the host application's native-library name.
*/
public final class ImeInsetsListener implements View.OnApplyWindowInsetsListener {

private boolean lastImeVisible;

/**
* Install the listener on the activity's decor view.
*
* <p>Runs on the UI thread (View listeners must be set there) and is a no-op
* below API 30, where {@code WindowInsets.Type.ime()} / {@code isVisible}
* are unavailable — matching the query-side API-30 fallback in Rust.
*/
public static void install(final Activity activity) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return;
}
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
View decor = activity.getWindow().getDecorView();
decor.setOnApplyWindowInsetsListener(new ImeInsetsListener());
// Kick an immediate inset dispatch so the initial state is known.
decor.requestApplyInsets();
}
});
}

@Override
public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
boolean imeVisible = insets.isVisible(WindowInsets.Type.ime());
if (imeVisible != lastImeVisible) {
lastImeVisible = imeVisible;
nativeOnImeInsetsChanged(imeVisible);
}
// Pass through so we neither consume nor alter the system's inset handling.
return v.onApplyWindowInsets(insets);
}

private static native void nativeOnImeInsetsChanged(boolean imeVisible);
}
Loading
Loading