aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/photos/PhotoLedger.kt
blob: f3aa6e586e1ff087e8e53751993a867bf329c273 (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
package org.meshbay.client.photos

import org.json.JSONObject
import java.io.File

/**
 * What this phone has sent to one folder of one group — the memory of what was
 * sent, never a mirror of the phone (docs/MESHBAY_DESIGN.md §9.12).
 *
 * A run sends what is not here, and nothing compares in the other direction: a
 * photo deleted on the phone simply stops being listed, and one deleted on the
 * node stays here and is not sent again — deleting it there was a decision.
 *
 * One line of JSON per send, appended; the last line for a media id wins. A
 * whole-file rewrite per photo would be megabytes written per photo on a roll
 * of twenty thousand. Compacted on load once the dead lines outnumber the live
 * ones. Plain files and org.json, so the JVM tests run it as it runs here.
 */
class PhotoLedger(private val file: File) {

    data class Entry(
        val mediaId: Long,
        /** MediaStore DATE_MODIFIED, seconds — what tells an edit from the photo sent. */
        val modified: Long,
        val size: Long,
        /** SHA-256 of the bytes sent, hex: an edit is sent only if this changed. */
        val sha256: String,
        /** Where the node put it: the folder and the name its ack gave. */
        val dir: String,
        val name: String,
        val sentAt: Long,
    )

    private val entries = HashMap<Long, Entry>()
    private var lines = 0

    init { load() }

    val size: Int get() = entries.size

    operator fun get(mediaId: Long): Entry? = entries[mediaId]

    fun all(): Collection<Entry> = entries.values

    fun record(entry: Entry) {
        entries[entry.mediaId] = entry
        file.parentFile?.mkdirs()
        file.appendText(encode(entry) + "\n")
        lines += 1
    }

    /** Everything forgotten — the group or the folder changed, or backup was turned off. */
    fun clear() {
        entries.clear()
        lines = 0
        file.delete()
    }

    private fun load() {
        if (!file.exists()) return
        file.forEachLine { line ->
            if (line.isBlank()) return@forEachLine
            lines += 1
            // A line cut short by a process killed mid-write is the only kind
            // that fails to parse; what it was recording is sent again, once.
            decode(line)?.let { entries[it.mediaId] = it }
        }
        if (lines > 2 * entries.size + COMPACT_SLACK) compact()
    }

    private fun compact() {
        val tmp = File(file.path + ".tmp")
        tmp.writeText(entries.values.joinToString("") { encode(it) + "\n" })
        if (!tmp.renameTo(file)) { tmp.delete(); return }
        lines = entries.size
    }

    companion object {
        private const val COMPACT_SLACK = 64

        fun encode(e: Entry): String = JSONObject()
            .put("id", e.mediaId).put("m", e.modified).put("s", e.size).put("h", e.sha256)
            .put("d", e.dir).put("n", e.name).put("t", e.sentAt).toString()

        fun decode(line: String): Entry? = try {
            val o = JSONObject(line)
            Entry(o.getLong("id"), o.getLong("m"), o.getLong("s"), o.getString("h"),
                  o.getString("d"), o.getString("n"), o.getLong("t"))
        } catch (e: Exception) { null }
    }
}