//! 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 = Result; /// 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::(); 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 { 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); /// Registers the Android share plugin. A no-op plugin elsewhere. pub fn init() -> tauri::plugin::TauriPlugin { 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() }