NativeActivity draws the whole window, also below the system bars, so the top toolbar button lay under the status bar and taps there went to Android (no other notebook could be opened). InkActivity now reports the system bar and cutout insets; the app shrinks egui's screen rect to the free area and adds the side insets to the page centering. The page itself still scrolls below the bars. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
6.5 KiB
Rust
211 lines
6.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::atomic::{AtomicBool, Ordering};
|
|
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);
|
|
/// Window edges covered by system bars or cutouts (left, top, right, bottom; pixels)
|
|
static INSETS: Mutex<[f32; 4]> = Mutex::new([0.0; 4]);
|
|
/// The insets changed since the last frame
|
|
static INSETS_CHANGED: AtomicBool = AtomicBool::new(false);
|
|
|
|
fn wake() {
|
|
if let Some(app) = crate::platform::android_app() {
|
|
app.create_waker().wake();
|
|
}
|
|
}
|
|
|
|
fn push(message: Message) {
|
|
MESSAGES
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.push(message);
|
|
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));
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeSetInsets(
|
|
_env: JNIEnv,
|
|
_class: JObject,
|
|
left: jint,
|
|
top: jint,
|
|
right: jint,
|
|
bottom: jint,
|
|
) {
|
|
let insets = [left, top, right, bottom].map(|v| v.max(0) as f32);
|
|
*INSETS.lock().unwrap_or_else(|e| e.into_inner()) = insets;
|
|
INSETS_CHANGED.store(true, Ordering::Relaxed);
|
|
wake();
|
|
}
|
|
|
|
/// Window edges covered by the status bar, the navigation bar or display cutouts (left, top,
|
|
/// right, bottom; physical pixels)
|
|
pub fn insets() -> [f32; 4] {
|
|
*INSETS.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// Is a message waiting or did the insets change (then the app draws a new frame)?
|
|
pub fn has_messages() -> bool {
|
|
INSETS_CHANGED.load(Ordering::Relaxed)
|
|
|| !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>) {
|
|
INSETS_CHANGED.store(false, Ordering::Relaxed);
|
|
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
|
|
}
|
|
}
|
|
}
|