android: the handoff — the pending rounds, the pipeline, and what is unverified
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.
This commit is contained in:
1 parent
867fd42972
commit
9f0601027c
400 files changed
+26401
-3
No files matched your search
@@ -0,0 +1,169 @@
|
||||
/** 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)
|
||||
}
|
||||
Reference in new issue
Block a user