package org.meshbay.client.photos import org.json.JSONArray import org.json.JSONObject import org.meshbay.client.phonesync.DocPlan import org.meshbay.client.phonesync.Destination import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone /** One image in MediaStore, as much of it as a plan needs. */ data class Photo( val mediaId: Long, val displayName: String, val size: Long, /** Milliseconds; 0 when the camera wrote none. */ val taken: Long, /** MediaStore DATE_ADDED and DATE_MODIFIED, seconds. */ val added: Long, val modified: Long, val bucketId: String, val mime: String, /** From MediaStore's video collection rather than its images. */ val video: Boolean = false, /** Where it is on the phone, `DCIM/Camera/PXL_….jpg`, and its album's name, for the manifest. */ val relPath: String = "", val album: String = "", ) /** * Which of this phone's photos are backed up, as the person set it up. Where * they go is the destination every kind shares (phonesync/Destination.kt). */ data class SyncConfig( val albums: List, /** The photos already on the phone too: the default, as backup applications do. */ 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. */ fun fromJson(o: JSONObject, now: Long, previous: SyncConfig? = null): SyncConfig { val albums = o.optJSONArray("albums") ?: JSONArray() val includeExisting = o.optBoolean("includeExisting", true) // A change of scope starts again from that moment; a change of // album list alone does not move it. val same = previous != null && previous.includeExisting == includeExisting return 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), ) } fun parse(text: String?): SyncConfig? = try { 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("includeVideos", false)) } catch (e: Exception) { null } } } /** A photo to send: a new one, or a new version of one already sent. */ data class Pending(val photo: Photo, val edited: Boolean, val dir: String, val name: String) /** * What a run sends, decided from the phone's photos and the ledger alone. * * Pure, so the rules are tested on the JVM; reading bytes for an edit's hash is * the caller's (`sameBytes`). */ object PhotoPlan { /** A run is due once a day, counted from the last one that finished. */ const val DAY_MS = 24L * 3600 * 1000 fun due(lastCompleted: Long?, now: Long): Boolean = lastCompleted == null || now - lastCompleted >= DAY_MS || now < lastCompleted /** * `sameBytes(photo, entry)` is asked only of a photo whose MediaStore * modification date moved since it was sent: true when its bytes are still * those the ledger hashed (a favourite flag, a rescan), in which case * nothing is sent. */ fun plan( photos: List, config: SyncConfig, base: String, ledger: (Long) -> PhotoLedger.Entry?, zone: TimeZone = TimeZone.getDefault(), sameBytes: (Photo, PhotoLedger.Entry) -> Boolean, ): List { val albums = config.albums.toSet() val out = ArrayList() for (p in photos) { if (p.bucketId !in albums) 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 out += Pending(p, false, dirFor(base, p, zone), nameFor(p)) } else if (sent.modified != p.modified || sent.size != p.size) { if (sent.size == p.size && sameBytes(p, sent)) continue out += Pending(p, true, dirFor(base, p, zone), editedName(p, zone)) } } // Newest first: the photos most likely to exist nowhere else are safe earliest. return out.sortedByDescending { whenTaken(it.photo) } } fun whenTaken(p: Photo): Long = if (p.taken > 0) p.taken else p.added * 1000 /** Where every photo goes: `/-photos`. */ fun baseFor(destination: Destination): String = DocPlan.dirFor(destination, SUFFIX) const val SUFFIX = "photos" /** `/YYYY/YYYY-MM`, from when it was taken: an album is a directory (§9.9). */ fun dirFor(base: String, p: Photo, zone: TimeZone): String { val fmt = SimpleDateFormat("yyyy/yyyy-MM", Locale.ROOT).apply { timeZone = zone } return "$base/${fmt.format(Date(whenTaken(p)))}" } fun nameFor(p: Photo): String = 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 * to the node it would be `IMG_…(1).jpg`, which says nothing. */ fun editedName(p: Photo, zone: TimeZone): String { val base = nameFor(p) val dot = base.lastIndexOf('.') val stem = if (dot > 0) base.substring(0, dot) else base val ext = if (dot > 0) base.substring(dot) else "" val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).apply { timeZone = zone } .format(Date(p.modified * 1000)) val suffix = "-edited-$stamp$ext" return stem.take(MAX_NAME - suffix.length) + suffix } 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" "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 /** * The node's SAFE_UPLOAD_NAME (roots.py), as files-app.js copies it. A name it * refuses would fail the upload; this one is renamed before it is sent. */ val UPLOAD_NAME = Regex("^[\\p{L}\\p{N}][\\p{L}\\p{N}_ .\\-()\\[\\]'’,&+#@]{0,127}(?