android: a playlist file, picked
The storage plugin grew `pick_file` beside `pick_folder` and `pick_image`: the document sheet with the caller's own filter, answered with a path Rust can open. A document already on this phone hands its path over as it lies — the document sibling of the same volume:relative id the folder picker resolves, plus the Downloads provider's raw: — so a 300 MB pick copies nothing; one that has no path is copied into cacheDir/picked under its own name, and only as far as the caller allows, because a playlist is kilobytes and a DJ set is not. Those copies are swept at the start of every pick, which pick_image's never were. The import sheet has its fifth door, on round 24's screen, wait card and review, and the note that apologised for its absence says the one true thing about it instead. `playlist_import_probe` took a path and a name the way `playlist_import_folder` took a path, and the name is the load-bearing half: the parser picks its format off the extension and titles the draft from the stem, so a cache copy under an invented name would have read every playlist as plain text. The mock walk drives the door and both of the picker's refusals through round 31's fail door; the phone walk opens the real sheet and dismisses it with BACK, reading nothing and writing nothing. The brief asked for a second consumer — sending a file in a chat — and there is nothing behind it: `community_pick_file` is the persona layer's three native dialogs, `soulseek-client` has `say_room` and `message_user` and nothing that sends a file, and both frontends' thread composers are text only. Nothing was invented for it. The plugin command is general enough — a caller's own filter, a zero-copy path, a bounded copy — that the one reader still waiting, the local set listener the phone puts off by design, needs no further plugin change. Playlist export is not on that list and never was: writing a document out is the share plugin's errand, not a picker's. The Kotlin is unbuilt — nothing on this machine compiles it.
This commit is contained in:
1 parent
04c87e0db1
commit
defd8c5e6d
16 files changed
+559
-57
No files matched your search
+187
@@ -29,6 +29,23 @@ class CreateDocumentArgs {
|
||||
var text: String? = null
|
||||
}
|
||||
|
||||
/** What a pick may offer and what it may copy. The filter is the document
|
||||
* sheet's own, comma-separated ("audio/mpeg,audio/flac"); blank offers
|
||||
* every type. `maxCopyBytes` is how big a document may be COPIED into the
|
||||
* app's cache when it has no path of its own on this phone — 0 refuses the
|
||||
* copy, which is what an audio-sized pick wants. Both are plain scalars on
|
||||
* purpose: String / Int / Boolean is what the other plugins' args prove. */
|
||||
@InvokeArg
|
||||
class PickFileArgs {
|
||||
var mimeTypes: String? = null
|
||||
var maxCopyBytes: Int = 0
|
||||
}
|
||||
|
||||
/** The copy walked past what the caller allowed. Its own exception, so the
|
||||
* partial file can be deleted and the caller told in megabytes rather than
|
||||
* in "could not be read". */
|
||||
private class TooBig(message: String) : Exception(message)
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -204,4 +221,174 @@ class StoragePlugin(private val activity: Activity) : Plugin(activity) {
|
||||
else -> invoke.reject("the picker failed")
|
||||
}
|
||||
}
|
||||
|
||||
/** A FILE from the phone: the document picker with the caller's own
|
||||
* filter. A document that is already on this phone's storage is handed
|
||||
* over as it lies — nothing is copied, so a 300 MB pick costs nothing
|
||||
* — and one that has no path of its own is copied into cacheDir/picked
|
||||
* under its own display name, as far as `maxCopyBytes` allows and no
|
||||
* further. Zero refuses the copy outright.
|
||||
*
|
||||
* The copies are swept at the start of every pick: pickImage's own
|
||||
* copies live in the cache for ever, and an audio file is not an
|
||||
* image. So a path handed out by one pick is gone by the next — read
|
||||
* it in the same breath.
|
||||
*/
|
||||
@Command
|
||||
fun pickFile(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(PickFileArgs::class.java)
|
||||
sweepPicked()
|
||||
val types = (args.mimeTypes ?: "").split(',').map { it.trim() }.filter { it.isNotEmpty() }
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
if (types.size == 1) {
|
||||
intent.type = types[0]
|
||||
} else {
|
||||
intent.type = "*/*"
|
||||
if (types.isNotEmpty()) intent.putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray())
|
||||
}
|
||||
startActivityForResult(invoke, intent, "filePicked")
|
||||
}
|
||||
|
||||
@ActivityCallback
|
||||
fun filePicked(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 }
|
||||
val info = openableInfo(uri)
|
||||
val name = info.first ?: "picked"
|
||||
val stated = info.second
|
||||
val out = JSObject()
|
||||
out.put("name", name)
|
||||
/* the document's own path first, and nothing copied: a row
|
||||
naming a file beside it still resolves against the real
|
||||
folder, and the size stops mattering */
|
||||
val here = documentPath(uri)
|
||||
if (here != null) {
|
||||
out.put("path", here)
|
||||
out.put("copied", false)
|
||||
if (stated != null) out.put("size", stated)
|
||||
invoke.resolve(out)
|
||||
return
|
||||
}
|
||||
/* the same Invoke the pick was made with — PluginHandle
|
||||
holds it in the result closure — so the cap is read back
|
||||
off the caller's own arguments rather than stashed in a
|
||||
field a recreated activity would have reset to zero */
|
||||
val cap = invoke.parseArgs(PickFileArgs::class.java).maxCopyBytes.toLong()
|
||||
if (cap <= 0L) { invoke.reject("that file is not on this phone's storage"); return }
|
||||
if (stated != null && stated > cap) {
|
||||
invoke.reject("that file is " + (stated / (1024 * 1024)) + " MB — too large to copy here")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val copy = copyInto(uri, name, cap)
|
||||
if (copy == null) { invoke.reject("that file could not be read"); return }
|
||||
out.put("path", copy.absolutePath)
|
||||
out.put("copied", true)
|
||||
out.put("size", copy.length())
|
||||
invoke.resolve(out)
|
||||
} catch (e: TooBig) {
|
||||
invoke.reject(e.message ?: "that file is too large to copy here")
|
||||
} catch (e: Exception) {
|
||||
invoke.reject("that file could not be read: " + e.message)
|
||||
}
|
||||
}
|
||||
Activity.RESULT_CANCELED -> invoke.reject("cancelled")
|
||||
else -> invoke.reject("the picker failed")
|
||||
}
|
||||
}
|
||||
|
||||
/** DISPLAY_NAME and SIZE off the provider. The name is load-bearing:
|
||||
* the Rust parser picks the format off the extension and titles the
|
||||
* draft from the stem, so a copy under an invented name would read
|
||||
* every playlist as plain text. SIZE is often absent, and absent is
|
||||
* not zero — the copy counts for itself. */
|
||||
private fun openableInfo(uri: Uri): Pair<String?, Long?> {
|
||||
var name: String? = null
|
||||
var size: Long? = null
|
||||
try {
|
||||
activity.contentResolver.query(uri, null, null, null, null)?.use { c ->
|
||||
if (c.moveToFirst()) {
|
||||
val ni = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (ni >= 0 && !c.isNull(ni)) name = c.getString(ni)
|
||||
val si = c.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (si >= 0 && !c.isNull(si)) size = c.getLong(si)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { /* a provider that answers nothing still has a document */ }
|
||||
return Pair(name, size)
|
||||
}
|
||||
|
||||
/** The document's own path, when this phone has one for it: the
|
||||
* Downloads provider's "raw:/storage/…", and the external-storage
|
||||
* provider's "primary:Music/set.m3u" — the DOCUMENT sibling of the
|
||||
* tree id pathOf resolves. Null when it names no path, and null when
|
||||
* this app may not read the one it names (all-files access is not
|
||||
* always granted, and a path Rust cannot open is worse than none). */
|
||||
private fun documentPath(uri: Uri): String? {
|
||||
val id = try { DocumentsContract.getDocumentId(uri) } catch (_: Exception) { return null }
|
||||
val path = if (id.startsWith("raw:")) {
|
||||
id.substring(4)
|
||||
} else if (uri.authority == "com.android.externalstorage.documents") {
|
||||
val i = id.indexOf(':')
|
||||
if (i < 0) return null
|
||||
val volume = id.substring(0, i)
|
||||
val rel = id.substring(i + 1).trimEnd('/')
|
||||
val root = if (volume == "primary") Environment.getExternalStorageDirectory().absolutePath else "/storage/$volume"
|
||||
if (rel.isEmpty()) root else "$root/$rel"
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
return if (java.io.File(path).canRead()) path else null
|
||||
}
|
||||
|
||||
private fun pickedDir(): java.io.File {
|
||||
val dir = java.io.File(activity.cacheDir, "picked")
|
||||
if (!dir.isDirectory) dir.mkdirs()
|
||||
return dir
|
||||
}
|
||||
|
||||
/** The last pick's copy, gone. A playlist is kilobytes and a DJ set is
|
||||
* not, and nothing here is worth keeping once the caller has read it. */
|
||||
private fun sweepPicked() {
|
||||
try { pickedDir().listFiles()?.forEach { it.delete() } } catch (_: Exception) { /* the copy is a courtesy */ }
|
||||
}
|
||||
|
||||
/** The picked document, copied under its own name so the extension
|
||||
* survives, counted as it goes so a provider that understated its size
|
||||
* cannot fill the cache; a partial copy is deleted. */
|
||||
private fun copyInto(uri: Uri, name: String, cap: Long): java.io.File? {
|
||||
val target = java.io.File(pickedDir(), safeName(name))
|
||||
val input = activity.contentResolver.openInputStream(uri) ?: return null
|
||||
var total = 0L
|
||||
try {
|
||||
input.use { i ->
|
||||
target.outputStream().use { o ->
|
||||
val buf = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val n = i.read(buf)
|
||||
if (n < 0) break
|
||||
total += n.toLong()
|
||||
if (total > cap) throw TooBig("that file is larger than " + (cap / (1024 * 1024)) + " MB")
|
||||
o.write(buf, 0, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
target.delete()
|
||||
throw e
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
/** A name the cache can hold: the document's own, minus anything that
|
||||
* could walk out of the folder. What the CALLER parses by is the
|
||||
* display name, which travels beside the path untouched. */
|
||||
private fun safeName(name: String): String {
|
||||
val base = name.substringAfterLast('/').substringAfterLast('\\').trim()
|
||||
val clean = base.replace(Regex("[^A-Za-z0-9 ._()\\[\\]+&,'-]"), "_")
|
||||
return if (clean.isEmpty() || clean == "." || clean == "..") "picked" else clean.take(120)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// The three commands, as the Rust functions are named; the ACL slugs them
|
||||
// The commands, as the Rust functions are named; the ACL slugs them
|
||||
// (`allow-pick-folder`, …) into permissions/autogenerated.
|
||||
const COMMANDS: &[&str] = &["pick_folder", "all_files_access", "request_all_files_access", "pick_image"];
|
||||
const COMMANDS: &[&str] = &["pick_folder", "all_files_access", "request_all_files_access", "pick_image", "pick_file"];
|
||||
|
||||
fn main() {
|
||||
// Registers the command permissions and, on Android, hands the Gradle
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-pick-file"
|
||||
description = "Enables the pick_file command without any pre-configured scope."
|
||||
commands.allow = ["pick_file"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-pick-file"
|
||||
description = "Denies the pick_file command without any pre-configured scope."
|
||||
commands.deny = ["pick_file"]
|
||||
@@ -1,8 +1,10 @@
|
||||
## Default Permission
|
||||
|
||||
The phone's storage: pick a library folder (a Storage Access Framework tree,
|
||||
resolved to a path when the volume allows), and Android's all-files access —
|
||||
whether it is granted, and the system screen that grants it.
|
||||
resolved to a path when the volume allows), pick a photo or a file (the
|
||||
document picker, answered with a path — the document's own where this phone
|
||||
has one, else a bounded copy in the app's cache), and Android's all-files
|
||||
access — whether it is granted, and the system screen that grants it.
|
||||
|
||||
#### This default permission set includes the following:
|
||||
|
||||
@@ -10,6 +12,7 @@ whether it is granted, and the system screen that grants it.
|
||||
- `allow-all-files-access`
|
||||
- `allow-request-all-files-access`
|
||||
- `allow-pick-image`
|
||||
- `allow-pick-file`
|
||||
|
||||
## Permission Table
|
||||
|
||||
@@ -49,6 +52,32 @@ Denies the all_files_access command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`storage:allow-pick-file`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the pick_file command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`storage:deny-pick-file`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the pick_file command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`storage:allow-pick-folder`
|
||||
|
||||
</td>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
[default]
|
||||
description = """
|
||||
The phone's storage: pick a library folder (a Storage Access Framework tree,
|
||||
resolved to a path when the volume allows), and Android's all-files access —
|
||||
whether it is granted, and the system screen that grants it.
|
||||
resolved to a path when the volume allows), pick a photo or a file (the
|
||||
document picker, answered with a path — the document's own where this phone
|
||||
has one, else a bounded copy in the app's cache), and Android's all-files
|
||||
access — whether it is granted, and the system screen that grants it.
|
||||
"""
|
||||
permissions = ["allow-pick-folder", "allow-all-files-access", "allow-request-all-files-access", "allow-pick-image"]
|
||||
permissions = ["allow-pick-folder", "allow-all-files-access", "allow-request-all-files-access", "allow-pick-image", "allow-pick-file"]
|
||||
@@ -306,6 +306,18 @@
|
||||
"const": "deny-all-files-access",
|
||||
"markdownDescription": "Denies the all_files_access command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the pick_file command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-pick-file",
|
||||
"markdownDescription": "Enables the pick_file command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the pick_file command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-pick-file",
|
||||
"markdownDescription": "Denies the pick_file command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the pick_folder command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -343,10 +355,10 @@
|
||||
"markdownDescription": "Denies the request_all_files_access command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), and Android's all-files access —\nwhether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`\n- `allow-pick-image`",
|
||||
"description": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), pick a photo or a file (the\ndocument picker, answered with a path — the document's own where this phone\nhas one, else a bounded copy in the app's cache), and Android's all-files\naccess — whether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`\n- `allow-pick-image`\n- `allow-pick-file`",
|
||||
"type": "string",
|
||||
"const": "default",
|
||||
"markdownDescription": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), and Android's all-files access —\nwhether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`\n- `allow-pick-image`"
|
||||
"markdownDescription": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), pick a photo or a file (the\ndocument picker, answered with a path — the document's own where this phone\nhas one, else a bounded copy in the app's cache), and Android's all-files\naccess — whether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`\n- `allow-pick-image`\n- `allow-pick-file`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,11 +8,26 @@
|
||||
//! for a folder in shared storage, Android's all-files access, which only
|
||||
//! the user can grant on a system screen.
|
||||
//!
|
||||
//! Three commands: `pick_folder` → `{ uri, path }` (path null when the tree
|
||||
//! is not on a volume with a path), `all_files_access` → `{ granted }`, and
|
||||
//! `request_all_files_access`, which opens the system screen. On the desktop
|
||||
//! the plugin is registered so the permission set resolves, and every
|
||||
//! command answers "not here".
|
||||
//! 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::{
|
||||
@@ -33,6 +48,32 @@ 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<String>,
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub size: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub copied: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PickedImage {
|
||||
@@ -97,6 +138,40 @@ fn request_all_files_access<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
mime_types: Option<String>,
|
||||
max_copy_bytes: Option<u32>,
|
||||
) -> Result<PickedFile, String> {
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
use tauri::Manager;
|
||||
let storage = app.state::<Storage<R>>();
|
||||
storage
|
||||
.0
|
||||
.run_mobile_plugin::<PickedFile>(
|
||||
"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).
|
||||
@@ -180,7 +255,7 @@ pub fn create_document<R: Runtime>(
|
||||
|
||||
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])
|
||||
.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")]
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user