aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-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
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js57
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js28
-rw-r--r--packages/meshbay-hub/tests/test_photo_sync.py30
-rw-r--r--packages/meshbay-hub/tests/test_photo_sync_ui.py5
24 files changed, 357 insertions, 78 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())
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 }) {
</p>
${status.permission === 'partial' && html`
<p class="settings-hint">${t('photosync.permission_partial')}</p>`}
+ ${cfg.includeVideos && status.videoPermission === 'denied' && html`
+ <p class="settings-hint">${t('photosync.video_permission_needed')}</p>
+ <button class="btn-secondary" onClick=${async () => {
+ try { setStatus(await platform.photoSync.permit(true)); } catch (e) { setError(e.message); }
+ }}>${t('photosync.permission_allow')}</button>`}
${status.failure
? html`<p class="error-msg">${t(FAILURES[status.failure] || 'photosync.fail_other')}</p>`
: stateLine(run, status) && html`<p class="settings-hint">${stateLine(run, status)}</p>`}
@@ -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,11 +217,23 @@ function Setup({ status, onPermission, onDone }) {
<span class="settings-name">
<strong>${a.camera ? t('photosync.album_camera') : a.name}</strong>
<span class="settings-hint">
- ${t('photosync.album_count', { n: a.count, size: formatSize(a.bytes) })}</span>
+ ${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) })}</span>
</span>
</label>
`)}
<label class="settings-choice">
+ <input type="checkbox" checked=${videos} onChange=${toggleVideos} />
+ <span class="settings-name">
+ <strong>${t('photosync.include_videos')}</strong>
+ <span class="settings-hint">${t('photosync.include_videos_hint')}</span>
+ </span>
+ </label>
+ ${videos && !videosAllowed && html`
+ <p class="settings-hint">${t('photosync.video_permission_needed')}</p>`}
+ <label class="settings-choice">
<input type="radio" name="photosync-scope" checked=${existing}
onChange=${() => setExisting(true)} />
<span><strong>${t('photosync.scope_existing')}</strong></span>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
index 9c59c17..ffb07aa 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
@@ -253,7 +253,7 @@ export async function upload(transport, item, file = platform.photoSync.file) {
timeout = setTimeout(() => reject(new Error('The node is busy')), LEASE_WAIT_MS);
}),
]).finally(() => clearTimeout(timeout));
- const bytes = await file(item.token, item.name);
+ const bytes = await file(item.token, item.name, item.size);
return await transport.uploadFile(bytes, {
dir: item.dir, root: item.dir.split('/')[0], tr: lease.tr,
});
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 2cfa673..8effc21 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -238,8 +238,8 @@ export const push = {
export const photoSync = {
available: Boolean(bridge && bridge.photoSync),
status() { return bridge.photoSync.status(); },
- permit() { return bridge.photoSync.permit(); },
- albums() { return bridge.photoSync.albums(); },
+ permit(withVideos) { return bridge.photoSync.permit(withVideos); },
+ albums(withVideos) { return bridge.photoSync.albums(withVideos); },
configure(settings) { return bridge.photoSync.configure(settings); },
estimate(settings) { return bridge.photoSync.estimate(settings); },
plan() { return bridge.photoSync.plan(); },
@@ -247,11 +247,25 @@ export const photoSync = {
completed() { return bridge.photoSync.completed(); },
failed(code, text) { return bridge.photoSync.failed(code, text); },
keepAlive(on, text) { return bridge.photoSync.keepAlive(on, text); },
- /** The bytes of a photo the last plan issued, as a File named for the node. */
- async file(token, name) {
- const r = await fetch(`/photosync/${encodeURIComponent(token)}`, { cache: 'no-store' });
- if (!r.ok) throw new Error('This photo is no longer on the phone');
- return new File([await r.blob()], name);
+ /**
+ * A photo or video the last plan issued, as what an upload reads (`name`,
+ * `size`, `slice(a, b).arrayBuffer()`): each slice is fetched from the phone
+ * when it is read, so a video of gigabytes is never whole in this page.
+ */
+ file(token, name, size) {
+ const url = `/photosync/${encodeURIComponent(token)}`;
+ const part = (start, end) => ({
+ async arrayBuffer() {
+ if (end <= start) return new ArrayBuffer(0);
+ const r = await fetch(url, { cache: 'no-store', headers: { Range: `bytes=${start}-${end - 1}` } });
+ if (r.status !== 206) throw new Error('This photo is no longer on the phone');
+ return r.arrayBuffer();
+ },
+ });
+ return {
+ name, size,
+ slice: (a = 0, b = size) => part(Math.max(0, a), Math.min(b, size)),
+ };
},
};
diff --git a/packages/meshbay-hub/tests/test_photo_sync.py b/packages/meshbay-hub/tests/test_photo_sync.py
index 98f49ba..c422f22 100644
--- a/packages/meshbay-hub/tests/test_photo_sync.py
+++ b/packages/meshbay-hub/tests/test_photo_sync.py
@@ -57,9 +57,13 @@ doc.visibilityState = 'visible';
globalThis.window = win;
globalThis.document = doc;
globalThis.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} };
-globalThis.fetch = async (url) => {
- fake.log.push(['fetch', url]);
- return { ok: true, blob: async () => new Blob([new Uint8Array(4)]) };
+globalThis.fetch = async (url, opts) => {
+ const range = opts && opts.headers && opts.headers.Range;
+ fake.log.push(['fetch', url, range || null]);
+ const m = /bytes=(\d+)-(\d+)/.exec(range || '');
+ const n = m ? Number(m[2]) - Number(m[1]) + 1 : 4;
+ return { ok: true, status: m ? 206 : 200, arrayBuffer: async () => new ArrayBuffer(n),
+ blob: async () => new Blob([new Uint8Array(n)]) };
};
const NOW = Date.now();
@@ -95,6 +99,12 @@ fake.transport = {
},
async uploadFile(file, opts) {
fake.log.push(['upload', opts.dir, file.name, opts.tr]);
+ if (fake.readChunk) {
+ for (let at = 0; at < file.size; at += fake.readChunk) {
+ const buf = await file.slice(at, at + fake.readChunk).arrayBuffer();
+ fake.log.push(['read', at, buf.byteLength]);
+ }
+ }
if (fake.uploadError) { const e = new Error('refused'); e.code = fake.uploadError; throw e; }
if (fake.afterUpload) fake.afterUpload();
return { stored_as: file.name, dir: opts.dir };
@@ -164,6 +174,20 @@ def test_a_due_run_sends_newest_first_and_records_each(tmp_path):
"the screen-off keep-alive was not released after the run")
+def test_a_file_is_read_from_the_phone_a_chunk_at_a_time(tmp_path):
+ """A video of gigabytes is never whole in the page: each slice the upload
+ reads is its own ranged request to the phone."""
+ log = _run(_start("""
+fake.items = [{ token: 'v1', name: 'clip.mp4', dir: 'Media/Photos/Bob/2026/2026-10', size: 25,
+ edited: false, video: true }];
+fake.readChunk = 10;
+"""), tmp_path)
+ assert _of(log, "fetch") == [["/photosync/v1", "bytes=0-9"], ["/photosync/v1", "bytes=10-19"],
+ ["/photosync/v1", "bytes=20-24"]]
+ assert _of(log, "read") == [[0, 10], [10, 10], [20, 5]]
+ assert ["sent", "v1", "Media/Photos/Bob/2026/2026-10", "clip.mp4"] in log
+
+
def test_only_missing_folders_are_made_and_parents_first(tmp_path):
log = _run(_start(), tmp_path)
assert _of(log, "mkdir") == [["Media/Photos/Bob/2026/2026-10"]]
diff --git a/packages/meshbay-hub/tests/test_photo_sync_ui.py b/packages/meshbay-hub/tests/test_photo_sync_ui.py
index cbac80a..8cc9417 100644
--- a/packages/meshbay-hub/tests/test_photo_sync_ui.py
+++ b/packages/meshbay-hub/tests/test_photo_sync_ui.py
@@ -74,3 +74,8 @@ def test_the_messages_backup_says_where_its_copies_go(probe):
def test_the_calendar_backup_says_where_its_copies_go(probe):
assert "bob-calendar/calendar-2026-10-10-0900.ics" in probe["configured360"]["text"]
+
+
+def test_videos_are_offered_and_off_by_default(probe):
+ assert "Videos too" in probe["setup360"]["text"]
+ assert "Videos too" not in probe["setup360"]["checked"]