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:
2026-10-28 22:48:36 -05:00
parent eeb426ab8f
commit ef4a0d740d
9 changed files with 173 additions and 36 deletions

View File

@@ -3,11 +3,14 @@ package com.garrettmills.mtgc
import android.app.Activity import android.app.Activity
import android.content.ClipData import android.content.ClipData
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import app.tauri.annotation.Command import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin import app.tauri.plugin.Plugin
import java.io.File import java.io.File
@@ -18,9 +21,16 @@ class ShareImageArgs {
var title: String? = null var title: String? = null
} }
@InvokeArg
class DisplayNameArgs {
/** A content:// URI the system file picker returned. */
lateinit var uri: String
}
/** /**
* The Android half of the app's `share` plugin (src-tauri/src/share.rs): * The Android half of the app's `share` plugin (src-tauri/src/share.rs):
* opens the system share sheet for a card image the Rust side has saved. * opens the system share sheet for a card image the Rust side has saved, and
* looks up the names of documents the system file picker returns.
*/ */
@TauriPlugin @TauriPlugin
class SharePlugin(private val activity: Activity) : Plugin(activity) { class SharePlugin(private val activity: Activity) : Plugin(activity) {
@@ -48,4 +58,18 @@ class SharePlugin(private val activity: Activity) : Plugin(activity) {
invoke.reject(ex.message) invoke.reject(ex.message)
} }
} }
/** The file name the picker showed for `uri`, e.g. "Atraxa.txt". */
@Command
fun displayName(invoke: Invoke) {
try {
val args = invoke.parseArgs(DisplayNameArgs::class.java)
val name = activity.contentResolver
.query(Uri.parse(args.uri), arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { if (it.moveToFirst()) it.getString(0) else null }
invoke.resolve(JSObject().apply { put("name", name) })
} catch (ex: Exception) {
invoke.reject(ex.message)
}
}
} }

View File

@@ -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 /// minutes — and Android may kill the app partway through. Copy the file from a
/// desktop install and point this at it. /// desktop install and point this at it.
#[tauri::command] #[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 src = PathBuf::from(&path);
let mut s = db.lock().unwrap(); let mut s = db.lock().unwrap();
let dest = s.reference_path.clone(); 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()); 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 // Validate before disturbing the working db, so picking the wrong file is a
// harmless error rather than a broken install. // harmless error rather than a broken install.
let cards = crate::logged( let cards = crate::logged(
"import_reference_db validate", "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"); crate::mlog!("import_reference_db: source holds {cards} oracle cards");
// Release the handles on the file we're about to replace. // Release the handles on the file we're about to replace.
s.close(); s.close();
// Copy beside the destination, then rename: a same-directory rename is if let Err(e) = std::fs::rename(&tmp, &dest) {
// 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() {
let _ = std::fs::remove_file(&tmp); let _ = std::fs::remove_file(&tmp);
let _ = s.reopen(); // put the previous db back into service let _ = s.reopen(); // put the previous db back into service
return Err(format!("Couldn't install that card database: {e}")); return Err(format!("Couldn't install that card database: {e}"));
@@ -477,10 +483,22 @@ pub struct LibraryImportResult {
} }
#[tauri::command] #[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 s = db.lock().unwrap();
let text = s.catalog()?.export_deck_text(deck_id).map_err(|e| e.to_string())?; 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 /// 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()) 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] #[tauri::command]
pub fn import_deck(app: AppHandle, db: Db, path: String, name: String) -> Res<DeckImportResult> { pub fn import_deck(app: AppHandle, db: Db, path: String, name: String) -> Res<DeckImportResult> {
let text = read_picked_text(&app, &path)?; 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] #[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 s = db.lock().unwrap();
let text = s let text = s
.catalog()? .catalog()?
.export_library_text(&oracle_ids) .export_library_text(&oracle_ids)
.map_err(|e| e.to_string())?; .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. /// How far a library import has got, for the progress bar.

View File

@@ -125,6 +125,7 @@ pub fn run() {
commands::set_deck_card_printing, commands::set_deck_card_printing,
commands::export_deck, commands::export_deck,
commands::import_deck, commands::import_deck,
commands::picked_file_name,
commands::export_library_cards, commands::export_library_cards,
commands::import_library_cards, commands::import_library_cards,
]) ])

View File

