From f1940011ea6e9e33d9f28980868d8a64999b53cb Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Tue, 19 Sep 2023 13:56:10 +0200 Subject: [PATCH] feat: setup app lock screen (#2243) Co-authored-by: Alexandre Ferris --- .../ui/home/appLock/SetLockCodeScreen.kt | 164 ++++++++++++++++++ .../ui/home/appLock/SetLockCodeViewState.kt | 27 +++ .../ui/home/appLock/SetLockScreenViewModel.kt | 64 +++++++ .../options/GroupConversationOptionsItem.kt | 2 +- .../settings/privacy/PrivacySettingsScreen.kt | 55 +++++- .../appLock/SetLockScreenViewModelTest.kt | 78 +++++++++ 6 files changed, 384 insertions(+), 6 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/appLock/SetLockCodeScreen.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/appLock/SetLockCodeViewState.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/appLock/SetLockScreenViewModel.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/appLock/SetLockScreenViewModelTest.kt 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/SetLockCodeScreen.kt new file mode 100644 index 00000000000..17668e4e977 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/SetLockCodeScreen.kt @@ -0,0 +1,164 @@ +/* + * 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 + +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.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +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.Navigator +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WirePrimaryButton +import com.wire.android.ui.common.dimensions +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.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireDimensions + +@RootNavGraph +@Destination +@Composable +fun SetLockCodeScreen( + viewModel: SetLockScreenViewModel = hiltViewModel(), + navigator: Navigator +) { + SetLockCodeScreenContent( + navigator = navigator, + state = viewModel.state, + scrollState = rememberScrollState(), + onPasswordChanged = viewModel::onPasswordChanged, + onBackPress = navigator::navigateBack, + onContinue = viewModel::onContinue + ) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun SetLockCodeScreenContent( + navigator: Navigator, + state: SetLockCodeViewState, + scrollState: ScrollState, + onPasswordChanged: (TextFieldValue) -> Unit, + onBackPress: () -> Unit = {}, + onContinue: () -> Unit +) { + LaunchedEffect(state.done) { + if (state.done) { + navigator.navigateBack() + } + } + + WireScaffold(topBar = { + WireCenterAlignedTopAppBar( + onNavigationPressed = onBackPress, + elevation = dimensions().spacing0x, + title = stringResource(id = R.string.settings_privacy_settings_label) + ) + }) { internalPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(internalPadding) + ) { + Column( + modifier = Modifier + .weight(weight = 1f, fill = true) + .verticalScroll(scrollState) + .padding(MaterialTheme.wireDimensions.spacing16x) + .semantics { + testTagsAsResourceId = true + } + ) { + WirePasswordTextField( + value = state.password, + onValueChange = onPasswordChanged, + labelMandatoryIcon = true, + descriptionText = stringResource(R.string.create_account_details_password_description), + imeAction = ImeAction.Done, + modifier = Modifier + .testTag("password"), + state = WireTextFieldState.Default, + autofill = false + ) + 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)) { + val enabled = state.password.text.isNotBlank() && state.isPasswordValid + ContinueButton( + enabled = enabled, + onContinue = onContinue + ) + } + } + } + } +} + +@Composable +private fun ContinueButton( + modifier: Modifier = Modifier.fillMaxWidth(), + enabled: Boolean, + onContinue: () -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + Column(modifier = modifier) { + WirePrimaryButton( + text = stringResource(R.string.label_continue), + onClick = onContinue, + state = if (enabled) WireButtonState.Default else WireButtonState.Disabled, + interactionSource = interactionSource, + modifier = Modifier + .fillMaxWidth() + .testTag("continue_button") + ) + } +} 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/SetLockCodeViewState.kt new file mode 100644 index 00000000000..d832e5be775 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/SetLockCodeViewState.kt @@ -0,0 +1,27 @@ +/* + * 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 + +import androidx.compose.ui.text.input.TextFieldValue + +data class SetLockCodeViewState( + val continueEnabled: Boolean = false, + val password: TextFieldValue = TextFieldValue(), + val isPasswordValid: Boolean = false, + val done: Boolean = false +) 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/SetLockScreenViewModel.kt new file mode 100644 index 00000000000..39d45c6fb1b --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/SetLockScreenViewModel.kt @@ -0,0 +1,64 @@ +/* + * 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 + +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 com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class SetLockScreenViewModel @Inject constructor( + private val validatePassword: ValidatePasswordUseCase +) : ViewModel() { + + var state: SetLockCodeViewState by mutableStateOf(SetLockCodeViewState()) + private set + + fun onPasswordChanged(password: TextFieldValue) { + state = state.copy( + password = password + ) + state = if (validatePassword(password.text)) { + state.copy( + continueEnabled = true, + isPasswordValid = true + ) + } else { + state.copy( + isPasswordValid = false + ) + } + } + + fun onContinue() { + state = state.copy(continueEnabled = false) + // the continue button is enabled iff the password is valid + // this check is for safety only + state = if (!validatePassword(state.password.text)) { + state.copy(isPasswordValid = false) + } else { + // TODO: store password in secure storage + state.copy(done = true) + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptionsItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptionsItem.kt index 7ce218e593d..774aef8a414 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptionsItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptionsItem.kt @@ -181,7 +181,7 @@ sealed class SwitchState { data class Enabled( override val value: Boolean = false, override val isOnOffVisible: Boolean = true, - val onCheckedChange: (Boolean) -> Unit + val onCheckedChange: ((Boolean) -> Unit)? ) : Visible(value = value, isOnOffVisible = isOnOffVisible, isSwitchVisible = true) data class Disabled( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsScreen.kt index 8ffe324b4fe..fa6ba16cff6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/privacy/PrivacySettingsScreen.kt @@ -23,8 +23,8 @@ package com.wire.android.ui.home.settings.privacy import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import com.wire.android.ui.common.scaffold.WireScaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -33,8 +33,14 @@ 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.model.Clickable +import com.wire.android.navigation.BackStackMode +import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator +import com.wire.android.navigation.rememberNavigator +import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.destinations.SetLockCodeScreenDestination import com.wire.android.ui.home.conversations.details.options.ArrowType import com.wire.android.ui.home.conversations.details.options.GroupConversationOptionsItem import com.wire.android.ui.home.conversations.details.options.SwitchState @@ -54,7 +60,7 @@ fun PrivacySettingsConfigScreen( setTypingIndicatorState = ::setTypingIndicatorState, screenshotCensoringConfig = state.screenshotCensoringConfig, setScreenshotCensoringConfig = ::setScreenshotCensoringConfig, - onBackPressed = navigator::navigateBack + navigator = navigator ) } } @@ -67,11 +73,11 @@ fun PrivacySettingsScreenContent( setTypingIndicatorState: (Boolean) -> Unit, screenshotCensoringConfig: ScreenshotCensoringConfig, setScreenshotCensoringConfig: (Boolean) -> Unit, - onBackPressed: () -> Unit + navigator: Navigator, ) { WireScaffold(topBar = { WireCenterAlignedTopAppBar( - onNavigationPressed = onBackPressed, + onNavigationPressed = navigator::navigateBack, elevation = 0.dp, title = stringResource(id = R.string.settings_privacy_settings_label) ) @@ -113,12 +119,51 @@ fun PrivacySettingsScreenContent( arrowType = ArrowType.NONE, subtitle = stringResource(id = R.string.settings_send_read_receipts_description) ) + + AppLockItem( + state = false, + canBeUpdated = true, + navigator = navigator + ) + } + } +} + +@Composable +fun AppLockItem( + state: Boolean, + canBeUpdated: Boolean, + navigator: Navigator +) { + val onCLick = remember(state) { + if (state) { + { + // call function to disable app lock IF POSSIBLE + // this will include checking if all logged accouts does not enforce app-lock + } + } else { + { + // navigate to app lock screen + navigator.navigate( + NavigationCommand( + SetLockCodeScreenDestination, + backStackMode = BackStackMode.NONE + ) + ) + } } } + GroupConversationOptionsItem( + title = "App lock", + switchState = SwitchState.Enabled(value = state, onCheckedChange = null), + arrowType = ArrowType.NONE, + subtitle = "subtitle", + clickable = Clickable(enabled = canBeUpdated, onClick = onCLick) + ) } @Composable @Preview fun PreviewSendReadReceipts() { - PrivacySettingsScreenContent(true, {}, true, {}, ScreenshotCensoringConfig.DISABLED, {}, {}) + PrivacySettingsScreenContent(true, {}, true, {}, ScreenshotCensoringConfig.DISABLED, {}, rememberNavigator({})) } 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 new file mode 100644 index 00000000000..7166977f6d7 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/appLock/SetLockScreenViewModelTest.kt @@ -0,0 +1,78 @@ +/* + * 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 + +import androidx.compose.ui.text.input.TextFieldValue +import com.wire.android.config.CoroutineTestExtension +import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import org.junit.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(CoroutineTestExtension::class) +class SetLockScreenViewModelTest { + + @Test + fun `given new password input, when valid,then should update state`() { + val (arrangement, viewModel) = Arrangement() + .withValidPassword() + .arrange() + + viewModel.onPasswordChanged(TextFieldValue("password")) + + assert(viewModel.state.password.text == "password") + assert(viewModel.state.isPasswordValid) + + verify(exactly = 1) { arrangement.validatePassword("password") } + } + + @Test + fun `given new password input, when invalid,then should update state`() { + val (arrangement, viewModel) = Arrangement() + .withInvalidPassword() + .arrange() + + viewModel.onPasswordChanged(TextFieldValue("password")) + + assert(viewModel.state.password.text == "password") + assert(!viewModel.state.isPasswordValid) + + verify(exactly = 1) { arrangement.validatePassword("password") } + } + + private class Arrangement { + @MockK + lateinit var validatePassword: ValidatePasswordUseCase + + fun withValidPassword() = apply { + every { validatePassword(any()) } returns true + } + + fun withInvalidPassword() = apply { + every { validatePassword(any()) } returns false + } + + private val viewModel = SetLockScreenViewModel( + validatePassword + ) + + fun arrange() = this to viewModel + } +}