From cbbd2afccdb4eda7bfce43aa5fac9f41cd4cf4df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Thu, 2 Nov 2023 14:02:17 +0100 Subject: [PATCH 01/14] feat[applock]: forgot passlock [WPB-5094] --- .../com/wire/android/ui/common/WireDialog.kt | 47 ++-- .../forgot/ForgotLockCodeResetDeviceDialog.kt | 134 +++++++++++ .../appLock/forgot/ForgotLockCodeScreen.kt | 209 ++++++++++++++++++ .../appLock/forgot/ForgotLockCodeViewState.kt | 38 ++++ .../forgot/ForgotLockScreenViewModel.kt | 191 ++++++++++++++++ .../appLock/{ => set}/SetLockCodeScreen.kt | 2 +- .../appLock/{ => set}/SetLockCodeViewState.kt | 2 +- .../{ => set}/SetLockScreenViewModel.kt | 2 +- .../AppUnlockWithBiometricsScreen.kt | 2 +- .../AppUnlockWithBiometricsViewModel.kt | 3 +- .../{ => unlock}/EnterLockCodeScreen.kt | 13 +- .../{ => unlock}/EnterLockCodeViewState.kt | 2 +- .../{ => unlock}/EnterLockScreenViewModel.kt | 3 +- app/src/main/res/values/strings.xml | 9 +- .../appLock/SetLockScreenViewModelTest.kt | 1 + 15 files changed, 634 insertions(+), 24 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeScreen.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeViewState.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt rename app/src/main/kotlin/com/wire/android/ui/home/appLock/{ => set}/SetLockCodeScreen.kt (99%) rename app/src/main/kotlin/com/wire/android/ui/home/appLock/{ => set}/SetLockCodeViewState.kt (96%) rename app/src/main/kotlin/com/wire/android/ui/home/appLock/{ => set}/SetLockScreenViewModel.kt (98%) rename app/src/main/kotlin/com/wire/android/ui/home/appLock/{ => unlock}/AppUnlockWithBiometricsScreen.kt (98%) rename app/src/main/kotlin/com/wire/android/ui/home/appLock/{ => unlock}/AppUnlockWithBiometricsViewModel.kt (90%) rename app/src/main/kotlin/com/wire/android/ui/home/appLock/{ => unlock}/EnterLockCodeScreen.kt (93%) rename app/src/main/kotlin/com/wire/android/ui/home/appLock/{ => unlock}/EnterLockCodeViewState.kt (95%) rename app/src/main/kotlin/com/wire/android/ui/home/appLock/{ => unlock}/EnterLockScreenViewModel.kt (96%) diff --git a/app/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt b/app/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt index 8e2db34c383..c1f1407139e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt @@ -57,9 +57,11 @@ import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.button.WireTertiaryButton +import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.common.textfield.WirePasswordTextField import com.wire.android.ui.markdown.MarkdownConstants import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography @@ -79,7 +81,7 @@ fun WireDialog( title: String, text: String, onDismiss: () -> Unit, - optionButton1Properties: WireDialogButtonProperties, + optionButton1Properties: WireDialogButtonProperties? = null, optionButton2Properties: WireDialogButtonProperties? = null, dismissButtonProperties: WireDialogButtonProperties? = null, buttonsHorizontalAlignment: Boolean = true, @@ -88,6 +90,7 @@ fun WireDialog( contentPadding: PaddingValues = PaddingValues(MaterialTheme.wireDimensions.dialogContentPadding), properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false), centerContent: Boolean = false, + titleLoading: Boolean = false, content: @Composable (() -> Unit)? = null ) { WireDialog( @@ -101,6 +104,7 @@ fun WireDialog( shape = shape, contentPadding = contentPadding, title = title, + titleLoading = titleLoading, text = buildAnnotatedString { val style = SpanStyle( color = colorsScheme().onBackground, @@ -122,7 +126,7 @@ fun WireDialog( title: String, text: AnnotatedString? = null, onDismiss: () -> Unit, - optionButton1Properties: WireDialogButtonProperties, + optionButton1Properties: WireDialogButtonProperties? = null, optionButton2Properties: WireDialogButtonProperties? = null, dismissButtonProperties: WireDialogButtonProperties? = null, buttonsHorizontalAlignment: Boolean = true, @@ -131,6 +135,7 @@ fun WireDialog( contentPadding: PaddingValues = PaddingValues(MaterialTheme.wireDimensions.dialogContentPadding), properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false), centerContent: Boolean = false, + titleLoading: Boolean = false, content: @Composable (() -> Unit)? = null ) { Dialog( @@ -146,6 +151,7 @@ fun WireDialog( shape = shape, contentPadding = contentPadding, title = title, + titleLoading = titleLoading, text = text, centerContent = centerContent, content = content @@ -156,8 +162,9 @@ fun WireDialog( @Composable private fun WireDialogContent( title: String, + titleLoading: Boolean = false, text: AnnotatedString? = null, - optionButton1Properties: WireDialogButtonProperties, + optionButton1Properties: WireDialogButtonProperties? = null, optionButton2Properties: WireDialogButtonProperties? = null, dismissButtonProperties: WireDialogButtonProperties? = null, buttonsHorizontalAlignment: Boolean = true, @@ -181,16 +188,24 @@ private fun WireDialogContent( .padding(contentPadding), horizontalAlignment = if (centerContent) Alignment.CenterHorizontally else Alignment.Start ) { - Text( - text = title, - style = MaterialTheme.wireTypography.title02, - modifier = Modifier.padding(bottom = MaterialTheme.wireDimensions.dialogTextsSpacing) - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + style = MaterialTheme.wireTypography.title02, + modifier = Modifier.weight(1f) + ) + if (titleLoading) { + WireCircularProgressIndicator(progressColor = MaterialTheme.wireColorScheme.onBackground) + } + } text?.let { ClickableText( text = text, style = MaterialTheme.wireTypography.body01, - modifier = Modifier.padding(bottom = MaterialTheme.wireDimensions.dialogTextsSpacing), + modifier = Modifier.padding( + top = MaterialTheme.wireDimensions.dialogTextsSpacing, + bottom = MaterialTheme.wireDimensions.dialogTextsSpacing, + ), onClick = { offset -> text.getStringAnnotations( tag = MarkdownConstants.TAG_URL, @@ -206,29 +221,31 @@ private fun WireDialogContent( } } + val containsAnyButton = dismissButtonProperties != null || optionButton1Properties != null || optionButton2Properties != null + val dialogButtonsSpacing = if (containsAnyButton) dimensions().dialogButtonsSpacing else dimensions().spacing0x if (buttonsHorizontalAlignment) { - Row(Modifier.padding(top = MaterialTheme.wireDimensions.dialogButtonsSpacing)) { + Row(Modifier.padding(top = dialogButtonsSpacing)) { dismissButtonProperties.getButton(Modifier.weight(1f)) if (dismissButtonProperties != null) { - Spacer(Modifier.width(MaterialTheme.wireDimensions.dialogButtonsSpacing)) + Spacer(Modifier.width(dialogButtonsSpacing)) } optionButton1Properties.getButton(Modifier.weight(1f)) if (optionButton2Properties != null) { - Spacer(Modifier.width(MaterialTheme.wireDimensions.dialogButtonsSpacing)) + Spacer(Modifier.width(dialogButtonsSpacing)) } optionButton2Properties.getButton(Modifier.weight(1f)) } } else { - Column(Modifier.padding(top = MaterialTheme.wireDimensions.dialogButtonsSpacing)) { + Column(Modifier.padding(top = dialogButtonsSpacing)) { optionButton1Properties.getButton() if (optionButton2Properties != null) { - Spacer(Modifier.height(MaterialTheme.wireDimensions.dialogButtonsSpacing)) + Spacer(Modifier.height(dialogButtonsSpacing)) } optionButton2Properties.getButton() if (dismissButtonProperties != null) { - Spacer(Modifier.height(MaterialTheme.wireDimensions.dialogButtonsSpacing)) + Spacer(Modifier.height(dialogButtonsSpacing)) } dismissButtonProperties.getButton() } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt new file mode 100644 index 00000000000..9fd40f455a1 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt @@ -0,0 +1,134 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.appLock.forgot + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.window.DialogProperties +import com.wire.android.R +import com.wire.android.ui.common.WireDialog +import com.wire.android.ui.common.WireDialogButtonProperties +import com.wire.android.ui.common.WireDialogButtonType +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.textfield.WirePasswordTextField +import com.wire.android.ui.common.textfield.WireTextFieldState +import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.ui.PreviewMultipleThemes + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ForgotLockCodeResetDeviceDialog( + username: String, + isPasswordValid: Boolean, + isResetDeviceEnabled: Boolean, + onPasswordChanged: (TextFieldValue) -> Unit, + onResetDeviceClicked: () -> Unit, + onDialogDismissed: () -> Unit +) { + var backupPassword by remember { mutableStateOf(TextFieldValue("")) } + var keyboardController: SoftwareKeyboardController? = null + val onDialogDismissHideKeyboard: () -> Unit = { + keyboardController?.hide() + onDialogDismissed() + } + WireDialog( + title = stringResource(R.string.settings_forgot_lock_screen_reset_device), + text = stringResource(R.string.settings_forgot_lock_screen_reset_device_description, username), + onDismiss = onDialogDismissHideKeyboard, + buttonsHorizontalAlignment = false, + dismissButtonProperties = WireDialogButtonProperties( + onClick = onDialogDismissHideKeyboard, + text = stringResource(id = R.string.label_cancel), + state = WireButtonState.Default + ), + optionButton1Properties = WireDialogButtonProperties( + onClick = { + keyboardController?.hide() + onResetDeviceClicked() + }, + text = stringResource(id = R.string.settings_forgot_lock_screen_reset_device), + type = WireDialogButtonType.Primary, + state = if (!isResetDeviceEnabled) WireButtonState.Disabled else WireButtonState.Error + ) + ) { + // keyboard controller from outside the Dialog doesn't work inside its content so we have to pass the state + // to the dialog's content and use keyboard controller from there + keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + WirePasswordTextField( + state = when { + !isPasswordValid -> WireTextFieldState.Error(stringResource(id = R.string.remove_device_invalid_password)) + else -> WireTextFieldState.Default + }, + value = backupPassword, + onValueChange = { + backupPassword = it + onPasswordChanged(it) + }, + autofill = false, + keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + modifier = Modifier + .focusRequester(focusRequester) + .padding(bottom = dimensions().spacing16x) + ) + LaunchedEffect(Unit) { // executed only once when showing the dialog + focusRequester.requestFocus() + } + } +} + +@Composable +fun ForgotLockCodeResettingDeviceDialog() { + WireDialog( + title = stringResource(R.string.settings_forgot_lock_screen_please_wait_label), + titleLoading = true, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), + onDismiss = {}, + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewForgotLockCodeResetDeviceDialog() { + WireTheme(isPreview = true) { + ForgotLockCodeResetDeviceDialog("Username", true, true, {}, {}, {}) + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewForgotLockCodeResettingDeviceDialog() { + WireTheme(isPreview = true) { + ForgotLockCodeResettingDeviceDialog() + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeScreen.kt new file mode 100644 index 00000000000..95bc5effbc6 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeScreen.kt @@ -0,0 +1,209 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.appLock.forgot + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.style.TextAlign +import androidx.hilt.navigation.compose.hiltViewModel +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.wire.android.R +import com.wire.android.navigation.BackStackMode +import com.wire.android.navigation.NavigationCommand +import com.wire.android.navigation.Navigator +import com.wire.android.ui.common.WireDialog +import com.wire.android.ui.common.WireDialogButtonProperties +import com.wire.android.ui.common.WireDialogButtonType +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WirePrimaryButton +import com.wire.android.ui.common.rememberBottomBarElevationState +import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.destinations.WelcomeScreenDestination +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.dialogErrorStrings +import com.wire.android.util.ui.PreviewMultipleThemes + +@RootNavGraph +@Destination +@Composable +fun ForgotLockCodeScreen( + navigator: Navigator, + viewModel: ForgotLockScreenViewModel = hiltViewModel(), +) { + with(viewModel.state) { + LaunchedEffect(completed) { + if (completed) navigator.navigate(NavigationCommand(WelcomeScreenDestination, BackStackMode.CLEAR_WHOLE)) + } + ForgotLockCodeScreenContent( + scrollState = rememberScrollState(), + onResetDevice = viewModel::onResetDevice, + ) + if (dialogState is ForgotLockCodeDialogState.Visible) { + if (dialogState.loading) ForgotLockCodeResettingDeviceDialog() + else ForgotLockCodeResetDeviceDialog( + username = dialogState.username, + isPasswordValid = dialogState.passwordValid, + isResetDeviceEnabled = dialogState.resetDeviceEnabled, + onPasswordChanged = viewModel::onPasswordChanged, + onResetDeviceClicked = viewModel::onResetDeviceConfirmed, + onDialogDismissed = viewModel::onDialogDismissed, + ) + } + if (error != null) { + val (title, message) = error.dialogErrorStrings(LocalContext.current.resources) + WireDialog( + title = title, + text = message, + onDismiss = viewModel::onErrorDismissed, + optionButton1Properties = WireDialogButtonProperties( + onClick = viewModel::onErrorDismissed, + text = stringResource(id = R.string.label_ok), + type = WireDialogButtonType.Primary, + ), + ) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ForgotLockCodeScreenContent( + scrollState: ScrollState, + onResetDevice: () -> Unit, +) { + WireScaffold { internalPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(internalPadding) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(weight = 1f, fill = true) + .verticalScroll(scrollState) + .padding(MaterialTheme.wireDimensions.spacing16x) + .semantics { testTagsAsResourceId = true } + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_wire_logo), + tint = MaterialTheme.colorScheme.onBackground, + contentDescription = stringResource(id = R.string.content_description_welcome_wire_logo), + modifier = Modifier.padding(top = MaterialTheme.wireDimensions.spacing56x) + ) + Text( + text = stringResource(id = R.string.settings_forgot_lock_screen_title), + style = MaterialTheme.wireTypography.title02, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding( + top = MaterialTheme.wireDimensions.spacing32x, + bottom = MaterialTheme.wireDimensions.spacing16x + ) + ) + Text( + text = stringResource(id = R.string.settings_forgot_lock_screen_description), + style = MaterialTheme.wireTypography.body01, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding( + top = MaterialTheme.wireDimensions.spacing8x, + bottom = MaterialTheme.wireDimensions.spacing8x + ) + ) + Text( + text = stringResource(id = R.string.settings_forgot_lock_screen_warning), + style = MaterialTheme.wireTypography.body01, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding( + top = MaterialTheme.wireDimensions.spacing8x, + bottom = MaterialTheme.wireDimensions.spacing8x + ) + ) + Spacer(modifier = Modifier.weight(1f)) + } + + Surface( + shadowElevation = scrollState.rememberBottomBarElevationState().value, + color = MaterialTheme.wireColorScheme.background, + modifier = Modifier.semantics { testTagsAsResourceId = true } + ) { + Box(modifier = Modifier.padding(MaterialTheme.wireDimensions.spacing16x)) { + ContinueButton(enabled = true, onContinue = onResetDevice) + } + } + } + } +} + +@Composable +private fun ContinueButton( + modifier: Modifier = Modifier.fillMaxWidth(), + enabled: Boolean, + onContinue: () -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + Column(modifier = modifier) { + WirePrimaryButton( + text = stringResource(R.string.settings_forgot_lock_screen_reset_device), + onClick = onContinue, + state = if (enabled) WireButtonState.Default else WireButtonState.Disabled, + interactionSource = interactionSource, + modifier = Modifier + .fillMaxWidth() + .testTag("reset_device_button") + ) + } +} + +@Composable +@PreviewMultipleThemes +fun PreviewForgotLockCodeScreen() { + WireTheme(isPreview = true) { + ForgotLockCodeScreenContent(rememberScrollState(), {}) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeViewState.kt new file mode 100644 index 00000000000..e0506aaa30a --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeViewState.kt @@ -0,0 +1,38 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.appLock.forgot + +import androidx.compose.ui.text.input.TextFieldValue +import com.wire.kalium.logic.CoreFailure + +data class ForgotLockCodeViewState( + val completed: Boolean = false, + val error: CoreFailure? = null, + val dialogState: ForgotLockCodeDialogState = ForgotLockCodeDialogState.Hidden, +) + +sealed class ForgotLockCodeDialogState { + data object Hidden : ForgotLockCodeDialogState() + data class Visible( + val username: String, + val password: TextFieldValue = TextFieldValue(""), + val passwordValid: Boolean = true, + val resetDeviceEnabled: Boolean = true, + val loading: Boolean = false, + ) : ForgotLockCodeDialogState() +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt new file mode 100644 index 00000000000..ed9ae5eb358 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt @@ -0,0 +1,191 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.appLock.forgot + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wire.android.appLogger +import com.wire.android.datastore.GlobalDataStore +import com.wire.android.datastore.UserDataStoreProvider +import com.wire.android.di.KaliumCoreLogic +import com.wire.android.feature.AccountSwitchUseCase +import com.wire.android.feature.SwitchAccountParam +import com.wire.android.notification.NotificationChannelsManager +import com.wire.android.notification.WireNotificationManager +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.data.client.DeleteClientParam +import com.wire.kalium.logic.data.logout.LogoutReason +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase +import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase +import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase +import com.wire.kalium.logic.feature.client.DeleteClientResult +import com.wire.kalium.logic.feature.client.DeleteClientUseCase +import com.wire.kalium.logic.feature.client.ObserveCurrentClientIdUseCase +import com.wire.kalium.logic.feature.session.GetAllSessionsResult +import com.wire.kalium.logic.feature.session.GetSessionsUseCase +import com.wire.kalium.logic.feature.user.GetSelfUserUseCase +import com.wire.kalium.logic.feature.user.IsPasswordRequiredUseCase +import com.wire.kalium.logic.functional.Either +import com.wire.kalium.logic.functional.flatMap +import com.wire.kalium.logic.functional.fold +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ForgotLockScreenViewModel @Inject constructor( + @KaliumCoreLogic private val coreLogic: CoreLogic, + private val globalDataStore: GlobalDataStore, + private val userDataStoreProvider: UserDataStoreProvider, + private val notificationChannelsManager: NotificationChannelsManager, + private val notificationManager: WireNotificationManager, + private val getSelf: GetSelfUserUseCase, + private val isPasswordRequired: IsPasswordRequiredUseCase, + private val validatePassword: ValidatePasswordUseCase, + private val observeCurrentClientId: ObserveCurrentClientIdUseCase, + private val deleteClient: DeleteClientUseCase, + private val getSessions: GetSessionsUseCase, + private val observeEstablishedCalls: ObserveEstablishedCallsUseCase, + private val endCall: EndCallUseCase, + private val accountSwitch: AccountSwitchUseCase, +) : ViewModel() { + + var state: ForgotLockCodeViewState by mutableStateOf(ForgotLockCodeViewState()) + private set + + private fun updateIfDialogStateVisible(update: (ForgotLockCodeDialogState.Visible) -> ForgotLockCodeDialogState) { + (state.dialogState as? ForgotLockCodeDialogState.Visible)?.let { dialogStateVisible -> + state = state.copy(dialogState = update(dialogStateVisible)) + } + } + + fun onPasswordChanged(password: TextFieldValue) { + updateIfDialogStateVisible { it.copy(password = password, resetDeviceEnabled = true) } + } + + fun onResetDevice() { + viewModelScope.launch { + state = state.copy(dialogState = ForgotLockCodeDialogState.Visible(username = getSelf().firstOrNull()?.name ?: "",)) + } + } + + fun onDialogDismissed() { + state = state.copy(dialogState = ForgotLockCodeDialogState.Hidden) + } + + fun onErrorDismissed() { + state = state.copy(error = null) + } + + fun onResetDeviceConfirmed() { + (state.dialogState as? ForgotLockCodeDialogState.Visible)?.let { dialogStateVisible -> + updateIfDialogStateVisible { it.copy(resetDeviceEnabled = false) } + viewModelScope.launch { + validatePasswordIfNeeded(dialogStateVisible.password.text) + .flatMapIfSuccess { + updateIfDialogStateVisible { it.copy(loading = true) } + deleteCurrentClient(dialogStateVisible.password.text) + .flatMapIfSuccess { hardLogoutAllAccounts() } + } + .fold({ error -> + state = state.copy(error = error) + updateIfDialogStateVisible { it.copy(loading = false, resetDeviceEnabled = true) } + }, { result -> + when (result) { + Result.InvalidPassword -> updateIfDialogStateVisible { it.copy(passwordValid = false, loading = false) } + Result.Success -> state = state.copy(completed = true, dialogState = ForgotLockCodeDialogState.Hidden) + } + } + ) + } + } + } + + private suspend fun validatePasswordIfNeeded(password: String): Either = + when (val isPasswordRequiredResult = isPasswordRequired()) { + is IsPasswordRequiredUseCase.Result.Failure -> { + appLogger.e("$TAG Failed to check if password is required when resetting passcode") + Either.Left(isPasswordRequiredResult.cause) + } + is IsPasswordRequiredUseCase.Result.Success -> { + if (!isPasswordRequiredResult.value || validatePassword(password).isValid) Either.Right(Result.Success) + else Either.Right(Result.InvalidPassword) + } + } + + private suspend fun deleteCurrentClient(password: String): Either = + observeCurrentClientId() + .filterNotNull() + .first() + .let { clientId -> + when (val deleteClientResult = deleteClient(DeleteClientParam(password, clientId))) { + is DeleteClientResult.Failure.Generic -> { + appLogger.e("$TAG Failed to delete current client when resetting passcode") + Either.Left(deleteClientResult.genericFailure) + } + DeleteClientResult.Success -> Either.Right(Result.Success) + else -> Either.Right(Result.InvalidPassword) + } + } + + private suspend fun hardLogoutAllAccounts(): Either = + when (val getAllSessionsResult = getSessions()) { + is GetAllSessionsResult.Failure.Generic -> { + appLogger.e("$TAG Failed to get all sessions when resetting passcode") + Either.Left(getAllSessionsResult.genericFailure) + } + + is GetAllSessionsResult.Failure.NoSessionFound -> Either.Right(Result.Success) + is GetAllSessionsResult.Success -> { + observeEstablishedCalls().firstOrNull()?.let { establishedCalls -> + establishedCalls.forEach { endCall(it.conversationId) } + } + getAllSessionsResult.sessions.forEach { session -> hardLogoutAccount(session.userId) } + globalDataStore.clearAppLockPasscode() + // it won't switch to any other account because there is none anymore, just required to clear-up after logout + accountSwitch(SwitchAccountParam.TryToSwitchToNextAccount) + Either.Right(Result.Success) + } + } + + // TODO: we should have a dedicated manager to perform these required actions in AR after every LogoutUseCase call + private suspend fun hardLogoutAccount(userId: UserId) { + notificationManager.stopObservingOnLogout(userId) + notificationChannelsManager.deleteChannelGroup(userId) + coreLogic.getSessionScope(userId).logout(LogoutReason.SELF_HARD_LOGOUT) + userDataStoreProvider.getOrCreate(userId).clear() + } + + private enum class Result { InvalidPassword, Success; } + + private inline fun Either.flatMapIfSuccess(block: () -> Either): Either = + this.flatMap { if (it == Result.Success) block() else Either.Right(it) } + + companion object { + const val TAG = "ForgotLockResetPasscode" + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/SetLockCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeScreen.kt similarity index 99% rename from app/src/main/kotlin/com/wire/android/ui/home/appLock/SetLockCodeScreen.kt rename to app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeScreen.kt index bee7339e887..1565711369b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/SetLockCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeScreen.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.home.appLock +package com.wire.android.ui.home.appLock.set import androidx.compose.foundation.ScrollState import androidx.compose.foundation.interaction.MutableInteractionSource diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/SetLockCodeViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeViewState.kt similarity index 96% rename from app/src/main/kotlin/com/wire/android/ui/home/appLock/SetLockCodeViewState.kt rename to app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeViewState.kt index 267d748c155..3f2e2a24776 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/SetLockCodeViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeViewState.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.home.appLock +package com.wire.android.ui.home.appLock.set import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.feature.AppLockConfig diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/SetLockScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockScreenViewModel.kt similarity index 98% rename from app/src/main/kotlin/com/wire/android/ui/home/appLock/SetLockScreenViewModel.kt rename to app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockScreenViewModel.kt index 9612aefd045..f07dddfcfd2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/SetLockScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockScreenViewModel.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.home.appLock +package com.wire.android.ui.home.appLock.set import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/AppUnlockWithBiometricsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/AppUnlockWithBiometricsScreen.kt similarity index 98% rename from app/src/main/kotlin/com/wire/android/ui/home/appLock/AppUnlockWithBiometricsScreen.kt rename to app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/AppUnlockWithBiometricsScreen.kt index 6e598841d99..2faf5fa41a6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/AppUnlockWithBiometricsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/AppUnlockWithBiometricsScreen.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.home.appLock +package com.wire.android.ui.home.appLock.unlock import androidx.activity.compose.BackHandler import androidx.appcompat.app.AppCompatActivity diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/AppUnlockWithBiometricsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/AppUnlockWithBiometricsViewModel.kt similarity index 90% rename from app/src/main/kotlin/com/wire/android/ui/home/appLock/AppUnlockWithBiometricsViewModel.kt rename to app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/AppUnlockWithBiometricsViewModel.kt index 240051dc381..c255a8fdd69 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/AppUnlockWithBiometricsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/AppUnlockWithBiometricsViewModel.kt @@ -15,9 +15,10 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.home.appLock +package com.wire.android.ui.home.appLock.unlock import androidx.lifecycle.ViewModel +import com.wire.android.ui.home.appLock.LockCodeTimeManager import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/EnterLockCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt similarity index 93% rename from app/src/main/kotlin/com/wire/android/ui/home/appLock/EnterLockCodeScreen.kt rename to app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt index 0307c960c4d..735e36a7ad1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/EnterLockCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.home.appLock +package com.wire.android.ui.home.appLock.unlock import androidx.activity.compose.BackHandler import androidx.compose.foundation.ScrollState @@ -50,14 +50,17 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph import com.wire.android.R +import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.rememberNavigator import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton +import com.wire.android.ui.common.button.WireTertiaryButton import com.wire.android.ui.common.rememberBottomBarElevationState import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.textfield.WirePasswordTextField import com.wire.android.ui.common.textfield.WireTextFieldState +import com.wire.android.ui.destinations.ForgotLockCodeScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions @@ -145,12 +148,20 @@ fun EnterLockCodeScreenContent( EnterLockCodeError.InvalidValue -> WireTextFieldState.Error( errorText = stringResource(R.string.settings_enter_lock_screen_wrong_passcode_label) ) + EnterLockCodeError.None -> WireTextFieldState.Default }, autofill = false, placeholderText = stringResource(R.string.settings_set_lock_screen_passcode_label), labelText = stringResource(R.string.settings_set_lock_screen_passcode_label).uppercase(Locale.getDefault()) ) + + WireTertiaryButton( + text = stringResource(id = R.string.settings_enter_lock_screen_forgot_passcode_label), + onClick = { navigator.navigate(NavigationCommand(ForgotLockCodeScreenDestination)) }, + modifier = Modifier.padding(MaterialTheme.wireDimensions.spacing16x) + ) + Spacer(modifier = Modifier.weight(1f)) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/EnterLockCodeViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeViewState.kt similarity index 95% rename from app/src/main/kotlin/com/wire/android/ui/home/appLock/EnterLockCodeViewState.kt rename to app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeViewState.kt index d3b41ae5cc2..ee804671ed0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/EnterLockCodeViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeViewState.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.home.appLock +package com.wire.android.ui.home.appLock.unlock import androidx.compose.ui.text.input.TextFieldValue diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/EnterLockScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockScreenViewModel.kt similarity index 96% rename from app/src/main/kotlin/com/wire/android/ui/home/appLock/EnterLockScreenViewModel.kt rename to app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockScreenViewModel.kt index 5cd7b4317f2..0dc3ff5571d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/EnterLockScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockScreenViewModel.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.home.appLock +package com.wire.android.ui.home.appLock.unlock import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -24,6 +24,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.datastore.GlobalDataStore +import com.wire.android.ui.home.appLock.LockCodeTimeManager import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.sha256 import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7fd46917320..80b6729509a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -933,7 +933,8 @@ Set a passcode Enter passcode to unlock Wire Unlock - Wrong passcode + Check your passcode and try again + Forgot your passcode? At least 8 characters A lowercase character An uppercase character @@ -942,6 +943,12 @@ Turn app lock off? You will no longer need to unlock Wire with your passcode or biometric authentication. Turn Off + Forgot your app lock passcode? + The data stored on this device can only be accessed with your app lock passcode. If you have forgotten your passcode, you can reset this device. + By resetting your device, all local data and messages for this account will be permanently deleted. + Remove Device + Enter your password for the account %s to verify you want to delete all data on this device. After deleting this device, you can login with your account credentials again. + Please wait... Your Devices Current Device diff --git a/app/src/test/kotlin/com/wire/android/ui/home/appLock/SetLockScreenViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/appLock/SetLockScreenViewModelTest.kt index adadd656aa9..a49262b8010 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/appLock/SetLockScreenViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/appLock/SetLockScreenViewModelTest.kt @@ -23,6 +23,7 @@ import com.wire.android.config.TestDispatcherProvider import com.wire.android.datastore.GlobalDataStore import com.wire.android.feature.AppLockConfig import com.wire.android.feature.ObserveAppLockConfigUseCase +import com.wire.android.ui.home.appLock.set.SetLockScreenViewModel import com.wire.kalium.logic.feature.auth.ValidatePasswordResult import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase import io.mockk.MockKAnnotations From 12999231655f0cea8f95e340400d5cac48270eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Thu, 2 Nov 2023 14:15:17 +0100 Subject: [PATCH 02/14] clean-up EnterLockCodeScreen --- .../ui/home/appLock/unlock/EnterLockCodeScreen.kt | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt index defa555905f..2e7bb3be2c9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt @@ -81,10 +81,8 @@ fun EnterLockCodeScreen( scrollState = rememberScrollState(), onPasswordChanged = viewModel::onPasswordChanged, onContinue = viewModel::onContinue, - onDone = navigator::navigateBack, onForgotCodeClicked = { navigator.navigate(NavigationCommand(ForgotLockCodeScreenDestination)) } ) - BackHandler { if (navigator.navController.previousBackStackEntry?.destination() is AppUnlockWithBiometricsScreenDestination) { navigator.navigateBack() @@ -92,6 +90,9 @@ fun EnterLockCodeScreen( navigator.finish() } } + LaunchedEffect(viewModel.state.done) { + if (viewModel.state.done) navigator.navigateBack() + } } @OptIn(ExperimentalComposeUiApi::class) @@ -101,15 +102,8 @@ fun EnterLockCodeScreenContent( scrollState: ScrollState, onPasswordChanged: (TextFieldValue) -> Unit, onContinue: () -> Unit, - onDone: () -> Unit, onForgotCodeClicked: () -> Unit, ) { - LaunchedEffect(state.done) { - if (state.done) { - onDone() - } - } - WireScaffold( snackbarHost = {} ) { internalPadding -> @@ -223,7 +217,6 @@ fun PreviewEnterLockCodeScreen() { scrollState = rememberScrollState(), onPasswordChanged = {}, onContinue = {}, - onDone = {}, onForgotCodeClicked = {} ) } From ecbb600d8aa7d6b89687a0ee4445644c1321e15d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Thu, 2 Nov 2023 16:25:28 +0100 Subject: [PATCH 03/14] fix snackbar host and remove initial dialog focus --- .../com/wire/android/ui/AppLockActivity.kt | 44 ++++++++++++------- .../forgot/ForgotLockCodeResetDeviceDialog.kt | 11 +---- .../appLock/unlock/EnterLockCodeScreen.kt | 4 +- 3 files changed, 29 insertions(+), 30 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt b/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt index 50dbd4aa1ee..557a3471404 100644 --- a/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt @@ -21,10 +21,14 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.biometric.BiometricManager +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import androidx.core.view.WindowCompat import com.wire.android.appLogger import com.wire.android.navigation.NavigationGraph import com.wire.android.navigation.rememberNavigator +import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.destinations.AppUnlockWithBiometricsScreenDestination import com.wire.android.ui.destinations.EnterLockCodeScreenDestination import com.wire.android.ui.theme.WireTheme @@ -36,26 +40,32 @@ class AppLockActivity : AppCompatActivity() { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) setContent { - WireTheme { - val canAuthenticateWithBiometrics = BiometricManager - .from(this) - .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) + val snackbarHostState = remember { SnackbarHostState() } + CompositionLocalProvider( + LocalSnackbarHostState provides snackbarHostState, + LocalActivity provides this + ) { + WireTheme { + val canAuthenticateWithBiometrics = BiometricManager + .from(this) + .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) - val navigator = rememberNavigator(this@AppLockActivity::finish) + val navigator = rememberNavigator(this@AppLockActivity::finish) - val startDestination = - if (canAuthenticateWithBiometrics == BiometricManager.BIOMETRIC_SUCCESS) { - appLogger.i("appLock: requesting app Unlock with biometrics") - AppUnlockWithBiometricsScreenDestination - } else { - appLogger.i("appLock: requesting app Unlock with passcode") - EnterLockCodeScreenDestination - } + val startDestination = + if (canAuthenticateWithBiometrics == BiometricManager.BIOMETRIC_SUCCESS) { + appLogger.i("appLock: requesting app Unlock with biometrics") + AppUnlockWithBiometricsScreenDestination + } else { + appLogger.i("appLock: requesting app Unlock with passcode") + EnterLockCodeScreenDestination + } - NavigationGraph( - navigator = navigator, - startDestination = startDestination - ) + NavigationGraph( + navigator = navigator, + startDestination = startDestination + ) + } } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt index 9fd40f455a1..918f29a0c38 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt @@ -20,15 +20,12 @@ package com.wire.android.ui.home.appLock.forgot import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -84,7 +81,6 @@ fun ForgotLockCodeResetDeviceDialog( // keyboard controller from outside the Dialog doesn't work inside its content so we have to pass the state // to the dialog's content and use keyboard controller from there keyboardController = LocalSoftwareKeyboardController.current - val focusRequester = remember { FocusRequester() } WirePasswordTextField( state = when { !isPasswordValid -> WireTextFieldState.Error(stringResource(id = R.string.remove_device_invalid_password)) @@ -97,13 +93,8 @@ fun ForgotLockCodeResetDeviceDialog( }, autofill = false, keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), - modifier = Modifier - .focusRequester(focusRequester) - .padding(bottom = dimensions().spacing16x) + modifier = Modifier.padding(bottom = dimensions().spacing16x) ) - LaunchedEffect(Unit) { // executed only once when showing the dialog - focusRequester.requestFocus() - } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt index 2e7bb3be2c9..7cb499997a4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/unlock/EnterLockCodeScreen.kt @@ -104,9 +104,7 @@ fun EnterLockCodeScreenContent( onContinue: () -> Unit, onForgotCodeClicked: () -> Unit, ) { - WireScaffold( - snackbarHost = {} - ) { internalPadding -> + WireScaffold { internalPadding -> Column( modifier = Modifier .fillMaxSize() From 26bf2ed135eb3fd37e216f5c938e94f11878343d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Thu, 2 Nov 2023 16:37:33 +0100 Subject: [PATCH 04/14] add bold user name --- .../forgot/ForgotLockCodeResetDeviceDialog.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt index 918f29a0c38..550e7b9fffc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt @@ -19,6 +19,7 @@ package com.wire.android.ui.home.appLock.forgot import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -26,6 +27,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -36,11 +38,14 @@ import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties import com.wire.android.ui.common.WireDialogButtonType import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.textfield.WirePasswordTextField import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.android.util.ui.stringWithStyledArgs @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -60,7 +65,14 @@ fun ForgotLockCodeResetDeviceDialog( } WireDialog( title = stringResource(R.string.settings_forgot_lock_screen_reset_device), - text = stringResource(R.string.settings_forgot_lock_screen_reset_device_description, username), + text = LocalContext.current.resources.stringWithStyledArgs( + R.string.settings_forgot_lock_screen_reset_device_description, + MaterialTheme.wireTypography.body01, + MaterialTheme.wireTypography.body02, + colorsScheme().onBackground, + colorsScheme().onBackground, + username + ), onDismiss = onDialogDismissHideKeyboard, buttonsHorizontalAlignment = false, dismissButtonProperties = WireDialogButtonProperties( From a6c523250401cbea154ec491da28480ea884d7cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Thu, 2 Nov 2023 17:26:36 +0100 Subject: [PATCH 05/14] return logout job and wait until all completes --- .../forgot/ForgotLockScreenViewModel.kt | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt index ed9ae5eb358..87e8a079712 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt @@ -50,9 +50,11 @@ import com.wire.kalium.logic.functional.Either import com.wire.kalium.logic.functional.flatMap import com.wire.kalium.logic.functional.fold import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import javax.inject.Inject @@ -89,7 +91,7 @@ class ForgotLockScreenViewModel @Inject constructor( fun onResetDevice() { viewModelScope.launch { - state = state.copy(dialogState = ForgotLockCodeDialogState.Visible(username = getSelf().firstOrNull()?.name ?: "",)) + state = state.copy(dialogState = ForgotLockCodeDialogState.Visible(username = getSelf().firstOrNull()?.name ?: "")) } } @@ -112,14 +114,14 @@ class ForgotLockScreenViewModel @Inject constructor( .flatMapIfSuccess { hardLogoutAllAccounts() } } .fold({ error -> - state = state.copy(error = error) - updateIfDialogStateVisible { it.copy(loading = false, resetDeviceEnabled = true) } - }, { result -> - when (result) { - Result.InvalidPassword -> updateIfDialogStateVisible { it.copy(passwordValid = false, loading = false) } - Result.Success -> state = state.copy(completed = true, dialogState = ForgotLockCodeDialogState.Hidden) - } + state = state.copy(error = error) + updateIfDialogStateVisible { it.copy(loading = false, resetDeviceEnabled = true) } + }, { result -> + when (result) { + Result.InvalidPassword -> updateIfDialogStateVisible { it.copy(passwordValid = false, loading = false) } + Result.Success -> state = state.copy(completed = true, dialogState = ForgotLockCodeDialogState.Hidden) } + } ) } } @@ -164,7 +166,9 @@ class ForgotLockScreenViewModel @Inject constructor( observeEstablishedCalls().firstOrNull()?.let { establishedCalls -> establishedCalls.forEach { endCall(it.conversationId) } } - getAllSessionsResult.sessions.forEach { session -> hardLogoutAccount(session.userId) } + getAllSessionsResult.sessions.map { session -> + hardLogoutAccount(session.userId) + }.joinAll() globalDataStore.clearAppLockPasscode() // it won't switch to any other account because there is none anymore, just required to clear-up after logout accountSwitch(SwitchAccountParam.TryToSwitchToNextAccount) @@ -173,11 +177,11 @@ class ForgotLockScreenViewModel @Inject constructor( } // TODO: we should have a dedicated manager to perform these required actions in AR after every LogoutUseCase call - private suspend fun hardLogoutAccount(userId: UserId) { + private suspend fun hardLogoutAccount(userId: UserId): Job { notificationManager.stopObservingOnLogout(userId) notificationChannelsManager.deleteChannelGroup(userId) - coreLogic.getSessionScope(userId).logout(LogoutReason.SELF_HARD_LOGOUT) userDataStoreProvider.getOrCreate(userId).clear() + return coreLogic.getSessionScope(userId).logout(LogoutReason.SELF_HARD_LOGOUT) } private enum class Result { InvalidPassword, Success; } From 3087034fe2901fe851455c1c8dcf1c51695a0218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Thu, 2 Nov 2023 17:27:45 +0100 Subject: [PATCH 06/14] add and use "clear" account switch option --- .../kotlin/com/wire/android/feature/AccountSwitchUseCase.kt | 2 ++ .../ui/home/appLock/forgot/ForgotLockScreenViewModel.kt | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/feature/AccountSwitchUseCase.kt b/app/src/main/kotlin/com/wire/android/feature/AccountSwitchUseCase.kt index b8ab67fa424..0dd42aa7e49 100644 --- a/app/src/main/kotlin/com/wire/android/feature/AccountSwitchUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/feature/AccountSwitchUseCase.kt @@ -68,6 +68,7 @@ class AccountSwitchUseCase @Inject constructor( return when (params) { is SwitchAccountParam.SwitchToAccount -> switch(params.userId, current.await()) SwitchAccountParam.TryToSwitchToNextAccount -> getNextAccountIfPossibleAndSwitch(current.await()) + SwitchAccountParam.Clear -> switch(null, current.await()) } } @@ -143,6 +144,7 @@ class AccountSwitchUseCase @Inject constructor( sealed class SwitchAccountParam { object TryToSwitchToNextAccount : SwitchAccountParam() data class SwitchToAccount(val userId: UserId) : SwitchAccountParam() + data object Clear : SwitchAccountParam() } sealed class SwitchAccountResult { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt index 87e8a079712..ad3f088a49a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt @@ -170,8 +170,7 @@ class ForgotLockScreenViewModel @Inject constructor( hardLogoutAccount(session.userId) }.joinAll() globalDataStore.clearAppLockPasscode() - // it won't switch to any other account because there is none anymore, just required to clear-up after logout - accountSwitch(SwitchAccountParam.TryToSwitchToNextAccount) + accountSwitch(SwitchAccountParam.Clear) Either.Right(Result.Success) } } From d8f13d06cdecc78f5b45f8e697d2f3040555a692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Thu, 2 Nov 2023 17:31:30 +0100 Subject: [PATCH 07/14] clean-up after merge --- .../ui/home/appLock/{ => set}/SetLockScreenViewModelTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) rename app/src/test/kotlin/com/wire/android/ui/home/appLock/{ => set}/SetLockScreenViewModelTest.kt (97%) diff --git a/app/src/test/kotlin/com/wire/android/ui/home/appLock/SetLockScreenViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/appLock/set/SetLockScreenViewModelTest.kt similarity index 97% rename from app/src/test/kotlin/com/wire/android/ui/home/appLock/SetLockScreenViewModelTest.kt rename to app/src/test/kotlin/com/wire/android/ui/home/appLock/set/SetLockScreenViewModelTest.kt index 3eda6dd96a7..4a9790f57e1 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/appLock/SetLockScreenViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/appLock/set/SetLockScreenViewModelTest.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.home.appLock +package com.wire.android.ui.home.appLock.set import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.config.CoroutineTestExtension @@ -23,7 +23,6 @@ import com.wire.android.config.TestDispatcherProvider import com.wire.android.datastore.GlobalDataStore import com.wire.android.feature.AppLockConfig import com.wire.android.feature.ObserveAppLockConfigUseCase -import com.wire.android.ui.home.appLock.set.SetLockScreenViewModel import com.wire.kalium.logic.feature.applock.AppLockTeamFeatureConfigObserverImpl import com.wire.kalium.logic.feature.auth.ValidatePasswordResult import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase From 4560db00882fb930acbaed84825183f6bfa84357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Thu, 2 Nov 2023 17:33:54 +0100 Subject: [PATCH 08/14] revert returning logout job --- .../ui/home/appLock/forgot/ForgotLockScreenViewModel.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt index ad3f088a49a..97f48400f25 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt @@ -168,7 +168,7 @@ class ForgotLockScreenViewModel @Inject constructor( } getAllSessionsResult.sessions.map { session -> hardLogoutAccount(session.userId) - }.joinAll() + } globalDataStore.clearAppLockPasscode() accountSwitch(SwitchAccountParam.Clear) Either.Right(Result.Success) @@ -176,11 +176,11 @@ class ForgotLockScreenViewModel @Inject constructor( } // TODO: we should have a dedicated manager to perform these required actions in AR after every LogoutUseCase call - private suspend fun hardLogoutAccount(userId: UserId): Job { + private suspend fun hardLogoutAccount(userId: UserId) { notificationManager.stopObservingOnLogout(userId) notificationChannelsManager.deleteChannelGroup(userId) + coreLogic.getSessionScope(userId).logout(LogoutReason.SELF_HARD_LOGOUT) userDataStoreProvider.getOrCreate(userId).clear() - return coreLogic.getSessionScope(userId).logout(LogoutReason.SELF_HARD_LOGOUT) } private enum class Result { InvalidPassword, Success; } From 63cc20e970cf6de86f520ed5df92523873097f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Thu, 2 Nov 2023 17:34:12 +0100 Subject: [PATCH 09/14] remove unused imports --- .../android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt index 97f48400f25..42bc8386cbb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt @@ -50,11 +50,9 @@ import com.wire.kalium.logic.functional.Either import com.wire.kalium.logic.functional.flatMap import com.wire.kalium.logic.functional.fold import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import javax.inject.Inject From 6e0dae27b24412035d6d0de7c6d4b3b87dfe4a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Thu, 2 Nov 2023 18:01:39 +0100 Subject: [PATCH 10/14] use waitUntilCompletes option for logout --- .../home/appLock/forgot/ForgotLockScreenViewModel.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt index 42bc8386cbb..fb019645653 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt @@ -50,9 +50,11 @@ import com.wire.kalium.logic.functional.Either import com.wire.kalium.logic.functional.flatMap import com.wire.kalium.logic.functional.fold import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import javax.inject.Inject @@ -165,8 +167,10 @@ class ForgotLockScreenViewModel @Inject constructor( establishedCalls.forEach { endCall(it.conversationId) } } getAllSessionsResult.sessions.map { session -> - hardLogoutAccount(session.userId) - } + viewModelScope.async { + hardLogoutAccount(session.userId) + } + }.joinAll() // wait until all accounts are logged out globalDataStore.clearAppLockPasscode() accountSwitch(SwitchAccountParam.Clear) Either.Right(Result.Success) @@ -177,7 +181,7 @@ class ForgotLockScreenViewModel @Inject constructor( private suspend fun hardLogoutAccount(userId: UserId) { notificationManager.stopObservingOnLogout(userId) notificationChannelsManager.deleteChannelGroup(userId) - coreLogic.getSessionScope(userId).logout(LogoutReason.SELF_HARD_LOGOUT) + coreLogic.getSessionScope(userId).logout(reason = LogoutReason.SELF_HARD_LOGOUT, waitUntilCompletes = true) userDataStoreProvider.getOrCreate(userId).clear() } From 4860a2020ba82f0680e6958e57bca19aa9d72c65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Thu, 2 Nov 2023 18:03:01 +0100 Subject: [PATCH 11/14] change kalium ref --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index bfcae0a7340..ac75dfc48c1 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit bfcae0a73405d6151558a00a031e9cf5abdbff28 +Subproject commit ac75dfc48c1109c23f64339d6085b6dc1d2d8342 From 16fa72f6ceb0d560c7aaa39d1659cb2415212748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Fri, 3 Nov 2023 10:46:28 +0100 Subject: [PATCH 12/14] update kalium ref --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index ac75dfc48c1..76b3f952861 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit ac75dfc48c1109c23f64339d6085b6dc1d2d8342 +Subproject commit 76b3f9528612aa950f464b44ed347914ed837c92 From b1c8ab47ad34ecdd365a048e3ca2e79067e9ac51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Fri, 3 Nov 2023 15:51:44 +0100 Subject: [PATCH 13/14] add tests --- .../forgot/ForgotLockScreenViewModel.kt | 22 +- .../forgot/ForgotLockScreenViewModelTest.kt | 232 ++++++++++++++++++ 2 files changed, 245 insertions(+), 9 deletions(-) create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModelTest.kt diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt index fb019645653..f82e6da7168 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt @@ -17,6 +17,7 @@ */ package com.wire.android.ui.home.appLock.forgot +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -36,6 +37,7 @@ import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.client.DeleteClientParam import com.wire.kalium.logic.data.logout.LogoutReason import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.auth.AccountInfo import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase @@ -50,7 +52,6 @@ import com.wire.kalium.logic.functional.Either import com.wire.kalium.logic.functional.flatMap import com.wire.kalium.logic.functional.fold import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.async import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull @@ -127,7 +128,8 @@ class ForgotLockScreenViewModel @Inject constructor( } } - private suspend fun validatePasswordIfNeeded(password: String): Either = + @VisibleForTesting + internal suspend fun validatePasswordIfNeeded(password: String): Either = when (val isPasswordRequiredResult = isPasswordRequired()) { is IsPasswordRequiredUseCase.Result.Failure -> { appLogger.e("$TAG Failed to check if password is required when resetting passcode") @@ -139,7 +141,8 @@ class ForgotLockScreenViewModel @Inject constructor( } } - private suspend fun deleteCurrentClient(password: String): Either = + @VisibleForTesting + internal suspend fun deleteCurrentClient(password: String): Either = observeCurrentClientId() .filterNotNull() .first() @@ -154,20 +157,21 @@ class ForgotLockScreenViewModel @Inject constructor( } } - private suspend fun hardLogoutAllAccounts(): Either = + @VisibleForTesting + internal suspend fun hardLogoutAllAccounts(): Either = when (val getAllSessionsResult = getSessions()) { is GetAllSessionsResult.Failure.Generic -> { appLogger.e("$TAG Failed to get all sessions when resetting passcode") Either.Left(getAllSessionsResult.genericFailure) } - - is GetAllSessionsResult.Failure.NoSessionFound -> Either.Right(Result.Success) + is GetAllSessionsResult.Failure.NoSessionFound, is GetAllSessionsResult.Success -> { observeEstablishedCalls().firstOrNull()?.let { establishedCalls -> establishedCalls.forEach { endCall(it.conversationId) } } - getAllSessionsResult.sessions.map { session -> - viewModelScope.async { + val sessions = if (getAllSessionsResult is GetAllSessionsResult.Success) getAllSessionsResult.sessions else emptyList() + sessions.filterIsInstance().map { session -> + viewModelScope.launch { hardLogoutAccount(session.userId) } }.joinAll() // wait until all accounts are logged out @@ -185,7 +189,7 @@ class ForgotLockScreenViewModel @Inject constructor( userDataStoreProvider.getOrCreate(userId).clear() } - private enum class Result { InvalidPassword, Success; } + internal enum class Result { InvalidPassword, Success; } private inline fun Either.flatMapIfSuccess(block: () -> Either): Either = this.flatMap { if (it == Result.Success) block() else Either.Right(it) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModelTest.kt new file mode 100644 index 00000000000..c8da6ab12f5 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModelTest.kt @@ -0,0 +1,232 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.appLock.forgot + +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.TestDispatcherProvider +import com.wire.android.datastore.GlobalDataStore +import com.wire.android.datastore.UserDataStore +import com.wire.android.datastore.UserDataStoreProvider +import com.wire.android.feature.AccountSwitchUseCase +import com.wire.android.feature.SwitchAccountParam +import com.wire.android.feature.SwitchAccountResult +import com.wire.android.notification.NotificationChannelsManager +import com.wire.android.notification.WireNotificationManager +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.StorageFailure +import com.wire.kalium.logic.data.conversation.ClientId +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.logout.LogoutReason +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.UserSessionScope +import com.wire.kalium.logic.feature.auth.AccountInfo +import com.wire.kalium.logic.feature.auth.LogoutUseCase +import com.wire.kalium.logic.feature.auth.ValidatePasswordResult +import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase +import com.wire.kalium.logic.feature.call.Call +import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase +import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase +import com.wire.kalium.logic.feature.client.DeleteClientResult +import com.wire.kalium.logic.feature.client.DeleteClientUseCase +import com.wire.kalium.logic.feature.client.ObserveCurrentClientIdUseCase +import com.wire.kalium.logic.feature.session.GetAllSessionsResult +import com.wire.kalium.logic.feature.session.GetSessionsUseCase +import com.wire.kalium.logic.feature.user.GetSelfUserUseCase +import com.wire.kalium.logic.feature.user.IsPasswordRequiredUseCase +import com.wire.kalium.logic.functional.Either +import com.wire.kalium.logic.functional.isLeft +import com.wire.kalium.logic.functional.isRight +import io.mockk.Called +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(CoroutineTestExtension::class) +class ForgotLockScreenViewModelTest { + private val dispatcher = TestDispatcherProvider() + + // password validation + @Test + fun `given password not required, when validating password, then return Success`() = + runTest(dispatcher.default()) { + val (arrangement, viewModel) = Arrangement() + .withIsPasswordRequiredResult(IsPasswordRequiredUseCase.Result.Success(false)) + .arrange() + val result = viewModel.validatePasswordIfNeeded("password") + assert(result.isRight() && (result as Either.Right).value == ForgotLockScreenViewModel.Result.Success) + verify { arrangement.validatePasswordUseCase(any()) wasNot Called } + } + @Test + fun `given password required and valid, when validating password, then return Success`() = + runTest(dispatcher.default()) { + val (_, viewModel) = Arrangement() + .withIsPasswordRequiredResult(IsPasswordRequiredUseCase.Result.Success(true)) + .withValidatePasswordResult(ValidatePasswordResult.Valid) + .arrange() + val result = viewModel.validatePasswordIfNeeded("password") + assert(result.isRight() && (result as Either.Right).value == ForgotLockScreenViewModel.Result.Success) + } + @Test + fun `given password required but invalid, when validating password, then return InvalidPassword`() = + runTest(dispatcher.default()) { + val (_, viewModel) = Arrangement() + .withIsPasswordRequiredResult(IsPasswordRequiredUseCase.Result.Success(true)) + .withValidatePasswordResult(ValidatePasswordResult.Invalid()) + .arrange() + val result = viewModel.validatePasswordIfNeeded("password") + assert(result.isRight() && (result as Either.Right).value == ForgotLockScreenViewModel.Result.InvalidPassword) + } + @Test + fun `given password required returns failure, when validating password, then return failure`() = + runTest(dispatcher.default()) { + val (_, viewModel) = Arrangement() + .withIsPasswordRequiredResult(IsPasswordRequiredUseCase.Result.Failure(StorageFailure.DataNotFound)) + .arrange() + val result = viewModel.validatePasswordIfNeeded("password") + assert(result.isLeft()) + } + + // current client deletion + private fun testSuccessfulClientDelete(deleteClientResult: DeleteClientResult, actionResult: ForgotLockScreenViewModel.Result) = + runTest(dispatcher.default()) { + val (_, viewModel) = Arrangement() + .withDeleteClientResult(deleteClientResult) + .arrange() + val result = viewModel.deleteCurrentClient("password") + assert(result.isRight() && (result as Either.Right).value == actionResult) + } + @Test + fun `given deleting client returns success, when deleting current client, then return Success`() = + testSuccessfulClientDelete(DeleteClientResult.Success, ForgotLockScreenViewModel.Result.Success) + @Test + fun `given deleting client returns invalid credentials, when deleting current client, then return InvalidPassword`() = + testSuccessfulClientDelete(DeleteClientResult.Failure.InvalidCredentials, ForgotLockScreenViewModel.Result.InvalidPassword) + @Test + fun `given deleting client returns failure, when deleting current client, then return failure`() = + runTest(dispatcher.default()) { + val (_, viewModel) = Arrangement() + .withDeleteClientResult(DeleteClientResult.Failure.Generic(StorageFailure.DataNotFound)) + .arrange() + val result = viewModel.deleteCurrentClient("password") + assert(result.isLeft()) + } + + // sessions hard logout + private fun Arrangement.verifyHardLogoutActions(logoutCalled: Boolean) { + coVerify { observeEstablishedCallsUseCase() } + coVerify { endCallUseCase(any()) } + coVerify { globalDataStore.clearAppLockPasscode() } + coVerify { accountSwitchUseCase(SwitchAccountParam.Clear) } + val (atLeast, atMost) = if (logoutCalled) 1 to 1 else 0 to 0 + coVerify(atLeast = atLeast, atMost = atMost) { logoutUseCase(any(), any()) } + coVerify(atLeast = atLeast, atMost = atMost) { notificationManager.stopObservingOnLogout(any()) } + coVerify(atLeast = atLeast, atMost = atMost) { notificationChannelsManager.deleteChannelGroup(any()) } + coVerify(atLeast = atLeast, atMost = atMost) { userDataStore.clear() } + } + private fun testSuccessfulLoggingOut(getSessionsResult: GetAllSessionsResult, logoutCalled: Boolean) = runTest(dispatcher.default()) { + val (arrangement, viewModel) = Arrangement() + .withGetSessionsResult(getSessionsResult) + .arrange() + val result = viewModel.hardLogoutAllAccounts() + advanceUntilIdle() + assert(result.isRight() && (result as Either.Right).value == ForgotLockScreenViewModel.Result.Success) + arrangement.verifyHardLogoutActions(logoutCalled = logoutCalled) + } + @Test + fun `given no sessions, when logging out, then make all required actions other than logout and return success`() = + testSuccessfulLoggingOut(getSessionsResult = GetAllSessionsResult.Failure.NoSessionFound, logoutCalled = false) + @Test + fun `given no valid sessions, when logging out, then make all required actions other than logout and return success`() = + testSuccessfulLoggingOut(getSessionsResult = GetAllSessionsResult.Success(listOf(INVALID_SESSION)), logoutCalled = false) + @Test + fun `given valid sessions, when logging out, then make all required actions with logout and return success`() = + testSuccessfulLoggingOut(getSessionsResult = GetAllSessionsResult.Success(listOf(VALID_SESSION)), logoutCalled = true) + @Test + fun `given sessions return failure, when hard-logging out sessions, then return failure`() = + runTest(dispatcher.default()) { + val (_, viewModel) = Arrangement() + .withGetSessionsResult(GetAllSessionsResult.Failure.Generic(StorageFailure.DataNotFound)) + .arrange() + val result = viewModel.hardLogoutAllAccounts() + assert(result.isLeft()) + } + + class Arrangement { + @MockK lateinit var coreLogic: CoreLogic + @MockK lateinit var userSessionScope: UserSessionScope + @MockK lateinit var logoutUseCase: LogoutUseCase + @MockK lateinit var globalDataStore: GlobalDataStore + @MockK lateinit var userDataStoreProvider: UserDataStoreProvider + @MockK lateinit var userDataStore: UserDataStore + @MockK lateinit var notificationChannelsManager: NotificationChannelsManager + @MockK lateinit var notificationManager: WireNotificationManager + @MockK lateinit var getSelfUserUseCase: GetSelfUserUseCase + @MockK lateinit var isPasswordRequiredUseCase: IsPasswordRequiredUseCase + @MockK lateinit var validatePasswordUseCase: ValidatePasswordUseCase + @MockK lateinit var observeCurrentClientIdUseCase: ObserveCurrentClientIdUseCase + @MockK lateinit var deleteClientUseCase: DeleteClientUseCase + @MockK lateinit var getSessionsUseCase: GetSessionsUseCase + @MockK lateinit var observeEstablishedCallsUseCase: ObserveEstablishedCallsUseCase + @MockK lateinit var endCallUseCase: EndCallUseCase + @MockK lateinit var accountSwitchUseCase: AccountSwitchUseCase + + private val viewModel: ForgotLockScreenViewModel by lazy { + ForgotLockScreenViewModel( + coreLogic, globalDataStore, userDataStoreProvider, notificationChannelsManager, notificationManager, getSelfUserUseCase, + isPasswordRequiredUseCase, validatePasswordUseCase, observeCurrentClientIdUseCase, deleteClientUseCase, getSessionsUseCase, + observeEstablishedCallsUseCase, endCallUseCase, accountSwitchUseCase + ) + } + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + every { coreLogic.getSessionScope(any()) } returns userSessionScope + every { userSessionScope.logout } returns logoutUseCase + every { userDataStoreProvider.getOrCreate(any()) } returns userDataStore + coEvery { observeCurrentClientIdUseCase() } returns flowOf(ClientId("currentClientId")) + val call: Call = mockk() + coEvery { observeEstablishedCallsUseCase() } returns flowOf(listOf(call)) + every { call.conversationId } returns ConversationId("conversationId", "domain") + coEvery { accountSwitchUseCase(any()) } returns SwitchAccountResult.NoOtherAccountToSwitch + } + + fun withIsPasswordRequiredResult(result: IsPasswordRequiredUseCase.Result) = + apply { coEvery { isPasswordRequiredUseCase() } returns result } + fun withValidatePasswordResult(result: ValidatePasswordResult) = + apply { coEvery { validatePasswordUseCase(any()) } returns result } + fun withDeleteClientResult(result: DeleteClientResult) = + apply { coEvery { deleteClientUseCase(any()) } returns result } + fun withGetSessionsResult(result: GetAllSessionsResult) = + apply { coEvery { getSessionsUseCase() } returns result } + fun arrange() = this to viewModel + } + + companion object { + val INVALID_SESSION = AccountInfo.Invalid(UserId("id", "domain"), LogoutReason.SELF_HARD_LOGOUT) + val VALID_SESSION = AccountInfo.Valid(UserId("id", "domain")) + } +} From 0dd15abd7502b6fab555d70f3d9a33ab02bcff75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Mon, 6 Nov 2023 09:49:53 +0100 Subject: [PATCH 14/14] fix detekt --- .../android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt index f82e6da7168..5084dd55fa6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt @@ -59,6 +59,7 @@ import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @HiltViewModel class ForgotLockScreenViewModel @Inject constructor( @KaliumCoreLogic private val coreLogic: CoreLogic,