diff --git a/source/app/src/main/kotlin/com/xayah/databackup/MainActivity.kt b/source/app/src/main/kotlin/com/xayah/databackup/MainActivity.kt index d91818b56d..f745c58ba6 100644 --- a/source/app/src/main/kotlin/com/xayah/databackup/MainActivity.kt +++ b/source/app/src/main/kotlin/com/xayah/databackup/MainActivity.kt @@ -152,6 +152,12 @@ class MainActivity : AppCompatActivity() { composable(route = MainRoutes.Directory.route) { PageDirectory() } + composable(MainRoutes.VerifyBackup.route) { navBackStackEntry -> + val storageMode = navBackStackEntry.arguments?.getString(MainRoutes.ARG_STORAGE_MODE) ?: "Local" + val cloudName = navBackStackEntry.arguments?.getString(MainRoutes.ARG_ACCOUNT_NAME) + val backupDir = navBackStackEntry.arguments?.getString(MainRoutes.ARG_ACCOUNT_REMOTE) ?: "" + com.xayah.feature.main.verify.VerifyBackupPage(storageMode = storageMode, cloudName = cloudName, backupDir = backupDir) + } } } } diff --git a/source/app/src/main/res/values/strings.xml b/source/app/src/main/res/values/strings.xml index fedaadd350..2d1dc4463c 100644 --- a/source/app/src/main/res/values/strings.xml +++ b/source/app/src/main/res/values/strings.xml @@ -420,4 +420,13 @@ This is needed to post necessary notifications Permission is denied Please check your root manager and restart app + Compression Type + Select the compression type for backups. TWRP ZIP is compatible with TWRP recovery. + Verify Backup + Check integrity of selected backup + Verify Backup + Verifying backup... + Verification Results + Backup verification successful. All files are intact. + Backup verification failed. Some files may be corrupted. \ No newline at end of file diff --git a/source/core/datastore/src/main/kotlin/com/xayah/core/datastore/String.kt b/source/core/datastore/src/main/kotlin/com/xayah/core/datastore/String.kt index f5931b7ed8..bfa7597616 100644 --- a/source/core/datastore/src/main/kotlin/com/xayah/core/datastore/String.kt +++ b/source/core/datastore/src/main/kotlin/com/xayah/core/datastore/String.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.map // -----------------------------------------Keys----------------------------------------- val KeyBackupSavePath = stringPreferencesKey("backup_save_path") +val KeyCompressionType = stringPreferencesKey("compression_type") // Added key val KeyAppVersionName = stringPreferencesKey("app_version_name") val KeyCloudActivatedAccountName = stringPreferencesKey("cloud_activated_account_name") val KeyLoadedIconMD5 = stringPreferencesKey("loaded_icon_md5") diff --git a/source/core/model/src/main/kotlin/com/xayah/core/model/Enum.kt b/source/core/model/src/main/kotlin/com/xayah/core/model/Enum.kt index 6898d49f0e..73db5b1b9c 100644 --- a/source/core/model/src/main/kotlin/com/xayah/core/model/Enum.kt +++ b/source/core/model/src/main/kotlin/com/xayah/core/model/Enum.kt @@ -9,7 +9,8 @@ const val LZ4_SUFFIX = "tar.lz4" enum class CompressionType(val type: String, val suffix: String, val compressPara: String, val decompressPara: String) { TAR("tar", TAR_SUFFIX, "", ""), ZSTD("zstd", ZSTD_SUFFIX, "zstd -r -T0 --ultra -q --priority=rt", "zstd"), - LZ4("lz4", LZ4_SUFFIX, "zstd -r -T0 --ultra -q --priority=rt --format=lz4", "zstd"); + LZ4("lz4", LZ4_SUFFIX, "zstd -r -T0 --ultra -q --priority=rt --format=lz4", "zstd"), + TWRP_ZIP("zip", "zip", "", ""); // Added for TWRP backup companion object } diff --git a/source/core/model/src/main/kotlin/com/xayah/core/model/database/PackageEntity.kt b/source/core/model/src/main/kotlin/com/xayah/core/model/database/PackageEntity.kt index e26f243e3d..0b1e0546a4 100644 --- a/source/core/model/src/main/kotlin/com/xayah/core/model/database/PackageEntity.kt +++ b/source/core/model/src/main/kotlin/com/xayah/core/model/database/PackageEntity.kt @@ -206,6 +206,20 @@ data class PackageEntity( val mediaSelected: Boolean get() = dataStates.mediaState == DataState.Selected + // Added for TWRP backup selection + val backupApk: Boolean + get() = dataStates.apkState == DataState.Selected + val backupUser: Boolean + get() = dataStates.userState == DataState.Selected + val backupUserDe: Boolean + get() = dataStates.userDeState == DataState.Selected + val backupData: Boolean + get() = dataStates.dataState == DataState.Selected + val backupObb: Boolean + get() = dataStates.obbState == DataState.Selected + val backupMedia: Boolean + get() = dataStates.mediaState == DataState.Selected + companion object { const val FLAG_NONE = 0 const val FLAG_APK = 1 // 000001 diff --git a/source/core/rootservice/src/main/aidl/com/xayah/core/rootservice/IRemoteRootService.aidl b/source/core/rootservice/src/main/aidl/com/xayah/core/rootservice/IRemoteRootService.aidl index 6cf86b00b6..ad2f93773d 100644 --- a/source/core/rootservice/src/main/aidl/com/xayah/core/rootservice/IRemoteRootService.aidl +++ b/source/core/rootservice/src/main/aidl/com/xayah/core/rootservice/IRemoteRootService.aidl @@ -47,4 +47,5 @@ interface IRemoteRootService { void setOpsMode(int code, int uid, String packageName, int mode); String calculateMD5(String src); + ParcelFileDescriptor openFileForStreaming(String path); // New method for streaming } diff --git a/source/core/rootservice/src/main/kotlin/com/xayah/core/rootservice/impl/RemoteRootServiceImpl.kt b/source/core/rootservice/src/main/kotlin/com/xayah/core/rootservice/impl/RemoteRootServiceImpl.kt index 931bbfadaf..da6d393fc5 100644 --- a/source/core/rootservice/src/main/kotlin/com/xayah/core/rootservice/impl/RemoteRootServiceImpl.kt +++ b/source/core/rootservice/src/main/kotlin/com/xayah/core/rootservice/impl/RemoteRootServiceImpl.kt @@ -516,4 +516,9 @@ internal class RemoteRootServiceImpl(private val context: Context) : IRemoteRoot } override fun calculateMD5(src: String): String = synchronized(lock) { HashUtil.calculateMD5(src) } + + override fun openFileForStreaming(path: String): ParcelFileDescriptor = synchronized(lock) { + val file = File(path) + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + } } diff --git a/source/core/rootservice/src/main/kotlin/com/xayah/core/rootservice/service/RemoteRootService.kt b/source/core/rootservice/src/main/kotlin/com/xayah/core/rootservice/service/RemoteRootService.kt index dbcbca1927..710316c260 100644 --- a/source/core/rootservice/src/main/kotlin/com/xayah/core/rootservice/service/RemoteRootService.kt +++ b/source/core/rootservice/src/main/kotlin/com/xayah/core/rootservice/service/RemoteRootService.kt @@ -377,4 +377,7 @@ class RemoteRootService(private val context: Context) { val bytes = readBytes(src = src) ProtoBuf.decodeFromByteArray(bytes) }.onFailure(onFailure).getOrNull() + + suspend fun openFileForStreaming(path: String): ParcelFileDescriptor? = + runCatching { getService().openFileForStreaming(path) }.onFailure(onFailure).getOrNull() } diff --git a/source/core/service/src/main/kotlin/com/xayah/core/service/packages/backup/AbstractBackupService.kt b/source/core/service/src/main/kotlin/com/xayah/core/service/packages/backup/AbstractBackupService.kt index 7c7dddeaab..d4165eccfd 100644 --- a/source/core/service/src/main/kotlin/com/xayah/core/service/packages/backup/AbstractBackupService.kt +++ b/source/core/service/src/main/kotlin/com/xayah/core/service/packages/backup/AbstractBackupService.kt @@ -27,6 +27,8 @@ import com.xayah.core.util.NotificationUtil import com.xayah.core.util.PathUtil import com.xayah.core.util.command.PreparationUtil import kotlinx.coroutines.flow.first +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream internal abstract class AbstractBackupService : AbstractPackagesService() { override suspend fun onInitializingPreprocessingEntities(entities: MutableList) { @@ -160,44 +162,105 @@ internal abstract class AbstractBackupService : AbstractPackagesService() { val dstDir = "${mAppsDir}/${p.archivesRelativeDir}" var restoreEntity = mPackageDao.query(p.packageName, OpType.RESTORE, p.userId, p.preserveId, p.indexInfo.compressionType, mTaskEntity.cloud, mTaskEntity.backupDir) mRootService.mkdirs(dstDir) - if (onAppDirCreated(archivesRelativeDir = p.archivesRelativeDir)) { - backup(type = DataType.PACKAGE_APK, p = p, r = restoreEntity, t = pkg, dstDir = dstDir) - backup(type = DataType.PACKAGE_USER, p = p, r = restoreEntity, t = pkg, dstDir = dstDir) - backup(type = DataType.PACKAGE_USER_DE, p = p, r = restoreEntity, t = pkg, dstDir = dstDir) - backup(type = DataType.PACKAGE_DATA, p = p, r = restoreEntity, t = pkg, dstDir = dstDir) - backup(type = DataType.PACKAGE_OBB, p = p, r = restoreEntity, t = pkg, dstDir = dstDir) - backup(type = DataType.PACKAGE_MEDIA, p = p, r = restoreEntity, t = pkg, dstDir = dstDir) - mPackagesBackupUtil.backupPermissions(p = p) - mPackagesBackupUtil.backupSsaid(p = p) - if (pkg.isSuccess) { - // Save config + if (p.indexInfo.compressionType == com.xayah.core.model.CompressionType.TWRP_ZIP) { + // TWRP ZIP Backup Logic + val zipFile = java.io.File(mAppsDir, "${p.packageName}_${p.userId}_${p.preserveId}.zip") + val checksums = mutableMapOf() // Map to store checksums + try { + ZipOutputStream(java.io.FileOutputStream(zipFile)).use { zos -> + val dataTypesToBackup = mutableListOf() + if (p.backupApk) dataTypesToBackup.add(DataType.PACKAGE_APK) + if (p.backupUser) dataTypesToBackup.add(DataType.PACKAGE_USER) + if (p.backupUserDe) dataTypesToBackup.add(DataType.PACKAGE_USER_DE) + if (p.backupData) dataTypesToBackup.add(DataType.PACKAGE_DATA) + if (p.backupObb) dataTypesToBackup.add(DataType.PACKAGE_OBB) + if (p.backupMedia) dataTypesToBackup.add(DataType.PACKAGE_MEDIA) + + dataTypesToBackup.forEach { dataType -> + val files = mPackagesBackupUtil.getFilesForDataType(p, dataType) + val basePath = when (dataType) { + DataType.PACKAGE_APK -> mPackagesBackupUtil.getPackageSourceDir(p.packageName, p.userId) + else -> mPackagesBackupUtil.packageRepository.getDataSrcDir(dataType, p.userId) + } + files.forEach { file -> + // Ensure basePath is not empty and file path is correctly relativized + val relativePath = if (basePath.isNotEmpty() && file.absolutePath.startsWith(basePath)) { + file.absolutePath.substring(basePath.length).trimStart('/') + } else { + file.name // Fallback if basePath is tricky or not applicable + } + val entryName = "${dataType.type}/$relativePath" + zos.putNextEntry(ZipEntry(entryName)) + // Stream file content directly to ZIP + var crcValue: Long = 0 + mRootService.openFileForStreaming(file.absolutePath)?.use { pfd -> + ParcelFileDescriptor.AutoCloseInputStream(pfd).use { fis -> + val checkedInputStream = java.util.zip.CheckedInputStream(fis, java.util.zip.CRC32()) + checkedInputStream.copyTo(zos) + crcValue = checkedInputStream.checksum.value + } + } ?: log { "Warning: Could not open file ${file.absolutePath} for streaming into TWRP ZIP." } + zos.closeEntry() + checksums[entryName] = crcValue + } + } + // Add checksums.txt to ZIP + zos.putNextEntry(ZipEntry("checksums.txt")) + val checksumContent = checksums.entries.joinToString("\n") { "${it.key}:${it.value}" } + zos.write(checksumContent.toByteArray()) + zos.closeEntry() + } + // Update package entity and task entity for success p.extraInfo.lastBackupTime = DateUtil.getTimestamp() - val id = restoreEntity?.id ?: 0 - restoreEntity = p.copy( - id = id, - indexInfo = p.indexInfo.copy(opType = OpType.RESTORE, cloud = mTaskEntity.cloud, backupDir = mTaskEntity.backupDir), - extraInfo = p.extraInfo.copy(activated = false) - ) - val configDst = PathUtil.getPackageRestoreConfigDst(dstDir = dstDir) - mRootService.writeJson(data = restoreEntity, dst = configDst) - onConfigSaved(path = configDst, archivesRelativeDir = p.archivesRelativeDir) - mPackageDao.upsert(restoreEntity) - mPackageDao.upsert(p) - pkg.update(packageEntity = p) + pkg.update(packageEntity = p, state = OperationState.DONE) mTaskEntity.update(successCount = mTaskEntity.successCount + 1) - } else { + } catch (e: Exception) { + log { "Error creating TWRP ZIP for ${p.packageName}: ${e.message}" } + pkg.update(state = OperationState.ERROR) mTaskEntity.update(failureCount = mTaskEntity.failureCount + 1) } } else { - pkg.update(dataType = DataType.PACKAGE_APK, state = OperationState.ERROR) - pkg.update(dataType = DataType.PACKAGE_USER, state = OperationState.ERROR) - pkg.update(dataType = DataType.PACKAGE_USER_DE, state = OperationState.ERROR) - pkg.update(dataType = DataType.PACKAGE_DATA, state = OperationState.ERROR) - pkg.update(dataType = DataType.PACKAGE_OBB, state = OperationState.ERROR) - pkg.update(dataType = DataType.PACKAGE_MEDIA, state = OperationState.ERROR) + // Existing backup logic + if (onAppDirCreated(archivesRelativeDir = p.archivesRelativeDir)) { + backup(type = DataType.PACKAGE_APK, p = p, r = restoreEntity, t = pkg, dstDir = dstDir) + backup(type = DataType.PACKAGE_USER, p = p, r = restoreEntity, t = pkg, dstDir = dstDir) + backup(type = DataType.PACKAGE_USER_DE, p = p, r = restoreEntity, t = pkg, dstDir = dstDir) + backup(type = DataType.PACKAGE_DATA, p = p, r = restoreEntity, t = pkg, dstDir = dstDir) + backup(type = DataType.PACKAGE_OBB, p = p, r = restoreEntity, t = pkg, dstDir = dstDir) + backup(type = DataType.PACKAGE_MEDIA, p = p, r = restoreEntity, t = pkg, dstDir = dstDir) + mPackagesBackupUtil.backupPermissions(p = p) + mPackagesBackupUtil.backupSsaid(p = p) + + if (pkg.isSuccess) { + // Save config + p.extraInfo.lastBackupTime = DateUtil.getTimestamp() + val id = restoreEntity?.id ?: 0 + restoreEntity = p.copy( + id = id, + indexInfo = p.indexInfo.copy(opType = OpType.RESTORE, cloud = mTaskEntity.cloud, backupDir = mTaskEntity.backupDir), + extraInfo = p.extraInfo.copy(activated = false) + ) + val configDst = PathUtil.getPackageRestoreConfigDst(dstDir = dstDir) + mRootService.writeJson(data = restoreEntity, dst = configDst) + onConfigSaved(path = configDst, archivesRelativeDir = p.archivesRelativeDir) + mPackageDao.upsert(restoreEntity) + mPackageDao.upsert(p) + pkg.update(packageEntity = p) + mTaskEntity.update(successCount = mTaskEntity.successCount + 1) + } else { + mTaskEntity.update(failureCount = mTaskEntity.failureCount + 1) + } + } else { + pkg.update(dataType = DataType.PACKAGE_APK, state = OperationState.ERROR) + pkg.update(dataType = DataType.PACKAGE_USER, state = OperationState.ERROR) + pkg.update(dataType = DataType.PACKAGE_USER_DE, state = OperationState.ERROR) + pkg.update(dataType = DataType.PACKAGE_DATA, state = OperationState.ERROR) + pkg.update(dataType = DataType.PACKAGE_OBB, state = OperationState.ERROR) + pkg.update(dataType = DataType.PACKAGE_MEDIA, state = OperationState.ERROR) + } + pkg.update(state = if (pkg.isSuccess) OperationState.DONE else OperationState.ERROR) } - pkg.update(state = if (pkg.isSuccess) OperationState.DONE else OperationState.ERROR) } mTaskEntity.update(processingIndex = mTaskEntity.processingIndex + 1) } diff --git a/source/core/service/src/main/kotlin/com/xayah/core/service/util/PackagesBackupUtil.kt b/source/core/service/src/main/kotlin/com/xayah/core/service/util/PackagesBackupUtil.kt index af7d90d613..68c75611d9 100644 --- a/source/core/service/src/main/kotlin/com/xayah/core/service/util/PackagesBackupUtil.kt +++ b/source/core/service/src/main/kotlin/com/xayah/core/service/util/PackagesBackupUtil.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import java.io.File import javax.inject.Inject import kotlin.coroutines.coroutineContext @@ -407,4 +408,65 @@ class PackagesBackupUtil @Inject constructor( t.updateInfo(dataType = dataType, state = if (isSuccess) OperationState.DONE else OperationState.ERROR, log = t.getLog(dataType) + "\n${outString}", content = "100%") } } + + // New method to get files for a specific data type + suspend fun getFilesForDataType(p: PackageEntity, dataType: DataType): List { + val files = mutableListOf() + val packageName = p.packageName + val userId = p.userId + val srcDir: String + + when (dataType) { + DataType.PACKAGE_APK -> { + srcDir = getPackageSourceDir(packageName, userId) + if (srcDir.isNotEmpty()) { + rootService.listDir(srcDir)?.filter { it.endsWith(".apk") }?.forEach { apkName -> + files.add(File(srcDir, apkName)) + } + } + } + DataType.PACKAGE_USER, DataType.PACKAGE_USER_DE, DataType.PACKAGE_DATA, DataType.PACKAGE_OBB, DataType.PACKAGE_MEDIA -> { + srcDir = packageRepository.getDataSrcDir(dataType, userId) + val dataPath = packageRepository.getDataSrc(srcDir, packageName) + if (rootService.exists(dataPath)) { + addFilesRecursively(dataPath, files, dataPath) // Pass dataPath as basePath + } + } + else -> { + // Not handled or not applicable for TWRP backup + } + } + return files + } + + // Made public to be accessible from AbstractBackupService + suspend fun getPackageSourceDir(packageName: String, userId: Int) = rootService.getPackageSourceDir(packageName, userId).let { list -> + if (list.isNotEmpty()) PathUtil.getParentPath(list[0]) else "" + } + + private suspend fun addFilesRecursively(currentPath: String, fileList: MutableList, basePath: String) { + val items = rootService.listDir(currentPath) + items?.forEach { item -> + val itemFile = File(currentPath, item) + // Check if it's a file or directory using rootService + // This is a simplified check; you might need a more specific way to differentiate + // or rely on `rootService.isDirectory(itemFile.absolutePath)` if available. + // For now, assuming if it's not ending with typical file extensions or if `listDir` on it returns non-null, it's a directory. + // This part needs a reliable way to check if 'itemFile' is a directory. + // Let's assume `rootService.isDirectory(itemFile.absolutePath)` exists for this example. + // if (rootService.isDirectory(itemFile.absolutePath)) { // This is hypothetical + // addFilesRecursively(itemFile.absolutePath, fileList, basePath) + // } else { + // fileList.add(itemFile) + // } + // Fallback: Add the file/dir and let ZIP handling figure it out, or refine this logic. + // The key challenge is determining if 'itemFile' is a directory without direct FS access. + // A common approach is to try listing its contents; if successful, it's a directory. + if (rootService.listDir(itemFile.absolutePath) != null && !rootService.isSymlink(itemFile.absolutePath)) { // Check if it's a directory and not a symlink + addFilesRecursively(itemFile.absolutePath, fileList, basePath) + } else if (rootService.exists(itemFile.absolutePath) && !rootService.isSymlink(itemFile.absolutePath)) { // Check if it's a file and not a symlink + fileList.add(itemFile) + } + } + } } diff --git a/source/core/ui/src/main/kotlin/com/xayah/core/ui/route/Routes.kt b/source/core/ui/src/main/kotlin/com/xayah/core/ui/route/Routes.kt index 41ad280ff8..509cfd843e 100644 --- a/source/core/ui/src/main/kotlin/com/xayah/core/ui/route/Routes.kt +++ b/source/core/ui/src/main/kotlin/com/xayah/core/ui/route/Routes.kt @@ -15,6 +15,7 @@ sealed class MainRoutes(val route: String) { const val ARG_TARGET = "target" const val ARG_OP_TYPE = "opType" const val ARG_ID = "id" + const val ARG_STORAGE_MODE = "storageMode" // For VerifyBackup } data object Dashboard : MainRoutes(route = "main_dashboard") @@ -83,4 +84,8 @@ sealed class MainRoutes(val route: String) { data object MediumRestoreProcessingGraph : MainRoutes(route = "main_medium_restore_processing_graph/{$ARG_ACCOUNT_NAME}/{$ARG_ACCOUNT_REMOTE}") { fun getRoute(cloudName: String = encodedURLWithSpace, backupDir: String = encodedURLWithSpace) = "main_medium_restore_processing_graph/${cloudName}/${backupDir}" } + + data object VerifyBackup : MainRoutes(route = "main_verify_backup/{$ARG_STORAGE_MODE}/{$ARG_ACCOUNT_NAME}/{$ARG_ACCOUNT_REMOTE}") { // New Route + fun getRoute(storageMode: String, cloudName: String, backupDir: String) = "main_verify_backup/${storageMode}/${cloudName}/${backupDir}" + } } diff --git a/source/feature/main/restore/src/main/kotlin/com/xayah/feature/main/restore/Index.kt b/source/feature/main/restore/src/main/kotlin/com/xayah/feature/main/restore/Index.kt index 0001e76301..9c565ea48d 100644 --- a/source/feature/main/restore/src/main/kotlin/com/xayah/feature/main/restore/Index.kt +++ b/source/feature/main/restore/src/main/kotlin/com/xayah/feature/main/restore/Index.kt @@ -189,6 +189,14 @@ fun PageRestore() { ) { viewModel.emitIntentOnIO(IndexUiIntent.ToReload(navController)) } + // Add Verify Backup Button + Clickable( + title = stringResource(id = R.string.verify_backup), // Add this string resource + value = stringResource(id = R.string.verify_backup_desc), // Add this string resource + leadingIcon = ImageVector.vectorResource(id = R.drawable.ic_rounded_verified), // Add a suitable icon + ) { + viewModel.emitIntentOnIO(IndexUiIntent.ToVerifyBackup(navController, uiState.storageType, uiState.cloudEntity?.name, if (uiState.storageType == StorageMode.Local) context.localBackupSaveDir() else uiState.cloudEntity?.remote)) + } } } } diff --git a/source/feature/main/restore/src/main/kotlin/com/xayah/feature/main/restore/IndexViewModel.kt b/source/feature/main/restore/src/main/kotlin/com/xayah/feature/main/restore/IndexViewModel.kt index d6af088bee..2b61a1cb08 100644 --- a/source/feature/main/restore/src/main/kotlin/com/xayah/feature/main/restore/IndexViewModel.kt +++ b/source/feature/main/restore/src/main/kotlin/com/xayah/feature/main/restore/IndexViewModel.kt @@ -49,6 +49,12 @@ sealed class IndexUiIntent : UiIntent { data class ToAppList(val navController: NavHostController) : IndexUiIntent() data class ToFileList(val navController: NavHostController) : IndexUiIntent() data class ToReload(val navController: NavHostController) : IndexUiIntent() + data class ToVerifyBackup( + val navController: NavHostController, + val storageMode: StorageMode, + val cloudName: String?, + val backupDir: String? + ) : IndexUiIntent() } @ExperimentalMaterial3Api @@ -194,6 +200,25 @@ class IndexViewModel @Inject constructor( } } } + + is IndexUiIntent.ToVerifyBackup -> { + // Navigate to a new screen/dialog for backup verification + // This will require a new route and potentially a new ViewModel/Page + // For now, let's assume a route like MainRoutes.VerifyBackup.getRoute(...) + // The actual implementation of VerifyBackup screen is out of scope for this change + // but this sets up the navigation. + if (intent.backupDir != null) { + val route = MainRoutes.VerifyBackup.getRoute( // Assuming VerifyBackup route exists + storageMode = intent.storageMode.name.encodeURL(), + cloudName = intent.cloudName?.encodeURL() ?: encodedURLWithSpace, + backupDir = intent.backupDir.encodeURL() + ) + intent.navController.navigateSingle(route) + } else { + // Handle error: backupDir is null + emitEffect(IndexUiEffect.ShowToast("Backup directory not available for verification.")) // Example effect + } + } } } diff --git a/source/feature/main/settings/src/main/kotlin/com/xayah/feature/main/settings/backup/Index.kt b/source/feature/main/settings/src/main/kotlin/com/xayah/feature/main/settings/backup/Index.kt index 44eeb6ba25..b42e30bbf9 100644 --- a/source/feature/main/settings/src/main/kotlin/com/xayah/feature/main/settings/backup/Index.kt +++ b/source/feature/main/settings/src/main/kotlin/com/xayah/feature/main/settings/backup/Index.kt @@ -28,9 +28,12 @@ import com.xayah.core.datastore.KeyCheckKeystore import com.xayah.core.datastore.KeyCompressionTest import com.xayah.core.datastore.KeyFollowSymlinks import com.xayah.core.datastore.readCompressionLevel +import com.xayah.core.datastore.readCompressionType // Added import import com.xayah.core.datastore.readKillAppOption import com.xayah.core.datastore.saveCompressionLevel +import com.xayah.core.datastore.saveCompressionType // Added import import com.xayah.core.datastore.saveKillAppOption +import com.xayah.core.model.CompressionType // Added import import com.xayah.core.model.KillAppOption import com.xayah.core.model.util.indexOf import com.xayah.core.ui.component.InnerBottomSpacer @@ -82,6 +85,24 @@ fun PageBackupSettings() { } } + val compressionTypeItems = CompressionType.values().map { DialogRadioItem(enum = it, title = it.type.uppercase(), desc = null) } + val currentCompressionType by context.readCompressionType().collectAsStateWithLifecycle(initialValue = CompressionType.ZSTD) + val currentCompressionTypeIndex by remember(currentCompressionType) { mutableIntStateOf(compressionTypeItems.indexOfFirst { it.enum == currentCompressionType }) } + Selectable( + title = stringResource(id = com.xayah.app.R.string.compression_type_title), + value = stringResource(id = com.xayah.app.R.string.compression_type_description), + current = compressionTypeItems[currentCompressionTypeIndex].title + ) { + val (state, selectedIndex) = dialogState.select( + title = context.getString(com.xayah.app.R.string.compression_type_title), + defIndex = currentCompressionTypeIndex, + items = compressionTypeItems + ) + if (state.isConfirm) { + context.saveCompressionType(compressionTypeItems[selectedIndex].enum!!) + } + } + val items = stringArrayResource(id = R.array.kill_app_options) val dialogItems by remember(items) { mutableStateOf(items.mapIndexed { index, s -> diff --git a/source/feature/main/verify/build.gradle.kts b/source/feature/main/verify/build.gradle.kts new file mode 100644 index 0000000000..74cc47a1d5 --- /dev/null +++ b/source/feature/main/verify/build.gradle.kts @@ -0,0 +1,59 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("kotlin-kapt") + id("dagger.hilt.android.plugin") +} + +android { + namespace = "com.xayah.feature.main.verify" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + // targetSdk = libs.versions.targetSdk.get().toInt() // Not needed for library modules usually + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() + } +} + +dependencies { + implementation(project(":source:core:ui")) + implementation(project(":source:core:rootservice")) + implementation(project(":source:core:datastore")) // If needed for any settings/preferences + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + kapt(libs.hilt.compiler) + + // For ParcelFileDescriptor.AutoCloseInputStream + implementation(libs.androidx.core.core.ktx) // Ensure this or a similar core ktx library is present +} diff --git a/source/feature/main/verify/src/main/kotlin/com/xayah/feature/main/verify/VerifyBackupPage.kt b/source/feature/main/verify/src/main/kotlin/com/xayah/feature/main/verify/VerifyBackupPage.kt new file mode 100644 index 0000000000..41440d4067 --- /dev/null +++ b/source/feature/main/verify/src/main/kotlin/com/xayah/feature/main/verify/VerifyBackupPage.kt @@ -0,0 +1,68 @@ +package com.xayah.feature.main.verify + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.xayah.core.ui.component.Title +import com.xayah.core.ui.token.SizeTokens +import com.xayah.app.R // Changed to use R from app module + +@ExperimentalMaterial3Api +@Composable +fun VerifyBackupPage( + storageMode: String, + cloudName: String?, + backupDir: String, + viewModel: VerifyBackupViewModel = hiltViewModel() +) { + val context = LocalContext.current + val verificationStatus by viewModel.verificationStatus.collectAsState() + + LaunchedEffect(Unit) { + viewModel.startVerification(context, storageMode, cloudName, backupDir) + } + + Scaffold( + topBar = { + TopAppBar(title = { Text(stringResource(id = R.string.verify_backup_title)) }) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(SizeTokens.Level16) + ) { + if (verificationStatus.isVerifying) { + Text(stringResource(id = R.string.verifying_backup_in_progress)) + // Optionally, show a progress indicator + } else { + Title(title = stringResource(id = R.string.verification_results)) + LazyColumn { + items(verificationStatus.results.entries.toList()) { (file, status) -> + Text("$file: $status") + } + } + if (verificationStatus.overallResult) { + Text(stringResource(id = R.string.verification_success)) + } else { + Text(stringResource(id = R.string.verification_failed)) + } + } + } + } +} diff --git a/source/feature/main/verify/src/main/kotlin/com/xayah/feature/main/verify/VerifyBackupViewModel.kt b/source/feature/main/verify/src/main/kotlin/com/xayah/feature/main/verify/VerifyBackupViewModel.kt new file mode 100644 index 0000000000..1f94ad2a2f --- /dev/null +++ b/source/feature/main/verify/src/main/kotlin/com/xayah/feature/main/verify/VerifyBackupViewModel.kt @@ -0,0 +1,148 @@ +package com.xayah.feature.main.verify + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.xayah.core.rootservice.service.RemoteRootService +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.io.ByteArrayInputStream +import java.io.File +import java.util.zip.CRC32 +import java.util.zip.CheckedInputStream +import java.util.zip.ZipInputStream +import javax.inject.Inject + +data class VerificationStatus( + val isVerifying: Boolean = false, + val results: Map = emptyMap(), + val overallResult: Boolean = true +) + +@HiltViewModel +class VerifyBackupViewModel @Inject constructor( + private val rootService: RemoteRootService +) : ViewModel() { + private val _verificationStatus = MutableStateFlow(VerificationStatus()) + val verificationStatus: StateFlow = _verificationStatus + + fun startVerification(context: Context, storageMode: String, cloudName: String?, backupDir: String) { + viewModelScope.launch { + _verificationStatus.value = VerificationStatus(isVerifying = true) + val results = mutableMapOf() + var overallSuccess = true + + // Determine the actual backup path + // This logic might need to be more sophisticated depending on cloud storage implementation + val actualBackupDir = if (storageMode == "Cloud" && cloudName != null) { + // Placeholder for cloud path resolution - this needs actual implementation + // For now, assuming backupDir is the relevant path or needs combining with cloudName + File(context.cacheDir, cloudName) // Example: download to a temporary local cache + // In a real scenario, you'd list files from the cloud, download the ZIPs, then verify + // This part is highly dependent on how cloud storage is structured and accessed + // For simplicity, we'll assume backupDir points to a local copy or accessible path + // If it's a remote path, files need to be downloaded first. + // This example will proceed as if backupDir is a local directory containing ZIPs. + // This part needs significant work for actual cloud support. + File(backupDir) // Simplified for now + } else { + File(backupDir) + } + + if (!actualBackupDir.exists() || !actualBackupDir.isDirectory) { + results["Error"] = "Backup directory not found or is not a directory." + overallSuccess = false + _verificationStatus.value = VerificationStatus(isVerifying = false, results = results, overallResult = overallSuccess) + return@launch + } + + actualBackupDir.listFiles { _, name -> name.endsWith(".zip") }?.forEach { zipFile -> + try { + val filePfd = rootService.openFileForStreaming(zipFile.absolutePath) + if (filePfd == null) { + results[zipFile.name] = "Error: Could not open ZIP file for reading." + overallSuccess = false + return@forEach + } + + ParcelFileDescriptor.AutoCloseInputStream(filePfd).use { fis -> + ZipInputStream(fis).use { zis -> + val storedChecksums = mutableMapOf() + var entry = zis.nextEntry + while (entry != null) { + if (entry.name == "checksums.txt") { + val checksumData = zis.readBytes() + ByteArrayInputStream(checksumData).bufferedReader().forEachLine { line -> + val parts = line.split(":") + if (parts.size == 2) { + storedChecksums[parts[0]] = parts[1].toLongOrNull() ?: 0L + } + } + } + entry = zis.nextEntry + } + } + } + + // Re-open for verification pass (ZipInputStream can't be reset easily) + val verifyPfd = rootService.openFileForStreaming(zipFile.absolutePath) + if (verifyPfd == null) { + results[zipFile.name] = "Error: Could not re-open ZIP file for verification." + overallSuccess = false + return@forEach + } + ParcelFileDescriptor.AutoCloseInputStream(verifyPfd).use { fisVerify -> + ZipInputStream(fisVerify).use { zisVerify -> + var entryVerify = zisVerify.nextEntry + var allFileChecksPass = true + while (entryVerify != null) { + if (entryVerify.name != "checksums.txt" && !entryVerify.isDirectory) { + val expectedCrc = storedChecksums[entryVerify.name] + if (expectedCrc == null) { + results["${zipFile.name}/${entryVerify.name}"] = "Missing checksum" + allFileChecksPass = false + overallSuccess = false + } else { + val checkedInputStream = CheckedInputStream(zisVerify, CRC32()) + // Drain the stream to calculate CRC + val buffer = ByteArray(8192) + while (checkedInputStream.read(buffer) != -1) { /* + // just read to update checksum + */ } + val actualCrc = checkedInputStream.checksum.value + if (actualCrc != expectedCrc) { + results["${zipFile.name}/${entryVerify.name}"] = "CRC mismatch (Expected: $expectedCrc, Actual: $actualCrc)" + allFileChecksPass = false + overallSuccess = false + } else { + // results["${zipFile.name}/${entryVerify.name}"] = "OK" // Optionally report OK files + } + } + } + zisVerify.closeEntry() // Important: close entry before getting next + entryVerify = zisVerify.nextEntry + } + if (allFileChecksPass && results[zipFile.name] == null) { // Only mark as OK if no individual file errors + results[zipFile.name] = "OK" + } else if (!allFileChecksPass && results[zipFile.name] == null) { + results[zipFile.name] = "Error: One or more files failed verification." + } + } + } + } catch (e: Exception) { + results[zipFile.name] = "Error: ${e.localizedMessage}" + overallSuccess = false + } + } + + if (actualBackupDir.listFiles { _, name -> name.endsWith(".zip") }?.isEmpty() == true) { + results["Info"] = "No ZIP files found in the backup directory." + } + + + _verificationStatus.value = VerificationStatus(isVerifying = false, results = results, overallResult = overallSuccess) + } + } +} diff --git a/source/settings.gradle.kts b/source/settings.gradle.kts index d8d2c4ee57..e03c80dd54 100644 --- a/source/settings.gradle.kts +++ b/source/settings.gradle.kts @@ -46,4 +46,5 @@ include(":feature:main:directory") include(":feature:flavor:foss") include(":feature:flavor:premium") include(":feature:flavor:alpha") +include(":feature:main:verify") // Added new module include(":native")