//! Storage on the phone. //! //! The library on Android lives in the app's private folder unless the user //! points it somewhere else. Pointing it somewhere else needs two things the //! WebView cannot do: a folder picker (Android's document tree picker, whose //! answer is a content URI — resolved here to a real path when the volume //! allows, which the Rust scanner and the download pipeline work on), and //! for a folder in shared storage, Android's all-files access, which only //! the user can grant on a system screen. //! //! Five commands: `pick_folder` → `{ uri, path }` (path null when the tree //! is not on a volume with a path), `all_files_access` → `{ granted }`, //! `request_all_files_access`, which opens the system screen, `pick_image` //! → `{ path }` (a photo, copied into the app's cache), and `pick_file` → //! `{ path, name, size, copied }` — the document picker with the caller's //! own MIME filter. //! //! `pick_file` copies as little as it can, because an image is small and an //! audio file is not. A document already on this phone's storage hands its //! own path over as it lies, so picking a 300 MB file costs nothing; one //! with no path of its own is copied into `cacheDir/picked` under its own //! display name, and only as far as `maxCopyBytes` allows — zero refuses //! the copy outright. Those copies are swept at the start of every pick, so //! a path from one pick is gone by the next: read it in the same breath. //! The display name travels beside the path because it is load-bearing — //! the playlist parser picks its format off the extension and titles the //! draft from the stem. //! //! On the desktop the plugin is registered so the permission set resolves, //! and every command answers "not here". use serde::{Deserialize, Serialize}; use tauri::{ plugin::{Builder, TauriPlugin}, Runtime, }; #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct PickedFolder { pub uri: String, pub path: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct AccessState { pub granted: bool, } /// What a pick offers and what it may copy. `mime_types` is the document /// sheet's own filter, comma-separated; empty offers every type. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct PickFileArgs { pub mime_types: Option, /// How big a document may be COPIED into the app's cache when it has no /// path of its own on this phone. Zero refuses the copy. pub max_copy_bytes: u32, } /// A picked document. `path` is the document's own when this phone has one /// for it, else the cache copy's (`copied`); `name` is the display name the /// provider gave it, which is what decides a playlist's format and title. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct PickedFile { pub path: Option, #[serde(default)] pub name: String, #[serde(default)] pub size: Option, #[serde(default)] pub copied: bool, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct PickedImage { pub path: Option, } #[cfg(mobile)] pub struct Storage(tauri::plugin::PluginHandle); #[tauri::command] async fn pick_folder(app: tauri::AppHandle) -> Result { #[cfg(mobile)] { use tauri::Manager; let storage = app.state::>(); storage .0 .run_mobile_plugin::("pickFolder", ()) .map_err(|e| e.to_string()) } #[cfg(not(mobile))] { let _ = app; Err("the folder picker here is the desktop's dialog".into()) } } #[tauri::command] fn all_files_access(app: tauri::AppHandle) -> Result { #[cfg(mobile)] { use tauri::Manager; let storage = app.state::>(); storage .0 .run_mobile_plugin::("allFilesAccess", ()) .map_err(|e| e.to_string()) } #[cfg(not(mobile))] { let _ = app; Ok(AccessState { granted: true }) } } #[tauri::command] fn request_all_files_access(app: tauri::AppHandle) -> Result<(), String> { #[cfg(mobile)] { use tauri::Manager; let storage = app.state::>(); storage .0 .run_mobile_plugin::("requestAllFilesAccess", ()) .map(|_| ()) .map_err(|e| e.to_string()) } #[cfg(not(mobile))] { let _ = app; Ok(()) } } /// A FILE from the phone: the document picker with the caller's own MIME /// filter, answered with a path the Rust side can open. A document already /// on this phone's storage is handed over as it lies; one that has no path /// is copied into the app's cache under its own name, and only as far as /// `max_copy_bytes` allows — zero refuses the copy, which is what a pick /// that could be hundreds of megabytes wants. #[tauri::command] async fn pick_file( app: tauri::AppHandle, mime_types: Option, max_copy_bytes: Option, ) -> Result { #[cfg(mobile)] { use tauri::Manager; let storage = app.state::>(); storage .0 .run_mobile_plugin::( "pickFile", PickFileArgs { mime_types, max_copy_bytes: max_copy_bytes.unwrap_or(0), }, ) .map_err(|e| e.to_string()) } #[cfg(not(mobile))] { let _ = (app, mime_types, max_copy_bytes); Err("the file picker here is the desktop's dialog".into()) } } /// A photo from the phone: the document picker limited to images, the /// picked content copied into the app's cache — a plain path for the Rust /// side (a playlist's cover, for one). #[tauri::command] async fn pick_image(app: tauri::AppHandle) -> Result { #[cfg(mobile)] { use tauri::Manager; let storage = app.state::>(); storage .0 .run_mobile_plugin::("pickImage", ()) .map_err(|e| e.to_string()) } #[cfg(not(mobile))] { let _ = app; Err("the image picker here is the desktop's dialog".into()) } } /// What Android's create-document sheet answered: where the file went, and /// the name it actually got (the sheet lets the user change it on the way /// past). Both absent when the sheet was dismissed — a cancel is not an /// error here, because the export it serves answers a cancel with None. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct CreatedDocument { pub uri: Option, pub name: Option, } /// What `createDocument` is handed, as the Kotlin `@InvokeArg` names it. /// Borrowed: the file can be long and nothing here keeps a copy. #[derive(Serialize)] #[serde(rename_all = "camelCase")] #[allow(dead_code)] // only the phone ever sends one; the desktop has a dialog struct CreateDocumentArgs<'a> { name: &'a str, mime: &'a str, text: &'a str, } /// A text file written wherever the user points: Android's create-document /// sheet (ACTION_CREATE_DOCUMENT), and the Kotlin side writing the bytes /// through the ContentResolver. That last part is the whole reason this /// exists — the sheet answers with a `content://` URI, and `std::fs` has /// never been able to open one, which is why the playlist export had no /// door on the phone at all. /// /// Deliberately NOT a `#[tauri::command]`: the caller is Rust, so the file /// is built where the desktop builds it and the bytes never pass through /// the WebView. Nothing is added to the ACL, and there is no JS wrapper to /// keep in step with. /// /// A dismissed sheet is not an error — it answers with both fields empty. pub fn create_document( app: &tauri::AppHandle, name: &str, mime: &str, text: &str, ) -> Result { #[cfg(mobile)] { use tauri::Manager; let storage = app.state::>(); storage .0 .run_mobile_plugin::( "createDocument", CreateDocumentArgs { name, mime, text }, ) .map_err(|e| e.to_string()) } #[cfg(not(mobile))] { let _ = (app, name, mime, text); Err("the create-document sheet is the phone's; here a save dialog picks the path".into()) } } pub fn init() -> TauriPlugin { Builder::new("storage") .invoke_handler(tauri::generate_handler![pick_folder, all_files_access, request_all_files_access, pick_image, pick_file]) .setup(|app, api| { #[cfg(target_os = "android")] { use tauri::Manager; let handle = api.register_android_plugin("com.soulstream.storage", "StoragePlugin")?; app.manage(Storage(handle)); } #[cfg(not(target_os = "android"))] { let _ = (app, api); } Ok(()) }) .build() }