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:
1 parent
95c2bfeeba
commit
04c87e0db1
9 files changed
+390
-72
No files matched your search
+80
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user