1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
|
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<String>,
/** 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<Photo>, config: SyncConfig, base: String, ledger: (Long) -> PhotoLedger.Entry?,
zone: TimeZone = TimeZone.getDefault(),
sameBytes: (Photo, PhotoLedger.Entry) -> Boolean,
): List<Pending> {
val albums = config.albums.toSet()
val out = ArrayList<Pending>()
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: `<folder>/<account>-photos`. */
fun baseFor(destination: Destination): String = DocPlan.dirFor(destination, SUFFIX)
const val SUFFIX = "photos"
/** `<base>/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}(?<![ .])$")
}
|