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 `/-drive//…` 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/`, 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, private val unmetered: () -> Boolean, private val onKeepAlive: (Boolean, String) -> Unit, ) { private val source = DriveSource(activity) private val random = SecureRandom() private val tokens = ConcurrentHashMap() 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 = 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) { 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): Map { if (!kind.keepTop) return list.associate { it.uri to "" } val used = HashMap() 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? = 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? = 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/` 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" } }