aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/drive/DrivePlan.kt
blob: eb373ba767dab0bd25fea765f6f7023b1005d72a (plain) (blame)
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
package org.meshbay.client.drive

import org.meshbay.client.photos.PhotoPlan
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone

/** One file found in a chosen folder, through the Storage Access Framework. */
data class DriveFile(
    /** The folder it was found under, as granted (a tree URI). */
    val tree: String,
    /** Its document id, stable for the life of the file (`primary:Documents/a.pdf`). */
    val docId: String,
    /** Its folder inside the chosen one, `/`-separated, empty at the top. */
    val sub: String,
    val name: String,
    val size: Long,
    /** Milliseconds. */
    val modified: Long,
    val mime: String,
)

/** A file to send: a new one, or a new version of one already sent. */
data class DrivePending(val file: DriveFile, val edited: Boolean, val dir: String, val name: String)

/**
 * The rules of the files backup (docs/MESHBAY_DESIGN.md §9.13), pure, so the
 * JVM tests hold them: which folders may be chosen, what a run sends, and the
 * names the node will accept.
 */
object DrivePlan {
    /**
     * The phone's media folders: what lives there is the photo backup's, and
     * taking it here too would store every photo twice on the node.
     */
    private val MEDIA = setOf("dcim", "pictures", "movies")

    /** `primary:Documents/Scans` → `Documents/Scans`; the part after the volume. */
    fun pathOf(docId: String): String = docId.substringAfter(':', "").trim('/')

    /**
     * Why a folder cannot be chosen, or null when it can: the whole of a
     * volume (it holds the media folders), or a media folder or anything in one.
     */
    fun refusal(treeDocId: String): String? {
        val path = pathOf(treeDocId)
        if (path.isEmpty()) return "whole_storage"
        if (path.split('/').first().lowercase(Locale.ROOT) in MEDIA) return "media_folder"
        return null
    }

    /**
     * A run's files: new ones, and new versions of ones sent (beside them,
     * never in their place). `skip` holds the files the photo backup already
     * sends, as lowercase `relative/path/name`. Smallest first: a run cut short
     * has saved the most files.
     */
    fun plan(
        files: List<DriveFile>, base: String, folderNames: Map<String, String>,
        sent: (String) -> DriveLedger.Entry?, skip: Set<String>, zone: TimeZone,
        sameBytes: (DriveFile, DriveLedger.Entry) -> Boolean,
    ): List<DrivePending> {
        val out = ArrayList<DrivePending>()
        for (f in files) {
            if (f.name.startsWith('.')) continue
            if (skip.contains(pathOf(f.docId).lowercase(Locale.ROOT))) continue
            val top = folderNames[f.tree] ?: continue
            val dir = dirFor(base, top, f.sub)
            val was = sent(f.docId)
            if (was == null) {
                out += DrivePending(f, false, dir, safeName(f.name))
            } else if (was.modified != f.modified || was.size != f.size) {
                if (was.size == f.size && sameBytes(f, was)) continue
                out += DrivePending(f, true, dir, editedName(f, zone))
            }
        }
        return out.sortedBy { it.file.size }
    }

    /** `<base>/<chosen folder>/<sub>`, each level a name the node accepts; no chosen-folder level when `top` is empty. */
    fun dirFor(base: String, top: String, sub: String): String =
        (listOf(base) + listOf(top).filter { it.isNotEmpty() }.map(::safeName) +
            sub.split('/').filter { it.isNotEmpty() }.map(::safeName)).joinToString("/")

    /**
     * The name as is when the node accepts it (PhotoPlan.UPLOAD_NAME, the
     * node's own rule); otherwise its refused characters become `_`, and a
     * name that cannot start as the node wants is given a `file-` prefix.
     */
    fun safeName(name: String): String {
        if (PhotoPlan.UPLOAD_NAME.matches(name)) return name
        var s = name.map { c -> if (c.isLetterOrDigit() || c in " _.-()[]'’,&+#@") c else '_' }.joinToString("")
            .trimEnd(' ', '.')
        if (s.isEmpty() || !s.first().isLetterOrDigit()) s = "file-$s"
        s = s.take(MAX_NAME).trimEnd(' ', '.')
        return if (PhotoPlan.UPLOAD_NAME.matches(s)) s else "file"
    }

    /** A new version lands beside the one sent, its date in its name. */
    fun editedName(f: DriveFile, zone: TimeZone): String {
        val base = safeName(f.name)
        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(f.modified))
        val suffix = "-modified-$stamp$ext"
        return stem.take(MAX_NAME - suffix.length) + suffix
    }

    private const val MAX_NAME = 128
}