Restore sends one zip (rehearsed on the TV). Ported from release-0.108 (0.108.109)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,10 +24,16 @@ import java.security.MessageDigest
|
||||
class InstallSnapshotReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val result = runCatching {
|
||||
if (intent.getStringExtra("do") == "restore") {
|
||||
if (intent.getStringExtra("do") == "prepare-restore") {
|
||||
// adb can't make folders in Orbit's shared folder on Android 14, only put files in one Orbit made.
|
||||
File(context.getExternalFilesDir(null), "install-restore").run { deleteRecursively(); mkdirs(); "ready" }
|
||||
} else 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 File(context.getExternalFilesDir(null), "install-restore").let { folder ->
|
||||
// The pushed copy goes once it's been used, restored or refused.
|
||||
try { InstallRestore.apply(context.filesDir.parentFile!!, folder, intent.getStringExtra("confirm"), System.currentTimeMillis()) }
|
||||
finally { if (intent.getStringExtra("confirm") == "RESTORE") folder.deleteRecursively() }
|
||||
}
|
||||
} else InstallSnapshot.handle(context, intent.getStringExtra("do").orEmpty(), intent.getStringExtra("version"))
|
||||
}
|
||||
resultData = result.fold({ it }, { "failed" })
|
||||
@@ -95,8 +101,24 @@ internal object InstallSnapshot {
|
||||
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 {
|
||||
fun apply(data: File, folder: File, confirm: String?, nowMs: Long): String {
|
||||
if (confirm != "RESTORE") return "refused: needs confirm RESTORE"
|
||||
// The snapshot comes as one restore.zip (adb can only put files in a folder Orbit made): unpacked beside it, taking
|
||||
// only restore.txt and the names a snapshot can hold, so nothing can be written anywhere else.
|
||||
val zip = File(folder, "restore.zip")
|
||||
val source = if (!zip.isFile) folder else File(folder, "unpacked").also { out ->
|
||||
out.deleteRecursively(); out.mkdirs()
|
||||
java.util.zip.ZipInputStream(zip.inputStream().buffered()).use { z ->
|
||||
while (true) {
|
||||
val e = z.nextEntry ?: break
|
||||
if (e.isDirectory) continue
|
||||
val name = e.name
|
||||
if (name != "restore.txt" && (!allowed.matches(name) || name.split('/').any { it == ".." || it == "." || it.isEmpty() }))
|
||||
return "refused: not allowed in zip: ${name.take(80)}"
|
||||
File(out, name).apply { parentFile?.mkdirs() }.outputStream().use { z.copyTo(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
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(' ', '*')
|
||||
|
||||
@@ -62,4 +62,22 @@ class InstallRestoreTest {
|
||||
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())
|
||||
}
|
||||
|
||||
@Test fun aZipFromThePcIsUnpackedAndRestored() {
|
||||
val (data, src) = box()
|
||||
manifest(src, listOf("no_backup/schedule/booked-0.json", "shared_prefs/orbit_profiles.xml"))
|
||||
val folder = tmp.newFolder("pushed")
|
||||
java.util.zip.ZipOutputStream(File(folder, "restore.zip").outputStream()).use { z ->
|
||||
for (n in listOf("no_backup/schedule/booked-0.json", "shared_prefs/orbit_profiles.xml", "restore.txt")) { z.putNextEntry(java.util.zip.ZipEntry(n)); z.write(File(src, n).readBytes()); z.closeEntry() }
|
||||
}
|
||||
assertTrue(InstallRestore.apply(data, folder, "RESTORE", 5L).startsWith("restored 2"))
|
||||
assertEquals("then", File(data, "no_backup/schedule/booked-0.json").readText())
|
||||
}
|
||||
@Test fun aZipCantWriteOutsideTheSnapshot() {
|
||||
val (data, _) = box()
|
||||
val folder = tmp.newFolder("evil")
|
||||
java.util.zip.ZipOutputStream(File(folder, "restore.zip").outputStream()).use { z -> z.putNextEntry(java.util.zip.ZipEntry("../../no_backup/catalogue.db")); z.write("x".toByteArray()); z.closeEntry() }
|
||||
assertTrue(InstallRestore.apply(data, folder, "RESTORE", 5L).startsWith("refused"))
|
||||
assertEquals("library", File(data, "no_backup/catalogue.db").readText())
|
||||
}
|
||||
}
|
||||
|
||||
+9
-7
@@ -8,18 +8,20 @@ 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
|
||||
STAGE=$(mktemp -d); trap 'rm -rf "$STAGE"; $A -s "$D" shell am start -n tv.orbit.classic/.MainActivity >/dev/null 2>&1 || true' 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; }
|
||||
( cd "$SNAP" && python3 -I -c 'import sys,zipfile
|
||||
z=zipfile.ZipFile(sys.argv[1],"w",zipfile.ZIP_DEFLATED)
|
||||
[z.write(p.strip(),p.strip()) for p in open(sys.argv[2]) if p.strip()]
|
||||
z.write(sys.argv[3],"restore.txt"); z.close()' "$STAGE/restore.zip" "$STAGE/list" "$STAGE/restore.txt" )
|
||||
$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="[^"]*"'
|
||||
R="$A -s $D shell am broadcast -f 0x20 -n tv.orbit.classic/.InstallSnapshotReceiver -a tv.orbit.classic.INSTALL_SNAPSHOT"
|
||||
$R --es do prepare-restore | grep -q 'data="ready"' || { echo "The TV couldn't get ready; nothing changed."; $A -s "$D" shell am start -n tv.orbit.classic/.MainActivity >/dev/null; exit 1; }
|
||||
$A -s "$D" push "$STAGE/restore.zip" "$REMOTE/restore.zip" >/dev/null
|
||||
$R --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."
|
||||
|
||||
Reference in New Issue
Block a user