diff --git a/android/app/src/main/java/tv/orbit/classic/ChairLearning.kt b/android/app/src/main/java/tv/orbit/classic/ChairLearning.kt index 22f2802..a8f70b4 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChairLearning.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChairLearning.kt @@ -105,7 +105,7 @@ internal object ChairLearning { }.getOrDefault(LearningState()) fun on(file: File): Boolean = load(file).on private fun save(file: File, s: LearningState) = runCatching { - file.writeText(JSONObject().put("on", s.on).put("confirmed", JSONArray(s.confirmed.toList())).put("dismissed", JSONArray(s.dismissed.toList())).toString()) + writeSafely(file, JSONObject().put("on", s.on).put("confirmed", JSONArray(s.confirmed.toList())).put("dismissed", JSONArray(s.dismissed.toList())).toString()) } fun set(file: File, on: Boolean) = load(file).let { save(file, LearningState(on, it.confirmed, it.dismissed)) } /** Confirms ([confirm] true) or dismisses a lesson by its key; either way it's no longer a proposal. */ diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt index 6ab3fef..2dd26af 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -190,7 +190,7 @@ internal class ChannelScheduler(private val store: ScheduleStore, EventIdeaHistory.dismissed(historyFile), limit = (EVENT_IDEAS_PER_WEEK - shownThisWeek).coerceAtLeast(0), current = current) EventIdeaHistory.record(historyFile, ideas, now) val thisWeek = EventIdeaHistory.load(historyFile).filter(::live) - runCatching { file.writeText(JSONObject().put("day", today.toString()).put("rules", EVENT_IDEA_RULES).put("ideas", JSONArray(thisWeek.map { h -> + runCatching { writeSafely(file, JSONObject().put("day", today.toString()).put("rules", EVENT_IDEA_RULES).put("ideas", JSONArray(thisWeek.map { h -> val ids = h.getJSONArray("films").let { a -> (0 until a.length()).map { a.getString(it) } } JSONObject(h.toString()).put("details", JSONArray(ids.mapNotNull { films[it] ?: read.firstOrNull { r -> r.id == it } }.map { f -> JSONObject().put("title", f.title).put("year", f.year ?: JSONObject.NULL).put("community", f.community ?: JSONObject.NULL).put("rating", f.rating) })) diff --git a/android/app/src/main/java/tv/orbit/classic/ContinueWatching.kt b/android/app/src/main/java/tv/orbit/classic/ContinueWatching.kt index a548bda..54759c1 100644 --- a/android/app/src/main/java/tv/orbit/classic/ContinueWatching.kt +++ b/android/app/src/main/java/tv/orbit/classic/ContinueWatching.kt @@ -78,5 +78,5 @@ internal object ContinueWatching { if (next != marks) { marks = next; save() } } fun remove(number: Int, startMs: Long) { marks = marks.filterNot { it.number == number && it.startMs == startMs }; save() } - private fun save() { runCatching { file?.writeText(JSONArray(marks.map { it.toJson() }).toString()) } } + private fun save() { runCatching { file?.let { writeSafely(it, JSONArray(marks.map { m -> m.toJson() }).toString()) } } } } diff --git a/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt b/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt index 674cec6..18c695e 100644 --- a/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt +++ b/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt @@ -335,7 +335,7 @@ internal fun directorReports(store: ScheduleStore, catalogue: CatalogueStore, us history = loadDecisions(decisions), learning = ChairLearning.load(store.learningFile())) val ideas = DeskEventIdea.fromFile(store.eventIdeasFile()) val reports = reports0.mapValues { (_, r) -> DeskReport.of(r.toJson().put("eventIdeas", JSONArray(ideas.map { it.toJson() }))) } - runCatching { cache.writeText(JSONObject().put("key", key).put("savedMs", nowMs).put("reports", JSONObject().apply { reports.forEach { (m, r) -> put(m.name, r.toJson()) } }).toString()) } + runCatching { writeSafely(cache, JSONObject().put("key", key).put("savedMs", nowMs).put("reports", JSONObject().apply { reports.forEach { (m, r) -> put(m.name, r.toJson()) } }).toString()) } val b = reports.getValue(DirectorMode.Balanced) android.util.Log.i("OrbitSchedule", "director newlyReleased=${b.newlyReleased} read=${b.scanned} strong=${b.strong} asks=${b.asks.size} collections=${b.collections} tookMs=${b.tookMs}") reports diff --git a/android/app/src/main/java/tv/orbit/classic/EventIdeas.kt b/android/app/src/main/java/tv/orbit/classic/EventIdeas.kt index a119f78..bac6316 100644 --- a/android/app/src/main/java/tv/orbit/classic/EventIdeas.kt +++ b/android/app/src/main/java/tv/orbit/classic/EventIdeas.kt @@ -275,10 +275,10 @@ internal object EventIdeaHistory { } val all = load(historyFile) if (all.none { it.optString("signature") == signature }) return@runCatching false - historyFile.writeText(JSONArray(all.map(::update)).toString()) + writeSafely(historyFile, JSONArray(all.map(::update)).toString()) runCatching { JSONObject(ideasFile.readText()).let { root -> val ideas = root.getJSONArray("ideas") - ideasFile.writeText(root.put("ideas", JSONArray((0 until ideas.length()).map { update(ideas.getJSONObject(it)) })).toString()) + writeSafely(ideasFile, root.put("ideas", JSONArray((0 until ideas.length()).map { update(ideas.getJSONObject(it)) })).toString()) } } ApprovedDriveInEvents.refresh(historyFile) true @@ -296,7 +296,7 @@ internal object EventIdeaHistory { fun withdrawOld(file: File) = runCatching { val all = load(file) if (all.none { it.optInt("rules") < EVENT_IDEA_RULES && it.optString("decision") == "proposed" }) return@runCatching - file.writeText(JSONArray(all.map { if (it.optInt("rules") < EVENT_IDEA_RULES && it.optString("decision") == "proposed") JSONObject(it.toString()).put("decision", "withdrawn: rules changed") else it }).toString()) + writeSafely(file, JSONArray(all.map { if (it.optInt("rules") < EVENT_IDEA_RULES && it.optString("decision") == "proposed") JSONObject(it.toString()).put("decision", "withdrawn: rules changed") else it }).toString()) } fun record(file: File, ideas: List, nowMs: Long) = runCatching { // An idea withdrawn only because the rules changed, and proposed again under the new ones, is shown again: its old entry goes. @@ -307,6 +307,6 @@ internal object EventIdeaHistory { JSONObject().put("signature", i.signature).put("theme", i.theme).put("source", i.source).put("format", i.format.name).put("evening", i.evening?.toString()) .put("films", JSONArray(i.films.map { it.id })).put("titles", JSONArray(i.films.map { it.title })).put("reasons", JSONArray(i.reasons)).put("shownMs", nowMs).put("decision", "proposed").put("rules", EVENT_IDEA_RULES) } - if (added.isNotEmpty() || all.size != load(file).size) file.writeText(JSONArray(all + added).toString()) + if (added.isNotEmpty() || all.size != load(file).size) writeSafely(file, JSONArray(all + added).toString()) } } diff --git a/android/app/src/main/java/tv/orbit/classic/EvidenceReader.kt b/android/app/src/main/java/tv/orbit/classic/EvidenceReader.kt index 17b59c6..065d3ae 100644 --- a/android/app/src/main/java/tv/orbit/classic/EvidenceReader.kt +++ b/android/app/src/main/java/tv/orbit/classic/EvidenceReader.kt @@ -64,8 +64,8 @@ internal class CollectionRefresh(private val file: java.io.File) { fun due(nowMs: Long): Boolean = finishedAt()?.let { next.exists() || nowMs - it >= COLLECTIONS_REFRESH_MS } ?: true /** A full read reached the end: a refresh replaces the file; either way the time is noted. */ fun finish(refreshing: Boolean, nowMs: Long) { - if (refreshing && next.exists()) { if (!next.renameTo(file)) { file.writeText(next.readText()); next.delete() } } - state.writeText(JSONObject().put("finishedAt", nowMs).toString()) + if (refreshing && next.exists()) { if (!next.renameTo(file)) { writeSafely(file, next.readText()); next.delete() } } + writeSafely(state, JSONObject().put("finishedAt", nowMs).toString()) } } diff --git a/android/app/src/main/java/tv/orbit/classic/Rooms.kt b/android/app/src/main/java/tv/orbit/classic/Rooms.kt index 92be0d3..7484c76 100644 --- a/android/app/src/main/java/tv/orbit/classic/Rooms.kt +++ b/android/app/src/main/java/tv/orbit/classic/Rooms.kt @@ -100,16 +100,16 @@ internal fun mergeFamilyFiles(dir: File, files: JSONObject, nowMs: Long = System val target = File(dir, name).also { it.parentFile?.mkdirs() } val here = if (target.exists()) target.readLines().mapNotNull { TasteEvent.of(it) } else emptyList() val merged = compactTaste(here + text.lines().mapNotNull { TasteEvent.of(it) }, nowMs) - target.writeText(merged.joinToString("") { it.toLine() + "\n" }) + writeSafely(target, merged.joinToString("") { it.toLine() + "\n" }) } name.startsWith("taste/") && name.endsWith(".state.json") && TasteFiles.matches(name.removePrefix("taste/")) -> - File(dir, name).let { if (!it.exists()) { it.parentFile?.mkdirs(); it.writeText(text) } } + File(dir, name).let { if (!it.exists()) { writeSafely(it, text) } } FamilyFiles.matches(name) -> { val target = File(dir, name) fun marks(t: String) = runCatching { JSONArray(t).let { a -> (0 until a.length()).mapNotNull { runCatching { ContinueMark.fromJson(a.getJSONObject(it)) }.getOrNull() } } }.getOrDefault(emptyList()) val all = (if (target.exists()) marks(target.readText()) else emptyList()) + marks(text) val latest = all.groupBy { it.number to it.startMs }.values.map { same -> same.maxBy { it.atMs } }.sortedByDescending { it.atMs }.take(20) - target.writeText(JSONArray(latest.map { it.toJson() }).toString()) + writeSafely(target, JSONArray(latest.map { it.toJson() }).toString()) } } } @@ -187,7 +187,7 @@ internal object RoomBundle { dir.mkdirs() fun write(files: JSONObject?) = files?.keys()?.forEach { n -> if (Regex("(choices-\\d+|episodes-\\d+|yours|director-decisions-0|director-learning-0|event-ideas-history-0)\\.json").matches(n) && !File(dir, n).exists()) - File(dir, n).writeText(files.getString(n)) + writeSafely(File(dir, n), files.getString(n)) } var lineup = emptyList() if (RoomPart.Channels in parts) bundle.optJSONObject(RoomPart.Channels.name)?.let { c -> @@ -201,7 +201,7 @@ internal object RoomBundle { // Only with the channels: a schedule is for a channel that's on. Never over a schedule this box already has. if (sameSchedules && RoomPart.Channels in parts) bundle.optString("schedules").takeIf { it.isNotBlank() }?.let { packed -> runCatching { JSONObject(gunzip(packed)).let { files -> files.keys().forEach { n -> - if (ScheduleFiles.matches(n) && !File(dir, n).exists()) File(dir, n).writeText(files.getString(n)) } } } + if (ScheduleFiles.matches(n) && !File(dir, n).exists()) writeSafely(File(dir, n), files.getString(n)) } } } } return lineup } diff --git a/android/app/src/main/java/tv/orbit/classic/SafeWrite.kt b/android/app/src/main/java/tv/orbit/classic/SafeWrite.kt new file mode 100644 index 0000000..cde39cc --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/SafeWrite.kt @@ -0,0 +1,26 @@ +package tv.orbit.classic + +import java.io.File +import java.io.OutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** + * Saving a whole file so a power cut can't leave it half-written (8 November 2026, audit A5): the new contents go to a file + * beside it, are flushed to the disk, then take its place in one step. Until that step the old file is untouched, so a + * failure at any point leaves either the old contents or the new, never a cut-short file that reads as empty. + */ +internal fun writeSafely(file: File, text: String) = writeSafely(file) { it.write(text.toByteArray(Charsets.UTF_8)) } + +internal fun writeSafely(file: File, write: (OutputStream) -> Unit) { + file.parentFile?.mkdirs() + val temp = File(file.parentFile, file.name + ".writing") + try { + java.io.FileOutputStream(temp).use { out -> write(out); out.flush(); out.fd.sync() } + try { Files.move(temp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) } + catch (_: AtomicMoveNotSupportedException) { Files.move(temp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING) } + } catch (failure: Throwable) { + temp.delete(); throw failure + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/Taste.kt b/android/app/src/main/java/tv/orbit/classic/Taste.kt index 188e4c5..0396b41 100644 --- a/android/app/src/main/java/tv/orbit/classic/Taste.kt +++ b/android/app/src/main/java/tv/orbit/classic/Taste.kt @@ -161,11 +161,7 @@ internal class TasteStore(val directory: File) { .put("latest", JSONArray(all.takeLast(20).reversed().map { JSONObject().put("kind", it.kind.key).put("title", it.title).put("channel", it.channel ?: JSONObject.NULL).put("at", it.atMs) })) } - private fun writeWhole(file: File, text: String) { - val temp = File(directory, file.name + ".tmp") - temp.writeText(text) - if (!temp.renameTo(file)) { file.writeText(text); temp.delete() } - } + private fun writeWhole(file: File, text: String) = writeSafely(file, text) } /** The box's Taste: the store for the server in use, and what's being watched now. */ diff --git a/android/app/src/main/java/tv/orbit/classic/Unplayable.kt b/android/app/src/main/java/tv/orbit/classic/Unplayable.kt index c9617a1..8dc2986 100644 --- a/android/app/src/main/java/tv/orbit/classic/Unplayable.kt +++ b/android/app/src/main/java/tv/orbit/classic/Unplayable.kt @@ -32,10 +32,10 @@ internal class UnplayableLog(private val file: File) { @Synchronized fun add(itemId: String, title: String, channel: Int?, reason: String, nowMs: Long) { val o = runCatching { JSONObject(file.readText()) }.getOrDefault(JSONObject()) o.put(itemId, JSONObject().put("title", title.take(160)).put("channel", channel ?: JSONObject.NULL).put("reason", reason.take(240)).put("at", nowMs)) - file.writeText(o.toString()) + writeSafely(file, o.toString()) } @Synchronized fun remove(itemId: String) { val o = runCatching { JSONObject(file.readText()) }.getOrNull() ?: return - o.remove(itemId); file.writeText(o.toString()) + o.remove(itemId); writeSafely(file, o.toString()) } } diff --git a/android/app/src/test/java/tv/orbit/classic/SafeWriteTest.kt b/android/app/src/test/java/tv/orbit/classic/SafeWriteTest.kt new file mode 100644 index 0000000..478b890 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/SafeWriteTest.kt @@ -0,0 +1,42 @@ +package tv.orbit.classic + +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** 8 November 2026 (audit A5): a save that fails part-way leaves the old file as it was, never a cut-short one. */ +class SafeWriteTest { + @get:Rule val tmp = TemporaryFolder() + + @Test fun aFinishedSaveReplacesTheFileAndLeavesNothingBeside() { + val f = File(tmp.root, "continue-grown.json").apply { writeText("[old]") } + writeSafely(f, "[new]") + assertEquals("[new]", f.readText()) + assertEquals(listOf("continue-grown.json"), tmp.root.list()!!.toList()) + } + @Test fun aSaveThatFailsHalfwayKeepsTheOldContents() { + val f = File(tmp.root, "director-learning-0.json").apply { writeText("""{"on":true}""") } + assertThrows(java.io.IOException::class.java) { + writeSafely(f) { out -> out.write("""{"on":fa""".toByteArray()); throw java.io.IOException("power cut") } + } + assertEquals("""{"on":true}""", f.readText()) + assertFalse(File(tmp.root, "director-learning-0.json.writing").exists()) + } + @Test fun aFirstSaveMakesTheFolderAndTheFile() { + val f = File(tmp.root, "taste/grown.state.json") + writeSafely(f, "{}") + assertEquals("{}", f.readText()) + } + @Test fun continueWatchingStillReadsBackWhatItSaved() { + val dir = tmp.newFolder("schedule") + java.io.File(dir, "continue-catchup-only").writeText("") + ContinueWatching.load(dir, "grown") + val show = SampleBroadcast(ScheduledMedia(SampleItem("Terror Train", "Movie", 97, "", "6048906", 97 * 60_000L), "src", 97 * 60_000L), 1_794_000_000_000L) + ContinueWatching.note(304, show, 30 * 60_000L, 1_794_000_000_000L + 30 * 60_000L) + ContinueWatching.load(dir, "grown") + assertEquals(listOf("6048906"), ContinueWatching.marks.map { it.itemId }) + assertFalse(dir.list()!!.any { it.endsWith(".writing") }) + } +}