diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0843f85c..1f8690bc 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") } diff --git a/docs/patches.md b/docs/patches.md index 07fc8ed0..38d79048 100644 --- a/docs/patches.md +++ b/docs/patches.md @@ -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 @@ -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. diff --git a/loki-text/src/lib.rs b/loki-text/src/lib.rs index 005dbd3c..c4be1253 100644 --- a/loki-text/src/lib.rs +++ b/loki-text/src/lib.rs @@ -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"); diff --git a/patches/blitz-shell/src/ime_android.rs b/patches/blitz-shell/src/ime_android.rs new file mode 100644 index 00000000..78db17b8 --- /dev/null +++ b/patches/blitz-shell/src/ime_android.rs @@ -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, 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, 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 { + match PENDING.swap(0, Ordering::SeqCst) { + 1 => Some(false), + 2 => Some(true), + _ => None, + } +} diff --git a/patches/blitz-shell/src/lib.rs b/patches/blitz-shell/src/lib.rs index 14fee544..8270f8de 100644 --- a/patches/blitz-shell/src/lib.rs +++ b/patches/blitz-shell/src/lib.rs @@ -11,6 +11,8 @@ mod application; mod convert_events; mod event; +#[cfg(target_os = "android")] +mod ime_android; mod net; mod tooltip; mod window; @@ -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}; diff --git a/patches/blitz-shell/src/window.rs b/patches/blitz-shell/src/window.rs index e1f090c2..53c9105b 100644 --- a/patches/blitz-shell/src/window.rs +++ b/patches/blitz-shell/src/window.rs @@ -166,6 +166,13 @@ impl View { 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, @@ -317,6 +324,19 @@ impl View { } 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 diff --git a/patches/loki-file-access/android/ImeInsetsListener.java b/patches/loki-file-access/android/ImeInsetsListener.java new file mode 100644 index 00000000..547c03ea --- /dev/null +++ b/patches/loki-file-access/android/ImeInsetsListener.java @@ -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. + * + *

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. + * + *

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. + * + *

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); +} diff --git a/patches/loki-file-access/build.rs b/patches/loki-file-access/build.rs index c2b63e57..21ce0ecb 100644 --- a/patches/loki-file-access/build.rs +++ b/patches/loki-file-access/build.rs @@ -1,7 +1,12 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 AppThere -//! Build script — compiles `FilePickerActivity.java` into `classes.dex` for Android targets. +//! Build script — compiles the Android Java shims into `classes.dex` for +//! Android targets. +//! +//! Shims (in `android/`): +//! - `FilePickerActivity.java` — Storage Access Framework trampoline. +//! - `ImeInsetsListener.java` — soft-keyboard (IME) visibility bridge. //! //! Requires: //! - `ANDROID_HOME` or `ANDROID_SDK_ROOT` pointing to the Android SDK @@ -11,8 +16,13 @@ use std::path::PathBuf; +/// Java shim source files (relative to `android/`) compiled into the DEX. +const JAVA_SHIMS: &[&str] = &["FilePickerActivity.java", "ImeInsetsListener.java"]; + fn main() { - println!("cargo:rerun-if-changed=android/FilePickerActivity.java"); + for shim in JAVA_SHIMS { + println!("cargo:rerun-if-changed=android/{shim}"); + } let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); if target_os != "android" { @@ -21,16 +31,19 @@ fn main() { let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); - let java_src = manifest_dir.join("android/FilePickerActivity.java"); + let java_srcs: Vec = JAVA_SHIMS + .iter() + .map(|shim| manifest_dir.join("android").join(shim)) + .collect(); let dex_out = out_dir.join("classes.dex"); - match compile_to_dex(&java_src, &out_dir, &dex_out) { + match compile_to_dex(&java_srcs, &out_dir, &dex_out) { Ok(()) => { - println!("cargo:warning=FilePickerActivity DEX: {}", dex_out.display()); + println!("cargo:warning=Java shim DEX: {}", dex_out.display()); println!("cargo:warning=Inject into APK with: scripts/build-android.ps1 -Install"); } Err(e) => { - println!("cargo:warning=FilePickerActivity compile skipped: {e}"); + println!("cargo:warning=Java shim compile skipped: {e}"); println!("cargo:warning=Run scripts/build-android.ps1 which compiles the DEX itself."); } } @@ -39,7 +52,7 @@ fn main() { } fn compile_to_dex( - java_src: &std::path::Path, + java_srcs: &[PathBuf], out_dir: &std::path::Path, dex_out: &std::path::Path, ) -> Result<(), String> { @@ -55,48 +68,75 @@ fn compile_to_dex( let classes_dir = out_dir.join("java_classes"); std::fs::create_dir_all(&classes_dir).map_err(|e| format!("mkdir classes: {e}"))?; + let mut javac_args: Vec = vec![ + "-source".into(), + "8".into(), + "-target".into(), + "8".into(), + "-classpath".into(), + android_jar.to_str().unwrap().to_owned(), + "-d".into(), + classes_dir.to_str().unwrap().to_owned(), + ]; + javac_args.extend(java_srcs.iter().map(|p| p.to_str().unwrap().to_owned())); + let status = std::process::Command::new(&javac) - .args([ - "-source", "8", - "-target", "8", - "-classpath", - android_jar.to_str().unwrap(), - "-d", - classes_dir.to_str().unwrap(), - java_src.to_str().unwrap(), - ]) + .args(&javac_args) .status() .map_err(|e| format!("javac exec failed: {e}"))?; if !status.success() { return Err(format!("javac exited {status}")); } - let class_file = classes_dir - .join("io/github/appthere/lokifileaccess/FilePickerActivity.class"); + // Collect every produced `.class` file (including inner classes such as the + // anonymous `Runnable` in `ImeInsetsListener`) so d8 dexes all of them. + let class_files = collect_class_files(&classes_dir); + if class_files.is_empty() { + return Err("no .class files produced by javac".to_owned()); + } let dex_dir = out_dir.join("dex_out"); std::fs::create_dir_all(&dex_dir).map_err(|e| format!("mkdir dex: {e}"))?; + let mut d8_args: Vec = class_files + .iter() + .map(|p| p.to_str().unwrap().to_owned()) + .collect(); + d8_args.push("--output".into()); + d8_args.push(dex_dir.to_str().unwrap().to_owned()); + d8_args.push("--min-api".into()); + d8_args.push("26".into()); + let status = std::process::Command::new(&d8) - .args([ - class_file.to_str().unwrap(), - "--output", - dex_dir.to_str().unwrap(), - "--min-api", - "26", - ]) + .args(&d8_args) .status() .map_err(|e| format!("d8 exec failed: {e}"))?; if !status.success() { return Err(format!("d8 exited {status}")); } - std::fs::copy(dex_dir.join("classes.dex"), dex_out) - .map_err(|e| format!("copy dex: {e}"))?; + std::fs::copy(dex_dir.join("classes.dex"), dex_out).map_err(|e| format!("copy dex: {e}"))?; Ok(()) } +/// Recursively collect all `.class` files under `dir`. +fn collect_class_files(dir: &std::path::Path) -> Vec { + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return out; + }; + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.is_dir() { + out.extend(collect_class_files(&path)); + } else if path.extension().is_some_and(|ext| ext == "class") { + out.push(path); + } + } + out +} + fn find_android_jar(android_home: &std::path::Path) -> Result { let platforms = android_home.join("platforms"); for api in (26..=36).rev() { @@ -139,11 +179,14 @@ fn find_javac() -> PathBuf { #[cfg(target_os = "windows")] { let pf = std::env::var("PROGRAMFILES").unwrap_or_else(|_| "C:\\Program Files".into()); - let p = PathBuf::from(pf) - .join("Android\\Android Studio\\jbr\\bin\\javac.exe"); + let p = PathBuf::from(pf).join("Android\\Android Studio\\jbr\\bin\\javac.exe"); if p.exists() { return p; } } - PathBuf::from(if cfg!(target_os = "windows") { "javac.exe" } else { "javac" }) + PathBuf::from(if cfg!(target_os = "windows") { + "javac.exe" + } else { + "javac" + }) } diff --git a/patches/loki-file-access/src/lib.rs b/patches/loki-file-access/src/lib.rs index 1dfed9c2..aaf6345c 100644 --- a/patches/loki-file-access/src/lib.rs +++ b/patches/loki-file-access/src/lib.rs @@ -75,4 +75,7 @@ pub use error::{AccessError, PickerError, TokenParseError}; pub use token::{FileAccessToken, PermissionStatus, ReadSeek, WriteSeek}; #[cfg(target_os = "android")] -pub use platform::{init_android, on_activity_result, query_insets_dp, query_window_insets_dp}; +pub use platform::{ + init_android, install_ime_listener, on_activity_result, query_insets_dp, query_window_insets_dp, + set_ime_visibility_listener, +}; diff --git a/patches/loki-file-access/src/platform/android/jni_ime.rs b/patches/loki-file-access/src/platform/android/jni_ime.rs new file mode 100644 index 00000000..f38a622f --- /dev/null +++ b/patches/loki-file-access/src/platform/android/jni_ime.rs @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 AppThere + +//! JNI install of the Android soft-keyboard (IME) visibility listener. +//! +//! `ImeInsetsListener` (a Java shim) observes the decor view's window insets and +//! calls back into `nativeOnImeInsetsChanged` on every IME visibility change — +//! including the user-initiated dismiss / re-summon that the OS otherwise never +//! reports to a `NativeActivity`. +//! +//! Install flow: +//! 1. Load the shim through the *application* class loader — a JNI-attached +//! native thread's default `FindClass` loader resolves only framework +//! classes, so an app class must be reached via the activity's own loader. +//! 2. Bind the native callback with `RegisterNatives`, so the binding does not +//! depend on the host application's `.so` name (unlike symbol-name +//! resolution, which is class-loader-scoped on Android 7+). +//! 3. Call the shim's static `install`, which registers the decor-view listener +//! on the UI thread. + +use std::ffi::c_void; +use std::sync::{Mutex, OnceLock}; + +use jni::objects::{JClass, JObject, JValueGen}; +use jni::sys::jboolean; +use jni::{JNIEnv, NativeMethod}; + +/// Fully-qualified name of the Java shim (for `ClassLoader.loadClass`). +const IME_CLASS_DOT: &str = "io.github.appthere.lokifileaccess.ImeInsetsListener"; + +/// Closure invoked (on the Android UI thread) whenever the soft keyboard's +/// visibility changes. `true` = keyboard now visible, `false` = collapsed. +type ImeCallback = Box; +static IME_CALLBACK: OnceLock>> = OnceLock::new(); + +fn ime_callback() -> &'static Mutex> { + IME_CALLBACK.get_or_init(|| Mutex::new(None)) +} + +/// Register the closure invoked on every soft-keyboard visibility change. +/// +/// Call once before [`install_ime_listener`]. A later call replaces the +/// previous closure. +pub fn set_ime_visibility_listener(callback: ImeCallback) { + if let Ok(mut guard) = ime_callback().lock() { + *guard = Some(callback); + } +} + +/// Install the decor-view IME inset listener for `activity_ptr` +/// (`AndroidApp::activity_as_ptr()`). +/// +/// Returns `true` when the Java `install` was invoked. Returns `false` on a null +/// pointer or any JNI failure; the Java side additionally no-ops below API 30, +/// matching the query-side fallback. Installing twice simply replaces the decor +/// view's listener. +pub fn install_ime_listener(activity_ptr: *mut c_void) -> bool { + if activity_ptr.is_null() { + return false; + } + let ctx = ndk_context::android_context(); + // SAFETY: ndk_context stores the JVM pointer initialised by android-activity + // before android_main; valid for the process lifetime. + let vm = match unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) } { + Ok(vm) => vm, + Err(_) => return false, + }; + let mut env = match vm.attach_current_thread() { + Ok(env) => env, + Err(_) => return false, + }; + let ok = install_with_env(&mut env, activity_ptr).is_some(); + // Clear any pending exception (e.g. from a failed class load) so the calling + // thread stays usable. + let _ = env.exception_clear(); + ok +} + +fn install_with_env(env: &mut JNIEnv<'_>, activity_ptr: *mut c_void) -> Option<()> { + // SAFETY: `activity_ptr` is the global-ref activity jobject owned by + // AndroidApp, valid for the activity's lifetime. + let activity = unsafe { JObject::from_raw(activity_ptr.cast()) }; + + let class = load_app_class(env, &activity)?; + register_native(env, &class)?; + + // ImeInsetsListener.install(activity) + env.call_static_method( + &class, + "install", + "(Landroid/app/Activity;)V", + &[JValueGen::Object(&activity)], + ) + .ok()?; + Some(()) +} + +/// Load `ImeInsetsListener` through the application class loader. +/// +/// A native thread's default class loader resolves only framework classes, so +/// we reach the app class via the activity's own `getClassLoader().loadClass`. +fn load_app_class<'a>(env: &mut JNIEnv<'a>, activity: &JObject<'_>) -> Option> { + let activity_class = env.get_object_class(activity).ok()?; + let loader = env + .call_method( + &activity_class, + "getClassLoader", + "()Ljava/lang/ClassLoader;", + &[], + ) + .ok()? + .l() + .ok()?; + let name = env.new_string(IME_CLASS_DOT).ok()?; + let class_obj = env + .call_method( + &loader, + "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;", + &[JValueGen::Object(&name)], + ) + .ok()? + .l() + .ok()?; + Some(JClass::from(class_obj)) +} + +/// Bind `nativeOnImeInsetsChanged` to [`ime_insets_changed`] via +/// `RegisterNatives`, so the callback resolves regardless of the host +/// application's native-library name. +fn register_native(env: &mut JNIEnv<'_>, class: &JClass<'_>) -> Option<()> { + let methods = [NativeMethod { + name: "nativeOnImeInsetsChanged".into(), + sig: "(Z)V".into(), + fn_ptr: ime_insets_changed as *mut c_void, + }]; + env.register_native_methods(class, &methods).ok() +} + +/// JNI callback invoked by `ImeInsetsListener.onApplyWindowInsets` on the +/// Android UI thread whenever the soft keyboard's visibility changes. +extern "system" fn ime_insets_changed(_env: JNIEnv<'_>, _class: JClass<'_>, ime_visible: jboolean) { + let visible = ime_visible != 0; + if let Ok(guard) = ime_callback().lock() + && let Some(callback) = guard.as_ref() + { + callback(visible); + } +} diff --git a/patches/loki-file-access/src/platform/android/mod.rs b/patches/loki-file-access/src/platform/android/mod.rs index da021c66..83d129c2 100644 --- a/patches/loki-file-access/src/platform/android/mod.rs +++ b/patches/loki-file-access/src/platform/android/mod.rs @@ -43,6 +43,7 @@ mod jni_activity; mod jni_common; mod jni_fd; +mod jni_ime; mod jni_insets; mod jni_intents; @@ -104,6 +105,10 @@ pub fn query_window_insets_dp(activity_ptr: *mut std::ffi::c_void) -> Option<(f3 jni_insets::query_window_insets_dp(activity_ptr) } +// ── Soft-keyboard (IME) visibility signal ───────────────────────────────────── + +pub use jni_ime::{install_ime_listener, set_ime_visibility_listener}; + // ── JNI result callback (called from Java FilePickerActivity) ───────────────── /// Delivers the selected URI (or `null` for cancellation) to the pending Rust future. diff --git a/patches/loki-file-access/src/platform/mod.rs b/patches/loki-file-access/src/platform/mod.rs index ee1ca85d..75e84e91 100644 --- a/patches/loki-file-access/src/platform/mod.rs +++ b/patches/loki-file-access/src/platform/mod.rs @@ -28,7 +28,8 @@ pub(crate) use android::{ }; #[cfg(target_os = "android")] pub use android::{ - init_android, on_activity_result, query_insets_dp, query_window_insets_dp, + init_android, install_ime_listener, on_activity_result, query_insets_dp, query_window_insets_dp, + set_ime_visibility_listener, }; #[cfg(target_os = "ios")] diff --git a/scripts/build-aab.sh b/scripts/build-aab.sh index dd130652..37ed69c7 100755 --- a/scripts/build-aab.sh +++ b/scripts/build-aab.sh @@ -147,14 +147,15 @@ for i in "${!TRIPLES[@]}"; do echo " -> jniLibs/$abidir/libloki_text.so ($(du -h "$so" | cut -f1))" done -# ── Stage FilePickerActivity.java (single source of truth in patches/) ──────── +# ── Stage Java shims (single source of truth in patches/) ───────────────────── JAVA_PKG_DIR="$ROOT/android/app/src/main/java/io/github/appthere/lokifileaccess" echo "" -echo "==> Staging FilePickerActivity.java..." +echo "==> Staging Java shims (FilePickerActivity, ImeInsetsListener)..." rm -rf "$ROOT/android/app/src/main/java" mkdir -p "$JAVA_PKG_DIR" cp "$ROOT/patches/loki-file-access/android/FilePickerActivity.java" "$JAVA_PKG_DIR/" +cp "$ROOT/patches/loki-file-access/android/ImeInsetsListener.java" "$JAVA_PKG_DIR/" # ── Gradle bundleRelease ────────────────────────────────────────────────────── diff --git a/scripts/build-android.ps1 b/scripts/build-android.ps1 index c6f3db97..666a92a0 100644 --- a/scripts/build-android.ps1 +++ b/scripts/build-android.ps1 @@ -1,9 +1,9 @@ <# .SYNOPSIS - Full Android build pipeline for Loki apps including optional FilePickerActivity. + Full Android build pipeline for Loki apps including optional Java shims. .DESCRIPTION - 1. Compiles FilePickerActivity.java → classes.dex (loki-text only). + 1. Compiles the Java shims (FilePickerActivity, ImeInsetsListener) → classes.dex (loki-text only). 2. Runs `cargo apk build` to produce the native library + bare APK. 3. Post-processes the APK: a. Replaces the auto-generated AndroidManifest.xml with the custom one @@ -128,7 +128,10 @@ Write-Host "==> App: $App ($cargoPackage)" # ── Paths ───────────────────────────────────────────────────────────────────── $profile = if ($Release) { "release" } else { "debug" } -$javaSrc = "patches\loki-file-access\android\FilePickerActivity.java" +$javaSrcs = @( + "patches\loki-file-access\android\FilePickerActivity.java", + "patches\loki-file-access\android\ImeInsetsListener.java" +) $outDir = "target\android-pkg" $apkSrc = "$PWD\target\$profile\apk\$apkBaseName.apk" @@ -152,16 +155,19 @@ if (Test-Path $debugKey) { # ── Step 1: Compile FilePickerActivity.java → classes.dex (loki-text only) ─── if ($includeDex) { - Write-Host "`n==> Compiling FilePickerActivity.java..." + Write-Host "`n==> Compiling Java shims (FilePickerActivity, ImeInsetsListener)..." $classesDir = "$outDir\java-classes" $dexDir = "$outDir\dex-out" New-Item -ItemType Directory -Force $classesDir, $dexDir | Out-Null - & $javac -source 8 -target 8 -classpath $platform -d $classesDir $javaSrc + & $javac -source 8 -target 8 -classpath $platform -d $classesDir @javaSrcs if ($LASTEXITCODE -ne 0) { throw "javac failed" } - $classFile = "$classesDir\io\github\appthere\lokifileaccess\FilePickerActivity.class" - & $d8 $classFile --output $dexDir --min-api 26 + # Dex every produced .class (includes inner classes such as the anonymous + # Runnable in ImeInsetsListener). + $classFiles = Get-ChildItem -Path $classesDir -Recurse -Filter *.class | ForEach-Object { $_.FullName } + if ($classFiles.Count -eq 0) { throw "javac produced no .class files" } + & $d8 @classFiles --output $dexDir --min-api 26 if ($LASTEXITCODE -ne 0) { throw "d8 failed" } $dexPath = "$dexDir\classes.dex" diff --git a/scripts/build-android.sh b/scripts/build-android.sh index db0d2297..61a939ff 100755 --- a/scripts/build-android.sh +++ b/scripts/build-android.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # SPDX-License-Identifier: Apache-2.0 # -# Full Android build pipeline for Loki Text including FilePickerActivity. +# Full Android build pipeline for Loki Text including the Java shims. # # Steps: -# 1. Compile FilePickerActivity.java → classes.dex (javac + d8) +# 1. Compile the Java shims (FilePickerActivity, ImeInsetsListener) → classes.dex # 2. cargo apk build --package loki-text # 3. Replace auto-generated AndroidManifest.xml with the custom one # (adds FilePickerActivity + android:hasCode="true") @@ -204,7 +204,8 @@ echo "==> javac: $JAVAC" # ── Paths ───────────────────────────────────────────────────────────────────── PROFILE="$([ "$RELEASE" -eq 1 ] && echo "release" || echo "debug")" -JAVA_SRC="patches/loki-file-access/android/FilePickerActivity.java" +JAVA_SRC_DIR="patches/loki-file-access/android" +JAVA_SRCS=("$JAVA_SRC_DIR/FilePickerActivity.java" "$JAVA_SRC_DIR/ImeInsetsListener.java") MANIFEST_XML="loki-text/AndroidManifest.xml" OUT_DIR="target/android-pkg" APK_SRC="$(pwd)/target/$PROFILE/apk/loki_text.apk" @@ -215,15 +216,21 @@ OUT_DIR="$(cd "$OUT_DIR" && pwd)" # make absolute for aapt # ── Step 1: Compile FilePickerActivity.java → classes.dex ──────────────────── echo "" -echo "==> Compiling FilePickerActivity.java..." +echo "==> Compiling Java shims (FilePickerActivity, ImeInsetsListener)..." CLASSES_DIR="$OUT_DIR/java-classes" DEX_DIR="$OUT_DIR/dex-out" mkdir -p "$CLASSES_DIR" "$DEX_DIR" -"$JAVAC" -source 8 -target 8 -classpath "$PLATFORM" -d "$CLASSES_DIR" "$JAVA_SRC" +"$JAVAC" -source 8 -target 8 -classpath "$PLATFORM" -d "$CLASSES_DIR" "${JAVA_SRCS[@]}" -CLASS_FILE="$CLASSES_DIR/io/github/appthere/lokifileaccess/FilePickerActivity.class" -"$D8" "$CLASS_FILE" --output "$DEX_DIR" --min-api 26 +# Dex every produced .class (includes inner classes such as the anonymous +# Runnable in ImeInsetsListener). +mapfile -t CLASS_FILES < <(find "$CLASSES_DIR" -name '*.class') +if [[ "${#CLASS_FILES[@]}" -eq 0 ]]; then + echo "ERROR: javac produced no .class files." >&2 + exit 1 +fi +"$D8" "${CLASS_FILES[@]}" --output "$DEX_DIR" --min-api 26 DEX_PATH="$DEX_DIR/classes.dex" echo " DEX: $DEX_PATH"