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