aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-10 15:11:52 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 15:11:52 +0200
commit1fd7bbcd038cddf40540bae7083b9adf4c728912 (patch)
tree00574a2ce5c3f592c71d5a339dde9fb263636eeb /packages/meshbay-android
parent3bad2b50187f55463588a2d4e74fa9df4beb35b8 (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-android/app/src/main/AndroidManifest.xml2
-rw-r--r--packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js4
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt3
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/ByteRange.kt40
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoChannels.kt69
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoPlan.kt19
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoSource.kt65
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ByteRangeTest.kt31
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PhotoSyncTest.kt30
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())