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 --- docs/MESHBAY_DESIGN.md | 19 +++++- docs/USERGUIDE.md | 7 ++- .../app/src/main/AndroidManifest.xml | 2 + .../app/src/main/assets/bridge/meshbay-bridge.js | 4 +- .../main/kotlin/org/meshbay/client/MainActivity.kt | 3 +- .../kotlin/org/meshbay/client/photos/ByteRange.kt | 40 +++++++++++++ .../org/meshbay/client/photos/PhotoChannels.kt | 69 ++++++++++++++-------- .../kotlin/org/meshbay/client/photos/PhotoPlan.kt | 19 ++++-- .../org/meshbay/client/photos/PhotoSource.kt | 65 +++++++++++++------- .../kotlin/org/meshbay/client/ByteRangeTest.kt | 31 ++++++++++ .../kotlin/org/meshbay/client/PhotoSyncTest.kt | 30 ++++++++-- .../src/meshbay_hub/static/locales/de.js | 5 ++ .../src/meshbay_hub/static/locales/en.js | 5 ++ .../src/meshbay_hub/static/locales/es.js | 5 ++ .../src/meshbay_hub/static/locales/fr.js | 5 ++ .../src/meshbay_hub/static/locales/it.js | 5 ++ .../src/meshbay_hub/static/locales/ja.js | 5 ++ .../src/meshbay_hub/static/locales/nl.js | 5 ++ .../src/meshbay_hub/static/locales/pl.js | 5 ++ .../src/meshbay_hub/static/locales/pt-BR.js | 5 ++ .../src/meshbay_hub/static/locales/zh-CN.js | 5 ++ .../src/meshbay_hub/static/photo-sync-settings.js | 57 ++++++++++++++---- .../src/meshbay_hub/static/photo-sync.js | 2 +- .../meshbay-hub/src/meshbay_hub/static/platform.js | 28 ++++++--- packages/meshbay-hub/tests/test_photo_sync.py | 30 +++++++++- packages/meshbay-hub/tests/test_photo_sync_ui.py | 5 ++ 26 files changed, 379 insertions(+), 82 deletions(-) create mode 100644 packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/ByteRange.kt create mode 100644 packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ByteRangeTest.kt diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index a30215d..5ba1980 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -3414,10 +3414,23 @@ the edit is sent **beside the original**, as `-edited-. + + 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/` 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/` 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() 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 { + /** Every album with photos in it, and with videos too when `withVideos`. */ + fun albums(withVideos: Boolean): List { val by = LinkedHashMap() - 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 { 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 { it.camera }.thenByDescending { it.count + it.videos }) } - fun photos(albums: List): List { + fun photos(albums: List, withVideos: Boolean): 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 } + 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?, each: (Photo, String, Boolean) -> Unit) { + private fun query(collection: Uri, video: Boolean, where: String?, args: Array?, + 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 = 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()) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 5f508da..ca15345 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -1313,6 +1313,11 @@ export default { 'photosync.albums': "Alben auf diesem Telefon", 'photosync.album_camera': "Kamera", 'photosync.album_count': "{n} Fotos · {size}", + 'photosync.include_videos': "Auch Videos", + 'photosync.include_videos_hint': "Videos sind groß: Sie werden wie Fotos über WLAN gesendet, und die erste Sicherung kann dauern.", + 'photosync.album_count_videos': "{n} Fotos, {v} Videos · {size}", + 'photosync.confirm_videos': "{count} Fotos und Videos ({size}), davon {videos} Videos ({videoSize}), werden nach {dir} gesendet, danach einmal täglich die neuen.", + 'photosync.video_permission_needed': "MeshBay braucht auch Zugriff auf die Videos dieses Telefons.", 'photosync.scope_existing': "Die Fotos, die schon auf diesem Telefon sind, dann neue", 'photosync.scope_new': "Nur Fotos, die ab jetzt aufgenommen werden", 'photosync.start': "Sicherung starten", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 6b7d73f..8619bdb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -1294,6 +1294,11 @@ export default { 'photosync.albums': "Albums on this phone", 'photosync.album_camera': "Camera", 'photosync.album_count': "{n} photos · {size}", + 'photosync.include_videos': "Videos too", + 'photosync.include_videos_hint': "Videos are large: they go on Wi-Fi like photos, and the first backup can take a while.", + 'photosync.album_count_videos': "{n} photos, {v} videos · {size}", + 'photosync.confirm_videos': "{count} photos and videos ({size}), including {videos} videos ({videoSize}), will be sent to {dir}, then new ones once a day.", + 'photosync.video_permission_needed': "MeshBay needs access to this phone's videos too.", 'photosync.scope_existing': "The photos already on this phone, then new ones", 'photosync.scope_new': "Only photos taken from now on", 'photosync.start': "Start backup", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index f2c1cfc..19db8f7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -1307,6 +1307,11 @@ export default { 'photosync.albums': "Álbumes de este teléfono", 'photosync.album_camera': "Cámara", 'photosync.album_count': "{n} fotos · {size}", + 'photosync.include_videos': "También los vídeos", + 'photosync.include_videos_hint': "Los vídeos ocupan mucho: se envían por Wi-Fi como las fotos, y la primera copia puede tardar.", + 'photosync.album_count_videos': "{n} fotos, {v} vídeos · {size}", + 'photosync.confirm_videos': "Se enviarán {count} fotos y vídeos ({size}), de ellos {videos} vídeos ({videoSize}), a {dir} y luego los nuevos una vez al día.", + 'photosync.video_permission_needed': "MeshBay también necesita acceso a los vídeos de este teléfono.", 'photosync.scope_existing': "Las fotos que ya están en este teléfono y luego las nuevas", 'photosync.scope_new': "Solo las fotos tomadas a partir de ahora", 'photosync.start': "Iniciar la copia", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 3b5b881..3f3cd42 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -1322,6 +1322,11 @@ export default { 'photosync.albums': "Albums de ce téléphone", 'photosync.album_camera': "Appareil photo", 'photosync.album_count': "{n} photos · {size}", + 'photosync.include_videos': "Les vidéos aussi", + 'photosync.include_videos_hint': "Les vidéos sont lourdes : elles partent en Wi-Fi comme les photos, et la première sauvegarde peut être longue.", + 'photosync.album_count_videos': "{n} photos, {v} vidéos · {size}", + 'photosync.confirm_videos': "{count} photos et vidéos ({size}), dont {videos} vidéos ({videoSize}), seront envoyées dans {dir}, puis les nouvelles une fois par jour.", + 'photosync.video_permission_needed': "MeshBay a aussi besoin d'accéder aux vidéos de ce téléphone.", 'photosync.scope_existing': "Les photos déjà sur ce téléphone, puis les nouvelles", 'photosync.scope_new': "Seulement les photos prises à partir de maintenant", 'photosync.start': "Lancer la sauvegarde", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 9935edb..f238495 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -1321,6 +1321,11 @@ export default { 'photosync.albums': "Album su questo telefono", 'photosync.album_camera': "Fotocamera", 'photosync.album_count': "{n} foto · {size}", + 'photosync.include_videos': "Anche i video", + 'photosync.include_videos_hint': "I video sono pesanti: partono in Wi-Fi come le foto, e il primo backup può richiedere tempo.", + 'photosync.album_count_videos': "{n} foto, {v} video · {size}", + 'photosync.confirm_videos': "{count} foto e video ({size}), di cui {videos} video ({videoSize}), verranno inviati in {dir}, poi quelli nuovi una volta al giorno.", + 'photosync.video_permission_needed': "MeshBay ha bisogno di accedere anche ai video di questo telefono.", 'photosync.scope_existing': "Le foto già su questo telefono, poi quelle nuove", 'photosync.scope_new': "Solo le foto scattate da ora in poi", 'photosync.start': "Avvia il backup", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 2af2f49..6a59d1d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -1305,6 +1305,11 @@ export default { 'photosync.albums': "この電話のアルバム", 'photosync.album_camera': "カメラ", 'photosync.album_count': "{n} 枚 · {size}", + 'photosync.include_videos': "動画も含める", + 'photosync.include_videos_hint': "動画はサイズが大きいため、写真と同じく Wi-Fi で送信され、最初のバックアップには時間がかかることがあります。", + 'photosync.album_count_videos': "写真 {n} 枚、動画 {v} 本 · {size}", + 'photosync.confirm_videos': "写真と動画 {count} 件 ({size}、うち動画 {videos} 本 {videoSize}) を {dir} に送り、その後は新しいものを1日1回送ります。", + 'photosync.video_permission_needed': "MeshBay にこのスマートフォンの動画へのアクセスも許可してください。", 'photosync.scope_existing': "この電話にすでにある写真と、これからの写真", 'photosync.scope_new': "これから撮る写真のみ", 'photosync.start': "バックアップを開始", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 9e83c86..4b68975 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -1323,6 +1323,11 @@ export default { 'photosync.albums': "Albums op deze telefoon", 'photosync.album_camera': "Camera", 'photosync.album_count': "{n} foto's · {size}", + 'photosync.include_videos': "Ook video's", + 'photosync.include_videos_hint': "Video's zijn groot: ze gaan net als foto's via wifi, en de eerste back-up kan even duren.", + 'photosync.album_count_videos': "{n} foto's, {v} video's · {size}", + 'photosync.confirm_videos': "{count} foto's en video's ({size}), waarvan {videos} video's ({videoSize}), worden naar {dir} gestuurd, daarna eenmaal per dag de nieuwe.", + 'photosync.video_permission_needed': "MeshBay heeft ook toegang nodig tot de video's op deze telefoon.", 'photosync.scope_existing': "De foto's die al op deze telefoon staan, daarna nieuwe", 'photosync.scope_new': "Alleen foto's die vanaf nu worden gemaakt", 'photosync.start': "Back-up starten", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index cb0d0b6..8b1b5db 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -1349,6 +1349,11 @@ export default { 'photosync.albums': "Albumy na tym telefonie", 'photosync.album_camera': "Aparat", 'photosync.album_count': "Zdjęcia: {n} · {size}", + 'photosync.include_videos': "Również filmy", + 'photosync.include_videos_hint': "Filmy są duże: wysyłane są przez Wi-Fi jak zdjęcia, a pierwsza kopia może potrwać.", + 'photosync.album_count_videos': "Zdjęcia: {n}, filmy: {v} · {size}", + 'photosync.confirm_videos': "Zdjęcia i filmy ({count}, {size}), w tym filmy: {videos} ({videoSize}), zostaną wysłane do {dir}, a potem nowe raz dziennie.", + 'photosync.video_permission_needed': "MeshBay potrzebuje też dostępu do filmów w tym telefonie.", 'photosync.scope_existing': "Zdjęcia, które już są na tym telefonie, a potem nowe", 'photosync.scope_new': "Tylko zdjęcia zrobione od teraz", 'photosync.start': "Rozpocznij kopię", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index cb3aa70..9fc634b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -1308,6 +1308,11 @@ export default { 'photosync.albums': "Álbuns deste celular", 'photosync.album_camera': "Câmera", 'photosync.album_count': "{n} fotos · {size}", + 'photosync.include_videos': "Vídeos também", + 'photosync.include_videos_hint': "Vídeos são pesados: vão pelo Wi-Fi como as fotos, e o primeiro backup pode demorar.", + 'photosync.album_count_videos': "{n} fotos, {v} vídeos · {size}", + 'photosync.confirm_videos': "{count} fotos e vídeos ({size}), sendo {videos} vídeos ({videoSize}), serão enviados para {dir} e depois os novos uma vez por dia.", + 'photosync.video_permission_needed': "O MeshBay também precisa de acesso aos vídeos deste celular.", 'photosync.scope_existing': "As fotos que já estão neste celular, depois as novas", 'photosync.scope_new': "Somente as fotos tiradas a partir de agora", 'photosync.start': "Iniciar backup", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index bb47b00..5859934 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -1294,6 +1294,11 @@ export default { 'photosync.albums': "这部手机上的相册", 'photosync.album_camera': "相机", 'photosync.album_count': "{n} 张照片 · {size}", + 'photosync.include_videos': "包括视频", + 'photosync.include_videos_hint': "视频体积较大:它们和照片一样通过 Wi-Fi 发送,首次备份可能需要一些时间。", + 'photosync.album_count_videos': "{n} 张照片,{v} 个视频 · {size}", + 'photosync.confirm_videos': "将把 {count} 个照片和视频({size},其中 {videos} 个视频,{videoSize})发送到 {dir},之后每天发送一次新的。", + 'photosync.video_permission_needed': "MeshBay 还需要访问本机的视频。", 'photosync.scope_existing': "手机上已有的照片,然后是新照片", 'photosync.scope_new': "仅从现在起拍摄的照片", 'photosync.start': "开始备份", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js index 7809efa..088de76 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js @@ -105,6 +105,11 @@ export function PhotoSyncSection({ destination }) {

${status.permission === 'partial' && html`

