diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt index 198c58bb84..b81e19e9a2 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt @@ -53,6 +53,8 @@ class SimplexApp: Application(), LifecycleEventObserver { if (res.second != DBMigrationResult.OK) { Log.d(TAG, "Unable to migrate successfully: ${res.second}") } else if (startChat) { + // If we migrated successfully means previous re-encryption process on database level finished successfully too + if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) withApi { val user = chatController.apiGetActiveUser() if (user == null) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 2198820b3e..60d8cc473e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -110,6 +110,7 @@ class AppPreferences(val context: Context) { val initialRandomDBPassphrase = mkBoolPreference(SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE, false) val encryptedDBPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE, null) val initializationVectorDBPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE, null) + val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null, true) val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name) val primaryColor = mkIntPreference(SHARED_PREFS_PRIMARY_COLOR, LightColorPalette.primary.toArgb()) @@ -146,13 +147,19 @@ class AppPreferences(val context: Context) { set = fun(value) = sharedPreferences.edit().putString(prefName, value).apply() ) - private fun mkDatePreference(prefName: String, default: Instant?): Preference = + /** + * Provide `[commit] = true` to save preferences right now, not after some unknown period of time. + * So in case of a crash this value will be saved 100% + * */ + private fun mkDatePreference(prefName: String, default: Instant?, commit: Boolean = false): Preference = Preference( get = { val pref = sharedPreferences.getString(prefName, default?.toEpochMilliseconds()?.toString()) pref?.let { Instant.fromEpochMilliseconds(pref.toLong()) } }, - set = fun(value) = sharedPreferences.edit().putString(prefName, value?.toEpochMilliseconds()?.toString()).apply() + set = fun(value) = sharedPreferences.edit().putString(prefName, value?.toEpochMilliseconds()?.toString()).let { + if (commit) it.commit() else it.apply() + } ) companion object { @@ -189,6 +196,7 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE = "InitialRandomDBPassphrase" private const val SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE = "EncryptedDBPassphrase" private const val SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE = "InitializationVectorDBPassphrase" + private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt" private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme" private const val SHARED_PREFS_PRIMARY_COLOR = "PrimaryColor" } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt index 3298ead4ac..e2fceae5bd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt @@ -32,6 +32,7 @@ import chat.simplex.app.SimplexApp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* +import kotlinx.datetime.Clock import kotlin.math.log2 @Composable @@ -63,7 +64,9 @@ fun DatabaseEncryptionView(m: ChatModel) { progressIndicator.value = true withApi { try { + prefs.encryptionStartedAt.set(Clock.System.now()) val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value) + prefs.encryptionStartedAt.set(null) val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError when { sqliteError is SQLiteError.ErrorNotADatabase -> { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt index 82454bf901..41428d4cbf 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt @@ -2,6 +2,7 @@ package chat.simplex.app.views.database import SectionSpacer import SectionView +import android.content.Context import android.util.Log import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -11,6 +12,7 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.* @@ -20,6 +22,11 @@ import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* import chat.simplex.app.views.usersettings.NotificationsMode import kotlinx.coroutines.* +import kotlinx.datetime.Clock +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import kotlin.io.path.Path @Composable fun DatabaseErrorView( @@ -30,6 +37,8 @@ fun DatabaseErrorView( val dbKey = remember { mutableStateOf("") } var storedDBKey by remember { mutableStateOf(DatabaseUtils.getDatabaseKey()) } var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) } + val context = LocalContext.current + val restoreDbFromBackup = remember { mutableStateOf(shouldShowRestoreDbButton(appPreferences, context)) } val saveAndRunChatOnClick: () -> Unit = { DatabaseUtils.setDatabaseKey(dbKey.value) storedDBKey = dbKey.value @@ -102,6 +111,20 @@ fun DatabaseErrorView( null -> { } } + if (restoreDbFromBackup.value) { + SectionSpacer() + Text(generalGetString(R.string.database_backup_can_be_restored)) + Spacer(Modifier.size(16.dp)) + RestoreDbButton { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.restore_database_alert_title), + text = generalGetString(R.string.restore_database_alert_desc), + confirmText = generalGetString(R.string.restore_database_alert_confirm), + onConfirm = { restoreDb(restoreDbFromBackup, appPreferences, context) }, + destructive = true, + ) + } + } } } } @@ -160,6 +183,31 @@ private fun runChat( } } +private fun shouldShowRestoreDbButton(prefs: AppPreferences, context: Context): Boolean { + val startedAt = prefs.encryptionStartedAt.get() ?: return false + /** Just in case there is any small difference between reported Java's [Clock.System.now] and Linux's time on a file */ + val safeDiffInTime = 10_000L + val filesChat = File(context.dataDir.absolutePath + File.separator + "files_chat.db.bak") + val filesAgent = File(context.dataDir.absolutePath + File.separator + "files_agent.db.bak") + return filesChat.exists() && + filesAgent.exists() && + startedAt.toEpochMilliseconds() - safeDiffInTime <= filesChat.lastModified() && + startedAt.toEpochMilliseconds() - safeDiffInTime <= filesAgent.lastModified() +} + +private fun restoreDb(restoreDbFromBackup: MutableState, prefs: AppPreferences, context: Context) { + val filesChatBase = context.dataDir.absolutePath + File.separator + "files_chat.db" + val filesAgentBase = context.dataDir.absolutePath + File.separator + "files_agent.db" + try { + Files.move(Path("$filesChatBase.bak"), Path(filesChatBase), StandardCopyOption.REPLACE_EXISTING) + Files.move(Path("$filesAgentBase.bak"), Path(filesAgentBase), StandardCopyOption.REPLACE_EXISTING) + restoreDbFromBackup.value = false + prefs.encryptionStartedAt.set(null) + } catch (e: Exception) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.database_restore_error), e.stackTraceToString()) + } +} + @Composable private fun DatabaseKeyField(text: MutableState, enabled: Boolean, onClick: (() -> Unit)? = null) { DatabaseKeyField( @@ -187,6 +235,13 @@ private fun ColumnScope.OpenChatButton(enabled: Boolean, onClick: () -> Unit) { } } +@Composable +private fun ColumnScope.RestoreDbButton(onClick: () -> Unit) { + TextButton(onClick, Modifier.align(Alignment.CenterHorizontally)) { + Text(generalGetString(R.string.restore_database), color = MaterialTheme.colors.error) + } +} + @Preview @Composable fun PreviewChatInfoLayout() { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt index 431c5e177a..9e07bcc7d0 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt @@ -18,7 +18,8 @@ object DatabaseUtils { private const val DATABASE_PASSWORD_ALIAS: String = "databasePassword" - fun hasDatabase(filesDirectory: String): Boolean = File(filesDirectory + File.separator + "files_chat.db").exists() + private fun hasDatabase(rootDir: String): Boolean = + File(rootDir + File.separator + "files_chat.db").exists() && File(rootDir + File.separator + "files_agent.db").exists() fun getDatabaseKey(): String? { return cryptor.decryptData( @@ -42,21 +43,21 @@ object DatabaseUtils { fun migrateChatDatabase(useKey: String? = null): Pair { Log.d(TAG, "migrateChatDatabase ${appPreferences.storeDBPassphrase.get()}") - val dbPath = getFilesDirectory(SimplexApp.context) + val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context) var dbKey = "" val useKeychain = appPreferences.storeDBPassphrase.get() if (useKey != null) { dbKey = useKey } else if (useKeychain) { - if (!hasDatabase(dbPath)) { + if (!hasDatabase(SimplexApp.context.dataDir.absolutePath)) { dbKey = randomDatabasePassword() appPreferences.initialRandomDBPassphrase.set(true) } else { dbKey = getDatabaseKey() ?: "" } } - Log.d(TAG, "migrateChatDatabase DB path: $dbPath") - val migrated = chatMigrateDB(dbPath, dbKey) + Log.d(TAG, "migrateChatDatabase DB path: $dbAbsolutePathPrefix") + val migrated = chatMigrateDB(dbAbsolutePathPrefix, dbKey) val res: DBMigrationResult = kotlin.runCatching { json.decodeFromString(migrated) }.getOrElse { DBMigrationResult.Unknown(migrated) } diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index a32d0848d4..e9e8f976a9 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -603,6 +603,12 @@ Введите пароль… Сохранить пароль и открыть чат Открыть чат + Попытка поменять пароль базы данных не была завершена. + Восстановить резервную копию + Восстановить резервную копию? + Введите предыдущий пароль после восстановления резервной копии. Это действие нельзя отменить. + Восстановить + Ошибка при восстановлении базы данных Чат остановлен diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 4db37e95d9..88bebbf341 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -604,6 +604,12 @@ Enter passphrase… Save passphrase and open chat Open chat + The attempt to change database passphrase was not completed. + Restore database backup + Restore database backup? + Please enter the previous password after restoring database backup. This action can not be undone. + Restore + Restore database error Chat is stopped