From 162eded75ffdf71e32941e8b18e05f060cd37e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= <30429749+saleniuk@users.noreply.github.com> Date: Tue, 7 Nov 2023 09:58:08 +0100 Subject: [PATCH] feat(applock): forgot passcode - reset device [WPB-5094] (#2392) --- .../android/feature/AccountSwitchUseCase.kt | 2 + .../com/wire/android/ui/AppLockActivity.kt | 44 ++-- .../com/wire/android/ui/common/WireDialog.kt | 47 ++-- .../forgot/ForgotLockCodeResetDeviceDialog.kt | 137 +++++++++++ .../appLock/forgot/ForgotLockCodeScreen.kt | 209 ++++++++++++++++ .../appLock/forgot/ForgotLockCodeViewState.kt | 38 +++ .../forgot/ForgotLockScreenViewModel.kt | 201 +++++++++++++++ .../appLock/{ => set}/SetLockCodeScreen.kt | 2 +- .../appLock/{ => set}/SetLockCodeViewState.kt | 2 +- .../{ => set}/SetLockScreenViewModel.kt | 2 +- .../AppUnlockWithBiometricsScreen.kt | 2 +- .../AppUnlockWithBiometricsViewModel.kt | 3 +- .../{ => unlock}/EnterLockCodeScreen.kt | 56 ++--- .../{ => unlock}/EnterLockCodeViewState.kt | 2 +- .../{ => unlock}/EnterLockScreenViewModel.kt | 3 +- app/src/main/res/values/strings.xml | 9 +- .../forgot/ForgotLockScreenViewModelTest.kt | 232 ++++++++++++++++++ .../{ => set}/SetLockScreenViewModelTest.kt | 2 +- kalium | 2 +- 19 files changed, 925 insertions(+), 70 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 (89%) 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%) create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModelTest.kt rename app/src/test/kotlin/com/wire/android/ui/home/appLock/{ => set}/SetLockScreenViewModelTest.kt (98%) 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 b8ab67fa42..0dd42aa7e4 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/AppLockActivity.kt b/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt index 50dbd4aa1e..557a347140 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/common/WireDialog.kt b/app/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt index 8e2db34c38..c1f1407139 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 0000000000..550e7b9fff --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt @@ -0,0 +1,137 @@ +/* + * 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.material3.MaterialTheme +import androidx.compose.runtime.Composable +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.platform.LocalContext +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.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 +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 = 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( + 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 + 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.padding(bottom = dimensions().spacing16x) + ) + } +} + +@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 0000000000..95bc5effbc --- /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 0000000000..e0506aaa30 --- /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 0000000000..5084dd55fa --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt @@ -0,0 +1,201 @@ +/* + * 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.annotation.VisibleForTesting +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.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 +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.joinAll +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("LongParameterList") +@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) + } + } + ) + } + } + } + + @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") + Either.Left(isPasswordRequiredResult.cause) + } + is IsPasswordRequiredUseCase.Result.Success -> { + if (!isPasswordRequiredResult.value || validatePassword(password).isValid) Either.Right(Result.Success) + else Either.Right(Result.InvalidPassword) + } + } + + @VisibleForTesting + internal 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) + } + } + + @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, + is GetAllSessionsResult.Success -> { + observeEstablishedCalls().firstOrNull()?.let { establishedCalls -> + establishedCalls.forEach { endCall(it.conversationId) } + } + 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 + globalDataStore.clearAppLockPasscode() + accountSwitch(SwitchAccountParam.Clear) + 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(reason = LogoutReason.SELF_HARD_LOGOUT, waitUntilCompletes = true) + userDataStoreProvider.getOrCreate(userId).clear() + } + + 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) } + + 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 bee7339e88..1565711369 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 dfcf8ddfc3..cb4dbfbbc7 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.kalium.logic.feature.applock.AppLockTeamFeatureConfigObserverImpl.Companion.DEFAULT_TIMEOUT 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 9612aefd04..f07dddfcfd 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 b17f05c2d8..5078d83571 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 android.widget.Toast 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 240051dc38..c255a8fdd6 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 89% 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 1f0c433831..7cb499997a 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,10 +15,9 @@ * 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 import androidx.compose.foundation.ScrollState import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box @@ -40,7 +39,6 @@ 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 @@ -53,15 +51,17 @@ import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph import com.ramcosta.composedestinations.utils.destination 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.AppUnlockWithBiometricsScreenDestination +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 @@ -77,41 +77,34 @@ fun EnterLockCodeScreen( navigator: Navigator ) { EnterLockCodeScreenContent( - navigator = navigator, state = viewModel.state, scrollState = rememberScrollState(), onPasswordChanged = viewModel::onPasswordChanged, - onContinue = viewModel::onContinue + onContinue = viewModel::onContinue, + onForgotCodeClicked = { navigator.navigate(NavigationCommand(ForgotLockCodeScreenDestination)) } ) + BackHandler { + if (navigator.navController.previousBackStackEntry?.destination() is AppUnlockWithBiometricsScreenDestination) { + navigator.navigateBack() + } else { + navigator.finish() + } + } + LaunchedEffect(viewModel.state.done) { + if (viewModel.state.done) navigator.navigateBack() + } } @OptIn(ExperimentalComposeUiApi::class) @Composable fun EnterLockCodeScreenContent( - navigator: Navigator, state: EnterLockCodeViewState, scrollState: ScrollState, onPasswordChanged: (TextFieldValue) -> Unit, - onContinue: () -> Unit + onContinue: () -> Unit, + onForgotCodeClicked: () -> Unit, ) { - val context = LocalContext.current - LaunchedEffect(state.done) { - if (state.done) { - navigator.navigateBack() - } - } - - BackHandler { - if (navigator.navController.previousBackStackEntry?.destination() is AppUnlockWithBiometricsScreenDestination) { - navigator.navigateBack() - } else { - (context as AppCompatActivity).finishAffinity() - } - } - - WireScaffold( - snackbarHost = {} - ) { internalPadding -> + WireScaffold { internalPadding -> Column( modifier = Modifier .fillMaxSize() @@ -164,6 +157,13 @@ fun EnterLockCodeScreenContent( Locale.getDefault() ) ) + + WireTertiaryButton( + text = stringResource(id = R.string.settings_enter_lock_screen_forgot_passcode_label), + onClick = onForgotCodeClicked, + modifier = Modifier.padding(MaterialTheme.wireDimensions.spacing16x) + ) + Spacer(modifier = Modifier.weight(1f)) } @@ -211,11 +211,11 @@ private fun ContinueButton( fun PreviewEnterLockCodeScreen() { WireTheme(isPreview = true) { EnterLockCodeScreenContent( - navigator = rememberNavigator {}, state = EnterLockCodeViewState(), scrollState = rememberScrollState(), onPasswordChanged = {}, - onContinue = {} + onContinue = {}, + onForgotCodeClicked = {} ) } } 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 d3b41ae5cc..ee804671ed 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 5cd7b4317f..0dc3ff5571 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 27984a471e..8b83dc36cc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -948,7 +948,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 @@ -957,6 +958,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/forgot/ForgotLockScreenViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModelTest.kt new file mode 100644 index 0000000000..c8da6ab12f --- /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")) + } +} 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 98% 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 227cca15fa..4a9790f57e 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 diff --git a/kalium b/kalium index bfcae0a734..76b3f95286 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit bfcae0a73405d6151558a00a031e9cf5abdbff28 +Subproject commit 76b3f9528612aa950f464b44ed347914ed837c92