Files
ink-notes/src/android_bridge.rs
T
haraldandClaude Opus 5.5 0be321837c feat(android): Java activity for keyboard text and the system file dialog (Issue #42)
InkActivity extends NativeActivity:
- An invisible view with its own input connection receives the soft
  keyboard's text (commitText, deleteSurroundingText), so characters
  without a key event (umlauts by long press, ...) arrive as well. The
  field is a visible-password field, so keyboards commit each character
  instead of composing words.
- Import and "insert image" open the system file dialog
  (ACTION_OPEN_DOCUMENT); the chosen file is copied to the cache folder
  and handed to Rust by path.

src/android_bridge.rs calls the activity through JNI and receives its
native calls; package-android.sh compiles the class with javac and d8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:23:52 +01:00

179 lines
5.5 KiB
Rust

//! Android: calls into the app's Java activity and what it reports back (Issue #42).
//!
//! `packaging/android/java/eu/pansi/inknotes/InkActivity.java` extends NativeActivity with
//! text input through an input connection (so that keyboards can send any character) and the
//! system file dialog. Rust calls its methods through JNI; it calls the `native*` functions
//! below, possibly on another thread, so they only queue messages and wake the event loop.
use std::path::PathBuf;
use std::sync::Mutex;
use jni::objects::{JObject, JString, JValue};
use jni::sys::jint;
use jni::JNIEnv;
use crate::file_picker::PickerKind;
use crate::ui::UiAction;
enum Message {
Text(String),
Delete { before: u32, after: u32 },
Picked(Option<PathBuf>),
}
static MESSAGES: Mutex<Vec<Message>> = Mutex::new(Vec::new());
/// What the open file dialog is for
static PICKING: Mutex<Option<PickerKind>> = Mutex::new(None);
fn push(message: Message) {
MESSAGES
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(message);
if let Some(app) = crate::platform::android_app() {
app.create_waker().wake();
}
}
#[no_mangle]
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeCommitText(
mut env: JNIEnv,
_class: JObject,
text: JString,
) {
if let Ok(text) = env.get_string(&text) {
push(Message::Text(text.into()));
}
}
#[no_mangle]
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeDeleteSurroundingText(
_env: JNIEnv,
_class: JObject,
before: jint,
after: jint,
) {
push(Message::Delete {
before: before.max(0) as u32,
after: after.max(0) as u32,
});
}
#[no_mangle]
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeFilePicked(
mut env: JNIEnv,
_class: JObject,
path: JString,
) {
let path = if path.is_null() {
None
} else {
env.get_string(&path)
.ok()
.map(|p| PathBuf::from(String::from(p)))
};
push(Message::Picked(path));
}
/// Is a message waiting (then the app draws a new frame to take it)?
pub fn has_messages() -> bool {
!MESSAGES
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_empty()
}
/// Typed text and deletions as egui input, and the action for a picked file.
pub fn take_messages() -> (Vec<egui::Event>, Vec<UiAction>) {
let messages = std::mem::take(&mut *MESSAGES.lock().unwrap_or_else(|e| e.into_inner()));
let mut events = Vec::new();
let mut actions = Vec::new();
for message in messages {
match message {
Message::Text(text) => events.extend(crate::osk::committed_text_events(&text)),
Message::Delete { before, after } => {
events.extend(crate::osk::deletion_key_events(before, after))
}
Message::Picked(path) => {
let kind = PICKING.lock().unwrap_or_else(|e| e.into_inner()).take();
match (kind, path) {
(Some(PickerKind::Image), Some(path)) => {
actions.push(UiAction::InsertImageFile(path))
}
(Some(PickerKind::Import), Some(path)) => {
actions.push(UiAction::ImportFile(path))
}
_ => {}
}
}
}
}
(events, actions)
}
/// Runs `f` with the JNI environment and the activity; local references it creates are
/// freed afterwards.
fn with_activity(
f: impl FnOnce(&mut JNIEnv, &JObject) -> jni::errors::Result<()>,
) -> Result<(), String> {
let app = crate::platform::android_app().ok_or("no activity")?;
// Safety: pointers to the VM and the activity (a global reference) that live as long as
// the app
let vm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) }.map_err(|e| e.to_string())?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| e.to_string())?;
let activity = unsafe { JObject::from_raw(app.activity_as_ptr().cast()) };
let result = env.with_local_frame(16, |env| f(env, &activity));
if env.exception_check().unwrap_or(false) {
let _ = env.exception_describe();
let _ = env.exception_clear();
}
result.map_err(|e| e.to_string())
}
pub fn show_keyboard(show: bool) {
let result = with_activity(|env, activity| {
env.call_method(
activity,
"showKeyboard",
"(Z)V",
&[JValue::Bool(show.into())],
)
.map(|_| ())
});
if let Err(err) = result {
log::warn!("showKeyboard: {err}");
}
}
/// Opens the system file dialog instead of the app's own; false if that is not possible
/// (e.g. for choosing a folder).
pub fn pick_file(kind: PickerKind) -> bool {
let mime_type = match kind {
PickerKind::Image => "image/*",
// Xournal++, Rnote and Saber files have no MIME type of their own
PickerKind::Import => "*/*",
PickerKind::Folder => return false,
};
*PICKING.lock().unwrap_or_else(|e| e.into_inner()) = Some(kind);
let result = with_activity(|env, activity| {
let mime = env.new_string(mime_type)?;
env.call_method(
activity,
"pickFile",
"(Ljava/lang/String;)V",
&[JValue::Object(&mime)],
)
.map(|_| ())
});
match result {
Ok(()) => true,
Err(err) => {
log::warn!("pickFile: {err}");
*PICKING.lock().unwrap_or_else(|e| e.into_inner()) = None;
false
}
}
}