1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
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"
}
}
|