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 c94194a358..f70032788b 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 0000000000..df2499926f --- /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 cb386be7a3..6b9770c09e 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 629d4b8699..0eb35fccd5 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 c39c000807..341f4e9548 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 6adadaffaa..a03df5addb 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 9539a07903..13ce16d0a7 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 37080ebd87..e34f80a7ef 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 7101481681..bce8fdf4f1 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 05f38b74de..bd29cb7ae3 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 d96b9d8a1e..d8466e9d96 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 10641b6d81..e7da47f8f0 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 5ab0e68c6b..7db001a4bd 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 756e605dc3..8b5c2a8336 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 84d1ae639d..72cbc3a628 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 3b2e0b408e..e3dfb2b736 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 e3190f8756..119ed8cd48 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 af640d5b48..aa413016d1 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 0000000000..9bc5ae846e --- /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 8248194ebc..f20c4508b4 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 c7d57353c1..8969e48b2c 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 ea6d13a355..52449eaa9d 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 0000000000..af2b269b58 --- /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, + ) +}