diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-10 15:11:52 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-10 15:11:52 +0200 |
| commit | 1fd7bbcd038cddf40540bae7083b9adf4c728912 (patch) | |
| tree | 00574a2ce5c3f592c71d5a339dde9fb263636eeb /packages/meshbay-android | |
| parent | 3bad2b50187f55463588a2d4e74fa9df4beb35b8 (diff) | |
| download | meshbay-1fd7bbcd038cddf40540bae7083b9adf4c728912.tar.gz | |
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-android')
9 files changed, 207 insertions, 56 deletions
diff --git a/packages/meshbay-android/app/src/main/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml index f52c06f..6b97b45 100644 --- a/packages/meshbay-android/app/src/main/AndroidManifest.xml +++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml @@ -18,6 +18,8 @@ location from the bytes this application reads, so a camera roll sent to a group does not say where its owner lives. --> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> + <!-- Videos of the same albums, when the person ticks them in: asked then. --> + <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> diff --git a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js index 9d313bf..80e0e34 100644 --- a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js +++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js @@ -207,8 +207,8 @@ // 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'), + permit: (withVideos) => call('photosync:permit', withVideos === true), + albums: (withVideos) => call('photosync:albums', withVideos === true), configure: (settings) => call('photosync:configure', settings || null), estimate: (settings) => call('photosync:estimate', settings), plan: () => call('photosync:plan'), 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 a0b1f71..0382534 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 @@ -176,7 +176,8 @@ 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.takeIf { backups }?.serve(url.path ?: "") ?: refused() + val range = request.requestHeaders.entries.firstOrNull { it.key.equals("Range", true) }?.value + return photos.takeIf { backups }?.serve(url.path ?: "", range) ?: refused() } if (url.host == UiAssets.HOST && url.path?.startsWith(DocChannels.PATH) == true) { val path = url.path ?: "" diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/ByteRange.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/ByteRange.kt new file mode 100644 index 0000000..001bea2 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/ByteRange.kt @@ -0,0 +1,40 @@ +package org.meshbay.client.photos + +import java.io.FilterInputStream +import java.io.InputStream + +/** + * One `Range: bytes=a-b` of a file the page fetches a piece at a time, so a + * video of gigabytes never sits whole in the page's memory: the upload reads + * one chunk, sends it, and asks for the next. + */ +object ByteRange { + /** The first and last byte asked for, inside `size`; null for no range or one this does not serve. */ + fun parse(header: String?, size: Long): LongRange? { + val m = Regex("^bytes=(\\d+)-(\\d*)$").matchEntire(header?.trim() ?: return null) ?: return null + val first = m.groupValues[1].toLongOrNull() ?: return null + val last = m.groupValues[2].takeIf { it.isNotEmpty() }?.toLongOrNull() ?: (size - 1) + if (first >= size || last < first) return null + return first..minOf(last, size - 1) + } + + /** `stream` from `range.first`, ending after `range.last`. */ + fun slice(stream: InputStream, range: LongRange): InputStream { + var toSkip = range.first + while (toSkip > 0) { + val n = stream.skip(toSkip) + if (n <= 0) { if (stream.read() < 0) break; toSkip -= 1 } else toSkip -= n + } + return object : FilterInputStream(stream) { + private var left = range.last - range.first + 1 + override fun read(): Int { + if (left <= 0) return -1 + return super.read().also { if (it >= 0) left -= 1 } + } + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (left <= 0) return -1 + return super.read(b, off, minOf(len.toLong(), left).toInt()).also { if (it > 0) left -= it } + } + } + } +} 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 index 3eb0ead..78f57cb 100644 --- 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 @@ -75,8 +75,8 @@ class PhotoChannels( fun call(channel: String, args: JSONArray): Any? = when (channel) { "photosync:status" -> status() - "photosync:permit" -> { permit(); status() } - "photosync:albums" -> { requirePermission(); albums() } + "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() } @@ -104,6 +104,7 @@ class PhotoChannels( 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) @@ -154,9 +155,10 @@ class PhotoChannels( // ── 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)) + 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. */ @@ -164,15 +166,17 @@ class PhotoChannels( 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, PhotoPlan.baseFor(d), ledger::get) { _, _ -> true } + 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, PhotoPlan.baseFor(d), ledger::get) { p, sent -> - hashOf(p.mediaId)?.let { it == sent.sha256 } ?: true + 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() @@ -182,6 +186,7 @@ class PhotoChannels( 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()))) @@ -194,19 +199,35 @@ class PhotoChannels( 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.mediaId) ?: throw Refused("Refused: the photo is gone") + val sha = issued.sha256 ?: hashOf(p) ?: throw Refused("Refused: the photo is gone") ledger(d).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/<token>` on the packaged - * origin. Hashed as they go out, so the ledger records exactly what was sent. + * The bytes of an issued photo or video, for `/photosync/<token>` 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): WebResourceResponse? { + fun serve(path: String, range: String? = null): 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 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) { + val size = issued.pending.photo.size + val r = ByteRange.parse(range, size) ?: run { + raw.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()) + return WebResourceResponse(mime, null, 206, "Partial Content", headers, ByteRange.slice(raw, r)) + } val digest = MessageDigest.getInstance("SHA-256") val stream = object : FilterInputStream(raw) { private var done = false @@ -216,20 +237,21 @@ class PhotoChannels( 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) + return WebResourceResponse(mime, null, 200, "OK", headers, stream) } - private fun hashOf(mediaId: Long): String? = try { - source.open(mediaId)?.use { s -> hex(digestOf(s)) } + private fun hashOf(p: Photo): String? = try { + source.open(p)?.use { s -> hex(digestOf(s)) } } catch (e: Exception) { null } // ── permission and network ─────────────────────────────────────────────── - private fun permissionState(): String { + /** 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(Manifest.permission.READ_MEDIA_IMAGES) -> "granted" + 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" @@ -241,11 +263,12 @@ class PhotoChannels( } /** Asks, and waits for the answer: the page goes on from what was decided. */ - private fun permit() { + 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 -> arrayOf(Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) - Build.VERSION.SDK_INT >= 33 -> arrayOf(Manifest.permission.READ_MEDIA_IMAGES) + 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) 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 index ee0275e..a8bf3b5 100644 --- 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 @@ -21,6 +21,8 @@ data class Photo( val modified: Long, val bucketId: String, val mime: String, + /** From MediaStore's video collection rather than its images. */ + val video: Boolean = false, ) /** @@ -33,9 +35,12 @@ data class SyncConfig( val includeExisting: Boolean, /** When it was set up or moved, ms: with `includeExisting` off, only photos added since count. */ val since: Long, + /** The videos of the same albums too, beside the photos. Off by default: they are large. */ + val includeVideos: Boolean = false, ) { fun toJson(): JSONObject = JSONObject() .put("albums", JSONArray(albums)).put("includeExisting", includeExisting).put("since", since) + .put("includeVideos", includeVideos) companion object { /** From the page, so checked like any input. */ @@ -49,6 +54,7 @@ data class SyncConfig( (0 until albums.length()).map { albums.optString(it, "").take(64) }.filter { it.isNotEmpty() }.distinct(), includeExisting, if (same) previous!!.since else now, + o.optBoolean("includeVideos", false), ) } @@ -56,7 +62,8 @@ data class SyncConfig( val o = JSONObject(text ?: return null) val albums = o.getJSONArray("albums") SyncConfig((0 until albums.length()).map { albums.getString(it) }, - o.optBoolean("includeExisting", true), o.getLong("since")) + o.optBoolean("includeExisting", true), o.getLong("since"), + o.optBoolean("includeVideos", false)) } catch (e: Exception) { null } } } @@ -92,7 +99,7 @@ object PhotoPlan { val out = ArrayList<Pending>() for (p in photos) { if (p.bucketId !in albums) continue - if (!p.mime.startsWith("image/")) continue + if (!(if (p.video) config.includeVideos && p.mime.startsWith("video/") else p.mime.startsWith("image/"))) continue val sent = ledger(p.mediaId) if (sent == null) { if (!config.includeExisting && p.added * 1000 < config.since) continue @@ -120,7 +127,8 @@ object PhotoPlan { } fun nameFor(p: Photo): String = - p.displayName.takeIf { UPLOAD_NAME.matches(it) } ?: "photo-${p.mediaId}.${extension(p)}" + p.displayName.takeIf { UPLOAD_NAME.matches(it) } + ?: "${if (p.video) "video" else "photo"}-${p.mediaId}.${extension(p)}" /** * An edit lands beside the original under a name that says what it is; left @@ -140,7 +148,10 @@ object PhotoPlan { 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" } + "image/webp" -> "webp"; "image/gif" -> "gif" + "video/mp4" -> "mp4"; "video/quicktime" -> "mov"; "video/3gpp" -> "3gp" + "video/webm" -> "webm"; "video/x-matroska" -> "mkv" + else -> if (p.video) "mp4" else "jpg" } private const val MAX_NAME = 128 diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt index f64612f..fe0d183 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt @@ -8,54 +8,75 @@ import android.provider.MediaStore import java.io.InputStream /** - * The phone's photos, through MediaStore and nothing else. + * The phone's photos and videos, through MediaStore and nothing else. * * Opened through MediaStore by an application without ACCESS_MEDIA_LOCATION, - * a photo's EXIF location is **redacted by the platform** (Android 10+): a whole - * camera roll going to several people does not say where its owner lives, and - * nobody had to do anything for that. The manifest does not ask for it. + * a file's location is **redacted by the platform** (Android 10+): the EXIF of + * a photo, and the location boxes of an MP4 or MOV video (MediaProvider's + * IsoInterface). A whole camera roll going to several people does not say + * where its owner lives, and nobody had to do anything for that. The manifest + * does not ask for it. + * + * An album is a MediaStore bucket, one directory on the phone; photos and + * videos in it share its id. */ class PhotoSource(private val context: Context) { - data class Album(val id: String, val name: String, val count: Int, val bytes: Long, val camera: Boolean) + data class Album( + val id: String, val name: String, val count: Int, val bytes: Long, val camera: Boolean, + val videos: Int = 0, val videoBytes: Long = 0, + ) - private val collection: Uri = + private val images: Uri = if (Build.VERSION.SDK_INT >= 29) MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) else MediaStore.Images.Media.EXTERNAL_CONTENT_URI + private val videos: Uri = + if (Build.VERSION.SDK_INT >= 29) MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) + else MediaStore.Video.Media.EXTERNAL_CONTENT_URI - fun albums(): List<Album> { + /** Every album with photos in it, and with videos too when `withVideos`. */ + fun albums(withVideos: Boolean): List<Album> { val by = LinkedHashMap<String, Album>() - query(null, null) { p, name, camera -> + query(images, false, 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<Album> { it.camera }.thenByDescending { it.count }) + if (withVideos) query(videos, true, null, null) { p, name, camera -> + val a = by[p.bucketId] ?: Album(p.bucketId, name, 0, 0, camera) + by[p.bucketId] = a.copy(videos = a.videos + 1, videoBytes = a.videoBytes + p.size, + camera = a.camera || camera) + } + return by.values.sortedWith(compareByDescending<Album> { it.camera }.thenByDescending { it.count + it.videos }) } - fun photos(albums: List<String>): List<Photo> { + fun photos(albums: List<String>, withVideos: Boolean): List<Photo> { if (albums.isEmpty()) return emptyList() val out = ArrayList<Photo>() val where = "${MediaStore.Images.Media.BUCKET_ID} IN (${albums.joinToString(",") { "?" }})" - query(where, albums.toTypedArray()) { p, _, _ -> out += p } + query(images, false, where, albums.toTypedArray()) { p, _, _ -> out += p } + if (withVideos) query(videos, true, where, albums.toTypedArray()) { p, _, _ -> out += p } return out } - fun open(mediaId: Long): InputStream? = - context.contentResolver.openInputStream(ContentUris.withAppendedId(collection, mediaId)) + fun open(p: Photo): InputStream? = + context.contentResolver.openInputStream(ContentUris.withAppendedId(if (p.video) videos else images, p.mediaId)) - private fun query(where: String?, args: Array<String>?, each: (Photo, String, Boolean) -> Unit) { + private fun query(collection: Uri, video: Boolean, where: String?, args: Array<String>?, + each: (Photo, String, Boolean) -> Unit) { + // The same columns exist for images and videos (MediaColumns), and on + // Android 8 and 9 under the same names in each collection. 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, + MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.SIZE, + MediaStore.Images.Media.DATE_TAKEN, MediaStore.MediaColumns.DATE_ADDED, + MediaStore.MediaColumns.DATE_MODIFIED, MediaStore.Images.Media.BUCKET_ID, + MediaStore.Images.Media.BUCKET_DISPLAY_NAME, MediaStore.MediaColumns.MIME_TYPE, ) @Suppress("DEPRECATION") - val location = if (Build.VERSION.SDK_INT >= 29) MediaStore.Images.Media.RELATIVE_PATH else MediaStore.Images.Media.DATA + val location = if (Build.VERSION.SDK_INT >= 29) MediaStore.MediaColumns.RELATIVE_PATH else MediaStore.MediaColumns.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 + // A photo or video still being written by the camera is not one yet. + val pending = if (Build.VERSION.SDK_INT >= 29) "${MediaStore.MediaColumns.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]) @@ -68,7 +89,7 @@ class PhotoSource(private val context: Context) { 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) ?: "", + bucketId, c.getString(mime) ?: "", video, ) val path = (c.getString(where2) ?: "").replace('\\', '/') each(photo, c.getString(bucketName) ?: "", isCamera(path)) 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<String> = listOf("cam")) = - SyncConfig(albums, includeExisting, since) + private fun config(includeExisting: Boolean = true, since: Long = 0, albums: List<String> = 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()) |