From 25152ddb61a89ea3ea29d3aef5de13343429d32a Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 9 Oct 2026 18:23:52 +0200 Subject: feat: back up the phone's photos to a group, once a day on Wi-Fi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android application sends the photos taken on the phone to one folder of one group chosen by the member (docs/MESHBAY_DESIGN.md §9.12). The phone lists MediaStore, keeps a ledger of what was sent and hands each photo's bytes to the page by an opaque token on the packaged origin; the page decides when a run is due and uploads through the existing path, one photo at a time under a slot. - Once a day from the last finished run, on an unmetered network only; "Back up now" asks first on mobile data. Leaving Wi-Fi stops after the file in flight. - Photos already on the phone are sent by default, newest first, under /YYYY/MM; edits are sent beside the original as -edited-. - Additive by construction: nothing is ever deleted, renamed or replaced on the node, and a photo deleted on the node is not sent again. - A confirmation names the group, owner, members, folder and size when the destination or starting point changes; a lasting refusal (disk full, folder read-only or gone, no longer a member) is said once and retried a day later. - No ACCESS_MEDIA_LOCATION, so the platform redacts photo locations. - A dataSync foreground service keeps a run going with the screen off. HEIC/HEIF photos are sent but not shown in Photos yet (§15.2). Co-Authored-By: Claude Opus 5.5 --- .../app/src/main/AndroidManifest.xml | 12 + .../app/src/main/assets/bridge/meshbay-bridge.js | 16 ++ .../main/kotlin/org/meshbay/client/MainActivity.kt | 69 ++++- .../kotlin/org/meshbay/client/bridge/Channels.kt | 3 + .../org/meshbay/client/photos/BackupService.kt | 91 +++++++ .../org/meshbay/client/photos/PhotoChannels.kt | 298 +++++++++++++++++++++ .../org/meshbay/client/photos/PhotoLedger.kt | 91 +++++++ .../kotlin/org/meshbay/client/photos/PhotoPlan.kt | 167 ++++++++++++ .../org/meshbay/client/photos/PhotoSource.kt | 84 ++++++ .../org/meshbay/client/shell/ShellWebView.kt | 6 +- .../kotlin/org/meshbay/client/PhotoSyncTest.kt | 182 +++++++++++++ 11 files changed, 1013 insertions(+), 6 deletions(-) create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/BackupService.kt create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoLedger.kt create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt create mode 100644 packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt (limited to 'packages/meshbay-android/app/src') diff --git a/packages/meshbay-android/app/src/main/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml index 0c234aa..37c18c7 100644 --- a/packages/meshbay-android/app/src/main/AndroidManifest.xml +++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml @@ -13,6 +13,14 @@ + + + + + on this origin. + // The page decides when and does the sending. Phone-only, like `push`. + photoSync: { + status: () => call('photosync:status'), + permit: () => call('photosync:permit'), + albums: () => call('photosync:albums'), + configure: (settings) => call('photosync:configure', settings || null), + estimate: (settings) => call('photosync:estimate', settings), + plan: () => call('photosync:plan'), + sent: (token, dir, name) => call('photosync:sent', token, dir, name), + completed: () => call('photosync:completed'), + failed: (code, text) => call('photosync:failed', code, text), + keepAlive: (on, text) => call('photosync:keep-alive', on === true, text || ''), + }, + // Where downloads go, chosen once. A display name comes back, never a URI. folder: { choose: () => call('folder:choose'), diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt index dad8462..16ea1cd 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt @@ -35,6 +35,8 @@ import org.meshbay.client.keys.SecretStore import org.meshbay.client.notify.Notifier import org.meshbay.client.notify.PushChannels import org.meshbay.client.notify.PushState +import org.meshbay.client.photos.BackupService +import org.meshbay.client.photos.PhotoChannels import org.meshbay.client.save.SaveSinks import org.meshbay.client.shell.Pickers import org.meshbay.client.shell.ShellWebView @@ -56,6 +58,8 @@ class MainActivity : Activity() { private lateinit var cast: CastChannels private lateinit var hub: HubClient private lateinit var channels: Channels + private lateinit var photos: PhotoChannels + private var network: android.net.ConnectivityManager.NetworkCallback? = null private val pickers = Pickers(this) private val text = NativeText { code -> try { assets.open("ui/locales/$code.js").bufferedReader().use { it.readText() } } catch (e: java.io.IOException) { null } @@ -63,6 +67,7 @@ class MainActivity : Activity() { private var shim: ScriptHandler? = null private var casting = false private var playing = false + private var syncing = false private var fullscreen: View? = null private var fullscreenCallback: WebChromeClient.CustomViewCallback? = null private var pendingLink: String? = null @@ -91,19 +96,24 @@ class MainActivity : Activity() { Thread { saves.cleanUpAfterAKilledProcess() }.start() cast = CastChannels(this, onCasting = { on -> runOnUiThread { casting = on; keepAlive() } }, tell = { m -> runOnUiThread { android.widget.Toast.makeText(this, m, android.widget.Toast.LENGTH_LONG).show() } }) + photos = PhotoChannels(this, getSharedPreferences(PhotoChannels.PREFS, Context.MODE_PRIVATE), + java.io.File(filesDir, "photosync"), + onKeepAlive = { on, line -> runOnUiThread { backup(on, line) } }) channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } }, hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves, cast = cast, onPlayback = { on -> runOnUiThread { playing = on; keepAlive() } }, push = PushChannels(this, PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE)), channelNames = { mapOf( Notifier.CHANNEL_CHAT to text.get("push.channel_chat", channels.locale), - Notifier.CHANNEL_OTHER to text.get("push.channel_other", channels.locale)) })) + Notifier.CHANNEL_OTHER to text.get("push.channel_other", channels.locale)) }), + photos = photos) WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels)) cast.control.warmUp() installShim() // Opened from a notification: to what it was about, once the page is up. pendingLink = Notifier.linkOf(intent) web.loadUrl(UiAssets.START) + watchNetwork() } override fun onNewIntent(intent: Intent) { @@ -141,6 +151,9 @@ class MainActivity : Activity() { override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? { val url = request.url + if (url.host == UiAssets.HOST && url.path?.startsWith(PhotoChannels.PATH) == true) { + return photos.serve(url.path ?: "") ?: refused() + } if (url.host == UiAssets.HOST) return loader.shouldInterceptRequest(url) ?: refused() // reCAPTCHA (sign-up) and nothing else goes to the network from // the page; the policy says the same, this is the second wall. @@ -258,11 +271,12 @@ class MainActivity : Activity() { /** * A cast, or music playing here, keeps the process, the Wi-Fi and the page * alive with the screen off (spike S-2a, scenario F): the foreground service - * holds the first two, the WebView reported visible holds the third. + * holds the first two, the WebView reported visible holds the third. A photo + * backup holds the page here too; its service is its own (`backup`). */ private fun keepAlive() { val on = casting || playing - web.keepVisible = on + web.keepVisible = on || syncing val service = Intent(this, CastService::class.java) if (!on) { stopService(service); return } service.putExtra(CastService.EXTRA_TEXT, @@ -272,6 +286,53 @@ class MainActivity : Activity() { try { startForegroundService(service) } catch (e: IllegalStateException) { Log.w(Bridge.TAG, "keep-alive refused: $e") } } + /** + * A photo backup running: its own foreground service, and the page kept + * visible like a cast. Started once; afterwards only its line changes, + * which needs no start — refused from the background on Android 12+. + */ + private fun backup(on: Boolean, line: String) { + val service = Intent(this, BackupService::class.java) + if (on && syncing) { BackupService.update(this, line); return } + if (on == syncing) return + syncing = on + if (on) { + try { startForegroundService(service.putExtra(BackupService.EXTRA_TEXT, line)) } + catch (e: IllegalStateException) { Log.w(Bridge.TAG, "backup keep-alive refused: $e") } + } else stopService(service) + keepAlive() + } + + /** + * Tells the page when the network becomes unmetered or stops being: a + * backup waiting for Wi-Fi starts, one running on it stops. An event on the + * window, carrying the one boolean and nothing about the network. + */ + private fun watchNetwork() { + val cm = getSystemService(android.net.ConnectivityManager::class.java) + var last: Boolean? = null + val callback = object : android.net.ConnectivityManager.NetworkCallback() { + override fun onCapabilitiesChanged(n: android.net.Network, caps: android.net.NetworkCapabilities) = tell() + override fun onLost(n: android.net.Network) = tell() + private fun tell() { + val now = photos.unmetered() + if (now == last) return + last = now + runOnUiThread { + if (::web.isInitialized) web.evaluateJavascript( + "window.dispatchEvent(new CustomEvent('meshbay-network', { detail: { unmetered: $now } }));", null) + } + } + } + try { cm.registerDefaultNetworkCallback(callback); network = callback } + catch (e: Exception) { Log.w(Bridge.TAG, "no network callback: $e") } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + if (::photos.isInitialized && photos.deliverPermission(requestCode)) return + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + private fun hasAsset(path: String) = try { assets.open(path).close(); true } catch (e: java.io.IOException) { false } private fun refused() = WebResourceResponse("text/plain", "utf-8", 403, "Forbidden", emptyMap(), "".byteInputStream()) @@ -335,6 +396,8 @@ class MainActivity : Activity() { override fun onDestroy() { if (::cast.isInitialized && cast.relay.active) cast.relay.stop() if (casting || playing) { casting = false; playing = false; keepAlive() } + if (syncing) backup(false, "") + network?.let { try { getSystemService(android.net.ConnectivityManager::class.java).unregisterNetworkCallback(it) } catch (e: Exception) {} } if (::web.isInitialized) { root.removeView(web); web.destroy() } super.onDestroy() } diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt index 5f202ba..1775d57 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt @@ -4,6 +4,7 @@ import org.json.JSONArray import org.meshbay.client.cast.CastChannels import org.meshbay.client.hub.HubClient import org.meshbay.client.notify.PushChannels +import org.meshbay.client.photos.PhotoChannels import org.meshbay.client.save.BinaryFrame import org.meshbay.client.save.SaveSinks import java.net.Inet4Address @@ -28,6 +29,7 @@ class Channels( private val cast: CastChannels? = null, private val onPlayback: (Boolean) -> Unit = {}, private val push: PushChannels? = null, + private val photos: PhotoChannels? = null, ) { @Volatile var locale = "en" private set @@ -56,6 +58,7 @@ class Channels( keys != null && keys.handles(channel) -> keys.call(channel, args) cast != null && cast.handles(channel) -> cast.call(channel, args) push != null && push.handles(channel) -> push.call(channel, args) + photos != null && photos.handles(channel) -> photos.call(channel, args) else -> throw Refused("Refused: no such channel") } } diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/BackupService.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/BackupService.kt new file mode 100644 index 0000000..b40d006 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/BackupService.kt @@ -0,0 +1,91 @@ +package org.meshbay.client.photos + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.wifi.WifiManager +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import org.meshbay.client.R + +/** + * Keeps a photo backup going with the screen off — the same pair as a cast + * (CastService): this service holds the process, the CPU and the Wi-Fi, and + * the shell keeps the WebView reported visible, because the sending happens in + * the page and Chromium freezes a hidden page after 60 s. + * + * Its own service, of type dataSync, rather than a second reason on the cast + * service: music can play during a backup, and each stops on its own. + * + * Started only from the page while the application is in front — a foreground + * service cannot be started from the background on Android 12+ — and its + * progress line is updated through the notification, which needs no start. + */ +class BackupService : Service() { + private var wake: PowerManager.WakeLock? = null + private var wifi: WifiManager.WifiLock? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val n = notification(this, intent?.getStringExtra(EXTRA_TEXT) ?: "") + if (Build.VERSION.SDK_INT >= 29) startForeground(ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + else startForeground(ID, n) + if (wake == null) { + wake = getSystemService(PowerManager::class.java) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "meshbay:backup").apply { acquire(MAX_HOLD_MS) } + @Suppress("DEPRECATION") + val mode = if (Build.VERSION.SDK_INT >= 29) WifiManager.WIFI_MODE_FULL_LOW_LATENCY else WifiManager.WIFI_MODE_FULL_HIGH_PERF + wifi = (applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager) + .createWifiLock(mode, "meshbay:backup").apply { acquire() } + } + return START_NOT_STICKY + } + + /** + * Android 15 gives dataSync six hours a day and then calls this; not + * stopping here is a crash. The run carries on with the screen on, or at + * the next opening, which is where an interrupted run goes anyway. + */ + override fun onTimeout(startId: Int, fgsType: Int) { + stopSelf() + } + + override fun onDestroy() { + wake?.let { if (it.isHeld) it.release() } + wifi?.let { if (it.isHeld) it.release() } + wake = null; wifi = null + super.onDestroy() + } + + companion object { + private const val ID = 8 + const val EXTRA_TEXT = "text" + private const val MAX_HOLD_MS = 6L * 3600 * 1000 + + fun notification(context: Context, text: String): Notification { + PhotoChannels.ensureChannel(context) + val open = PendingIntent.getActivity(context, ID, + context.packageManager.getLaunchIntentForPackage(context.packageName), PendingIntent.FLAG_IMMUTABLE) + return Notification.Builder(context, PhotoChannels.CHANNEL) + .setContentTitle("MeshBay") + .setContentText(text) + .setSmallIcon(R.drawable.ic_notify) + .setContentIntent(open) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build() + } + + /** The progress line of a running backup; nothing when none runs. */ + fun update(context: Context, text: String) { + val nm = context.getSystemService(NotificationManager::class.java) + if (nm.activeNotifications.any { it.id == ID }) nm.notify(ID, notification(context, text)) + } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt new file mode 100644 index 0000000..874f5f3 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt @@ -0,0 +1,298 @@ +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 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 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 + } + + fun handles(channel: String) = channel.startsWith("photosync:") + + fun call(channel: String, args: JSONArray): Any? = when (channel) { + "photosync:status" -> status() + "photosync:permit" -> { permit(); status() } + "photosync:albums" -> { requirePermission(); albums() } + "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: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)) + + private fun ledger(c: SyncConfig) = PhotoLedger(File(dir, hex(sha256Of(c.ledgerKey.toByteArray())).take(32) + ".jsonl")) + + fun status(): JSONObject { + val c = config() + return JSONObject() + .put("permission", permissionState()) + .put("unmetered", unmetered()) + .put("config", c?.toJson() ?: 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", c?.let { ledger(it).size } ?: 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 + } + val next = try { SyncConfig.fromJson(o, System.currentTimeMillis(), previous) } + catch (e: IllegalArgumentException) { throw Refused("Refused: ${e.message}") } + val edit = prefs.edit().putString(CONFIG, next.toJson().toString()) + // A different destination or scope is a different backup: due at once, + // and whatever the last one was refused for is not this one's problem. + if (previous == null || previous.ledgerKey != next.ledgerKey || previous.since != next.since) { + 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() + notify(text.take(300)) + return true + } + + // ── the phone's photos ─────────────────────────────────────────────────── + + private fun albums(): JSONArray = JSONArray().apply { + for (a in source.albums()) put(JSONObject().put("id", a.id).put("name", a.name) + .put("count", a.count).put("bytes", a.bytes).put("camera", a.camera)) + } + + /** What a backup set up this way would send first: the count and size the confirmation states. */ + private fun estimate(o: JSONObject): JSONObject { + val c = try { SyncConfig.fromJson(o, System.currentTimeMillis(), config()) } + catch (e: IllegalArgumentException) { throw Refused("Refused: ${e.message}") } + val ledger = ledger(c) + val items = PhotoPlan.plan(source.photos(c.albums), c, ledger::get) { _, _ -> true } + return JSONObject().put("count", items.size).put("bytes", items.sumOf { it.photo.size }) + } + + private fun plan(): JSONObject { + val c = config() ?: throw Refused("Refused: photo backup is off") + val ledger = ledger(c) + val items = PhotoPlan.plan(source.photos(c.albums), c, ledger::get) { p, sent -> + hashOf(p.mediaId)?.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, c.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)) + // 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) + } + + /** 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 c = config()?.takeIf { it.ledgerKey == issued.key } ?: throw Refused("Refused: the backup changed") + val p = issued.pending.photo + val sha = issued.sha256 ?: hashOf(p.mediaId) ?: throw Refused("Refused: the photo is gone") + ledger(c).record(PhotoLedger.Entry(p.mediaId, p.modified, p.size, sha, + dir.take(1024), name.take(256), System.currentTimeMillis())) + tokens.remove(token) + } + + /** + * The bytes of an issued photo, for `/photosync/` on the packaged + * origin. Hashed as they go out, so the ledger records exactly what was sent. + */ + fun serve(path: String): WebResourceResponse? { + val issued = tokens[path.removePrefix(PATH)] ?: return null + val raw = try { source.open(issued.pending.photo.mediaId) } catch (e: Exception) { null } ?: return null + 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(issued.pending.photo.mime.ifEmpty { "application/octet-stream" }, + null, 200, "OK", headers, stream) + } + + private fun hashOf(mediaId: Long): String? = try { + source.open(mediaId)?.use { s -> hex(digestOf(s)) } + } catch (e: Exception) { null } + + // ── permission and network ─────────────────────────────────────────────── + + private fun permissionState(): String { + fun has(p: String) = activity.checkSelfPermission(p) == PackageManager.PERMISSION_GRANTED + return when { + Build.VERSION.SDK_INT >= 33 && has(Manifest.permission.READ_MEDIA_IMAGES) -> "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() { + val wanted = when { + Build.VERSION.SDK_INT >= 34 -> arrayOf(Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) + Build.VERSION.SDK_INT >= 33 -> arrayOf(Manifest.permission.READ_MEDIA_IMAGES) + 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)) + } + + private fun notify(text: String) { + val nm = activity.getSystemService(NotificationManager::class.java) + ensureChannel(activity) + val open = Intent(activity, MainActivity::class.java).setAction(Intent.ACTION_VIEW) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + .putExtra(Notifier.EXTRA_LINK, "#/settings") + val pending = PendingIntent.getActivity(activity, NOTIFY_ID, open, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + nm.notify(NOTIFY_ID, Notification.Builder(activity, CHANNEL) + .setSmallIcon(R.drawable.ic_notify).setContentTitle("MeshBay").setContentText(text) + .setStyle(Notification.BigTextStyle().bigText(text)) + .setContentIntent(pending).setAutoCancel(true).build()) + } + + 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" + + fun ensureChannel(context: Context) { + val nm = context.getSystemService(NotificationManager::class.java) + if (nm.getNotificationChannel(CHANNEL) == null) { + nm.createNotificationChannel(NotificationChannel(CHANNEL, "Photo backup", NotificationManager.IMPORTANCE_LOW)) + } + } + + 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) } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoLedger.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoLedger.kt new file mode 100644 index 0000000..f3aa6e5 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoLedger.kt @@ -0,0 +1,91 @@ +package org.meshbay.client.photos + +import org.json.JSONObject +import java.io.File + +/** + * What this phone has sent to one folder of one group — the memory of what was + * sent, never a mirror of the phone (docs/MESHBAY_DESIGN.md §9.12). + * + * A run sends what is not here, and nothing compares in the other direction: a + * photo deleted on the phone simply stops being listed, and one deleted on the + * node stays here and is not sent again — deleting it there was a decision. + * + * One line of JSON per send, appended; the last line for a media id wins. A + * whole-file rewrite per photo would be megabytes written per photo on a roll + * of twenty thousand. Compacted on load once the dead lines outnumber the live + * ones. Plain files and org.json, so the JVM tests run it as it runs here. + */ +class PhotoLedger(private val file: File) { + + data class Entry( + val mediaId: Long, + /** MediaStore DATE_MODIFIED, seconds — what tells an edit from the photo sent. */ + val modified: Long, + val size: Long, + /** SHA-256 of the bytes sent, hex: an edit is sent only if this changed. */ + val sha256: String, + /** Where the node put it: the folder and the name its ack gave. */ + val dir: String, + val name: String, + val sentAt: Long, + ) + + private val entries = HashMap() + private var lines = 0 + + init { load() } + + val size: Int get() = entries.size + + operator fun get(mediaId: Long): Entry? = entries[mediaId] + + fun all(): Collection = entries.values + + fun record(entry: Entry) { + entries[entry.mediaId] = entry + file.parentFile?.mkdirs() + file.appendText(encode(entry) + "\n") + lines += 1 + } + + /** Everything forgotten — the group or the folder changed, or backup was turned off. */ + fun clear() { + entries.clear() + lines = 0 + file.delete() + } + + private fun load() { + if (!file.exists()) return + file.forEachLine { line -> + if (line.isBlank()) return@forEachLine + lines += 1 + // A line cut short by a process killed mid-write is the only kind + // that fails to parse; what it was recording is sent again, once. + decode(line)?.let { entries[it.mediaId] = it } + } + if (lines > 2 * entries.size + COMPACT_SLACK) compact() + } + + private fun compact() { + val tmp = File(file.path + ".tmp") + tmp.writeText(entries.values.joinToString("") { encode(it) + "\n" }) + if (!tmp.renameTo(file)) { tmp.delete(); return } + lines = entries.size + } + + companion object { + private const val COMPACT_SLACK = 64 + + fun encode(e: Entry): String = JSONObject() + .put("id", e.mediaId).put("m", e.modified).put("s", e.size).put("h", e.sha256) + .put("d", e.dir).put("n", e.name).put("t", e.sentAt).toString() + + fun decode(line: String): Entry? = try { + val o = JSONObject(line) + Entry(o.getLong("id"), o.getLong("m"), o.getLong("s"), o.getString("h"), + o.getString("d"), o.getString("n"), o.getLong("t")) + } catch (e: Exception) { null } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt new file mode 100644 index 0000000..de39669 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt @@ -0,0 +1,167 @@ +package org.meshbay.client.photos + +import org.json.JSONArray +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** One image in MediaStore, as much of it as a plan needs. */ +data class Photo( + val mediaId: Long, + val displayName: String, + val size: Long, + /** Milliseconds; 0 when the camera wrote none. */ + val taken: Long, + /** MediaStore DATE_ADDED and DATE_MODIFIED, seconds. */ + val added: Long, + val modified: Long, + val bucketId: String, + val mime: String, +) + +/** + * Where this phone's photos go and which of them, as the person set it up. + * One group per phone (docs/MESHBAY_DESIGN.md §9.12). + */ +data class SyncConfig( + val account: String, + val groupId: String, + val groupName: String, + val owner: String, + /** A folder among the group's own, as a virtual path (`Photos/Family`). */ + val folder: String, + val albums: List, + /** The photos already on the phone too — the default, as backup applications do. */ + val includeExisting: Boolean, + /** When it was set up, ms: with `includeExisting` off, only photos added since count. */ + val since: Long, +) { + /** The ledger belongs to this, so a different group or folder starts a fresh one. */ + val ledgerKey: String get() = "$account\n$groupId\n$folder" + + fun toJson(): JSONObject = JSONObject() + .put("account", account).put("groupId", groupId).put("groupName", groupName) + .put("owner", owner).put("folder", folder).put("albums", JSONArray(albums)) + .put("includeExisting", includeExisting).put("since", since) + + companion object { + /** From the page, so checked like any input: names it chose, never a path on this phone. */ + fun fromJson(o: JSONObject, now: Long, previous: SyncConfig? = null): SyncConfig { + val account = o.optString("account", "").take(64) + val groupId = o.optString("groupId", "").take(64) + val folder = o.optString("folder", "").trim().trim('/').take(1024) + require(account.isNotEmpty() && groupId.isNotEmpty() && folder.isNotEmpty()) { "incomplete" } + require(folder.split('/').none { it.isEmpty() || it == "." || it == ".." }) { "bad folder" } + val albums = o.optJSONArray("albums") ?: JSONArray() + val includeExisting = o.optBoolean("includeExisting", true) + // A change of destination or of scope starts again from that moment; + // a change of album list alone does not move it. + val same = previous != null && previous.account == account && previous.groupId == groupId && + previous.folder == folder && previous.includeExisting == includeExisting + return SyncConfig( + account, groupId, + o.optString("groupName", "").take(256), o.optString("owner", "").take(64), + folder, + (0 until albums.length()).map { albums.optString(it, "").take(64) }.filter { it.isNotEmpty() }.distinct(), + includeExisting, + if (same) previous!!.since else now, + ) + } + + fun parse(text: String?): SyncConfig? = try { + val o = JSONObject(text ?: return null) + val albums = o.getJSONArray("albums") + SyncConfig(o.getString("account"), o.getString("groupId"), o.optString("groupName"), + o.optString("owner"), o.getString("folder"), + (0 until albums.length()).map { albums.getString(it) }, + o.optBoolean("includeExisting", true), o.getLong("since")) + } catch (e: Exception) { null } + } +} + +/** A photo to send: a new one, or a new version of one already sent. */ +data class Pending(val photo: Photo, val edited: Boolean, val dir: String, val name: String) + +/** + * What a run sends, decided from the phone's photos and the ledger alone. + * + * Pure, so the rules are tested on the JVM; reading bytes for an edit's hash is + * the caller's (`sameBytes`). + */ +object PhotoPlan { + /** A run is due once a day, counted from the last one that finished. */ + const val DAY_MS = 24L * 3600 * 1000 + + fun due(lastCompleted: Long?, now: Long): Boolean = + lastCompleted == null || now - lastCompleted >= DAY_MS || now < lastCompleted + + /** + * `sameBytes(photo, entry)` is asked only of a photo whose MediaStore + * modification date moved since it was sent: true when its bytes are still + * those the ledger hashed (a favourite flag, a rescan), in which case + * nothing is sent. + */ + fun plan( + photos: List, config: SyncConfig, ledger: (Long) -> PhotoLedger.Entry?, + zone: TimeZone = TimeZone.getDefault(), + sameBytes: (Photo, PhotoLedger.Entry) -> Boolean, + ): List { + val albums = config.albums.toSet() + val out = ArrayList() + for (p in photos) { + if (p.bucketId !in albums) continue + if (!p.mime.startsWith("image/")) continue + val sent = ledger(p.mediaId) + if (sent == null) { + if (!config.includeExisting && p.added * 1000 < config.since) continue + out += Pending(p, false, dirFor(config.folder, p, zone), nameFor(p)) + } else if (sent.modified != p.modified || sent.size != p.size) { + if (sent.size == p.size && sameBytes(p, sent)) continue + out += Pending(p, true, dirFor(config.folder, p, zone), editedName(p, zone)) + } + } + // Newest first: the photos most likely to exist nowhere else are safe earliest. + return out.sortedByDescending { whenTaken(it.photo) } + } + + fun whenTaken(p: Photo): Long = if (p.taken > 0) p.taken else p.added * 1000 + + /** `/YYYY/MM`, from when it was taken: an album is a directory (§9.9). */ + fun dirFor(folder: String, p: Photo, zone: TimeZone): String { + val fmt = SimpleDateFormat("yyyy/MM", Locale.ROOT).apply { timeZone = zone } + return "$folder/${fmt.format(Date(whenTaken(p)))}" + } + + fun nameFor(p: Photo): String = + p.displayName.takeIf { UPLOAD_NAME.matches(it) } ?: "photo-${p.mediaId}.${extension(p)}" + + /** + * An edit lands beside the original under a name that says what it is; left + * to the node it would be `IMG_…(1).jpg`, which says nothing. + */ + fun editedName(p: Photo, zone: TimeZone): String { + val base = nameFor(p) + val dot = base.lastIndexOf('.') + val stem = if (dot > 0) base.substring(0, dot) else base + val ext = if (dot > 0) base.substring(dot) else "" + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).apply { timeZone = zone } + .format(Date(p.modified * 1000)) + val suffix = "-edited-$stamp$ext" + return stem.take(MAX_NAME - suffix.length) + suffix + } + + private fun extension(p: Photo): String = + p.displayName.substringAfterLast('.', "").lowercase(Locale.ROOT).takeIf { it.matches(Regex("^[a-z0-9]{1,5}$")) } + ?: when (p.mime) { "image/png" -> "png"; "image/heic" -> "heic"; "image/heif" -> "heif" + "image/webp" -> "webp"; "image/gif" -> "gif"; else -> "jpg" } + + private const val MAX_NAME = 128 + + /** + * The node's SAFE_UPLOAD_NAME (roots.py), as files-app.js copies it. A name it + * refuses would fail the upload; this one is renamed before it is sent. + */ + val UPLOAD_NAME = Regex("^[\\p{L}\\p{N}][\\p{L}\\p{N}_ .\\-()\\[\\]'’,&+#@]{0,127}(?= 29) MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) + else MediaStore.Images.Media.EXTERNAL_CONTENT_URI + + fun albums(): List { + val by = LinkedHashMap() + query(null, null) { p, name, camera -> + val a = by[p.bucketId] + by[p.bucketId] = if (a == null) Album(p.bucketId, name, 1, p.size, camera) + else a.copy(count = a.count + 1, bytes = a.bytes + p.size, camera = a.camera || camera) + } + return by.values.sortedWith(compareByDescending { it.camera }.thenByDescending { it.count }) + } + + fun photos(albums: List): List { + if (albums.isEmpty()) return emptyList() + val out = ArrayList() + val where = "${MediaStore.Images.Media.BUCKET_ID} IN (${albums.joinToString(",") { "?" }})" + query(where, albums.toTypedArray()) { p, _, _ -> out += p } + return out + } + + fun open(mediaId: Long): InputStream? = + context.contentResolver.openInputStream(ContentUris.withAppendedId(collection, mediaId)) + + private fun query(where: String?, args: Array?, each: (Photo, String, Boolean) -> Unit) { + val cols = mutableListOf( + MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.SIZE, + MediaStore.Images.Media.DATE_TAKEN, MediaStore.Images.Media.DATE_ADDED, + MediaStore.Images.Media.DATE_MODIFIED, MediaStore.Images.Media.BUCKET_ID, + MediaStore.Images.Media.BUCKET_DISPLAY_NAME, MediaStore.Images.Media.MIME_TYPE, + ) + @Suppress("DEPRECATION") + val location = if (Build.VERSION.SDK_INT >= 29) MediaStore.Images.Media.RELATIVE_PATH else MediaStore.Images.Media.DATA + cols += location + // A photo still being written by the camera is not a photo yet. + val pending = if (Build.VERSION.SDK_INT >= 29) "${MediaStore.Images.Media.IS_PENDING} = 0" else null + val selection = listOfNotNull(pending, where).joinToString(" AND ").ifEmpty { null } + context.contentResolver.query(collection, cols.toTypedArray(), selection, args, null)?.use { c -> + val id = c.getColumnIndexOrThrow(cols[0]); val name = c.getColumnIndexOrThrow(cols[1]) + val size = c.getColumnIndexOrThrow(cols[2]); val taken = c.getColumnIndexOrThrow(cols[3]) + val added = c.getColumnIndexOrThrow(cols[4]); val modified = c.getColumnIndexOrThrow(cols[5]) + val bucket = c.getColumnIndexOrThrow(cols[6]); val bucketName = c.getColumnIndexOrThrow(cols[7]) + val mime = c.getColumnIndexOrThrow(cols[8]); val where2 = c.getColumnIndexOrThrow(cols[9]) + while (c.moveToNext()) { + val bucketId = c.getString(bucket) ?: continue + val photo = Photo( + c.getLong(id), c.getString(name) ?: "", c.getLong(size), + if (c.isNull(taken)) 0 else c.getLong(taken), c.getLong(added), c.getLong(modified), + bucketId, c.getString(mime) ?: "", + ) + val path = (c.getString(where2) ?: "").replace('\\', '/') + each(photo, c.getString(bucketName) ?: "", isCamera(path)) + } + } + } + + companion object { + /** `DCIM/Camera/` (RELATIVE_PATH) or `…/DCIM/Camera/x.jpg` (DATA, before Android 10). */ + fun isCamera(path: String): Boolean = + path.startsWith("DCIM/Camera") || path.contains("/DCIM/Camera/") + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt index 731da69..f3eb84e 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/ShellWebView.kt @@ -8,9 +8,9 @@ import android.webkit.WebView * While `keepVisible` is set, the WebView is told its window stayed visible * when the screen turns off. Chromium then never marks the page hidden, and * its freeze of hidden pages — exactly 60 s after hiding, measured (spike - * S-2a C) — never starts. Set only while a cast runs or music plays: a page that is never - * hidden is never throttled, which is the battery cost the freeze exists to - * avoid. + * S-2a C) — never starts. Set only while a cast runs, music plays or photos + * are being backed up: a page that is never hidden is never throttled, which + * is the battery cost the freeze exists to avoid. */ class ShellWebView(context: Context) : WebView(context) { var keepVisible = false diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt new file mode 100644 index 0000000..24e1841 --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt @@ -0,0 +1,182 @@ +package org.meshbay.client + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.meshbay.client.photos.Photo +import org.meshbay.client.photos.PhotoLedger +import org.meshbay.client.photos.PhotoPlan +import org.meshbay.client.photos.SyncConfig +import java.util.TimeZone + +class PhotoSyncTest { + @get:Rule val tmp = TemporaryFolder() + + private val utc = TimeZone.getTimeZone("UTC") + // 2026-10-09 12:00:00 UTC + private val oct9 = 1791547200000L + + private fun photo(id: Long, name: String = "IMG_$id.jpg", size: Long = 100, taken: Long = oct9, + added: Long = oct9 / 1000, modified: Long = oct9 / 1000, bucket: String = "cam", + mime: String = "image/jpeg") = + Photo(id, name, size, taken, added, modified, bucket, mime) + + private fun config(includeExisting: Boolean = true, since: Long = 0, albums: List = listOf("cam")) = + SyncConfig("bob", "g1", "Family", "alice", "Media/Photos/Bob", albums, includeExisting, since) + + private fun entry(p: Photo, sha: String = "h", name: String = p.displayName) = + PhotoLedger.Entry(p.mediaId, p.modified, p.size, sha, "d", name, 1) + + private fun plan(photos: List, c: SyncConfig = config(), ledger: Map = emptyMap(), + same: Boolean = false) = + PhotoPlan.plan(photos, c, { ledger[it] }, utc) { _, _ -> same } + + // ── what is sent ────────────────────────────────────────────────────────── + + @Test fun `everything already on the phone is sent, newest first, under its year and month`() { + val out = plan(listOf(photo(1, taken = oct9 - 40L * 86400000), photo(2), photo(3, taken = oct9 - 86400000))) + assertEquals(listOf(2L, 3L, 1L), out.map { it.photo.mediaId }) + assertEquals("Media/Photos/Bob/2026/10", out[0].dir) + assertEquals("Media/Photos/Bob/2026/08", out[2].dir) + assertEquals("IMG_2.jpg", out[0].name) + } + + @Test fun `from now on leaves out what was on the phone before`() { + val since = oct9 + 1000 + val out = plan(listOf(photo(1), photo(2, added = (oct9 + 5000) / 1000)), config(includeExisting = false, since = since)) + assertEquals(listOf(2L), out.map { it.photo.mediaId }) + } + + @Test fun `only the chosen albums, and only images`() { + val out = plan(listOf(photo(1), photo(2, bucket = "screens"), photo(3, mime = "video/mp4"))) + assertEquals(listOf(1L), out.map { it.photo.mediaId }) + } + + @Test fun `a photo already sent is not sent again`() { + val p = photo(1) + assertTrue(plan(listOf(p), ledger = mapOf(1L to entry(p))).isEmpty()) + } + + @Test fun `a photo deleted on the phone is simply not listed, and nothing is asked of the node`() { + // The plan has only additions in it: there is no other kind of item. + val sent = photo(1) + assertTrue(plan(emptyList(), ledger = mapOf(1L to entry(sent))).isEmpty()) + } + + // ── edits ──────────────────────────────────────────────────────────────── + + @Test fun `an edit is sent beside the original under a name that says so`() { + val before = photo(1) + val after = before.copy(size = 120, modified = before.modified + 3600) + val out = plan(listOf(after), ledger = mapOf(1L to entry(before))) + assertEquals(1, out.size) + assertTrue(out[0].edited) + assertEquals("IMG_1-edited-20261009-130000.jpg", out[0].name) + assertEquals("Media/Photos/Bob/2026/10", out[0].dir) + } + + @Test fun `a touch that left the bytes alone sends nothing`() { + val before = photo(1) + val touched = before.copy(modified = before.modified + 60) + assertTrue(plan(listOf(touched), ledger = mapOf(1L to entry(before)), same = true).isEmpty()) + assertEquals(1, plan(listOf(touched), ledger = mapOf(1L to entry(before)), same = false).size) + } + + @Test fun `the bytes are only read when the date moved and the size did not`() { + val before = photo(1) + var asked = 0 + PhotoPlan.plan(listOf(before), config(), { entry(before) }, utc) { _, _ -> asked++; true } + PhotoPlan.plan(listOf(before.copy(size = 7, modified = 9)), config(), { entry(before) }, utc) { _, _ -> asked++; true } + assertEquals(0, asked) + } + + // ── names ──────────────────────────────────────────────────────────────── + + @Test fun `a name the node would refuse is replaced before it is sent`() { + assertEquals("IMG_1.jpg", PhotoPlan.nameFor(photo(1))) + assertEquals("photo-7.jpg", PhotoPlan.nameFor(photo(7, name = ".hidden.jpg"))) + assertEquals("photo-8.png", PhotoPlan.nameFor(photo(8, name = "_x.png"))) + assertEquals("photo-9.jpg", PhotoPlan.nameFor(photo(9, name = ""))) + assertTrue(PhotoPlan.UPLOAD_NAME.matches("PXL_20261009_120000123.jpg")) + assertFalse(PhotoPlan.UPLOAD_NAME.matches("a.jpg.")) + } + + @Test fun `an edited name stays within the node's length`() { + val long = photo(1, name = "A".repeat(124) + ".jpg", modified = 1) + val name = PhotoPlan.editedName(long, utc) + assertTrue(name.length <= 128) + assertTrue(PhotoPlan.UPLOAD_NAME.matches(name)) + } + + // ── when ───────────────────────────────────────────────────────────────── + + @Test fun `once a day, counted from the last run that finished`() { + assertTrue(PhotoPlan.due(null, oct9)) + assertFalse(PhotoPlan.due(oct9 - 3600_000, oct9)) + assertTrue(PhotoPlan.due(oct9 - PhotoPlan.DAY_MS, oct9)) + assertTrue("a clock moved back must not stop backups for good", PhotoPlan.due(oct9 + 3600_000, oct9)) + } + + // ── settings from the page ─────────────────────────────────────────────── + + @Test fun `a folder that is not one of the group's own is refused`() { + for (bad in listOf("", "../etc", "Media/../x", "Media//x", "/")) { + val o = JSONObject().put("account", "bob").put("groupId", "g1").put("folder", bad) + assertThrows(bad, IllegalArgumentException::class.java) { SyncConfig.fromJson(o, 0) } + } + } + + @Test fun `changing the albums keeps the starting point, changing the group moves it`() { + val o = JSONObject().put("account", "bob").put("groupId", "g1").put("folder", "Media/Photos") + .put("albums", JSONArray(listOf("cam"))).put("includeExisting", false) + val first = SyncConfig.fromJson(o, 100) + assertEquals(100, SyncConfig.fromJson(o.put("albums", JSONArray(listOf("cam", "x"))), 200, first).since) + assertEquals(300, SyncConfig.fromJson(o.put("groupId", "g2"), 300, first).since) + } + + @Test fun `settings survive being stored`() { + val c = config(albums = listOf("a", "b")) + assertEquals(c, SyncConfig.parse(c.toJson().toString())) + assertNull(SyncConfig.parse("{}")) + } + + // ── the ledger ─────────────────────────────────────────────────────────── + + @Test fun `the ledger remembers across a restart, last line wins`() { + val f = tmp.newFile("l.jsonl") + PhotoLedger(f).apply { + record(entry(photo(1), sha = "a")) + record(entry(photo(2), sha = "b")) + record(entry(photo(1), sha = "c")) + } + val again = PhotoLedger(f) + assertEquals(2, again.size) + assertEquals("c", again[1]!!.sha256) + } + + @Test fun `a line cut short by a killed process costs that one photo, not the ledger`() { + val f = tmp.newFile("l.jsonl") + PhotoLedger(f).record(entry(photo(1))) + f.appendText("{\"id\":2,\"m\":") + val again = PhotoLedger(f) + assertEquals(1, again.size) + assertNull(again[2]) + } + + @Test fun `a ledger rewritten many times is compacted on load`() { + val f = tmp.newFile("l.jsonl") + val l = PhotoLedger(f) + repeat(300) { l.record(entry(photo(1), sha = "s$it")) } + val again = PhotoLedger(f) + assertEquals(1, again.size) + assertEquals("s299", again[1]!!.sha256) + assertEquals(1, f.readLines().count { it.isNotBlank() }) + } +} -- cgit v1.2.3