diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-09 18:23:52 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-09 18:23:56 +0200 |
| commit | 25152ddb61a89ea3ea29d3aef5de13343429d32a (patch) | |
| tree | ebf8f11f66146caaa3c01270cd6cfcb0ee51723c /packages/meshbay-android/app/src/test/kotlin | |
| parent | c85c7f48e8f29923038d4c90cc1a6f9b8bcd7673 (diff) | |
| download | meshbay-25152ddb61a89ea3ea29d3aef5de13343429d32a.tar.gz | |
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')
| -rw-r--r-- | packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt | 182 |
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() }) + } +} |