返回 CodeWhale
PetHabitatStore.kt
根目录 / pet / android / src / main / kotlin / codewhale / pet / PetHabitatStore.kt
1 package codewhale.pet
2
3 import android.util.AtomicFile
4 import java.io.File
5 import java.io.ByteArrayOutputStream
6 import java.io.InputStream
7 import java.io.RandomAccessFile
8 import java.nio.channels.OverlappingFileLockException
9 import java.security.MessageDigest
10
11 /** Private, bounded, atomic recordings. The revision check also protects a
12 * second window/process from silently overwriting a newer habitat. */
13 class PetHabitatStore(private val directory: File, private val name: String) {
14 init { require(name in setOf("wild", "demo", "recording", "live")) }
15 private val file = AtomicFile(File(directory, "pet-$name.json"))
16 private val lockFile = File(directory, "pet-$name.lock")
17 var revision: String? = null; private set
18
19 fun read(): String? = locked {
20 revision = currentRevision()
21 if (revision == null) null else file.openRead().use { boundedRead(it) }
22 }
23
24 fun save(text: String, archive: File? = null, tick: Long = 0) = locked {
25 val bytes = text.toByteArray(Charsets.UTF_8)
26 require(bytes.size <= PetNativeCore.MAX_HABITAT_BYTES) { "Habitat exceeds 8 MiB." }
27 check(currentRevision() == revision) { "Another window changed this habitat. Reopen it before saving." }
28 if (archive != null) {
29 require(archive.length() <= 64L * 1024 * 1024 && tick >= 0)
30 val digest = archive.inputStream().use(::hashInput)
31 val target = File(directory, "pet-$name-segment-${"%012d".format(java.util.Locale.ROOT, tick)}-$digest.json")
32 if (target.exists()) check(target.inputStream().use(::hashInput) == digest) { "An archived recording was changed." }
33 else {
34 val atomic = AtomicFile(target)
35 val output = atomic.startWrite()
36 try { archive.inputStream().use { it.copyTo(output) }; atomic.finishWrite(output) }
37 catch (e: Throwable) { atomic.failWrite(output); throw e }
38 }
39 }
40 write(bytes)
41 revision = hash(text)
42 }
43
44 /** Called only after the explicit Start fresh confirmation. Rename keeps
45 * even an oversized/corrupt original without decoding or duplicating it. */
46 fun restart(text: String): File? = locked {
47 val bytes = text.toByteArray(Charsets.UTF_8)
48 require(bytes.size <= PetNativeCore.MAX_HABITAT_BYTES)
49 check(currentRevision() == revision) { "Another window changed this habitat. Reopen it before restarting." }
50 val backup = if (file.baseFile.exists()) File(file.baseFile.parentFile,
51 file.baseFile.nameWithoutExtension + "-recovery-${java.util.UUID.randomUUID()}.json") else null
52 if (backup != null) check(file.baseFile.renameTo(backup)) { "Could not preserve the previous habitat." }
53 try { write(bytes); revision = hash(text) } catch (e: Throwable) {
54 if (!file.baseFile.exists()) backup?.renameTo(file.baseFile)
55 throw e
56 }
57 backup
58 }
59
60 fun archives(): List<String> = directory.list()?.filter { validArchive(it) }?.sortedDescending() ?: emptyList()
61 fun archive(key: String): File {
62 require(validArchive(key)) { "Invalid recording archive." }
63 val saved = File(directory, key)
64 require(saved.isFile && saved.length() <= 64L * 1024 * 1024) { "This recording is unavailable." }
65 val digest = saved.inputStream().use(::hashInput)
66 check(key.endsWith("-$digest.json")) { "The archived recording was changed. Its file was kept." }
67 return saved
68 }
69 private fun validArchive(key: String) = key.matches(Regex("pet-$name-segment-[0-9]{12}-[a-f0-9]{64}\\.json"))
70 private fun hashInput(input: InputStream): String {
71 val digest = MessageDigest.getInstance("SHA-256"); val buffer = ByteArray(8192)
72 while (true) { val count = input.read(buffer); if (count < 0) break; digest.update(buffer, 0, count) }
73 return digest.digest().joinToString("") { "%02x".format(it) }
74 }
75
76 private fun currentRevision(): String? {
77 if (!file.baseFile.exists() && !File(file.baseFile.path + ".bak").exists()) return null
78 return file.openRead().use { input ->
79 val digest = MessageDigest.getInstance("SHA-256"); val buffer = ByteArray(8192)
80 while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
81 digest.digest().joinToString("") { "%02x".format(it) }
82 }
83 }
84 private fun write(bytes: ByteArray) {
85 val out = file.startWrite()
86 try { out.write(bytes); file.finishWrite(out) } catch (e: Throwable) { file.failWrite(out); throw e }
87 }
88
89 private fun <T> locked(block: () -> T): T {
90 lockFile.parentFile?.mkdirs()
91 return RandomAccessFile(lockFile, "rw").use { handle ->
92 val lock = try { handle.channel.tryLock() } catch (_: OverlappingFileLockException) { null }
93 check(lock != null) { "Another window is saving this habitat. Try again." }
94 lock.use { block() }
95 }
96 }
97 companion object {
98 fun boundedRead(input: InputStream): String {
99 val out = ByteArrayOutputStream()
100 val buffer = ByteArray(8192)
101 while (true) {
102 val n = input.read(buffer)
103 if (n < 0) break
104 require(out.size() + n <= PetNativeCore.MAX_HABITAT_BYTES) { "Habitat exceeds 8 MiB." }
105 out.write(buffer, 0, n)
106 }
107 val bytes = out.toByteArray()
108 return Charsets.UTF_8.newDecoder().decode(java.nio.ByteBuffer.wrap(bytes)).toString()
109 }
110 private fun hash(text: String) = MessageDigest.getInstance("SHA-256").digest(text.toByteArray(Charsets.UTF_8))
111 .joinToString("") { "%02x".format(it) }
112 }
113 }
114
114 lines Plain Text