diff --git a/source-next/app/src/main/java/com/xayah/databackup/feature/backup/BackupPreviewScreen.kt b/source-next/app/src/main/java/com/xayah/databackup/feature/backup/BackupPreviewScreen.kt index 2d68e9194a..0920600de5 100644 --- a/source-next/app/src/main/java/com/xayah/databackup/feature/backup/BackupPreviewScreen.kt +++ b/source-next/app/src/main/java/com/xayah/databackup/feature/backup/BackupPreviewScreen.kt @@ -21,24 +21,31 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.material3.Checkbox +import androidx.compose.material3.OutlinedTextField import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import com.xayah.databackup.R import com.xayah.databackup.feature.BackupApps +import com.xayah.databackup.feature.backup.apps.AppsViewModel import com.xayah.databackup.ui.component.SelectableActionButton import com.xayah.databackup.ui.component.verticalFadingEdges import com.xayah.databackup.util.navigateSafely import com.xayah.databackup.util.popBackStackSafely @Composable -fun BackupPreviewScreen(navController: NavHostController) { +fun BackupPreviewScreen(navController: NavHostController, appsViewModel: AppsViewModel = viewModel()) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) Scaffold( modifier = Modifier @@ -135,6 +142,30 @@ fun BackupPreviewScreen(navController: NavHostController) { subtitle = "No items selected" ) {} + Spacer(modifier = Modifier.height(16.dp)) // Added spacer for separation + + // Encryption Checkbox and Password Field + val encryptBackupState = remember { mutableStateOf(false) } + val passwordState = remember { mutableStateOf("") } + + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = encryptBackupState.value, + onCheckedChange = { encryptBackupState.value = it } + ) + Text(text = stringResource(R.string.encrypt_backup)) + } + + if (encryptBackupState.value) { + OutlinedTextField( + value = passwordState.value, + onValueChange = { passwordState.value = it }, + label = { Text(stringResource(R.string.password)) }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + } + Spacer(modifier = Modifier.height(0.dp)) } @@ -149,7 +180,13 @@ fun BackupPreviewScreen(navController: NavHostController) { Button( modifier = Modifier.wrapContentSize(), - onClick = { } + onClick = { + appsViewModel.setEncryptionEnabled(encryptBackupState.value) + if (encryptBackupState.value) { + appsViewModel.setEncryptionPassword(passwordState.value) + } + navController.navigateSafely(BackupApps) + } ) { Text(text = stringResource(R.string.next)) } diff --git a/source-next/app/src/main/java/com/xayah/databackup/feature/backup/apps/AppsViewModel.kt b/source-next/app/src/main/java/com/xayah/databackup/feature/backup/apps/AppsViewModel.kt index 3a69d320dc..313050a320 100644 --- a/source-next/app/src/main/java/com/xayah/databackup/feature/backup/apps/AppsViewModel.kt +++ b/source-next/app/src/main/java/com/xayah/databackup/feature/backup/apps/AppsViewModel.kt @@ -34,12 +34,20 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch data object UiState open class AppsViewModel : BaseViewModel() { private val _uiState = MutableStateFlow(UiState) val uiState: StateFlow = _uiState.asStateFlow() + + private val _encryptionEnabled = MutableStateFlow(false) + val encryptionEnabled: StateFlow = _encryptionEnabled.asStateFlow() + + private val _encryptionPassword = MutableStateFlow("") + val encryptionPassword: StateFlow = _encryptionPassword.asStateFlow() + val apps = combine( DatabaseHelper.appDao.loadFlowApps(), App.application.readInt(FilterBackupUser), @@ -134,4 +142,16 @@ open class AppsViewModel : BaseViewModel() { App.application.saveBoolean(key, value) } } + + fun setEncryptionEnabled(enabled: Boolean) { + viewModelScope.launch { + _encryptionEnabled.value = enabled + } + } + + fun setEncryptionPassword(password: String) { + viewModelScope.launch { + _encryptionPassword.value = password + } + } } diff --git a/source-next/app/src/main/java/com/xayah/databackup/util/EncryptionHelper.kt b/source-next/app/src/main/java/com/xayah/databackup/util/EncryptionHelper.kt new file mode 100644 index 0000000000..97c3502108 --- /dev/null +++ b/source-next/app/src/main/java/com/xayah/databackup/util/EncryptionHelper.kt @@ -0,0 +1,54 @@ +package com.xayah.databackup.util + +import javax.crypto.Cipher +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec +import java.security.SecureRandom + +object EncryptionHelper { + + private const val ALGORITHM = "AES" + private const val TRANSFORMATION = "AES/CBC/PKCS5Padding" + private const val KEY_FACTORY_ALGORITHM = "PBKDF2WithHmacSHA256" + private const val ITERATION_COUNT = 65536 + private const val KEY_LENGTH = 256 + private const val IV_LENGTH = 16 // AES block size in bytes + + fun encrypt(data: ByteArray, password: CharArray): Pair { + val salt = ByteArray(KEY_LENGTH / 8) + SecureRandom().nextBytes(salt) + + val keySpec = PBEKeySpec(password, salt, ITERATION_COUNT, KEY_LENGTH) + val secretKeyFactory = SecretKeyFactory.getInstance(KEY_FACTORY_ALGORITHM) + val secretKey = secretKeyFactory.generateSecret(keySpec) + val secretKeySpec = SecretKeySpec(secretKey.encoded, ALGORITHM) + + val iv = ByteArray(IV_LENGTH) + SecureRandom().nextBytes(iv) + val ivParameterSpec = IvParameterSpec(iv) + + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec) + val encryptedData = cipher.doFinal(data) + + return Pair(salt, iv + encryptedData) // Prepend IV to the encrypted data + } + + fun decrypt(encryptedDataWithIv: ByteArray, password: CharArray, salt: ByteArray): ByteArray { + val iv = encryptedDataWithIv.copyOfRange(0, IV_LENGTH) + val encryptedData = encryptedDataWithIv.copyOfRange(IV_LENGTH, encryptedDataWithIv.size) + + val keySpec = PBEKeySpec(password, salt, ITERATION_COUNT, KEY_LENGTH) + val secretKeyFactory = SecretKeyFactory.getInstance(KEY_FACTORY_ALGORITHM) + val secretKey = secretKeyFactory.generateSecret(keySpec) + val secretKeySpec = SecretKeySpec(secretKey.encoded, ALGORITHM) + + val ivParameterSpec = IvParameterSpec(iv) + + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec) + return cipher.doFinal(encryptedData) + } +} diff --git a/source-next/app/src/main/res/values/strings.xml b/source-next/app/src/main/res/values/strings.xml index 56a453d06c..92fa859b83 100644 --- a/source-next/app/src/main/res/values/strings.xml +++ b/source-next/app/src/main/res/values/strings.xml @@ -410,6 +410,8 @@ Current level: %1$s Last update Enjoy hassle-free backups with DataBackup. It’s simple to use and completely FOSS. Keep your data safe without the stress + Encrypt backup + Password Get started File Dismiss diff --git a/source-next/app/src/test/java/com/xayah/databackup/util/EncryptionHelperTest.kt b/source-next/app/src/test/java/com/xayah/databackup/util/EncryptionHelperTest.kt new file mode 100644 index 0000000000..d21ed44bc4 --- /dev/null +++ b/source-next/app/src/test/java/com/xayah/databackup/util/EncryptionHelperTest.kt @@ -0,0 +1,87 @@ +package com.xayah.databackup.util + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import java.security.SecureRandom +import javax.crypto.AEADBadTagException + +class EncryptionHelperTest { + + @Test + fun encryptDecrypt_success() { + val data = "Hello, World!".toByteArray() + val password = "testPassword".toCharArray() + + val (salt, encryptedDataWithIv) = EncryptionHelper.encrypt(data, password) + val decryptedData = EncryptionHelper.decrypt(encryptedDataWithIv, password, salt) + + assertArrayEquals(data, decryptedData) + } + + @Test + fun encryptDecrypt_differentData() { + val data1 = "Hello, World!".toByteArray() + val data2 = "Goodbye, World!".toByteArray() + val password = "testPassword".toCharArray() + + val (salt, encryptedDataWithIv1) = EncryptionHelper.encrypt(data1, password) + val decryptedData1 = EncryptionHelper.decrypt(encryptedDataWithIv1, password, salt) + + val (salt2, encryptedDataWithIv2) = EncryptionHelper.encrypt(data2, password) + val decryptedData2 = EncryptionHelper.decrypt(encryptedDataWithIv2, password, salt2) + + + assertArrayEquals(data1, decryptedData1) + assertArrayEquals(data2, decryptedData2) + assertNotEquals(String(encryptedDataWithIv1), String(encryptedDataWithIv2)) + } + + @Test(expected = AEADBadTagException::class) + fun decrypt_wrongPassword_throwsException() { + val data = "Hello, World!".toByteArray() + val correctPassword = "correctPassword".toCharArray() + val wrongPassword = "wrongPassword".toCharArray() + + val (salt, encryptedDataWithIv) = EncryptionHelper.encrypt(data, correctPassword) + EncryptionHelper.decrypt(encryptedDataWithIv, wrongPassword, salt) + } + + @Test(expected = AEADBadTagException::class) + fun decrypt_wrongSalt_throwsException() { + val data = "Hello, World!".toByteArray() + val password = "testPassword".toCharArray() + + val (salt, encryptedDataWithIv) = EncryptionHelper.encrypt(data, password) + + val wrongSalt = ByteArray(EncryptionHelper.KEY_LENGTH / 8) + SecureRandom().nextBytes(wrongSalt) + + EncryptionHelper.decrypt(encryptedDataWithIv, password, wrongSalt) + } + + @Test(expected = AEADBadTagException::class) + fun decrypt_tamperedData_throwsException() { + val data = "Hello, World!".toByteArray() + val password = "testPassword".toCharArray() + + val (salt, encryptedDataWithIv) = EncryptionHelper.encrypt(data, password) + + // Tamper with the encrypted data + encryptedDataWithIv[encryptedDataWithIv.size / 2] = (encryptedDataWithIv[encryptedDataWithIv.size / 2] + 1).toByte() + + EncryptionHelper.decrypt(encryptedDataWithIv, password, salt) + } + + @Test + fun encrypt_producesDifferentCiphertextForSameDataWithDifferentSalts() { + val data = "Hello, World!".toByteArray() + val password = "testPassword".toCharArray() + + val (salt1, encryptedDataWithIv1) = EncryptionHelper.encrypt(data, password) + val (salt2, encryptedDataWithIv2) = EncryptionHelper.encrypt(data, password) // Encryption will generate a new salt + + assertNotEquals(String(salt1), String(salt2)) + assertNotEquals(String(encryptedDataWithIv1), String(encryptedDataWithIv2)) + } +} diff --git a/source/core/data/src/main/kotlin/com/xayah/core/data/repository/AppsRepo.kt b/source/core/data/src/main/kotlin/com/xayah/core/data/repository/AppsRepo.kt index bb06c23c2b..b28ff951c8 100644 --- a/source/core/data/src/main/kotlin/com/xayah/core/data/repository/AppsRepo.kt +++ b/source/core/data/src/main/kotlin/com/xayah/core/data/repository/AppsRepo.kt @@ -50,7 +50,12 @@ import com.xayah.core.util.PathUtil import com.xayah.core.util.command.BaseUtil import com.xayah.core.util.command.PackageUtil import com.xayah.core.util.command.Tar +import com.xayah.core.util.EncryptionHelper +import com.xayah.core.util.LogUtil import com.xayah.core.util.filesDir +import java.security.SecureRandom +import com.google.gson.Gson +import java.nio.charset.StandardCharsets // For UTF_8 import com.xayah.core.util.iconDir import com.xayah.core.util.localBackupSaveDir import com.xayah.core.util.withLog @@ -506,43 +511,126 @@ class AppsRepo @Inject constructor( private suspend fun loadCloudApps(cloudName: String, onLoad: suspend (cur: Int, max: Int, content: String) -> Unit) = runCatching { cloudRepo.withClient(cloudName) { client, entity -> - val remote = entity.remote - val path = pathUtil.getCloudRemoteAppsDir(remote) - if (client.exists(path)) { - val paths = client.walkFileTree(path) - val tmpDir = pathUtil.getCloudTmpDir() - paths.forEachIndexed { index, pathParcelable -> - val fileName = PathUtil.getFileName(pathParcelable.pathString) - onLoad(index, paths.size, fileName) - if (fileName == ConfigsPackageRestoreName) { - runCatching { - cloudRepo.download(client = client, src = pathParcelable.pathString, dstDir = tmpDir) { path -> - rootService.readJson(path).also { p -> - p?.id = 0 - p?.extraInfo?.activated = false - p?.indexInfo?.cloud = entity.name - p?.indexInfo?.backupDir = remote - parsePreserveAndUserId(pathParcelable).also { result -> - result?.also { (pId, uId) -> - p?.indexInfo?.preserveId = pId - p?.indexInfo?.userId = uId + val remote = entity.remote // Base remote path for this cloud account + val remoteAppsPath = pathUtil.getCloudRemoteAppsDir(remote) // e.g., //DataBackup/apps + if (client.exists(remoteAppsPath)) { + val paths = client.walkFileTree(remoteAppsPath) // List all files/dirs under .../apps/ + val tmpDir = pathUtil.getCloudTmpDir() // Local temporary directory for downloads + val gson = Gson() + val settings = settingsDataRepo.settingsData.first() // For password placeholder + + paths.forEachIndexed { index, pathParcelable -> // pathParcelable here refers to a remote path + val remoteConfigFilePathString = pathParcelable.pathString + val fileName = PathUtil.getFileName(remoteConfigFilePathString) + onLoad(index, paths.size, fileName) // Progress update + + if (fileName == ConfigsPackageRestoreName) { // "package.json" + var packageEntity: PackageEntity? = null + val remoteSaltFilePathString = "$remoteConfigFilePathString.salt" + + // Determine local temporary paths for downloaded config and salt + val localTmpConfigFilePath = File(tmpDir, "${PathUtil.getFileNameWithoutExtension(fileName)}_${DateUtil.getTimestamp()}.${PathUtil.getExtension(fileName)}").absolutePath + val localTmpSaltFilePath = "$localTmpConfigFilePath.salt" + + try { + // Attempt to download salt file first, if it exists remotely + var salt: ByteArray? = null + if (client.exists(remoteSaltFilePathString)) { + LogUtil.log { "AppsRepo" to "Found remote salt file for $remoteConfigFilePathString, downloading." } + val downloadSaltSuccess = cloudRepo.download(client = client, src = remoteSaltFilePathString, dst = localTmpSaltFilePath) + if (downloadSaltSuccess) { + salt = rootService.readBytes(localTmpSaltFilePath) // Read the downloaded salt file + rootService.deleteRecursively(localTmpSaltFilePath) // Clean up tmp salt file + } else { + LogUtil.log { "AppsRepo" to "Failed to download salt file $remoteSaltFilePathString" } + } + } + + // Download the main config file (package.json) + val downloadConfigSuccess = cloudRepo.download(client = client, src = remoteConfigFilePathString, dst = localTmpConfigFilePath) + if (downloadConfigSuccess) { + val configFileBytes = rootService.readBytes(localTmpConfigFilePath) // Read downloaded config + rootService.deleteRecursively(localTmpConfigFilePath) // Clean up tmp config file + + if (configFileBytes.isNotEmpty()) { + if (salt != null && salt.isNotEmpty()) { + // Encrypted + LogUtil.log { "AppsRepo" to "Attempting decryption for downloaded $remoteConfigFilePathString" } + if (settings.encryptionPasswordRaw.isNotEmpty()) { + try { + val decryptedConfigBytes = EncryptionHelper.decrypt(configFileBytes, settings.encryptionPasswordRaw.toCharArray(), salt) + packageEntity = gson.fromJson(String(decryptedConfigBytes, StandardCharsets.UTF_8), PackageEntity::class.java) + LogUtil.log { "AppsRepo" to "Decryption successful for downloaded $remoteConfigFilePathString" } + } catch (e: Exception) { + LogUtil.log { "AppsRepo" to "Decryption failed for downloaded $remoteConfigFilePathString: ${e.message}" } + e.printStackTrace() + } + } else { + LogUtil.log { "AppsRepo" to "Salt found but no password available for $remoteConfigFilePathString. Skipping." } + } + } else { + // Not encrypted + LogUtil.log { "AppsRepo" to "No salt file or empty salt for $remoteConfigFilePathString, reading as unencrypted." } + try { + packageEntity = gson.fromJson(String(configFileBytes, StandardCharsets.UTF_8), PackageEntity::class.java) + } catch (e: Exception) { + LogUtil.log { "AppsRepo" to "Failed to parse unencrypted downloaded $remoteConfigFilePathString: ${e.message}" } } } - }?.apply { - if (appsDao.query(packageName, indexInfo.opType, userId, preserveId, indexInfo.compressionType, indexInfo.cloud, indexInfo.backupDir) == null) { - appsDao.upsert(this) - } + } else { + LogUtil.log { "AppsRepo" to "Downloaded config file $remoteConfigFilePathString is empty."} + } + } else { + LogUtil.log { "AppsRepo" to "Failed to download config file $remoteConfigFilePathString" } + } + } finally { + // Ensure cleanup of temp files even if errors occur before reading them + if (rootService.exists(localTmpConfigFilePath)) rootService.deleteRecursively(localTmpConfigFilePath) + if (rootService.exists(localTmpSaltFilePath)) rootService.deleteRecursively(localTmpSaltFilePath) + } + + // Process valid packageEntity + packageEntity?.also { p -> + p.id = 0 + p.extraInfo.activated = false + p.indexInfo.cloud = entity.name // Set cloud name from the current cloud entity + p.indexInfo.backupDir = remote // Set backupDir to the base remote path for this cloud + + parsePreserveAndUserId(pathParcelable).also { result -> // pathParcelable here is remote + result?.also { (pId, uId) -> + p.indexInfo.preserveId = pId + p.indexInfo.userId = uId + } ?: run { + LogUtil.log { "AppsRepo" to "Failed to parse preserveId/userId from remote path $remoteConfigFilePathString. Skipping DB upsert." } + return@forEachIndexed + } + } + + if (p.packageName.isNotBlank() && p.indexInfo.backupDir.isNotBlank()) { + val existingEntry = appsDao.query(p.packageName, p.indexInfo.opType, p.userId, p.indexInfo.preserveId, p.indexInfo.compressionType, p.indexInfo.cloud, p.indexInfo.backupDir) + if (existingEntry == null) { + appsDao.upsert(p) + } else { + p.id = existingEntry.id + appsDao.updatePackage(p) } + } else { + LogUtil.log { "AppsRepo" to "Skipping DB upsert for $remoteConfigFilePathString due to missing critical info." } } } } } - appsDao.queryPackages(OpType.RESTORE, entity.name, entity.remote).forEach { - val src = "${path}/${it.archivesRelativeDir}" - if (client.exists(src).not()) { + // Validate database entries against remote file system (more complex, might need selective validation) + appsDao.queryPackages(OpType.RESTORE, entity.name, remote).forEach { + val remotePackageDir = "${remoteAppsPath}/${it.archivesRelativeDir}" // e.g., //DataBackup/apps/com.example.app/0 + val remoteConfigFile = PathUtil.getPackageRestoreConfigDst(remotePackageDir) + if (!client.exists(remoteConfigFile)) { + LogUtil.log { "AppsRepo" to "Cloud DB entry for ${it.packageName}/${it.indexInfo.preserveId} points to non-existent remote config ($remoteConfigFile). Deleting from DB." } appsDao.delete(it.id) } } + } else { + LogUtil.log { "AppsRepo" to "Remote apps path $remoteAppsPath does not exist for cloud ${entity.name}."} } } }.withLog() @@ -633,12 +721,93 @@ class AppsRepo @Inject constructor( private suspend fun protectLocalApp(app: PackageEntity) { val protectedApp = app.copy(indexInfo = app.indexInfo.copy(preserveId = DateUtil.getTimestamp())) - val appsDir = pathUtil.getLocalBackupAppsDir() - val src = "${appsDir}/${app.archivesRelativeDir}" - val dst = "${appsDir}/${protectedApp.archivesRelativeDir}" - rootService.writeJson(data = protectedApp, dst = PathUtil.getPackageRestoreConfigDst(src)) - rootService.renameTo(src, dst) - appsDao.update(protectedApp) + val localAppsDir = pathUtil.getLocalBackupAppsDir() + // Directory for the original backup (e.g., .../apps/com.example.app/0/) + // This is the source directory for the rename operation later. + val originalAppBackupDir = "${localAppsDir}/${app.archivesRelativeDir}" + // Directory for the new protected backup (e.g., .../apps/com.example.app/1678886400000/) + // This is the target directory for the rename operation. + val protectedAppBackupDir = "${localAppsDir}/${protectedApp.archivesRelativeDir}" + // Config file path within the *original* app backup directory. + // We modify/create the package.json here, then rename its parent directory. + val configFilePath = PathUtil.getPackageRestoreConfigDst(originalAppBackupDir) + val saltFilePath = "$configFilePath.salt" + val gson = Gson() + + val settings = settingsDataRepo.settingsData.first() + + // Ensure the directory where package.json will be written exists. + // For a "protect" operation, originalAppBackupDir (e.g., .../0/) should already exist. + if (!rootService.exists(originalAppBackupDir)) { + // This case should ideally not be hit if we are "protecting" an existing backup. + // If it's a brand new backup being saved directly as "protected", then mkdirs makes sense. + // However, "protectLocalApp" implies an existing 'app' entity. + LogUtil.log { "AppsRepo" to "Warning: Original backup directory $originalAppBackupDir not found during protect operation. Creating." } + rootService.mkdirs(originalAppBackupDir) + } + + if (settings.encryptionEnabled && settings.encryptionPasswordRaw.isNotEmpty()) { + try { + LogUtil.log { "AppsRepo" to "Encryption enabled for ${protectedApp.packageName}. Encrypting metadata." } + val configJsonBytes = gson.toJson(protectedApp).toByteArray(Charsets.UTF_8) + val salt = ByteArray(16).apply { SecureRandom().nextBytes(this) } // Generate 16-byte salt + + // EncryptionHelper.encrypt now expects salt and returns IV+ciphertext + val encryptedConfigDataWithIv = EncryptionHelper.encrypt(configJsonBytes, settings.encryptionPasswordRaw.toCharArray(), salt) + + val writeConfigSuccess = rootService.writeBytes(bytes = encryptedConfigDataWithIv, dst = configFilePath) + if (!writeConfigSuccess) { + LogUtil.log { "AppsRepo" to "Failed to write encrypted config for ${protectedApp.packageName}" } + rootService.deleteRecursively(configFilePath) // Clean up partial file + return // Stop if config write fails + } + + val writeSaltSuccess = rootService.writeBytes(bytes = salt, dst = saltFilePath) + if (!writeSaltSuccess) { + LogUtil.log { "AppsRepo" to "Failed to write salt for ${protectedApp.packageName}" } + rootService.deleteRecursively(configFilePath) // Clean up config file + rootService.deleteRecursively(saltFilePath) // Clean up partial salt file + return // Stop if salt write fails + } + LogUtil.log { "AppsRepo" to "Encrypted metadata and salt written for ${protectedApp.packageName}" } + } catch (e: Exception) { + LogUtil.log { "AppsRepo" to "Encryption failed for ${protectedApp.packageName}: ${e.message}" } + e.printStackTrace() + // Clean up any partially written files in case of an encryption error + rootService.deleteRecursively(configFilePath) + rootService.deleteRecursively(saltFilePath) + return // Stop if encryption process fails + } + } else { + LogUtil.log { "AppsRepo" to "Encryption not enabled for ${protectedApp.packageName}. Writing unencrypted metadata." } + // Write unencrypted config + rootService.writeJson(data = protectedApp, dst = configFilePath) + // Ensure no old salt file is lingering if encryption was previously enabled and now turned off + if(rootService.exists(saltFilePath)) { + LogUtil.log { "AppsRepo" to "Deleting orphaned salt file for ${protectedApp.packageName} at $saltFilePath" } + rootService.deleteRecursively(saltFilePath) + } + } + + // Rename the whole directory from original preserveId (e.g., /0/) to new protected preserveId (e.g., /timestamp/) + // This moves package.json, package.json.salt (if any), and all TAR files. + if (rootService.exists(originalAppBackupDir)) { + if (rootService.renameTo(originalAppBackupDir, protectedAppBackupDir)) { + LogUtil.log { "AppsRepo" to "Successfully renamed $originalAppBackupDir to $protectedAppBackupDir" } + appsDao.update(protectedApp) // Update DB with new preserveId only if rename is successful + } else { + LogUtil.log { "AppsRepo" to "Failed to rename backup directory for ${protectedApp.packageName} from $originalAppBackupDir to $protectedAppBackupDir. Metadata may be in inconsistent state in $originalAppBackupDir." } + // If rename fails, the package.json in originalAppBackupDir is now for 'protectedApp'. + // This is an error state that needs careful handling, possibly by trying to revert package.json or flagging. + } + } else { + // This state implies that the config file was written (either encrypted or plain) to a directory that + // doesn't match app.archivesRelativeDir (the source for rename). + // This could happen if originalAppBackupDir didn't exist and was created, then written to, + // but app.archivesRelativeDir pointed to something else. + // This situation should be rare if path logic is consistent. + LogUtil.log { "AppsRepo" to "Critical error: Original backup directory $originalAppBackupDir was expected to exist for rename but was not found. The config for ${protectedApp.packageName} might be orphaned in $configFilePath."} + } } private suspend fun protectCloudApp(cloudName: String, app: PackageEntity) = runCatching { diff --git a/source/core/model/src/main/kotlin/com/xayah/core/model/SettingsData.kt b/source/core/model/src/main/kotlin/com/xayah/core/model/SettingsData.kt index 66b9c83407..a7a00a5388 100644 --- a/source/core/model/src/main/kotlin/com/xayah/core/model/SettingsData.kt +++ b/source/core/model/src/main/kotlin/com/xayah/core/model/SettingsData.kt @@ -6,4 +6,6 @@ const val DEFAULT_APPS_UPDATE_TIME = 0L data class SettingsData( val compressionType: CompressionType = DEFAULT_COMPRESSION_TYPE, val appsUpdateTime: Long = DEFAULT_APPS_UPDATE_TIME, + val encryptionEnabled: Boolean = false, + val encryptionPasswordRaw: String = "", ) 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..e0d8cc720f 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 @@ -151,6 +151,13 @@ data class PackageDataStats( var dataBytes: Long = 0, var obbBytes: Long = 0, var mediaBytes: Long = 0, + // Timestamps for component-level incremental backup + var apkLastBackupTimestamp: Long = 0L, + var dataLastBackupTimestamp: Long = 0L, + var userLastBackupTimestamp: Long = 0L, + var userDeLastBackupTimestamp: Long = 0L, + var obbLastBackupTimestamp: Long = 0L, + var mediaLastBackupTimestamp: Long = 0L, ) /** 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..9682545b79 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); + long getLatestModificationTimestamp(String path); } 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..9126114664 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 @@ -50,6 +50,7 @@ import java.nio.file.Paths import java.nio.file.SimpleFileVisitor import java.nio.file.attribute.BasicFileAttributes import kotlin.io.path.pathString +import java.util.concurrent.TimeUnit // For potential shell command timeout internal class RemoteRootServiceImpl(private val context: Context) : IRemoteRootService.Stub() { private val lock = Any() @@ -516,4 +517,86 @@ internal class RemoteRootServiceImpl(private val context: Context) : IRemoteRoot } override fun calculateMD5(src: String): String = synchronized(lock) { HashUtil.calculateMD5(src) } + + override fun getLatestModificationTimestamp(path: String): Long = synchronized(lock) { + if (!File(path).exists()) { + return 0L + } + + // Attempt 1: Using find command (more efficient if available and works) + // BusyBox find typically supports -print0 and xargs -0 for safety with special filenames. + // %T@ gives seconds since epoch. %TFT%TH:%TM:%.2S@%t gives a more complex but parsable format if %T@ is not available. + // The command aims to find all files (-type f), print their modification timestamps (seconds since epoch), + // sort numerically in reverse order, and take the first one (the latest). + // Added single quotes around path to handle spaces or special characters. + val command = "find '${path.replace("'", "'\\''")}' -type f -exec stat -c %Y {} \\; | sort -nr | head -n1" + try { + val result = ShellUtils.fastCmd(command) + if (result.isNotEmpty()) { + val timestamp = result.trim().toLongOrNull() + if (timestamp != null) { + return timestamp * 1000 // Convert seconds to milliseconds + } + } + } catch (e: Exception) { + // Log the exception if shell command fails (e.g., find not as expected, permission issues though unlikely as root) + // System.err.println("Shell command for timestamp failed: " + e.getMessage()); + // Fall through to manual recursive scan if shell command fails + } + + // Attempt 2: Manual recursive scan (fallback if shell command fails or returns nothing) + var latestTimestamp = 0L + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Files.walkFileTree(Paths.get(path), object : SimpleFileVisitor() { + override fun visitFile(file: Path?, attrs: BasicFileAttributes?): FileVisitResult { + if (attrs != null) { + val fileTime = attrs.lastModifiedTime().toMillis() + if (fileTime > latestTimestamp) { + latestTimestamp = fileTime + } + } + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path?, exc: IOException?): FileVisitResult { + if (dir != null) { + try { + val dirTime = Files.getLastModifiedTime(dir).toMillis() + if (dirTime > latestTimestamp) { + latestTimestamp = dirTime + } + } catch (e: IOException) { + // Ignore if can't get dir time for some reason + } + } + if (exc != null) { + // Log or handle exception if needed + return FileVisitResult.TERMINATE + } + return FileVisitResult.CONTINUE + } + }) + } else { + // Manual walk for older Android versions + val stack = ArrayDeque() + stack.addFirst(File(path)) + while (stack.isNotEmpty()) { + val currentFile = stack.removeFirst() + val fileTime = currentFile.lastModified() // Returns 0L if file not found, but we check existence first + if (fileTime > latestTimestamp) { + latestTimestamp = fileTime + } + if (currentFile.isDirectory) { + currentFile.listFiles()?.forEach { stack.addFirst(it) } + } + } + } + } catch (e: Exception) { + // Log the exception from manual scan + // System.err.println("Manual timestamp scan failed: " + e.getMessage()); + return 0L // Or throw, depending on desired error handling + } + return latestTimestamp + } } 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..b9eaedfe7b 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 @@ -328,6 +328,9 @@ class RemoteRootService(private val context: Context) { suspend fun calculateMD5(src: String): String? = runCatching { getService().calculateMD5(src) }.onFailure(onFailure).getOrNull() + suspend fun getLatestModificationTimestamp(path: String): Long = + runCatching { getService().getLatestModificationTimestamp(path) }.onFailure(onFailure).getOrElse { 0L } + suspend fun writeJson(data: Any, dst: String): ShellResult = runCatching { var isSuccess: Boolean val out = mutableListOf() diff --git a/source/core/util/src/main/kotlin/com/xayah/core/util/LogUtil.kt b/source/core/util/src/main/kotlin/com/xayah/core/util/LogUtil.kt index cc8bc3d6b1..44a04eaebd 100644 --- a/source/core/util/src/main/kotlin/com/xayah/core/util/LogUtil.kt +++ b/source/core/util/src/main/kotlin/com/xayah/core/util/LogUtil.kt @@ -33,6 +33,7 @@ object LogUtil { fun initialize(context: Context, cacheDir: String) = runCatching { // Clear empty log files. + val currentLogFileName = getLogFileName() FileUtil.listFilePaths(cacheDir).forEach { path -> File(path).apply { if (readLines().size <= 4) deleteRecursively() @@ -43,7 +44,8 @@ object LogUtil { if (exists().not()) mkdirs() } this.cacheDir = cacheDir - this.logFile = RandomAccessFile("$cacheDir/${getLogFileName()}", "rw") + this.logFile = RandomAccessFile("$cacheDir/$currentLogFileName", "rw") + log("Log file created at: $cacheDir/$currentLogFileName") // Added this line log("Version: ${BuildConfigUtil.VERSION_NAME}") log("Model: ${Build.MODEL}") log("ABIs: ${Build.SUPPORTED_ABIS.firstOrNull() ?: ""}") diff --git a/source/feature/crash/src/main/kotlin/com/xayah/feature/crash/MainActivity.kt b/source/feature/crash/src/main/kotlin/com/xayah/feature/crash/MainActivity.kt index 255db8cbb2..89f10bafc9 100644 --- a/source/feature/crash/src/main/kotlin/com/xayah/feature/crash/MainActivity.kt +++ b/source/feature/crash/src/main/kotlin/com/xayah/feature/crash/MainActivity.kt @@ -3,23 +3,36 @@ package com.xayah.feature.crash import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity +import android.content.Intent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Share import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.core.view.WindowCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -81,7 +94,40 @@ class MainActivity : AppCompatActivity() { TopBarTitle(text = stringResource(id = R.string.app_crashed)) // Content - LabelSmallText(text = uiState.text, fontFamily = JetbrainsMonoFamily) + LabelSmallText(text = uiState.text, fontFamily = JetbrainsMonoFamily, modifier = Modifier.fillMaxWidth()) + + Spacer(modifier = Modifier.height(PaddingTokens.Level4)) + + // Action Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End // Align buttons to the end (right) + ) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + + OutlinedButton(onClick = { + clipboardManager.setText(AnnotatedString(uiState.text)) + }) { + Icon(Icons.Rounded.ContentCopy, contentDescription = stringResource(id = R.string.copy_to_clipboard), modifier = Modifier.size(PaddingTokens.Level3)) + Spacer(modifier = Modifier.width(PaddingTokens.Level1)) + Text(stringResource(id = R.string.copy_to_clipboard)) + } + Spacer(modifier = Modifier.width(PaddingTokens.Level2)) + Button(onClick = { + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, uiState.text) + type = "text/plain" + } + val shareIntent = Intent.createChooser(sendIntent, null) + context.startActivity(shareIntent) + }) { + Icon(Icons.Rounded.Share, contentDescription = stringResource(id = R.string.share_report), modifier = Modifier.size(PaddingTokens.Level3)) + Spacer(modifier = Modifier.width(PaddingTokens.Level1)) + Text(stringResource(id = R.string.share_report)) + } + } InnerBottomSpacer(innerPadding = innerPadding) } @@ -91,3 +137,7 @@ class MainActivity : AppCompatActivity() { } } } +// Add these string resources to source/feature/crash/src/main/res/values/strings.xml +// Copy +// Share +// R.string.app_crashed is already used from core/ui presumably diff --git a/source/feature/crash/src/main/res/values/strings.xml b/source/feature/crash/src/main/res/values/strings.xml new file mode 100644 index 0000000000..73cc5f3f04 --- /dev/null +++ b/source/feature/crash/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Copy + Share + diff --git a/source/feature/main/cloud/src/main/kotlin/com/xayah/feature/main/cloud/IndexViewModel.kt b/source/feature/main/cloud/src/main/kotlin/com/xayah/feature/main/cloud/IndexViewModel.kt index 187aab0310..fe40d8ce59 100644 --- a/source/feature/main/cloud/src/main/kotlin/com/xayah/feature/main/cloud/IndexViewModel.kt +++ b/source/feature/main/cloud/src/main/kotlin/com/xayah/feature/main/cloud/IndexViewModel.kt @@ -13,6 +13,9 @@ import com.xayah.core.ui.viewmodel.UiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException import javax.inject.Inject data class IndexUiState( @@ -47,8 +50,13 @@ class IndexViewModel @Inject constructor( emitEffectOnIO(IndexUiEffect.ShowSnackbar(type = SnackbarType.Success, message = cloudRepo.getString(R.string.connection_established))) }.onFailure { emitEffect(IndexUiEffect.DismissSnackbar) - if (it.localizedMessage != null) - emitEffectOnIO(IndexUiEffect.ShowSnackbar(type = SnackbarType.Error, message = it.localizedMessage!!, duration = SnackbarDuration.Long)) + val errorMsg = when (it) { + is UnknownHostException -> cloudRepo.getString(R.string.connection_failed_unknown_host) + is SocketTimeoutException, is ConnectException -> cloudRepo.getString(R.string.connection_failed_timeout) + else -> it.localizedMessage ?: cloudRepo.getString(R.string.connection_failed_generic) + } + val displayMessage = "${cloudRepo.getString(R.string.connection_test_failed)}: $errorMsg" + emitEffectOnIO(IndexUiEffect.ShowSnackbar(type = SnackbarType.Error, message = displayMessage, duration = SnackbarDuration.Long)) } emitState(state.copy(isProcessing = false)) } diff --git a/source/feature/main/cloud/src/main/res/values/strings.xml b/source/feature/main/cloud/src/main/res/values/strings.xml new file mode 100644 index 0000000000..13cf313491 --- /dev/null +++ b/source/feature/main/cloud/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Unknown host. Please check the server address. + Connection timed out. Please check your network and server. + An unexpected error occurred. + Connection test failed + diff --git a/source/feature/main/details/src/main/kotlin/com/xayah/feature/main/details/DetailsViewModel.kt b/source/feature/main/details/src/main/kotlin/com/xayah/feature/main/details/DetailsViewModel.kt index 73b4360a62..6f20751576 100644 --- a/source/feature/main/details/src/main/kotlin/com/xayah/feature/main/details/DetailsViewModel.kt +++ b/source/feature/main/details/src/main/kotlin/com/xayah/feature/main/details/DetailsViewModel.kt @@ -130,11 +130,19 @@ class DetailsViewModel @Inject constructor( fun addLabel(label: String) { viewModelScope.launchOnDefault { - if (label.trim().isEmpty() || labelsRepo.addLabel(label.trim()) == -1L) { + val trimmedLabel = label.trim() + if (trimmedLabel.isEmpty()) { withMainContext { - Toast.makeText(context, context.getString(R.string.failed), Toast.LENGTH_SHORT).show() + // TODO: Replace R.string.label_cannot_be_empty with actual resource ID after adding to strings.xml + Toast.makeText(context, "Label cannot be empty.", Toast.LENGTH_SHORT).show() + } + } else if (labelsRepo.addLabel(trimmedLabel) == -1L) { + withMainContext { + // TODO: Replace R.string.add_label_failed with actual resource ID after adding to strings.xml + Toast.makeText(context, "Failed to add label. It might already exist or an error occurred.", Toast.LENGTH_SHORT).show() } } + // Implicitly, if addLabel doesn't return -1L and label is not empty, it's a success (no message needed or handled elsewhere) } } diff --git a/source/feature/main/details/src/main/res/values/strings.xml b/source/feature/main/details/src/main/res/values/strings.xml new file mode 100644 index 0000000000..c2bb346b1e --- /dev/null +++ b/source/feature/main/details/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Label cannot be empty. + Failed to add label. It might already exist or an error occurred. +