${t('photosync.permission_partial')}

`} + ${cfg.includeVideos && status.videoPermission === 'denied' && html` +

${t('photosync.video_permission_needed')}

+ `} ${status.failure ? html`

${t(FAILURES[status.failure] || 'photosync.fail_other')}

` : stateLine(run, status) && html`

${stateLine(run, status)}

`} @@ -129,19 +134,32 @@ function Setup({ status, onPermission, onDone }) { const [albums, setAlbums] = useState(null); const [chosen, setChosen] = useState(() => new Set(cfg ? cfg.albums : [])); const [existing, setExisting] = useState(cfg ? cfg.includeExisting : true); + const [videos, setVideos] = useState(cfg ? !!cfg.includeVideos : false); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const permitted = status.permission !== 'denied'; + const videosAllowed = status.videoPermission !== 'denied'; useEffect(() => { if (!permitted) return; - platform.photoSync.albums().then((list) => { + platform.photoSync.albums(videos && videosAllowed).then((list) => { setAlbums(list); // The camera, and only the camera, unless the person chose before: // screenshots and saved images are where photos nobody meant to share live. - if (!cfg) setChosen(new Set(list.filter((a) => a.camera).map((a) => a.id))); + if (!cfg) { + setChosen((prev) => (prev.size ? prev : new Set(list.filter((a) => a.camera).map((a) => a.id)))); + } }).catch((e) => setError(e.message)); - }, [permitted]); + }, [permitted, videos, videosAllowed]); + + // Ticking videos asks for them then, not before: Android 13+ asks apart. + const toggleVideos = async () => { + const next = !videos; + setVideos(next); + if (next && !videosAllowed) { + try { onPermission(await platform.photoSync.permit(true)); } catch (e) { setError(e.message); } + } + }; const allow = async () => { setError(''); @@ -159,14 +177,19 @@ function Setup({ status, onPermission, onDone }) { setBusy(true); setError(''); try { - const settings = { albums: [...chosen], includeExisting: existing }; - // Asked when the photos would come from somewhere new in time; never - // for a change of albums alone, which the person just made. - if (!cfg || cfg.includeExisting !== existing) { + const settings = { albums: [...chosen], includeExisting: existing, includeVideos: videos }; + // Asked when the photos would come from somewhere new in time, or when + // videos start going; never for a change of albums alone, which the + // person just made. + if (!cfg || cfg.includeExisting !== existing || (videos && !cfg.includeVideos)) { const est = await platform.photoSync.estimate(settings); - const params = { count: est.count, size: formatSize(est.bytes), dir: status.dir }; - const yes = await ask(t(existing && est.count > 0 ? 'photosync.confirm' - : 'photosync.confirm_new', params)); + const params = { + count: est.count, size: formatSize(est.bytes), dir: status.dir, + videos: est.videos || 0, videoSize: formatSize(est.videoBytes || 0), + }; + const key = !(est.count > 0) ? 'photosync.confirm_new' + : est.videos > 0 ? 'photosync.confirm_videos' : 'photosync.confirm'; + const yes = await ask(t(key, params)); if (!yes) { setBusy(false); return; } } onDone(await platform.photoSync.configure(settings)); @@ -194,10 +217,22 @@ function Setup({ status, onPermission, onDone }) { ${a.camera ? t('photosync.album_camera') : a.name} - ${t('photosync.album_count', { n: a.count, size: formatSize(a.bytes) })} + ${videos && a.videos + ? t('photosync.album_count_videos', + { n: a.count, v: a.videos, size: formatSize(a.bytes + a.videoBytes) }) + : t('photosync.album_count', { n: a.count, size: formatSize(a.bytes) })} `)} + + ${videos && !videosAllowed && html` +

${t('photosync.video_permission_needed')}

`}