Android: write and read picked files through content URIs
On Android the file dialogs return content:// URIs, not paths. Exports wrote them with std::fs, which failed after the system had already created the document, leaving a 0 B file; deck, library-card and library backup exports now write through tauri-plugin-fs, and a failed deck export shows an error instead of failing silently. Library-file and card-database imports read the pick through the fs plugin too. A library file is told apart by its SQLite header rather than its extension, and SQLite opens a local copy. The library-import picker drops its filters on Android, where they hid .db and backup files. Deck import suggests the file's display name rather than the URI's opaque id, and the backup's "Saved to" message shows the file name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -147,7 +147,7 @@ pub async fn update_card_data(app: AppHandle, db: Db<'_>) -> Res<Status> {
|
||||
/// minutes — and Android may kill the app partway through. Copy the file from a
|
||||
/// desktop install and point this at it.
|
||||
#[tauri::command]
|
||||
pub fn import_reference_db(db: Db, path: String) -> Res<Status> {
|
||||
pub fn import_reference_db(app: AppHandle, db: Db, path: String) -> Res<Status> {
|
||||
let src = PathBuf::from(&path);
|
||||
let mut s = db.lock().unwrap();
|
||||
let dest = s.reference_path.clone();
|
||||
@@ -158,28 +158,34 @@ pub fn import_reference_db(db: Db, path: String) -> Res<Status> {
|
||||
|
||||
crate::mlog!("import_reference_db: from {}", src.display());
|
||||
|
||||
// Copy beside the destination, then rename: a same-directory rename is
|
||||
// atomic, so an interrupted copy can't leave a half-written reference.db.
|
||||
// The copy also gives SQLite a real file, which an Android pick (a
|
||||
// content:// URI) isn't.
|
||||
let tmp = dest.with_extension("db.import");
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Err(e) = copy_picked(&app, &path, &tmp) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(format!("Couldn't read that card database: {e}"));
|
||||
}
|
||||
|
||||
// Validate before disturbing the working db, so picking the wrong file is a
|
||||
// harmless error rather than a broken install.
|
||||
let cards = crate::logged(
|
||||
"import_reference_db validate",
|
||||
mtgc_core::db::inspect_reference_db(&src).map_err(|e| e.to_string()),
|
||||
)?;
|
||||
mtgc_core::db::inspect_reference_db(&tmp).map_err(|e| e.to_string()),
|
||||
)
|
||||
.inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})?;
|
||||
crate::mlog!("import_reference_db: source holds {cards} oracle cards");
|
||||
|
||||
// Release the handles on the file we're about to replace.
|
||||
s.close();
|
||||
|
||||
// Copy beside the destination, then rename: a same-directory rename is
|
||||
// atomic, so an interrupted copy can't leave a half-written reference.db.
|
||||
let tmp = dest.with_extension("db.import");
|
||||
let install = || -> std::io::Result<()> {
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(&src, &tmp)?;
|
||||
std::fs::rename(&tmp, &dest)
|
||||
};
|
||||
if let Err(e) = install() {
|
||||
if let Err(e) = std::fs::rename(&tmp, &dest) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let _ = s.reopen(); // put the previous db back into service
|
||||
return Err(format!("Couldn't install that card database: {e}"));
|
||||
@@ -477,10 +483,22 @@ pub struct LibraryImportResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_deck(db: Db, deck_id: i64, path: String) -> Res<()> {
|
||||
pub fn export_deck(app: AppHandle, db: Db, deck_id: i64, path: String) -> Res<()> {
|
||||
let s = db.lock().unwrap();
|
||||
let text = s.catalog()?.export_deck_text(deck_id).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&path, text).map_err(|e| e.to_string())
|
||||
write_picked_text(&app, &path, &text)
|
||||
}
|
||||
|
||||
/// Write a text file the save dialog picked. On Android that's a content:// URI
|
||||
/// for a document the system has already created (empty), so std::fs can't
|
||||
/// write it; the fs plugin opens it through the content resolver.
|
||||
pub(crate) fn write_picked_text(app: &AppHandle, path: &str, text: &str) -> Res<()> {
|
||||
use std::io::Write;
|
||||
let path: FilePath = path.parse().unwrap();
|
||||
let mut opts = tauri_plugin_fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
let mut file = app.fs().open(path, opts).map_err(|e| e.to_string())?;
|
||||
file.write_all(text.as_bytes()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Read a text file the open dialog picked. On Android that's a content:// URI
|
||||
@@ -490,6 +508,41 @@ fn read_picked_text(app: &AppHandle, path: &str) -> Res<String> {
|
||||
app.fs().read_to_string(path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Read a file the open dialog picked, as bytes; see [`read_picked_text`].
|
||||
pub(crate) fn read_picked(app: &AppHandle, path: &str) -> Res<Vec<u8>> {
|
||||
let path: FilePath = path.parse().unwrap();
|
||||
app.fs().read(path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Copy a file the open dialog picked to `dest`, streaming it rather than
|
||||
/// holding it in memory. For files SQLite has to open, which on Android can't
|
||||
/// be done through the content:// URI the dialog returns.
|
||||
pub(crate) fn copy_picked(app: &AppHandle, path: &str, dest: &std::path::Path) -> Res<()> {
|
||||
let path: FilePath = path.parse().unwrap();
|
||||
let mut opts = tauri_plugin_fs::OpenOptions::new();
|
||||
opts.read(true);
|
||||
let mut src = app.fs().open(path, opts).map_err(|e| e.to_string())?;
|
||||
let mut out = std::fs::File::create(dest).map_err(|e| e.to_string())?;
|
||||
std::io::copy(&mut src, &mut out).map_err(|e| e.to_string())?;
|
||||
out.sync_all().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The name to show for a file the open dialog picked: its last path segment
|
||||
/// on desktop, the document's display name on Android (whose content:// URIs
|
||||
/// often end in an opaque id like `msf:1000012345`).
|
||||
#[tauri::command]
|
||||
pub async fn picked_file_name(app: AppHandle, path: String) -> Res<Option<String>> {
|
||||
crate::sync::blocking(move || {
|
||||
#[cfg(target_os = "android")]
|
||||
if path.starts_with("content://") {
|
||||
return Ok(crate::share::display_name(&app, &path));
|
||||
}
|
||||
let _ = app;
|
||||
Ok(std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_deck(app: AppHandle, db: Db, path: String, name: String) -> Res<DeckImportResult> {
|
||||
let text = read_picked_text(&app, &path)?;
|
||||
@@ -499,13 +552,18 @@ pub fn import_deck(app: AppHandle, db: Db, path: String, name: String) -> Res<De
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_library_cards(db: Db, oracle_ids: Vec<String>, path: String) -> Res<()> {
|
||||
pub fn export_library_cards(
|
||||
app: AppHandle,
|
||||
db: Db,
|
||||
oracle_ids: Vec<String>,
|
||||
path: String,
|
||||
) -> Res<()> {
|
||||
let s = db.lock().unwrap();
|
||||
let text = s
|
||||
.catalog()?
|
||||
.export_library_text(&oracle_ids)
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(&path, text).map_err(|e| e.to_string())
|
||||
write_picked_text(&app, &path, &text)
|
||||
}
|
||||
|
||||
/// How far a library import has got, for the progress bar.
|
||||
|
||||
Reference in New Issue
Block a user