From 20006739e1d8dc3bd81293a8d325941f81d85ca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Wed, 27 Sep 2023 17:49:37 +0200 Subject: [PATCH 1/6] feat: enter app lock --- .../wire/android/datastore/GlobalDataStore.kt | 12 +- .../feature/ObserveAppLockConfigUseCase.kt | 6 +- .../com/wire/android/ui/WireActivity.kt | 20 ++ .../ui/home/appLock/EnterLockCodeScreen.kt | 177 ++++++++++++++++++ .../ui/home/appLock/EnterLockCodeViewState.kt | 33 ++++ .../home/appLock/EnterLockScreenViewModel.kt | 83 ++++++++ .../ui/home/appLock/LockCodeTimeManager.kt | 79 ++++++++ .../ui/home/appLock/SetLockScreenViewModel.kt | 25 ++- app/src/main/res/values/strings.xml | 3 + 9 files changed, 425 insertions(+), 13 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/appLock/EnterLockCodeScreen.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/appLock/EnterLockCodeViewState.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/appLock/EnterLockScreenViewModel.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/appLock/LockCodeTimeManager.kt diff --git a/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt b/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt index 5d8b1fe26c8..4faccc0df24 100644 --- a/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt +++ b/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt @@ -26,6 +26,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.wire.android.BuildConfig @@ -52,6 +53,7 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex private val IS_LOGGING_ENABLED = booleanPreferencesKey("is_logging_enabled") private val IS_ENCRYPTED_PROTEUS_STORAGE_ENABLED = booleanPreferencesKey("is_encrypted_proteus_storage_enabled") private val APP_LOCK_PASSCODE = stringPreferencesKey("app_lock_passcode") + private val APP_LOCK_TIMESTAMP = longPreferencesKey("app_lock_timestamp") private val Context.dataStore: DataStore by preferencesDataStore(name = PREFERENCES_NAME) private fun userMigrationStatusKey(userId: String): Preferences.Key = intPreferencesKey("user_migration_status_$userId") private fun userDoubleTapToastStatusKey(userId: String): Preferences.Key = @@ -158,10 +160,18 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex suspend fun setAppLockPasscode(passcode: String) { context.dataStore.edit { try { - it[APP_LOCK_PASSCODE] = EncryptionManager.encrypt(APP_LOCK_PASSCODE.name, passcode) + val encrypted = EncryptionManager.encrypt(APP_LOCK_PASSCODE.name, passcode) + it[APP_LOCK_PASSCODE] = encrypted } catch (e: Exception) { it.remove(APP_LOCK_PASSCODE) } } } + + fun getAppLockTimestampFlow(): Flow = + context.dataStore.data.map { it[APP_LOCK_TIMESTAMP] } + + suspend fun setAppLockTimestamp(timestamp: Long) { + context.dataStore.edit { it[APP_LOCK_TIMESTAMP] = timestamp } + } } diff --git a/app/src/main/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCase.kt b/app/src/main/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCase.kt index 699ff8beb8b..3eb3c7f0930 100644 --- a/app/src/main/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCase.kt @@ -18,12 +18,12 @@ package com.wire.android.feature import com.wire.android.datastore.GlobalDataStore -import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject +import javax.inject.Singleton -@ViewModelScoped +@Singleton class ObserveAppLockConfigUseCase @Inject constructor( private val globalDataStore: GlobalDataStore, ) { @@ -43,6 +43,6 @@ sealed class AppLockConfig(open val timeoutInSeconds: Int = DEFAULT_TIMEOUT) { data class EnforcedByTeam(override val timeoutInSeconds: Int) : AppLockConfig(timeoutInSeconds) companion object { - const val DEFAULT_TIMEOUT = 60 + const val DEFAULT_TIMEOUT = 5 } } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index 4c5557b3831..34ca9e85a0d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -66,6 +66,7 @@ import com.wire.android.ui.calling.ProximitySensorManager import com.wire.android.ui.common.topappbar.CommonTopAppBar import com.wire.android.ui.common.topappbar.CommonTopAppBarViewModel import com.wire.android.ui.destinations.ConversationScreenDestination +import com.wire.android.ui.destinations.EnterLockCodeScreenDestination import com.wire.android.ui.destinations.HomeScreenDestination import com.wire.android.ui.destinations.ImportMediaScreenDestination import com.wire.android.ui.destinations.IncomingCallScreenDestination @@ -78,6 +79,7 @@ import com.wire.android.ui.destinations.SelfUserProfileScreenDestination import com.wire.android.ui.destinations.WelcomeScreenDestination import com.wire.android.ui.home.E2EIRequiredDialog import com.wire.android.ui.home.E2EISnoozeDialog +import com.wire.android.ui.home.appLock.LockCodeTimeManager import com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel import com.wire.android.ui.snackbar.LocalSnackbarHostState import com.wire.android.ui.theme.WireTheme @@ -91,6 +93,8 @@ import com.wire.android.util.ui.updateScreenSettings import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onSubscription @@ -108,6 +112,9 @@ class WireActivity : AppCompatActivity() { @Inject lateinit var proximitySensorManager: ProximitySensorManager + @Inject + lateinit var lockCodeTimeManager: LockCodeTimeManager + private val viewModel: WireActivityViewModel by viewModels() private val featureFlagNotificationViewModel: FeatureFlagNotificationViewModel by viewModels() @@ -179,7 +186,9 @@ class WireActivity : AppCompatActivity() { setUpNavigation(navigator.navController, onComplete, scope) isLoaded = true handleScreenshotCensoring() + handleAppLock() handleDialogs(navigator::navigate) + } } } @@ -229,6 +238,17 @@ class WireActivity : AppCompatActivity() { } } + @Composable + private fun handleAppLock() { + LaunchedEffect(Unit) { + lockCodeTimeManager.shouldLock() + .filter { it } + .collectLatest { + navigationCommands.emit(NavigationCommand(EnterLockCodeScreenDestination)) + } + } + } + @Composable private fun handleDialogs(navigate: (NavigationCommand) -> Unit) { featureFlagNotificationViewModel.loadInitialSync() 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/EnterLockCodeScreen.kt new file mode 100644 index 00000000000..3f20b487c7e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/EnterLockCodeScreen.kt @@ -0,0 +1,177 @@ +/* + * 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.activity.compose.BackHandler +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 +import java.util.Locale + +@RootNavGraph +@Destination +@Composable +fun EnterLockCodeScreen( + viewModel: EnterLockScreenViewModel = hiltViewModel(), + navigator: Navigator +) { + EnterLockCodeScreenContent( + navigator = navigator, + state = viewModel.state, + scrollState = rememberScrollState(), + onPasswordChanged = viewModel::onPasswordChanged, + onContinue = viewModel::onContinue, + onBackPress = { navigator.finish() } + ) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun EnterLockCodeScreenContent( + navigator: Navigator, + state: EnterLockCodeViewState, + scrollState: ScrollState, + onPasswordChanged: (TextFieldValue) -> Unit, + onBackPress: () -> Unit, + onContinue: () -> Unit +) { + LaunchedEffect(state.done) { + if (state.done) { + navigator.navigateBack() + } + } + BackHandler { + onBackPress() + } + + WireScaffold(topBar = { + WireCenterAlignedTopAppBar( + onNavigationPressed = onBackPress, + elevation = dimensions().spacing0x, + title = stringResource(id = R.string.settings_enter_lock_screen_title) + ) + }) { 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 = when (state.error) { + 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()) + ) + 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.isUnlockEnabled + 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.settings_enter_lock_screen_unlock_button_label), + 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/EnterLockCodeViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/EnterLockCodeViewState.kt new file mode 100644 index 00000000000..d3b41ae5cc2 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/EnterLockCodeViewState.kt @@ -0,0 +1,33 @@ +/* + * 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 EnterLockCodeViewState( + val continueEnabled: Boolean = false, + val password: TextFieldValue = TextFieldValue(), + val isUnlockEnabled: Boolean = false, + val error: EnterLockCodeError = EnterLockCodeError.None, + val done: Boolean = false +) + +sealed class EnterLockCodeError { + data object None : EnterLockCodeError() + data object InvalidValue : EnterLockCodeError() +} 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/EnterLockScreenViewModel.kt new file mode 100644 index 00000000000..9b8d6abed3c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/EnterLockScreenViewModel.kt @@ -0,0 +1,83 @@ +/* + * 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 androidx.lifecycle.viewModelScope +import com.wire.android.datastore.GlobalDataStore +import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.android.util.sha256 +import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +class EnterLockScreenViewModel @Inject constructor( + private val validatePassword: ValidatePasswordUseCase, + private val globalDataStore: GlobalDataStore, + private val dispatchers: DispatcherProvider, +) : ViewModel() { + + var state: EnterLockCodeViewState by mutableStateOf(EnterLockCodeViewState()) + private set + + fun onPasswordChanged(password: TextFieldValue) { + state = state.copy( + error = EnterLockCodeError.None, + password = password + ) + state = if (validatePassword(password.text)) { + state.copy( + continueEnabled = true, + isUnlockEnabled = true + ) + } else { + state.copy( + isUnlockEnabled = false + ) + } + } + + fun onContinue() { + state = state.copy(continueEnabled = false) + // the continue button is enabled iff the password is valid + // this check is for safety only + if (!validatePassword(state.password.text)) { + state = state.copy(isUnlockEnabled = false) + } else { + viewModelScope.launch { + val storedPasscode = withContext(dispatchers.io()) { globalDataStore.getAppLockPasscodeFlow().firstOrNull() } + withContext(dispatchers.main()) { + state = if (storedPasscode == state.password.text.sha256()) { + state.copy(done = true) + } else { + state.copy(error = EnterLockCodeError.InvalidValue) + } + } + + } + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/LockCodeTimeManager.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/LockCodeTimeManager.kt new file mode 100644 index 00000000000..a8fc8c83a7b --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/LockCodeTimeManager.kt @@ -0,0 +1,79 @@ +/* + * 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 com.wire.android.datastore.GlobalDataStore +import com.wire.android.di.ApplicationScope +import com.wire.android.feature.AppLockConfig +import com.wire.android.feature.ObserveAppLockConfigUseCase +import com.wire.android.util.CurrentScreenManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.take +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LockCodeTimeManager @Inject constructor( + @ApplicationScope private val appCoroutineScope: CoroutineScope, + currentScreenManager: CurrentScreenManager, + observeAppLockConfigUseCase: ObserveAppLockConfigUseCase, + globalDataStore: GlobalDataStore, +) { + + private val lockCodeRequiredFlow = globalDataStore.getAppLockTimestampFlow().take(1) + .flatMapLatest { lastAppLockTimestamp -> + combine( + currentScreenManager.isAppVisibleFlow() + .scan(AppVisibilityTimestampData(lastAppLockTimestamp ?: -1, false)) { previousData, currentlyVisible -> + if (previousData.isAppVisible != currentlyVisible) { + val timestamp = if (!currentlyVisible) { // app moved to background + System.currentTimeMillis().also { + globalDataStore.setAppLockTimestamp(it) + } + } else previousData.timestamp + AppVisibilityTimestampData( + timestamp = timestamp, + isAppVisible = currentlyVisible + ) + } else previousData + }, + observeAppLockConfigUseCase() + ) { appVisibilityTimestampData, appLockConfig -> + appVisibilityTimestampData.isAppVisible + && appLockConfig !is AppLockConfig.Disabled + && appVisibilityTimestampData.timestamp >= 0 + && (System.currentTimeMillis() - appVisibilityTimestampData.timestamp) > (appLockConfig.timeoutInSeconds * 1000) + } + .distinctUntilChanged() + } + .shareIn(scope = appCoroutineScope, started = SharingStarted.Eagerly, replay = 1) + + fun shouldLock(): Flow = lockCodeRequiredFlow + + private data class AppVisibilityTimestampData( + val timestamp: Long, + val isAppVisible: Boolean + ) +} 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 index 4a1c0eca79f..792124ac34f 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/SetLockScreenViewModel.kt @@ -24,16 +24,19 @@ 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.util.dispatchers.DispatcherProvider import com.wire.android.util.sha256 import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject @HiltViewModel class SetLockScreenViewModel @Inject constructor( private val validatePassword: ValidatePasswordUseCase, private val globalDataStore: GlobalDataStore, + private val dispatchers: DispatcherProvider, ) : ViewModel() { var state: SetLockCodeViewState by mutableStateOf(SetLockCodeViewState()) @@ -56,15 +59,19 @@ class SetLockScreenViewModel @Inject constructor( } fun onContinue() { - viewModelScope.launch { - 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 { - globalDataStore.setAppLockPasscode(state.password.text.sha256()) - state.copy(done = true) + state = state.copy(continueEnabled = false) + // the continue button is enabled iff the password is valid + // this check is for safety only + if (!validatePassword(state.password.text)) { + state = state.copy(isPasswordValid = false) + } else { + viewModelScope.launch { + withContext(dispatchers.io()) { + globalDataStore.setAppLockPasscode(state.password.text.sha256()) + } + withContext(dispatchers.main()) { + state = state.copy(done = true) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e8ea877302e..593658854b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -891,6 +891,9 @@ The app will lock itself after a certain time of inactivity. To unlock the app you need to enter this passcode. Make sure to remember this passcode as there is no way to recover it. Passcode Set a passcode + Enter passcode to unlock Wire + Unlock + Wrong passcode Your Devices Current Device From 31b6eafd24b13425040f14a6fa9a5e377e1fd471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Wed, 27 Sep 2023 17:51:45 +0200 Subject: [PATCH 2/6] change default timeout to 60s --- .../com/wire/android/feature/ObserveAppLockConfigUseCase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCase.kt b/app/src/main/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCase.kt index 3eb3c7f0930..2ff0d39a761 100644 --- a/app/src/main/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCase.kt @@ -43,6 +43,6 @@ sealed class AppLockConfig(open val timeoutInSeconds: Int = DEFAULT_TIMEOUT) { data class EnforcedByTeam(override val timeoutInSeconds: Int) : AppLockConfig(timeoutInSeconds) companion object { - const val DEFAULT_TIMEOUT = 5 + const val DEFAULT_TIMEOUT = 60 } } From 0c05d2115ca359f881d0bd881ac2687346d4d681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Wed, 27 Sep 2023 18:10:22 +0200 Subject: [PATCH 3/6] fix detekt issues --- app/src/main/kotlin/com/wire/android/ui/WireActivity.kt | 1 - .../com/wire/android/ui/home/appLock/EnterLockCodeScreen.kt | 3 +-- .../wire/android/ui/home/appLock/EnterLockScreenViewModel.kt | 1 - .../com/wire/android/ui/home/appLock/LockCodeTimeManager.kt | 1 + 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index 34ca9e85a0d..fc5a9667b8c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -188,7 +188,6 @@ class WireActivity : AppCompatActivity() { handleScreenshotCensoring() handleAppLock() handleDialogs(navigator::navigate) - } } } 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/EnterLockCodeScreen.kt index 3f20b487c7e..233b53f7d77 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/EnterLockCodeScreen.kt @@ -128,8 +128,7 @@ fun EnterLockCodeScreenContent( 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()) 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/EnterLockScreenViewModel.kt index 9b8d6abed3c..01640a5b537 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/EnterLockScreenViewModel.kt @@ -76,7 +76,6 @@ class EnterLockScreenViewModel @Inject constructor( state.copy(error = EnterLockCodeError.InvalidValue) } } - } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/LockCodeTimeManager.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/LockCodeTimeManager.kt index a8fc8c83a7b..897c4ac230f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/LockCodeTimeManager.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/LockCodeTimeManager.kt @@ -42,6 +42,7 @@ class LockCodeTimeManager @Inject constructor( globalDataStore: GlobalDataStore, ) { + @Suppress("MagicNumber") private val lockCodeRequiredFlow = globalDataStore.getAppLockTimestampFlow().take(1) .flatMapLatest { lastAppLockTimestamp -> combine( From a99bde3d804e2dc64ee6c5729ebddf0a277b0799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Wed, 27 Sep 2023 18:23:31 +0200 Subject: [PATCH 4/6] fix tests --- .../android/ui/home/appLock/SetLockScreenViewModelTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 0c106799060..aa44652b461 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 @@ -19,6 +19,7 @@ package com.wire.android.ui.home.appLock import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.TestDispatcherProvider import com.wire.android.datastore.GlobalDataStore import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase import io.mockk.MockKAnnotations @@ -81,7 +82,8 @@ class SetLockScreenViewModelTest { private val viewModel = SetLockScreenViewModel( validatePassword, - globalDataStore + globalDataStore, + dispatchers = TestDispatcherProvider(), ) fun arrange() = this to viewModel From b16ec5fd5d99aff5d837852e9f9a4a80ccfa114d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Wed, 11 Oct 2023 10:05:00 +0200 Subject: [PATCH 5/6] add tests for the LockCodeTimeManager --- .../kotlin/com/wire/android/di/AppModule.kt | 5 + .../ui/home/appLock/LockCodeTimeManager.kt | 7 +- .../home/appLock/LockCodeTimeManagerTest.kt | 170 ++++++++++++++++++ 3 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/appLock/LockCodeTimeManagerTest.kt diff --git a/app/src/main/kotlin/com/wire/android/di/AppModule.kt b/app/src/main/kotlin/com/wire/android/di/AppModule.kt index 3fdc3230101..6395f4ca06f 100644 --- a/app/src/main/kotlin/com/wire/android/di/AppModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/AppModule.kt @@ -27,6 +27,7 @@ import android.media.MediaPlayer import androidx.core.app.NotificationManagerCompat import com.wire.android.BuildConfig import com.wire.android.mapper.MessageResourceProvider +import com.wire.android.ui.home.appLock.CurrentTimestampProvider import com.wire.android.util.dispatchers.DefaultDispatcherProvider import com.wire.android.util.dispatchers.DispatcherProvider import dagger.Module @@ -79,4 +80,8 @@ object AppModule { ) } } + + @Singleton + @Provides + fun provideCurrentTimestampProvider(): CurrentTimestampProvider = { System.currentTimeMillis() } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/LockCodeTimeManager.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/LockCodeTimeManager.kt index 897c4ac230f..5e79f2ff473 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/LockCodeTimeManager.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/LockCodeTimeManager.kt @@ -40,6 +40,7 @@ class LockCodeTimeManager @Inject constructor( currentScreenManager: CurrentScreenManager, observeAppLockConfigUseCase: ObserveAppLockConfigUseCase, globalDataStore: GlobalDataStore, + currentTimestamp: CurrentTimestampProvider, ) { @Suppress("MagicNumber") @@ -50,7 +51,7 @@ class LockCodeTimeManager @Inject constructor( .scan(AppVisibilityTimestampData(lastAppLockTimestamp ?: -1, false)) { previousData, currentlyVisible -> if (previousData.isAppVisible != currentlyVisible) { val timestamp = if (!currentlyVisible) { // app moved to background - System.currentTimeMillis().also { + currentTimestamp().also { globalDataStore.setAppLockTimestamp(it) } } else previousData.timestamp @@ -65,7 +66,7 @@ class LockCodeTimeManager @Inject constructor( appVisibilityTimestampData.isAppVisible && appLockConfig !is AppLockConfig.Disabled && appVisibilityTimestampData.timestamp >= 0 - && (System.currentTimeMillis() - appVisibilityTimestampData.timestamp) > (appLockConfig.timeoutInSeconds * 1000) + && (currentTimestamp() - appVisibilityTimestampData.timestamp) > (appLockConfig.timeoutInSeconds * 1000) } .distinctUntilChanged() } @@ -78,3 +79,5 @@ class LockCodeTimeManager @Inject constructor( val isAppVisible: Boolean ) } + +typealias CurrentTimestampProvider = () -> Long diff --git a/app/src/test/kotlin/com/wire/android/ui/home/appLock/LockCodeTimeManagerTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/appLock/LockCodeTimeManagerTest.kt new file mode 100644 index 00000000000..47f04b96029 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/appLock/LockCodeTimeManagerTest.kt @@ -0,0 +1,170 @@ +/* + * 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 com.wire.android.datastore.GlobalDataStore +import com.wire.android.feature.AppLockConfig +import com.wire.android.feature.ObserveAppLockConfigUseCase +import com.wire.android.util.CurrentScreenManager +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Test + +class LockCodeTimeManagerTest { + + private val dispatcher = StandardTestDispatcher() + + private fun testStopAndStart(appLockConfig: AppLockConfig, delay: Long, expected: Boolean) = + runTest(dispatcher) { + val (arrangement, manager) = Arrangement(dispatcher) + .withAppLockConfig(appLockConfig) + .withIsAppVisible(true) + .arrange() + advanceUntilIdle() + arrangement.withIsAppVisible(false) + advanceTimeBy(delay) + arrangement.withIsAppVisible(true) + advanceUntilIdle() + val result = manager.shouldLock().first() + assertEquals(expected, result) + } + + private fun AppLockConfig.timeoutInMillis(): Long = this.timeoutInSeconds * 1000L + + @Test + fun givenLockEnabledAndAppOpen_whenAppClosedAndOpenedAgainBeforeLockTimeout_thenDoNotRequirePasscode() = + testStopAndStart(AppLockConfig.Enabled, AppLockConfig.Enabled.timeoutInMillis() - 100L, false) + + @Test + fun givenLockEnabledAndAppOpen_whenAppClosedAndOpenedAgainAfterLockTimeout_thenRequirePasscode() = + testStopAndStart(AppLockConfig.Enabled, AppLockConfig.Enabled.timeoutInMillis() + 100L, true) + + @Test + fun givenLockDisabledAndAppOpen_whenAppClosedAndOpenedAgainBeforeLockTimeout_thenDoNotRequirePasscode() = + testStopAndStart(AppLockConfig.Disabled, AppLockConfig.Disabled.timeoutInMillis() - 100L, false) + + @Test + fun givenLockDisabledAndAppOpen_whenAppClosedAndOpenedAgainAfterLockTimeout_thenDoNotRequirePasscode() = + testStopAndStart(AppLockConfig.Disabled, AppLockConfig.Disabled.timeoutInMillis() + 100L, false) + + private fun testStart(appLockConfig: AppLockConfig, withInitialTimestamp: Boolean, delay: Long, expected: Boolean) = + runTest(dispatcher) { + val (arrangement, manager) = Arrangement(dispatcher) + .withInitialAppLockTimestamp(if (withInitialTimestamp) dispatcher.scheduler.currentTime else -1) + .withAppLockConfig(appLockConfig) + .withIsAppVisible(false) + .arrange() + advanceUntilIdle() + advanceTimeBy(delay) + arrangement.withIsAppVisible(true) + advanceUntilIdle() + val result = manager.shouldLock().first() + assertEquals(expected, result) + } + + @Test + fun givenLockEnabledAndNoInitialTimestamp_whenAppOpenedBeforeLockTimeout_thenDoNotRequirePasscode() = + testStart(AppLockConfig.Enabled, false, AppLockConfig.Enabled.timeoutInMillis() - 100, false) + + @Test + fun givenLockEnabledAndNoInitialTimestamp_whenAppOpenedAfterLockTimeout_thenDoNotRequirePasscode() = + testStart(AppLockConfig.Enabled, false, AppLockConfig.Enabled.timeoutInMillis() + 100, false) + + @Test + fun givenLockEnabledAndInitialTimestamp_whenAppOpenedBeforeLockTimeout_thenDoNotRequirePasscode() = + testStart(AppLockConfig.Enabled, true, AppLockConfig.Enabled.timeoutInMillis() - 100, false) + + @Test + fun givenLockEnabledAndInitialTimestamp_whenAppOpenedAfterLockTimeout_thenRequirePasscode() = + testStart(AppLockConfig.Enabled, true, AppLockConfig.Enabled.timeoutInMillis() + 100, true) + + @Test + fun givenLockDisabledAndNoInitialTimestamp_whenAppOpenedBeforeLockTimeout_thenDoNotRequirePasscode() = + testStart(AppLockConfig.Disabled, false, AppLockConfig.Disabled.timeoutInMillis() - 100, false) + + @Test + fun givenLockDisabledAndNoInitialTimestamp_whenAppOpenedAfterLockTimeout_thenDoNotRequirePasscode() = + testStart(AppLockConfig.Disabled, false, AppLockConfig.Disabled.timeoutInMillis() + 100, false) + + @Test + fun givenLockDisabledAndInitialTimestamp_whenAppOpenedBeforeLockTimeout_thenDoNotRequirePasscode() = + testStart(AppLockConfig.Disabled, true, AppLockConfig.Disabled.timeoutInMillis() - 100, false) + + @Test + fun givenLockDisabledAndInitialTimestamp_whenAppOpenedAfterLockTimeout_thenDoNotRequirePasscode() = + testStart(AppLockConfig.Disabled, true, AppLockConfig.Disabled.timeoutInMillis() + 100, false) + + class Arrangement(dispatcher: TestDispatcher) { + + @MockK + private lateinit var currentScreenManager: CurrentScreenManager + + @MockK + private lateinit var observeAppLockConfigUseCase: ObserveAppLockConfigUseCase + + @MockK + private lateinit var globalDataStore: GlobalDataStore + + private val lockCodeTimeManager by lazy { + LockCodeTimeManager( + CoroutineScope(dispatcher), + currentScreenManager, + observeAppLockConfigUseCase, + globalDataStore, + dispatcher.scheduler::currentTime + ) + } + + private val isAppVisibleStateFlow = MutableStateFlow(false) + private val appLockConfigStateFlow = MutableStateFlow(AppLockConfig.Disabled) + + fun arrange() = this to lockCodeTimeManager + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + withInitialAppLockTimestamp(-1L) + coEvery { globalDataStore.setAppLockTimestamp(any()) } returns Unit + } + + fun withInitialAppLockTimestamp(value: Long = -1L): Arrangement = apply { + every { globalDataStore.getAppLockTimestampFlow() } returns flowOf(value) + } + + fun withIsAppVisible(value: Boolean): Arrangement = apply { + isAppVisibleStateFlow.value = value + every { currentScreenManager.isAppVisibleFlow() } returns isAppVisibleStateFlow + } + + fun withAppLockConfig(value: AppLockConfig): Arrangement = apply { + appLockConfigStateFlow.value = value + every { observeAppLockConfigUseCase() } returns appLockConfigStateFlow + } + } +} From 7723adc5428426acfa5eff78437354414f487872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Wed, 11 Oct 2023 11:22:19 +0200 Subject: [PATCH 6/6] change unit test jdk distribution --- .github/workflows/gradle-run-unit-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gradle-run-unit-tests.yml b/.github/workflows/gradle-run-unit-tests.yml index c3f74c76634..d6a3b19f452 100644 --- a/.github/workflows/gradle-run-unit-tests.yml +++ b/.github/workflows/gradle-run-unit-tests.yml @@ -28,7 +28,7 @@ jobs: uses: actions/setup-java@v3 with: java-version: '17' - distribution: 'adopt' + distribution: 'temurin' cache: gradle - name: Validate Gradle wrapper