@@ -3,7 +3,8 @@
//! //!
//! Android has no official Tauri share plugin, so this registers a small one //! Android has no official Tauri share plugin, so this registers a small one
//! of our own whose Kotlin half (`SharePlugin.kt`, in the Android app module) //! of our own whose Kotlin half (`SharePlugin.kt`, in the Android app module)
//! hands the image to `ACTION_SEND` via the app's FileProvider. //! hands the image to `ACTION_SEND` via the app's FileProvider. The same
//! plugin looks up the display names of documents the file picker returns.
use tauri::AppHandle; use tauri::AppHandle;
@@ -72,6 +73,26 @@ fn deliver(_app: &AppHandle, _bytes: &[u8], _name: &str) -> Res<&'static str> {
Err("Sharing isn't available on iOS yet.".into()) Err("Sharing isn't available on iOS yet.".into())
} }
/// The name the system file picker showed for a content:// URI, if the
/// document's provider reports one.
#[cfg(target_os = "android")]
pub fn display_name(app: &AppHandle, uri: &str) -> Option<String> {
use tauri::Manager;
#[derive(serde::Deserialize)]
struct Named {
name: Option<String>,
}
let plugin = app.state::<SharePlugin>();
let named: Named = plugin
.0
.run_mobile_plugin("displayName", serde_json::json!({ "uri": uri }))
.map_err(|e| crate::mlog!("display name for {uri}: {e}"))
.ok()?;
named.name
}
/// The Kotlin half's handle, for `run_mobile_plugin`. /// The Kotlin half's handle, for `run_mobile_plugin`.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
struct SharePlugin(tauri::plugin::PluginHandle<tauri::Wry>); struct SharePlugin(tauri::plugin::PluginHandle<tauri::Wry>);

View File

