From 1fd7bbcd038cddf40540bae7083b9adf4c728912 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 10 Oct 2026 15:11:52 +0200 Subject: feat(android): back the videos of the chosen albums up beside the photos A 'Videos too' option, off by default, sends them into the same YYYY/YYYY-MM folders. Files are read from the phone a ranged chunk at a time, so a large video is never whole in the page. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/org/meshbay/client/ByteRangeTest.kt | 31 ++++++++++++++++++++++ .../kotlin/org/meshbay/client/PhotoSyncTest.kt | 30 ++++++++++++++++++--- 2 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ByteRangeTest.kt (limited to 'packages/meshbay-android/app/src/test/kotlin/org/meshbay') diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ByteRangeTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ByteRangeTest.kt new file mode 100644 index 0000000..29d8528 --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ByteRangeTest.kt @@ -0,0 +1,31 @@ +package org.meshbay.client + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.meshbay.client.photos.ByteRange + +class ByteRangeTest { + @Test fun `a range is read as the upload asks for it`() { + assertEquals(0L..1023L, ByteRange.parse("bytes=0-1023", 5000)) + assertEquals(4096L..4999L, ByteRange.parse("bytes=4096-", 5000)) + assertEquals("the last chunk may ask past the end", 4096L..4999L, ByteRange.parse("bytes=4096-8191", 5000)) + } + + @Test fun `what cannot be served is refused`() { + assertNull(ByteRange.parse(null, 10)) + assertNull(ByteRange.parse("bytes=10-20", 10)) + assertNull(ByteRange.parse("bytes=5-2", 10)) + assertNull(ByteRange.parse("bytes=0-1,4-5", 10)) + assertNull(ByteRange.parse("bytes=-5", 10)) + assertNull(ByteRange.parse("items=0-1", 10)) + } + + @Test fun `a slice holds exactly those bytes`() { + val data = ByteArray(100_000) { (it % 251).toByte() } + val got = ByteRange.slice(data.inputStream(), 70_000L..99_999L).readBytes() + assertArrayEquals(data.copyOfRange(70_000, 100_000), got) + assertArrayEquals(data.copyOfRange(10, 12), ByteRange.slice(data.inputStream(), 10L..11L).readBytes()) + } +} 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 index 7c0b794..a87bffb 100644 --- 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 @@ -25,11 +25,12 @@ class PhotoSyncTest { 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) + mime: String = "image/jpeg", video: Boolean = false) = + Photo(id, name, size, taken, added, modified, bucket, mime, video) - private fun config(includeExisting: Boolean = true, since: Long = 0, albums: List = listOf("cam")) = - SyncConfig(albums, includeExisting, since) + private fun config(includeExisting: Boolean = true, since: Long = 0, albums: List = listOf("cam"), + videos: Boolean = false) = + SyncConfig(albums, includeExisting, since, videos) private val base = PhotoPlan.baseFor(Destination("bob", "g1", "Mine", "Backups")) @@ -61,6 +62,27 @@ class PhotoSyncTest { assertEquals(listOf(1L), out.map { it.photo.mediaId }) } + @Test fun `videos of the same albums go beside the photos, only when asked for`() { + val clip = photo(5, name = "PXL_20261009_120000.mp4", mime = "video/mp4", video = true) + assertTrue(plan(listOf(clip)).isEmpty()) + val out = plan(listOf(photo(1), clip), config(videos = true)) + assertEquals(setOf(1L, 5L), out.map { it.photo.mediaId }.toSet()) + assertEquals("Backups/bob-photos/2026/2026-10", out.first { it.photo.video }.dir) + assertTrue("a video file is not taken for a photo", plan(listOf(photo(6, mime = "video/mp4")), config(videos = true)).isEmpty()) + assertEquals("video-9.mp4", PhotoPlan.nameFor(photo(9, name = "_clip.mp4", mime = "video/mp4", video = true))) + assertEquals("video-9.mov", PhotoPlan.nameFor(photo(9, name = "", mime = "video/quicktime", video = true))) + } + + @Test fun `ticking videos in keeps the starting point`() { + val o = JSONObject().put("albums", JSONArray(listOf("cam"))).put("includeExisting", false) + val first = SyncConfig.fromJson(o, 100) + val withVideos = SyncConfig.fromJson(o.put("includeVideos", true), 200, first) + assertEquals(100, withVideos.since) + assertTrue(withVideos.includeVideos) + assertEquals(withVideos, SyncConfig.parse(withVideos.toJson().toString())) + assertFalse(SyncConfig.parse("""{"albums":[],"since":1}""")!!.includeVideos) + } + @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()) -- cgit v1.2.3