From aff71c58d7c0436158a3547309ba36cc990712e2 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 5 Sep 2023 13:45:09 +0300 Subject: [PATCH] desktop: setup passphrase during onboarding (#2987) * desktop: setup passphrase during onboarding * updated logic * removed unused code * button and starting chat action * better * removed debug code * fallback * focusing and moving focus on desktop text fields * different logic * removed unused variable * divided logic in two functions * enabled keyboard enter * rollback when db deleted by hand on desktop * update texts, font size * stopping chat before other actions --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../main/java/chat/simplex/app/SimplexApp.kt | 6 +- .../DatabaseEncryptionView.android.kt | 106 ++++++++ .../kotlin/chat/simplex/common/App.kt | 13 +- .../chat/simplex/common/model/ChatModel.kt | 1 - .../chat/simplex/common/platform/Core.kt | 7 +- .../simplex/common/platform/NtfManager.kt | 2 +- .../chat/simplex/common/views/WelcomeView.kt | 35 ++- .../views/database/DatabaseEncryptionView.kt | 187 ++++++-------- .../views/database/DatabaseErrorView.kt | 18 +- .../common/views/database/DatabaseView.kt | 5 +- .../common/views/helpers/AlertManager.kt | 6 +- .../common/views/helpers/DatabaseUtils.kt | 6 + .../common/views/helpers/SimpleButton.kt | 7 +- .../common/views/localauth/LocalAuthView.kt | 1 - .../views/onboarding/CreateSimpleXAddress.kt | 24 +- .../common/views/onboarding/HowItWorks.kt | 5 +- .../common/views/onboarding/OnboardingView.kt | 1 + .../views/onboarding/SetNotificationsMode.kt | 2 +- .../onboarding/SetupDatabasePassphrase.kt | 233 ++++++++++++++++++ .../common/views/onboarding/SimpleXInfo.kt | 13 +- .../common/views/usersettings/SettingsView.kt | 9 +- .../commonMain/resources/MR/base/strings.xml | 12 + .../DatabaseEncryptionView.desktop.kt | 106 ++++++++ 23 files changed, 652 insertions(+), 153 deletions(-) create mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index c94194a35..f70032788 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -71,7 +71,7 @@ class SimplexApp: Application(), LifecycleEventObserver { } Lifecycle.Event.ON_RESUME -> { isAppOnForeground = true - if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) { + if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { SimplexService.showBackgroundServiceNoticeIfNeeded() } /** @@ -80,7 +80,7 @@ class SimplexApp: Application(), LifecycleEventObserver { * It can happen when app was started and a user enables battery optimization while app in background * */ if (chatModel.chatRunning.value != false && - chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete && + chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && appPrefs.notificationsMode.get() == NotificationsMode.SERVICE ) { SimplexService.start() @@ -191,7 +191,7 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidChatInitializedAndStarted() { // Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet - if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) { + if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { SimplexService.showBackgroundServiceNoticeIfNeeded() if (appPrefs.notificationsMode.get() == NotificationsMode.SERVICE) withBGApi { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt new file mode 100644 index 000000000..df2499926 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt @@ -0,0 +1,106 @@ +package chat.simplex.common.views.database + +import SectionItemView +import SectionTextFooter +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import chat.simplex.common.ui.theme.SimplexGreen +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +actual fun SavePassphraseSetting( + useKeychain: Boolean, + initialRandomDBPassphrase: Boolean, + storedKey: Boolean, + progressIndicator: Boolean, + minHeight: Dp, + onCheckedChange: (Boolean) -> Unit, +) { + SectionItemView(minHeight = minHeight) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (storedKey) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_vpn_key_off_filled), + stringResource(MR.strings.save_passphrase_in_keychain), + tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text( + stringResource(MR.strings.save_passphrase_in_keychain), + Modifier.padding(end = 24.dp), + color = Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch( + checked = useKeychain, + onCheckedChange = onCheckedChange, + enabled = !initialRandomDBPassphrase && !progressIndicator + ) + } + } +} + +@Composable +actual fun DatabaseEncryptionFooter( + useKeychain: MutableState, + chatDbEncrypted: Boolean?, + storedKey: MutableState, + initialRandomDBPassphrase: MutableState, +) { + if (chatDbEncrypted == false) { + SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted)) + } else if (useKeychain.value) { + if (storedKey.value) { + SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely)) + if (initialRandomDBPassphrase.value) { + SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase)) + } else { + SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) + } + } else { + SectionTextFooter(generalGetString(MR.strings.keychain_allows_to_receive_ntfs)) + } + } else { + SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time)) + SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) + } +} + +actual fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.encrypt_database_question), + text = generalGetString(MR.strings.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(), + confirmText = generalGetString(MR.strings.encrypt_database), + onConfirm = onConfirm, + destructive = true, + ) +} + +actual fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.change_database_passphrase_question), + text = generalGetString(MR.strings.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(), + confirmText = generalGetString(MR.strings.update_database), + onConfirm = onConfirm, + destructive = false, + ) +} + +actual fun removePassphraseAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.remove_passphrase_from_keychain), + text = generalGetString(MR.strings.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(), + confirmText = generalGetString(MR.strings.remove_passphrase), + onConfirm = onConfirm, + destructive = true, + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index cb386be7a..6b9770c09 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -32,8 +32,7 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.* data class SettingsViewState( val userPickerState: MutableStateFlow, @@ -64,7 +63,7 @@ fun MainScreen() { if ( !chatModel.controller.appPrefs.laNoticeShown.get() && showAdvertiseLAAlert - && chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete + && chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && chatModel.chats.isNotEmpty() && chatModel.activeCallInvitation.value == null ) { @@ -102,7 +101,10 @@ fun MainScreen() { } Box { - val onboarding = chatModel.onboardingStage.value + var onboarding by remember { mutableStateOf(chatModel.controller.appPrefs.onboardingStage.get()) } + LaunchedEffect(Unit) { + snapshotFlow { chatModel.controller.appPrefs.onboardingStage.state.value }.distinctUntilChanged().collect { onboarding = it } + } val userCreated = chatModel.userCreated.value var showInitializationView by remember { mutableStateOf(false) } when { @@ -112,7 +114,7 @@ fun MainScreen() { DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs) } } - onboarding == null || userCreated == null -> SplashView() + remember { chatModel.chatDbEncrypted }.value == null || userCreated == null -> SplashView() onboarding == OnboardingStage.OnboardingComplete && userCreated -> { Box { showAdvertiseLAAlert = true @@ -134,6 +136,7 @@ fun MainScreen() { } } onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {} + onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel) onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 629d4b869..0eb35fccd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -38,7 +38,6 @@ import kotlin.time.* @Stable object ChatModel { val controller: ChatController = ChatController - val onboardingStage = mutableStateOf(null) val setDeliveryReceipts = mutableStateOf(false) val currentUser = mutableStateOf(null) val users = mutableStateListOf() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index c39c00080..341f4e954 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -50,17 +50,16 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat if (user == null) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) - chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo chatModel.currentUser.value = null chatModel.users.clear() } else { val savedOnboardingStage = appPreferences.onboardingStage.get() - chatModel.onboardingStage.value = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { + appPreferences.onboardingStage.set(if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { OnboardingStage.Step3_CreateSimpleXAddress } else { savedOnboardingStage - } - if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete && !chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.get()) { + }) + if (appPreferences.onboardingStage.get() == OnboardingStage.OnboardingComplete && !chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.get()) { chatModel.setDeliveryReceipts.value = true } chatController.startChat(user) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index 6adadaffa..a03df5add 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -100,7 +100,7 @@ abstract class NtfManager { if (chatModel.chatRunning.value == null) { val step = 50L for (i in 0..(timeout / step)) { - if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) { + if (chatModel.chatRunning.value == true || chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.Step1_SimpleXInfo) { break } delay(step) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 9539a0790..13ce16d0a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatModel import chat.simplex.common.model.Profile +import chat.simplex.common.platform.appPlatform import chat.simplex.common.platform.navigationBarsWithImePadding import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -88,14 +89,20 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) { icon = painterResource(MR.images.ic_arrow_back_ios_new), textDecoration = TextDecoration.None, fontWeight = FontWeight.Medium - ) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo } + ) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) } } Spacer(Modifier.fillMaxWidth().weight(1f)) val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) val createModifier: Modifier val createColor: Color if (enabled) { - createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value, close) }.padding(8.dp) + createModifier = Modifier.clickable { + if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { + createProfileInProfiles(chatModel, displayName.value, fullName.value, close) + } else { + createProfileOnboarding(chatModel, displayName.value, fullName.value, close) + } + }.padding(8.dp) createColor = MaterialTheme.colors.primary } else { createModifier = Modifier.padding(8.dp) @@ -116,7 +123,7 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) { } } -fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) { +fun createProfileInProfiles(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) { withApi { val user = chatModel.controller.apiCreateActiveUser( Profile(displayName, fullName, null) @@ -125,16 +132,32 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, c if (chatModel.users.isEmpty()) { chatModel.controller.startChat(user) chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress) - chatModel.onboardingStage.value = OnboardingStage.Step3_CreateSimpleXAddress } else { val users = chatModel.controller.listUsers() chatModel.users.clear() chatModel.users.addAll(users) chatModel.controller.getUserChatData() + close() + } + } +} + +fun createProfileOnboarding(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) { + withApi { + chatModel.controller.apiCreateActiveUser( + Profile(displayName, fullName, null) + ) ?: return@withApi + val onboardingStage = chatModel.controller.appPrefs.onboardingStage + if (chatModel.users.isEmpty()) { + onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get()) { + OnboardingStage.Step2_5_SetupDatabasePassphrase + } else { + OnboardingStage.Step3_CreateSimpleXAddress + }) + } else { // the next two lines are only needed for failure case when because of the database error the app gets stuck on on-boarding screen, // this will get it unstuck. - chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) - chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete + onboardingStage.set(OnboardingStage.OnboardingComplete) close() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index 37080ebd8..e34f80a7e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -30,6 +30,7 @@ import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* +import chat.simplex.common.platform.appPlatform import chat.simplex.res.MR import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.datetime.Clock @@ -61,46 +62,8 @@ fun DatabaseEncryptionView(m: ChatModel) { initialRandomDBPassphrase, progressIndicator, onConfirmEncrypt = { - 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 -> { - operationEnded(m, progressIndicator) { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.wrong_passphrase_title), - generalGetString(MR.strings.enter_correct_current_passphrase) - ) - } - } - error != null -> { - operationEnded(m, progressIndicator) { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), - "failed to set storage encryption: ${error.responseType} ${error.details}" - ) - } - } - else -> { - prefs.initialRandomDBPassphrase.set(false) - initialRandomDBPassphrase.value = false - if (useKeychain.value) { - DatabaseUtils.ksDatabasePassword.set(newKey.value) - } - resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value) - operationEnded(m, progressIndicator) { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted)) - } - } - } - } catch (e: Exception) { - operationEnded(m, progressIndicator) { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), e.stackTraceToString()) - } - } + encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator) } } ) @@ -143,17 +106,11 @@ fun DatabaseEncryptionLayout( if (checked) { setUseKeychain(true, useKeychain, prefs) } else if (storedKey.value) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.remove_passphrase_from_keychain), - text = generalGetString(MR.strings.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(), - confirmText = generalGetString(MR.strings.remove_passphrase), - onConfirm = { - DatabaseUtils.ksDatabasePassword.remove() - setUseKeychain(false, useKeychain, prefs) - storedKey.value = false - }, - destructive = true, - ) + removePassphraseAlert { + DatabaseUtils.ksDatabasePassword.remove() + setUseKeychain(false, useKeychain, prefs) + storedKey.value = false + } } else { setUseKeychain(false, useKeychain, prefs) } @@ -217,37 +174,13 @@ fun DatabaseEncryptionLayout( } Column { - if (chatDbEncrypted == false) { - SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted)) - } else if (useKeychain.value) { - if (storedKey.value) { - SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely)) - if (initialRandomDBPassphrase.value) { - SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase)) - } else { - SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) - } - } else { - SectionTextFooter(generalGetString(MR.strings.keychain_allows_to_receive_ntfs)) - } - } else { - SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time)) - SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) - } + DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase) } SectionBottomSpacer() } } -fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.encrypt_database_question), - text = generalGetString(MR.strings.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(), - confirmText = generalGetString(MR.strings.encrypt_database), - onConfirm = onConfirm, - destructive = true, - ) -} +expect fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) fun encryptDatabaseAlert(onConfirm: () -> Unit) { AlertManager.shared.showAlertDialog( @@ -259,15 +192,7 @@ fun encryptDatabaseAlert(onConfirm: () -> Unit) { ) } -fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.change_database_passphrase_question), - text = generalGetString(MR.strings.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(), - confirmText = generalGetString(MR.strings.update_database), - onConfirm = onConfirm, - destructive = false, - ) -} +expect fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) fun changeDatabaseKeyAlert(onConfirm: () -> Unit) { AlertManager.shared.showAlertDialog( @@ -279,37 +204,25 @@ fun changeDatabaseKeyAlert(onConfirm: () -> Unit) { ) } +expect fun removePassphraseAlert(onConfirm: () -> Unit) + @Composable -fun SavePassphraseSetting( +expect fun SavePassphraseSetting( useKeychain: Boolean, initialRandomDBPassphrase: Boolean, storedKey: Boolean, progressIndicator: Boolean, minHeight: Dp = TextFieldDefaults.MinHeight, onCheckedChange: (Boolean) -> Unit, -) { - SectionItemView(minHeight = minHeight) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - if (storedKey) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_vpn_key_off_filled), - stringResource(MR.strings.save_passphrase_in_keychain), - tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - Text( - stringResource(MR.strings.save_passphrase_in_keychain), - Modifier.padding(end = 24.dp), - color = Color.Unspecified - ) - Spacer(Modifier.fillMaxWidth().weight(1f)) - DefaultSwitch( - checked = useKeychain, - onCheckedChange = onCheckedChange, - enabled = !initialRandomDBPassphrase && !progressIndicator - ) - } - } -} +) + +@Composable +expect fun DatabaseEncryptionFooter( + useKeychain: MutableState, + chatDbEncrypted: Boolean?, + storedKey: MutableState, + initialRandomDBPassphrase: MutableState, +) fun resetFormAfterEncryption( m: ChatModel, @@ -443,6 +356,62 @@ fun PassphraseField( } } +suspend fun encryptDatabase( + currentKey: MutableState, + newKey: MutableState, + confirmNewKey: MutableState, + initialRandomDBPassphrase: MutableState, + useKeychain: MutableState, + storedKey: MutableState, + progressIndicator: MutableState +): Boolean { + val m = ChatModel + val prefs = ChatController.appPrefs + progressIndicator.value = true + return 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 -> { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.wrong_passphrase_title), + generalGetString(MR.strings.enter_correct_current_passphrase) + ) + } + false + } + error != null -> { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), + "failed to set storage encryption: ${error.responseType} ${error.details}" + ) + } + false + } + else -> { + prefs.initialRandomDBPassphrase.set(false) + initialRandomDBPassphrase.value = false + if (useKeychain.value) { + DatabaseUtils.ksDatabasePassword.set(newKey.value) + } + resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value) + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted)) + } + true + } + } + } catch (e: Exception) { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), e.stackTraceToString()) + } + false + } +} + // based on https://generatepasswords.org/how-to-calculate-entropy/ private fun passphraseEntropy(s: String): Double { var hasDigits = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt index 710148168..bce8fdf4f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt @@ -12,6 +12,9 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.common.model.AppPreferences @@ -252,6 +255,11 @@ private fun mtrErrorDescription(err: MTRError): String = @Composable private fun DatabaseKeyField(text: MutableState, enabled: Boolean, onClick: (() -> Unit)? = null) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + delay(100L) + focusRequester.requestFocus() + } PassphraseField( text, generalGetString(MR.strings.enter_passphrase), @@ -259,7 +267,15 @@ private fun DatabaseKeyField(text: MutableState, enabled: Boolean, onCli keyboardActions = KeyboardActions(onDone = if (enabled) { { onClick?.invoke() } } else null - ) + ), + modifier = Modifier.focusRequester(focusRequester).onPreviewKeyEvent { + if (onClick != null && it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + onClick() + true + } else { + false + } + } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 05f38b74d..bd29cb7ae 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -73,6 +73,7 @@ fun DatabaseView( m.chatDbChanged.value, useKeychain.value, m.chatDbEncrypted.value, + m.controller.appPrefs.storeDBPassphrase.state.value, m.controller.appPrefs.initialRandomDBPassphrase, importArchiveLauncher, chatArchiveName, @@ -122,6 +123,7 @@ fun DatabaseLayout( chatDbChanged: Boolean, useKeyChain: Boolean, chatDbEncrypted: Boolean?, + passphraseSaved: Boolean, initialRandomDBPassphrase: SharedPreference, importArchiveLauncher: FileChooserLauncher, chatArchiveName: MutableState, @@ -182,7 +184,7 @@ fun DatabaseLayout( else painterResource(MR.images.ic_lock), stringResource(MR.strings.database_passphrase), click = showSettingsModal() { DatabaseEncryptionView(it) }, - iconColor = if (unencrypted) WarningOrange else MaterialTheme.colors.secondary, + iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary, disabled = operationsDisabled ) SettingsActionItem( @@ -657,6 +659,7 @@ fun PreviewDatabaseLayout() { chatDbChanged = false, useKeyChain = false, chatDbEncrypted = false, + passphraseSaved = false, initialRandomDBPassphrase = SharedPreference({ true }, {}), importArchiveLauncher = rememberFileChooserLauncher(true) {}, chatArchiveName = remember { mutableStateOf("dummy_archive") }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index d96b9d8a1..d8466e9d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -101,6 +101,10 @@ class AlertManager { Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.SpaceBetween ) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } TextButton(onClick = { onDismiss?.invoke() hideAlert() @@ -108,7 +112,7 @@ class AlertManager { TextButton(onClick = { onConfirm?.invoke() hideAlert() - }) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } + }, Modifier.focusRequester(focusRequester)) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } } }, shape = RoundedCornerShape(corner = CornerSize(25.dp)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt index 10641b6d8..e7da47f8f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt @@ -54,6 +54,12 @@ object DatabaseUtils { } else { dbKey = ksDatabasePassword.get() ?: "" } + } else if (appPlatform.isDesktop && !hasDatabase(dataDir.absolutePath)) { + // In case of database was deleted by hand + dbKey = randomDatabasePassword() + ksDatabasePassword.set(dbKey) + appPreferences.initialRandomDBPassphrase.set(true) + appPreferences.storeDBPassphrase.set(true) } return dbKey } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt index 5ab0e68c6..7db001a4b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp @@ -66,11 +67,13 @@ fun SimpleButton( fun SimpleButtonIconEnded( text: String, icon: Painter, + style: TextStyle = MaterialTheme.typography.caption, color: Color = MaterialTheme.colors.primary, + disabled: Boolean = false, click: () -> Unit ) { - SimpleButtonFrame(click) { - Text(text, style = MaterialTheme.typography.caption, color = color) + SimpleButtonFrame(click, disabled = disabled) { + Text(text, style = style, color = color) Icon( icon, text, tint = color, modifier = Modifier.padding(start = 8.dp) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt index 756e605dc..8b5c2a833 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt @@ -66,7 +66,6 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: ( val createdUser = m.controller.apiCreateActiveUser(profile, pastTimestamp = true) m.currentUser.value = createdUser m.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) - m.onboardingStage.value = OnboardingStage.OnboardingComplete if (createdUser != null) { m.controller.startChat(createdUser) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt index 84d1ae639..72cbc3a62 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt @@ -14,8 +14,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.UserContactLinkRec +import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -29,6 +28,10 @@ fun CreateSimpleXAddress(m: ChatModel) { val clipboard = LocalClipboardManager.current val uriHandler = LocalUriHandler.current + LaunchedEffect(Unit) { + prepareChatBeforeAddressCreation() + } + CreateSimpleXAddressLayout( userAddress.value, share = { address: String -> clipboard.shareText(address) }, @@ -63,7 +66,6 @@ fun CreateSimpleXAddress(m: ChatModel) { OnboardingStage.OnboardingComplete } m.controller.appPrefs.onboardingStage.set(next) - m.onboardingStage.value = next }, ) @@ -172,3 +174,19 @@ private fun ProgressIndicator() { ) } } + +private fun prepareChatBeforeAddressCreation() { + if (chatModel.users.isNotEmpty()) return + withApi { + val user = chatModel.controller.apiGetActiveUser() ?: return@withApi + chatModel.currentUser.value = user + if (chatModel.users.isEmpty()) { + chatModel.controller.startChat(user) + } else { + val users = chatModel.controller.listUsers() + chatModel.users.clear() + chatModel.users.addAll(users) + chatModel.controller.getUserChatData() + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt index 3b2e0b408..e3dfb2b73 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt @@ -13,8 +13,7 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.common.model.ChatController -import chat.simplex.common.model.User +import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* @@ -22,7 +21,7 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource @Composable -fun HowItWorks(user: User?, onboardingStage: MutableState? = null) { +fun HowItWorks(user: User?, onboardingStage: SharedPreference? = null) { Column(Modifier .fillMaxWidth() .padding(horizontal = DEFAULT_PADDING), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt index e3190f875..119ed8cd4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.launch enum class OnboardingStage { Step1_SimpleXInfo, Step2_CreateProfile, + Step2_5_SetupDatabasePassphrase, Step3_CreateSimpleXAddress, Step4_SetNotificationsMode, OnboardingComplete diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt index af640d5b4..aa413016d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt @@ -41,7 +41,7 @@ fun SetNotificationsMode(m: ChatModel) { } Spacer(Modifier.fillMaxHeight().weight(1f)) Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), contentAlignment = Alignment.Center) { - OnboardingActionButton(MR.strings.use_chat, OnboardingStage.OnboardingComplete, m.onboardingStage, false) { + OnboardingActionButton(MR.strings.use_chat, OnboardingStage.OnboardingComplete, false) { changeNotificationsMode(currentMode.value, m) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt new file mode 100644 index 000000000..9bc5ae846 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -0,0 +1,233 @@ +package chat.simplex.common.views.onboarding + +import SectionBottomSpacer +import SectionItemView +import SectionItemViewSpaceBetween +import SectionTextFooter +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.* +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.database.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import kotlinx.coroutines.delay + +@Composable +fun SetupDatabasePassphrase(m: ChatModel) { + val progressIndicator = remember { mutableStateOf(false) } + val prefs = m.controller.appPrefs + val saveInPreferences = remember { mutableStateOf(prefs.storeDBPassphrase.get()) } + val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) } + // Do not do rememberSaveable on current key to prevent saving it on disk in clear text + val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.ksDatabasePassword.get() ?: "" else "") } + val newKey = rememberSaveable { mutableStateOf("") } + val confirmNewKey = rememberSaveable { mutableStateOf("") } + fun nextStep() { + m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress) + } + SetupDatabasePassphraseLayout( + currentKey, + newKey, + confirmNewKey, + progressIndicator, + onConfirmEncrypt = { + withApi { + if (m.chatRunning.value == true) { + // Stop chat if it's started before doing anything + stopChatAsync(m) + } + prefs.storeDBPassphrase.set(false) + + val newKeyValue = newKey.value + val success = encryptDatabase(currentKey, newKey, confirmNewKey, mutableStateOf(true), saveInPreferences, mutableStateOf(true), progressIndicator) + if (success) { + startChat(newKeyValue) + nextStep() + } else { + // Rollback in case of it is finished with error in order to allow to repeat the process again + prefs.storeDBPassphrase.set(true) + } + } + }, + nextStep = ::nextStep, + ) + + if (progressIndicator.value) { + ProgressIndicator() + } + + DisposableEffect(Unit) { + onDispose { + if (m.chatRunning.value != true) { + withBGApi { + val user = chatController.apiGetActiveUser() + if (user != null) { + m.controller.startChat(user) + } + } + } + } + } +} + +@Composable +private fun SetupDatabasePassphraseLayout( + currentKey: MutableState, + newKey: MutableState, + confirmNewKey: MutableState, + progressIndicator: MutableState, + onConfirmEncrypt: () -> Unit, + nextStep: () -> Unit, +) { + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = DEFAULT_PADDING), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarTitle(stringResource(MR.strings.setup_database_passphrase)) + + Spacer(Modifier.weight(1f)) + + Column(Modifier.width(600.dp)) { + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + LaunchedEffect(Unit) { + delay(100L) + focusRequester.requestFocus() + } + PassphraseField( + newKey, + generalGetString(MR.strings.new_passphrase), + modifier = Modifier + .padding(horizontal = DEFAULT_PADDING) + .focusRequester(focusRequester) + .onPreviewKeyEvent { + if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + focusManager.moveFocus(FocusDirection.Down) + true + } else { + false + } + }, + showStrength = true, + isValid = ::validKey, + keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), + ) + val onClickUpdate = { + // Don't do things concurrently. Shouldn't be here concurrently, just in case + if (!progressIndicator.value) { + encryptDatabaseAlert(onConfirmEncrypt) + } + } + val disabled = currentKey.value == newKey.value || + newKey.value != confirmNewKey.value || + newKey.value.isEmpty() || + !validKey(currentKey.value) || + !validKey(newKey.value) || + progressIndicator.value + + PassphraseField( + confirmNewKey, + generalGetString(MR.strings.confirm_new_passphrase), + modifier = Modifier + .padding(horizontal = DEFAULT_PADDING) + .onPreviewKeyEvent { + if (!disabled && it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + onClickUpdate() + true + } else { + false + } + }, + isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value }, + keyboardActions = KeyboardActions(onDone = { + if (!disabled) onClickUpdate() + defaultKeyboardAction(ImeAction.Done) + }), + ) + + Box(Modifier.align(Alignment.CenterHorizontally).padding(vertical = DEFAULT_PADDING)) { + SetPassphraseButton(disabled, onClickUpdate) + } + + Column { + SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time)) + SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) + } + } + + Spacer(Modifier.weight(1f)) + SkipButton(progressIndicator.value, nextStep) + + SectionBottomSpacer() + } +} + +@Composable +private fun SetPassphraseButton(disabled: Boolean, onClick: () -> Unit) { + SimpleButtonIconEnded( + stringResource(MR.strings.set_database_passphrase), + painterResource(MR.images.ic_check), + style = MaterialTheme.typography.h2, + color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, + disabled = disabled, + click = onClick + ) +} + +@Composable +private fun SkipButton(disabled: Boolean, onClick: () -> Unit) { + SimpleButtonIconEnded(stringResource(MR.strings.use_random_passphrase), painterResource(MR.images.ic_chevron_right), color = + if (disabled) MaterialTheme.colors.secondary else WarningOrange, disabled = disabled, click = onClick) + Text( + stringResource(MR.strings.you_can_change_it_later), + Modifier + .fillMaxWidth() + .padding(horizontal = DEFAULT_PADDING * 3), + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun ProgressIndicator() { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 3.dp + ) + } +} + +private suspend fun startChat(key: String?) { + val m = ChatModel + initChatController(key) + m.chatDbChanged.value = false + m.chatRunning.value = true +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index 8248194eb..f20c4508b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter @@ -25,7 +24,7 @@ import dev.icerock.moko.resources.StringResource fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) { SimpleXInfoLayout( user = chatModel.currentUser.value, - onboardingStage = if (onboarding) chatModel.onboardingStage else null, + onboardingStage = if (onboarding) chatModel.controller.appPrefs.onboardingStage else null, showModal = { modalView -> { if (onboarding) ModalManager.fullscreen.showModal { modalView(chatModel) } else ModalManager.start.showModal { modalView(chatModel) } } }, ) } @@ -33,7 +32,7 @@ fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) { @Composable fun SimpleXInfoLayout( user: User?, - onboardingStage: MutableState?, + onboardingStage: SharedPreference?, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), ) { Column( @@ -100,11 +99,11 @@ private fun InfoRow(icon: Painter, titleId: StringResource, textId: StringResour } @Composable -fun OnboardingActionButton(user: User?, onboardingStage: MutableState, onclick: (() -> Unit)? = null) { +fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)? = null) { if (user == null) { - OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, true, onclick) + OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, onclick) } else { - OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, true, onclick) + OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick) } } @@ -112,7 +111,6 @@ fun OnboardingActionButton(user: User?, onboardingStage: MutableState, border: Boolean, onclick: (() -> Unit)? ) { @@ -129,7 +127,6 @@ fun OnboardingActionButton( SimpleButtonFrame(click = { onclick?.invoke() - onboardingStage.value = onboarding if (onboarding != null) { ChatController.appPrefs.onboardingStage.set(onboarding) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index c7d57353c..8969e48b2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -43,6 +43,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerSt profile = user.profile, stopped, chatModel.chatDbEncrypted.value == true, + remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value, remember { chatModel.controller.appPrefs.notificationsMode.state }, user.displayName, setPerformLA = setPerformLA, @@ -115,6 +116,7 @@ fun SettingsLayout( profile: LocalProfile, stopped: Boolean, encrypted: Boolean, + passphraseSaved: Boolean, notificationsMode: State, userDisplayName: String, setPerformLA: (Boolean) -> Unit, @@ -162,7 +164,7 @@ fun SettingsLayout( SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it, showSettingsModal) }, extraPadding = true) - DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) + DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) } SectionDividerSpaced() @@ -207,7 +209,7 @@ expect fun SettingsSectionApp( withAuth: (title: String, desc: String, block: () -> Unit) -> Unit ) -@Composable private fun DatabaseItem(encrypted: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) { +@Composable private fun DatabaseItem(encrypted: Boolean, saved: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) { SectionItemViewWithIcon(openDatabaseView) { Row( Modifier.fillMaxWidth(), @@ -217,7 +219,7 @@ expect fun SettingsSectionApp( Icon( painterResource(MR.images.ic_database), contentDescription = stringResource(MR.strings.database_passphrase_and_export), - tint = if (encrypted) MaterialTheme.colors.secondary else WarningOrange, + tint = if (encrypted && (appPlatform.isAndroid || !saved)) MaterialTheme.colors.secondary else WarningOrange, ) TextIconSpaced(true) Text(stringResource(MR.strings.database_passphrase_and_export)) @@ -473,6 +475,7 @@ fun PreviewSettingsLayout() { profile = LocalProfile.sampleData, stopped = false, encrypted = false, + passphraseSaved = false, notificationsMode = remember { mutableStateOf(NotificationsMode.OFF) }, userDisplayName = "Alice", setPerformLA = { _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index ea6d13a35..52449eaa9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -769,6 +769,11 @@ Good for battery. Background service checks messages every 10 minutes. You may miss calls or urgent messages.]]> Uses more battery! Background service always runs – notifications are shown as soon as messages are available.]]> + + Setup database passphrase + Random passphrase is stored in settings as plaintext.\nYou can change it later. + Use random passphrase + Paste received link @@ -984,9 +989,11 @@ Save passphrase in Keystore + Save passphrase in settings Database encrypted! Error encrypting database Remove passphrase from Keystore? + Remove passphrase from settings? Notifications will be delivered only until the app stops! Remove Encrypt @@ -995,18 +1002,23 @@ New passphrase… Confirm new passphrase… Update database passphrase + Set database passphrase Please enter correct current passphrase. Your chat database is not encrypted - set passphrase to protect it. Android Keystore is used to securely store passphrase - it allows notification service to work. + The passphrase is stored in settings as plaintext. Database is encrypted using a random passphrase, you can change it. Please note: you will NOT be able to recover or change passphrase if you lose it.]]> Android Keystore will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving notifications. + The passphrase will be stored in settings as plaintext after you change it or restart the app. You have to enter passphrase every time the app starts - it is not stored on the device. Encrypt database? Change database passphrase? Database will be encrypted. Database will be encrypted and the passphrase stored in the Keystore. + Database will be encrypted and the passphrase stored in settings. Database encryption passphrase will be updated and stored in the Keystore. + Database encryption passphrase will be updated and stored in settings. Database encryption passphrase will be updated. Please store passphrase securely, you will NOT be able to change it if you lose it. Please store passphrase securely, you will NOT be able to access chat if you lose it. diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt new file mode 100644 index 000000000..af2b269b5 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt @@ -0,0 +1,106 @@ +package chat.simplex.common.views.database + +import SectionItemView +import SectionTextFooter +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import chat.simplex.common.ui.theme.WarningOrange +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +actual fun SavePassphraseSetting( + useKeychain: Boolean, + initialRandomDBPassphrase: Boolean, + storedKey: Boolean, + progressIndicator: Boolean, + minHeight: Dp, + onCheckedChange: (Boolean) -> Unit, +) { + SectionItemView(minHeight = minHeight) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (storedKey) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_vpn_key_off_filled), + stringResource(MR.strings.save_passphrase_in_settings), + tint = if (storedKey) WarningOrange else MaterialTheme.colors.secondary + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text( + stringResource(MR.strings.save_passphrase_in_settings), + Modifier.padding(end = 24.dp), + color = Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch( + checked = useKeychain, + onCheckedChange = onCheckedChange, + enabled = !initialRandomDBPassphrase && !progressIndicator + ) + } + } +} + +@Composable +actual fun DatabaseEncryptionFooter( + useKeychain: MutableState, + chatDbEncrypted: Boolean?, + storedKey: MutableState, + initialRandomDBPassphrase: MutableState, +) { + if (chatDbEncrypted == false) { + SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted)) + } else if (useKeychain.value) { + if (storedKey.value) { + SectionTextFooter(generalGetString(MR.strings.settings_is_storing_in_clear_text)) + if (initialRandomDBPassphrase.value) { + SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase)) + } else { + SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) + } + } else { + SectionTextFooter(generalGetString(MR.strings.passphrase_will_be_saved_in_settings)) + } + } else { + SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time)) + SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) + } +} + +actual fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.encrypt_database_question), + text = generalGetString(MR.strings.database_will_be_encrypted_and_passphrase_stored_in_settings) + "\n" + storeSecurelySaved(), + confirmText = generalGetString(MR.strings.encrypt_database), + onConfirm = onConfirm, + destructive = true, + ) +} + +actual fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.change_database_passphrase_question), + text = generalGetString(MR.strings.database_encryption_will_be_updated_in_settings) + "\n" + storeSecurelySaved(), + confirmText = generalGetString(MR.strings.update_database), + onConfirm = onConfirm, + destructive = false, + ) +} + +actual fun removePassphraseAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.remove_passphrase_from_settings), + text = storeSecurelyDanger(), + confirmText = generalGetString(MR.strings.remove_passphrase), + onConfirm = onConfirm, + destructive = true, + ) +}