Give an album a folder on the tablet, through Android's folder picker
Android's only export destination was the library on the server
(ExportTarget::available), because writing to the device goes through
the Storage Access Framework and nothing did. An album's folder on the
tablet is now chosen in the system's tree picker — which has its own
"Create new folder" — and exports are written into it with
DocumentsContract.
The picker answers through onActivityResult, and the main activity is
NativeActivity, whose result is not ours. FolderPicker is a translucent
activity that only asks: it starts ACTION_OPEN_DOCUMENT_TREE, takes a
persistable grant (a folder is chosen once and exported to for months),
leaves the URI in a static, and finishes. Rust polls it from a Slint
timer — one static call, rather than a registered native method and a
thread to deliver on.
Two things the first build on the tablet got wrong, recorded where they
are fixed:
- Our classes must be loaded through Context.getClassLoader(). The
class of what ndk_context holds is a framework class from the boot
loader, which reports every class in the APK as not found.
- What ndk_context holds is the application context, not the activity,
and starting an activity from it throws without FLAG_ACTIVITY_NEW_TASK.
Saf.write creates the document (or, under Overwrite, reopens the one of
that name with "wt" so a shorter file does not keep the old tail) and
returns the name the provider actually gave it, since SAF renames on a
collision by itself; the album records that name. A tree URI reads in
the sidebar as its folder ("Pictures/Web"), not as a content:// string.
This commit is contained in:
@@ -158,6 +158,18 @@
|
||||
android:theme="@style/ManualTheme"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
|
||||
|
||||
<!-- FR-EXP-10: the system's folder picker, for an album's folder on
|
||||
this device. NativeActivity's onActivityResult is not ours, so
|
||||
this activity exists only to ask and hand the answer back (see
|
||||
FolderPicker.java). Translucent and without a title so nothing
|
||||
of it shows but the system chooser; not exported, and started by
|
||||
class name from dr_ui::saf. -->
|
||||
<activity
|
||||
android:name="paris.tourolle.darkroom.FolderPicker"
|
||||
android:exported="false"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
|
||||
|
||||
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
|
||||
between apps since API 24 — handing one out raises
|
||||
FileUriExposedException in *this* process — so an exported JPEG
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package paris.tourolle.darkroom;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
/**
|
||||
* The system's folder picker, for an album's folder on this device (FR-EXP-10).
|
||||
*
|
||||
* <h2>Why an activity of its own</h2>
|
||||
*
|
||||
* <p>{@code ACTION_OPEN_DOCUMENT_TREE} answers through
|
||||
* {@code onActivityResult}, and the main activity is {@code NativeActivity},
|
||||
* whose result callback is not ours to override. So this one exists only to
|
||||
* ask: it starts the picker, takes the answer, and finishes — no layout, a
|
||||
* translucent theme, nothing on screen but the system's own chooser, which has
|
||||
* its own "New folder".
|
||||
*
|
||||
* <p>The answer is left in a static for Rust to poll ({@link #poll}), rather
|
||||
* than called back into native code: a callback would need a registered
|
||||
* native method and a thread to deliver on, and a poll from the Slint timer
|
||||
* that is already running is one static call.
|
||||
*
|
||||
* <h2>The grant</h2>
|
||||
*
|
||||
* <p>A tree URI is usable only while its permission is held, and a plain
|
||||
* result grants it until the process dies. {@code takePersistableUriPermission}
|
||||
* keeps it across restarts — an album's folder is chosen once and exported to
|
||||
* for months.
|
||||
*/
|
||||
public final class FolderPicker extends Activity {
|
||||
private static final String TAG = "DarkRoom";
|
||||
private static final int REQUEST = 0x5AF;
|
||||
|
||||
/** The last answer: a tree URI, "" for a cancel, null while none has come. */
|
||||
private static volatile String answer = null;
|
||||
|
||||
/**
|
||||
* Start asking. Clears any answer left from before.
|
||||
*
|
||||
* <p>Takes a {@code Context} rather than an {@code Activity}, because what
|
||||
* native code holds (ndk_context's handle) is the application context,
|
||||
* and starting an activity from one that is not an activity needs
|
||||
* {@code FLAG_ACTIVITY_NEW_TASK} — without it the call throws. The picker
|
||||
* shares the app's task affinity, so it still opens over the app and Back
|
||||
* still returns to it.
|
||||
*/
|
||||
public static void start(Context from) {
|
||||
answer = null;
|
||||
Intent intent = new Intent(from, FolderPicker.class);
|
||||
if (!(from instanceof Activity)) {
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
}
|
||||
from.startActivity(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer, once: a tree URI, "" if the user backed out, or null while
|
||||
* the picker is still open. Reading it clears it, so a second poll after a
|
||||
* cancel does not see the cancel again.
|
||||
*/
|
||||
public static String poll() {
|
||||
String a = answer;
|
||||
if (a != null) {
|
||||
answer = null;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
// Recreated after a rotation with the picker already up: asking again
|
||||
// would stack a second chooser over the first.
|
||||
if (state != null) {
|
||||
return;
|
||||
}
|
||||
Intent pick = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
pick.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
|
||||
try {
|
||||
startActivityForResult(pick, REQUEST);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
Log.w(TAG, "no folder picker on this device", e);
|
||||
answer = "";
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int request, int result, Intent data) {
|
||||
if (request != REQUEST) {
|
||||
return;
|
||||
}
|
||||
Uri tree = (result == RESULT_OK && data != null) ? data.getData() : null;
|
||||
if (tree == null) {
|
||||
answer = "";
|
||||
} else {
|
||||
try {
|
||||
getContentResolver().takePersistableUriPermission(tree,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
} catch (SecurityException e) {
|
||||
// Still usable this session; said in the log so a folder that
|
||||
// stops working after a restart has an explanation.
|
||||
Log.w(TAG, "the folder grant could not be kept: " + tree, e);
|
||||
}
|
||||
answer = tree.toString();
|
||||
}
|
||||
finish();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package paris.tourolle.darkroom;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.provider.DocumentsContract;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* Writing an export into a folder the user granted through
|
||||
* {@link FolderPicker} — the Storage Access Framework, which is the only way
|
||||
* this app reaches a folder on the device (FR-PLAT-AND-1).
|
||||
*
|
||||
* <p>A tree URI is not a path: a child is found by listing the folder and
|
||||
* matching its display name, and created through the provider, which may
|
||||
* rename it on a collision. So the name that was actually written is handed
|
||||
* back, and the album records that one.
|
||||
*
|
||||
* <p>Two static calls, strings and a byte array in, a string out, for the
|
||||
* reason {@link Intents} gives: every call here would be a signature typed as
|
||||
* a string on the Rust side, and the fewer of those the better.
|
||||
*/
|
||||
public final class Saf {
|
||||
private static final String TAG = "DarkRoom";
|
||||
|
||||
private Saf() {
|
||||
}
|
||||
|
||||
/** Whether {@code name} already exists in the folder. False on any error. */
|
||||
public static boolean exists(Context context, String tree, String name) {
|
||||
try {
|
||||
return find(context.getContentResolver(), Uri.parse(tree), name) != null;
|
||||
} catch (RuntimeException e) {
|
||||
Log.w(TAG, "checking " + name + " in " + tree, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write {@code bytes} as {@code name} in the folder, replacing a file of
|
||||
* that name when {@code replace} is set.
|
||||
*
|
||||
* @return the name the file has in the folder — the provider may have
|
||||
* added " (1)" — or null on failure, with the reason in the log.
|
||||
*/
|
||||
public static String write(Context context, String tree, String name, String mime,
|
||||
byte[] bytes, boolean replace) {
|
||||
ContentResolver resolver = context.getContentResolver();
|
||||
Uri treeUri = Uri.parse(tree);
|
||||
try {
|
||||
Uri target = replace ? find(resolver, treeUri, name) : null;
|
||||
if (target == null) {
|
||||
Uri folder = DocumentsContract.buildDocumentUriUsingTree(treeUri,
|
||||
DocumentsContract.getTreeDocumentId(treeUri));
|
||||
target = DocumentsContract.createDocument(resolver, folder, mime, name);
|
||||
}
|
||||
if (target == null) {
|
||||
Log.w(TAG, "the folder refused to create " + name + " in " + tree);
|
||||
return null;
|
||||
}
|
||||
// "wt": truncate. A replacement shorter than what it replaces
|
||||
// must not keep the old file's tail.
|
||||
try (OutputStream out = resolver.openOutputStream(target, "wt")) {
|
||||
if (out == null) {
|
||||
Log.w(TAG, "no stream for " + target);
|
||||
return null;
|
||||
}
|
||||
out.write(bytes);
|
||||
}
|
||||
String written = displayName(resolver, target);
|
||||
return written != null ? written : name;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
Log.w(TAG, "writing " + name + " to " + tree, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The document for {@code name} directly in the tree's folder, or null. */
|
||||
private static Uri find(ContentResolver resolver, Uri tree, String name) {
|
||||
String folderId = DocumentsContract.getTreeDocumentId(tree);
|
||||
Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(tree, folderId);
|
||||
String[] columns = {
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
};
|
||||
try (Cursor c = resolver.query(children, columns, null, null, null)) {
|
||||
if (c == null) {
|
||||
return null;
|
||||
}
|
||||
while (c.moveToNext()) {
|
||||
if (name.equals(c.getString(1))) {
|
||||
return DocumentsContract.buildDocumentUriUsingTree(tree, c.getString(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String displayName(ContentResolver resolver, Uri document) {
|
||||
String[] columns = {DocumentsContract.Document.COLUMN_DISPLAY_NAME};
|
||||
try (Cursor c = resolver.query(document, columns, null, null, null)) {
|
||||
if (c != null && c.moveToFirst()) {
|
||||
return c.getString(0);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
Log.w(TAG, "reading the name of " + document, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -479,6 +479,14 @@ fn choose_device_folder(window: &AppWindow, ctl: &Rc<AlbumsController>) {
|
||||
}
|
||||
render_sheet(&w, &ctl);
|
||||
};
|
||||
// Android has no filesystem dialogue; its folder picker hands back a
|
||||
// tree the app is granted, which is what an export there writes into.
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let _ = (window, start);
|
||||
crate::saf::pick_tree(chosen);
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
crate::folder_dialog::ask(
|
||||
window,
|
||||
"Folder for this album",
|
||||
|
||||
@@ -213,6 +213,21 @@ pub fn place(
|
||||
if destination.trim().is_empty() {
|
||||
return Err("No export folder is set. Choose an album to export to.".into());
|
||||
}
|
||||
// TRACES: FR-EXP-10 | FR-PLAT-AND-1
|
||||
// A SAF tree on Android: written through the provider, which may
|
||||
// rename on a collision, so the name it reports is the one kept.
|
||||
#[cfg(target_os = "android")]
|
||||
if destination.starts_with("content://") {
|
||||
let replace = collision_replaces(&encoded.name, destination);
|
||||
let written = crate::saf::write(
|
||||
destination,
|
||||
&encoded.name,
|
||||
mime_for(&encoded.name),
|
||||
&encoded.bytes,
|
||||
replace,
|
||||
)?;
|
||||
return Ok(Placed::Device(PathBuf::from(written)));
|
||||
}
|
||||
let dir = PathBuf::from(destination);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let path = dir.join(&encoded.name);
|
||||
@@ -231,6 +246,30 @@ pub fn place(
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether writing `name` into a SAF folder should replace a file already
|
||||
/// there. By the time `place` runs, the batch has already chosen the name
|
||||
/// under the collision policy, so a name that is taken can only have been
|
||||
/// chosen under Overwrite.
|
||||
#[cfg(target_os = "android")]
|
||||
fn collision_replaces(name: &str, tree: &str) -> bool {
|
||||
crate::saf::exists(tree, name)
|
||||
}
|
||||
|
||||
/// The MIME type a document provider is told, from the name the encoder gave.
|
||||
#[cfg(target_os = "android")]
|
||||
fn mime_for(name: &str) -> &'static str {
|
||||
match Path::new(name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref()
|
||||
{
|
||||
Some("png") => "image/png",
|
||||
Some("tif" | "tiff") => "image/tiff",
|
||||
_ => "image/jpeg",
|
||||
}
|
||||
}
|
||||
|
||||
/// Write bytes and their destination record into the outbox.
|
||||
fn stage(encoded: &Encoded, remote_dir: &str, outbox: &Path) -> Result<PathBuf, String> {
|
||||
std::fs::create_dir_all(outbox).map_err(|e| format!("{}: {e}", outbox.display()))?;
|
||||
@@ -983,6 +1022,10 @@ fn resolve_batch_name(
|
||||
let dir = PathBuf::from(&settings.destination);
|
||||
let taken = |name: &str| -> bool {
|
||||
match settings.target {
|
||||
#[cfg(target_os = "android")]
|
||||
dr_types::ExportTarget::Device if settings.destination.starts_with("content://") => {
|
||||
crate::saf::exists(&settings.destination, name)
|
||||
}
|
||||
dr_types::ExportTarget::Device => dir.join(name).exists(),
|
||||
// A queued export cannot see the server, and may never be able to.
|
||||
// Names are kept apart in the outbox instead — see [`stage`].
|
||||
|
||||
@@ -68,6 +68,8 @@ mod recovery_ui;
|
||||
mod refine;
|
||||
mod remote;
|
||||
mod remote_folders;
|
||||
#[cfg(target_os = "android")]
|
||||
mod saf;
|
||||
pub mod repairs;
|
||||
mod segmentation;
|
||||
mod settings_store;
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//! TRACES: FR-EXP-10 | FR-PLAT-AND-1
|
||||
//! Android's Storage Access Framework, for an album's folder on the device.
|
||||
//!
|
||||
//! The folder is chosen in the system's own picker, which can make a new
|
||||
//! folder too, and comes back as a tree URI with a persisted grant. Exports
|
||||
//! are then written into it through `DocumentsContract` — a tree URI is not a
|
||||
//! path, so nothing here touches the filesystem. The Java halves are
|
||||
//! `FolderPicker.java` and `Saf.java` in the Android app; this is the JNI
|
||||
//! bridge to them, in the jni 0.22 idiom `launch_ui::android_open_url` uses.
|
||||
//!
|
||||
//! The classes are loaded through the application's class loader rather than
|
||||
//! `FindClass`: a worker thread attached from Rust sees only the system's
|
||||
//! classes through `FindClass`, and an export writes from a worker.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use jni::objects::{JClass, JClassLoader, JObject, JString, JValue};
|
||||
use jni::strings::JNIStr;
|
||||
|
||||
/// Run `body` with the application context ndk_context holds, on whatever
|
||||
/// thread this is. It is a `Context`, not the activity — see
|
||||
/// `FolderPicker.start` for what that changes.
|
||||
fn call<T>(
|
||||
what: &str,
|
||||
body: impl FnOnce(&mut jni::Env, &JObject) -> jni::errors::Result<T>,
|
||||
) -> Result<T, String> {
|
||||
let ctx = ndk_context::android_context();
|
||||
if ctx.vm().is_null() || ctx.context().is_null() {
|
||||
return Err(format!("{what}: no Android context"));
|
||||
}
|
||||
// SAFETY: the pointers come from ndk_context, which android-activity's
|
||||
// glue fills in with the process's JavaVM and activity before any Rust
|
||||
// runs; the activity outlives every call here.
|
||||
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
|
||||
let raw: jni::sys::jobject = ctx.context().cast();
|
||||
vm.attach_current_thread(|env| -> jni::errors::Result<T> {
|
||||
// SAFETY: valid for this frame, which is as long as it is used.
|
||||
let activity = unsafe { JObject::from_raw(env, raw) };
|
||||
let result = body(env, &activity);
|
||||
// A Java exception left pending makes the next JNI call on this
|
||||
// thread abort the process. The Java side logs its own failures, so
|
||||
// describing it here is for the ones it did not expect.
|
||||
if env.exception_check() {
|
||||
env.exception_describe();
|
||||
env.exception_clear();
|
||||
}
|
||||
result
|
||||
})
|
||||
.map_err(|e| format!("{what}: {e}"))
|
||||
}
|
||||
|
||||
/// One of the app's own classes, through the application's class loader.
|
||||
///
|
||||
/// Asked of the context with `getClassLoader()`, not taken from the
|
||||
/// context's class: that is a framework class (`android.app.NativeActivity`,
|
||||
/// or the application context behind it) the boot loader defined, and the
|
||||
/// boot loader has never heard of anything in this APK. Loading through it fails with "class not found",
|
||||
/// which is what the first build on the tablet did.
|
||||
fn class<'local>(
|
||||
env: &mut jni::Env<'local>,
|
||||
activity: &JObject,
|
||||
name: &JNIStr,
|
||||
) -> jni::errors::Result<JClass<'local>> {
|
||||
let loader = env
|
||||
.call_method(
|
||||
activity,
|
||||
jni::jni_str!("getClassLoader"),
|
||||
jni::jni_sig!("()Ljava/lang/ClassLoader;"),
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
let loader = env.cast_local::<JClassLoader>(loader)?;
|
||||
jni::refs::LoaderContext::Loader(&loader).load_class(env, name, true)
|
||||
}
|
||||
|
||||
/// A Java string result, or `None` for null.
|
||||
fn text(env: &mut jni::Env, value: JObject) -> jni::errors::Result<Option<String>> {
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let s = env.cast_local::<JString>(value)?;
|
||||
Ok(Some(s.try_to_string(env)?))
|
||||
}
|
||||
|
||||
/// Ask for a folder, and call `chosen` with its tree URI if one is picked.
|
||||
///
|
||||
/// Nothing is called on a cancel, matching `folder_dialog::ask`. The answer
|
||||
/// is polled from the Slint timer on the UI thread: the picker is another
|
||||
/// activity, and this one's event loop keeps running under it.
|
||||
pub fn pick_tree(chosen: impl FnOnce(String) + 'static) {
|
||||
let started = call("opening the folder picker", |env, activity| {
|
||||
let cls = class(env, activity, jni::jni_str!("paris.tourolle.darkroom.FolderPicker"))?;
|
||||
env.call_static_method(
|
||||
&cls,
|
||||
jni::jni_str!("start"),
|
||||
jni::jni_sig!("(Landroid/content/Context;)V"),
|
||||
&[activity.into()],
|
||||
)?;
|
||||
Ok(())
|
||||
});
|
||||
if let Err(e) = started {
|
||||
log::warn!("{e}");
|
||||
return;
|
||||
}
|
||||
|
||||
// The timer owns itself until the answer arrives; see
|
||||
// `remote_folders::run` for why it is stopped and released separately.
|
||||
let slot: Rc<RefCell<Option<slint::Timer>>> = Rc::new(RefCell::new(None));
|
||||
let held = slot.clone();
|
||||
let mut chosen = Some(chosen);
|
||||
let since = Instant::now();
|
||||
let timer = slint::Timer::default();
|
||||
timer.start(slint::TimerMode::Repeated, Duration::from_millis(250), move || {
|
||||
let answer = call("reading the folder picker", |env, activity| {
|
||||
let cls = class(env, activity, jni::jni_str!("paris.tourolle.darkroom.FolderPicker"))?;
|
||||
let value = env
|
||||
.call_static_method(
|
||||
&cls,
|
||||
jni::jni_str!("poll"),
|
||||
jni::jni_sig!("()Ljava/lang/String;"),
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
text(env, value)
|
||||
});
|
||||
let finished = match answer {
|
||||
Ok(None) => since.elapsed() > Duration::from_secs(600),
|
||||
Ok(Some(uri)) => {
|
||||
if !uri.is_empty() {
|
||||
if let Some(chosen) = chosen.take() {
|
||||
chosen(uri);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("{e}");
|
||||
true
|
||||
}
|
||||
};
|
||||
if finished {
|
||||
if let Some(t) = held.borrow().as_ref() {
|
||||
t.stop();
|
||||
}
|
||||
let held = held.clone();
|
||||
slint::Timer::single_shot(Duration::ZERO, move || {
|
||||
held.borrow_mut().take();
|
||||
});
|
||||
}
|
||||
});
|
||||
*slot.borrow_mut() = Some(timer);
|
||||
}
|
||||
|
||||
/// Whether `name` is already in the folder. False when it cannot be told,
|
||||
/// which lets the provider's own rename-on-collision be the backstop.
|
||||
pub fn exists(tree: &str, name: &str) -> bool {
|
||||
call("checking the album folder", |env, activity| {
|
||||
let cls = class(env, activity, jni::jni_str!("paris.tourolle.darkroom.Saf"))?;
|
||||
let tree = env.new_string(tree)?;
|
||||
let name = env.new_string(name)?;
|
||||
env.call_static_method(
|
||||
&cls,
|
||||
jni::jni_str!("exists"),
|
||||
jni::jni_sig!("(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)Z"),
|
||||
&[activity.into(), (&tree).into(), (&name).into()],
|
||||
)?
|
||||
.z()
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("{e}");
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
/// Write an export into the folder. Returns the name it has there, which the
|
||||
/// provider may have changed on a collision.
|
||||
pub fn write(tree: &str, name: &str, mime: &str, bytes: &[u8], replace: bool) -> Result<String, String> {
|
||||
call("writing to the album folder", |env, activity| {
|
||||
let cls = class(env, activity, jni::jni_str!("paris.tourolle.darkroom.Saf"))?;
|
||||
let jtree = env.new_string(tree)?;
|
||||
let jname = env.new_string(name)?;
|
||||
let jmime = env.new_string(mime)?;
|
||||
let data = env.byte_array_from_slice(bytes)?;
|
||||
let value = env
|
||||
.call_static_method(
|
||||
&cls,
|
||||
jni::jni_str!("write"),
|
||||
jni::jni_sig!(
|
||||
"(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[BZ)Ljava/lang/String;"
|
||||
),
|
||||
&[
|
||||
activity.into(),
|
||||
(&jtree).into(),
|
||||
(&jname).into(),
|
||||
(&jmime).into(),
|
||||
(&data).into(),
|
||||
JValue::Bool(replace),
|
||||
],
|
||||
)?
|
||||
.l()?;
|
||||
text(env, value)
|
||||
})?
|
||||
.ok_or_else(|| format!("the folder refused {name}; the log has the reason"))
|
||||
}
|
||||
Reference in New Issue
Block a user