Restore a pulled snapshot onto a TV. Ported from release-0.108 (0.108.108)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 5m14s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 5m10s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 11:31:39 +00:00
co-authored by Claude Opus 5.5
parent 061753fe52
commit 5b550c3f56
4 changed files with 159 additions and 2 deletions
@@ -15,18 +15,29 @@ import java.security.MessageDigest
* - `rollback <version>`: copies the schedules aside as schedule-rollback-<version>, as every install does.
* - `catalogue`: a read-only copy of the library catalogue beside it, for checking new channels' titles against the library.
* - `clear`: removes the copy once it's been pulled.
* - `restore` (8 November 2026, audit U12): puts a pulled snapshot back. See [InstallRestore]: only with `confirm RESTORE`,
* only while Orbit's screen isn't open, only files a snapshot takes, each checked against its checksum first, and the
* current data is kept aside before anything is replaced.
*
* It only ever copies; it never changes the schedules, the library or the sign-in.
* Apart from `restore`, it only ever copies; it never changes the schedules, the library or the sign-in.
*/
class InstallSnapshotReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val result = runCatching { InstallSnapshot.handle(context, intent.getStringExtra("do").orEmpty(), intent.getStringExtra("version")) }
val result = runCatching {
if (intent.getStringExtra("do") == "restore") {
if (InstallSnapshot.screenOpen) "refused: Orbit is open; force-stop it first"
else InstallRestore.apply(context.filesDir.parentFile!!, File(context.getExternalFilesDir(null), "install-restore"),
intent.getStringExtra("confirm"), System.currentTimeMillis())
} else InstallSnapshot.handle(context, intent.getStringExtra("do").orEmpty(), intent.getStringExtra("version"))
}
resultData = result.fold({ it }, { "failed" })
android.util.Log.i("OrbitInstall", "${intent.getStringExtra("do")}: $resultData")
}
}
internal object InstallSnapshot {
/** Orbit's screen is open in this process (set by MainActivity); a restore waits until it isn't. */
@Volatile var screenOpen = false
private val versionShape = Regex("""\d+\.\d+\.\d+""")
fun handle(context: Context, action: String, version: String?): String {
@@ -68,3 +79,57 @@ internal object InstallSnapshot {
digest.digest().joinToString("") { "%02x".format(it) }
}
}
/**
* Putting a snapshot back (8 November 2026, audit U12: Android won't install an older Orbit over a newer one, so without
* this a bad install could only be fixed forward). adb pushes a pulled snapshot to Orbit's shared folder as
* install-restore/, with restore.txt listing each file's SHA-256 (`sha256sum` format). Then, with Orbit force-stopped:
*
* - nothing happens without `confirm RESTORE`;
* - only what a snapshot takes can come back: no_backup/schedule/... and the settings and profiles prefs. Never the
* library catalogue, the sign-in or the PIN;
* - every listed file is checked against its checksum before anything is touched, and any problem refuses the lot;
* - the current schedules and prefs are kept aside as no_backup/before-restore-<time> (the last two are kept);
* - the schedules are copied in beside the old ones and swapped in by renaming, so a failure leaves the old ones in place.
*/
internal object InstallRestore {
private val allowed = Regex("""no_backup/schedule/[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)?|shared_prefs/orbit_(settings|profiles)\.xml""")
fun apply(data: File, source: File, confirm: String?, nowMs: Long): String {
if (confirm != "RESTORE") return "refused: needs confirm RESTORE"
val manifest = File(source, "restore.txt").takeIf { it.isFile } ?: return "refused: no restore.txt"
val listed = manifest.readLines().filter { it.isNotBlank() }.map { line ->
val hash = line.substringBefore(' '); val path = line.substringAfter(' ').trimStart(' ', '*')
if (!Regex("[0-9a-f]{64}").matches(hash) || !allowed.matches(path) || path.split('/').any { it == ".." || it == "." || it.isEmpty() })
return "refused: not allowed: ${path.take(80)}"
path to hash
}
if (listed.none { it.first.startsWith("no_backup/schedule/") }) return "refused: no schedules listed"
for ((path, hash) in listed) {
val f = File(source, path)
if (!f.isFile || sha256(f) != hash) return "refused: checksum: ${path.take(80)}"
}
// Keep what's there now, before anything changes.
val aside = File(data, "no_backup/before-restore-$nowMs")
File(data, "no_backup/schedule").takeIf { it.isDirectory }?.copyRecursively(File(aside, "schedule"), overwrite = true)
listOf("orbit_settings.xml", "orbit_profiles.xml").forEach { n -> File(data, "shared_prefs/$n").takeIf { it.isFile }?.copyTo(File(aside, "shared_prefs/$n"), overwrite = true) }
File(data, "no_backup").listFiles { f -> f.name.startsWith("before-restore-") }.orEmpty().sortedBy { it.name }.dropLast(2).forEach { it.deleteRecursively() }
// The schedules: built beside, then swapped in.
val incoming = File(data, "no_backup/schedule.restoring").apply { deleteRecursively(); mkdirs() }
for ((path, _) in listed.filter { it.first.startsWith("no_backup/schedule/") })
File(source, path).copyTo(File(incoming, path.removePrefix("no_backup/schedule/")), overwrite = true)
val live = File(data, "no_backup/schedule"); val old = File(data, "no_backup/schedule.replaced")
old.deleteRecursively()
if (live.exists() && !live.renameTo(old)) { incoming.deleteRecursively(); return "failed: couldn't move the current schedules" }
if (!incoming.renameTo(live)) { old.renameTo(live); incoming.deleteRecursively(); return "failed: couldn't swap in; current schedules kept" }
old.deleteRecursively()
for ((path, _) in listed.filter { it.first.startsWith("shared_prefs/") }) writeSafely(File(data, path)) { out -> File(source, path).inputStream().use { it.copyTo(out) } }
return "restored ${listed.size}; aside before-restore-$nowMs"
}
private fun sha256(file: File): String = file.inputStream().use { input ->
val digest = MessageDigest.getInstance("SHA-256"); val buffer = ByteArray(64 * 1024)
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
digest.digest().joinToString("") { "%02x".format(it) }
}
}
@@ -56,8 +56,10 @@ class MainActivity : ComponentActivity() {
}
// Phones stay connected while Orbit is in the background (Paul, 8 November 2026: the web app couldn't connect while the TV
// was in another app), and paired rooms keep syncing; the phone says Orbit isn't on screen instead of failing to load.
override fun onDestroy() { InstallSnapshot.screenOpen = false; super.onDestroy() }
override fun onStop() { OrbitCompanion.onTv = false; PhoneLink.offerRoom(null); if (PhoneLink.phones.isEmpty() && RoomLinks.links.none { it.on }) { RoomBeacon.off(this); PhoneLink.stop() }; super.onStop() }
override fun onCreate(savedInstanceState: Bundle?) {
InstallSnapshot.screenOpen = true
OrbitCompanion.activity = java.lang.ref.WeakReference(this)
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@@ -0,0 +1,65 @@
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
import java.security.MessageDigest
/** 8 November 2026 (audit U12): a pulled snapshot can be put back, only deliberately, only checked, keeping what was there. */
class InstallRestoreTest {
@get:Rule val tmp = TemporaryFolder()
private fun sha(f: File) = MessageDigest.getInstance("SHA-256").digest(f.readBytes()).joinToString("") { "%02x".format(it) }
private fun put(root: File, path: String, text: String) = File(root, path).apply { parentFile.mkdirs(); writeText(text) }
private fun manifest(src: File, paths: List<String>) = File(src, "restore.txt").writeText(paths.joinToString("") { "${sha(File(src, it))} $it\n" })
private fun box(): Pair<File, File> {
val data = tmp.newFolder("data"); val src = tmp.newFolder("src")
put(data, "no_backup/schedule/booked-0.json", "now"); put(data, "no_backup/schedule/only-now.json", "x")
put(data, "shared_prefs/orbit_profiles.xml", "<now/>"); put(data, "no_backup/catalogue.db", "library")
put(src, "no_backup/schedule/booked-0.json", "then"); put(src, "no_backup/schedule/taste/grown.events.jsonl", "{}\n")
put(src, "shared_prefs/orbit_profiles.xml", "<then/>")
return data to src
}
@Test fun putsTheSnapshotBackAndKeepsWhatWasThere() {
val (data, src) = box()
manifest(src, listOf("no_backup/schedule/booked-0.json", "no_backup/schedule/taste/grown.events.jsonl", "shared_prefs/orbit_profiles.xml"))
val said = InstallRestore.apply(data, src, "RESTORE", 1000L)
assertEquals("restored 3; aside before-restore-1000", said)
assertEquals("then", File(data, "no_backup/schedule/booked-0.json").readText())
assertEquals("{}\n", File(data, "no_backup/schedule/taste/grown.events.jsonl").readText())
assertFalse(File(data, "no_backup/schedule/only-now.json").exists())
assertEquals("<then/>", File(data, "shared_prefs/orbit_profiles.xml").readText())
// What was there, kept aside; the library untouched; nothing half-done left behind.
assertEquals("now", File(data, "no_backup/before-restore-1000/schedule/booked-0.json").readText())
assertEquals("<now/>", File(data, "no_backup/before-restore-1000/shared_prefs/orbit_profiles.xml").readText())
assertEquals("library", File(data, "no_backup/catalogue.db").readText())
assertFalse(File(data, "no_backup/schedule.restoring").exists()); assertFalse(File(data, "no_backup/schedule.replaced").exists())
}
@Test fun nothingChangesWithoutConfirmOrWithABadChecksum() {
val (data, src) = box()
manifest(src, listOf("no_backup/schedule/booked-0.json"))
assertTrue(InstallRestore.apply(data, src, null, 1L).startsWith("refused"))
File(src, "no_backup/schedule/booked-0.json").writeText("tampered")
assertTrue(InstallRestore.apply(data, src, "RESTORE", 1L).startsWith("refused: checksum"))
assertEquals("now", File(data, "no_backup/schedule/booked-0.json").readText())
assertFalse(File(data, "no_backup/before-restore-1").exists())
}
@Test fun onlyWhatASnapshotTakesCanComeBack() {
val (data, src) = box()
put(src, "no_backup/catalogue.db", "other library"); put(src, "shared_prefs/orbit_lock.xml", "<pin/>")
for (bad in listOf("no_backup/catalogue.db", "shared_prefs/orbit_lock.xml", "no_backup/schedule/../catalogue.db", "no_backup/orbit-connection.v1")) {
File(src, "restore.txt").writeText("${"0".repeat(64)} $bad\n")
assertTrue(bad, InstallRestore.apply(data, src, "RESTORE", 1L).startsWith("refused"))
}
assertEquals("library", File(data, "no_backup/catalogue.db").readText())
}
@Test fun keepsOnlyTheLastTwoSetAsides() {
val (data, src) = box()
manifest(src, listOf("no_backup/schedule/booked-0.json"))
for (t in 1L..4L) InstallRestore.apply(data, src, "RESTORE", t)
assertEquals(listOf("before-restore-3", "before-restore-4"), File(data, "no_backup").list()!!.filter { it.startsWith("before-restore-") }.sorted())
}
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Put a pulled Orbit snapshot back on a TV (8 November 2026, audit U12).
# tools/restore-tv.sh <adb-device> <snapshot-folder> e.g. 192.168.0.252:5555 backups/install-0.108.107-.../before
# The snapshot folder is one pulled by the install procedure (no_backup/schedule and shared_prefs inside it).
# Orbit is force-stopped first; the TV keeps what it had in no_backup/before-restore-<time> (the last two are kept).
# Only schedules, settings and profiles come back; never the library catalogue, the sign-in or the PIN.
set -euo pipefail
A=${ADB:-/opt/android-sdk/platform-tools/adb}; D=$1; SNAP=$(cd "$2" && pwd)
[ -d "$SNAP/no_backup/schedule" ] || { echo "Not a snapshot folder: $SNAP"; exit 1; }
REMOTE=/sdcard/Android/data/tv.orbit.classic/files/install-restore
STAGE=$(mktemp -d); trap 'rm -rf "$STAGE"' EXIT
( cd "$SNAP" && find no_backup/schedule -type f; for p in shared_prefs/orbit_settings.xml shared_prefs/orbit_profiles.xml; do [ -f "$p" ] && echo "$p"; done ) | sort > "$STAGE/list"
( cd "$SNAP" && xargs -d '\n' sha256sum < "$STAGE/list" ) > "$STAGE/restore.txt"
echo "restoring $(wc -l < "$STAGE/list") files from $SNAP"
read -r -p "Type RESTORE to put this snapshot back on $D: " ok; [ "$ok" = "RESTORE" ] || { echo "Not restored."; exit 1; }
$A -s "$D" shell am force-stop tv.orbit.classic
$A -s "$D" shell rm -rf "$REMOTE"
$A -s "$D" push "$SNAP/no_backup" "$REMOTE/no_backup" >/dev/null
[ -d "$SNAP/shared_prefs" ] && $A -s "$D" push "$SNAP/shared_prefs" "$REMOTE/shared_prefs" >/dev/null
$A -s "$D" push "$STAGE/restore.txt" "$REMOTE/restore.txt" >/dev/null
$A -s "$D" shell am broadcast -f 0x20 -n tv.orbit.classic/.InstallSnapshotReceiver -a tv.orbit.classic.INSTALL_SNAPSHOT --es do restore --es confirm RESTORE | grep -o 'data="[^"]*"'
$A -s "$D" shell am force-stop tv.orbit.classic
$A -s "$D" shell rm -rf "$REMOTE"
$A -s "$D" shell am start -n tv.orbit.classic/.MainActivity >/dev/null
echo "Orbit restarted. Take a snapshot and compare it with $SNAP to check."