From 7f9546fa06d5378e27616b2cb773bbc852d65322 Mon Sep 17 00:00:00 2001 From: chindaronit Date: Sat, 25 Jul 2026 23:02:32 +0530 Subject: [PATCH 1/2] feat: addition of backup encryption to all the export of backup. --- app/build.gradle.kts | 2 + app/src/main/java/com/flux/MainActivity.kt | 5 +- .../com/flux/data/database/FluxDatabase.kt | 16 +- .../java/com/flux/di/BackupCryptoModule.kt | 29 +++ .../java/com/flux/navigation/NavRoutes.kt | 2 +- .../other/crypto/BackupCredentialStore.kt | 66 ++++++ .../other/crypto/BackupCryptoException.kt | 21 ++ .../com/flux/other/crypto/BackupEncryptor.kt | 83 +++++++ .../com/flux/other/crypto/BackupFileFormat.kt | 13 + .../main/java/com/flux/ui/common/Dialog.kt | 126 +++++++++- .../java/com/flux/ui/screens/settings/Data.kt | 224 +++++++++++++----- .../screens/workspaces/WorkspaceHomeScreen.kt | 22 ++ .../ui/viewModel/BackupSettingsViewModel.kt | 49 ++++ .../com/flux/ui/viewModel/BackupViewModel.kt | 177 +++++++------- .../java/com/flux/ui/viewModel/ViewModels.kt | 3 +- app/src/main/res/values-de-rDE/strings.xml | 16 ++ app/src/main/res/values-es-rES/strings.xml | 16 ++ app/src/main/res/values-fr-rFR/strings.xml | 16 ++ app/src/main/res/values-hi-rIN/strings.xml | 16 ++ app/src/main/res/values-nl-rNL/strings.xml | 17 ++ app/src/main/res/values-pt-rBR/strings.xml | 17 ++ app/src/main/res/values-ru-rRU/strings.xml | 17 +- app/src/main/res/values-zh-rCN/strings.xml | 17 ++ app/src/main/res/values/strings.xml | 16 ++ gradle/libs.versions.toml | 2 + 25 files changed, 822 insertions(+), 166 deletions(-) create mode 100644 app/src/main/java/com/flux/di/BackupCryptoModule.kt create mode 100644 app/src/main/java/com/flux/other/crypto/BackupCredentialStore.kt create mode 100644 app/src/main/java/com/flux/other/crypto/BackupCryptoException.kt create mode 100644 app/src/main/java/com/flux/other/crypto/BackupEncryptor.kt create mode 100644 app/src/main/java/com/flux/other/crypto/BackupFileFormat.kt create mode 100644 app/src/main/java/com/flux/ui/viewModel/BackupSettingsViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c1d2617..75babef 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -138,4 +138,6 @@ dependencies { // draggable list implementation(libs.reorderable) + + implementation(libs.androidx.security.crypto) } diff --git a/app/src/main/java/com/flux/MainActivity.kt b/app/src/main/java/com/flux/MainActivity.kt index 130ff97..0bc75fa 100644 --- a/app/src/main/java/com/flux/MainActivity.kt +++ b/app/src/main/java/com/flux/MainActivity.kt @@ -30,6 +30,7 @@ import com.flux.other.createNotificationChannel import com.flux.ui.effects.ScreenEffect import com.flux.ui.state.States import com.flux.ui.theme.FluxTheme +import com.flux.ui.viewModel.BackupSettingsViewModel import com.flux.ui.viewModel.BackupViewModel import com.flux.ui.viewModel.EventViewModel import com.flux.ui.viewModel.HabitViewModel @@ -74,6 +75,7 @@ class MainActivity : AppCompatActivity() { val backupViewModel: BackupViewModel = hiltViewModel() val labelViewModel: LabelViewModel = hiltViewModel() val progressBoardViewModel: ProgressBoardViewModel = hiltViewModel() + val backupSettingsViewModel: BackupSettingsViewModel = hiltViewModel() // States val settings by settingsViewModel.state.collectAsState() @@ -114,7 +116,8 @@ class MainActivity : AppCompatActivity() { settingsViewModel, backupViewModel, labelViewModel, - progressBoardViewModel + progressBoardViewModel, + backupSettingsViewModel ), states = States( notesState, diff --git a/app/src/main/java/com/flux/data/database/FluxDatabase.kt b/app/src/main/java/com/flux/data/database/FluxDatabase.kt index 710377d..3000956 100644 --- a/app/src/main/java/com/flux/data/database/FluxDatabase.kt +++ b/app/src/main/java/com/flux/data/database/FluxDatabase.kt @@ -411,18 +411,12 @@ val MIGRATION_10_11 = object : Migration(10, 11) { } val MIGRATION_11_12 = object : Migration(11, 12) { - - private val TAG = "Migration_11_12" private val OLD_TABLE = "WorkspaceModel" private val TMP_TABLE = "WorkspaceModel_new" override fun migrate(db: SupportSQLiteDatabase) { - Log.i(TAG, "Starting migration 11 -> 12 (passKey -> hashed passKeyHash)") - recreateTableWithHashedColumn(db) rehashExistingPasswords(db) - - Log.i(TAG, "Migration 11 -> 12 finished") } /** Step 1: recreate WorkspaceModel with passKeyHash instead of passKey, copying all rows as-is. */ @@ -455,8 +449,6 @@ val MIGRATION_11_12 = object : Migration(11, 12) { db.safeExec("DROP TABLE IF EXISTS `$OLD_TABLE`") db.safeExec("ALTER TABLE `$TMP_TABLE` RENAME TO `$OLD_TABLE`") - - Log.d(TAG, "WorkspaceModel table recreated with passKeyHash column") } /** @@ -487,7 +479,6 @@ val MIGRATION_11_12 = object : Migration(11, 12) { try { if (PasswordHasher.isHashed(currentValue)) { - Log.d(TAG, "Workspace $workspaceId already hashed, skipping") alreadyHashed++ continue } @@ -501,22 +492,21 @@ val MIGRATION_11_12 = object : Migration(11, 12) { arrayOf(hashed, workspaceId) ) migrated++ - Log.d(TAG, "Workspace $workspaceId passkey hashed successfully") } catch (rowError: Exception) { failed++ Log.e( - TAG, + "Migration_11_12", "Failed to hash passkey for workspace $workspaceId, leaving value untouched", rowError ) } } } catch (e: Exception) { - Log.e(TAG, "Critical error while iterating WorkspaceModel rows for hashing", e) + Log.e("Migration_11_12", "Critical error while iterating WorkspaceModel rows for hashing", e) } finally { cursor?.close() Log.i( - TAG, + "Migration_11_12", "Passkey hashing summary — total: $total, migrated: $migrated, " + "alreadyHashed: $alreadyHashed, failed: $failed" ) diff --git a/app/src/main/java/com/flux/di/BackupCryptoModule.kt b/app/src/main/java/com/flux/di/BackupCryptoModule.kt new file mode 100644 index 0000000..1422372 --- /dev/null +++ b/app/src/main/java/com/flux/di/BackupCryptoModule.kt @@ -0,0 +1,29 @@ +package com.flux.di + +import com.flux.other.crypto.AesGcmBackupEncryptor +import com.flux.other.crypto.BackupCredentialsStore +import com.flux.other.crypto.BackupEncryptor +import com.flux.other.crypto.EncryptedPrefsBackupCredentialsStore +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class BackupCryptoModule { + + @Binds + @Singleton + abstract fun bindBackupCredentialsStore( + impl: EncryptedPrefsBackupCredentialsStore + ): BackupCredentialsStore + + companion object { + @Provides + @Singleton + fun provideBackupEncryptor(): BackupEncryptor = AesGcmBackupEncryptor() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/navigation/NavRoutes.kt b/app/src/main/java/com/flux/navigation/NavRoutes.kt index fdd56f5..ffe0f0f 100644 --- a/app/src/main/java/com/flux/navigation/NavRoutes.kt +++ b/app/src/main/java/com/flux/navigation/NavRoutes.kt @@ -227,7 +227,7 @@ val SettingsScreens = Contact(navController, states.settings.data.cornerRadius) }, NavRoutes.Backup.route to { navController, snackbarHostState, states, viewModels -> - Data(navController, states.settings.data.cornerRadius, states.settings, snackbarHostState, viewModels.backupViewModel, viewModels.settingsViewModel::onEvent) + Data(navController, states.settings.data.cornerRadius, states.settings, snackbarHostState, viewModels.backupViewModel, viewModels.backupSettingsViewModel, viewModels.settingsViewModel::onEvent) }, NavRoutes.Editor.route to { navController, _, states, viewModels -> Editor(navController, states.settings, viewModels.settingsViewModel::onEvent) diff --git a/app/src/main/java/com/flux/other/crypto/BackupCredentialStore.kt b/app/src/main/java/com/flux/other/crypto/BackupCredentialStore.kt new file mode 100644 index 0000000..d8fb863 --- /dev/null +++ b/app/src/main/java/com/flux/other/crypto/BackupCredentialStore.kt @@ -0,0 +1,66 @@ +package com.flux.other.crypto + +import android.content.Context +import androidx.core.content.edit +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +interface BackupCredentialsStore { + suspend fun getPassword(): CharArray? + suspend fun setPassword(password: CharArray?) + fun hasPasswordFlow(): StateFlow +} + +/** + * The ONE place the backup password lives. Deliberately outside Room and outside + * FluxBackup/SettingsModel — it must never be written into an exported backup file. + * Backed by Android Keystore via EncryptedSharedPreferences: protects against file + * extraction / offline attacks, not against a fully unlocked device in an attacker's hand. + * Lost on uninstall — by design, there is no recovery path. + */ + +@Singleton +class EncryptedPrefsBackupCredentialsStore @Inject constructor( + @param:ApplicationContext private val context: Context +) : BackupCredentialsStore { + + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val prefs = EncryptedSharedPreferences.create( + context, + "flux_backup_credentials", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + private val _hasPassword = MutableStateFlow(prefs.contains(KEY_PASSWORD)) + override fun hasPasswordFlow(): StateFlow = _hasPassword.asStateFlow() + + override suspend fun getPassword(): CharArray? = withContext(Dispatchers.IO) { + prefs.getString(KEY_PASSWORD, null)?.toCharArray() + } + + override suspend fun setPassword(password: CharArray?) = withContext(Dispatchers.IO) { + if (password == null) { + prefs.edit { remove(KEY_PASSWORD) } + } else { + prefs.edit { putString(KEY_PASSWORD, String(password)) } + } + _hasPassword.value = (password != null) + } + + private companion object { + const val KEY_PASSWORD = "backup_password" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/other/crypto/BackupCryptoException.kt b/app/src/main/java/com/flux/other/crypto/BackupCryptoException.kt new file mode 100644 index 0000000..b0a0d46 --- /dev/null +++ b/app/src/main/java/com/flux/other/crypto/BackupCryptoException.kt @@ -0,0 +1,21 @@ +package com.flux.other.crypto + +import com.flux.R +import androidx.annotation.StringRes + +sealed class BackupCryptoException( + @param:StringRes val messageRes: Int, + vararg val formatArgs: Any, + cause: Throwable? = null +) : Exception(null, cause) { + + class WrongPasswordOrCorrupted(cause: Throwable) : BackupCryptoException( + R.string.backup_wrong_password_or_corrupted, + cause = cause + ) + + class UnsupportedVersion(version: Int) : BackupCryptoException( + R.string.unsupported_backup_version, + version + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/other/crypto/BackupEncryptor.kt b/app/src/main/java/com/flux/other/crypto/BackupEncryptor.kt new file mode 100644 index 0000000..2ef03a0 --- /dev/null +++ b/app/src/main/java/com/flux/other/crypto/BackupEncryptor.kt @@ -0,0 +1,83 @@ +package com.flux.other.crypto + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.ByteBuffer +import java.security.SecureRandom +import javax.crypto.AEADBadTagException +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec + +interface BackupEncryptor { + suspend fun encrypt(plaintext: ByteArray, password: CharArray): ByteArray + suspend fun decrypt(encryptedFile: ByteArray, password: CharArray): ByteArray +} + +/** + * AES-256-GCM with a PBKDF2-derived key. Salt + IV are random per export and stored + * in the file header, so the file is fully self-contained and portable across devices — + * only the password (known to the user) is needed to open it anywhere. + */ +class AesGcmBackupEncryptor : BackupEncryptor { + + private companion object { + const val SALT_SIZE = 16 + const val IV_SIZE = 12 + const val GCM_TAG_BITS = 128 + const val PBKDF2_ITERATIONS = 210_000 // OWASP-recommended floor for PBKDF2-HMAC-SHA256 + const val KEY_BITS = 256 + const val TRANSFORMATION = "AES/GCM/NoPadding" + } + + override suspend fun encrypt(plaintext: ByteArray, password: CharArray): ByteArray = + withContext(Dispatchers.Default) { + val salt = randomBytes(SALT_SIZE) + val iv = randomBytes(IV_SIZE) + val key = deriveKey(password, salt) + + val cipher = Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv)) + } + val ciphertext = cipher.doFinal(plaintext) + + BackupFileFormat.MAGIC + byteArrayOf(BackupFileFormat.VERSION) + salt + iv + ciphertext + } + + override suspend fun decrypt(encryptedFile: ByteArray, password: CharArray): ByteArray = + withContext(Dispatchers.Default) { + val buffer = ByteBuffer.wrap(encryptedFile).apply { position(BackupFileFormat.MAGIC.size) } + + val version = buffer.get() + if (version != BackupFileFormat.VERSION) { + throw BackupCryptoException.UnsupportedVersion(version.toInt()) + } + + val salt = ByteArray(SALT_SIZE).also { buffer.get(it) } + val iv = ByteArray(IV_SIZE).also { buffer.get(it) } + val ciphertext = ByteArray(buffer.remaining()).also { buffer.get(it) } + + val key = deriveKey(password, salt) + val cipher = Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv)) + } + + try { + cipher.doFinal(ciphertext) + } catch (e: AEADBadTagException) { + throw BackupCryptoException.WrongPasswordOrCorrupted(e) + } + } + + private fun deriveKey(password: CharArray, salt: ByteArray): SecretKey { + val spec = PBEKeySpec(password, salt, PBKDF2_ITERATIONS, KEY_BITS) + val raw = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded + spec.clearPassword() + return SecretKeySpec(raw, "AES") + } + + private fun randomBytes(size: Int): ByteArray = ByteArray(size).also { SecureRandom().nextBytes(it) } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/other/crypto/BackupFileFormat.kt b/app/src/main/java/com/flux/other/crypto/BackupFileFormat.kt new file mode 100644 index 0000000..4905025 --- /dev/null +++ b/app/src/main/java/com/flux/other/crypto/BackupFileFormat.kt @@ -0,0 +1,13 @@ +package com.flux.other.crypto + +/** + * Self-describing header so encrypted and legacy plaintext backups can coexist + * in the same folder, and imports can tell them apart without relying on file extension. + */ +object BackupFileFormat { + val MAGIC: ByteArray = "FLUXBK1".toByteArray(Charsets.US_ASCII) // 7 bytes + const val VERSION: Byte = 1 + + fun isEncrypted(bytes: ByteArray): Boolean = + bytes.size >= MAGIC.size && bytes.copyOfRange(0, MAGIC.size).contentEquals(MAGIC) +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/common/Dialog.kt b/app/src/main/java/com/flux/ui/common/Dialog.kt index 71f1608..876fc17 100644 --- a/app/src/main/java/com/flux/ui/common/Dialog.kt +++ b/app/src/main/java/com/flux/ui/common/Dialog.kt @@ -68,6 +68,13 @@ import java.util.Calendar import java.util.Date import java.util.Locale import java.util.TimeZone +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation fun convertMillisToDate(millis: Long): String { val formatter = SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) @@ -311,7 +318,7 @@ fun DataCopyDialog( selectedWorkspaces.clear() }, selected = selectedType == DataCopyType.COPY, - label = { Text("Copy") } + label = { Text(stringResource(R.string.copy)) } ) SegmentedButton( @@ -324,12 +331,12 @@ fun DataCopyDialog( selectedWorkspaces.clear() }, selected = selectedType == DataCopyType.MOVE, - label = { Text("Move") } + label = { Text(stringResource(R.string.move)) } ) } }, title = { - Text("Select Workspaces") + Text(stringResource(R.string.select_workspaces)) }, text = { LazyColumn (Modifier @@ -397,13 +404,122 @@ fun DataCopyDialog( onConfirm(selectedType, selectedWorkspaces.toList()) onDismiss() }) { - Text("Confirm") + Text(stringResource(R.string.Confirm)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text("Dismiss") + Text(stringResource(R.string.Dismiss)) } } ) +} + +/** Captures a password string. Used for: initial setup, per-file import prompt, and password change. */ +@Composable +fun BackupPasswordEntryDialog( + title: String, + onConfirm: (CharArray) -> Unit, + onDismiss: (() -> Unit)?, // null = non-dismissible (used for the mandatory setup flow) + supportingText: String? = null +) { + var password by remember { mutableStateOf("") } + var visible by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = { onDismiss?.invoke() }, + title = { Text(title) }, + text = { + Column { + supportingText?.let { + Text(it, style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(12.dp)) + } + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.backup_password)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { visible = !visible }) { + Icon( + if (visible) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility, + contentDescription = null + ) + } + } + ) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.backup_password_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + }, + confirmButton = { + TextButton( + enabled = password.isNotEmpty(), + onClick = { + val pw = password.toCharArray() + password = "" + onConfirm(pw) + } + ) { Text(stringResource(R.string.set_password)) } + }, + dismissButton = onDismiss?.let { + { TextButton(onClick = it) { Text(stringResource(R.string.Dismiss)) } } + } + ) +} + +/** Non-dismissible: fires whenever auto-backup is on but no password exists (migration, import, or live toggle). */ +@Composable +fun AutoBackupNeedsPasswordDialog( + onSetPasswordClick: () -> Unit, + onTurnOffAutoBackup: () -> Unit +) { + AlertDialog( + onDismissRequest = { /* force an explicit choice */ }, + title = { Text(stringResource(R.string.backup_password_setting)) }, + text = { Text(stringResource(R.string.auto_backup_requires_password)) }, + confirmButton = { + TextButton(onClick = onSetPasswordClick) { Text(stringResource(R.string.set_password)) } + }, + dismissButton = { + TextButton(onClick = onTurnOffAutoBackup) { Text(stringResource(R.string.turn_off_auto_backup)) } + } + ) +} + +@Composable +fun AutoBackupNeedsPasswordInfoDialog( + onNavigate: () -> Unit, +) { + AlertDialog( + onDismissRequest = { /* force an explicit choice */ }, + title = { Text(stringResource(R.string.backup_password_setting)) }, + text = { Text(stringResource(R.string.auto_backup_requires_password)) }, + confirmButton = { + TextButton(onClick = onNavigate) { Text(stringResource(R.string.go_to_settings)) } + }, + dismissButton = { } + ) +} + +/** Confirms the destructive consequence of rotating the password. */ +@Composable +fun ChangePasswordWarningDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.change_backup_password_title)) }, + text = { Text(stringResource(R.string.change_backup_password_message)) }, + confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(R.string.Confirm)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.Dismiss)) } } + ) } \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/settings/Data.kt b/app/src/main/java/com/flux/ui/screens/settings/Data.kt index 05f6755..f5eaace 100644 --- a/app/src/main/java/com/flux/ui/screens/settings/Data.kt +++ b/app/src/main/java/com/flux/ui/screens/settings/Data.kt @@ -1,5 +1,6 @@ package com.flux.ui.screens.settings +import android.net.Uri import android.os.Build import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult @@ -13,6 +14,7 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.outlined.CleaningServices import androidx.compose.material.icons.outlined.EditCalendar import androidx.compose.material.icons.rounded.Backup +import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.Restore import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -31,6 +33,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -52,9 +55,13 @@ import com.flux.other.canScheduleReminder import com.flux.other.isNotificationPermissionGranted import com.flux.other.openAppNotificationSettings import com.flux.other.requestExactAlarmPermission +import com.flux.ui.common.AutoBackupNeedsPasswordDialog +import com.flux.ui.common.BackupPasswordEntryDialog +import com.flux.ui.common.ChangePasswordWarningDialog import com.flux.ui.common.DeleteAlert import com.flux.ui.events.SettingEvents import com.flux.ui.state.Settings +import com.flux.ui.viewModel.BackupSettingsViewModel import com.flux.ui.viewModel.BackupViewModel import kotlinx.coroutines.launch @@ -67,6 +74,7 @@ fun Data( settings: Settings, snackbarHostState: SnackbarHostState, backupViewModel: BackupViewModel, + backupSettingsViewModel: BackupSettingsViewModel, onSettingsEvents: (SettingEvents) -> Unit ) { val context = LocalContext.current @@ -74,16 +82,49 @@ fun Data( val coroutineScope = rememberCoroutineScope() val operationSuccessful = stringResource(R.string.success) val operationFailed = stringResource(R.string.Failed) + var showWarningDialog by remember { mutableStateOf(false) } + var showAutoBackupNeedsPasswordDialog by remember { mutableStateOf(false) } + var showSetPasswordDialog by remember { mutableStateOf(false) } + var showChangePasswordWarning by remember { mutableStateOf(false) } + var showChangePasswordEntry by remember { mutableStateOf(false) } + var showImportPasswordDialog by remember { mutableStateOf(false) } + var pendingImportUri by remember { mutableStateOf(null) } + var pendingBackupFrequency by remember { + mutableStateOf(null) + } + val hasPassword by backupSettingsViewModel.hasBackupPassword.collectAsState() + + // --- Migration / startup check: existing users with auto-backup already on, no password yet --- + LaunchedEffect(Unit) { + if (backupSettingsViewModel.isAutoBackupUnsafe(settings.data.backupFrequency)) { + showAutoBackupNeedsPasswordDialog = true + } + } + + // --- Post-import check: imported settings may enable auto-backup on a device with no password --- + LaunchedEffect(Unit) { + backupViewModel.importCompleted.collect { + if (backupSettingsViewModel.isAutoBackupUnsafe(settings.data.backupFrequency)) { + showAutoBackupNeedsPasswordDialog = true + } + } + } + + // --- Import needs a password we don't have / doesn't match the stored one --- + LaunchedEffect(Unit) { + backupViewModel.passwordRequired.collect { uri -> + pendingImportUri = uri + showImportPasswordDialog = true + } + } - // IMPORT launcher val importLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument() ) { uri -> uri?.let { backupViewModel.importBackup(context, it) } } - // Observe result - Collect SharedFlow properly LaunchedEffect(Unit) { backupViewModel.backupResult.collect { result -> if (result.isSuccess) { @@ -94,7 +135,6 @@ fun Data( } } - // ... rest of your UI Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainerLow, topBar = { @@ -102,17 +142,14 @@ fun Data( colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), title = { Text(stringResource(R.string.data_title)) }, navigationIcon = { - IconButton({navController.navigateUp()}) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) + IconButton({ navController.navigateUp() }) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } } ) }, snackbarHost = { SnackbarHost(snackbarHostState) } - ){ innerPadding -> + ) { innerPadding -> LazyColumn( modifier = Modifier .padding(innerPadding) @@ -125,7 +162,7 @@ fun Data( icon = Icons.Rounded.Backup, radius = shapeManager(radius = radius, isFirst = true), actionType = ActionType.CUSTOM, - onCustomClick = { coroutineScope.launch { backupViewModel.exportBackup(context) }} + onCustomClick = { coroutineScope.launch { backupViewModel.exportBackup(context) } } ) } @@ -137,23 +174,15 @@ fun Data( title = stringResource(R.string.Restore), description = stringResource(R.string.Restore_Description), icon = Icons.Rounded.Restore, - radius = shapeManager(radius = radius, isLast = true), + radius = shapeManager(radius = radius), actionType = ActionType.CUSTOM, onCustomClick = { if (!canScheduleReminder(context)) { - Toast.makeText( - context, - reminderPermission, - Toast.LENGTH_SHORT - ).show() + Toast.makeText(context, reminderPermission, Toast.LENGTH_SHORT).show() requestExactAlarmPermission(context) } if (!isNotificationPermissionGranted(context)) { - Toast.makeText( - context, - notificationPermission, - Toast.LENGTH_SHORT - ).show() + Toast.makeText(context, notificationPermission, Toast.LENGTH_SHORT).show() openAppNotificationSettings(context) } if (canScheduleReminder(context) && isNotificationPermissionGranted(context)) { @@ -163,41 +192,46 @@ fun Data( ) } + // --- Backup password setting row --- + item { + SettingOption( + title = stringResource(R.string.backup_password_setting), + description = + if (hasPassword) stringResource(R.string.backup_encrypted) + else stringResource(R.string.backup_not_encrypted) + , + icon = Icons.Rounded.Lock, + radius = shapeManager(radius = radius, isLast = true), + actionType = ActionType.CUSTOM, + onCustomClick = { + if (hasPassword) showChangePasswordWarning = true + else showSetPasswordDialog = true + } + ) + } + item { val workManager = remember { WorkManager.getInstance(context.applicationContext) } val backupManager = remember(workManager) { BackupManager(workManager) } fun mapDaysToSliderPosition(days: Int): Float = when (days) { - 0 -> 0f - 1 -> 1f - 7 -> 2f - 30 -> 3f - else -> 0f + 0 -> 0f; 1 -> 1f; 7 -> 2f; 30 -> 3f; else -> 0f } - fun mapSliderPositionToFrequency(position: Float): BackupFrequency = when (position) { - 0f -> BackupFrequency.NEVER - 1f -> BackupFrequency.DAILY - 2f -> BackupFrequency.WEEKLY - 3f -> BackupFrequency.MONTHLY + 0f -> BackupFrequency.NEVER; 1f -> BackupFrequency.DAILY + 2f -> BackupFrequency.WEEKLY; 3f -> BackupFrequency.MONTHLY else -> BackupFrequency.NEVER } + var currentSliderPosition by remember(settings.data.backupFrequency) { mutableFloatStateOf(mapDaysToSliderPosition(settings.data.backupFrequency)) } - val selectedFrequency = mapSliderPositionToFrequency(currentSliderPosition) + ListItem( modifier = Modifier.padding(top = 8.dp), - leadingContent = { - Icon( - imageVector = Icons.Outlined.EditCalendar, - contentDescription = "Auto backup" - ) - }, - colors = ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ), + leadingContent = { Icon(Icons.Outlined.EditCalendar, contentDescription = "Auto backup") }, + colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), headlineContent = { Text(stringResource(R.string.backup_frequency)) }, supportingContent = { Text(text = stringResource(selectedFrequency.textRes)) } ) @@ -206,29 +240,32 @@ fun Data( modifier = Modifier.padding(horizontal = 16.dp), value = currentSliderPosition, onValueChange = { newPosition -> - currentSliderPosition = newPosition // update local state immediately + currentSliderPosition = newPosition hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) val newFrequency = mapSliderPositionToFrequency(newPosition) onSettingsEvents(SettingEvents.UpdateSettings(settings.data.copy(backupFrequency = newFrequency.days))) }, onValueChangeFinished = { - backupManager.scheduleBackup(mapSliderPositionToFrequency(currentSliderPosition)) + val newFrequency = mapSliderPositionToFrequency(currentSliderPosition) + + coroutineScope.launch { + if (backupSettingsViewModel.isAutoBackupUnsafe(newFrequency.days)) { + pendingBackupFrequency = newFrequency + showAutoBackupNeedsPasswordDialog = true + } else { + backupManager.scheduleBackup(newFrequency) + } + } }, valueRange = 0f..3f, steps = 2 ) } + item { ListItem( - colors = ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ), - leadingContent = { - Icon( - imageVector = Icons.Outlined.CleaningServices, - contentDescription = "Reset" - ) - }, + colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + leadingContent = { Icon(Icons.Outlined.CleaningServices, contentDescription = "Reset") }, headlineContent = { Text(stringResource(R.string.reset_database)) }, trailingContent = { TextButton( @@ -240,27 +277,88 @@ fun Data( containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer ) - ) { - Text(stringResource(R.string.reset)) - } + ) { Text(stringResource(R.string.reset)) } }, - supportingContent = { - Text(stringResource(R.string.clear_app_data)) - } + supportingContent = { Text(stringResource(R.string.clear_app_data)) } ) } } - if(showWarningDialog){ + + if (showWarningDialog) { DeleteAlert( - onConfirmation = { - showWarningDialog=false - onSettingsEvents(SettingEvents.ResetDatabase) }, + onConfirmation = { showWarningDialog = false; onSettingsEvents(SettingEvents.ResetDatabase) }, onDismissRequest = { showWarningDialog = false }, dialogTitle = stringResource(R.string.deleteDialogTitle), dialogText = stringResource(R.string.deleteDialogText), icon = Icons.Default.Delete ) } + + // --- Mandatory: auto-backup on, no password (migration / post-import / live toggle) --- + if (showAutoBackupNeedsPasswordDialog) { + AutoBackupNeedsPasswordDialog( + onSetPasswordClick = { + showSetPasswordDialog = true + }, + onTurnOffAutoBackup = { + showAutoBackupNeedsPasswordDialog = false + onSettingsEvents(SettingEvents.UpdateSettings(settings.data.copy(backupFrequency = BackupFrequency.NEVER.days))) + WorkManager.getInstance(context.applicationContext).let { BackupManager(it).scheduleBackup(BackupFrequency.NEVER) } + } + ) + } + + // --- Initial password setup (first time only) --- + if (showSetPasswordDialog) { + BackupPasswordEntryDialog( + title = stringResource(R.string.set_backup_password), + onConfirm = { pw -> + showAutoBackupNeedsPasswordDialog=false + showSetPasswordDialog = false + backupSettingsViewModel.setPassword(pw) { + pendingBackupFrequency?.let { frequency -> + val workManager = WorkManager.getInstance(context.applicationContext) + onSettingsEvents(SettingEvents.UpdateSettings(settings.data.copy(backupFrequency = frequency.days))) + BackupManager(workManager).scheduleBackup(frequency) + pendingBackupFrequency = null + } + } + }, + onDismiss = { + showSetPasswordDialog = false + } + ) + } + + // --- Rotation warning, then capture new password --- + if (showChangePasswordWarning) { + ChangePasswordWarningDialog( + onConfirm = { showChangePasswordWarning = false; showChangePasswordEntry = true }, + onDismiss = { showChangePasswordWarning = false } + ) + } + + if (showChangePasswordEntry) { + BackupPasswordEntryDialog( + title = stringResource(R.string.set_backup_password), + onConfirm = { pw -> + showChangePasswordEntry = false + backupSettingsViewModel.changePassword(pw) + }, + onDismiss = { showChangePasswordEntry = false } + ) + } + + // --- Per-file password prompt on import (new device, or file predates a rotation) --- + if (showImportPasswordDialog) { + BackupPasswordEntryDialog( + title = stringResource(R.string.enter_backup_password_title), + onConfirm = { pw -> + showImportPasswordDialog = false + pendingImportUri?.let { backupViewModel.importBackupWithPassword(context, it, pw) } + }, + onDismiss = { showImportPasswordDialog = false } + ) + } } } - diff --git a/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceHomeScreen.kt b/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceHomeScreen.kt index e3f06b3..ae60f60 100644 --- a/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceHomeScreen.kt +++ b/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceHomeScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -39,6 +40,7 @@ import com.flux.navigation.NavRoutes import com.flux.ui.events.WorkspaceEvents import com.flux.R import com.flux.data.model.verifyPasskey +import com.flux.ui.common.AutoBackupNeedsPasswordInfoDialog import com.flux.ui.common.BottomBar import com.flux.ui.common.SelectedToolBarRow import com.flux.ui.state.States @@ -59,6 +61,9 @@ fun WorkspaceHomeScreen( val wrongPassKeyLabel = stringResource(R.string.Wrong_Passkey) val selectedWorkspace = remember { mutableStateListOf() } var lockedWorkspace by remember { mutableStateOf(null) } + var showAutoBackupNeedsPasswordDialog by remember { mutableStateOf(false) } + val backupSettingsViewModel = viewModels.backupSettingsViewModel + val settings = states.settings.data lockedWorkspace?.let { SetPasskeyDialog(onConfirmRequest = { passkey -> @@ -75,6 +80,23 @@ fun WorkspaceHomeScreen( else { navController.navigate(NavRoutes.WorkspaceHome.withArgs(space.workspaceId)) } } + // --- Migration / startup check: existing users with auto-backup already on, no password yet --- + LaunchedEffect(Unit) { + if (backupSettingsViewModel.isAutoBackupUnsafe(settings.backupFrequency)) { + showAutoBackupNeedsPasswordDialog = true + } + } + + // --- Mandatory: auto-backup on, no password (migration / post-import / live toggle) --- + if (showAutoBackupNeedsPasswordDialog) { + AutoBackupNeedsPasswordInfoDialog( + onNavigate = { + showAutoBackupNeedsPasswordDialog=false + navController.navigate(NavRoutes.Backup.route) + } + ) + } + Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainerLow, topBar = { diff --git a/app/src/main/java/com/flux/ui/viewModel/BackupSettingsViewModel.kt b/app/src/main/java/com/flux/ui/viewModel/BackupSettingsViewModel.kt new file mode 100644 index 0000000..c5b5e63 --- /dev/null +++ b/app/src/main/java/com/flux/ui/viewModel/BackupSettingsViewModel.kt @@ -0,0 +1,49 @@ +package com.flux.ui.viewModel + +import androidx.lifecycle.viewModelScope +import com.flux.other.BackupFrequency +import com.flux.other.crypto.BackupCredentialsStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class BackupSettingsViewModel @Inject constructor( + private val credentialsStore: BackupCredentialsStore +) : androidx.lifecycle.ViewModel() { + + val hasBackupPassword: StateFlow = credentialsStore.hasPasswordFlow() + + /** + * Single source of truth for "auto-backup is currently misconfigured." + * True whenever a non-NEVER frequency is set but no password exists on this device — + * covers migration (pre-existing users), post-import, and live toggling identically. + */ + suspend fun isAutoBackupUnsafe(currentFrequencyDays: Int): Boolean = + currentFrequencyDays != BackupFrequency.NEVER.days && credentialsStore.getPassword() == null + + fun setPassword(password: CharArray, onDone: () -> Unit = {}) { + viewModelScope.launch { + credentialsStore.setPassword(password) + password.fill('\u0000') + onDone() + } + } + + /** Explicit rotation: intentionally orphans any backup encrypted with the old password. */ + fun changePassword(newPassword: CharArray, onDone: () -> Unit = {}) { + viewModelScope.launch { + credentialsStore.setPassword(newPassword) + newPassword.fill('\u0000') + onDone() + } + } + + fun disableEncryption(onDone: () -> Unit = {}) { + viewModelScope.launch { + credentialsStore.setPassword(null) + onDone() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/viewModel/BackupViewModel.kt b/app/src/main/java/com/flux/ui/viewModel/BackupViewModel.kt index eb6944c..7c17d20 100644 --- a/app/src/main/java/com/flux/ui/viewModel/BackupViewModel.kt +++ b/app/src/main/java/com/flux/ui/viewModel/BackupViewModel.kt @@ -2,7 +2,6 @@ package com.flux.ui.viewModel import android.content.Context import android.net.Uri -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flux.data.database.FluxBackup import com.flux.data.database.FluxDatabase @@ -13,6 +12,10 @@ import com.flux.data.repository.SettingsRepository import com.flux.other.BackupFrequency import com.flux.other.BackupManager import com.flux.other.Constants +import com.flux.other.crypto.BackupCredentialsStore +import com.flux.other.crypto.BackupCryptoException +import com.flux.other.crypto.BackupEncryptor +import com.flux.other.crypto.BackupFileFormat import com.flux.other.getOrCreateDirectory import com.flux.other.scheduleNextReminder import com.flux.other.tryRestoreUriPermission @@ -25,19 +28,31 @@ import kotlinx.coroutines.withContext import javax.inject.Inject import kotlinx.serialization.json.Json + @HiltViewModel class BackupViewModel @Inject constructor( private val db: FluxDatabase, private val settingsRepository: SettingsRepository, - private val backupManager: BackupManager -) : ViewModel() { + private val backupManager: BackupManager, + private val backupEncryptor: BackupEncryptor, + private val credentialsStore: BackupCredentialsStore +) : androidx.lifecycle.ViewModel() { private val _backupResult = MutableSharedFlow>() val backupResult = _backupResult.asSharedFlow() + + /** Fired when an encrypted file can't be opened with whatever password is currently stored (or none). */ + private val _passwordRequired = MutableSharedFlow() + val passwordRequired = _passwordRequired.asSharedFlow() + + /** Fired after a successful import — lets the UI re-check whether auto-backup is now unsafe. */ + private val _importCompleted = MutableSharedFlow() + val importCompleted = _importCompleted.asSharedFlow() + private val backupJson = Json { - ignoreUnknownKeys = true // old backups won't have new fields → skip them - coerceInputValues = true // null where non-null expected → use default - encodeDefaults = true // ensure new fields are always written on export + ignoreUnknownKeys = true + coerceInputValues = true + encodeDefaults = true classDiscriminator = "type" } @@ -45,40 +60,75 @@ class BackupViewModel @Inject constructor( val rootUri = settingsRepository.getStorageRoot() viewModelScope.launch(Dispatchers.IO) { - val baseDir = getOrCreateDirectory(context, rootUri, Constants.File.FLUX) - val backupDir = baseDir?.let { dir -> - getOrCreateDirectory(context, dir.uri, Constants.File.FLUX_BACKUP) - } + try { + val baseDir = getOrCreateDirectory(context, rootUri, Constants.File.FLUX) + val backupDir = baseDir?.let { getOrCreateDirectory(context, it.uri, Constants.File.FLUX_BACKUP) } + + backupDir?.let { dir -> + val plainJson = writeJsonBackup() + val password = credentialsStore.getPassword() - backupDir?.let { dir -> - val json = writeJsonBackup() - try { val fileName = "${System.currentTimeMillis()}.json" val file = dir.createFile("application/json", fileName) - file?.let { docFile -> - saveToUri(context, docFile.uri, json) + val bytes = if (password != null) { + backupEncryptor.encrypt(plainJson.toByteArray(Charsets.UTF_8), password) + .also { password.fill('\u0000') } + } else { + plainJson.toByteArray(Charsets.UTF_8) // no password set → plaintext, unchanged legacy behavior } + file?.let { saveBytesToUri(context, it.uri, bytes) } _backupResult.emit(Result.success(Unit)) - } catch (e: Exception) { - e.printStackTrace() - _backupResult.emit(Result.failure(e)) } + } catch (e: Exception) { + e.printStackTrace() + _backupResult.emit(Result.failure(e)) } } } + /** Entry point from the file picker. Tries the currently stored password first, silently. */ fun importBackup(context: Context, uri: Uri) { viewModelScope.launch { - try { - val json = readFromUri(context, uri) - uploadBackupToDatabase(context, json) - _backupResult.emit(Result.success(Unit)) - } catch (e: Exception) { - e.printStackTrace() - _backupResult.emit(Result.failure(e)) + attemptImport(context, uri, credentialsStore.getPassword()) + } + } + + /** Entry point after the user manually supplies a password (stored one didn't work, or none exists). */ + fun importBackupWithPassword(context: Context, uri: Uri, password: CharArray) { + viewModelScope.launch { + attemptImport(context, uri, password) + } + } + + private suspend fun attemptImport(context: Context, uri: Uri, password: CharArray?) { + try { + val bytes = readBytesFromUri(context, uri) + + val plainJson = if (BackupFileFormat.isEncrypted(bytes)) { + if (password == null) { + _passwordRequired.emit(uri) + return + } + try { + String(backupEncryptor.decrypt(bytes, password), Charsets.UTF_8) + } catch (e: BackupCryptoException.WrongPasswordOrCorrupted) { + _passwordRequired.emit(uri) // wrong/old password — ask the user for this file's actual one + return + } finally { + password.fill('\u0000') + } + } else { + String(bytes, Charsets.UTF_8) // legacy plaintext backup, decoded exactly as before } + + uploadBackupToDatabase(context, plainJson) + _backupResult.emit(Result.success(Unit)) + _importCompleted.emit(Unit) + } catch (e: Exception) { + e.printStackTrace() + _backupResult.emit(Result.failure(e)) } } @@ -94,104 +144,73 @@ class BackupViewModel @Inject constructor( labels = db.labelDao.getAll(), events = db.eventDao.loadAllEvents(), eventInstances = db.eventInstanceDao.getAll(), - settings = db.settingsDao.loadSetting()?: SettingsModel(), + settings = db.settingsDao.loadSetting() ?: SettingsModel(), progressBoardItems = db.progressBoardDao.getAllBoardItems() ) backupJson.encodeToString(FluxBackup.serializer(), backup) } - private suspend fun saveToUri(context: Context, uri: Uri, json: String) = + private suspend fun saveBytesToUri(context: Context, uri: Uri, bytes: ByteArray) = withContext(Dispatchers.IO) { - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - outputStream.write(json.toByteArray()) - outputStream.flush() - } ?: throw IllegalStateException("Could not open OutputStream") + context.contentResolver.openOutputStream(uri)?.use { it.write(bytes); it.flush() } + ?: throw IllegalStateException("Could not open OutputStream") } - private suspend fun readFromUri(context: Context, uri: Uri): String = + private suspend fun readBytesFromUri(context: Context, uri: Uri): ByteArray = withContext(Dispatchers.IO) { - context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: throw IllegalStateException("Could not open InputStream") } private suspend fun uploadBackupToDatabase(context: Context, json: String) = withContext(Dispatchers.IO) { val backup = backupJson.decodeFromString(FluxBackup.serializer(), json) - // --- Workspaces --- - backup.workspaces.forEach { ws -> - if (!db.workspaceDao.exists(ws.workspaceId)) db.workspaceDao.upsertWorkspace(ws) - } + backup.workspaces.forEach { ws -> if (!db.workspaceDao.exists(ws.workspaceId)) db.workspaceDao.upsertWorkspace(ws) } + backup.notes.forEach { note -> if (!db.notesDao.exists(note.notesId)) db.notesDao.upsertNote(note) } - // --- Notes --- - backup.notes.forEach { note -> - if (!db.notesDao.exists(note.notesId)) db.notesDao.upsertNote(note) - } - - // --- Todos --- backup.todos.forEach { todo -> if (!db.todoDao.exists(todo.id)) { - if(todo.recurrence== RecurrenceRule.Weekly) scheduleNextReminder(context, todo.toScheduleRequest()) + if (todo.recurrence == RecurrenceRule.Weekly) scheduleNextReminder(context, todo.toScheduleRequest()) db.todoDao.upsertList(todo) } } - backup.todoInstances.forEach { instance -> - if (!db.todoInstanceDao.exists(instance.todoId, instance.instanceDate)) - db.todoInstanceDao.upsertTodoInstance(instance) + if (!db.todoInstanceDao.exists(instance.todoId, instance.instanceDate)) db.todoInstanceDao.upsertTodoInstance(instance) } - // --- Habits --- backup.habits.forEach { habit -> if (!db.habitDao.exists(habit.id)) { scheduleNextReminder(context, habit.toScheduleRequest()) db.habitDao.upsertHabit(habit) } } + backup.habitInstances.forEach { hi -> if (!db.habitInstanceDao.exists(hi.habitId, hi.instanceDate)) db.habitInstanceDao.upsertInstance(hi) } - // --- Habit Instances --- - backup.habitInstances.forEach { hi -> - if (!db.habitInstanceDao.exists(hi.habitId, hi.instanceDate)) db.habitInstanceDao.upsertInstance(hi) - } + backup.journals.forEach { journal -> if (!db.journalDao.exists(journal.journalId)) db.journalDao.upsertEntry(journal) } + backup.labels.forEach { label -> if (!db.labelDao.exists(label.labelId)) db.labelDao.upsertLabel(label) } - // --- Journals --- - backup.journals.forEach { journal -> - if (!db.journalDao.exists(journal.journalId)) db.journalDao.upsertEntry(journal) - } - - // --- Labels --- - backup.labels.forEach { label -> - if (!db.labelDao.exists(label.labelId)) db.labelDao.upsertLabel(label) - } - - // --- Events --- backup.events.forEach { event -> - if (!db.eventDao.exists(event.id)){ + if (!db.eventDao.exists(event.id)) { scheduleNextReminder(context, event.toScheduleRequest()) db.eventDao.upsertEvent(event) } } - - // --- Event Instances --- - backup.eventInstances.forEach { ei -> - if (!db.eventInstanceDao.exists(ei.eventId, ei.instanceDate)) - db.eventInstanceDao.upsertEventInstance(ei) - } + backup.eventInstances.forEach { ei -> if (!db.eventInstanceDao.exists(ei.eventId, ei.instanceDate)) db.eventInstanceDao.upsertEventInstance(ei) } // --- Settings --- val current = db.settingsDao.loadSetting() - val merged = if (backup.settings.storageRootUri != null) { backup.settings } else { backup.settings.copy(storageRootUri = current?.storageRootUri) } - db.settingsDao.upsertSettings(merged) - merged.storageRootUri?.let { - tryRestoreUriPermission(context, it) - } + merged.storageRootUri?.let { tryRestoreUriPermission(context, it) } + // NOTE: we schedule the WorkManager job based on the imported frequency regardless of + // whether a password exists — BackupWorker itself is the guard that skips unsafe runs. + // The UI-level check (isAutoBackupUnsafe) is what prompts the user afterward via importCompleted. val frequency = merged.backupFrequency fun mapDaysToFrequency(day: Int): BackupFrequency = when (day) { 0 -> BackupFrequency.NEVER @@ -200,12 +219,8 @@ class BackupViewModel @Inject constructor( 30 -> BackupFrequency.MONTHLY else -> BackupFrequency.NEVER } - backupManager.scheduleBackup(mapDaysToFrequency(frequency)) - // --- Progress Board --- - backup.progressBoardItems.forEach { item -> - if (!db.progressBoardDao.exists(item.workspaceId)) db.progressBoardDao.upsertBoardItem(item) - } + backup.progressBoardItems.forEach { item -> if (!db.progressBoardDao.exists(item.workspaceId)) db.progressBoardDao.upsertBoardItem(item) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/viewModel/ViewModels.kt b/app/src/main/java/com/flux/ui/viewModel/ViewModels.kt index 2ffb819..6f272bf 100644 --- a/app/src/main/java/com/flux/ui/viewModel/ViewModels.kt +++ b/app/src/main/java/com/flux/ui/viewModel/ViewModels.kt @@ -10,5 +10,6 @@ data class ViewModels( val settingsViewModel: SettingsViewModel, val backupViewModel: BackupViewModel, val labelViewModel: LabelViewModel, - val progressBoardViewModel: ProgressBoardViewModel + val progressBoardViewModel: ProgressBoardViewModel, + val backupSettingsViewModel: BackupSettingsViewModel ) diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 83c6bda..3bc0e1f 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -540,4 +540,20 @@ Löschwarnung! Du bist dabei, %1$s Bereich(e) mit allen Daten dauerhaft zu löschen. Dies kann nicht rückgängig gemacht werden. + Backup-Passwort festlegen + Backup-Passwort eingeben + Backup-Passwort festlegen + Backups sind verschlüsselt + Backups sind nicht verschlüsselt + Backup-Passwort + Dieses Passwort kann nicht wiederhergestellt werden, wenn es verloren geht. Sie benötigen es, um dieses Backup auf jedem Gerät wiederherzustellen. + Backup-Passwort festlegen + Die automatische Sicherung ist aktiviert, aber es wurde kein Passwort festgelegt. Deshalb können Sicherungen nicht sicher ausgeführt werden. Legen Sie jetzt ein Passwort fest oder deaktivieren Sie die automatische Sicherung. + Zu den Einstellungen + Backup-Passwort ändern? + Bereits mit Ihrem aktuellen Passwort verschlüsselte Backups können nach dieser Änderung nicht mehr wiederhergestellt werden. Neue Backups verwenden das neue Passwort. Dieser Vorgang kann nicht rückgängig gemacht werden. + Passwort festlegen + Automatische Sicherung deaktivieren + Nicht unterstützte Backup-Version: %1$s + Falsches Passwort oder die Datei ist beschädigt bzw. wurde manipuliert. \ No newline at end of file diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 5f80dec..73d664d 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -544,4 +544,20 @@ ¡Alerta de eliminación! Estás a punto de eliminar permanentemente los espacios %1$s con todos sus datos. Esta acción no se puede deshacer. + Establecer contraseña de copia de seguridad + Introducir contraseña de copia de seguridad + Establecer una contraseña de copia de seguridad + Las copias de seguridad están cifradas + Las copias de seguridad no están cifradas + Contraseña de copia de seguridad + Esta contraseña no puede recuperarse si se pierde. La necesitará para restaurar esta copia de seguridad en cualquier dispositivo. + Establecer una contraseña de copia de seguridad + La copia de seguridad automática está activada, pero no se ha configurado una contraseña, por lo que no puede ejecutarse de forma segura. Configure una contraseña ahora o desactive la copia de seguridad automática. + Ir a ajustes + ¿Cambiar la contraseña de copia de seguridad? + Las copias de seguridad ya cifradas con su contraseña actual dejarán de poder restaurarse después de este cambio. Las nuevas copias de seguridad usarán la nueva contraseña. Esta acción no se puede deshacer. + Establecer contraseña + Desactivar copia de seguridad automática + Versión de copia de seguridad no compatible: %1$s + La contraseña es incorrecta o el archivo está dañado o ha sido manipulado. \ No newline at end of file diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 14d046a..cd8c10b 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -548,4 +548,20 @@ Alerte de suppression ! Vous êtes sur le point de supprimer définitivement les espaces %1$s avec toutes leurs données. Cette action est irréversible. + Définir un mot de passe de sauvegarde + Saisir le mot de passe de sauvegarde + Définir un mot de passe de sauvegarde + Les sauvegardes sont chiffrées + Les sauvegardes ne sont pas chiffrées + Mot de passe de sauvegarde + Ce mot de passe ne peut pas être récupéré s\'il est perdu. Vous en aurez besoin pour restaurer cette sauvegarde sur n'importe quel appareil. + Définir un mot de passe de sauvegarde + La sauvegarde automatique est activée, mais aucun mot de passe n\'est défini. Les sauvegardes ne peuvent donc pas être exécutées en toute sécurité. Définissez un mot de passe maintenant ou désactivez la sauvegarde automatique. + Aller aux paramètres + Changer le mot de passe de sauvegarde ? + Les sauvegardes déjà chiffrées avec votre mot de passe actuel ne pourront plus être restaurées après cette modification. Les nouvelles sauvegardes utiliseront le nouveau mot de passe. Cette action est irréversible. + Définir le mot de passe + Désactiver la sauvegarde automatique + Version de sauvegarde non prise en charge : %1$s + Mot de passe incorrect, ou le fichier est corrompu ou a été modifié. \ No newline at end of file diff --git a/app/src/main/res/values-hi-rIN/strings.xml b/app/src/main/res/values-hi-rIN/strings.xml index e737195..36eef5c 100644 --- a/app/src/main/res/values-hi-rIN/strings.xml +++ b/app/src/main/res/values-hi-rIN/strings.xml @@ -547,4 +547,20 @@ हटाने की चेतावनी! %1$s स्पेस और उनका सारा डेटा स्थायी रूप से हटाया जाएगा। इसे वापस नहीं लाया जा सकता। + बैकअप पासवर्ड सेट करें + बैकअप पासवर्ड दर्ज करें + बैकअप पासवर्ड सेट करें + बैकअप एन्क्रिप्टेड हैं + बैकअप एन्क्रिप्टेड नहीं हैं + बैकअप पासवर्ड + यदि यह पासवर्ड खो जाता है तो इसे पुनर्प्राप्त नहीं किया जा सकता। किसी भी डिवाइस पर इस बैकअप को पुनर्स्थापित करने के लिए इसकी आवश्यकता होगी। + बैकअप पासवर्ड सेट करें + स्वचालित बैकअप चालू है, लेकिन कोई पासवर्ड सेट नहीं है, इसलिए बैकअप सुरक्षित रूप से नहीं चल सकते। अभी पासवर्ड सेट करें या स्वचालित बैकअप बंद करें। + सेटिंग्स पर जाएँ + बैकअप पासवर्ड बदलें? + आपके वर्तमान पासवर्ड से पहले से एन्क्रिप्ट किए गए बैकअप इस परिवर्तन के बाद पुनर्स्थापित नहीं किए जा सकेंगे। नए बैकअप नए पासवर्ड का उपयोग करेंगे। इस परिवर्तन को वापस नहीं किया जा सकता। + पासवर्ड सेट करें + स्वचालित बैकअप बंद करें + असमर्थित बैकअप संस्करण: %1$s + गलत पासवर्ड, या फ़ाइल क्षतिग्रस्त है या उसके साथ छेड़छाड़ की गई है। diff --git a/app/src/main/res/values-nl-rNL/strings.xml b/app/src/main/res/values-nl-rNL/strings.xml index 4b87f97..3115343 100644 --- a/app/src/main/res/values-nl-rNL/strings.xml +++ b/app/src/main/res/values-nl-rNL/strings.xml @@ -546,4 +546,21 @@ Sleep horizontaal om opnieuw te ordenen Verwijderingswaarschuwing! Je staat op het punt %1$s ruimte(n) met alle gegevens permanent te verwijderen. Dit kan niet ongedaan worden gemaakt. + + Back-upwachtwoord instellen + Back-upwachtwoord invoeren + Een back-upwachtwoord instellen + Back-ups zijn versleuteld + Back-ups zijn niet versleuteld + Back-upwachtwoord + Dit wachtwoord kan niet worden hersteld als u het kwijtraakt. U hebt het nodig om deze back-up op elk apparaat te herstellen. + Een back-upwachtwoord instellen + Automatische back-up is ingeschakeld, maar er is geen wachtwoord ingesteld. Daardoor kunnen back-ups niet veilig worden uitgevoerd. Stel nu een wachtwoord in of schakel automatische back-up uit. + Ga naar instellingen + Back-upwachtwoord wijzigen? + Back-ups die al met uw huidige wachtwoord zijn versleuteld, kunnen na deze wijziging niet meer worden hersteld. Nieuwe back-ups gebruiken het nieuwe wachtwoord. Dit kan niet ongedaan worden gemaakt. + Wachtwoord instellen + Automatische back-up uitschakelen + Niet-ondersteunde back-upversie: %1$s + Onjuist wachtwoord of het bestand is beschadigd of ermee is geknoeid. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 4f38988..6f27313 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -546,4 +546,21 @@ Arraste horizontalmente para reorganizar Alerta de exclusão! Você está prestes a excluir permanentemente o(s) espaço(s) %1$s e todos os seus dados. Esta ação não pode ser desfeita. + + Definir senha do backup + Inserir senha do backup + Definir uma senha para o backup + Os backups estão criptografados + Os backups não estão criptografados + Senha do backup + Esta senha não pode ser recuperada caso seja perdida. Você precisará dela para restaurar este backup em qualquer dispositivo. + Definir uma senha para o backup + O backup automático está ativado, mas nenhuma senha foi definida, portanto os backups não podem ser executados com segurança. Defina uma senha agora ou desative o backup automático. + Ir para as configurações + Alterar senha do backup? + Os backups já criptografados com sua senha atual não poderão mais ser restaurados após esta alteração. Os novos backups usarão a nova senha. Esta ação não pode ser desfeita. + Definir senha + Desativar backup automático + Versão de backup não suportada: %1$s + Senha incorreta ou o arquivo está corrompido ou foi adulterado. \ No newline at end of file diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index e5a2791..aa4cb66 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -536,7 +536,6 @@ Пропущено Выбрать задачу - Элементы списка Для этой даты нет контрольного списка. Отслеживание начнётся позже! @@ -548,4 +547,20 @@ Предупреждение об удалении! Вы собираетесь навсегда удалить пространство(а) %1$s вместе со всеми данными. Это действие нельзя отменить. + Установить пароль резервной копии + Введите пароль резервной копии + Установить пароль резервной копии + Резервные копии зашифрованы + Резервные копии не зашифрованы + Пароль резервной копии + Этот пароль невозможно восстановить, если он будет утерян. Он потребуется для восстановления этой резервной копии на любом устройстве. + Установить пароль резервной копии + Автоматическое резервное копирование включено, но пароль не задан, поэтому резервные копии не могут создаваться безопасно. Установите пароль сейчас или отключите автоматическое резервное копирование. + Перейти к настройкам + Изменить пароль резервной копии? + Резервные копии, уже зашифрованные текущим паролем, больше нельзя будет восстановить после этого изменения. Новые резервные копии будут использовать новый пароль. Это действие нельзя отменить. + Установить пароль + Отключить автоматическое резервное копирование + Неподдерживаемая версия резервной копии: %1$s + Неверный пароль, либо файл повреждён или был изменён. \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1372cae..14dec31 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -551,4 +551,21 @@ 水平拖动以重新排序 删除警告! 你即将永久删除 %1$s 个空间及其所有数据,此操作无法撤销。 + + 设置备份密码 + 输入备份密码 + 设置备份密码 + 备份已加密 + 备份未加密 + 备份密码 + 如果丢失,此密码无法恢复。您需要使用它才能在任何设备上恢复此备份。 + 设置备份密码 + 自动备份已开启,但尚未设置密码,因此无法安全执行备份。请立即设置密码,或关闭自动备份。 + 前往设置 + 更改备份密码? + 使用当前密码加密的现有备份在更改后将无法再恢复。新的备份将使用新密码。此操作无法撤销。 + 设置密码 + 关闭自动备份 + 不支持的备份版本:%1$s + 密码错误,或文件已损坏或被篡改。 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5bb7545..25cc93b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -545,4 +545,20 @@ Delete Alert! You are about to delete %1$s space(s) with their data permanently. This can\'t be undone. + Set backup password + Enter backup password + Set a backup password + Backups are encrypted + Backups are not encrypted + Backup password + This password cannot be recovered if lost. You will need it to restore this backup on any device. + Set a backup password + Automatic backup is turned on, but no password is set, so backups cannot run safely. Set a password now, or turn automatic backup off. + Go to settings + Change backup password? + Backups already encrypted with your current password will no longer be restorable after this change. New backups will use the new password. This cannot be undone. + Set password + Turn off auto-backup + Unsupported backup version: %1$s + Wrong password, or the file is corrupted or has been tampered with. \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bbb87f9..5b73fa2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ hilt = "2.60.1" hiltNavigationCompose = "1.4.0" browser = "1.10.0" documentfile = "1.1.0" +securityCrypto = "1.1.0" workRuntimeKtx = "2.11.2" foundationLayout = "1.11.4" adaptive = "1.2.0" @@ -50,6 +51,7 @@ androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = " androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } # Hilt (Dependency Injection) +androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } From 8e281098b6ffbb3b9b1a07568184207dac307539 Mon Sep 17 00:00:00 2001 From: chindaronit Date: Sun, 26 Jul 2026 11:04:50 +0530 Subject: [PATCH 2/2] fix: - color in todo expandable card - fr string build issue --- .../main/java/com/flux/ui/screens/todo/TodoComponents.kt | 4 ++-- app/src/main/res/values-fr-rFR/strings.xml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/flux/ui/screens/todo/TodoComponents.kt b/app/src/main/java/com/flux/ui/screens/todo/TodoComponents.kt index 327158e..3004ed9 100644 --- a/app/src/main/java/com/flux/ui/screens/todo/TodoComponents.kt +++ b/app/src/main/java/com/flux/ui/screens/todo/TodoComponents.kt @@ -131,7 +131,7 @@ fun TodoExpandableCard( Card( modifier = Modifier.padding(top = 4.dp), shape = if(isExpanded) shapeManager(isBoth = true, radius=radius) else RoundedCornerShape(50), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) ) { Column { TodoHeaderRow( @@ -250,7 +250,7 @@ fun MaterialListItem( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(50), colors = CardDefaults.cardColors( - containerColor = if (todoItem.isChecked) MaterialTheme.colorScheme.primaryContainer.copy(0.5f) else MaterialTheme.colorScheme.primaryContainer.copy(0.7f) + containerColor = if (todoItem.isChecked) MaterialTheme.colorScheme.primaryContainer.copy(0.4f) else MaterialTheme.colorScheme.primaryContainer.copy(0.6f) ), onClick = onToggleCheck ) { diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index cd8c10b..ee91336 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -1,5 +1,5 @@ - + Flux @@ -372,7 +372,7 @@ Non commencé - En cours + En cours Vide Objectif Début @@ -554,7 +554,7 @@ Les sauvegardes sont chiffrées Les sauvegardes ne sont pas chiffrées Mot de passe de sauvegarde - Ce mot de passe ne peut pas être récupéré s\'il est perdu. Vous en aurez besoin pour restaurer cette sauvegarde sur n'importe quel appareil. + Ce mot de passe ne peut pas être récupéré s\'il est perdu. Vous en aurez besoin pour restaurer cette sauvegarde sur n\'importe quel appareil. Définir un mot de passe de sauvegarde La sauvegarde automatique est activée, mais aucun mot de passe n\'est défini. Les sauvegardes ne peuvent donc pas être exécutées en toute sécurité. Définissez un mot de passe maintenant ou désactivez la sauvegarde automatique. Aller aux paramètres