- 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:
1 parent
4ca21483e5
commit
a8aef30db7
13 files changed
+1187
-559
No files matched your search
+510
-553
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user