@@ -952,18 +952,26 @@ pub async fn import_library_file(app: AppHandle, path: String) -> Result<ImportR
let app2 = app.clone(); let app2 = app.clone();
let report = blocking(move || { let report = blocking(move || {
let file = PathBuf::from(&path); let file = PathBuf::from(&path);
if !file.is_file() { // On Android the dialog returns a content:// URI rather than a path:
// it's read through the fs plugin, and has no extension to go by.
if !path.starts_with("content://") && !file.is_file() {
return Err(format!("{} isn't there.", file.display())); return Err(format!("{} isn't there.", file.display()));
} }
let is_json = file.extension().is_some_and(|e| e.eq_ignore_ascii_case("json")); let bytes = crate::commands::read_picked(&app2, &path)?;
let doc = if is_json { let mut s = lock(&app2);
let text = std::fs::read_to_string(&file).map_err(|e| e.to_string())?; let doc = if bytes.starts_with(b"SQLite format 3\0") {
LibraryDoc::from_json(&text).map_err(|e| format!("Not a library file: {e}"))? // SQLite needs a real file, so convert a copy beside the working db.
let tmp = s.working_db_path.with_file_name("library-import.db");
let converted = std::fs::write(&tmp, &bytes)
.map_err(|e| e.to_string())
.and_then(|()| core_sync::convert_legacy_library(&tmp).map_err(|e| e.to_string()));
let _ = std::fs::remove_file(&tmp);
converted?
} else { } else {
core_sync::convert_legacy_library(&file).map_err(|e| e.to_string())? let text = String::from_utf8(bytes).map_err(|_| "Not a library file.".to_string())?;
LibraryDoc::from_json(&text).map_err(|e| format!("Not a library file: {e}"))?
}; };
let mut s = lock(&app2);
let conn = mtgc_core::db::open_library(&s.working_db_path).map_err(|e| e.to_string())?; let conn = mtgc_core::db::open_library(&s.working_db_path).map_err(|e| e.to_string())?;
let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?; let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?;
let (merged, report) = core_sync::merge3(&LibraryDoc::default(), &ours, &doc); let (merged, report) = core_sync::merge3(&LibraryDoc::default(), &ours, &doc);
@@ -996,7 +1004,8 @@ pub async fn export_library_document(app: AppHandle, path: String) -> Result<(),
local_doc(&s.working_db_path)? local_doc(&s.working_db_path)?
}; };
let text = doc.to_json().map_err(|e| e.to_string())?; let text = doc.to_json().map_err(|e| e.to_string())?;
std::fs::write(&path, text).map_err(|e| format!("Couldn't save {path}: {e}")) crate::commands::write_picked_text(&app, &path, &text)
.map_err(|e| format!("Couldn't save {path}: {e}"))
}) })
.await .await
} }

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { open, save } from "@tauri-apps/plugin-dialog"; import { open, save } from "@tauri-apps/plugin-dialog";
import { api, FirstSync, SyncDevice, SyncReport, SyncStatus } from "./api"; import { api, FirstSync, SyncDevice, SyncReport, SyncStatus } from "./api";
import { isAndroid } from "./backButton";
import { ConfirmDialog, formatRelativeTime } from "./components"; import { ConfirmDialog, formatRelativeTime } from "./components";
/** The server's answer when this device's token stops working. */ /** The server's answer when this device's token stops working. */
@@ -182,7 +183,11 @@ export function SyncPanel({
}); });
if (typeof path !== "string") return null; if (typeof path !== "string") return null;
await api.exportLibraryDocument(path); await api.exportLibraryDocument(path);
return `Saved to ${path}.`; // On Android the path is a content:// URI, so show the file's name instead.
const shown = path.startsWith("content://")
? ((await api.pickedFileName(path).catch(() => null)) ?? "your chosen file")
: path;
return `Saved to ${shown}.`;
}); });
const importLibrary = (preset?: string) => const importLibrary = (preset?: string) =>
@@ -191,10 +196,14 @@ export function SyncPanel({
preset ?? preset ??
(await open({ (await open({
title: "Import a library file", title: "Import a library file",
filters: [ // Android filters by MIME type, and knows none for .db (nor reliably
{ name: "mtgc library", extensions: ["json", "db"] }, // for a backup .json), so it would hide the very files we want.
{ name: "All files", extensions: ["*"] }, filters: isAndroid
], ? []
: [
{ name: "mtgc library", extensions: ["json", "db"] },
{ name: "All files", extensions: ["*"] },
],
})); }));
if (typeof path !== "string") return null; if (typeof path !== "string") return null;
const r = await api.importLibraryFile(path); const r = await api.importLibraryFile(path);

View File

@@ -601,6 +601,7 @@ export const api = {
invoke<DeckEntry | null>("set_deck_card_printing", { deckId, oracleId, scryfallId }), invoke<DeckEntry | null>("set_deck_card_printing", { deckId, oracleId, scryfallId }),
exportDeck: (deckId: number, path: string) => invoke<void>("export_deck", { deckId, path }), exportDeck: (deckId: number, path: string) => invoke<void>("export_deck", { deckId, path }),
pickedFileName: (path: string) => invoke<string | null>("picked_file_name", { path }),
importDeck: (path: string, name: string) => importDeck: (path: string, name: string) =>
invoke<DeckImportResult>("import_deck", { path, name }), invoke<DeckImportResult>("import_deck", { path, name }),
exportLibraryCards: (oracleIds: string[], path: string) => exportLibraryCards: (oracleIds: string[], path: string) =>

View File

@@ -11,7 +11,7 @@ import { PluginListener } from "@tauri-apps/api/core";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
// The plugin's register/remove commands only exist on mobile. // The plugin's register/remove commands only exist on mobile.
const isAndroid = /Android/i.test(navigator.userAgent); export const isAndroid = /Android/i.test(navigator.userAgent);
/** Claimants in the order they claimed the back button; the last one wins. */ /** Claimants in the order they claimed the back button; the last one wins. */
const stack: { handle: () => void }[] = []; const stack: { handle: () => void }[] = [];

View File

@@ -101,6 +101,7 @@ type DeckDialog =
| { kind: "duplicate"; deck: Deck } | { kind: "duplicate"; deck: Deck }
| { kind: "import-name"; suggestedName: string; path: string } | { kind: "import-name"; suggestedName: string; path: string }
| { kind: "import-result"; result: DeckImportResult } | { kind: "import-result"; result: DeckImportResult }
| { kind: "export-failed"; message: string }
| null; | null;
/** Name a new deck and, optionally, pick its format up front. */ /** Name a new deck and, optionally, pick its format up front. */
@@ -1775,7 +1776,12 @@ export function DeckView({
defaultPath: `${d.name}.txt`, defaultPath: `${d.name}.txt`,
filters: [{ name: "Decklist", extensions: ["txt"] }], filters: [{ name: "Decklist", extensions: ["txt"] }],
}); });
if (typeof path === "string") await api.exportDeck(d.id, path); if (typeof path !== "string") return;
try {
await api.exportDeck(d.id, path);
} catch (err) {
setDialog({ kind: "export-failed", message: String(err) });
}
} }
async function pickImportFile() { async function pickImportFile() {
@@ -1784,7 +1790,8 @@ export function DeckView({
filters: [{ name: "Decklist", extensions: ["txt"] }], filters: [{ name: "Decklist", extensions: ["txt"] }],
}); });
if (typeof path !== "string") return; if (typeof path !== "string") return;
const base = path.split(/[/\\]/).pop() ?? "Imported deck"; // On Android the path is a content:// URI, so ask for the file's name.
const base = (await api.pickedFileName(path).catch(() => null)) || "Imported deck";
const suggestedName = base.replace(/\.txt$/i, ""); const suggestedName = base.replace(/\.txt$/i, "");
showDialog({ kind: "import-name", suggestedName, path }); showDialog({ kind: "import-name", suggestedName, path });
} }
@@ -1935,6 +1942,13 @@ export function DeckView({
onClose={() => setDialog(null)} onClose={() => setDialog(null)}
/> />
)} )}
{dialog?.kind === "export-failed" && (
<InfoDialog
title="Couldn't export deck"
message={dialog.message}
onClose={() => setDialog(null)}
/>
)}
</> </>
); );
} }