Share a card's image from the preview
A share button beside the preview's close button sends the face being shown at Scryfall's large size: on desktop the image is decoded and put on the clipboard (tauri-plugin-clipboard-manager); on Android it's saved to the cache and handed to the system share sheet by a small in-repo plugin (SharePlugin.kt) through the app's FileProvider. The preview's corner buttons are also a little larger.
This commit is contained in:
@@ -25,6 +25,11 @@ mtgc-core = { path = "../crates/mtgc-core" }
|
||||
tauri-plugin-opener = "2.5.4"
|
||||
url = "2"
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
# Copying a card image: the clipboard takes raw pixels, and Scryfall serves JPEGs.
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
# Delivers sync sign-ins back to the app through the `mtgc://` scheme.
|
||||
tauri-plugin-deep-link = "2"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.garrettmills.mtgc
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
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.Plugin
|
||||
import java.io.File
|
||||
|
||||
@InvokeArg
|
||||
class ShareImageArgs {
|
||||
/** A JPEG under the app's cache dir, which the FileProvider exposes. */
|
||||
lateinit var path: String
|
||||
var title: String? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@TauriPlugin
|
||||
class SharePlugin(private val activity: Activity) : Plugin(activity) {
|
||||
@Command
|
||||
fun shareImage(invoke: Invoke) {
|
||||
try {
|
||||
val args = invoke.parseArgs(ShareImageArgs::class.java)
|
||||
val uri = FileProvider.getUriForFile(
|
||||
activity,
|
||||
"${activity.packageName}.fileprovider",
|
||||
File(args.path),
|
||||
)
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "image/jpeg"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
args.title?.let { putExtra(Intent.EXTRA_TITLE, it) }
|
||||
// ClipData lets the chooser show a preview of the image, and carries
|
||||
// the read grant along with it.
|
||||
clipData = ClipData.newRawUri(args.title, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
activity.startActivity(Intent.createChooser(send, args.title))
|
||||
invoke.resolve()
|
||||
} catch (ex: Exception) {
|
||||
invoke.reject(ex.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod commands;
|
||||
mod libraries;
|
||||
mod news;
|
||||
mod share;
|
||||
mod state;
|
||||
mod sync;
|
||||
|
||||
@@ -38,7 +39,10 @@ pub(crate) fn logged<T>(what: &str, result: Result<T, String>) -> Result<T, Stri
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default().plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_opener::init());
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(share::init());
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.plugin(tauri_plugin_clipboard_manager::init());
|
||||
// Android gets sync sign-ins back through the `mtgc://` scheme; desktop
|
||||
// uses a loopback port instead and needs no scheme registered.
|
||||
#[cfg(target_os = "android")]
|
||||
@@ -66,6 +70,7 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_status,
|
||||
share::share_card_image,
|
||||
commands::update_card_data,
|
||||
commands::cancel_update_card_data,
|
||||
commands::import_reference_db,
|
||||
|
||||
92
src-tauri/src/share.rs
Normal file
92
src-tauri/src/share.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! 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.
|
||||
|
||||
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 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()
|
||||
}
|
||||
Reference in New Issue
Block a user