Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
170 lines
8.0 KiB
Plaintext
170 lines
8.0 KiB
Plaintext
/** 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)
|
|
}
|