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 abd7cca06aa..bc8912e5ad1 100644 --- a/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt +++ b/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking import javax.inject.Inject import javax.inject.Singleton @@ -47,15 +48,20 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex private const val PREFERENCES_NAME = "global_data" // keys - private val SHOW_CALLING_DOUBLE_TAP_TOAST = booleanPreferencesKey("show_calling_double_tap_toast_") + private val SHOW_CALLING_DOUBLE_TAP_TOAST = + booleanPreferencesKey("show_calling_double_tap_toast_") private val MIGRATION_COMPLETED = booleanPreferencesKey("migration_completed") private val WELCOME_SCREEN_PRESENTED = booleanPreferencesKey("welcome_screen_presented") private val IS_LOGGING_ENABLED = booleanPreferencesKey("is_logging_enabled") - private val IS_ENCRYPTED_PROTEUS_STORAGE_ENABLED = booleanPreferencesKey("is_encrypted_proteus_storage_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_THEME_OPTION = stringPreferencesKey("app_theme_option") + private val TEAM_APP_LOCK_PASSCODE = stringPreferencesKey("team_app_lock_passcode") + val APP_THEME_OPTION = stringPreferencesKey("app_theme_option") private val Context.dataStore: DataStore by preferencesDataStore(name = PREFERENCES_NAME) - private fun userMigrationStatusKey(userId: String): Preferences.Key = intPreferencesKey("user_migration_status_$userId") + private fun userMigrationStatusKey(userId: String): Preferences.Key = + intPreferencesKey("user_migration_status_$userId") + private fun userDoubleTapToastStatusKey(userId: String): Preferences.Key = booleanPreferencesKey("$SHOW_CALLING_DOUBLE_TAP_TOAST$userId") @@ -67,24 +73,31 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex context.dataStore.edit { it.clear() } } - private fun getBooleanPreference(key: Preferences.Key, defaultValue: Boolean): Flow = + fun getBooleanPreference(key: Preferences.Key, defaultValue: Boolean): Flow = context.dataStore.data.map { it[key] ?: defaultValue } - private fun getStringPreference(key: Preferences.Key, defaultValue: String): Flow = + private fun getStringPreference( + key: Preferences.Key, + defaultValue: String + ): Flow = context.dataStore.data.map { it[key] ?: defaultValue } fun isMigrationCompletedFlow(): Flow = getBooleanPreference(MIGRATION_COMPLETED, false) suspend fun isMigrationCompleted(): Boolean = isMigrationCompletedFlow().firstOrNull() ?: false - fun isLoggingEnabled(): Flow = getBooleanPreference(IS_LOGGING_ENABLED, BuildConfig.LOGGING_ENABLED) + fun isLoggingEnabled(): Flow = + getBooleanPreference(IS_LOGGING_ENABLED, BuildConfig.LOGGING_ENABLED) suspend fun setLoggingEnabled(enabled: Boolean) { context.dataStore.edit { it[IS_LOGGING_ENABLED] = enabled } } fun isEncryptedProteusStorageEnabled(): Flow = - getBooleanPreference(IS_ENCRYPTED_PROTEUS_STORAGE_ENABLED, BuildConfig.ENCRYPT_PROTEUS_STORAGE) + getBooleanPreference( + IS_ENCRYPTED_PROTEUS_STORAGE_ENABLED, + BuildConfig.ENCRYPT_PROTEUS_STORAGE + ) suspend fun setEncryptedProteusStorageEnabled(enabled: Boolean) { context.dataStore.edit { it[IS_ENCRYPTED_PROTEUS_STORAGE_ENABLED] = enabled } @@ -110,7 +123,10 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex when (status) { UserMigrationStatus.Completed, UserMigrationStatus.CompletedWithErrors, - UserMigrationStatus.Successfully -> setUserMigrationAppVersion(userId, BuildConfig.VERSION_CODE) + UserMigrationStatus.Successfully -> setUserMigrationAppVersion( + userId, + BuildConfig.VERSION_CODE + ) UserMigrationStatus.NoNeed, UserMigrationStatus.NotStarted -> { @@ -125,7 +141,13 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex * meaning that the user does not need to be migrated. */ fun getUserMigrationStatus(userId: String): Flow = - context.dataStore.data.map { it[userMigrationStatusKey(userId)]?.let { status -> UserMigrationStatus.fromInt(status) } } + context.dataStore.data.map { + it[userMigrationStatusKey(userId)]?.let { status -> + UserMigrationStatus.fromInt( + status + ) + } + } suspend fun setUserMigrationAppVersion(userId: String, version: Int) { context.dataStore.edit { it[userLastMigrationAppVersion(userId)] = version } @@ -141,47 +163,90 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex suspend fun getShouldShowDoubleTapToast(userId: String): Boolean = getBooleanPreference(userDoubleTapToastStatusKey(userId), true).first() - // returns a flow with decoded passcode + /** + * returns a flow with decoded passcode + */ @Suppress("TooGenericExceptionCaught") - fun getAppLockPasscodeFlow(): Flow = - context.dataStore.data.map { - it[APP_LOCK_PASSCODE]?.let { + fun getAppLockPasscodeFlow(): Flow { + val preference = if (isAppLockPasscodeSet()) APP_LOCK_PASSCODE else TEAM_APP_LOCK_PASSCODE + return context.dataStore.data.map { + it[preference]?.let { passcode -> try { - EncryptionManager.decrypt(APP_LOCK_PASSCODE.name, it) + EncryptionManager.decrypt(preference.name, passcode) } catch (e: Exception) { null } } } + } - // returns a flow only informing whether the passcode is set, without the need to decode it + /** + * returns a flow only informing whether the passcode is set, without the need to decode it + */ fun isAppLockPasscodeSetFlow(): Flow = context.dataStore.data.map { it.contains(APP_LOCK_PASSCODE) } + fun isAppLockPasscodeSet(): Boolean = runBlocking { + context.dataStore.data.map { + it.contains(APP_LOCK_PASSCODE) + }.first() + } + + fun isAppTeamPasscodeSet(): Boolean = runBlocking { + context.dataStore.data.map { + it.contains(TEAM_APP_LOCK_PASSCODE) + }.first() + } + suspend fun clearAppLockPasscode() { context.dataStore.edit { it.remove(APP_LOCK_PASSCODE) } } + suspend fun clearTeamAppLockPasscode() { + context.dataStore.edit { + it.remove(TEAM_APP_LOCK_PASSCODE) + } + } + @Suppress("TooGenericExceptionCaught") - suspend fun setAppLockPasscode(passcode: String) { + private suspend fun setAppLockPasscode( + passcode: String, + key: Preferences.Key + ) { context.dataStore.edit { try { - val encrypted = EncryptionManager.encrypt(APP_LOCK_PASSCODE.name, passcode) - it[APP_LOCK_PASSCODE] = encrypted + val encrypted = + EncryptionManager.encrypt(key.name, passcode) + it[key] = encrypted } catch (e: Exception) { - it.remove(APP_LOCK_PASSCODE) + it.remove(key) } } } + suspend fun setTeamAppLock( + passcode: String, + key: Preferences.Key = TEAM_APP_LOCK_PASSCODE + ) { + setAppLockPasscode(passcode, key) + } + + suspend fun setUserAppLock( + passcode: String, + key: Preferences.Key = APP_LOCK_PASSCODE + ) { + setAppLockPasscode(passcode, key) + } + suspend fun setThemeOption(option: ThemeOption) { context.dataStore.edit { it[APP_THEME_OPTION] = option.toString() } } - fun selectedThemeOptionFlow(): Flow = getStringPreference(APP_THEME_OPTION, ThemeOption.SYSTEM.toString()) - .map { ThemeOption.valueOf(it) } + fun selectedThemeOptionFlow(): Flow = + getStringPreference(APP_THEME_OPTION, ThemeOption.SYSTEM.toString()) + .map { ThemeOption.valueOf(it) } } diff --git a/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt b/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt index b67c5ad429b..bb454292456 100644 --- a/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt @@ -391,6 +391,11 @@ class UseCaseModule { fun provideMarkGuestLinkFeatureFlagAsNotChangedUseCase(@KaliumCoreLogic coreLogic: CoreLogic, @CurrentAccount currentAccount: UserId) = coreLogic.getSessionScope(currentAccount).markGuestLinkFeatureFlagAsNotChanged + @ViewModelScoped + @Provides + fun provideMarkTeamAppLockStatusAsNotifiedUseCase(@KaliumCoreLogic coreLogic: CoreLogic, @CurrentAccount currentAccount: UserId) = + coreLogic.getSessionScope(currentAccount).markTeamAppLockStatusAsNotified + @ViewModelScoped @Provides fun provideGetOtherUserSecurityClassificationLabelUseCase( diff --git a/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt b/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt index 0ae97d2d55b..948ded9062f 100644 --- a/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt @@ -64,7 +64,9 @@ class KaliumConfigsModule { wipeOnDeviceRemoval = BuildConfig.WIPE_ON_DEVICE_REMOVAL, wipeOnRootedDevice = BuildConfig.WIPE_ON_ROOTED_DEVICE, isWebSocketEnabledByDefault = isWebsocketEnabledByDefault(context), - certPinningConfig = BuildConfig.CERTIFICATE_PINNING_CONFIG + certPinningConfig = BuildConfig.CERTIFICATE_PINNING_CONFIG, + teamAppLock = BuildConfig.TEAM_APP_LOCK, + teamAppLockTimeout = BuildConfig.TEAM_APP_LOCK_TIMEOUT ) } } 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 557a3471404..c257cb51d94 100644 --- a/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/AppLockActivity.kt @@ -31,6 +31,7 @@ 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.destinations.SetLockCodeScreenDestination import com.wire.android.ui.theme.WireTheme import dagger.hilt.android.AndroidEntryPoint @@ -46,13 +47,16 @@ class AppLockActivity : AppCompatActivity() { LocalActivity provides this ) { WireTheme { - val canAuthenticateWithBiometrics = BiometricManager - .from(this) - .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) - val navigator = rememberNavigator(this@AppLockActivity::finish) - val startDestination = + val startDestination = + if (intent.getBooleanExtra(SET_TEAM_APP_LOCK, false)) { + appLogger.i("appLock: requesting set team app lock") + SetLockCodeScreenDestination + } else { + val canAuthenticateWithBiometrics = BiometricManager + .from(this) + .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) if (canAuthenticateWithBiometrics == BiometricManager.BIOMETRIC_SUCCESS) { appLogger.i("appLock: requesting app Unlock with biometrics") AppUnlockWithBiometricsScreenDestination @@ -60,6 +64,7 @@ class AppLockActivity : AppCompatActivity() { appLogger.i("appLock: requesting app Unlock with passcode") EnterLockCodeScreenDestination } + } NavigationGraph( navigator = navigator, @@ -69,4 +74,8 @@ class AppLockActivity : AppCompatActivity() { } } } + + companion object { + const val SET_TEAM_APP_LOCK = "set_team_app_lock" + } } 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 34df999ad88..7e07ea7b00f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -263,6 +263,35 @@ class WireActivity : AppCompatActivity() { ) } + if (shouldShowTeamAppLockDialog) { + TeamAppLockFeatureFlagDialog( + isTeamAppLockEnabled = isTeamAppLockEnabled, + onConfirm = { + featureFlagNotificationViewModel.dismissTeamAppLockDialog() + if (isTeamAppLockEnabled) { + val isUserAppLockSet = + featureFlagNotificationViewModel.isUserAppLockSet() + // No need to setup another app lock if the user already has one + if (!isUserAppLockSet) { + Intent(this@WireActivity, AppLockActivity::class.java) + .apply { + putExtra(AppLockActivity.SET_TEAM_APP_LOCK, true) + }.also { + startActivity(it) + } + } else { + featureFlagNotificationViewModel.markTeamAppLockStatusAsNot() + } + } else { + with(featureFlagNotificationViewModel) { + markTeamAppLockStatusAsNot() + clearTeamAppLockPasscode() + } + } + } + ) + } + if (shouldShowSelfDeletingMessagesDialog) { SelfDeletingMessagesDialog( areSelfDeletingMessagesEnabled = areSelfDeletedMessagesEnabled, diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt index eaf1edab2e1..1b907862343 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt @@ -127,6 +127,27 @@ fun GuestRoomLinkFeatureFlagDialog( ) } +@Composable +fun TeamAppLockFeatureFlagDialog( + isTeamAppLockEnabled: Boolean, + onConfirm: () -> Unit, +) { + val text: String = + stringResource(id = if (isTeamAppLockEnabled) R.string.team_app_lock_enabled + else R.string.team_app_lock_disabled) + + WireDialog( + title = stringResource(id = R.string.team_settings_changed), + text = text, + onDismiss = {}, + optionButton1Properties = WireDialogButtonProperties( + onClick = onConfirm, + text = stringResource(id = R.string.label_ok), + type = WireDialogButtonType.Primary, + ) + ) +} + @Composable fun UpdateAppDialog(shouldShow: Boolean, onUpdateClick: () -> Unit) { if (shouldShow) { 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 c1f1407139e..1ff2d1b52f6 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 @@ -120,7 +120,6 @@ fun WireDialog( ) } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun WireDialog( title: String, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/FeatureFlagState.kt b/app/src/main/kotlin/com/wire/android/ui/home/FeatureFlagState.kt index a3eb2ac548a..ce5d0ce6f8c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/FeatureFlagState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/FeatureFlagState.kt @@ -28,6 +28,8 @@ data class FeatureFlagState( val isFileSharingEnabledState: Boolean = true, val fileSharingRestrictedState: SharingRestrictedState? = null, val shouldShowGuestRoomLinkDialog: Boolean = false, + val shouldShowTeamAppLockDialog: Boolean = false, + val isTeamAppLockEnabled: Boolean = false, val isGuestRoomLinkEnabled: Boolean = true, val shouldShowSelfDeletingMessagesDialog: Boolean = false, val enforcedTimeoutDuration: SelfDeletionDuration = SelfDeletionDuration.None, 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 2a0d6bc9052..64b8deb6e79 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 @@ -18,6 +18,7 @@ package com.wire.android.ui.home.appLock import com.wire.android.appLogger +import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.ApplicationScope import com.wire.android.feature.AppLockConfig import com.wire.android.feature.ObserveAppLockConfigUseCase @@ -44,6 +45,7 @@ class LockCodeTimeManager @Inject constructor( @ApplicationScope private val appCoroutineScope: CoroutineScope, currentScreenManager: CurrentScreenManager, observeAppLockConfigUseCase: ObserveAppLockConfigUseCase, + globalDataStore: GlobalDataStore ) { private val isLockedFlow = MutableStateFlow(false) @@ -52,8 +54,10 @@ class LockCodeTimeManager @Inject constructor( // first, set initial value - if app lock is enabled then app needs to be locked right away runBlocking { observeAppLockConfigUseCase().firstOrNull()?.let { appLockConfig -> - if (appLockConfig !is AppLockConfig.Disabled) { - appLogger.i("$TAG app initially locked") + // app could be locked by team but user still didn't set the passcode + val isTeamAppLockSet = appLockConfig is AppLockConfig.EnforcedByTeam && + globalDataStore.isAppTeamPasscodeSet() + if (appLockConfig is AppLockConfig.Enabled || isTeamAppLockSet) { isLockedFlow.value = true } } @@ -74,8 +78,13 @@ class LockCodeTimeManager @Inject constructor( !isInForeground && !isLockedFlow.value -> flow { appLogger.i("$TAG lock is enabled and app in the background, lock count started") delay(appLockConfig.timeout.inWholeMilliseconds) - appLogger.i("$TAG lock count ended, app state is locked") - emit(true) + appLogger.i("$TAG lock count ended, app state should be locked if passcode is set") + // app could be locked by team but user still didn't set the passcode + val isTeamAppLockSet = appLockConfig is AppLockConfig.EnforcedByTeam + && globalDataStore.isAppTeamPasscodeSet() + if (appLockConfig is AppLockConfig.Enabled || isTeamAppLockSet) { + emit(true) + } } else -> { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeScreen.kt index 1565711369b..e13be653569 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeScreen.kt @@ -104,12 +104,14 @@ fun SetLockCodeScreenContent( } } - WireScaffold(topBar = { - WireCenterAlignedTopAppBar( - onNavigationPressed = onBackPress, - elevation = dimensions().spacing0x, - title = stringResource(id = R.string.settings_set_lock_screen_title) - ) + WireScaffold( + snackbarHost = {}, + topBar = { + WireCenterAlignedTopAppBar( + onNavigationPressed = onBackPress, + elevation = dimensions().spacing0x, + title = stringResource(id = R.string.settings_set_lock_screen_title) + ) }) { internalPadding -> Column( modifier = Modifier diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeViewState.kt index cb4dbfbbc70..6e52106d329 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockCodeViewState.kt @@ -27,5 +27,6 @@ data class SetLockCodeViewState( val password: TextFieldValue = TextFieldValue(), val passwordValidation: ValidatePasswordResult = ValidatePasswordResult.Invalid(), val timeout: Duration = DEFAULT_TIMEOUT, + val isAppLockByUser: Boolean = true, val done: Boolean = false ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockScreenViewModel.kt index f07dddfcfd2..ced89271034 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/set/SetLockScreenViewModel.kt @@ -24,9 +24,11 @@ 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.feature.AppLockConfig import com.wire.android.feature.ObserveAppLockConfigUseCase import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.sha256 +import com.wire.kalium.logic.feature.applock.MarkTeamAppLockStatusAsNotifiedUseCase import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.collectLatest @@ -40,6 +42,7 @@ class SetLockScreenViewModel @Inject constructor( private val globalDataStore: GlobalDataStore, private val dispatchers: DispatcherProvider, private val observeAppLockConfigUseCase: ObserveAppLockConfigUseCase, + private val markTeamAppLockStatusAsNotified: MarkTeamAppLockStatusAsNotifiedUseCase ) : ViewModel() { var state: SetLockCodeViewState by mutableStateOf(SetLockCodeViewState()) @@ -49,7 +52,10 @@ class SetLockScreenViewModel @Inject constructor( viewModelScope.launch { observeAppLockConfigUseCase() .collectLatest { - state = state.copy(timeout = it.timeout) + state = state.copy( + timeout = it.timeout, + isAppLockByUser = it !is AppLockConfig.EnforcedByTeam + ) } } } @@ -75,7 +81,14 @@ class SetLockScreenViewModel @Inject constructor( if (it.isValid) { viewModelScope.launch { withContext(dispatchers.io()) { - globalDataStore.setAppLockPasscode(state.password.text.sha256()) + with(globalDataStore) { + if (state.isAppLockByUser) { + setUserAppLock(state.password.text.sha256()) + } else { + setTeamAppLock(state.password.text.sha256()) + } + markTeamAppLockStatusAsNotified() + } } withContext(dispatchers.main()) { state = state.copy(done = true) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModel.kt index 472cf020854..f6d28386188 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModel.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.appLogger +import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.KaliumCoreLogic import com.wire.android.ui.home.FeatureFlagState import com.wire.android.ui.home.conversations.selfdeletion.SelfDeletionMapper.toSelfDeletionDuration @@ -39,14 +40,18 @@ import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.session.CurrentSessionUseCase import com.wire.kalium.logic.feature.user.E2EIRequiredResult import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("TooManyFunctions") @HiltViewModel class FeatureFlagNotificationViewModel @Inject constructor( @KaliumCoreLogic private val coreLogic: CoreLogic, - private val currentSessionUseCase: CurrentSessionUseCase + private val currentSessionUseCase: CurrentSessionUseCase, + private val globalDataStore: GlobalDataStore ) : ViewModel() { var featureFlagState by mutableStateOf(FeatureFlagState()) @@ -72,7 +77,8 @@ class FeatureFlagNotificationViewModel @Inject constructor( when (currentSessionResult) { is CurrentSessionResult.Failure -> { appLogger.e("Failure while getting current session from FeatureFlagNotificationViewModel") - featureFlagState = featureFlagState.copy(fileSharingRestrictedState = FeatureFlagState.SharingRestrictedState.NO_USER) + featureFlagState = + featureFlagState.copy(fileSharingRestrictedState = FeatureFlagState.SharingRestrictedState.NO_USER) } is CurrentSessionResult.Success -> { @@ -84,6 +90,7 @@ class FeatureFlagNotificationViewModel @Inject constructor( observeTeamSettingsSelfDeletionStatus(userId) setGuestRoomLinkFeatureFlag(userId) setE2EIRequiredState(userId) + setTeamAppLockFeatureFlag(userId) } } } @@ -113,39 +120,56 @@ class FeatureFlagNotificationViewModel @Inject constructor( private fun setGuestRoomLinkFeatureFlag(userId: UserId) { viewModelScope.launch { - coreLogic.getSessionScope(userId).observeGuestRoomLinkFeatureFlag().collect { guestRoomLinkStatus -> - guestRoomLinkStatus.isGuestRoomLinkEnabled?.let { - featureFlagState = featureFlagState.copy(isGuestRoomLinkEnabled = it) + coreLogic.getSessionScope(userId).observeGuestRoomLinkFeatureFlag() + .collect { guestRoomLinkStatus -> + guestRoomLinkStatus.isGuestRoomLinkEnabled?.let { + featureFlagState = featureFlagState.copy(isGuestRoomLinkEnabled = it) + } + guestRoomLinkStatus.isStatusChanged?.let { + featureFlagState = featureFlagState.copy(shouldShowGuestRoomLinkDialog = it) + } } - guestRoomLinkStatus.isStatusChanged?.let { - featureFlagState = featureFlagState.copy(shouldShowGuestRoomLinkDialog = it) + } + } + + private fun setTeamAppLockFeatureFlag(userId: UserId) { + viewModelScope.launch { + coreLogic.getSessionScope(userId).appLockTeamFeatureConfigObserver() + .distinctUntilChanged() + .collectLatest { + it.isStatusChanged?.let { isStatusChanged -> + featureFlagState = featureFlagState.copy( + isTeamAppLockEnabled = it.isEnabled, + shouldShowTeamAppLockDialog = isStatusChanged + ) + } } - } } } private fun observeTeamSettingsSelfDeletionStatus(userId: UserId) { viewModelScope.launch { - coreLogic.getSessionScope(userId).observeTeamSettingsSelfDeletionStatus().collect { teamSettingsSelfDeletingStatus -> - val areSelfDeletedMessagesEnabled = - teamSettingsSelfDeletingStatus.enforcedSelfDeletionTimer !is TeamSelfDeleteTimer.Disabled - val shouldShowSelfDeletingMessagesDialog = - teamSettingsSelfDeletingStatus.hasFeatureChanged ?: false - val enforcedTimeoutDuration: SelfDeletionDuration = - with(teamSettingsSelfDeletingStatus.enforcedSelfDeletionTimer) { - when (this) { - TeamSelfDeleteTimer.Disabled, - TeamSelfDeleteTimer.Enabled -> SelfDeletionDuration.None - - is TeamSelfDeleteTimer.Enforced -> this.enforcedDuration.toSelfDeletionDuration() + coreLogic.getSessionScope(userId).observeTeamSettingsSelfDeletionStatus() + .collect { teamSettingsSelfDeletingStatus -> + val areSelfDeletedMessagesEnabled = + teamSettingsSelfDeletingStatus.enforcedSelfDeletionTimer !is TeamSelfDeleteTimer.Disabled + val shouldShowSelfDeletingMessagesDialog = + teamSettingsSelfDeletingStatus.hasFeatureChanged ?: false + val enforcedTimeoutDuration: SelfDeletionDuration = + with(teamSettingsSelfDeletingStatus.enforcedSelfDeletionTimer) { + when (this) { + TeamSelfDeleteTimer.Disabled, + TeamSelfDeleteTimer.Enabled -> SelfDeletionDuration.None + + is TeamSelfDeleteTimer.Enforced -> this.enforcedDuration.toSelfDeletionDuration() + } } - } - featureFlagState = featureFlagState.copy( - areSelfDeletedMessagesEnabled = areSelfDeletedMessagesEnabled, - shouldShowSelfDeletingMessagesDialog = shouldShowSelfDeletingMessagesDialog, - enforcedTimeoutDuration = enforcedTimeoutDuration - ) - } + featureFlagState = featureFlagState.copy( + areSelfDeletedMessagesEnabled = areSelfDeletedMessagesEnabled, + shouldShowSelfDeletingMessagesDialog = shouldShowSelfDeletingMessagesDialog, + enforcedTimeoutDuration = enforcedTimeoutDuration + ) + } } } @@ -154,8 +178,14 @@ class FeatureFlagNotificationViewModel @Inject constructor( val state = when (result) { E2EIRequiredResult.NoGracePeriod.Create -> FeatureFlagState.E2EIRequired.NoGracePeriod.Create E2EIRequiredResult.NoGracePeriod.Renew -> FeatureFlagState.E2EIRequired.NoGracePeriod.Renew - is E2EIRequiredResult.WithGracePeriod.Create -> FeatureFlagState.E2EIRequired.WithGracePeriod.Create(result.timeLeft) - is E2EIRequiredResult.WithGracePeriod.Renew -> FeatureFlagState.E2EIRequired.WithGracePeriod.Renew(result.timeLeft) + is E2EIRequiredResult.WithGracePeriod.Create -> FeatureFlagState.E2EIRequired.WithGracePeriod.Create( + result.timeLeft + ) + + is E2EIRequiredResult.WithGracePeriod.Renew -> FeatureFlagState.E2EIRequired.WithGracePeriod.Renew( + result.timeLeft + ) + E2EIRequiredResult.NotRequired -> null } featureFlagState = featureFlagState.copy(e2EIRequired = state) @@ -165,7 +195,9 @@ class FeatureFlagNotificationViewModel @Inject constructor( fun dismissSelfDeletingMessagesDialog() { featureFlagState = featureFlagState.copy(shouldShowSelfDeletingMessagesDialog = false) viewModelScope.launch { - currentUserId?.let { coreLogic.getSessionScope(it).markSelfDeletingMessagesAsNotified() } + currentUserId?.let { + coreLogic.getSessionScope(it).markSelfDeletingMessagesAsNotified() + } } } @@ -178,11 +210,36 @@ class FeatureFlagNotificationViewModel @Inject constructor( fun dismissGuestRoomLinkDialog() { viewModelScope.launch { - currentUserId?.let { coreLogic.getSessionScope(it).markGuestLinkFeatureFlagAsNotChanged() } + currentUserId?.let { + coreLogic.getSessionScope(it).markGuestLinkFeatureFlagAsNotChanged() + } } featureFlagState = featureFlagState.copy(shouldShowGuestRoomLinkDialog = false) } + fun dismissTeamAppLockDialog() { + featureFlagState = featureFlagState.copy(shouldShowTeamAppLockDialog = false) + } + + fun markTeamAppLockStatusAsNot() { + viewModelScope.launch { + val currentSession = currentSessionUseCase() + if (currentSessionUseCase() is CurrentSessionResult.Success) { + coreLogic.getSessionScope( + (currentSession as CurrentSessionResult.Success).accountInfo.userId + ).markTeamAppLockStatusAsNotified() + } + } + } + + fun clearTeamAppLockPasscode() { + viewModelScope.launch { + globalDataStore.clearTeamAppLockPasscode() + } + } + + fun isUserAppLockSet() = globalDataStore.isAppLockPasscodeSet() + fun getE2EICertificate() { // TODO do the magic featureFlagState = featureFlagState.copy(e2EIRequired = null) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8b83dc36cc8..a474e5c4eb5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -958,6 +958,8 @@ Turn app lock off? You will no longer need to unlock Wire with your passcode or biometric authentication. Turn Off + App lock is now mandatory. Wire will lock itself after a certain time of inactivity. To unlock the app, you need to enter a passcode or use biometric authentication. + App lock is not mandatory any more. Wire will no longer lock itself after a certain time of inactivity. 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. diff --git a/app/src/test/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCaseTest.kt index a436d4d6c40..172470a4125 100644 --- a/app/src/test/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCaseTest.kt @@ -22,7 +22,7 @@ import com.wire.android.datastore.GlobalDataStore import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.UserSessionScope -import com.wire.kalium.logic.feature.applock.AppLockTeamConfig +import com.wire.kalium.logic.configuration.AppLockTeamConfig import com.wire.kalium.logic.feature.applock.AppLockTeamFeatureConfigObserver import com.wire.kalium.logic.feature.auth.AccountInfo import com.wire.kalium.logic.feature.session.CurrentSessionResult @@ -162,7 +162,7 @@ class ObserveAppLockConfigUseCaseTest { } returns appLockTeamFeatureConfigObserver every { appLockTeamFeatureConfigObserver.invoke() - } returns flowOf(AppLockTeamConfig(true, timeout)) + } returns flowOf(AppLockTeamConfig(true, timeout, false)) } fun withTeamAppLockDisabled() = apply { @@ -172,7 +172,7 @@ class ObserveAppLockConfigUseCaseTest { } returns appLockTeamFeatureConfigObserver every { appLockTeamFeatureConfigObserver.invoke() - } returns flowOf(AppLockTeamConfig(false, timeout)) + } returns flowOf(AppLockTeamConfig(false, timeout, false)) } fun withAppLockedByCurrentUser() = apply { 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 index 1544544c6a9..ee359a3bc4d 100644 --- 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 @@ -18,6 +18,7 @@ package com.wire.android.ui.home.appLock import app.cash.turbine.test +import com.wire.android.datastore.GlobalDataStore import com.wire.android.feature.AppLockConfig import com.wire.android.feature.ObserveAppLockConfigUseCase import com.wire.android.util.CurrentScreenManager @@ -43,11 +44,16 @@ class LockCodeTimeManagerTest { private fun AppLockConfig.timeoutInMillis(): Long = this.timeout.inWholeMilliseconds - private fun testInitialStart(appLockConfig: AppLockConfig, expected: Boolean) = + private fun testInitialStart( + appLockConfig: AppLockConfig, + isTeamPasscodeSet: Boolean = false, + expected: Boolean + ) = runTest(dispatcher) { // given val (arrangement, manager) = Arrangement(dispatcher) .withAppLockConfig(appLockConfig) + .withTeamPasscodeSet(isTeamPasscodeSet) .withIsAppVisible(false) .arrange() advanceUntilIdle() @@ -60,11 +66,27 @@ class LockCodeTimeManagerTest { @Test fun givenLockEnabled_whenAppInitiallyOpened_thenLocked() = - testInitialStart(AppLockConfig.Enabled(DEFAULT_TIMEOUT), true) + testInitialStart(AppLockConfig.Enabled(DEFAULT_TIMEOUT), expected = true) @Test fun givenLockDisabled_whenAppInitiallyOpened_thenNotLocked() = - testInitialStart(AppLockConfig.Disabled(DEFAULT_TIMEOUT), false) + testInitialStart(AppLockConfig.Disabled(DEFAULT_TIMEOUT), expected = false) + + @Test + fun givenLockForcedByTeamAndPasscodeSet_whenAppInitiallyOpened_thenAppIsLocked() = + testInitialStart( + appLockConfig = AppLockConfig.EnforcedByTeam(DEFAULT_TIMEOUT), + isTeamPasscodeSet = true, + expected = true + ) + + @Test + fun givenLockForcedByTeamAndPasscodeNotSet_whenAppInitiallyOpened_thenAppIsNotLocked() = + testInitialStart( + appLockConfig = AppLockConfig.EnforcedByTeam(DEFAULT_TIMEOUT), + isTeamPasscodeSet = false, + expected = false + ) private fun testStop(appLockConfig: AppLockConfig, delayAfterStop: Long, expected: Boolean) = runTest(dispatcher) { @@ -121,6 +143,7 @@ class LockCodeTimeManagerTest { val (arrangement, manager) = Arrangement(dispatcher) .withAppLockConfig(AppLockConfig.Enabled(timeout = 1000.seconds)) .withIsAppVisible(true) + .withTeamPasscodeSet(true) .arrange() manager.appUnlocked() advanceUntilIdle() @@ -203,7 +226,7 @@ class LockCodeTimeManagerTest { @Test fun givenLockEnabledAndAppOpenedLocked_whenAppIsUnlocked_thenNotLocked() = runTest(dispatcher) { // given - val (arrangement, manager) = Arrangement(dispatcher) + val (_, manager) = Arrangement(dispatcher) .withAppLockConfig(AppLockConfig.Enabled(DEFAULT_TIMEOUT)) .withIsAppVisible(true) .arrange() @@ -215,6 +238,38 @@ class LockCodeTimeManagerTest { assertEquals(false, manager.observeAppLock().first()) } + @Test + fun givenLockEnforcedByTeamAndPasscodeSet_whenAppIsUnlocked_thenNotLocked() = + runTest(dispatcher) { + // given + val (_, manager) = Arrangement(dispatcher) + .withAppLockConfig(AppLockConfig.EnforcedByTeam(DEFAULT_TIMEOUT)) + .withTeamPasscodeSet(true) + .withIsAppVisible(true) + .arrange() + advanceUntilIdle() + // when + advanceTimeBy(AppLockConfig.Enabled(DEFAULT_TIMEOUT).timeoutInMillis() - 100L) + // then + assertEquals(true, manager.observeAppLock().first()) + } + + @Test + fun givenLockEnforcedByTeamAndNoPasscodeSet_whenAppIsUnlocked_thenNotLocked() = + runTest(dispatcher) { + // given + val (_, manager) = Arrangement(dispatcher) + .withAppLockConfig(AppLockConfig.EnforcedByTeam(DEFAULT_TIMEOUT)) + .withTeamPasscodeSet(false) + .withIsAppVisible(true) + .arrange() + advanceUntilIdle() + // when + advanceTimeBy(AppLockConfig.Enabled(DEFAULT_TIMEOUT).timeoutInMillis() - 100L) + // then + assertEquals(false, manager.observeAppLock().first()) + } + class Arrangement(dispatcher: TestDispatcher) { @MockK @@ -223,11 +278,15 @@ class LockCodeTimeManagerTest { @MockK private lateinit var observeAppLockConfigUseCase: ObserveAppLockConfigUseCase + @MockK + private lateinit var globalDataStore: GlobalDataStore + private val lockCodeTimeManager by lazy { LockCodeTimeManager( CoroutineScope(dispatcher), currentScreenManager, observeAppLockConfigUseCase, + globalDataStore ) } @@ -250,5 +309,9 @@ class LockCodeTimeManagerTest { appLockConfigStateFlow.value = value every { observeAppLockConfigUseCase() } returns appLockConfigStateFlow } + + fun withTeamPasscodeSet(value: Boolean): Arrangement = apply { + every { globalDataStore.isAppTeamPasscodeSet() } returns value + } } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/appLock/set/SetLockScreenViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/appLock/set/SetLockScreenViewModelTest.kt index 4a9790f57e1..80ce6940bac 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/appLock/set/SetLockScreenViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/appLock/set/SetLockScreenViewModelTest.kt @@ -24,6 +24,7 @@ import com.wire.android.datastore.GlobalDataStore import com.wire.android.feature.AppLockConfig import com.wire.android.feature.ObserveAppLockConfigUseCase import com.wire.kalium.logic.feature.applock.AppLockTeamFeatureConfigObserverImpl +import com.wire.kalium.logic.feature.applock.MarkTeamAppLockStatusAsNotifiedUseCase import com.wire.kalium.logic.feature.auth.ValidatePasswordResult import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase import io.mockk.MockKAnnotations @@ -67,17 +68,24 @@ class SetLockScreenViewModelTest { } private class Arrangement { + @MockK lateinit var validatePassword: ValidatePasswordUseCase + @MockK lateinit var globalDataStore: GlobalDataStore + + @MockK + private lateinit var observeAppLockConfig: ObserveAppLockConfigUseCase + @MockK - private lateinit var observeAppLockConfigUseCase: ObserveAppLockConfigUseCase + private lateinit var markTeamAppLockStatusAsNotified: MarkTeamAppLockStatusAsNotifiedUseCase init { MockKAnnotations.init(this, relaxUnitFun = true) - coEvery { globalDataStore.setAppLockPasscode(any()) } returns Unit - coEvery { observeAppLockConfigUseCase() } returns flowOf( + coEvery { globalDataStore.setUserAppLock(any()) } returns Unit + coEvery { globalDataStore.setTeamAppLock(any()) } returns Unit + coEvery { observeAppLockConfig() } returns flowOf( AppLockConfig.Disabled(AppLockTeamFeatureConfigObserverImpl.DEFAULT_TIMEOUT) ) } @@ -94,7 +102,8 @@ class SetLockScreenViewModelTest { validatePassword, globalDataStore, TestDispatcherProvider(), - observeAppLockConfigUseCase + observeAppLockConfig, + markTeamAppLockStatusAsNotified ) fun arrange() = this to viewModel diff --git a/app/src/test/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModelTest.kt index 65553bda1b9..62fb66a6e92 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModelTest.kt @@ -1,6 +1,7 @@ package com.wire.android.ui.home.sync import com.wire.android.config.CoroutineTestExtension +import com.wire.android.datastore.GlobalDataStore import com.wire.android.framework.TestUser import com.wire.android.ui.home.FeatureFlagState import com.wire.kalium.logic.CoreLogic @@ -201,6 +202,7 @@ class FeatureFlagNotificationViewModelTest { coEvery { currentSession() } returns CurrentSessionResult.Success(AccountInfo.Valid(TestUser.USER_ID)) coEvery { coreLogic.getSessionScope(any()).observeSyncState() } returns flowOf(SyncState.Live) coEvery { coreLogic.getSessionScope(any()).observeTeamSettingsSelfDeletionStatus() } returns flowOf() + coEvery { coreLogic.getSessionScope(any()).appLockTeamFeatureConfigObserver() } returns flowOf() } @MockK @@ -218,9 +220,13 @@ class FeatureFlagNotificationViewModelTest { @MockK lateinit var markE2EIRequiredAsNotified: MarkEnablingE2EIAsNotifiedUseCase + @MockK + lateinit var globalDataStore: GlobalDataStore + val viewModel: FeatureFlagNotificationViewModel = FeatureFlagNotificationViewModel( coreLogic = coreLogic, - currentSessionUseCase = currentSession + currentSessionUseCase = currentSession, + globalDataStore = globalDataStore ) init { diff --git a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt index e15645d80be..36d8e6d45be 100644 --- a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt +++ b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt @@ -48,6 +48,8 @@ enum class FeatureConfigs(val value: String, val configType: ConfigType) { UPDATE_APP_URL("update_app_url", ConfigType.STRING), ENABLE_BLACKLIST("enable_blacklist", ConfigType.BOOLEAN), WEBSOCKET_ENABLED_BY_DEFAULT("websocket_enabled_by_default", ConfigType.BOOLEAN), + TEAM_APP_LOCK("team_app_lock", ConfigType.BOOLEAN), + TEAM_APP_LOCK_TIMEOUT("team_app_lock_timeout", ConfigType.INT), /** * Security/Cryptography stuff diff --git a/default.json b/default.json index e8631fd4e83..17e7d8c0e02 100644 --- a/default.json +++ b/default.json @@ -107,5 +107,7 @@ "default_backend_title": "wire-production", "cert_pinning_config": {}, "is_password_protected_guest_link_enabled": false, - "url_rss_release_notes": "https://medium.com/feed/wire-news/tagged/android" + "url_rss_release_notes": "https://medium.com/feed/wire-news/tagged/android", + "team_app_lock": false, + "team_app_lock_timeout": 60 } diff --git a/kalium b/kalium index 76b3f952861..313fa794cf9 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 76b3f9528612aa950f464b44ed347914ed837c92 +Subproject commit 313fa794cf98ac3812f33d75ccd7ac76f469a6b3