diff --git a/src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/SharePlugin.kt b/src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/SharePlugin.kt index 1225224..76de9c3 100644 --- a/src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/SharePlugin.kt +++ b/src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/SharePlugin.kt @@ -3,11 +3,14 @@ package com.garrettmills.mtgc import android.app.Activity import android.content.ClipData import android.content.Intent +import android.net.Uri +import android.provider.OpenableColumns import androidx.core.content.FileProvider import app.tauri.annotation.Command import app.tauri.annotation.InvokeArg import app.tauri.annotation.TauriPlugin import app.tauri.plugin.Invoke +import app.tauri.plugin.JSObject import app.tauri.plugin.Plugin import java.io.File @@ -18,9 +21,16 @@ class ShareImageArgs { 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): - * 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 class SharePlugin(private val activity: Activity) : Plugin(activity) { @@ -48,4 +58,18 @@ class SharePlugin(private val activity: Activity) : Plugin(activity) { 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) + } + } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1b0dac2..ffcac1f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -147,7 +147,7 @@ pub async fn update_card_data(app: AppHandle, db: Db<'_>) -> Res { /// 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 { +pub fn import_reference_db(app: AppHandle, db: Db, path: String) -> Res { 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 { 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 { 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> { + 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> { + 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 { let text = read_picked_text(&app, &path)?; @@ -499,13 +552,18 @@ pub fn import_deck(app: AppHandle, db: Db, path: String, name: String) -> Res, path: String) -> Res<()> { +pub fn export_library_cards( + app: AppHandle, + db: Db, + oracle_ids: Vec, + 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. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d13d3c5..4454743 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -125,6 +125,7 @@ pub fn run() { commands::set_deck_card_printing, commands::export_deck, commands::import_deck, + commands::picked_file_name, commands::export_library_cards, commands::import_library_cards, ]) diff --git a/src-tauri/src/share.rs b/src-tauri/src/share.rs index 63d7d11..4265567 100644 --- a/src-tauri/src/share.rs +++ b/src-tauri/src/share.rs @@ -3,7 +3,8 @@ //! //! 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) -//! 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; @@ -72,6 +73,26 @@ fn deliver(_app: &AppHandle, _bytes: &[u8], _name: &str) -> Res<&'static str> { 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 { + use tauri::Manager; + + #[derive(serde::Deserialize)] + struct Named { + name: Option, + } + + let plugin = app.state::(); + 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`. #[cfg(target_os = "android")] struct SharePlugin(tauri::plugin::PluginHandle); diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index a40555c..8d2a7b9 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -952,18 +952,26 @@ pub async fn import_library_file(app: AppHandle, path: String) -> Result Result<(), local_doc(&s.working_db_path)? }; 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 } diff --git a/src/SyncPanel.tsx b/src/SyncPanel.tsx index 0fe3a5f..c28937a 100644 --- a/src/SyncPanel.tsx +++ b/src/SyncPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { open, save } from "@tauri-apps/plugin-dialog"; import { api, FirstSync, SyncDevice, SyncReport, SyncStatus } from "./api"; +import { isAndroid } from "./backButton"; import { ConfirmDialog, formatRelativeTime } from "./components"; /** The server's answer when this device's token stops working. */ @@ -182,7 +183,11 @@ export function SyncPanel({ }); if (typeof path !== "string") return null; 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) => @@ -191,10 +196,14 @@ export function SyncPanel({ preset ?? (await open({ title: "Import a library file", - filters: [ - { name: "mtgc library", extensions: ["json", "db"] }, - { name: "All files", extensions: ["*"] }, - ], + // Android filters by MIME type, and knows none for .db (nor reliably + // for a backup .json), so it would hide the very files we want. + filters: isAndroid + ? [] + : [ + { name: "mtgc library", extensions: ["json", "db"] }, + { name: "All files", extensions: ["*"] }, + ], })); if (typeof path !== "string") return null; const r = await api.importLibraryFile(path); diff --git a/src/api.ts b/src/api.ts index 283794c..c9e13ae 100644 --- a/src/api.ts +++ b/src/api.ts @@ -601,6 +601,7 @@ export const api = { invoke("set_deck_card_printing", { deckId, oracleId, scryfallId }), exportDeck: (deckId: number, path: string) => invoke("export_deck", { deckId, path }), + pickedFileName: (path: string) => invoke("picked_file_name", { path }), importDeck: (path: string, name: string) => invoke("import_deck", { path, name }), exportLibraryCards: (oracleIds: string[], path: string) => diff --git a/src/backButton.ts b/src/backButton.ts index b526501..521227b 100644 --- a/src/backButton.ts +++ b/src/backButton.ts @@ -11,7 +11,7 @@ import { PluginListener } from "@tauri-apps/api/core"; import { useEffect, useRef } from "react"; // 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. */ const stack: { handle: () => void }[] = []; diff --git a/src/views/DeckView.tsx b/src/views/DeckView.tsx index 1ff19d8..2c1915d 100644 --- a/src/views/DeckView.tsx +++ b/src/views/DeckView.tsx @@ -101,6 +101,7 @@ type DeckDialog = | { kind: "duplicate"; deck: Deck } | { kind: "import-name"; suggestedName: string; path: string } | { kind: "import-result"; result: DeckImportResult } + | { kind: "export-failed"; message: string } | null; /** Name a new deck and, optionally, pick its format up front. */ @@ -1775,7 +1776,12 @@ export function DeckView({ defaultPath: `${d.name}.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() { @@ -1784,7 +1790,8 @@ export function DeckView({ filters: [{ name: "Decklist", extensions: ["txt"] }], }); 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, ""); showDialog({ kind: "import-name", suggestedName, path }); } @@ -1935,6 +1942,13 @@ export function DeckView({ onClose={() => setDialog(null)} /> )} + {dialog?.kind === "export-failed" && ( + setDialog(null)} + /> + )} ); }