From d81b0005ee81fe72bf62e1d42cde79c7aa8df00d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bispo?= Date: Tue, 18 Nov 2025 16:02:35 +0000 Subject: [PATCH] [PM-27150] React to device changes on device screen unlock method (#6103) --- .../auth/datasource/disk/AuthDiskSource.kt | 2 +- .../datasource/disk/AuthDiskSourceImpl.kt | 2 +- .../repository/SettingsRepositoryImpl.kt | 2 +- .../datasource/disk/AuthDiskSourceTest.kt | 23 +- .../disk/util/FakeAuthDiskSource.kt | 2 +- .../auth/datasource/disk/AuthDiskSource.kt | 6 + .../datasource/disk/AuthDiskSourceImpl.kt | 12 + .../platform/repository/SettingsRepository.kt | 5 + .../repository/SettingsRepositoryImpl.kt | 11 + .../components/biometrics/BiometricChanges.kt | 38 ++ .../platform/feature/rootnav/RootNavScreen.kt | 15 +- .../feature/rootnav/RootNavViewModel.kt | 35 +- .../feature/settings/SettingsScreen.kt | 15 +- .../feature/settings/SettingsViewModel.kt | 43 ++ .../disk/util/FakeAuthDiskSource.kt | 12 + .../repository/SettingsRepositoryTest.kt | 15 + .../feature/rootnav/RootNavScreenTest.kt | 314 +++++++++++++++ .../feature/rootnav/RootNavViewModelTest.kt | 369 ++++++++++++++++++ .../feature/settings/SettingsScreenTest.kt | 18 + .../feature/settings/SettingsViewModelTest.kt | 20 + 20 files changed, 944 insertions(+), 15 deletions(-) create mode 100644 authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/biometrics/BiometricChanges.kt create mode 100644 authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreenTest.kt create mode 100644 authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModelTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt index f38336bac6..314dc35cb4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt @@ -211,7 +211,7 @@ interface AuthDiskSource : AppIdProvider { /** * Gets the flow for the biometrics key for the given [userId]. */ - fun getUserBiometicUnlockKeyFlow(userId: String): Flow + fun getUserBiometricUnlockKeyFlow(userId: String): Flow /** * Retrieves a pin-protected user key for the given [userId]. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt index e352529dc6..477b1e1c1c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -331,7 +331,7 @@ class AuthDiskSourceImpl( getMutableBiometricUnlockKeyFlow(userId).tryEmit(biometricsKey) } - override fun getUserBiometicUnlockKeyFlow(userId: String): Flow = + override fun getUserBiometricUnlockKeyFlow(userId: String): Flow = getMutableBiometricUnlockKeyFlow(userId) .onSubscription { emit(getUserBiometricUnlockKey(userId = userId)) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt index c09538add7..fbc5e979e4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt @@ -279,7 +279,7 @@ class SettingsRepositoryImpl( get() = activeUserId ?.let { userId -> authDiskSource - .getUserBiometicUnlockKeyFlow(userId) + .getUserBiometricUnlockKeyFlow(userId) .map { it != null } } ?: flowOf(false) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt index e737c5231e..3be18acfed 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt @@ -721,6 +721,25 @@ class AuthDiskSourceTest { assertEquals(biometricsKey, actual) } + @Test + fun `getUserBiometricUnlockKeyFlow should react to changes in getUserBiometricUnlockKey`() = + runTest { + val mockUserId = "mockUserId" + val biometricsKey = "1234" + authDiskSource.getUserBiometricUnlockKeyFlow(userId = mockUserId).test { + // The initial values of the Flow and the property are in sync + assertNull(authDiskSource.getUserBiometricUnlockKey(userId = mockUserId)) + assertNull(awaitItem()) + + // Updating the disk source updates shared preferences + authDiskSource.storeUserBiometricUnlockKey( + userId = mockUserId, + biometricsKey = biometricsKey, + ) + assertEquals(biometricsKey, awaitItem()) + } + } + @Test fun `storeUserBiometricInitVector for non-null values should update SharedPreferences`() { val biometricsInitVectorBaseKey = "bwSecureStorage:biometricInitializationVector" @@ -783,11 +802,11 @@ class AuthDiskSourceTest { @Suppress("MaxLineLength") @Test - fun `storeUserBiometricUnlockKey should update the resulting flow from getUserBiometicUnlockKeyFlow`() = + fun `storeUserBiometricUnlockKey should update the resulting flow from getUserBiometricUnlockKeyFlow`() = runTest { val topSecretKey = "topsecret" val mockUserId = "mockUserId" - authDiskSource.getUserBiometicUnlockKeyFlow(mockUserId).test { + authDiskSource.getUserBiometricUnlockKeyFlow(mockUserId).test { assertNull(awaitItem()) authDiskSource.storeUserBiometricUnlockKey( userId = mockUserId, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index a562e6d1bb..c3000891c7 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -274,7 +274,7 @@ class FakeAuthDiskSource : AuthDiskSource { getMutableBiometricUnlockKeyFlow(userId).tryEmit(biometricsKey) } - override fun getUserBiometicUnlockKeyFlow(userId: String): Flow = + override fun getUserBiometricUnlockKeyFlow(userId: String): Flow = getMutableBiometricUnlockKeyFlow(userId) .onSubscription { emit(getUserBiometricUnlockKey(userId)) } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt index 7b77001360..6106452c06 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt @@ -1,12 +1,18 @@ package com.bitwarden.authenticator.data.auth.datasource.disk import com.bitwarden.network.provider.AppIdProvider +import kotlinx.coroutines.flow.Flow /** * Primary access point for disk information. */ interface AuthDiskSource : AppIdProvider { + /** + * Tracks the biometrics key. + */ + val userBiometricUnlockKeyFlow: Flow + /** * Retrieves the "last active time". * diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt index b387c3f207..94fa5b301b 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -1,7 +1,10 @@ package com.bitwarden.authenticator.data.auth.datasource.disk import android.content.SharedPreferences +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.data.datasource.disk.BaseEncryptedDiskSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onSubscription import java.util.UUID private const val AUTHENTICATOR_SYNC_SYMMETRIC_KEY = "authenticatorSyncSymmetricKey" @@ -20,6 +23,7 @@ class AuthDiskSourceImpl( sharedPreferences = sharedPreferences, ), AuthDiskSource { + private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow(replay = 1) override val uniqueAppId: String get() = getString(key = UNIQUE_APP_ID_KEY) ?: generateAndStoreUniqueAppId() @@ -39,6 +43,13 @@ class AuthDiskSourceImpl( override fun getUserBiometricUnlockKey(): String? = getEncryptedString(key = BIOMETRICS_UNLOCK_KEY) + override val userBiometricUnlockKeyFlow: Flow + get() = + mutableUserBiometricUnlockKeyFlow + .onSubscription { + emit(getUserBiometricUnlockKey()) + } + override fun storeUserBiometricUnlockKey( biometricsKey: String?, ) { @@ -46,6 +57,7 @@ class AuthDiskSourceImpl( key = BIOMETRICS_UNLOCK_KEY, value = biometricsKey, ) + mutableUserBiometricUnlockKeyFlow.tryEmit(biometricsKey) } override var authenticatorBridgeSymmetricSyncKey: ByteArray? diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt index e3954317fb..8a15884b0c 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt @@ -57,6 +57,11 @@ interface SettingsRepository { */ val isUnlockWithBiometricsEnabled: Boolean + /** + * Tracks whether or not biometric unlocking is enabled for the current user. + */ + val isUnlockWithBiometricsEnabledFlow: StateFlow + /** * Tracks changes to the expiration alert threshold. */ diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt index 734a637bb9..b6b347d89d 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt @@ -66,6 +66,17 @@ class SettingsRepositoryImpl( override val isUnlockWithBiometricsEnabled: Boolean get() = authDiskSource.getUserBiometricUnlockKey() != null + override val isUnlockWithBiometricsEnabledFlow: StateFlow + get() = + authDiskSource + .userBiometricUnlockKeyFlow + .map { it != null } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = isUnlockWithBiometricsEnabled, + ) + override val appThemeStateFlow: StateFlow get() = settingsDiskSource .appThemeFlow diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/biometrics/BiometricChanges.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/biometrics/BiometricChanges.kt new file mode 100644 index 0000000000..57f8747389 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/biometrics/BiometricChanges.kt @@ -0,0 +1,38 @@ +package com.bitwarden.authenticator.ui.platform.components.biometrics + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager +import com.bitwarden.ui.platform.base.util.LifecycleEventEffect + +/** + * Tracks changes in biometric support and notifies when the app resumes. + * + * This composable monitors lifecycle events and checks biometric support status + * whenever the app returns to the foreground ([Lifecycle.Event.ON_RESUME]) or + * biometric support status changes (via [LaunchedEffect]). + * + * @param biometricsManager Manager to check current biometric support status. + * @param onBiometricSupportChange Callback invoked with the current biometric + * support status. + */ +@Composable +fun BiometricChanges( + biometricsManager: BiometricsManager, + onBiometricSupportChange: (isSupported: Boolean) -> Unit, +) { + LaunchedEffect(biometricsManager.isBiometricsSupported) { + onBiometricSupportChange(biometricsManager.isBiometricsSupported) + } + + LifecycleEventEffect { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + onBiometricSupportChange(biometricsManager.isBiometricsSupported) + } + + else -> Unit + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt index c02b95ddea..9af0b080ed 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt @@ -19,12 +19,15 @@ import com.bitwarden.authenticator.ui.auth.unlock.unlockDestination import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.AuthenticatorGraphRoute import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.authenticatorGraph import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.navigateToAuthenticatorGraph +import com.bitwarden.authenticator.ui.platform.components.biometrics.BiometricChanges +import com.bitwarden.authenticator.ui.platform.composition.LocalBiometricsManager import com.bitwarden.authenticator.ui.platform.feature.splash.SplashRoute import com.bitwarden.authenticator.ui.platform.feature.splash.navigateToSplash import com.bitwarden.authenticator.ui.platform.feature.splash.splashDestination import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialRoute import com.bitwarden.authenticator.ui.platform.feature.tutorial.navigateToTutorial import com.bitwarden.authenticator.ui.platform.feature.tutorial.tutorialDestination +import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager import com.bitwarden.ui.platform.theme.NonNullEnterTransitionProvider import com.bitwarden.ui.platform.theme.NonNullExitTransitionProvider import com.bitwarden.ui.platform.theme.RootTransitionProviders @@ -41,7 +44,8 @@ import java.util.concurrent.atomic.AtomicReference fun RootNavScreen( viewModel: RootNavViewModel = hiltViewModel(), navController: NavHostController = rememberNavController(), - onSplashScreenRemoved: () -> Unit, + biometricsManager: BiometricsManager = LocalBiometricsManager.current, + onSplashScreenRemoved: () -> Unit = {}, ) { val state by viewModel.stateFlow.collectAsState() val previousStateReference = remember { AtomicReference(state) } @@ -61,6 +65,15 @@ fun RootNavScreen( .launchIn(this) } + BiometricChanges( + biometricsManager = biometricsManager, + onBiometricSupportChange = remember(viewModel) { + { + viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it)) + } + }, + ) + NavHost( navController = navController, startDestination = SplashRoute, diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt index 53280c50d6..29c6746e55 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -46,7 +46,7 @@ class RootNavViewModel @Inject constructor( } is RootNavAction.Internal.HasSeenWelcomeTutorialChange -> { - handleHasSeenWelcomeTutorialChange(action.hasSeenWelcomeGuide) + handleHasSeenWelcomeTutorialChange(action) } RootNavAction.Internal.TutorialFinished -> { @@ -60,6 +60,10 @@ class RootNavViewModel @Inject constructor( RootNavAction.Internal.AppUnlocked -> { handleAppUnlocked() } + + is RootNavAction.BiometricSupportChanged -> { + handleBiometricSupportChanged(action) + } } } @@ -67,11 +71,14 @@ class RootNavViewModel @Inject constructor( authRepository.updateLastActiveTime() } - private fun handleHasSeenWelcomeTutorialChange(hasSeenWelcomeGuide: Boolean) { - settingsRepository.hasSeenWelcomeTutorial = hasSeenWelcomeGuide - if (hasSeenWelcomeGuide) { + private fun handleHasSeenWelcomeTutorialChange( + action: RootNavAction.Internal.HasSeenWelcomeTutorialChange, + ) { + settingsRepository.hasSeenWelcomeTutorial = action.hasSeenWelcomeGuide + if (action.hasSeenWelcomeGuide) { if (settingsRepository.isUnlockWithBiometricsEnabled && - biometricsEncryptionManager.isBiometricIntegrityValid()) { + biometricsEncryptionManager.isBiometricIntegrityValid() + ) { mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Locked) } } else { mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) } @@ -99,6 +106,19 @@ class RootNavViewModel @Inject constructor( it.copy(navState = RootNavState.NavState.Unlocked) } } + + private fun handleBiometricSupportChanged( + action: RootNavAction.BiometricSupportChanged, + ) { + if (!action.isBiometricsSupported) { + settingsRepository.clearBiometricsKey() + + // If currently locked, navigate to unlocked since biometrics are no longer available + if (mutableStateFlow.value.navState is RootNavState.NavState.Locked) { + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) } + } + } + } } /** @@ -153,6 +173,11 @@ sealed class RootNavAction { */ data object BackStackUpdate : RootNavAction() + /** + * Indicates an update on device biometrics support. + */ + data class BiometricSupportChanged(val isBiometricsSupported: Boolean) : RootNavAction() + /** * Models actions the [RootNavViewModel] itself may send. */ diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index 192d29cd7c..9a5d9331ce 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.authenticator.ui.platform.components.biometrics.BiometricChanges import com.bitwarden.authenticator.ui.platform.composition.LocalBiometricsManager import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption @@ -134,6 +135,15 @@ fun SettingsScreen( } } + BiometricChanges( + biometricsManager = biometricsManager, + onBiometricSupportChange = remember(viewModel) { + { + viewModel.trySendAction(SettingsAction.BiometricSupportChanged(it)) + } + }, + ) + BitwardenScaffold( modifier = Modifier .fillMaxSize() @@ -287,8 +297,7 @@ private fun SecuritySettings( ) Spacer(modifier = Modifier.height(8.dp)) - val hasBiometrics = biometricsManager.isBiometricsSupported - if (hasBiometrics) { + if (state.hasBiometricsSupport) { UnlockWithBiometricsRow( modifier = Modifier .testTag("UnlockWithBiometricsSwitch") @@ -302,7 +311,7 @@ private fun SecuritySettings( ScreenCaptureRow( currentValue = state.allowScreenCapture, - cardStyle = if (hasBiometrics) { + cardStyle = if (state.hasBiometricsSupport) { CardStyle.Bottom } else { CardStyle.Full diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt index 63881754d9..2d2ee3972e 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt @@ -81,6 +81,11 @@ class SettingsViewModel @Inject constructor( .map { SettingsAction.Internal.DefaultSaveOptionUpdated(it) } .onEach(::sendAction) .launchIn(viewModelScope) + settingsRepository + .isUnlockWithBiometricsEnabledFlow + .map { SettingsAction.Internal.UnlockWithBiometricsUpdated(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: SettingsAction) { @@ -118,6 +123,30 @@ class SettingsViewModel @Inject constructor( } is SettingsAction.Internal.DynamicColorsUpdated -> handleDynamicColorsUpdated(action) + + is SettingsAction.Internal.UnlockWithBiometricsUpdated -> { + handleUnlockWithBiometricsUpdated(action) + } + + is SettingsAction.BiometricSupportChanged -> { + handleBiometricSupportChanged(action) + } + } + } + + private fun handleBiometricSupportChanged(action: SettingsAction.BiometricSupportChanged) { + mutableStateFlow.update { + it.copy(hasBiometricsSupport = action.isBiometricsSupported) + } + } + + private fun handleUnlockWithBiometricsUpdated( + action: SettingsAction.Internal.UnlockWithBiometricsUpdated, + ) { + mutableStateFlow.update { + it.copy( + isUnlockWithBiometricsEnabled = action.isEnabled, + ) } } @@ -385,6 +414,7 @@ class SettingsViewModel @Inject constructor( showSyncWithBitwarden = shouldShowSyncWithBitwarden, showDefaultSaveOptionRow = shouldShowDefaultSaveOption, allowScreenCapture = isScreenCaptureAllowed, + hasBiometricsSupport = true, ) } } @@ -398,6 +428,7 @@ data class SettingsState( val appearance: Appearance, val defaultSaveOption: DefaultSaveOption, val isUnlockWithBiometricsEnabled: Boolean, + val hasBiometricsSupport: Boolean, val isSubmitCrashLogsEnabled: Boolean, val showSyncWithBitwarden: Boolean, val showDefaultSaveOptionRow: Boolean, @@ -504,6 +535,11 @@ sealed class SettingsAction( ) : Dialog() } + /** + * Indicates an update on device biometrics support. + */ + data class BiometricSupportChanged(val isBiometricsSupported: Boolean) : SettingsAction() + /** * Models actions for the Security section of settings. */ @@ -648,5 +684,12 @@ sealed class SettingsAction( data class DynamicColorsUpdated( val isEnabled: Boolean, ) : SettingsAction() + + /** + * Indicates that the biometric state on disk was updated. + */ + data class UnlockWithBiometricsUpdated( + val isEnabled: Boolean, + ) : SettingsAction() } } diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index 7dfe7587af..7a36a3bfcd 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -1,12 +1,16 @@ package com.bitwarden.authenticator.data.auth.datasource.disk.util import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onSubscription import java.util.UUID class FakeAuthDiskSource : AuthDiskSource { private var lastActiveTimeMillis: Long? = null private var userBiometricUnlockKey: String? = null + private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow(replay = 1) override val uniqueAppId: String get() = UUID.randomUUID().toString() @@ -19,7 +23,15 @@ class FakeAuthDiskSource : AuthDiskSource { override fun getUserBiometricUnlockKey(): String? = userBiometricUnlockKey + override val userBiometricUnlockKeyFlow: Flow + get() = + mutableUserBiometricUnlockKeyFlow + .onSubscription { + emit(getUserBiometricUnlockKey()) + } + override fun storeUserBiometricUnlockKey(biometricsKey: String?) { + mutableUserBiometricUnlockKeyFlow.tryEmit(biometricsKey) this@FakeAuthDiskSource.userBiometricUnlockKey = biometricsKey } diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt index c7238845f3..fa5e0d8808 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt @@ -165,4 +165,19 @@ class SettingsRepositoryTest { settingsRepository.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3") verify { settingsDiskSource.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3") } } + + @Test + fun `isUnlockWithBiometricsEnabledFlow should react to changes in AuthDiskSource`() = runTest { + settingsRepository.isUnlockWithBiometricsEnabledFlow.test { + assertFalse(awaitItem()) + authDiskSource.storeUserBiometricUnlockKey( + biometricsKey = "biometricsKey", + ) + assertTrue(awaitItem()) + authDiskSource.storeUserBiometricUnlockKey( + biometricsKey = null, + ) + assertFalse(awaitItem()) + } + } } diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreenTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreenTest.kt new file mode 100644 index 0000000000..e2e1a98ea0 --- /dev/null +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -0,0 +1,314 @@ +package com.bitwarden.authenticator.ui.platform.feature.rootnav + +import androidx.navigation.navOptions +import com.bitwarden.authenticator.ui.auth.unlock.UnlockRoute +import com.bitwarden.authenticator.ui.authenticator.feature.navbar.AuthenticatorNavbarRoute +import com.bitwarden.authenticator.ui.platform.base.AuthenticatorComposeTest +import com.bitwarden.authenticator.ui.platform.feature.splash.SplashRoute +import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialRoute +import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager +import com.bitwarden.ui.platform.base.createMockNavHostController +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test + +class RootNavScreenTest : AuthenticatorComposeTest() { + + private var onSplashScreenRemovedCalled = false + + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + + private val viewModel: RootNavViewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns emptyFlow() + every { trySendAction(any()) } just runs + } + + private val navController = createMockNavHostController() + + private val expectedNavOptions = navOptions { + // When changing root navigation state, pop everything else off the back stack: + popUpTo(id = navController.graph.id) { + inclusive = false + saveState = false + } + launchSingleTop = true + restoreState = false + } + + private val biometricsManager: BiometricsManager = mockk { + every { isBiometricsSupported } returns true + } + + @Before + fun setup() { + onSplashScreenRemovedCalled = false + setContent( + biometricsManager = biometricsManager, + ) { + RootNavScreen( + viewModel = viewModel, + navController = navController, + biometricsManager = biometricsManager, + onSplashScreenRemoved = { onSplashScreenRemovedCalled = true }, + ) + } + } + + @Test + fun `when navState is Splash should show splash screen`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Splash, + ) + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = SplashRoute, + navOptions = expectedNavOptions, + ) + } + assertFalse(onSplashScreenRemovedCalled) + } + } + + @Test + fun `when navState is Tutorial should show tutorial screen`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Tutorial, + ) + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = TutorialRoute, + navOptions = expectedNavOptions, + ) + } + assertTrue(onSplashScreenRemovedCalled) + } + } + + @Test + fun `when navState is Locked should show unlock screen`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Locked, + ) + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = UnlockRoute, + navOptions = expectedNavOptions, + ) + } + assertTrue(onSplashScreenRemovedCalled) + } + } + + @Test + fun `when navState is Unlocked should show authenticator graph`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Unlocked, + ) + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = AuthenticatorNavbarRoute, + navOptions = expectedNavOptions, + ) + } + assertTrue(onSplashScreenRemovedCalled) + } + } + + @Test + fun `onSplashScreenRemoved should be called when navState changes from Splash`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Splash, + ) + + composeTestRule.runOnIdle { + assertFalse(onSplashScreenRemovedCalled) + } + + mutableStateFlow.update { + it.copy(navState = RootNavState.NavState.Tutorial) + } + + composeTestRule.runOnIdle { + assertTrue(onSplashScreenRemovedCalled) + } + } + + @Test + fun `onSplashScreenRemoved should not be called when navState is Splash`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Splash, + ) + + composeTestRule.runOnIdle { + assertFalse(onSplashScreenRemovedCalled) + } + } + + @Test + fun `navigation should handle Splash to Tutorial transition`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Splash, + ) + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = SplashRoute, + navOptions = expectedNavOptions, + ) + } + } + + mutableStateFlow.update { + it.copy(navState = RootNavState.NavState.Tutorial) + } + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = TutorialRoute, + navOptions = expectedNavOptions, + ) + } + } + } + + @Test + fun `navigation should handle Tutorial to Unlocked transition`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Tutorial, + ) + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = TutorialRoute, + navOptions = expectedNavOptions, + ) + } + } + + mutableStateFlow.update { + it.copy(navState = RootNavState.NavState.Unlocked) + } + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = AuthenticatorNavbarRoute, + navOptions = expectedNavOptions, + ) + } + } + } + + @Test + fun `navigation should handle Splash to Locked transition`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Splash, + ) + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = SplashRoute, + navOptions = expectedNavOptions, + ) + } + } + + mutableStateFlow.update { + it.copy(navState = RootNavState.NavState.Locked) + } + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = UnlockRoute, + navOptions = expectedNavOptions, + ) + } + } + } + + @Test + fun `navigation should handle Locked to Unlocked transition`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Locked, + ) + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = UnlockRoute, + navOptions = expectedNavOptions, + ) + } + } + + mutableStateFlow.update { + it.copy(navState = RootNavState.NavState.Unlocked) + } + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = AuthenticatorNavbarRoute, + navOptions = expectedNavOptions, + ) + } + } + } + + @Test + fun `navigation should handle Splash to Unlocked transition`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Splash, + ) + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = SplashRoute, + navOptions = expectedNavOptions, + ) + } + } + + mutableStateFlow.update { + it.copy(navState = RootNavState.NavState.Unlocked) + } + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = AuthenticatorNavbarRoute, + navOptions = expectedNavOptions, + ) + } + } + } +} + +private val DEFAULT_STATE = RootNavState( + hasSeenWelcomeGuide = false, + navState = RootNavState.NavState.Splash, +) diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModelTest.kt new file mode 100644 index 0000000000..c691abb70d --- /dev/null +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -0,0 +1,369 @@ +package com.bitwarden.authenticator.ui.platform.feature.rootnav + +import app.cash.turbine.test +import com.bitwarden.authenticator.data.auth.repository.AuthRepository +import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.ui.platform.base.BaseViewModelTest +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class RootNavViewModelTest : BaseViewModelTest() { + + private val mutableHasSeenWelcomeTutorialFlow = MutableStateFlow(false) + private val authRepository: AuthRepository = mockk { + every { updateLastActiveTime() } just runs + } + private val settingsRepository: SettingsRepository = mockk { + every { hasSeenWelcomeTutorial } returns false + every { hasSeenWelcomeTutorial = any() } just runs + every { hasSeenWelcomeTutorialFlow } returns mutableHasSeenWelcomeTutorialFlow + every { isUnlockWithBiometricsEnabled } returns false + every { clearBiometricsKey() } just runs + } + private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk() + + @Test + fun `initialState should be correct when hasSeenWelcomeTutorial is false`() = runTest { + every { settingsRepository.hasSeenWelcomeTutorial } returns false + mutableHasSeenWelcomeTutorialFlow.value = false + val viewModel = createViewModel() + // When hasSeenWelcomeTutorial is false, the flow emits and triggers navigation to Tutorial + assertEquals( + RootNavState( + hasSeenWelcomeGuide = false, + navState = RootNavState.NavState.Tutorial, + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `initialState should be correct when hasSeenWelcomeTutorial is true`() = runTest { + every { settingsRepository.hasSeenWelcomeTutorial } returns true + mutableHasSeenWelcomeTutorialFlow.value = true + val viewModel = createViewModel() + // When hasSeenWelcomeTutorial is true and biometrics is not enabled, navigates to Unlocked + assertEquals( + RootNavState( + hasSeenWelcomeGuide = true, + navState = RootNavState.NavState.Unlocked, + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on BackStackUpdate should call updateLastActiveTime`() { + val viewModel = createViewModel() + viewModel.trySendAction(RootNavAction.BackStackUpdate) + verify(exactly = 1) { authRepository.updateLastActiveTime() } + } + + @Test + @Suppress("MaxLineLength") + fun `on HasSeenWelcomeTutorialChange with true and biometrics enabled and valid should navigate to Locked`() { + every { settingsRepository.isUnlockWithBiometricsEnabled } returns true + every { biometricsEncryptionManager.isBiometricIntegrityValid() } returns true + val viewModel = createViewModel() + + viewModel.trySendAction( + RootNavAction.Internal.HasSeenWelcomeTutorialChange(true), + ) + + assertEquals( + RootNavState( + hasSeenWelcomeGuide = false, + navState = RootNavState.NavState.Locked, + ), + viewModel.stateFlow.value, + ) + verify(exactly = 1) { settingsRepository.hasSeenWelcomeTutorial = true } + } + + @Test + @Suppress("MaxLineLength") + fun `on HasSeenWelcomeTutorialChange with true and biometrics enabled but invalid should navigate to Unlocked`() { + every { settingsRepository.isUnlockWithBiometricsEnabled } returns true + every { biometricsEncryptionManager.isBiometricIntegrityValid() } returns false + val viewModel = createViewModel() + + viewModel.trySendAction( + RootNavAction.Internal.HasSeenWelcomeTutorialChange(true), + ) + + assertEquals( + RootNavState( + hasSeenWelcomeGuide = false, + navState = RootNavState.NavState.Unlocked, + ), + viewModel.stateFlow.value, + ) + verify(exactly = 1) { settingsRepository.hasSeenWelcomeTutorial = true } + } + + @Test + @Suppress("MaxLineLength") + fun `on HasSeenWelcomeTutorialChange with true and biometrics disabled should navigate to Unlocked`() { + every { settingsRepository.isUnlockWithBiometricsEnabled } returns false + val viewModel = createViewModel() + + viewModel.trySendAction( + RootNavAction.Internal.HasSeenWelcomeTutorialChange(true), + ) + + assertEquals( + RootNavState( + hasSeenWelcomeGuide = false, + navState = RootNavState.NavState.Unlocked, + ), + viewModel.stateFlow.value, + ) + verify(exactly = 1) { settingsRepository.hasSeenWelcomeTutorial = true } + } + + @Test + fun `on HasSeenWelcomeTutorialChange with false should navigate to Tutorial`() { + val viewModel = createViewModel() + + viewModel.trySendAction( + RootNavAction.Internal.HasSeenWelcomeTutorialChange(false), + ) + + assertEquals( + RootNavState( + hasSeenWelcomeGuide = false, + navState = RootNavState.NavState.Tutorial, + ), + viewModel.stateFlow.value, + ) + // Called twice: once during init when flow emits, once from the action + verify(exactly = 2) { settingsRepository.hasSeenWelcomeTutorial = false } + } + + @Test + fun `on TutorialFinished should update settingsRepository and navigate to Unlocked`() { + val viewModel = createViewModel() + + viewModel.trySendAction(RootNavAction.Internal.TutorialFinished) + + assertEquals( + RootNavState( + hasSeenWelcomeGuide = false, + navState = RootNavState.NavState.Unlocked, + ), + viewModel.stateFlow.value, + ) + verify(exactly = 1) { settingsRepository.hasSeenWelcomeTutorial = true } + } + + @Test + @Suppress("MaxLineLength") + fun `on SplashScreenDismissed when hasSeenWelcomeTutorial is true and currently Splash should navigate to Unlocked`() = + runTest { + // Set hasSeenWelcomeTutorial to false initially to stay on Splash + every { settingsRepository.hasSeenWelcomeTutorial } returns false + mutableHasSeenWelcomeTutorialFlow.value = false + val viewModel = createViewModel() + + viewModel.stateFlow.test { + // Initial state - Tutorial from init flow + assertEquals( + RootNavState( + hasSeenWelcomeGuide = false, + navState = RootNavState.NavState.Tutorial, + ), + awaitItem(), + ) + + // Now change the repository value and trigger SplashScreenDismissed + every { settingsRepository.hasSeenWelcomeTutorial } returns true + + viewModel.trySendAction(RootNavAction.Internal.SplashScreenDismissed) + + // Should navigate to Unlocked based on new repository value + assertEquals( + RootNavState( + hasSeenWelcomeGuide = false, + navState = RootNavState.NavState.Unlocked, + ), + awaitItem(), + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `on SplashScreenDismissed when hasSeenWelcomeTutorial is false and currently in different state should navigate to Tutorial`() = + runTest { + // Start with hasSeenWelcomeTutorial = true to go to Unlocked + every { settingsRepository.hasSeenWelcomeTutorial } returns true + mutableHasSeenWelcomeTutorialFlow.value = true + val viewModel = createViewModel() + + viewModel.stateFlow.test { + // Initial state - Unlocked from init flow + assertEquals( + RootNavState( + hasSeenWelcomeGuide = true, + navState = RootNavState.NavState.Unlocked, + ), + awaitItem(), + ) + + // Change the repository value and trigger SplashScreenDismissed + every { settingsRepository.hasSeenWelcomeTutorial } returns false + + viewModel.trySendAction(RootNavAction.Internal.SplashScreenDismissed) + + // Should navigate to Tutorial based on new repository value + assertEquals( + RootNavState( + hasSeenWelcomeGuide = true, + navState = RootNavState.NavState.Tutorial, + ), + awaitItem(), + ) + } + } + + @Test + fun `on AppUnlocked should navigate to Unlocked`() { + val viewModel = createViewModel() + + viewModel.trySendAction(RootNavAction.Internal.AppUnlocked) + + assertEquals( + RootNavState( + hasSeenWelcomeGuide = false, + navState = RootNavState.NavState.Unlocked, + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on BiometricSupportChanged with false should clear biometrics key`() { + val viewModel = createViewModel() + + viewModel.trySendAction(RootNavAction.BiometricSupportChanged(false)) + + verify(exactly = 1) { settingsRepository.clearBiometricsKey() } + } + + @Test + fun `on BiometricSupportChanged with true should not clear biometrics key`() { + val viewModel = createViewModel() + + viewModel.trySendAction(RootNavAction.BiometricSupportChanged(true)) + + verify(exactly = 0) { settingsRepository.clearBiometricsKey() } + } + + @Test + @Suppress("MaxLineLength") + fun `on BiometricSupportChanged with false when Locked should navigate to Unlocked`() = runTest { + every { settingsRepository.hasSeenWelcomeTutorial } returns true + every { settingsRepository.isUnlockWithBiometricsEnabled } returns true + every { biometricsEncryptionManager.isBiometricIntegrityValid() } returns true + mutableHasSeenWelcomeTutorialFlow.value = true + val viewModel = createViewModel() + + // Verify initial state is Locked + assertEquals( + RootNavState( + hasSeenWelcomeGuide = true, + navState = RootNavState.NavState.Locked, + ), + viewModel.stateFlow.value, + ) + + // Send BiometricSupportChanged with false + viewModel.trySendAction(RootNavAction.BiometricSupportChanged(false)) + + // Should navigate to Unlocked and clear biometric key + assertEquals( + RootNavState( + hasSeenWelcomeGuide = true, + navState = RootNavState.NavState.Unlocked, + ), + viewModel.stateFlow.value, + ) + verify(exactly = 1) { settingsRepository.clearBiometricsKey() } + } + + @Test + @Suppress("MaxLineLength") + fun `on BiometricSupportChanged with false when not Locked should not change navigation state`() = + runTest { + every { settingsRepository.hasSeenWelcomeTutorial } returns true + mutableHasSeenWelcomeTutorialFlow.value = true + val viewModel = createViewModel() + + // Verify initial state is Unlocked (biometrics not enabled) + assertEquals( + RootNavState( + hasSeenWelcomeGuide = true, + navState = RootNavState.NavState.Unlocked, + ), + viewModel.stateFlow.value, + ) + + // Send BiometricSupportChanged with false + viewModel.trySendAction(RootNavAction.BiometricSupportChanged(false)) + + // Should remain Unlocked and clear biometric key + assertEquals( + RootNavState( + hasSeenWelcomeGuide = true, + navState = RootNavState.NavState.Unlocked, + ), + viewModel.stateFlow.value, + ) + verify(exactly = 1) { settingsRepository.clearBiometricsKey() } + } + + @Test + @Suppress("MaxLineLength") + fun `hasSeenWelcomeTutorialFlow updates should trigger HasSeenWelcomeTutorialChange action`() = + runTest { + every { settingsRepository.isUnlockWithBiometricsEnabled } returns false + val viewModel = createViewModel() + + viewModel.stateFlow.test { + // Initial emission after flow subscription - navigates to Tutorial since hasSeenWelcomeTutorial is false + assertEquals( + RootNavState( + hasSeenWelcomeGuide = false, + navState = RootNavState.NavState.Tutorial, + ), + awaitItem(), + ) + + // Update the flow value to true + mutableHasSeenWelcomeTutorialFlow.value = true + + // Should navigate to Unlocked since biometrics is not enabled + assertEquals( + RootNavState( + hasSeenWelcomeGuide = false, + navState = RootNavState.NavState.Unlocked, + ), + awaitItem(), + ) + } + } + + private fun createViewModel() = RootNavViewModel( + authRepository = authRepository, + settingsRepository = settingsRepository, + biometricsEncryptionManager = biometricsEncryptionManager, + ) +} diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt index be1e36c667..9c31d93246 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt @@ -254,6 +254,23 @@ class SettingsScreenTest : AuthenticatorComposeTest() { viewModel.trySendAction(SettingsAction.AppearanceChange.DynamicColorChange(true)) } } + + @Test + fun `Unlock with biometrics row should be hidden when hasBiometricsSupport is false`() { + mutableStateFlow.value = DEFAULT_STATE + composeTestRule + .onNodeWithText("Use your device’s lock method to unlock the app") + .assertExists() + + mutableStateFlow.update { + it.copy( + hasBiometricsSupport = false, + ) + } + composeTestRule + .onNodeWithText("Use your device’s lock method to unlock the app") + .assertDoesNotExist() + } } private val APP_LANGUAGE = AppLanguage.ENGLISH @@ -276,4 +293,5 @@ private val DEFAULT_STATE = SettingsState( .concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()), copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(), allowScreenCapture = false, + hasBiometricsSupport = true, ) diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt index dc2f625ae8..13a4c1d2fe 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt @@ -51,6 +51,7 @@ class SettingsViewModelTest : BaseViewModelTest() { private val mutableDefaultSaveOptionFlow = bufferedMutableSharedFlow() private val mutableScreenCaptureAllowedStateFlow = MutableStateFlow(false) private val mutableIsDynamicColorsEnabledFlow = MutableStateFlow(false) + private val mutableIsUnlockWithBiometricsEnabledFlow = MutableStateFlow(true) private val settingsRepository: SettingsRepository = mockk { every { appLanguage } returns APP_LANGUAGE every { appTheme } returns APP_THEME @@ -64,6 +65,7 @@ class SettingsViewModelTest : BaseViewModelTest() { every { isDynamicColorsEnabled } answers { mutableIsDynamicColorsEnabledFlow.value } every { isDynamicColorsEnabled = any() } just runs every { isDynamicColorsEnabledFlow } returns mutableIsDynamicColorsEnabledFlow + every { isUnlockWithBiometricsEnabledFlow } returns mutableIsUnlockWithBiometricsEnabledFlow } private val clipboardManager: BitwardenClipboardManager = mockk() @@ -263,6 +265,23 @@ class SettingsViewModelTest : BaseViewModelTest() { ) } + @Test + fun `on BiometricSupportChanged should update value in state`() = + runTest { + val viewModel = createViewModel() + + viewModel.trySendAction( + SettingsAction.BiometricSupportChanged(isBiometricsSupported = false), + ) + + assertEquals( + DEFAULT_STATE.copy( + hasBiometricsSupport = false, + ), + viewModel.stateFlow.value, + ) + } + private fun createViewModel( savedState: SettingsState? = DEFAULT_STATE, ) = SettingsViewModel( @@ -301,4 +320,5 @@ private val DEFAULT_STATE = SettingsState( .concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()), copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(), allowScreenCapture = false, + hasBiometricsSupport = true, )