aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-09 18:23:52 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-09 18:23:56 +0200
commit25152ddb61a89ea3ea29d3aef5de13343429d32a (patch)
treeebf8f11f66146caaa3c01270cd6cfcb0ee51723c /packages/meshbay-android/app/src/test/kotlin/org/meshbay/client
parentc85c7f48e8f29923038d4c90cc1a6f9b8bcd7673 (diff)
downloadmeshbay-25152ddb61a89ea3ea29d3aef5de13343429d32a.tar.gz
feat: back up the phone's photos to a group, once a day on Wi-FiHEADmain
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 <folder>/YYYY/MM; edits are sent beside the original as -edited-<date>. - 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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-android/app/src/test/kotlin/org/meshbay/client')
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt182
1 files changed, 182 insertions, 0 deletions
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<String> = 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<Photo>, c: SyncConfig = config(), ledger: Map<Long, PhotoLedger.Entry> = 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() })
+ }
+}