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>
114 lines
4.1 KiB
Rust
114 lines
4.1 KiB
Rust
//! Sharing a card's image: onto the clipboard on desktop, through the system
|
|
//! share sheet on Android.
|
|
//!
|
|
//! 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. The same
|
|
//! plugin looks up the display names of documents the file picker returns.
|
|
|
|
use tauri::AppHandle;
|
|
|
|
type Res<T> = Result<T, String>;
|
|
|
|
/// Scryfall serves each scan at several sizes under the same path; the
|
|
/// previews show "normal", but a shared or pasted image deserves "large".
|
|
fn large_variant(url: &str) -> String {
|
|
if url.starts_with("https://cards.scryfall.io/normal/") {
|
|
url.replacen("/normal/", "/large/", 1)
|
|
} else {
|
|
url.to_string()
|
|
}
|
|
}
|
|
|
|
/// Copy (desktop) or share (Android) the card image at `url`. Returns what
|
|
/// happened — "copied" or "shared" — for the UI to confirm.
|
|
#[tauri::command]
|
|
pub async fn share_card_image(app: AppHandle, url: String, name: String) -> Res<&'static str> {
|
|
crate::sync::blocking(move || {
|
|
let bytes = mtgc_core::fetch_image(&large_variant(&url)).map_err(|e| e.to_string())?;
|
|
deliver(&app, &bytes, &name)
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[cfg(desktop)]
|
|
fn deliver(app: &AppHandle, bytes: &[u8], _name: &str) -> Res<&'static str> {
|
|
use tauri_plugin_clipboard_manager::ClipboardExt;
|
|
// The clipboard takes raw pixels, and Scryfall's scans are JPEGs.
|
|
let rgba = image::load_from_memory(bytes).map_err(|e| e.to_string())?.to_rgba8();
|
|
let (width, height) = rgba.dimensions();
|
|
app.clipboard()
|
|
.write_image(&tauri::image::Image::new(rgba.as_raw(), width, height))
|
|
.map_err(|e| e.to_string())?;
|
|
Ok("copied")
|
|
}
|
|
|
|
#[cfg(target_os = "android")]
|
|
fn deliver(app: &AppHandle, bytes: &[u8], name: &str) -> Res<&'static str> {
|
|
use tauri::Manager;
|
|
// The cache dir is what the FileProvider exposes (res/xml/file_paths.xml).
|
|
// Named after the card, since that's what the receiving app shows.
|
|
let dir = app.path().app_cache_dir().map_err(|e| e.to_string())?.join("shared");
|
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
|
let file_name: String = name
|
|
.chars()
|
|
.map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' })
|
|
.collect();
|
|
let path = dir.join(format!("{}.jpg", file_name.trim()));
|
|
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
|
|
|
|
let plugin = app.state::<SharePlugin>();
|
|
plugin
|
|
.0
|
|
.run_mobile_plugin::<()>(
|
|
"shareImage",
|
|
serde_json::json!({ "path": path.to_string_lossy(), "title": name }),
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok("shared")
|
|
}
|
|
|
|
#[cfg(target_os = "ios")]
|
|
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<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`.
|
|
#[cfg(target_os = "android")]
|
|
struct SharePlugin(tauri::plugin::PluginHandle<tauri::Wry>);
|
|
|
|
/// Registers the Android share plugin. A no-op plugin elsewhere.
|
|
pub fn init() -> tauri::plugin::TauriPlugin<tauri::Wry> {
|
|
tauri::plugin::Builder::new("share")
|
|
.setup(|_app, _api| {
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
use tauri::Manager;
|
|
let handle = _api.register_android_plugin("com.garrettmills.mtgc", "SharePlugin")?;
|
|
_app.manage(SharePlugin(handle));
|
|
}
|
|
Ok(())
|
|
})
|
|
.build()
|
|
}
|