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() private var lines = 0 init { load() } val size: Int get() = entries.size operator fun get(mediaId: Long): Entry? = entries[mediaId] fun all(): Collection = 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 } } }