Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/dev/adrian/thortools/SystemBackend.kt
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ data class ThorSnapshot(
val stockRestoreAvailable: Boolean = false,
val patchedBackupAvailable: Boolean,
val patchedCacheAvailable: Boolean = false,
val availableBootSlots: Set<String> = emptySet(),
val stockBackupSlots: Set<String> = emptySet(),
val patchedBackupSlots: Set<String> = emptySet(),
val operation: OperationState,
Expand All @@ -121,6 +122,9 @@ data class ThorSnapshot(
else -> "Unavailable"
}

val stockBackupCoverageReady: Boolean
get() = availableBootSlots.isNotEmpty() && availableBootSlots.all(stockBackupSlots::contains)

val capabilityRows: List<Pair<String, Boolean>>
get() = listOf(
"Thor device" to profile.isThor,
Expand Down Expand Up @@ -156,6 +160,7 @@ data class ThorSnapshot(
stockRestoreAvailable = false,
patchedBackupAvailable = false,
patchedCacheAvailable = false,
availableBootSlots = emptySet(),
operation = operation,
)
}
Expand Down Expand Up @@ -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 -> {
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
17 changes: 15 additions & 2 deletions app/src/main/java/dev/adrian/thortools/screens/ThorScreens.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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),
Expand All @@ -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") }
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/dev/adrian/thortools/utils/PatchUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ object PatchUtils {
internal fun hasCompleteSlotCoverage(requiredSlots: Set<String>, backedUpSlots: Set<String>): Boolean =
requiredSlots.isNotEmpty() && requiredSlots.all(backedUpSlots::contains)

fun availableBootSlots(context: Context): Set<String> = slots.filter { slot ->
preferredPartition(context, slot) != null
}.toSet()

fun stockBackupSlots(context: Context): Set<String> = slots.filter { slot ->
val partition = preferredPartition(context, slot) ?: return@filter false
RecoveryManifestStore.hasVerifiedStockImage(
Expand Down
22 changes: 22 additions & 0 deletions app/src/test/java/dev/adrian/thortools/PatchUtilsTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
28 changes: 28 additions & 0 deletions app/src/test/java/dev/adrian/thortools/ThorOperationGuardTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -178,6 +202,8 @@ class ThorOperationGuardTest {
stockRestoreAvailable: Boolean = backupAvailable,
patchedBackupAvailable: Boolean = false,
batteryAvailable: Boolean = true,
availableBootSlots: Set<String> = setOf("_a", "_b"),
stockBackupSlots: Set<String> = if (backupAvailable) availableBootSlots else emptySet(),
): ThorSnapshot {
return ThorSnapshot(
profile = DeviceProfile.detect(DeviceProperties(model = "AYN Thor")).copy(
Expand All @@ -199,6 +225,8 @@ class ThorOperationGuardTest {
stockRestoreAvailable = stockRestoreAvailable,
patchedBackupAvailable = patchedBackupAvailable,
patchedCacheAvailable = patchedBackupAvailable,
availableBootSlots = availableBootSlots,
stockBackupSlots = stockBackupSlots,
operation = OperationState(),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -97,6 +107,8 @@ class ThorRecoveryGuidanceTest {
stockRestoreAvailable: Boolean = backupAvailable,
patchedBackupAvailable: Boolean = false,
batteryAvailable: Boolean = true,
availableBootSlots: Set<String> = setOf("_a", "_b"),
stockBackupSlots: Set<String> = if (backupAvailable) availableBootSlots else emptySet(),
): ThorSnapshot {
return ThorSnapshot(
profile = DeviceProfile.detect(DeviceProperties(model = "AYN Thor")).copy(
Expand All @@ -117,6 +129,8 @@ class ThorRecoveryGuidanceTest {
backupAvailable = backupAvailable,
stockRestoreAvailable = stockRestoreAvailable,
patchedBackupAvailable = patchedBackupAvailable,
availableBootSlots = availableBootSlots,
stockBackupSlots = stockBackupSlots,
operation = OperationState(),
)
}
Expand Down
2 changes: 1 addition & 1 deletion docs/EZROOT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/USERGUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down