android: a playlist, out of the phone

The desktop has had Export since playlists v2 and the phone had no door onto
it for one honest reason: `tauri-plugin-dialog` does open Android's
create-document sheet, but it answers with a `content://` URI and
`playlist_export` wrote with `std::fs::write`, which cannot take one. The
storage plugin grew a `create_document` whose Kotlin writes the bytes through
the ContentResolver — a plugin function called from Rust, not a command, so
the file never passes through the WebView and nothing was added to the ACL —
and `playlist_export` now builds the file above the platform fork with the
same `playlist_formats` writers, so the phone rebuilds no format and the two
exports cannot drift. The door is the playlist's own sheet, on the page and
on the shelf card, with the desktop's five profiles in the desktop's order; a
dismissed sheet says nothing, because nothing happened. The mock walk drives
it three ways — a file written where the sheet pointed, a sheet dismissed, a
refusal read back as a sentence — and the phone walk opens the real sheet and
dismisses it with BACK, writing nothing to the phone. The Kotlin is unbuilt:
nothing in this tree compiles it, though it is written against
tauri-android's own `PluginHandle`, `Invoke` and `JSObject` rather than
against a guess at them.
This commit is contained in:
KeelrLeek committed 2026-09-14 02:38:44 -05:00
1 parent 95c2bfeeba
commit 04c87e0db1
9 files changed
+390 -72

No files matched your search

+60
View File
@@ -118,6 +118,66 @@ async fn pick_image<R: Runtime>(app: tauri::AppHandle<R>) -> Result<PickedImage,
}
}
/// 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<String>,
pub name: Option<String>,
}
/// 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<R: Runtime>(
app: &tauri::AppHandle<R>,
name: &str,
mime: &str,
text: &str,
) -> Result<CreatedDocument, String> {
#[cfg(mobile)]
{
use tauri::Manager;
let storage = app.state::<Storage<R>>();
storage
.0
.run_mobile_plugin::<CreatedDocument>(
"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<R: Runtime>() -> TauriPlugin<R> {
Builder::new("storage")
.invoke_handler(tauri::generate_handler![pick_folder, all_files_access, request_all_files_access, pick_image])