    /** 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)
    }
