package org.meshbay.client.photos import android.Manifest import android.app.Activity import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Build import android.webkit.WebResourceResponse import org.json.JSONArray import org.json.JSONObject import org.meshbay.client.MainActivity import org.meshbay.client.R import org.meshbay.client.bridge.Refused import org.meshbay.client.notify.Notifier import org.meshbay.client.phonesync.Destination import org.meshbay.client.phonesync.DestinationChannels import org.meshbay.client.phonesync.ManifestLog import java.io.File import java.io.FilterInputStream import java.io.InputStream import java.security.MessageDigest import java.security.SecureRandom import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit /** * Photo backup (docs/MESHBAY_DESIGN.md §9.12): what the page needs from the * phone, and nothing it could use to read anything else. * * The page decides when a run is due and does the sending, because the * transport and the group key are there. This side lists the photos, keeps the * ledger, and hands over bytes — by an opaque token the page fetches from the * packaged origin (`/photosync/`), valid for the run that issued it and * for nothing but the photo it names. The page never sees a `content://` URI. * * Phone-only: the desktop preload has no counterpart, so `platform.photoSync` * is absent there. */ class PhotoChannels( private val activity: Activity, private val prefs: SharedPreferences, private val destinations: DestinationChannels, private val dir: File, private val onKeepAlive: (Boolean, String) -> Unit, ) { private val source = PhotoSource(activity) private val random = SecureRandom() private val tokens = ConcurrentHashMap() @Volatile private var permission: CountDownLatch? = null private class Issued(val pending: Pending, val key: String) { @Volatile var sha256: String? = null } init { // Somewhere new is a new backup: due at once, from now when only new // photos were asked for, and its ledger is the new place's own. destinations.onChange { tokens.clear() val edit = prefs.edit().remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED) config()?.let { edit.putString(CONFIG, it.copy(since = System.currentTimeMillis()).toJson().toString()) } edit.apply() } } fun handles(channel: String) = channel.startsWith("photosync:") fun call(channel: String, args: JSONArray): Any? = when (channel) { "photosync:status" -> status() "photosync:permit" -> { permit(args.optBoolean(0, false)); status() } "photosync:albums" -> { requirePermission(); albums(args.optBoolean(0, false)) } "photosync:configure" -> { configure(args.optJSONObject(0)); status() } "photosync:estimate" -> { requirePermission(); estimate(args.optJSONObject(0) ?: throw Refused("Refused: no settings")) } "photosync:plan" -> { requirePermission(); plan() } "photosync:sent" -> { sent(args.optString(0, ""), args.optString(1, ""), args.optString(2, "")); true } "photosync:manifest" -> manifest() "photosync:manifest-sent" -> { manifestSent(args.optString(0, "")); true } "photosync:completed" -> { completed(); status() } "photosync:failed" -> failed(args.optString(0, ""), args.optString(1, "")) "photosync:keep-alive" -> { onKeepAlive(args.optBoolean(0, false), args.optString(1, "").take(200)); true } else -> throw Refused("Refused: no such channel") } // ── state ──────────────────────────────────────────────────────────────── private fun config(): SyncConfig? = SyncConfig.parse(prefs.getString(CONFIG, null)) /** Where photos go, and which: both, or null when either is missing. */ private fun active(): Pair? { val c = config() ?: return null return (destinations.get() ?: return null) to c } private fun keyOf(d: Destination) = hex(sha256Of(d.ledgerKey.toByteArray())).take(32) private fun ledger(d: Destination) = PhotoLedger(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 c = config() val d = destinations.get() return JSONObject() .put("permission", permissionState()) .put("videoPermission", permissionState(video = true)) .put("unmetered", unmetered()) .put("config", c?.toJson() ?: JSONObject.NULL) .put("destination", d?.toJson() ?: JSONObject.NULL) .put("dir", d?.let { PhotoPlan.baseFor(it) } ?: JSONObject.NULL) .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 (c != null && d != null) ledger(d).size else 0) .put("now", System.currentTimeMillis()) } private fun configure(o: JSONObject?) { val previous = config() if (o == null) { prefs.edit().remove(CONFIG).remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply() tokens.clear() return } if (destinations.get() == null) throw Refused("Refused: no destination") val next = SyncConfig.fromJson(o, System.currentTimeMillis(), previous) val edit = prefs.edit().putString(CONFIG, next.toJson().toString()) // Anything that asks for more (another scope, an album, the videos) // is due at once rather than tomorrow: the person just asked for it, // and waits to see it go. Whatever the last run was refused for is // not this one's problem. if (previous == null || previous != next) { edit.remove(LAST).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED) } edit.apply() tokens.clear() } 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 — the folder is no * longer writable, the disk is full, the person left the group. Said once, * in a notification, rather than every day; 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() notice(activity, NOTIFY_ID, text.take(300)) return true } // ── the phone's photos ─────────────────────────────────────────────────── private fun albums(withVideos: Boolean): JSONArray = JSONArray().apply { for (a in source.albums(withVideos)) put(JSONObject().put("id", a.id).put("name", a.name) .put("count", a.count).put("bytes", a.bytes).put("camera", a.camera) .put("videos", a.videos).put("videoBytes", a.videoBytes)) } /** What a backup set up this way would send first: the count and size the confirmation states. */ private fun estimate(o: JSONObject): JSONObject { val d = destinations.get() ?: throw Refused("Refused: no destination") val c = SyncConfig.fromJson(o, System.currentTimeMillis(), config()) val ledger = ledger(d) val items = PhotoPlan.plan(source.photos(c.albums, c.includeVideos), c, PhotoPlan.baseFor(d), ledger::get) { _, _ -> true } val videos = items.filter { it.photo.video } return JSONObject().put("count", items.size).put("bytes", items.sumOf { it.photo.size }) .put("videos", videos.size).put("videoBytes", videos.sumOf { it.photo.size }) } private fun plan(): JSONObject { val (d, c) = active() ?: throw Refused("Refused: photo backup is off") val ledger = ledger(d) val items = PhotoPlan.plan(source.photos(c.albums, c.includeVideos), c, PhotoPlan.baseFor(d), ledger::get) { p, sent -> hashOf(p)?.let { it == sent.sha256 } ?: true } // A new plan replaces the last one: tokens are for one run, never kept. tokens.clear() val out = JSONArray() for (p in items) { val token = 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.photo.size).put("edited", p.edited).put("taken", PhotoPlan.whenTaken(p.photo)) .put("video", p.photo.video) // After a reinstall the ledger is empty, and the page looks in the // folder for what is already there — an edit under its own name too. .put("alsoKnownAs", PhotoPlan.editedName(p.photo, java.util.TimeZone.getDefault()))) } return JSONObject().put("items", out).put("manifest", manifestLog(d).waiting()) } /** The node took it (or already had it): into the ledger, under the name its ack gave. */ private fun sent(token: String, dir: String, name: String) { val issued = tokens[token] ?: throw Refused("Refused: unknown photo") val d = active()?.first?.takeIf { it.ledgerKey == issued.key } ?: throw Refused("Refused: the backup changed") val p = issued.pending.photo val sha = issued.sha256 ?: hashOf(p) ?: throw Refused("Refused: the photo is gone") val now = System.currentTimeMillis() ledger(d).record(PhotoLedger.Entry(p.mediaId, p.modified, p.size, sha, dir.take(1024), name.take(256), now)) manifestLog(d).append(JSONObject() .put("kind", if (p.video) "video" else "photo") .put("node", "${dir.take(1024)}/${name.take(256)}") .put("source", p.relPath).put("album", p.album) .put("taken", PhotoPlan.whenTaken(p)).put("modified", p.modified * 1000) .put("size", p.size).put("sha256", sha).put("mime", p.mime) .put("edited", issued.pending.edited).put("sentAt", now)) tokens.remove(token) } /** * The manifest of what was sent and not yet described on the node, for * `/meshbay-manifest/` (ManifestLog), or `item: null`. */ private fun manifest(): JSONObject { val (d, _) = active() ?: throw Refused("Refused: photo backup is off") val file = manifestLog(d).issue() ?: return JSONObject().put("item", JSONObject.NULL) val token = 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(), java.util.TimeZone.getDefault())) .put("dir", PhotoPlan.baseFor(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 } /** * The bytes of an issued photo or video, for `/photosync/` on the * packaged origin. Asked a range at a time (ByteRange), as the upload * reads them, so a video never sits whole in the page; the ledger's hash * is then taken when the node has it (`sent`). Asked whole, it is hashed * as it goes out. */ fun serve(path: String, range: String? = null): WebResourceResponse? { manifestToken?.takeIf { it.first == path.removePrefix(PATH) }?.let { (_, file) -> return serveRange(file.inputStream(), file.length(), "application/x-ndjson", range) } val issued = tokens[path.removePrefix(PATH)] ?: return null val raw = try { source.open(issued.pending.photo) } catch (e: Exception) { null } ?: return null val mime = issued.pending.photo.mime.ifEmpty { "application/octet-stream" } if (range != null) return serveRange(raw, issued.pending.photo.size, mime, range) val digest = MessageDigest.getInstance("SHA-256") val stream = object : FilterInputStream(raw) { private var done = false override fun read(): Int = super.read().also { if (it < 0) finish() else digest.update(it.toByte()) } override fun read(b: ByteArray, off: Int, len: Int): Int = super.read(b, off, len).also { if (it < 0) finish() else digest.update(b, off, it) } private fun finish() { if (!done) { done = true; issued.sha256 = hex(digest.digest()) } } } val headers = mapOf("Cache-Control" to "no-store", "X-Content-Type-Options" to "nosniff") return WebResourceResponse(mime, null, 200, "OK", headers, stream) } private fun hashOf(p: Photo): String? = try { source.open(p)?.use { s -> hex(digestOf(s)) } } catch (e: Exception) { null } /** What the photo backup sends, for the files backup to leave out; empty while it is off. */ fun backedUpPaths(): Set { val c = config() ?: return emptySet() if (permissionState() == "denied") return emptySet() return try { source.relativePaths(c.albums, c.includeVideos) } catch (e: Exception) { emptySet() } } // ── permission and network ─────────────────────────────────────────────── /** For the photos, or with `video` for the videos, which Android 13+ asks about apart. */ private fun permissionState(video: Boolean = false): String { fun has(p: String) = activity.checkSelfPermission(p) == PackageManager.PERMISSION_GRANTED val media = if (video) Manifest.permission.READ_MEDIA_VIDEO else Manifest.permission.READ_MEDIA_IMAGES return when { Build.VERSION.SDK_INT >= 33 && has(media) -> "granted" Build.VERSION.SDK_INT >= 34 && has(Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) -> "partial" Build.VERSION.SDK_INT < 33 && has(Manifest.permission.READ_EXTERNAL_STORAGE) -> "granted" else -> "denied" } } private fun requirePermission() { if (permissionState() == "denied") throw Refused("Refused: no access to photos") } /** Asks, and waits for the answer: the page goes on from what was decided. */ private fun permit(withVideos: Boolean) { val media = listOf(Manifest.permission.READ_MEDIA_IMAGES) + (if (withVideos) listOf(Manifest.permission.READ_MEDIA_VIDEO) else emptyList()) val wanted = when { Build.VERSION.SDK_INT >= 34 -> (media + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED).toTypedArray() Build.VERSION.SDK_INT >= 33 -> media.toTypedArray() else -> arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) } val latch = CountDownLatch(1) permission = latch activity.runOnUiThread { activity.requestPermissions(wanted, PERMISSION_REQUEST) } latch.await(5, TimeUnit.MINUTES) permission = null } /** From Activity.onRequestPermissionsResult; true when the request was ours. */ fun deliverPermission(requestCode: Int): Boolean { if (requestCode != PERMISSION_REQUEST) return false permission?.countDown() return true } /** * Not "on Wi-Fi": a phone joined to another phone's hotspot is on Wi-Fi and * spending that phone's mobile data, and Android reports it as metered. */ fun unmetered(): Boolean { val cm = activity.getSystemService(ConnectivityManager::class.java) val caps = cm.getNetworkCapabilities(cm.activeNetwork ?: return false) ?: return false return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) || (Build.VERSION.SDK_INT >= 30 && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED)) } companion object { const val PATH = "/photosync/" const val CHANNEL = "backup" private const val NOTIFY_ID = 9 private const val PERMISSION_REQUEST = 4208 const val PREFS = "photosync" private const val CONFIG = "config" private const val LAST = "last_completed" private const val FAILURE = "failure" private const val FAILURE_AT = "failure_at" private const val NOTIFIED = "notified" /** Created, or renamed from "Photo backup" now that contacts and messages use it too. */ fun ensureChannel(context: Context) { context.getSystemService(NotificationManager::class.java) .createNotificationChannel(NotificationChannel(CHANNEL, "Backup", NotificationManager.IMPORTANCE_LOW)) } /** A backup that stopped, said once; a tap opens the Android Sync page. */ fun notice(context: Context, id: Int, text: String) { val nm = context.getSystemService(NotificationManager::class.java) ensureChannel(context) val open = Intent(context, MainActivity::class.java).setAction(Intent.ACTION_VIEW) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) .putExtra(Notifier.EXTRA_LINK, "#/android-sync") val pending = PendingIntent.getActivity(context, id, open, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) nm.notify(id, Notification.Builder(context, CHANNEL) .setSmallIcon(R.drawable.ic_notify).setContentTitle("MeshBay").setContentText(text) .setStyle(Notification.BigTextStyle().bigText(text)) .setContentIntent(pending).setAutoCancel(true).build()) } /** `range` of `stream`, `size` bytes long (or a 416 for a range that cannot be served). */ fun serveRange(stream: InputStream, size: Long, mime: String, range: String?): WebResourceResponse { val r = ByteRange.parse(range, size) ?: run { stream.close() return WebResourceResponse("text/plain", null, 416, "Range Not Satisfiable", mapOf("Content-Range" to "bytes */$size"), "".byteInputStream()) } val headers = mapOf("Cache-Control" to "no-store", "X-Content-Type-Options" to "nosniff", "Content-Range" to "bytes ${r.first}-${r.last}/$size", "Content-Length" to (r.last - r.first + 1).toString()) // 200, not 206: what was asked is in the address, and a 206 to a // request that carried no Range is not something to rely on. return WebResourceResponse(mime, null, 200, "OK", headers, ByteRange.slice(stream, r)) } fun digestOf(s: InputStream): ByteArray { val d = MessageDigest.getInstance("SHA-256") val buf = ByteArray(64 * 1024) while (true) { val n = s.read(buf); if (n < 0) break; d.update(buf, 0, n) } return d.digest() } fun sha256Of(b: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(b) fun hex(b: ByteArray): String = b.joinToString("") { "%02x".format(it) } } }