feat(android): pen input, soft keyboard and exports to Downloads (Issues #42, #44)

- Pen vs. finger (#44): the patched winit hands Android pointers with
  tool type stylus/eraser to the app separately (take_pen_samples, with
  hover and button state) instead of as touch events. src/pen.rs turns
  them, like octotablet's events on the desktop, into one PenInput
  stream, so pen buttons, the eraser end and palm rejection work the
  same everywhere. Hover exit right before touch-down does not count as
  leaving; a pen without hover leaves 150 ms after lifting.
- Soft keyboard: shown via AndroidApp::show_soft_input when a text field
  gets focus, hidden when it loses it. The patched winit now fills
  KeyEvent::text, so typed characters reach egui.
- Exports (PDF, PNG, SVG) are written to the app folder and copied to
  Download/ink-notes through the MediaStore (Android 10+, via JNI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
haraldandClaude Opus 5.5 committed 2026-10-27 20:54:48 +01:00
1 parent 4ca21483e5
commit a8aef30db7
13 files changed
+1187 -559

No files matched your search

+510 -553
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -51,6 +51,13 @@ pub fn sanitize_filename_component(title: &str) -> String {
/// `~/.config/user-dirs.dirs` (z. B. „Dokumente“), sonst `~/Documents`. Im Flatpak ist nur
/// dieser Ordner beschreibbar, der Fallback allein träfe bei deutschen Systemen daneben.
pub fn default_documents_dir() -> PathBuf {
// Android: written into the app's own folder first, then copied to Downloads
// (`platform::publish_export`)
#[cfg(target_os = "android")]
if let Some(dir) = crate::platform::android_app().and_then(|app| app.external_data_path()) {
return dir.join("Documents");
}
if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") {
if !dir.trim().is_empty() {
return PathBuf::from(dir);
+2
View File
@@ -22,6 +22,7 @@ pub mod paths;
pub mod pdf_doc;
pub mod pdf_merge;
pub mod pdf_writer;
pub mod pen;
pub mod platform;
pub mod rnote;
pub mod ruler;
@@ -51,6 +52,7 @@ fn android_main(app: winit::platform::android::activity::AndroidApp) {
);
// Panics would otherwise only go to stderr, which Android discards
std::panic::set_hook(Box::new(|info| log::error!("{info}")));
platform::set_android_app(app.clone());
if let Some(dir) = app.internal_data_path() {
paths::set_app_dir(dir);
}
+33 -2
View File
@@ -17,11 +17,40 @@ mod wayland;
#[cfg(target_os = "linux")]
pub use wayland::KeyboardRequester;
/// Android: NativeActivity shows the soft keyboard only when asked to (Issue #42). The typed
/// characters arrive as key events. Characters the keyboard can't send as key events (e.g.
/// umlauts chosen by long press, which it commits as text) don't arrive.
#[cfg(target_os = "android")]
pub struct KeyboardRequester {
app: winit::platform::android::activity::AndroidApp,
}
#[cfg(target_os = "android")]
impl KeyboardRequester {
/// # Safety
/// Nothing to uphold; `unsafe` like the Wayland version.
pub unsafe fn for_window(_window: &winit::window::Window) -> Option<Self> {
crate::platform::android_app().map(|app| Self { app })
}
pub fn request(&mut self) {
self.app.show_soft_input(false);
}
pub fn release(&mut self) {
self.app.hide_soft_input(false);
}
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
Vec::new()
}
}
/// Elsewhere the system shows the on-screen keyboard by itself; nothing to request.
#[cfg(not(target_os = "linux"))]
#[cfg(not(any(target_os = "linux", target_os = "android")))]
pub struct KeyboardRequester;
#[cfg(not(target_os = "linux"))]
#[cfg(not(any(target_os = "linux", target_os = "android")))]
impl KeyboardRequester {
/// # Safety
/// Nothing to uphold; `unsafe` like the Wayland version.
@@ -31,6 +60,8 @@ impl KeyboardRequester {
pub fn request(&mut self) {}
pub fn release(&mut self) {}
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
Vec::new()
}
+4
View File
@@ -92,6 +92,10 @@ impl KeyboardRequester {
}
}
/// The text field lost focus. Nothing to do: the compositor hides the keyboard once
/// winit disables text input.
pub fn release(&mut self) {}
/// Tastendrücke für das seit dem letzten Aufruf gewünschte Löschen (vor `begin_frame`
/// aufrufen und an die egui-Eingabe anhängen)
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
+382
View File
@@ -0,0 +1,382 @@
//! Pen input independent of the platform (Issue #44).
//!
//! On the desktop the pen comes from octotablet (Wayland/X11 tablet protocols, Windows Ink),
//! on Android as touch samples with the tool type stylus or eraser, which the patched winit
//! hands over separately (`winit::platform::android::take_pen_samples`). Both are turned into
//! the same [`PenInput`] events, which the app handles in one place.
use std::time::{Duration, Instant};
/// What happened to the pen.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PenEvent {
/// Pen comes into range (hovers above the screen)
In,
/// Pen left the range
Out,
/// Tip touches the screen (at the position of the last `Pose`)
Down,
/// Tip lifted
Up,
/// Position in logical pixels; pressure 0..=1 if known
Pose {
position: [f32; 2],
pressure: Option<f32>,
},
/// Pen button pressed or released; `key` identifies the button across sessions
Button { key: u64, pressed: bool },
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PenInput {
/// Identifies the pen (several pens can be in use on the desktop)
pub tool: u64,
/// Eraser end of the pen
pub is_eraser: bool,
pub event: PenEvent,
}
fn hash_of(value: impl std::hash::Hash) -> u64 {
use std::hash::Hasher;
// DefaultHasher::new() uses fixed keys: same input, same value in every session
let mut hasher = std::collections::hash_map::DefaultHasher::new();
value.hash(&mut hasher);
hasher.finish()
}
/// Turns octotablet's events into [`PenInput`]s, with each `Down` after the `Pose` of the
/// same frame (see [`crate::platform::down_after_pose`]).
#[cfg(not(target_os = "android"))]
pub fn from_octotablet<'a>(
events: impl IntoIterator<Item = octotablet::events::Event<'a>>,
) -> Vec<PenInput> {
use octotablet::events::{Event, ToolEvent};
let inputs = events.into_iter().filter_map(|event| {
let Event::Tool { tool, event } = event else {
return None;
};
let event = match event {
ToolEvent::In { .. } => PenEvent::In,
ToolEvent::Out | ToolEvent::Removed => PenEvent::Out,
ToolEvent::Down => PenEvent::Down,
ToolEvent::Up => PenEvent::Up,
ToolEvent::Pose(pose) => PenEvent::Pose {
position: pose.position,
pressure: pose.pressure.get(),
},
ToolEvent::Button { button_id, pressed } => PenEvent::Button {
key: hash_of(button_id),
pressed,
},
_ => return None,
};
Some(PenInput {
tool: hash_of(tool.id()),
is_eraser: tool.tool_type == Some(octotablet::tool::Type::Eraser),
event,
})
});
crate::platform::down_after_pose(
inputs,
|i| i.event == PenEvent::Down,
|i| matches!(i.event, PenEvent::Pose { .. }),
)
}
/// What an Android pen sample reports (mirrors `winit::platform::android::PenAction`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SampleAction {
HoverEnter,
HoverMove,
HoverExit,
Down,
Move,
Up,
Cancel,
}
/// One Android pen sample (mirrors `winit::platform::android::PenSample`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Sample {
pub action: SampleAction,
/// Physical pixels
pub x: f32,
pub y: f32,
pub pressure: f32,
pub eraser: bool,
/// `MotionEvent.getButtonState()`
pub buttons: u32,
}
#[cfg(target_os = "android")]
impl From<winit::platform::android::PenSample> for Sample {
fn from(s: winit::platform::android::PenSample) -> Self {
use winit::platform::android::PenAction as A;
Self {
action: match s.action {
A::HoverEnter => SampleAction::HoverEnter,
A::HoverMove => SampleAction::HoverMove,
A::HoverExit => SampleAction::HoverExit,
A::Down => SampleAction::Down,
A::Move => SampleAction::Move,
A::Up => SampleAction::Up,
A::Cancel => SampleAction::Cancel,
},
x: s.x,
y: s.y,
pressure: s.pressure,
eraser: s.eraser,
buttons: s.buttons,
}
}
}
/// The two pen buttons of Android's `MotionEvent` (`BUTTON_STYLUS_PRIMARY`/`SECONDARY`).
const ANDROID_BUTTONS: [(u32, &str); 2] = [
(0x20, "android-stylus-primary"),
(0x40, "android-stylus-secondary"),
];
/// How long the pen may be out of sight before it counts as gone. Android reports
/// `HoverExit` right before the tip touches down, and pens without hover report nothing
/// after lifting.
const ANDROID_OUT_DELAY: Duration = Duration::from_millis(150);
/// Turns Android pen samples into [`PenInput`]s.
#[derive(Debug, Default)]
pub struct AndroidPen {
in_range: bool,
down: bool,
eraser: bool,
buttons: u32,
/// Since when the pen is neither hovering nor touching
gone_since: Option<Instant>,
}
impl AndroidPen {
/// The single pen Android reports
const TOOL: u64 = 1;
fn push(&self, out: &mut Vec<PenInput>, event: PenEvent) {
out.push(PenInput {
tool: Self::TOOL,
is_eraser: self.eraser,
event,
});
}
/// Events for `samples` (in this order); `scale` converts physical to logical pixels.
pub fn convert(&mut self, samples: &[Sample], scale: f32, now: Instant) -> Vec<PenInput> {
let mut out = Vec::new();
for sample in samples {
self.eraser = sample.eraser;
for (mask, name) in ANDROID_BUTTONS {
let pressed = sample.buttons & mask != 0;
if pressed != (self.buttons & mask != 0) {
self.push(
&mut out,
PenEvent::Button {
key: hash_of(name),
pressed,
},
);
}
}
self.buttons = sample.buttons;
let position = [sample.x / scale, sample.y / scale];
let touching = Some(sample.pressure.clamp(0.0, 1.0));
match sample.action {
SampleAction::HoverEnter | SampleAction::HoverMove => {
self.gone_since = None;
self.enter(&mut out);
let pose = PenEvent::Pose {
position,
pressure: None,
};
self.push(&mut out, pose);
}
SampleAction::HoverExit => {
if !self.down {
self.gone_since.get_or_insert(now);
}
}
SampleAction::Down => {
self.gone_since = None;
self.enter(&mut out);
let pose = PenEvent::Pose {
position,
pressure: touching,
};
self.push(&mut out, pose);
if !self.down {
self.down = true;
self.push(&mut out, PenEvent::Down);
}
}
SampleAction::Move => {
self.gone_since = None;
self.enter(&mut out);
let pressure = if self.down { touching } else { None };
self.push(&mut out, PenEvent::Pose { position, pressure });
}
SampleAction::Up | SampleAction::Cancel => {
if self.down {
if sample.action == SampleAction::Up {
let pose = PenEvent::Pose {
position,
pressure: touching,
};
self.push(&mut out, pose);
}
self.down = false;
self.push(&mut out, PenEvent::Up);
}
self.gone_since = Some(now);
}
}
}
out.extend(self.poll(now));
out
}
fn enter(&mut self, out: &mut Vec<PenInput>) {
if !self.in_range {
self.in_range = true;
self.push(out, PenEvent::In);
}
}
/// `Out` once the pen has been gone for a moment; call regularly while it is in range.
pub fn poll(&mut self, now: Instant) -> Vec<PenInput> {
let mut out = Vec::new();
let gone = self
.gone_since
.is_some_and(|since| now.duration_since(since) >= ANDROID_OUT_DELAY);
if gone && self.in_range && !self.down {
self.in_range = false;
self.gone_since = None;
if self.buttons != 0 {
for (mask, name) in ANDROID_BUTTONS {
if self.buttons & mask != 0 {
self.push(
&mut out,
PenEvent::Button {
key: hash_of(name),
pressed: false,
},
);
}
}
self.buttons = 0;
}
self.push(&mut out, PenEvent::Out);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(action: SampleAction, x: f32, pressure: f32, buttons: u32) -> Sample {
Sample {
action,
x,
y: 10.0,
pressure,
eraser: false,
buttons,
}
}
fn kinds(inputs: &[PenInput]) -> Vec<&'static str> {
inputs
.iter()
.map(|i| match i.event {
PenEvent::In => "in",
PenEvent::Out => "out",
PenEvent::Down => "down",
PenEvent::Up => "up",
PenEvent::Pose { pressure: None, .. } => "hover",
PenEvent::Pose { .. } => "pose",
PenEvent::Button { pressed: true, .. } => "button-down",
PenEvent::Button { .. } => "button-up",
})
.collect()
}
#[test]
fn test_android_stroke_with_hover() {
use SampleAction::*;
let mut pen = AndroidPen::default();
let t = Instant::now();
let ms = |n| t + Duration::from_millis(n);
let out = pen.convert(&[sample(HoverEnter, 20.0, 0.0, 0)], 2.0, t);
assert_eq!(kinds(&out), ["in", "hover"]);
assert_eq!(
out[1].event,
PenEvent::Pose {
position: [10.0, 5.0],
pressure: None
}
);
// Android ends the hover right before the tip touches: no Out in between
let out = pen.convert(&[sample(HoverExit, 20.0, 0.0, 0)], 2.0, ms(5));
assert!(out.is_empty());
let out = pen.convert(
&[sample(Down, 20.0, 0.4, 0), sample(Move, 30.0, 0.5, 0)],
2.0,
ms(10),
);
assert_eq!(kinds(&out), ["pose", "down", "pose"]);
assert!(pen.poll(ms(500)).is_empty(), "still touching");
let out = pen.convert(&[sample(Up, 30.0, 0.0, 0)], 2.0, ms(600));
assert_eq!(kinds(&out), ["pose", "up"]);
let out = pen.convert(&[sample(HoverEnter, 30.0, 0.0, 0)], 2.0, ms(610));
assert_eq!(kinds(&out), ["hover"]);
pen.convert(&[sample(HoverExit, 30.0, 0.0, 0)], 2.0, ms(700));
assert!(pen.poll(ms(750)).is_empty());
assert_eq!(kinds(&pen.poll(ms(900))), ["out"]);
}
#[test]
fn test_android_pen_without_hover_leaves_after_lifting() {
use SampleAction::*;
let mut pen = AndroidPen::default();
let t = Instant::now();
let out = pen.convert(&[sample(Down, 0.0, 0.7, 0)], 1.0, t);
assert_eq!(kinds(&out), ["in", "pose", "down"]);
pen.convert(&[sample(Up, 0.0, 0.0, 0)], 1.0, t);
assert_eq!(kinds(&pen.poll(t + Duration::from_millis(200))), ["out"]);
}
#[test]
fn test_android_buttons_and_cancel() {
use SampleAction::*;
let mut pen = AndroidPen::default();
let t = Instant::now();
let out = pen.convert(
&[
sample(HoverMove, 0.0, 0.0, 0x20),
sample(Down, 0.0, 0.5, 0x20),
sample(Cancel, 0.0, 0.0, 0x20),
],
1.0,
t,
);
assert_eq!(
kinds(&out),
["button-down", "in", "hover", "pose", "down", "up"]
);
// Pen gone with the button still held: released before Out
let out = pen.poll(t + Duration::from_millis(200));
assert_eq!(kinds(&out), ["button-up", "out"]);
}
}
+141
View File
@@ -127,6 +127,147 @@ where
event_loop.run(handler)
}
/// The Android activity, for calls winit doesn't offer (soft keyboard, Issue #42).
#[cfg(target_os = "android")]
static ANDROID_APP: std::sync::OnceLock<winit::platform::android::activity::AndroidApp> =
std::sync::OnceLock::new();
/// Remembers the activity; called once by `android_main`.
#[cfg(target_os = "android")]
pub fn set_android_app(app: winit::platform::android::activity::AndroidApp) {
let _ = ANDROID_APP.set(app);
}
#[cfg(target_os = "android")]
pub fn android_app() -> Option<winit::platform::android::activity::AndroidApp> {
ANDROID_APP.get().cloned()
}
/// Makes an exported file available to the user. Desktop: it already is (the path is
/// returned). Android: copied to `Download/ink-notes` through the MediaStore (Android 10 and
/// newer), as other apps can't see the app's own folder; older versions keep it there.
pub fn publish_export(path: std::path::PathBuf) -> Result<std::path::PathBuf, String> {
#[cfg(target_os = "android")]
match android_files::publish_to_downloads(&path) {
Ok(location) => {
let _ = std::fs::remove_file(&path);
return Ok(location);
}
Err(err) => log::warn!("export not copied to Downloads: {err}"),
}
Ok(path)
}
#[cfg(target_os = "android")]
mod android_files {
use jni::objects::{JObject, JValue};
use jni::JNIEnv;
use std::path::{Path, PathBuf};
/// Folder below the shared storage the exports go to
const RELATIVE_PATH: &str = "Download/ink-notes";
fn mime_type(path: &Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()) {
Some("pdf") => "application/pdf",
Some("png") => "image/png",
Some("svg") => "image/svg+xml",
_ => "application/octet-stream",
}
}
pub fn publish_to_downloads(path: &Path) -> Result<PathBuf, String> {
let app = super::android_app().ok_or("no activity")?;
let name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or("invalid file name")?
.to_string();
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
// 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().map_err(|e| e.to_string())?;
let activity = unsafe { JObject::from_raw(app.activity_as_ptr().cast()) };
let result = insert(&mut env, &activity, &name, mime_type(path), &bytes);
if env.exception_check().unwrap_or(false) {
let _ = env.exception_describe();
let _ = env.exception_clear();
}
result.map_err(|e| e.to_string())?;
Ok(Path::new(RELATIVE_PATH).join(name))
}
fn insert(
env: &mut JNIEnv,
activity: &JObject,
name: &str,
mime: &str,
bytes: &[u8],
) -> jni::errors::Result<()> {
let sdk = env
.get_static_field("android/os/Build$VERSION", "SDK_INT", "I")?
.i()?;
if sdk < 29 {
return Err(jni::errors::Error::JniCall(jni::errors::JniError::Unknown));
}
let values = env.new_object("android/content/ContentValues", "()V", &[])?;
for (key, value) in [
("_display_name", name),
("mime_type", mime),
("relative_path", RELATIVE_PATH),
] {
let key = env.new_string(key)?;
let value = env.new_string(value)?;
env.call_method(
&values,
"put",
"(Ljava/lang/String;Ljava/lang/String;)V",
&[JValue::Object(&key), JValue::Object(&value)],
)?;
}
let resolver = env
.call_method(
activity,
"getContentResolver",
"()Landroid/content/ContentResolver;",
&[],
)?
.l()?;
let collection = env
.get_static_field(
"android/provider/MediaStore$Downloads",
"EXTERNAL_CONTENT_URI",
"Landroid/net/Uri;",
)?
.l()?;
let uri = env
.call_method(
&resolver,
"insert",
"(Landroid/net/Uri;Landroid/content/ContentValues;)Landroid/net/Uri;",
&[JValue::Object(&collection), JValue::Object(&values)],
)?
.l()?;
if uri.is_null() {
return Err(jni::errors::Error::NullPtr("MediaStore insert"));
}
let stream = env
.call_method(
&resolver,
"openOutputStream",
"(Landroid/net/Uri;)Ljava/io/OutputStream;",
&[JValue::Object(&uri)],
)?
.l()?;
let array = env.byte_array_from_slice(bytes)?;
env.call_method(&stream, "write", "([B)V", &[JValue::Object(&array)])?;
env.call_method(&stream, "close", "()V", &[])?;
Ok(())
}
}
/// Gives the window the app id (Wayland: app_id, X11: WM_CLASS), so that dock and task bar
/// show the app icon for it.
pub fn with_app_id(