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:
KeelrLeek committed 2026-09-14 02:55:56 -05:00
1 parent 04c87e0db1
commit defd8c5e6d
16 files changed
+559 -57

No files matched your search

@@ -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)
}
}