aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-02 18:21:26 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-03 14:24:54 +0200
commitd8f5005ba2c9a249ce5ce8976d9c3754a3a7458e (patch)
tree960ece9a79c58fbe60033b91f6ec685795eff8a1 /packages/meshbay-android/app
parentede70b0fcc006d3ced8ef0d1d7115593f6627aef (diff)
downloadmeshbay-d8f5005ba2c9a249ce5ce8976d9c3754a3a7458e.tar.gz
feat(android): downloads to disk and uploads through the system picker
Native save over the Storage Access Framework and MediaStore, chunks sent as binary bridge messages, a chosen folder that has gone asks rather than redirects, unfinished files removed on abort and after a killed process. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-android/app')
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js58
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt38
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt19
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt25
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveNames.kt73
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveSinks.kt238
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/Pickers.kt40
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SaveNamesTest.kt45
8 files changed, 527 insertions, 9 deletions
diff --git a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js
index c6e1f53..5fb5bb6 100644
--- a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js
+++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js
@@ -15,8 +15,9 @@
* that refuses: `platform.js` decides what to show from whether an object
* exists (`platform.node.available`, `platform.folder.available`, …).
*
- * `HUB_BASE` is prepended by the shell when it injects this file: the
- * interface asks for it while its modules load, before anything can await.
+ * `HUB_BASE` and `BINARY` are prepended by the shell when it injects this
+ * file: the interface asks for the hub while its modules load, before anything
+ * can await, and BINARY says whether the WebView carries ArrayBuffer messages.
*/
(function () {
'use strict';
@@ -43,6 +44,34 @@
port.postMessage(JSON.stringify({ id, ch: channel, args }));
});
+ // A write is a binary message: "MBB1" | id | channel | 0 | handle | bytes,
+ // big-endian — one copy, no JSON, no base64 (spike S-3: 136 MB/s awaited
+ // per 48 KB chunk). Without ArrayBuffer messages, base64 over JSON.
+ const SAVE_WRITE = 1;
+ const bytesOf = (chunk) => (chunk instanceof Uint8Array ? chunk
+ : ArrayBuffer.isView(chunk) ? new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)
+ : new Uint8Array(chunk));
+ const writeChunk = (handle, chunk) => {
+ const bytes = bytesOf(chunk);
+ if (!BINARY) {
+ let s = '';
+ for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
+ return call('save:write', handle, btoa(s));
+ }
+ return new Promise((resolve, reject) => {
+ const id = ++seq;
+ pending.set(id, { resolve, reject });
+ const frame = new ArrayBuffer(16 + bytes.length);
+ const head = new DataView(frame);
+ head.setUint32(0, 0x4d424231); // "MBB1"
+ head.setUint32(4, id);
+ head.setUint16(8, SAVE_WRITE);
+ head.setUint32(12, handle);
+ new Uint8Array(frame, 16).set(bytes);
+ port.postMessage(frame);
+ });
+ };
+
const meshbay = {
hubBase: () => HUB_BASE,
setHubBase: (base) => call('hub:set', base),
@@ -50,7 +79,7 @@
capabilities: {
nodeAdmin: false, // no node runs on a phone (§11.3)
localFolders: false,
- nativeSave: false, // phase 2
+ nativeSave: true,
lanCast: false, // phase 3
tray: false,
},
@@ -102,6 +131,29 @@
secrets: {
backend: () => call('secrets:backend'),
},
+
+ // Where downloads go, chosen once. A display name comes back, never a URI.
+ folder: {
+ choose: () => call('folder:choose'),
+ get: () => call('folder:get'),
+ forget: () => call('folder:forget'),
+ },
+
+ // A sink that writes to disk as chunks arrive, never a buffer handed over
+ // at the end. The page holds an id. `open` exists only where the target
+ // says the file may be opened — a type that runs nothing.
+ saveFile: async (suggestedName, opts) => {
+ const handle = await call('save:begin', suggestedName, opts);
+ if (!handle) return null;
+ const sink = {
+ name: handle.name,
+ write: (chunk) => writeChunk(handle.id, chunk),
+ close: () => call('save:end', handle.id),
+ abort: () => call('save:abort', handle.id),
+ };
+ if (handle.openable) sink.open = () => call('save:open', handle.id);
+ return sink;
+ },
};
const freeze = (o) => {
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
index 9892cae..bd096f7 100644
--- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
@@ -28,6 +28,8 @@ import org.meshbay.client.bridge.Channels
import org.meshbay.client.bridge.KeyChannels
import org.meshbay.client.hub.HubClient
import org.meshbay.client.keys.SecretStore
+import org.meshbay.client.save.SaveSinks
+import org.meshbay.client.shell.Pickers
import org.meshbay.client.shell.EngineCheck
import org.meshbay.client.shell.NativeText
import org.meshbay.client.shell.UiAssets
@@ -45,6 +47,7 @@ class MainActivity : Activity() {
private lateinit var web: WebView
private lateinit var hub: HubClient
private lateinit var channels: Channels
+ private val pickers = Pickers(this)
private val text = NativeText { code ->
try { assets.open("ui/locales/$code.js").bufferedReader().use { it.readText() } } catch (e: java.io.IOException) { null }
}
@@ -69,8 +72,12 @@ class MainActivity : Activity() {
val keys = KeyChannels(SecretStore(this), confirm = ::confirmNatively,
declined = { text.get("native.declined", channels.locale) })
+ val saves = SaveSinks(this, getSharedPreferences("downloads", Context.MODE_PRIVATE), pickers,
+ startActivity = { intent -> runOnUiThread { startActivity(intent) } })
+ // A process killed mid-download left unfinished files; nothing else will.
+ Thread { saves.cleanUpAfterAKilledProcess() }.start()
channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } },
- hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys)
+ hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves)
WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels))
installShim()
web.loadUrl(UiAssets.START)
@@ -137,6 +144,27 @@ class MainActivity : Activity() {
setFullscreenBars(false)
}
+ // <input type=file>: the system picker; the page reads what it is
+ // given through the File objects the WebView makes of the URIs.
+ // The callback is always answered, or the next chooser never opens.
+ override fun onShowFileChooser(view: WebView, callback: android.webkit.ValueCallback<Array<Uri>>,
+ params: FileChooserParams): Boolean {
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*")
+ .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, params.mode == FileChooserParams.MODE_OPEN_MULTIPLE)
+ Thread {
+ val result = pickers.run(intent)
+ // A single pick in multiple mode can come back with an
+ // empty clipData and the file in `data`: take whichever
+ // carries it, or the page receives a selection of nothing.
+ val uris = result?.let { r ->
+ val clip = r.clipData?.takeIf { it.itemCount > 0 }
+ clip?.let { c -> (0 until c.itemCount).map { c.getItemAt(it).uri } } ?: listOfNotNull(r.data)
+ }?.takeIf { it.isNotEmpty() }?.toTypedArray()
+ runOnUiThread { callback.onReceiveValue(uris) }
+ }.start()
+ return true
+ }
+
override fun onConsoleMessage(m: ConsoleMessage): Boolean {
if (BuildConfig.DEBUG) Log.i("MeshBayPage", "${m.messageLevel()} ${m.message()} @${m.sourceId()}:${m.lineNumber()}")
return true
@@ -152,7 +180,8 @@ class MainActivity : Activity() {
private fun installShim() {
shim?.remove()
val source = assets.open("bridge/meshbay-bridge.js").bufferedReader().use { it.readText() }
- val prelude = "const HUB_BASE = ${JSONObject.quote(hub.base)};\n"
+ val binary = WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER)
+ val prelude = "const HUB_BASE = ${JSONObject.quote(hub.base)};\nconst BINARY = $binary;\n"
shim = WebViewCompat.addDocumentStartJavaScript(web, "(function(){$prelude$source\n})();", setOf(UiAssets.ORIGIN))
}
@@ -182,6 +211,11 @@ class MainActivity : Activity() {
return accepted
}
+ @Deprecated("Activity results for the system pickers; the platform API, kept for minSdk 26.")
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ if (!pickers.deliver(requestCode, resultCode, data)) super.onActivityResult(requestCode, resultCode, data)
+ }
+
private fun hasAsset(path: String) = try { assets.open(path).close(); true } catch (e: java.io.IOException) { false }
private fun refused() = WebResourceResponse("text/plain", "utf-8", 403, "Forbidden", emptyMap(), "".byteInputStream())
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt
index 50f711c..0946464 100644
--- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt
@@ -10,6 +10,7 @@ import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import org.json.JSONArray
import org.json.JSONObject
+import org.meshbay.client.save.BinaryFrame
import org.meshbay.client.shell.UiAssets
import java.util.concurrent.Executors
@@ -32,19 +33,29 @@ class Bridge(private val channels: Channels) : WebViewCompat.WebMessageListener
override fun onPostMessage(view: WebView, message: WebMessageCompat, sourceOrigin: Uri,
isMainFrame: Boolean, replyProxy: JavaScriptReplyProxy) {
- val request = try { JSONObject(message.data ?: return) } catch (e: Exception) { return }
- val id = request.optLong("id", -1)
if (!isMainFrame || sourceOrigin.toString() != UiAssets.ORIGIN) {
Log.w(TAG, "refused a message from $sourceOrigin (main frame: $isMainFrame)")
+ val id = if (message.type == WebMessageCompat.TYPE_STRING)
+ try { JSONObject(message.data ?: "").optLong("id", -1) } catch (e: Exception) { -1 } else -1
replyProxy.postMessage(error(id, "Refused: not the MeshBay interface"))
return
}
+ if (message.type == WebMessageCompat.TYPE_ARRAY_BUFFER) {
+ val frame = BinaryFrame.parse(message.arrayBuffer) ?: return
+ dispatch(frame.id, replyProxy, "binary ${frame.channel}") { channels.binary(frame) }
+ return
+ }
+ val request = try { JSONObject(message.data ?: return) } catch (e: Exception) { return }
+ val id = request.optLong("id", -1)
val channel = request.optString("ch")
val args = request.optJSONArray("args") ?: JSONArray()
+ dispatch(id, replyProxy, channel) { channels.call(channel, args) }
+ }
+
+ private fun dispatch(id: Long, replyProxy: JavaScriptReplyProxy, channel: String, call: () -> Any?) {
work.execute {
val reply = try {
- JSONObject().put("id", id).put("ok", true).put("value", channels.call(channel, args) ?: JSONObject.NULL)
- .toString()
+ JSONObject().put("id", id).put("ok", true).put("value", call() ?: JSONObject.NULL).toString()
} catch (e: Refused) {
error(id, e.message ?: "Refused")
} catch (e: Exception) {
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
index 736e434..308dfa5 100644
--- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
@@ -2,6 +2,8 @@ package org.meshbay.client.bridge
import org.json.JSONArray
import org.meshbay.client.hub.HubClient
+import org.meshbay.client.save.BinaryFrame
+import org.meshbay.client.save.SaveSinks
import java.net.Inet4Address
import java.net.InetAddress
@@ -20,6 +22,7 @@ class Channels(
private val onHubChanged: () -> Unit,
private val hasCatalogue: (String) -> Boolean,
private val keys: KeyChannels? = null,
+ private val saves: SaveSinks? = null,
) {
@Volatile var locale = "en"
private set
@@ -29,10 +32,32 @@ class Channels(
"hub:fetch" -> hub.fetch(args.optString(0, ""), args.optJSONObject(1))
"ice:resolve-stun" -> resolveStun(args.optJSONArray(0) ?: JSONArray())
"ui:locale" -> setLocale(args.optString(0, ""))
+
+ // Where downloads go, chosen once; a display name, never a URI.
+ "folder:choose" -> saves().chooseFolder()
+ "folder:get" -> saves().getFolder()
+ "folder:forget" -> saves().forgetFolder()
+ // A sink the page refers to by an opaque id.
+ "save:begin" -> saves().begin(args.optString(0, ""), args.optJSONObject(1)?.optBoolean("auto", false) ?: false)
+ "save:write" -> { // the base64 path, for a WebView without ArrayBuffer messages
+ val bytes = java.util.Base64.getDecoder().decode(args.optString(1, ""))
+ saves().write(args.optLong(0, -1), bytes, 0, bytes.size)
+ }
+ "save:end" -> saves().end(args.optLong(0, -1))
+ "save:abort" -> saves().abort(args.optLong(0, -1))
+ "save:open" -> saves().open(args.optLong(0, -1))
else -> if (keys != null && keys.handles(channel)) keys.call(channel, args)
else throw Refused("Refused: no such channel")
}
+ private fun saves() = saves ?: throw Refused("Refused: no such channel")
+
+ /** A binary message: one write, its bytes left where the message put them. */
+ fun binary(frame: BinaryFrame): Any? = when (frame.channel) {
+ BinaryFrame.SAVE_WRITE -> saves().write(frame.handle, frame.bytes, frame.offset, frame.length)
+ else -> throw Refused("Refused: no such channel")
+ }
+
private fun setLocale(code: String): String {
// A code, never text, and only one the package has a catalogue for.
if (LOCALE.matches(code) && hasCatalogue(code)) locale = code
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveNames.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveNames.kt
new file mode 100644
index 0000000..eed096f
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveNames.kt
@@ -0,0 +1,73 @@
+package org.meshbay.client.save
+
+/** The pure part of saving: names, types and the binary frame. JVM-tested. */
+object SaveNames {
+ private val BIDI = Regex("[\\u061c\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]")
+
+ /**
+ * main.js `save:begin`: the basename only, bidirectional controls replaced
+ * — "invoice‮fdp.exe" would otherwise be listed as "invoiceexe.pdf".
+ */
+ fun sanitize(suggested: String?): String {
+ val base = (suggested ?: "").replace('\\', '/').substringAfterLast('/')
+ .replace(BIDI, "_").replace(Regex("[\\u0000-\\u001f]"), "_").trim()
+ return if (base.isEmpty() || base == "." || base == "..") "download" else base
+ }
+
+ /**
+ * downloads.js `OPENABLE`, entry for entry (test_android_downloads.py):
+ * what may be handed to another app to open, under a type chosen from the
+ * name — never one guessed from the bytes.
+ */
+ val OPENABLE = mapOf(
+ "pdf" to "application/pdf",
+ "png" to "image/png", "jpg" to "image/jpeg", "jpeg" to "image/jpeg", "gif" to "image/gif",
+ "webp" to "image/webp", "avif" to "image/avif", "bmp" to "image/bmp",
+ "mp3" to "audio/mpeg", "m4a" to "audio/mp4", "aac" to "audio/aac", "ogg" to "audio/ogg",
+ "oga" to "audio/ogg", "opus" to "audio/ogg", "flac" to "audio/flac", "wav" to "audio/wav",
+ "mp4" to "video/mp4", "m4v" to "video/mp4", "webm" to "video/webm", "ogv" to "video/ogg",
+ "mov" to "video/quicktime",
+ "txt" to "text/plain", "log" to "text/plain", "md" to "text/plain", "csv" to "text/plain",
+ )
+
+ fun extension(name: String): String? = Regex("\\.([A-Za-z0-9]+)$").find(name)?.groupValues?.get(1)?.lowercase()
+
+ fun openableType(name: String): String? = extension(name)?.let { OPENABLE[it] }
+
+ /** The type a file is created under: openable ones by name, the rest opaque. */
+ fun storedType(name: String): String = openableType(name) ?: "application/octet-stream"
+
+ /** "name (n).ext", as main.js `freeName` — never an overwrite. */
+ fun numbered(name: String, n: Int): String {
+ val ext = extension(name)?.let { ".$it" } ?: ""
+ val stem = if (ext.isEmpty()) name else name.dropLast(ext.length)
+ return "$stem ($n)$ext"
+ }
+}
+
+/**
+ * A binary bridge message: one write, no JSON, no base64.
+ *
+ * "MBB1" | u32 request id | u16 channel | u16 reserved | u32 handle | bytes
+ *
+ * all big-endian. The reply is an ordinary JSON reply carrying the id.
+ */
+class BinaryFrame(val id: Long, val channel: Int, val handle: Long, val bytes: ByteArray, val offset: Int) {
+ val length get() = bytes.size - offset
+
+ companion object {
+ const val HEADER = 16
+ const val SAVE_WRITE = 1
+ private val MAGIC = byteArrayOf('M'.code.toByte(), 'B'.code.toByte(), 'B'.code.toByte(), '1'.code.toByte())
+
+ private fun u32(b: ByteArray, at: Int) =
+ ((b[at].toLong() and 0xff) shl 24) or ((b[at + 1].toLong() and 0xff) shl 16) or
+ ((b[at + 2].toLong() and 0xff) shl 8) or (b[at + 3].toLong() and 0xff)
+
+ fun parse(b: ByteArray): BinaryFrame? {
+ if (b.size < HEADER || !(0 until 4).all { b[it] == MAGIC[it] }) return null
+ val channel = ((b[8].toInt() and 0xff) shl 8) or (b[9].toInt() and 0xff)
+ return BinaryFrame(u32(b, 4), channel, u32(b, 12), b, HEADER)
+ }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveSinks.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveSinks.kt
new file mode 100644
index 0000000..2da7ec7
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/save/SaveSinks.kt
@@ -0,0 +1,238 @@
+package org.meshbay.client.save
+
+import android.content.ContentResolver
+import android.content.ContentValues
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.net.Uri
+import android.os.Build
+import android.provider.DocumentsContract
+import android.provider.MediaStore
+import android.util.Log
+import org.json.JSONObject
+import org.meshbay.client.bridge.Refused
+import org.meshbay.client.shell.Pickers
+import java.io.OutputStream
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicLong
+
+/**
+ * Downloads, written to disk as they arrive — never collected in the page and
+ * handed over at the end (§8.5; main.js `save:*`).
+ *
+ * The page never names a path or a URI: it asks, is told a display name, and
+ * holds an opaque id. Where a file lands:
+ *
+ * - **automatic**, a folder chosen in Settings → that folder (a Storage Access
+ * Framework tree), as `<name>.part`, renamed on completion;
+ * - **automatic**, no folder chosen → the system's Downloads collection, as a
+ * pending entry that only becomes visible when complete — the Android form
+ * of `.part`;
+ * - **asked**, or a chosen folder that has gone → the system save dialog.
+ * A folder that was chosen and has since gone is never silently replaced
+ * by Downloads: somebody who picked a card wants to know it is not there.
+ *
+ * An unfinished file never carries the final name where that can be avoided,
+ * an aborted one is deleted, and one left by a killed process is deleted at the
+ * next start — Android gives no reliable quit hook, so `before-quit`'s cleanup
+ * happens there.
+ */
+class SaveSinks(
+ private val context: Context,
+ private val prefs: SharedPreferences,
+ private val pickers: Pickers,
+ private val startActivity: (Intent) -> Unit,
+) {
+ private enum class Kind { TREE_PART, MEDIASTORE, PICKED }
+
+ private class Sink(val uri: Uri, val out: OutputStream, val kind: Kind, val finalName: String, val tree: Uri?)
+
+ private val resolver: ContentResolver get() = context.contentResolver
+ private val sinks = ConcurrentHashMap<Long, Sink>()
+ private val completed = ConcurrentHashMap<Long, Pair<Uri, String>>()
+ private val ids = AtomicLong()
+
+ // ── Where downloads go ──────────────────────────────────────────────────
+
+ private val configuredTree: Uri? get() = prefs.getString(KEY_TREE, null)?.let(Uri::parse)
+
+ /** The chosen folder, if it is still there and still ours to write. */
+ private fun usableTree(): Uri? {
+ val tree = configuredTree ?: return null
+ val held = resolver.persistedUriPermissions.any { it.uri == tree && it.isWritePermission }
+ return if (held && displayName(treeDocument(tree)) != null) tree else null
+ }
+
+ private fun treeDocument(tree: Uri) =
+ DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree))
+
+ private fun displayName(uri: Uri): String? = try {
+ resolver.query(uri, arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), null, null, null)?.use {
+ if (it.moveToFirst()) it.getString(0) else null
+ }
+ } catch (e: Exception) { null }
+
+ fun chooseFolder(): String? {
+ val result = pickers.run(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)) ?: return null
+ val tree = result.data ?: return null
+ resolver.takePersistableUriPermission(tree,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ configuredTree?.takeIf { it != tree }?.let { release(it) }
+ prefs.edit().putString(KEY_TREE, tree.toString()).apply()
+ return displayName(treeDocument(tree)) ?: "folder"
+ }
+
+ /** `{name, isDefault}`, which the Settings row renders; a name, never a URI. */
+ fun getFolder(): JSONObject {
+ val tree = usableTree()
+ val name = tree?.let { displayName(treeDocument(it)) }
+ return JSONObject().put("name", name ?: DEFAULT_NAME).put("isDefault", name == null)
+ }
+
+ fun forgetFolder(): Boolean {
+ configuredTree?.let { release(it) }
+ prefs.edit().remove(KEY_TREE).apply()
+ return true
+ }
+
+ private fun release(tree: Uri) {
+ try {
+ resolver.releasePersistableUriPermission(tree,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ } catch (e: SecurityException) { /* already gone */ }
+ }
+
+ // ── Writing ─────────────────────────────────────────────────────────────
+
+ fun begin(suggestedName: String?, auto: Boolean): JSONObject? {
+ val wanted = SaveNames.sanitize(suggestedName)
+ val type = SaveNames.storedType(wanted)
+ val tree = usableTree()
+ var target: Pair<Uri, Kind>? = null
+
+ if (auto && !(configuredTree != null && tree == null)) {
+ target = try {
+ when {
+ tree != null -> DocumentsContract.createDocument(resolver, treeDocument(tree),
+ "application/octet-stream", "$wanted.part")?.let { it to Kind.TREE_PART }
+ Build.VERSION.SDK_INT >= 29 -> resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI,
+ ContentValues().apply {
+ put(MediaStore.MediaColumns.DISPLAY_NAME, wanted)
+ put(MediaStore.MediaColumns.MIME_TYPE, type)
+ put(MediaStore.MediaColumns.IS_PENDING, 1)
+ })?.let { it to Kind.MEDIASTORE }
+ else -> null
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "automatic save target failed", e); null
+ }
+ }
+ if (target == null) {
+ val ask = Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE)
+ .setType(type).putExtra(Intent.EXTRA_TITLE, wanted)
+ tree?.let { ask.putExtra(DocumentsContract.EXTRA_INITIAL_URI, treeDocument(it)) }
+ val uri = pickers.run(ask)?.data ?: return null // dismissed: not an error
+ target = uri to Kind.PICKED
+ }
+
+ val (uri, kind) = target
+ val out = resolver.openOutputStream(uri, "wt") ?: throw Refused("Could not write the file")
+ val id = ids.incrementAndGet()
+ val shown = if (kind == Kind.TREE_PART) wanted else (displayName(uri) ?: wanted)
+ sinks[id] = Sink(uri, out, kind, wanted, tree)
+ rememberPending(uri, true)
+ return JSONObject().put("id", id).put("name", shown)
+ .put("openable", SaveNames.openableType(shown) != null)
+ }
+
+ /** Returns once the bytes are written: the await is the backpressure. */
+ fun write(id: Long, bytes: ByteArray, offset: Int, length: Int): Boolean {
+ val sink = sinks[id] ?: throw Refused("No such download")
+ synchronized(sink) { sink.out.write(bytes, offset, length) }
+ return true
+ }
+
+ fun end(id: Long): Boolean {
+ val sink = sinks.remove(id) ?: return false
+ synchronized(sink) { sink.out.flush(); sink.out.close() }
+ // Publishing the file is what makes it complete — only after the stream
+ // has flushed, or the final name would be on a short file.
+ val published: Uri = try {
+ when (sink.kind) {
+ Kind.MEDIASTORE -> {
+ resolver.update(sink.uri, ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }, null, null)
+ sink.uri
+ }
+ Kind.TREE_PART -> renameFree(sink.uri, sink.finalName)
+ Kind.PICKED -> sink.uri
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "could not finalise a download", e)
+ return false
+ }
+ rememberPending(sink.uri, false)
+ completed[id] = published to (displayName(published) ?: sink.finalName)
+ return true
+ }
+
+ private fun renameFree(uri: Uri, name: String): Uri {
+ var candidate = name
+ for (n in 2 until 1000) {
+ try {
+ return DocumentsContract.renameDocument(resolver, uri, candidate) ?: uri
+ } catch (e: Exception) {
+ // Most providers refuse a name that exists; try the next one.
+ candidate = SaveNames.numbered(name, n)
+ }
+ }
+ throw IllegalStateException("No free name for $name")
+ }
+
+ fun abort(id: Long): Boolean {
+ val sink = sinks.remove(id) ?: return false
+ synchronized(sink) { try { sink.out.close() } catch (e: Exception) { /* already closed */ } }
+ // A cancelled download leaves nothing: a truncated file looks like a
+ // complete one to whoever opens it next.
+ delete(sink.uri)
+ rememberPending(sink.uri, false)
+ return true
+ }
+
+ /** Hand a finished file to the app that opens its type — only a type that runs nothing. */
+ fun open(id: Long): Boolean {
+ val (uri, name) = completed[id] ?: return false
+ val type = SaveNames.openableType(name) ?: throw Refused("This kind of file is not opened from here")
+ startActivity(Intent(Intent.ACTION_VIEW).setDataAndType(uri, type)
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK))
+ return true
+ }
+
+ // ── What a killed process left behind ───────────────────────────────────
+
+ private fun rememberPending(uri: Uri, add: Boolean) = synchronized(prefs) {
+ val set = HashSet(prefs.getStringSet(KEY_PENDING, emptySet()) ?: emptySet())
+ if (add) set.add(uri.toString()) else set.remove(uri.toString())
+ prefs.edit().putStringSet(KEY_PENDING, set).commit()
+ }
+
+ fun cleanUpAfterAKilledProcess() {
+ val pending = prefs.getStringSet(KEY_PENDING, emptySet()) ?: emptySet()
+ for (u in pending) delete(Uri.parse(u))
+ prefs.edit().remove(KEY_PENDING).apply()
+ }
+
+ private fun delete(uri: Uri) {
+ try {
+ if (DocumentsContract.isDocumentUri(context, uri)) DocumentsContract.deleteDocument(resolver, uri)
+ else resolver.delete(uri, null, null)
+ } catch (e: Exception) { Log.w(TAG, "could not remove an unfinished download", e) }
+ }
+
+ companion object {
+ private const val TAG = "MeshBay"
+ private const val KEY_TREE = "downloadTree"
+ private const val KEY_PENDING = "pendingDownloads"
+ const val DEFAULT_NAME = "Downloads"
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/Pickers.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/Pickers.kt
new file mode 100644
index 0000000..f54ef87
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/Pickers.kt
@@ -0,0 +1,40 @@
+package org.meshbay.client.shell
+
+import android.app.Activity
+import android.content.Intent
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * A system picker (folder, save-as, open) started from a bridge worker and
+ * waited on there — the bridge never blocks the UI thread, and the page gets
+ * its answer as the reply to the call that asked.
+ */
+class Pickers(private val activity: Activity) {
+ private class Waiting { val done = CountDownLatch(1); @Volatile var result: Intent? = null }
+
+ private val waiting = ConcurrentHashMap<Int, Waiting>()
+ private val codes = AtomicInteger(4000)
+
+ /** The result intent, or null when the person dismissed the picker. */
+ fun run(intent: Intent): Intent? {
+ val code = codes.incrementAndGet()
+ val w = Waiting()
+ waiting[code] = w
+ activity.runOnUiThread {
+ try { activity.startActivityForResult(intent, code) }
+ catch (e: android.content.ActivityNotFoundException) { waiting.remove(code); w.done.countDown() }
+ }
+ w.done.await()
+ return w.result
+ }
+
+ /** From Activity.onActivityResult; true when the code was one of ours. */
+ fun deliver(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
+ val w = waiting.remove(requestCode) ?: return false
+ w.result = if (resultCode == Activity.RESULT_OK) data ?: Intent() else null
+ w.done.countDown()
+ return true
+ }
+}
diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SaveNamesTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SaveNamesTest.kt
new file mode 100644
index 0000000..d3b8450
--- /dev/null
+++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/SaveNamesTest.kt
@@ -0,0 +1,45 @@
+package org.meshbay.client
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.meshbay.client.save.BinaryFrame
+import org.meshbay.client.save.SaveNames
+
+class SaveNamesTest {
+ @Test fun `a suggested name is a basename with no bidirectional tricks`() {
+ assertEquals("invoice_fdp.exe", SaveNames.sanitize("invoice\u202efdp.exe"))
+ assertEquals("passwd", SaveNames.sanitize("../../etc/passwd"))
+ assertEquals("x.txt", SaveNames.sanitize("C:\\Users\\x.txt"))
+ assertEquals("download", SaveNames.sanitize(""))
+ assertEquals("download", SaveNames.sanitize(".."))
+ assertEquals("a_b", SaveNames.sanitize("a\u0000b"))
+ }
+
+ @Test fun `only what runs nothing is opened, under a type from the name`() {
+ assertEquals("application/pdf", SaveNames.openableType("Report.PDF"))
+ assertEquals("video/mp4", SaveNames.openableType("clip.mp4"))
+ for (n in listOf("page.html", "image.svg", "run.apk", "script.js", "noext", "x.pdf.exe")) {
+ assertNull(n, SaveNames.openableType(n))
+ }
+ assertEquals("application/octet-stream", SaveNames.storedType("page.html"))
+ }
+
+ @Test fun `a taken name becomes name (n), never an overwrite`() {
+ assertEquals("film (2).mkv", SaveNames.numbered("film.mkv", 2))
+ assertEquals("README (3)", SaveNames.numbered("README", 3))
+ }
+
+ @Test fun `a binary frame is read as the shim writes it`() {
+ val payload = byteArrayOf(1, 2, 3, 4, 5)
+ val b = byteArrayOf(0x4d, 0x42, 0x42, 0x31, 0, 0, 1, 2, 0, 1, 0, 0, 0, 0, 0, 7) + payload
+ val f = BinaryFrame.parse(b)!!
+ assertEquals(258L, f.id)
+ assertEquals(BinaryFrame.SAVE_WRITE, f.channel)
+ assertEquals(7L, f.handle)
+ assertArrayEquals(payload, f.bytes.copyOfRange(f.offset, f.bytes.size))
+ assertNull(BinaryFrame.parse(byteArrayOf(0x4d, 0x42, 0x42, 0x32) + ByteArray(12)))
+ assertNull(BinaryFrame.parse(ByteArray(10)))
+ }
+}