package org.meshbay.client.phonesync import android.app.Activity import android.content.SharedPreferences import android.content.pm.PackageManager import android.webkit.WebResourceResponse import org.json.JSONArray import org.json.JSONObject import org.meshbay.client.bridge.Refused import org.meshbay.client.photos.PhotoChannels import java.io.File import java.io.FileInputStream import java.security.SecureRandom import java.util.TimeZone import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit /** * Contacts or messages backup (docs/MESHBAY_DESIGN.md §9.13): what the page * needs from the phone for one kind of data, and nothing it could use to read * anything else. * * The same split as photos (PhotoChannels): the page decides when and does the * sending; this side writes what is new into one file and hands it over by an * opaque token at `/phonesync/`, valid until the next plan. What the * next file starts from (`marker`) moves only when the node has taken this one. * * Where the file goes is the destination every kind shares (Destination.kt); * what is kept here is only whether this kind is on, and what was sent. * `prefix` names the channels (`contactsync:status`, …). */ class DocChannels( private val prefix: String, private val activity: Activity, private val prefs: SharedPreferences, private val destinations: DestinationChannels, private val dir: File, private val source: DocSource, private val notifyId: Int, private val permissionRequest: Int, ) { private val random = SecureRandom() @Volatile private var issued: Issued? = null @Volatile private var permission: CountDownLatch? = null private class Issued(val token: String, val export: Export, val key: String) init { // Somewhere new starts from nothing: the first file there holds everything. destinations.onChange { issued = null prefs.edit().remove(MARKER).remove(SENT).remove(LAST_SENT).remove(LAST) .remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply() } } fun handles(channel: String) = channel.startsWith("$prefix:") fun call(channel: String, args: JSONArray): Any? = when (channel.removePrefix("$prefix:")) { "status" -> status() "permit" -> { permit(); status() } "configure" -> { configure(args.optJSONObject(0)); status() } "plan" -> { requirePermission(); plan() } "sent" -> { sent(args.optString(0, ""), args.optString(1, ""), args.optString(2, "")); true } "completed" -> { completed(); status() } "failed" -> failed(args.optString(0, ""), args.optString(1, "")) "open-settings" -> { openSettings(); true } else -> throw Refused("Refused: no such channel") } // ── state ──────────────────────────────────────────────────────────────── /** On, and somewhere to go: the destination, or null. */ private fun active(): Destination? = if (prefs.getBoolean(ON, false)) destinations.get() else null fun status(): JSONObject { val d = destinations.get() return JSONObject() .put("permission", if (permitted()) "granted" else "denied") .put("on", prefs.getBoolean(ON, false)) .put("destination", d?.toJson() ?: JSONObject.NULL) .put("dir", d?.let { DocPlan.dirFor(it, source.suffix) } ?: JSONObject.NULL) .put("lastCompleted", if (prefs.contains(LAST)) prefs.getLong(LAST, 0) else JSONObject.NULL) .put("lastSent", prefs.getString(LAST_SENT, null) ?: 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", prefs.getInt(SENT, 0)) .put("now", System.currentTimeMillis()) } /** `{}` turns it on, null off; turned off, it forgets what it sent. */ private fun configure(o: JSONObject?) { issued = null if (o == null) { prefs.edit().clear().apply() return } if (destinations.get() == null) throw Refused("Refused: no destination") prefs.edit().putBoolean(ON, true).apply() } private fun completed() { prefs.edit().putLong(LAST, System.currentTimeMillis()).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply() } /** A run stopped for a reason that will hold tomorrow too; said once. True when this call said it. */ 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, notifyId, text.take(300)) return true } // ── the file ───────────────────────────────────────────────────────────── private fun plan(): JSONObject { val c = active() ?: throw Refused("Refused: this backup is off") issued = null dir.listFiles()?.forEach { it.delete() } dir.mkdirs() val export = source.export(prefs.getString(MARKER, null), dir, System.currentTimeMillis(), TimeZone.getDefault()) ?: return JSONObject().put("item", JSONObject.NULL) val token = PhotoChannels.hex(ByteArray(16).also { random.nextBytes(it) }) issued = Issued(token, export, c.ledgerKey) return JSONObject().put("item", JSONObject() .put("token", token).put("name", export.name) .put("dir", DocPlan.dirFor(c, source.suffix) + (if (export.subdir.isEmpty()) "" else "/${export.subdir}")) .put("size", export.file.length()).put("count", export.count)) } /** The node took it: what the next file starts from moves, never before. */ private fun sent(token: String, dir: String, name: String) { val i = issued?.takeIf { it.token == token } ?: throw Refused("Refused: unknown file") if (active()?.ledgerKey != i.key) throw Refused("Refused: the backup changed") prefs.edit().putString(MARKER, i.export.marker).putInt(SENT, prefs.getInt(SENT, 0) + 1) .putString(LAST_SENT, "${dir.take(1024)}/${name.take(256)}").apply() issued = null i.export.file.delete() } /** The bytes of the issued file, for `/phonesync/` on the packaged origin. */ fun serve(path: String): WebResourceResponse? { val i = issued?.takeIf { it.token == path.removePrefix(PATH) } ?: return null val stream = try { FileInputStream(i.export.file) } catch (e: Exception) { return null } val headers = mapOf("Cache-Control" to "no-store", "X-Content-Type-Options" to "nosniff") return WebResourceResponse(i.export.mime, null, 200, "OK", headers, stream) } // ── permission ─────────────────────────────────────────────────────────── private fun permitted() = source.permissions.all { activity.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED } private fun requirePermission() { if (!permitted()) throw Refused("Refused: no access") } /** Asks, and waits for the answer: the page goes on from what was decided. */ private fun permit() { if (permitted()) return val latch = CountDownLatch(1) permission = latch activity.runOnUiThread { activity.requestPermissions(source.permissions, permissionRequest) } latch.await(5, TimeUnit.MINUTES) permission = null } /** * The application's page in the system's settings. Android keeps some * permissions (text messages) behind "restricted settings" for an * application installed outside a store; that page is where the person * lifts it (⋮ > Allow restricted settings), and nowhere in here can. */ private fun openSettings() { val intent = android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, android.net.Uri.fromParts("package", activity.packageName, null)) .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) activity.runOnUiThread { activity.startActivity(intent) } } /** From Activity.onRequestPermissionsResult; true when the request was ours. */ fun deliverPermission(requestCode: Int): Boolean { if (requestCode != permissionRequest) return false permission?.countDown() return true } companion object { const val PATH = "/phonesync/" private const val ON = "on" private const val MARKER = "marker" private const val SENT = "sent" private const val LAST_SENT = "last_sent" private const val LAST = "last_completed" private const val FAILURE = "failure" private const val FAILURE_AT = "failure_at" private const val NOTIFIED = "notified" } }