diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-02 18:21:26 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-03 14:24:54 +0200 |
| commit | d8f5005ba2c9a249ce5ce8976d9c3754a3a7458e (patch) | |
| tree | 960ece9a79c58fbe60033b91f6ec685795eff8a1 /packages/meshbay-android/app/src/main | |
| parent | ede70b0fcc006d3ced8ef0d1d7115593f6627aef (diff) | |
| download | meshbay-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/src/main')
7 files changed, 482 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 + * — "invoicefdp.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 + } +} |