diff options
26 files changed, 379 insertions, 82 deletions
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 `<stem>-edited-<YYYYMMDD-HHMMSS>.<e from the edit's date. A touch that left the bytes alone sends nothing. "Save as copy" makes a new photo and needs no rule. +**Videos.** Off by default, ticked in the same section: the videos of the +chosen albums (a MediaStore bucket holds both, under one id) go beside the +photos, in the same `YYYY/YYYY-MM` folders, under the same ledger, rules and +unmetered network. `READ_MEDIA_VIDEO` is asked for when they are ticked in. +The confirmation states the videos' count and size apart, because they are +most of it. A file is never whole in the page: the upload reads it a chunk at +a time and each chunk is a ranged request to the phone (`Range: bytes=a-b`, +answered 206 by `ByteRange.kt`), so a video of gigabytes costs the WebView one +chunk of memory; the SHA-256 the ledger keeps is then read from the phone once +the node has the file. The Photos tab does not show videos yet; Files does. + **Location.** The manifest does not ask for `ACCESS_MEDIA_LOCATION`, so a -photo read through MediaStore has its EXIF location **redacted by the -platform**: a camera roll going to a group does not say where its owner lives, -and nobody had to do anything for it. +photo or video read through MediaStore has its location **redacted by the +platform** (Android 10+): a photo's EXIF, and in an MP4 or MOV the location +boxes MediaProvider finds (`IsoInterface`). A camera roll going to a group +does not say where its owner lives, and nobody had to do anything for it. On +Android 8 and 9 nothing redacts it. **Refusals.** A refusal that will hold tomorrow — `disk_full` (§6.4), a folder no longer writable, gone or on a drive that is not plugged, the member removed diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index 1587160..dac1f9f 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -408,6 +408,10 @@ backup at all. - **Set up** asks for access to your photos, then for the albums to send. The camera is ticked; screenshots and other albums are yours to add. +- **Videos too** adds the videos of the same albums, beside the photos. It is + off by default: videos are large, and the first backup can take a while. + They are sent on Wi-Fi like photos. For now they show in Files, not in the + Photos tab. - By default **the photos already on the phone are sent too**, newest first, then each new one. Choose "Only photos taken from now on" to skip the ones already there. Before the first send you are told how many photos and how @@ -422,7 +426,8 @@ backup at all. the group is not sent again, unless the application is reinstalled. A photo you edit on the phone is sent again beside the original, with `-edited-` and the date in its name. -- **Where a photo was taken is removed** from the copy that is sent. +- **Where a photo or video was taken is removed** from the copy that is sent + (Android 10 and later). #### Contacts 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"] |