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 `.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() private val completed = ConcurrentHashMap>() 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? = 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" } }