[PM-27150] React to device changes on device screen unlock method (#6103)

This commit is contained in:
André Bispo 2025-11-18 16:02:35 +00:00 committed by GitHub
parent 794b27a750
commit d81b0005ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 944 additions and 15 deletions

View File

@ -211,7 +211,7 @@ interface AuthDiskSource : AppIdProvider {
/**
* Gets the flow for the biometrics key for the given [userId].
*/
fun getUserBiometicUnlockKeyFlow(userId: String): Flow<String?>
fun getUserBiometricUnlockKeyFlow(userId: String): Flow<String?>
/**
* Retrieves a pin-protected user key for the given [userId].

View File

@ -331,7 +331,7 @@ class AuthDiskSourceImpl(
getMutableBiometricUnlockKeyFlow(userId).tryEmit(biometricsKey)
}
override fun getUserBiometicUnlockKeyFlow(userId: String): Flow<String?> =
override fun getUserBiometricUnlockKeyFlow(userId: String): Flow<String?> =
getMutableBiometricUnlockKeyFlow(userId)
.onSubscription { emit(getUserBiometricUnlockKey(userId = userId)) }

View File

@ -279,7 +279,7 @@ class SettingsRepositoryImpl(
get() = activeUserId
?.let { userId ->
authDiskSource
.getUserBiometicUnlockKeyFlow(userId)
.getUserBiometricUnlockKeyFlow(userId)
.map { it != null }
}
?: flowOf(false)

View File

@ -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,

View File

@ -274,7 +274,7 @@ class FakeAuthDiskSource : AuthDiskSource {
getMutableBiometricUnlockKeyFlow(userId).tryEmit(biometricsKey)
}
override fun getUserBiometicUnlockKeyFlow(userId: String): Flow<String?> =
override fun getUserBiometricUnlockKeyFlow(userId: String): Flow<String?> =
getMutableBiometricUnlockKeyFlow(userId)
.onSubscription { emit(getUserBiometricUnlockKey(userId)) }

View File

@ -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<String?>
/**
* Retrieves the "last active time".
*

View File

@ -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<String?>(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<String?>
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?

View File

@ -57,6 +57,11 @@ interface SettingsRepository {
*/
val isUnlockWithBiometricsEnabled: Boolean
/**
* Tracks whether or not biometric unlocking is enabled for the current user.
*/
val isUnlockWithBiometricsEnabledFlow: StateFlow<Boolean>
/**
* Tracks changes to the expiration alert threshold.
*/

View File

@ -66,6 +66,17 @@ class SettingsRepositoryImpl(
override val isUnlockWithBiometricsEnabled: Boolean
get() = authDiskSource.getUserBiometricUnlockKey() != null
override val isUnlockWithBiometricsEnabledFlow: StateFlow<Boolean>
get() =
authDiskSource
.userBiometricUnlockKeyFlow
.map { it != null }
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = isUnlockWithBiometricsEnabled,
)
override val appThemeStateFlow: StateFlow<AppTheme>
get() = settingsDiskSource
.appThemeFlow

View File

@ -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
}
}
}

View File

@ -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,

View File

@ -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.
*/

View File

@ -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

View File

@ -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()
}
}

View File

@ -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<String?>(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<String?>
get() =
mutableUserBiometricUnlockKeyFlow
.onSubscription {
emit(getUserBiometricUnlockKey())
}
override fun storeUserBiometricUnlockKey(biometricsKey: String?) {
mutableUserBiometricUnlockKeyFlow.tryEmit(biometricsKey)
this@FakeAuthDiskSource.userBiometricUnlockKey = biometricsKey
}

View File

@ -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())
}
}
}

View File

@ -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,
)

View File

@ -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,
)
}

View File

@ -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 devices lock method to unlock the app")
.assertExists()
mutableStateFlow.update {
it.copy(
hasBiometricsSupport = false,
)
}
composeTestRule
.onNodeWithText("Use your devices 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,
)

View File

@ -51,6 +51,7 @@ class SettingsViewModelTest : BaseViewModelTest() {
private val mutableDefaultSaveOptionFlow = bufferedMutableSharedFlow<DefaultSaveOption>()
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,
)