From 066056647582aa0f2bd9542963abdefbcd5a016e Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:26:44 +0200 Subject: [PATCH] fix: require complete Thor slot backups --- .../thortools/DebugSystemBackendFactory.kt | 1 + .../dev/adrian/thortools/SystemBackend.kt | 9 ++++++ .../adrian/thortools/ThorRecoveryGuidance.kt | 4 +-- .../adrian/thortools/screens/ThorScreens.kt | 17 +++++++++-- .../dev/adrian/thortools/utils/PatchUtils.kt | 4 +++ .../dev/adrian/thortools/PatchUtilsTest.kt | 22 +++++++++++++++ .../thortools/ThorOperationGuardTest.kt | 28 +++++++++++++++++++ .../thortools/ThorRecoveryGuidanceTest.kt | 16 ++++++++++- docs/EZROOT.md | 2 +- docs/USERGUIDE.md | 2 +- 10 files changed, 98 insertions(+), 7 deletions(-) diff --git a/app/src/debug/java/dev/adrian/thortools/DebugSystemBackendFactory.kt b/app/src/debug/java/dev/adrian/thortools/DebugSystemBackendFactory.kt index 371185f..1314996 100644 --- a/app/src/debug/java/dev/adrian/thortools/DebugSystemBackendFactory.kt +++ b/app/src/debug/java/dev/adrian/thortools/DebugSystemBackendFactory.kt @@ -54,6 +54,7 @@ private class FakeSystemBackend(private val context: Context) : SystemBackend { stockRestoreAvailable = false, patchedBackupAvailable = false, patchedCacheAvailable = false, + availableBootSlots = setOf("_a", "_b"), stockBackupSlots = emptySet(), patchedBackupSlots = emptySet(), operation = OperationState(), diff --git a/app/src/main/java/dev/adrian/thortools/SystemBackend.kt b/app/src/main/java/dev/adrian/thortools/SystemBackend.kt index 65e789d..98ba92c 100644 --- a/app/src/main/java/dev/adrian/thortools/SystemBackend.kt +++ b/app/src/main/java/dev/adrian/thortools/SystemBackend.kt @@ -109,6 +109,7 @@ data class ThorSnapshot( val stockRestoreAvailable: Boolean = false, val patchedBackupAvailable: Boolean, val patchedCacheAvailable: Boolean = false, + val availableBootSlots: Set = emptySet(), val stockBackupSlots: Set = emptySet(), val patchedBackupSlots: Set = emptySet(), val operation: OperationState, @@ -121,6 +122,9 @@ data class ThorSnapshot( else -> "Unavailable" } + val stockBackupCoverageReady: Boolean + get() = availableBootSlots.isNotEmpty() && availableBootSlots.all(stockBackupSlots::contains) + val capabilityRows: List> get() = listOf( "Thor device" to profile.isThor, @@ -156,6 +160,7 @@ data class ThorSnapshot( stockRestoreAvailable = false, patchedBackupAvailable = false, patchedCacheAvailable = false, + availableBootSlots = emptySet(), operation = operation, ) } @@ -213,11 +218,13 @@ object ThorOperationGuard { if (snapshot.rooted) return "The Thor is already rooted; restore stock before preparing another patch" if (!snapshot.magiskInstalled) return "Install Magisk before preparing a root patch" if (!snapshot.backupAvailable) return "Create a verified ${snapshot.recoveryPartition} stock backup before patching" + if (!snapshot.stockBackupCoverageReady) return "Create verified stock backups for every available Thor slot before patching" } ThorOperation.FLASH -> { if (snapshot.rooted) return "The Thor already reports root access; restore stock before flashing again" if (!snapshot.magiskInstalled) return "Install Magisk before flashing a root patch" if (!snapshot.stockRestoreAvailable) return "Keep a verified ${snapshot.recoveryPartition} stock backup before flashing a root patch" + if (!snapshot.stockBackupCoverageReady) return "Create verified stock backups for every available Thor slot before flashing" if (!snapshot.patchedBackupAvailable) return "Prepare a Magisk-patched active-slot image first" } ThorOperation.RESTORE -> { @@ -243,6 +250,7 @@ class RealSystemBackend(private val context: Context) : SystemBackend { val initBoot = rootService && RootUtils.hasPartition(context, "init_boot", properties.slot) val boot = rootService && RootUtils.hasPartition(context, "boot", properties.slot) val backupDestination = FileUtils.isBackupDestinationWritable(context) + val availableBootSlots = PatchUtils.availableBootSlots(context) val capabilities = buildSet { if (rootService) add(ThorCapability.ROOT_SERVICE) if (rooted) add(ThorCapability.ROOTED) @@ -271,6 +279,7 @@ class RealSystemBackend(private val context: Context) : SystemBackend { stockRestoreAvailable = PatchUtils.checkActiveSlotRestoreExists(context), patchedBackupAvailable = PatchUtils.checkBootMagiskExists(context), patchedCacheAvailable = PatchUtils.hasPatchedCache(context), + availableBootSlots = availableBootSlots, stockBackupSlots = PatchUtils.stockBackupSlots(context), patchedBackupSlots = PatchUtils.patchedBackupSlots(context), operation = operation, diff --git a/app/src/main/java/dev/adrian/thortools/ThorRecoveryGuidance.kt b/app/src/main/java/dev/adrian/thortools/ThorRecoveryGuidance.kt index 5f493c9..8905bac 100644 --- a/app/src/main/java/dev/adrian/thortools/ThorRecoveryGuidance.kt +++ b/app/src/main/java/dev/adrian/thortools/ThorRecoveryGuidance.kt @@ -26,10 +26,10 @@ object ThorRecoveryGuidance { "The Thor is rooted without a verified stock restore source. Do not update until a stock image is secured." snapshot.rooted && snapshot.stockRestoreAvailable -> "Keep the verified stock image before OTA or service work, and restore stock before updating." + !snapshot.backupAvailable || !snapshot.stockBackupCoverageReady -> + "Create verified stock backups for every available slot before preparing a root patch." snapshot.patchedBackupAvailable && !snapshot.rooted -> "A verified active-slot patch is ready. Review the hashes and flash only the active slot." - !snapshot.backupAvailable -> - "Create verified stock backups for every available slot before preparing a root patch." !snapshot.magiskInstalled -> "Install Magisk after the stock backup is verified, then return to EZ Root." else -> diff --git a/app/src/main/java/dev/adrian/thortools/screens/ThorScreens.kt b/app/src/main/java/dev/adrian/thortools/screens/ThorScreens.kt index 92ca345..c4b9ce0 100644 --- a/app/src/main/java/dev/adrian/thortools/screens/ThorScreens.kt +++ b/app/src/main/java/dev/adrian/thortools/screens/ThorScreens.kt @@ -204,11 +204,19 @@ private fun DashboardIdentity(snapshot: ThorSnapshot) { DataLine("Build fingerprint", snapshot.profile.properties.buildFingerprint) DataLine("Serial", snapshot.profile.properties.serial) DataLine("Active slot", snapshot.activeSlot) + DataLine("Available boot slots", snapshot.availableBootSlots.sorted().joinToString(", ")) DataLine("Root service", if (snapshot.rootServiceAvailable) "Available" else "Unavailable") DataLine("Root state", if (snapshot.rooted) "Rooted" else "Not rooted") DataLine("Magisk", if (snapshot.magiskInstalled) "Installed" else "Not installed") DataLine("Recovery target", snapshot.recoveryPartition) - DataLine("Stock backups", "${snapshot.stockBackupSlots.size}/2 slots") + DataLine( + "Stock backups", + if (snapshot.availableBootSlots.isEmpty()) { + "Unavailable" + } else { + "${snapshot.stockBackupSlots.size}/${snapshot.availableBootSlots.size} slots" + }, + ) DataLine("Stock restore source", if (snapshot.stockRestoreAvailable) "Available" else "Unavailable") DataLine("Patched backups", "${snapshot.patchedBackupSlots.size}/2 slots") DataLine("Battery", if (snapshot.batteryPercent > 0) "${snapshot.batteryPercent}%" else "Unavailable") @@ -461,6 +469,11 @@ private fun RootPanel(session: ThorSession, context: Context, operationScope: Co actionReady && ThorOperationGuard.validate(snapshot, operation) == null val backupReason = if (rootReady) ThorOperationGuard.validate(snapshot, ThorOperation.BACKUP) else null val commandModifier = Modifier.fillMaxWidth().heightIn(min = 56.dp) + val backupLabel = if (snapshot.availableBootSlots.isEmpty()) { + "Back up available slots" + } else { + "Back up available slots (${snapshot.stockBackupSlots.size}/${snapshot.availableBootSlots.size} ready)" + } Column( modifier = Modifier.fillMaxWidth().fillMaxHeight().verticalScroll(rememberScrollState()).padding(16.dp), @@ -469,7 +482,7 @@ private fun RootPanel(session: ThorSession, context: Context, operationScope: Co Text("EZ Root for AYN Thor", style = MaterialTheme.typography.headlineSmall) Text("ThorTools checks the active slot and partition layout again before each image operation. Backups are copied to the app folder and Download folder.") Button(enabled = operationReady(ThorOperation.INSTALL_MAGISK) && !snapshot.magiskInstalled, onClick = { session.run(operationScope, ThorOperation.INSTALL_MAGISK) }, modifier = commandModifier) { Text(if (snapshot.magiskInstalled) "Magisk installed" else "Download Magisk") } - Button(enabled = operationReady(ThorOperation.BACKUP) && snapshot.stockBackupSlots.size < 2, onClick = { pendingOperation = ThorOperation.BACKUP }, modifier = commandModifier) { Text("Back up available slots (${snapshot.stockBackupSlots.size}/2 ready)") } + Button(enabled = operationReady(ThorOperation.BACKUP) && !snapshot.stockBackupCoverageReady, onClick = { pendingOperation = ThorOperation.BACKUP }, modifier = commandModifier) { Text(backupLabel) } Button(enabled = operationReady(ThorOperation.PATCH) && !snapshot.patchedBackupAvailable, onClick = { pendingOperation = ThorOperation.PATCH }, modifier = commandModifier) { Text("Prepare root patch") } Button(enabled = operationReady(ThorOperation.FLASH), onClick = { pendingOperation = ThorOperation.FLASH }, modifier = commandModifier) { Text("Flash active-slot patch") } Button(enabled = operationReady(ThorOperation.RESTORE), onClick = { pendingOperation = ThorOperation.RESTORE }, modifier = commandModifier) { Text("Restore stock image") } diff --git a/app/src/main/java/dev/adrian/thortools/utils/PatchUtils.kt b/app/src/main/java/dev/adrian/thortools/utils/PatchUtils.kt index 9a02ae6..0ad3fb2 100644 --- a/app/src/main/java/dev/adrian/thortools/utils/PatchUtils.kt +++ b/app/src/main/java/dev/adrian/thortools/utils/PatchUtils.kt @@ -29,6 +29,10 @@ object PatchUtils { internal fun hasCompleteSlotCoverage(requiredSlots: Set, backedUpSlots: Set): Boolean = requiredSlots.isNotEmpty() && requiredSlots.all(backedUpSlots::contains) + fun availableBootSlots(context: Context): Set = slots.filter { slot -> + preferredPartition(context, slot) != null + }.toSet() + fun stockBackupSlots(context: Context): Set = slots.filter { slot -> val partition = preferredPartition(context, slot) ?: return@filter false RecoveryManifestStore.hasVerifiedStockImage( diff --git a/app/src/test/java/dev/adrian/thortools/PatchUtilsTest.kt b/app/src/test/java/dev/adrian/thortools/PatchUtilsTest.kt index b64251d..ee5c8fe 100644 --- a/app/src/test/java/dev/adrian/thortools/PatchUtilsTest.kt +++ b/app/src/test/java/dev/adrian/thortools/PatchUtilsTest.kt @@ -16,6 +16,28 @@ class PatchUtilsTest { assertFalse(PatchUtils.hasCompleteSlotCoverage(emptySet(), setOf("_a", "_b"))) } + @Test + fun reportsCompleteCoverageOnlyForEveryAvailableSlot() { + assertTrue( + ThorSnapshot.loading(OperationState()).copy( + availableBootSlots = setOf("_a", "_b"), + stockBackupSlots = setOf("_a", "_b"), + ).stockBackupCoverageReady, + ) + assertFalse( + ThorSnapshot.loading(OperationState()).copy( + availableBootSlots = setOf("_a", "_b"), + stockBackupSlots = setOf("_a"), + ).stockBackupCoverageReady, + ) + assertFalse( + ThorSnapshot.loading(OperationState()).copy( + availableBootSlots = emptySet(), + stockBackupSlots = setOf("_a", "_b"), + ).stockBackupCoverageReady, + ) + } + @Test fun selectPartitionUsesInitBootWhenBothPartitionsExist() { assertEquals("init_boot", PatchUtils.selectPartition(initBootAvailable = true, bootAvailable = true)) diff --git a/app/src/test/java/dev/adrian/thortools/ThorOperationGuardTest.kt b/app/src/test/java/dev/adrian/thortools/ThorOperationGuardTest.kt index a6e2425..fbf1174 100644 --- a/app/src/test/java/dev/adrian/thortools/ThorOperationGuardTest.kt +++ b/app/src/test/java/dev/adrian/thortools/ThorOperationGuardTest.kt @@ -101,6 +101,30 @@ class ThorOperationGuardTest { ) } + @Test + fun requiresCompleteStockCoverageBeforePatchOrFlash() { + assertEquals( + "Create verified stock backups for every available Thor slot before patching", + ThorOperationGuard.validate( + snapshot(magiskInstalled = true, backupAvailable = true, stockBackupSlots = setOf("_a")), + ThorOperation.PATCH, + ), + ) + assertEquals( + "Create verified stock backups for every available Thor slot before flashing", + ThorOperationGuard.validate( + snapshot( + magiskInstalled = true, + backupAvailable = true, + stockRestoreAvailable = true, + patchedBackupAvailable = true, + stockBackupSlots = setOf("_a"), + ), + ThorOperation.FLASH, + ), + ) + } + @Test fun keepsPatchedCacheCleanupAvailableWithoutRootService() { assertNull( @@ -178,6 +202,8 @@ class ThorOperationGuardTest { stockRestoreAvailable: Boolean = backupAvailable, patchedBackupAvailable: Boolean = false, batteryAvailable: Boolean = true, + availableBootSlots: Set = setOf("_a", "_b"), + stockBackupSlots: Set = if (backupAvailable) availableBootSlots else emptySet(), ): ThorSnapshot { return ThorSnapshot( profile = DeviceProfile.detect(DeviceProperties(model = "AYN Thor")).copy( @@ -199,6 +225,8 @@ class ThorOperationGuardTest { stockRestoreAvailable = stockRestoreAvailable, patchedBackupAvailable = patchedBackupAvailable, patchedCacheAvailable = patchedBackupAvailable, + availableBootSlots = availableBootSlots, + stockBackupSlots = stockBackupSlots, operation = OperationState(), ) } diff --git a/app/src/test/java/dev/adrian/thortools/ThorRecoveryGuidanceTest.kt b/app/src/test/java/dev/adrian/thortools/ThorRecoveryGuidanceTest.kt index d7121cd..5960965 100644 --- a/app/src/test/java/dev/adrian/thortools/ThorRecoveryGuidanceTest.kt +++ b/app/src/test/java/dev/adrian/thortools/ThorRecoveryGuidanceTest.kt @@ -72,7 +72,17 @@ class ThorRecoveryGuidanceTest { ) assertEquals( "A verified active-slot patch is ready. Review the hashes and flash only the active slot.", - ThorRecoveryGuidance.forSnapshot(snapshot(patchedBackupAvailable = true)), + ThorRecoveryGuidance.forSnapshot(snapshot(backupAvailable = true, patchedBackupAvailable = true)), + ) + assertEquals( + "Create verified stock backups for every available slot before preparing a root patch.", + ThorRecoveryGuidance.forSnapshot( + snapshot( + backupAvailable = true, + patchedBackupAvailable = true, + stockBackupSlots = setOf("_a"), + ), + ), ) assertEquals( "Create verified stock backups for every available slot before preparing a root patch.", @@ -97,6 +107,8 @@ class ThorRecoveryGuidanceTest { stockRestoreAvailable: Boolean = backupAvailable, patchedBackupAvailable: Boolean = false, batteryAvailable: Boolean = true, + availableBootSlots: Set = setOf("_a", "_b"), + stockBackupSlots: Set = if (backupAvailable) availableBootSlots else emptySet(), ): ThorSnapshot { return ThorSnapshot( profile = DeviceProfile.detect(DeviceProperties(model = "AYN Thor")).copy( @@ -117,6 +129,8 @@ class ThorRecoveryGuidanceTest { backupAvailable = backupAvailable, stockRestoreAvailable = stockRestoreAvailable, patchedBackupAvailable = patchedBackupAvailable, + availableBootSlots = availableBootSlots, + stockBackupSlots = stockBackupSlots, operation = OperationState(), ) } diff --git a/docs/EZROOT.md b/docs/EZROOT.md index 22177c4..f315310 100644 --- a/docs/EZROOT.md +++ b/docs/EZROOT.md @@ -14,7 +14,7 @@ EZ Root is an on-device workflow for backing up, Magisk-patching, flashing, and 1. Open the EZ Root section on the lower display and review the upper dashboard. 2. Download and install Magisk if it is not already present. 3. Choose **Back up available slots**. ThorTools stores every discovered slot image in its app folder, copies each one to `Download`, and shows the exact app-local paths and SHA-256 hashes on both displays. -4. Choose **Prepare root patch**. ThorTools uses the active slot's `init_boot` image when that partition exists and uses `boot` only when `init_boot` is unavailable; it blocks if the selected target lacks a verified stock image. +4. Choose **Prepare root patch**. ThorTools uses the active slot's `init_boot` image when that partition exists and uses `boot` only when `init_boot` is unavailable; it blocks if the selected target lacks a verified stock image or any currently available slot is missing its stock backup. 5. Review the displayed image hashes and confirm **Flash active-slot patch**. 6. After reboot, complete Magisk's additional setup. diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index e1ab52d..9e6acd7 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -16,7 +16,7 @@ Display and animation controls require the Thor privileged root service. Volume- The root flow checks the active slot and partition layout before every backup, patch, flash, and restore. The privileged script receives that validated slot, rechecks the image hash and partition capacity immediately before writing, and aborts if the device changes slots while the operation is starting. It requires a 35% battery level, a non-empty image, and an explicit confirmation on the lower display for writes. When both partitions exist, `init_boot` is the explicit recovery target; ThorTools blocks rather than silently falling back to `boot` when that target lacks a verified stock image. A stock backup succeeds only after every discovered slot and its independent Download copy are complete. -Each recovery record is bound to its slot, partition, build fingerprint, file size, and hash. Editing, replacing, or carrying an image across an OTA invalidates it; run **Back up available slots** again after a firmware change. +Each recovery record is bound to its slot, partition, build fingerprint, file size, and hash. Editing, replacing, or carrying an image across an OTA invalidates it; run **Back up available slots** again after a firmware change. Preparing or flashing a root patch stays blocked until every boot slot currently exposed by the Thor has a verified stock backup. Flashing is blocked unless a stock active-slot restore source is still available. The patched-cache cleanup action only changes app-local files, including stale or cross-build patched files, and remains available even when the privileged root service is temporarily unavailable.