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
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
|
package org.meshbay.client.drive
import android.app.Activity
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.provider.DocumentsContract
import android.webkit.WebResourceResponse
import org.json.JSONArray
import org.json.JSONObject
import org.meshbay.client.bridge.Refused
import org.meshbay.client.phonesync.Destination
import org.meshbay.client.phonesync.DestinationChannels
import org.meshbay.client.phonesync.ManifestLog
import org.meshbay.client.phonesync.DocPlan
import org.meshbay.client.photos.PhotoChannels
import org.meshbay.client.shell.Pickers
import java.io.File
import java.security.SecureRandom
import java.util.TimeZone
import java.util.concurrent.ConcurrentHashMap
/**
* Files backup (docs/MESHBAY_DESIGN.md §9.13): the folders the person chose,
* sent into `<folder>/<account>-drive/<chosen folder>/…` of the destination
* every kind shares, on the photo backup's terms: the page decides when and
* sends, this side lists, keeps the ledger and hands bytes over by token
* (`/drivesync/<token>`, a range at a time).
*
* Media folders cannot be chosen, and files the photo backup sends are left
* out (DrivePlan): nothing is stored twice.
*/
class DriveChannels(
private val kind: DriveKind,
private val activity: Activity,
private val prefs: SharedPreferences,
private val dir: File,
private val destinations: DestinationChannels,
private val pickers: Pickers,
private val photosBackedUp: () -> Set<String>,
private val unmetered: () -> Boolean,
private val onKeepAlive: (Boolean, String) -> Unit,
) {
private val source = DriveSource(activity)
private val random = SecureRandom()
private val tokens = ConcurrentHashMap<String, Issued>()
private class Issued(val pending: DrivePending, val key: String)
init {
destinations.onChange {
tokens.clear()
prefs.edit().remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply()
}
}
/** `/drivesync/`, `/wasync/`: where this kind's files are served on the packaged origin. */
val path: String get() = kind.path
fun handles(channel: String) = channel.startsWith("${kind.prefix}:")
fun call(channel: String, args: JSONArray): Any? = when (channel.removePrefix("${kind.prefix}:")) {
"status" -> status()
"add-folder" -> { addFolder(); status() }
"remove-folder" -> { removeFolder(args.optString(0, "")); status() }
"configure" -> { configure(args.optJSONObject(0)); status() }
"plan" -> plan()
"sent" -> { sent(args.optString(0, ""), args.optString(1, ""), args.optString(2, "")); true }
"manifest" -> manifest()
"manifest-sent" -> { manifestSent(args.optString(0, "")); true }
"completed" -> { completed(); status() }
"failed" -> failed(args.optString(0, ""), args.optString(1, ""))
"keep-alive" -> { onKeepAlive(args.optBoolean(0, false), args.optString(1, "").take(200)); true }
else -> throw Refused("Refused: no such channel")
}
// ── state ────────────────────────────────────────────────────────────────
private data class Folder(val uri: String, val name: String)
private fun folders(): List<Folder> = try {
val a = JSONArray(prefs.getString(FOLDERS, "[]"))
(0 until a.length()).map { a.getJSONObject(it).let { o -> Folder(o.getString("uri"), o.getString("name")) } }
} catch (e: Exception) { emptyList() }
private fun saveFolders(list: List<Folder>) {
prefs.edit().putString(FOLDERS, JSONArray(list.map { JSONObject().put("uri", it.uri).put("name", it.name) })
.toString()).apply()
}
/** Two chosen folders of one name go into two folders on the node: `Notes`, `Notes-2`. */
private fun topNames(list: List<Folder>): Map<String, String> {
if (!kind.keepTop) return list.associate { it.uri to "" }
val used = HashMap<String, Int>()
return list.associate { f ->
val n = (used[f.name.lowercase()] ?: 0) + 1
used[f.name.lowercase()] = n
f.uri to (if (n == 1) f.name else "${f.name}-$n")
}
}
private fun base(d: Destination) = DocPlan.dirFor(d, kind.suffix)
private fun options(): JSONObject = try { JSONObject(prefs.getString(OPTIONS, "{}")) } catch (e: Exception) { JSONObject() }
private fun keyOf(d: Destination) = PhotoChannels.hex(PhotoChannels.sha256Of(d.ledgerKey.toByteArray())).take(32)
private fun ledger(d: Destination) = DriveLedger(File(dir, keyOf(d) + ".jsonl"))
private fun manifestLog(d: Destination) = ManifestLog(File(dir, keyOf(d) + ".manifest"))
@Volatile private var manifestToken: Pair<String, File>? = null
fun status(): JSONObject {
val d = destinations.get()
return JSONObject()
.put("on", prefs.getBoolean(ON, false))
.put("options", options())
.put("unmetered", unmetered())
.put("destination", d?.toJson() ?: JSONObject.NULL)
.put("dir", d?.let { base(it) } ?: JSONObject.NULL)
.put("folders", JSONArray(folders().map { JSONObject().put("id", it.uri).put("name", it.name) }))
.put("lastCompleted", if (prefs.contains(LAST)) prefs.getLong(LAST, 0) else JSONObject.NULL)
.put("failure", prefs.getString(FAILURE, null) ?: JSONObject.NULL)
.put("failureAt", if (prefs.contains(FAILURE_AT)) prefs.getLong(FAILURE_AT, 0) else JSONObject.NULL)
.put("sent", if (d != null) ledger(d).size else 0)
.put("now", System.currentTimeMillis())
}
/** The system's folder picker; a media folder or a whole volume is refused, with the reason. */
private fun addFolder() {
val ask = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
kind.initialDocId?.let {
ask.putExtra(DocumentsContract.EXTRA_INITIAL_URI,
DocumentsContract.buildDocumentUri("com.android.externalstorage.documents", it))
}
val result = pickers.run(ask) ?: return
val tree = result.data ?: return
kind.refusal(DocumentsContract.getTreeDocumentId(tree))?.let { throw Refused("Refused: $it") }
activity.contentResolver.takePersistableUriPermission(tree, Intent.FLAG_GRANT_READ_URI_PERMISSION)
var list = folders()
if (list.any { it.uri == tree.toString() }) return
// One folder only: the one chosen now replaces the last.
if (kind.single) { list.forEach { removeFolder(it.uri) }; list = emptyList() }
val fallback = DrivePlan.pathOf(DocumentsContract.getTreeDocumentId(tree)).substringAfterLast('/')
saveFolders(list + Folder(tree.toString(), source.nameOf(tree) ?: fallback.ifEmpty { "folder" }))
}
/** Forgotten here, and its grant given back; what was sent from it stays on the node. */
private fun removeFolder(uri: String) {
val list = folders()
if (list.none { it.uri == uri }) return
saveFolders(list.filter { it.uri != uri })
try {
activity.contentResolver.releasePersistableUriPermission(Uri.parse(uri), Intent.FLAG_GRANT_READ_URI_PERMISSION)
} catch (e: Exception) { /* already gone */ }
}
/** `{…options}` turns it on and keeps them (`media` for WhatsApp); null turns it off. */
private fun configure(o: JSONObject?) {
tokens.clear()
if (o == null) {
prefs.edit().remove(ON).remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply()
return
}
if (destinations.get() == null) throw Refused("Refused: no destination")
val options = JSONObject().put("media", o.optBoolean("media", false))
prefs.edit().putBoolean(ON, true).putString(OPTIONS, options.toString()).apply()
}
private fun completed() {
prefs.edit().putLong(LAST, System.currentTimeMillis()).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply()
}
private fun failed(code: String, text: String): Boolean {
val c = code.take(64)
prefs.edit().putString(FAILURE, c).putLong(FAILURE_AT, System.currentTimeMillis()).apply()
if (prefs.getString(NOTIFIED, null) == c || text.isBlank()) return false
prefs.edit().putString(NOTIFIED, c).apply()
PhotoChannels.notice(activity, kind.notifyId, text.take(300))
return true
}
// ── the files ────────────────────────────────────────────────────────────
private fun plan(): JSONObject {
val d = destinations.get()?.takeIf { prefs.getBoolean(ON, false) } ?: throw Refused("Refused: files backup is off")
val list = folders()
val ledger = ledger(d)
// A folder whose grant is gone (removed on the phone, revoked) is
// skipped this run rather than failing the others.
val options = options()
val files = list.flatMap { f -> try { source.files(Uri.parse(f.uri)) } catch (e: Exception) { emptyList() } }
.filter { kind.include(it, options) }
restoreSet = kind.restoreSet?.let { inSet -> files.filter(inSet) }
val items = DrivePlan.plan(files, base(d), topNames(list), ledger::get, photosBackedUp(),
TimeZone.getDefault()) { f, sent -> hashOf(f)?.let { it == sent.sha256 } ?: true }
tokens.clear()
val out = JSONArray()
for (p in items) {
val token = PhotoChannels.hex(ByteArray(16).also { random.nextBytes(it) })
tokens[token] = Issued(p, d.ledgerKey)
out.put(JSONObject().put("token", token).put("name", p.name).put("dir", p.dir)
.put("size", p.file.size).put("edited", p.edited))
}
return JSONObject().put("items", out).put("manifest", manifestLog(d).waiting())
}
/** The node took it (or already had it): into the ledger, hashed from the phone now. */
private fun sent(token: String, dir: String, name: String) {
val issued = tokens[token] ?: throw Refused("Refused: unknown file")
val d = destinations.get()?.takeIf { it.ledgerKey == issued.key } ?: throw Refused("Refused: the backup changed")
val f = issued.pending.file
val sha = hashOf(f) ?: throw Refused("Refused: the file is gone")
val now = System.currentTimeMillis()
ledger(d).record(DriveLedger.Entry(f.docId, f.modified, f.size, sha, dir.take(1024), name.take(256), now))
manifestLog(d).append(JSONObject()
.put("kind", "file")
.put("node", "${dir.take(1024)}/${name.take(256)}")
.put("source", DrivePlan.pathOf(f.docId)).put("name", f.name)
.put("folder", folders().firstOrNull { it.uri == f.tree }?.name ?: "")
.put("modified", f.modified).put("size", f.size).put("sha256", sha).put("mime", f.mime)
.put("edited", issued.pending.edited).put("sentAt", now))
if (kind.restoreSet?.invoke(f) == true) prefs.edit().putBoolean(SET_CHANGED, true).apply()
tokens.remove(token)
}
/** The files a restore needs together, as the last plan found them. */
@Volatile private var restoreSet: List<DriveFile>? = null
/**
* Once something in the restore set was sent: one manifest line naming, for
* each file of the set as it is now, the copy on the node that a restore
* takes. Without it, a restore could pair this week's full copy with last
* week's increments, which WhatsApp would not open.
*/
private fun noteRestoreSet(d: Destination) {
val set = restoreSet ?: return
if (!prefs.getBoolean(SET_CHANGED, false)) return
val ledger = ledger(d)
val files = JSONArray()
for (f in set) {
val e = ledger[f.docId] ?: return // not all on the node yet: the next run says it
if (e.size != f.size || e.modified != f.modified) return
files.put(JSONObject().put("source", DrivePlan.pathOf(f.docId)).put("node", "${e.dir}/${e.name}")
.put("size", e.size).put("sha256", e.sha256))
}
manifestLog(d).append(JSONObject().put("kind", "restore-set").put("app", kind.suffix)
.put("at", System.currentTimeMillis()).put("files", files))
prefs.edit().remove(SET_CHANGED).apply()
}
/** The manifest of what was sent and not yet described on the node (ManifestLog), or `item: null`. */
private fun manifest(): JSONObject {
val d = destinations.get()?.takeIf { prefs.getBoolean(ON, false) } ?: throw Refused("Refused: files backup is off")
noteRestoreSet(d)
val file = manifestLog(d).issue() ?: return JSONObject().put("item", JSONObject.NULL)
val token = PhotoChannels.hex(ByteArray(16).also { random.nextBytes(it) })
manifestToken = token to file
return JSONObject().put("item", JSONObject().put("token", token)
.put("name", ManifestLog.nameFor(System.currentTimeMillis(), TimeZone.getDefault()))
.put("dir", base(d) + "/" + ManifestLog.DIR).put("size", file.length()))
}
private fun manifestSent(token: String) {
if (manifestToken?.first != token) throw Refused("Refused: unknown manifest")
val d = destinations.get() ?: throw Refused("Refused: the backup changed")
manifestLog(d).confirm()
manifestToken = null
}
/** A range of an issued file, for `/drivesync/<token>` on the packaged origin. */
fun serve(path: String, range: String?): WebResourceResponse? {
manifestToken?.takeIf { it.first == path.removePrefix(kind.path) }?.let { (_, file) ->
return PhotoChannels.serveRange(file.inputStream(), file.length(), "application/x-ndjson", range)
}
val issued = tokens[path.removePrefix(kind.path)] ?: return null
val f = issued.pending.file
val raw = try { source.open(f) } catch (e: Exception) { null } ?: return null
return PhotoChannels.serveRange(raw, f.size, f.mime.ifEmpty { "application/octet-stream" }, range)
}
private fun hashOf(f: DriveFile): String? = try {
source.open(f)?.use { PhotoChannels.hex(PhotoChannels.digestOf(it)) }
} catch (e: Exception) { null }
companion object {
private const val OPTIONS = "options"
private const val SET_CHANGED = "restore_set_changed"
private const val FOLDERS = "folders"
private const val ON = "on"
private const val LAST = "last_completed"
private const val FAILURE = "failure"
private const val FAILURE_AT = "failure_at"
private const val NOTIFIED = "notified"
}
}
|