mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 00:06:22 -06:00
[PM-27150] React to device changes on device screen unlock method (#6103)
This commit is contained in:
parent
794b27a750
commit
d81b0005ee
@ -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].
|
||||
|
||||
@ -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)) }
|
||||
|
||||
|
||||
@ -279,7 +279,7 @@ class SettingsRepositoryImpl(
|
||||
get() = activeUserId
|
||||
?.let { userId ->
|
||||
authDiskSource
|
||||
.getUserBiometicUnlockKeyFlow(userId)
|
||||
.getUserBiometricUnlockKeyFlow(userId)
|
||||
.map { it != null }
|
||||
}
|
||||
?: flowOf(false)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)) }
|
||||
|
||||
|
||||
@ -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".
|
||||
*
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user