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

@@ -6,15 +6,29 @@ import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.provider.Settings
import androidx.activity.result.ActivityResult
import app.tauri.annotation.ActivityCallback
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
/** What `createDocument` writes and where it offers to put it — the Rust
* `CreateDocumentArgs`, camelCase. */
@InvokeArg
class CreateDocumentArgs {
/** the name the sheet opens with, its extension included */
var name: String? = null
/** the type Android files it under, and picks the sheet's apps by */
var mime: String? = null
/** the whole file, built in Rust — the WebView never sees it */
var text: String? = null
}
/**
* The phone's storage for the library: a folder picked through Android's
* document tree picker (its content URI kept with a persistable grant, and
@@ -91,6 +105,72 @@ class StoragePlugin(private val activity: Activity) : Plugin(activity) {
}
invoke.resolve()
}
/** A file written wherever the user points it: Android's create-document
* sheet, and the bytes through the ContentResolver — a content URI is
* not a path, so `std::fs` on the Rust side could never have written
* this one. The file arrives whole (the playlist export builds it with
* the desktop's own writers), so nothing here knows a format. */
@Command
fun createDocument(invoke: Invoke) {
val args = invoke.parseArgs(CreateDocumentArgs::class.java)
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = args.mime ?: "application/octet-stream"
intent.putExtra(Intent.EXTRA_TITLE, args.name ?: "playlist")
startActivityForResult(invoke, intent, "documentCreated")
}
@ActivityCallback
fun documentCreated(invoke: Invoke, result: ActivityResult) {
when (result.resultCode) {
Activity.RESULT_OK -> {
val uri = result.data?.data
if (uri == null) { invoke.reject("no file came back"); return }
try {
/* The file comes off the SAME invoke, read a second time
rather than parked in a field of ours: PluginHandle's
activity-result closure holds the Invoke, so its
arguments are still here when the sheet comes back, and
no call of this can hand its bytes to another's file. */
val text = invoke.parseArgs(CreateDocumentArgs::class.java).text ?: ""
/* "wt" truncates: without it, writing a short file over a
longer one leaves the old tail behind the new bytes. */
val out = activity.contentResolver.openOutputStream(uri, "wt")
if (out == null) { invoke.reject("the file could not be opened for writing"); return }
out.use { o -> o.write(text.toByteArray(Charsets.UTF_8)) }
val res = JSObject()
res.put("uri", uri.toString())
res.put("name", displayName(uri))
invoke.resolve(res)
} catch (e: Exception) {
invoke.reject("the file could not be written: " + e.message)
}
}
/* Deliberately NOT a rejection, where pickFolder and pickImage
both reject "cancelled": a dismissed save is `Ok(None)` on the
desktop and the export says nothing at all for it. An error
here would paint a failure over a choice. */
Activity.RESULT_CANCELED -> invoke.resolve(JSObject())
else -> invoke.reject("the file could not be created")
}
}
/** The name the document actually got — the sheet lets it be changed on
* the way past. The URI's last segment when the provider will not say. */
private fun displayName(uri: Uri): String? {
try {
activity.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c ->
val i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (i >= 0 && c.moveToFirst()) {
val n = c.getString(i)
if (!n.isNullOrEmpty()) return n
}
}
} catch (_: Exception) { /* the name is a courtesy; the bytes are written */ }
return uri.lastPathSegment
}
/** A photo from the phone: the document picker limited to images, the
* picked content copied into the app's cache so the Rust side gets a
* plain path (a content URI is not one). */