mirror of
https://github.com/bitwarden/android.git
synced 2025-12-11 04:39:19 -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].
|
* 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].
|
* Retrieves a pin-protected user key for the given [userId].
|
||||||
|
|||||||
@ -331,7 +331,7 @@ class AuthDiskSourceImpl(
|
|||||||
getMutableBiometricUnlockKeyFlow(userId).tryEmit(biometricsKey)
|
getMutableBiometricUnlockKeyFlow(userId).tryEmit(biometricsKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getUserBiometicUnlockKeyFlow(userId: String): Flow<String?> =
|
override fun getUserBiometricUnlockKeyFlow(userId: String): Flow<String?> =
|
||||||
getMutableBiometricUnlockKeyFlow(userId)
|
getMutableBiometricUnlockKeyFlow(userId)
|
||||||
.onSubscription { emit(getUserBiometricUnlockKey(userId = userId)) }
|
.onSubscription { emit(getUserBiometricUnlockKey(userId = userId)) }
|
||||||
|
|
||||||
|
|||||||
@ -279,7 +279,7 @@ class SettingsRepositoryImpl(
|
|||||||
get() = activeUserId
|
get() = activeUserId
|
||||||
?.let { userId ->
|
?.let { userId ->
|
||||||
authDiskSource
|
authDiskSource
|
||||||
.getUserBiometicUnlockKeyFlow(userId)
|
.getUserBiometricUnlockKeyFlow(userId)
|
||||||
.map { it != null }
|
.map { it != null }
|
||||||
}
|
}
|
||||||
?: flowOf(false)
|
?: flowOf(false)
|
||||||
|
|||||||
@ -721,6 +721,25 @@ class AuthDiskSourceTest {
|
|||||||
assertEquals(biometricsKey, actual)
|
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
|
@Test
|
||||||
fun `storeUserBiometricInitVector for non-null values should update SharedPreferences`() {
|
fun `storeUserBiometricInitVector for non-null values should update SharedPreferences`() {
|
||||||
val biometricsInitVectorBaseKey = "bwSecureStorage:biometricInitializationVector"
|
val biometricsInitVectorBaseKey = "bwSecureStorage:biometricInitializationVector"
|
||||||
@ -783,11 +802,11 @@ class AuthDiskSourceTest {
|
|||||||
|
|
||||||
@Suppress("MaxLineLength")
|
@Suppress("MaxLineLength")
|
||||||
@Test
|
@Test
|
||||||
fun `storeUserBiometricUnlockKey should update the resulting flow from getUserBiometicUnlockKeyFlow`() =
|
fun `storeUserBiometricUnlockKey should update the resulting flow from getUserBiometricUnlockKeyFlow`() =
|
||||||
runTest {
|
runTest {
|
||||||
val topSecretKey = "topsecret"
|
val topSecretKey = "topsecret"
|
||||||
val mockUserId = "mockUserId"
|
val mockUserId = "mockUserId"
|
||||||
authDiskSource.getUserBiometicUnlockKeyFlow(mockUserId).test {
|
authDiskSource.getUserBiometricUnlockKeyFlow(mockUserId).test {
|
||||||
assertNull(awaitItem())
|
assertNull(awaitItem())
|
||||||
authDiskSource.storeUserBiometricUnlockKey(
|
authDiskSource.storeUserBiometricUnlockKey(
|
||||||
userId = mockUserId,
|
userId = mockUserId,
|
||||||
|
|||||||
@ -274,7 +274,7 @@ class FakeAuthDiskSource : AuthDiskSource {
|
|||||||
getMutableBiometricUnlockKeyFlow(userId).tryEmit(biometricsKey)
|
getMutableBiometricUnlockKeyFlow(userId).tryEmit(biometricsKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getUserBiometicUnlockKeyFlow(userId: String): Flow<String?> =
|
override fun getUserBiometricUnlockKeyFlow(userId: String): Flow<String?> =
|
||||||
getMutableBiometricUnlockKeyFlow(userId)
|
getMutableBiometricUnlockKeyFlow(userId)
|
||||||
.onSubscription { emit(getUserBiometricUnlockKey(userId)) }
|
.onSubscription { emit(getUserBiometricUnlockKey(userId)) }
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
package com.bitwarden.authenticator.data.auth.datasource.disk
|
package com.bitwarden.authenticator.data.auth.datasource.disk
|
||||||
|
|
||||||
import com.bitwarden.network.provider.AppIdProvider
|
import com.bitwarden.network.provider.AppIdProvider
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Primary access point for disk information.
|
* Primary access point for disk information.
|
||||||
*/
|
*/
|
||||||
interface AuthDiskSource : AppIdProvider {
|
interface AuthDiskSource : AppIdProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the biometrics key.
|
||||||
|
*/
|
||||||
|
val userBiometricUnlockKeyFlow: Flow<String?>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the "last active time".
|
* Retrieves the "last active time".
|
||||||
*
|
*
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
package com.bitwarden.authenticator.data.auth.datasource.disk
|
package com.bitwarden.authenticator.data.auth.datasource.disk
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||||
import com.bitwarden.data.datasource.disk.BaseEncryptedDiskSource
|
import com.bitwarden.data.datasource.disk.BaseEncryptedDiskSource
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.onSubscription
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
private const val AUTHENTICATOR_SYNC_SYMMETRIC_KEY = "authenticatorSyncSymmetricKey"
|
private const val AUTHENTICATOR_SYNC_SYMMETRIC_KEY = "authenticatorSyncSymmetricKey"
|
||||||
@ -20,6 +23,7 @@ class AuthDiskSourceImpl(
|
|||||||
sharedPreferences = sharedPreferences,
|
sharedPreferences = sharedPreferences,
|
||||||
),
|
),
|
||||||
AuthDiskSource {
|
AuthDiskSource {
|
||||||
|
private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow<String?>(replay = 1)
|
||||||
|
|
||||||
override val uniqueAppId: String
|
override val uniqueAppId: String
|
||||||
get() = getString(key = UNIQUE_APP_ID_KEY) ?: generateAndStoreUniqueAppId()
|
get() = getString(key = UNIQUE_APP_ID_KEY) ?: generateAndStoreUniqueAppId()
|
||||||
@ -39,6 +43,13 @@ class AuthDiskSourceImpl(
|
|||||||
override fun getUserBiometricUnlockKey(): String? =
|
override fun getUserBiometricUnlockKey(): String? =
|
||||||
getEncryptedString(key = BIOMETRICS_UNLOCK_KEY)
|
getEncryptedString(key = BIOMETRICS_UNLOCK_KEY)
|
||||||
|
|
||||||
|
override val userBiometricUnlockKeyFlow: Flow<String?>
|
||||||
|
get() =
|
||||||
|
mutableUserBiometricUnlockKeyFlow
|
||||||
|
.onSubscription {
|
||||||
|
emit(getUserBiometricUnlockKey())
|
||||||
|
}
|
||||||
|
|
||||||
override fun storeUserBiometricUnlockKey(
|
override fun storeUserBiometricUnlockKey(
|
||||||
biometricsKey: String?,
|
biometricsKey: String?,
|
||||||
) {
|
) {
|
||||||
@ -46,6 +57,7 @@ class AuthDiskSourceImpl(
|
|||||||
key = BIOMETRICS_UNLOCK_KEY,
|
key = BIOMETRICS_UNLOCK_KEY,
|
||||||
value = biometricsKey,
|
value = biometricsKey,
|
||||||
)
|
)
|
||||||
|
mutableUserBiometricUnlockKeyFlow.tryEmit(biometricsKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
override var authenticatorBridgeSymmetricSyncKey: ByteArray?
|
override var authenticatorBridgeSymmetricSyncKey: ByteArray?
|
||||||
|
|||||||
@ -57,6 +57,11 @@ interface SettingsRepository {
|
|||||||
*/
|
*/
|
||||||
val isUnlockWithBiometricsEnabled: Boolean
|
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.
|
* Tracks changes to the expiration alert threshold.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -66,6 +66,17 @@ class SettingsRepositoryImpl(
|
|||||||
override val isUnlockWithBiometricsEnabled: Boolean
|
override val isUnlockWithBiometricsEnabled: Boolean
|
||||||
get() = authDiskSource.getUserBiometricUnlockKey() != null
|
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>
|
override val appThemeStateFlow: StateFlow<AppTheme>
|
||||||
get() = settingsDiskSource
|
get() = settingsDiskSource
|
||||||
.appThemeFlow
|
.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.AuthenticatorGraphRoute
|
||||||
import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.authenticatorGraph
|
import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.authenticatorGraph
|
||||||
import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.navigateToAuthenticatorGraph
|
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.SplashRoute
|
||||||
import com.bitwarden.authenticator.ui.platform.feature.splash.navigateToSplash
|
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.splash.splashDestination
|
||||||
import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialRoute
|
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.navigateToTutorial
|
||||||
import com.bitwarden.authenticator.ui.platform.feature.tutorial.tutorialDestination
|
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.NonNullEnterTransitionProvider
|
||||||
import com.bitwarden.ui.platform.theme.NonNullExitTransitionProvider
|
import com.bitwarden.ui.platform.theme.NonNullExitTransitionProvider
|
||||||
import com.bitwarden.ui.platform.theme.RootTransitionProviders
|
import com.bitwarden.ui.platform.theme.RootTransitionProviders
|
||||||
@ -41,7 +44,8 @@ import java.util.concurrent.atomic.AtomicReference
|
|||||||
fun RootNavScreen(
|
fun RootNavScreen(
|
||||||
viewModel: RootNavViewModel = hiltViewModel(),
|
viewModel: RootNavViewModel = hiltViewModel(),
|
||||||
navController: NavHostController = rememberNavController(),
|
navController: NavHostController = rememberNavController(),
|
||||||
onSplashScreenRemoved: () -> Unit,
|
biometricsManager: BiometricsManager = LocalBiometricsManager.current,
|
||||||
|
onSplashScreenRemoved: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val state by viewModel.stateFlow.collectAsState()
|
val state by viewModel.stateFlow.collectAsState()
|
||||||
val previousStateReference = remember { AtomicReference(state) }
|
val previousStateReference = remember { AtomicReference(state) }
|
||||||
@ -61,6 +65,15 @@ fun RootNavScreen(
|
|||||||
.launchIn(this)
|
.launchIn(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BiometricChanges(
|
||||||
|
biometricsManager = biometricsManager,
|
||||||
|
onBiometricSupportChange = remember(viewModel) {
|
||||||
|
{
|
||||||
|
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = SplashRoute,
|
startDestination = SplashRoute,
|
||||||
|
|||||||
@ -46,7 +46,7 @@ class RootNavViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is RootNavAction.Internal.HasSeenWelcomeTutorialChange -> {
|
is RootNavAction.Internal.HasSeenWelcomeTutorialChange -> {
|
||||||
handleHasSeenWelcomeTutorialChange(action.hasSeenWelcomeGuide)
|
handleHasSeenWelcomeTutorialChange(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
RootNavAction.Internal.TutorialFinished -> {
|
RootNavAction.Internal.TutorialFinished -> {
|
||||||
@ -60,6 +60,10 @@ class RootNavViewModel @Inject constructor(
|
|||||||
RootNavAction.Internal.AppUnlocked -> {
|
RootNavAction.Internal.AppUnlocked -> {
|
||||||
handleAppUnlocked()
|
handleAppUnlocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is RootNavAction.BiometricSupportChanged -> {
|
||||||
|
handleBiometricSupportChanged(action)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,11 +71,14 @@ class RootNavViewModel @Inject constructor(
|
|||||||
authRepository.updateLastActiveTime()
|
authRepository.updateLastActiveTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleHasSeenWelcomeTutorialChange(hasSeenWelcomeGuide: Boolean) {
|
private fun handleHasSeenWelcomeTutorialChange(
|
||||||
settingsRepository.hasSeenWelcomeTutorial = hasSeenWelcomeGuide
|
action: RootNavAction.Internal.HasSeenWelcomeTutorialChange,
|
||||||
if (hasSeenWelcomeGuide) {
|
) {
|
||||||
|
settingsRepository.hasSeenWelcomeTutorial = action.hasSeenWelcomeGuide
|
||||||
|
if (action.hasSeenWelcomeGuide) {
|
||||||
if (settingsRepository.isUnlockWithBiometricsEnabled &&
|
if (settingsRepository.isUnlockWithBiometricsEnabled &&
|
||||||
biometricsEncryptionManager.isBiometricIntegrityValid()) {
|
biometricsEncryptionManager.isBiometricIntegrityValid()
|
||||||
|
) {
|
||||||
mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Locked) }
|
mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Locked) }
|
||||||
} else {
|
} else {
|
||||||
mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) }
|
mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) }
|
||||||
@ -99,6 +106,19 @@ class RootNavViewModel @Inject constructor(
|
|||||||
it.copy(navState = RootNavState.NavState.Unlocked)
|
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()
|
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.
|
* Models actions the [RootNavViewModel] itself may send.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.composition.LocalBiometricsManager
|
||||||
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
|
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
|
||||||
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
|
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(
|
BitwardenScaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@ -287,8 +297,7 @@ private fun SecuritySettings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
val hasBiometrics = biometricsManager.isBiometricsSupported
|
if (state.hasBiometricsSupport) {
|
||||||
if (hasBiometrics) {
|
|
||||||
UnlockWithBiometricsRow(
|
UnlockWithBiometricsRow(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.testTag("UnlockWithBiometricsSwitch")
|
.testTag("UnlockWithBiometricsSwitch")
|
||||||
@ -302,7 +311,7 @@ private fun SecuritySettings(
|
|||||||
|
|
||||||
ScreenCaptureRow(
|
ScreenCaptureRow(
|
||||||
currentValue = state.allowScreenCapture,
|
currentValue = state.allowScreenCapture,
|
||||||
cardStyle = if (hasBiometrics) {
|
cardStyle = if (state.hasBiometricsSupport) {
|
||||||
CardStyle.Bottom
|
CardStyle.Bottom
|
||||||
} else {
|
} else {
|
||||||
CardStyle.Full
|
CardStyle.Full
|
||||||
|
|||||||
@ -81,6 +81,11 @@ class SettingsViewModel @Inject constructor(
|
|||||||
.map { SettingsAction.Internal.DefaultSaveOptionUpdated(it) }
|
.map { SettingsAction.Internal.DefaultSaveOptionUpdated(it) }
|
||||||
.onEach(::sendAction)
|
.onEach(::sendAction)
|
||||||
.launchIn(viewModelScope)
|
.launchIn(viewModelScope)
|
||||||
|
settingsRepository
|
||||||
|
.isUnlockWithBiometricsEnabledFlow
|
||||||
|
.map { SettingsAction.Internal.UnlockWithBiometricsUpdated(it) }
|
||||||
|
.onEach(::sendAction)
|
||||||
|
.launchIn(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleAction(action: SettingsAction) {
|
override fun handleAction(action: SettingsAction) {
|
||||||
@ -118,6 +123,30 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is SettingsAction.Internal.DynamicColorsUpdated -> handleDynamicColorsUpdated(action)
|
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,
|
showSyncWithBitwarden = shouldShowSyncWithBitwarden,
|
||||||
showDefaultSaveOptionRow = shouldShowDefaultSaveOption,
|
showDefaultSaveOptionRow = shouldShowDefaultSaveOption,
|
||||||
allowScreenCapture = isScreenCaptureAllowed,
|
allowScreenCapture = isScreenCaptureAllowed,
|
||||||
|
hasBiometricsSupport = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -398,6 +428,7 @@ data class SettingsState(
|
|||||||
val appearance: Appearance,
|
val appearance: Appearance,
|
||||||
val defaultSaveOption: DefaultSaveOption,
|
val defaultSaveOption: DefaultSaveOption,
|
||||||
val isUnlockWithBiometricsEnabled: Boolean,
|
val isUnlockWithBiometricsEnabled: Boolean,
|
||||||
|
val hasBiometricsSupport: Boolean,
|
||||||
val isSubmitCrashLogsEnabled: Boolean,
|
val isSubmitCrashLogsEnabled: Boolean,
|
||||||
val showSyncWithBitwarden: Boolean,
|
val showSyncWithBitwarden: Boolean,
|
||||||
val showDefaultSaveOptionRow: Boolean,
|
val showDefaultSaveOptionRow: Boolean,
|
||||||
@ -504,6 +535,11 @@ sealed class SettingsAction(
|
|||||||
) : Dialog()
|
) : Dialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates an update on device biometrics support.
|
||||||
|
*/
|
||||||
|
data class BiometricSupportChanged(val isBiometricsSupported: Boolean) : SettingsAction()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Models actions for the Security section of settings.
|
* Models actions for the Security section of settings.
|
||||||
*/
|
*/
|
||||||
@ -648,5 +684,12 @@ sealed class SettingsAction(
|
|||||||
data class DynamicColorsUpdated(
|
data class DynamicColorsUpdated(
|
||||||
val isEnabled: Boolean,
|
val isEnabled: Boolean,
|
||||||
) : SettingsAction()
|
) : 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
|
package com.bitwarden.authenticator.data.auth.datasource.disk.util
|
||||||
|
|
||||||
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
|
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
|
import java.util.UUID
|
||||||
|
|
||||||
class FakeAuthDiskSource : AuthDiskSource {
|
class FakeAuthDiskSource : AuthDiskSource {
|
||||||
|
|
||||||
private var lastActiveTimeMillis: Long? = null
|
private var lastActiveTimeMillis: Long? = null
|
||||||
private var userBiometricUnlockKey: String? = null
|
private var userBiometricUnlockKey: String? = null
|
||||||
|
private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow<String?>(replay = 1)
|
||||||
|
|
||||||
override val uniqueAppId: String
|
override val uniqueAppId: String
|
||||||
get() = UUID.randomUUID().toString()
|
get() = UUID.randomUUID().toString()
|
||||||
@ -19,7 +23,15 @@ class FakeAuthDiskSource : AuthDiskSource {
|
|||||||
|
|
||||||
override fun getUserBiometricUnlockKey(): String? = userBiometricUnlockKey
|
override fun getUserBiometricUnlockKey(): String? = userBiometricUnlockKey
|
||||||
|
|
||||||
|
override val userBiometricUnlockKeyFlow: Flow<String?>
|
||||||
|
get() =
|
||||||
|
mutableUserBiometricUnlockKeyFlow
|
||||||
|
.onSubscription {
|
||||||
|
emit(getUserBiometricUnlockKey())
|
||||||
|
}
|
||||||
|
|
||||||
override fun storeUserBiometricUnlockKey(biometricsKey: String?) {
|
override fun storeUserBiometricUnlockKey(biometricsKey: String?) {
|
||||||
|
mutableUserBiometricUnlockKeyFlow.tryEmit(biometricsKey)
|
||||||
this@FakeAuthDiskSource.userBiometricUnlockKey = biometricsKey
|
this@FakeAuthDiskSource.userBiometricUnlockKey = biometricsKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -165,4 +165,19 @@ class SettingsRepositoryTest {
|
|||||||
settingsRepository.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3")
|
settingsRepository.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3")
|
||||||
verify { settingsDiskSource.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))
|
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
|
private val APP_LANGUAGE = AppLanguage.ENGLISH
|
||||||
@ -276,4 +293,5 @@ private val DEFAULT_STATE = SettingsState(
|
|||||||
.concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()),
|
.concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()),
|
||||||
copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(),
|
copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(),
|
||||||
allowScreenCapture = false,
|
allowScreenCapture = false,
|
||||||
|
hasBiometricsSupport = true,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -51,6 +51,7 @@ class SettingsViewModelTest : BaseViewModelTest() {
|
|||||||
private val mutableDefaultSaveOptionFlow = bufferedMutableSharedFlow<DefaultSaveOption>()
|
private val mutableDefaultSaveOptionFlow = bufferedMutableSharedFlow<DefaultSaveOption>()
|
||||||
private val mutableScreenCaptureAllowedStateFlow = MutableStateFlow(false)
|
private val mutableScreenCaptureAllowedStateFlow = MutableStateFlow(false)
|
||||||
private val mutableIsDynamicColorsEnabledFlow = MutableStateFlow(false)
|
private val mutableIsDynamicColorsEnabledFlow = MutableStateFlow(false)
|
||||||
|
private val mutableIsUnlockWithBiometricsEnabledFlow = MutableStateFlow(true)
|
||||||
private val settingsRepository: SettingsRepository = mockk {
|
private val settingsRepository: SettingsRepository = mockk {
|
||||||
every { appLanguage } returns APP_LANGUAGE
|
every { appLanguage } returns APP_LANGUAGE
|
||||||
every { appTheme } returns APP_THEME
|
every { appTheme } returns APP_THEME
|
||||||
@ -64,6 +65,7 @@ class SettingsViewModelTest : BaseViewModelTest() {
|
|||||||
every { isDynamicColorsEnabled } answers { mutableIsDynamicColorsEnabledFlow.value }
|
every { isDynamicColorsEnabled } answers { mutableIsDynamicColorsEnabledFlow.value }
|
||||||
every { isDynamicColorsEnabled = any() } just runs
|
every { isDynamicColorsEnabled = any() } just runs
|
||||||
every { isDynamicColorsEnabledFlow } returns mutableIsDynamicColorsEnabledFlow
|
every { isDynamicColorsEnabledFlow } returns mutableIsDynamicColorsEnabledFlow
|
||||||
|
every { isUnlockWithBiometricsEnabledFlow } returns mutableIsUnlockWithBiometricsEnabledFlow
|
||||||
}
|
}
|
||||||
private val clipboardManager: BitwardenClipboardManager = mockk()
|
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(
|
private fun createViewModel(
|
||||||
savedState: SettingsState? = DEFAULT_STATE,
|
savedState: SettingsState? = DEFAULT_STATE,
|
||||||
) = SettingsViewModel(
|
) = SettingsViewModel(
|
||||||
@ -301,4 +320,5 @@ private val DEFAULT_STATE = SettingsState(
|
|||||||
.concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()),
|
.concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()),
|
||||||
copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(),
|
copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(),
|
||||||
allowScreenCapture = false,
|
allowScreenCapture = false,
|
||||||
|
hasBiometricsSupport = true,
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user