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

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

View File

@ -211,7 +211,7 @@ interface AuthDiskSource : AppIdProvider {
/** /**
* Gets the flow for the biometrics key for the given [userId]. * 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].

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,38 @@
package com.bitwarden.authenticator.ui.platform.components.biometrics
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.Lifecycle
import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager
import com.bitwarden.ui.platform.base.util.LifecycleEventEffect
/**
* Tracks changes in biometric support and notifies when the app resumes.
*
* This composable monitors lifecycle events and checks biometric support status
* whenever the app returns to the foreground ([Lifecycle.Event.ON_RESUME]) or
* biometric support status changes (via [LaunchedEffect]).
*
* @param biometricsManager Manager to check current biometric support status.
* @param onBiometricSupportChange Callback invoked with the current biometric
* support status.
*/
@Composable
fun BiometricChanges(
biometricsManager: BiometricsManager,
onBiometricSupportChange: (isSupported: Boolean) -> Unit,
) {
LaunchedEffect(biometricsManager.isBiometricsSupported) {
onBiometricSupportChange(biometricsManager.isBiometricsSupported)
}
LifecycleEventEffect { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
onBiometricSupportChange(biometricsManager.isBiometricsSupported)
}
else -> Unit
}
}
}

View File

@ -19,12 +19,15 @@ import com.bitwarden.authenticator.ui.auth.unlock.unlockDestination
import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.AuthenticatorGraphRoute import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,314 @@
package com.bitwarden.authenticator.ui.platform.feature.rootnav
import androidx.navigation.navOptions
import com.bitwarden.authenticator.ui.auth.unlock.UnlockRoute
import com.bitwarden.authenticator.ui.authenticator.feature.navbar.AuthenticatorNavbarRoute
import com.bitwarden.authenticator.ui.platform.base.AuthenticatorComposeTest
import com.bitwarden.authenticator.ui.platform.feature.splash.SplashRoute
import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialRoute
import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager
import com.bitwarden.ui.platform.base.createMockNavHostController
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.update
import org.junit.Before
import org.junit.Test
class RootNavScreenTest : AuthenticatorComposeTest() {
private var onSplashScreenRemovedCalled = false
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel: RootNavViewModel = mockk {
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns emptyFlow()
every { trySendAction(any()) } just runs
}
private val navController = createMockNavHostController()
private val expectedNavOptions = navOptions {
// When changing root navigation state, pop everything else off the back stack:
popUpTo(id = navController.graph.id) {
inclusive = false
saveState = false
}
launchSingleTop = true
restoreState = false
}
private val biometricsManager: BiometricsManager = mockk {
every { isBiometricsSupported } returns true
}
@Before
fun setup() {
onSplashScreenRemovedCalled = false
setContent(
biometricsManager = biometricsManager,
) {
RootNavScreen(
viewModel = viewModel,
navController = navController,
biometricsManager = biometricsManager,
onSplashScreenRemoved = { onSplashScreenRemovedCalled = true },
)
}
}
@Test
fun `when navState is Splash should show splash screen`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
navState = RootNavState.NavState.Splash,
)
composeTestRule.runOnIdle {
verify {
navController.navigate(
route = SplashRoute,
navOptions = expectedNavOptions,
)
}
assertFalse(onSplashScreenRemovedCalled)
}
}
@Test
fun `when navState is Tutorial should show tutorial screen`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
navState = RootNavState.NavState.Tutorial,
)
composeTestRule.runOnIdle {
verify {
navController.navigate(
route = TutorialRoute,
navOptions = expectedNavOptions,
)
}
assertTrue(onSplashScreenRemovedCalled)
}
}
@Test
fun `when navState is Locked should show unlock screen`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
navState = RootNavState.NavState.Locked,
)
composeTestRule.runOnIdle {
verify {
navController.navigate(
route = UnlockRoute,
navOptions = expectedNavOptions,
)
}
assertTrue(onSplashScreenRemovedCalled)
}
}
@Test
fun `when navState is Unlocked should show authenticator graph`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
navState = RootNavState.NavState.Unlocked,
)
composeTestRule.runOnIdle {
verify {
navController.navigate(
route = AuthenticatorNavbarRoute,
navOptions = expectedNavOptions,
)
}
assertTrue(onSplashScreenRemovedCalled)
}
}
@Test
fun `onSplashScreenRemoved should be called when navState changes from Splash`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
navState = RootNavState.NavState.Splash,
)
composeTestRule.runOnIdle {
assertFalse(onSplashScreenRemovedCalled)
}
mutableStateFlow.update {
it.copy(navState = RootNavState.NavState.Tutorial)
}
composeTestRule.runOnIdle {
assertTrue(onSplashScreenRemovedCalled)
}
}
@Test
fun `onSplashScreenRemoved should not be called when navState is Splash`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
navState = RootNavState.NavState.Splash,
)
composeTestRule.runOnIdle {
assertFalse(onSplashScreenRemovedCalled)
}
}
@Test
fun `navigation should handle Splash to Tutorial transition`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
navState = RootNavState.NavState.Splash,
)
composeTestRule.runOnIdle {
verify {
navController.navigate(
route = SplashRoute,
navOptions = expectedNavOptions,
)
}
}
mutableStateFlow.update {
it.copy(navState = RootNavState.NavState.Tutorial)
}
composeTestRule.runOnIdle {
verify {
navController.navigate(
route = TutorialRoute,
navOptions = expectedNavOptions,
)
}
}
}
@Test
fun `navigation should handle Tutorial to Unlocked transition`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
navState = RootNavState.NavState.Tutorial,
)
composeTestRule.runOnIdle {
verify {
navController.navigate(
route = TutorialRoute,
navOptions = expectedNavOptions,
)
}
}
mutableStateFlow.update {
it.copy(navState = RootNavState.NavState.Unlocked)
}
composeTestRule.runOnIdle {
verify {
navController.navigate(
route = AuthenticatorNavbarRoute,
navOptions = expectedNavOptions,
)
}
}
}
@Test
fun `navigation should handle Splash to Locked transition`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
navState = RootNavState.NavState.Splash,
)
composeTestRule.runOnIdle {
verify {
navController.navigate(
route = SplashRoute,
navOptions = expectedNavOptions,
)
}
}
mutableStateFlow.update {
it.copy(navState = RootNavState.NavState.Locked)
}
composeTestRule.runOnIdle {
verify {
navController.navigate(
route = UnlockRoute,
navOptions = expectedNavOptions,
)
}
}
}
@Test
fun `navigation should handle Locked to Unlocked transition`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
navState = RootNavState.NavState.Locked,
)
composeTestRule.runOnIdle {
verify {
navController.navigate(
route = UnlockRoute,
navOptions = expectedNavOptions,
)
}
}
mutableStateFlow.update {
it.copy(navState = RootNavState.NavState.Unlocked)
}
composeTestRule.runOnIdle {
verify {
navController.navigate(
route = AuthenticatorNavbarRoute,
navOptions = expectedNavOptions,
)
}
}
}
@Test
fun `navigation should handle Splash to Unlocked transition`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
navState = RootNavState.NavState.Splash,
)
composeTestRule.runOnIdle {
verify {
navController.navigate(
route = SplashRoute,
navOptions = expectedNavOptions,
)
}
}
mutableStateFlow.update {
it.copy(navState = RootNavState.NavState.Unlocked)
}
composeTestRule.runOnIdle {
verify {
navController.navigate(
route = AuthenticatorNavbarRoute,
navOptions = expectedNavOptions,
)
}
}
}
}
private val DEFAULT_STATE = RootNavState(
hasSeenWelcomeGuide = false,
navState = RootNavState.NavState.Splash,
)

View File

@ -0,0 +1,369 @@
package com.bitwarden.authenticator.ui.platform.feature.rootnav
import app.cash.turbine.test
import com.bitwarden.authenticator.data.auth.repository.AuthRepository
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class RootNavViewModelTest : BaseViewModelTest() {
private val mutableHasSeenWelcomeTutorialFlow = MutableStateFlow(false)
private val authRepository: AuthRepository = mockk {
every { updateLastActiveTime() } just runs
}
private val settingsRepository: SettingsRepository = mockk {
every { hasSeenWelcomeTutorial } returns false
every { hasSeenWelcomeTutorial = any() } just runs
every { hasSeenWelcomeTutorialFlow } returns mutableHasSeenWelcomeTutorialFlow
every { isUnlockWithBiometricsEnabled } returns false
every { clearBiometricsKey() } just runs
}
private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk()
@Test
fun `initialState should be correct when hasSeenWelcomeTutorial is false`() = runTest {
every { settingsRepository.hasSeenWelcomeTutorial } returns false
mutableHasSeenWelcomeTutorialFlow.value = false
val viewModel = createViewModel()
// When hasSeenWelcomeTutorial is false, the flow emits and triggers navigation to Tutorial
assertEquals(
RootNavState(
hasSeenWelcomeGuide = false,
navState = RootNavState.NavState.Tutorial,
),
viewModel.stateFlow.value,
)
}
@Test
fun `initialState should be correct when hasSeenWelcomeTutorial is true`() = runTest {
every { settingsRepository.hasSeenWelcomeTutorial } returns true
mutableHasSeenWelcomeTutorialFlow.value = true
val viewModel = createViewModel()
// When hasSeenWelcomeTutorial is true and biometrics is not enabled, navigates to Unlocked
assertEquals(
RootNavState(
hasSeenWelcomeGuide = true,
navState = RootNavState.NavState.Unlocked,
),
viewModel.stateFlow.value,
)
}
@Test
fun `on BackStackUpdate should call updateLastActiveTime`() {
val viewModel = createViewModel()
viewModel.trySendAction(RootNavAction.BackStackUpdate)
verify(exactly = 1) { authRepository.updateLastActiveTime() }
}
@Test
@Suppress("MaxLineLength")
fun `on HasSeenWelcomeTutorialChange with true and biometrics enabled and valid should navigate to Locked`() {
every { settingsRepository.isUnlockWithBiometricsEnabled } returns true
every { biometricsEncryptionManager.isBiometricIntegrityValid() } returns true
val viewModel = createViewModel()
viewModel.trySendAction(
RootNavAction.Internal.HasSeenWelcomeTutorialChange(true),
)
assertEquals(
RootNavState(
hasSeenWelcomeGuide = false,
navState = RootNavState.NavState.Locked,
),
viewModel.stateFlow.value,
)
verify(exactly = 1) { settingsRepository.hasSeenWelcomeTutorial = true }
}
@Test
@Suppress("MaxLineLength")
fun `on HasSeenWelcomeTutorialChange with true and biometrics enabled but invalid should navigate to Unlocked`() {
every { settingsRepository.isUnlockWithBiometricsEnabled } returns true
every { biometricsEncryptionManager.isBiometricIntegrityValid() } returns false
val viewModel = createViewModel()
viewModel.trySendAction(
RootNavAction.Internal.HasSeenWelcomeTutorialChange(true),
)
assertEquals(
RootNavState(
hasSeenWelcomeGuide = false,
navState = RootNavState.NavState.Unlocked,
),
viewModel.stateFlow.value,
)
verify(exactly = 1) { settingsRepository.hasSeenWelcomeTutorial = true }
}
@Test
@Suppress("MaxLineLength")
fun `on HasSeenWelcomeTutorialChange with true and biometrics disabled should navigate to Unlocked`() {
every { settingsRepository.isUnlockWithBiometricsEnabled } returns false
val viewModel = createViewModel()
viewModel.trySendAction(
RootNavAction.Internal.HasSeenWelcomeTutorialChange(true),
)
assertEquals(
RootNavState(
hasSeenWelcomeGuide = false,
navState = RootNavState.NavState.Unlocked,
),
viewModel.stateFlow.value,
)
verify(exactly = 1) { settingsRepository.hasSeenWelcomeTutorial = true }
}
@Test
fun `on HasSeenWelcomeTutorialChange with false should navigate to Tutorial`() {
val viewModel = createViewModel()
viewModel.trySendAction(
RootNavAction.Internal.HasSeenWelcomeTutorialChange(false),
)
assertEquals(
RootNavState(
hasSeenWelcomeGuide = false,
navState = RootNavState.NavState.Tutorial,
),
viewModel.stateFlow.value,
)
// Called twice: once during init when flow emits, once from the action
verify(exactly = 2) { settingsRepository.hasSeenWelcomeTutorial = false }
}
@Test
fun `on TutorialFinished should update settingsRepository and navigate to Unlocked`() {
val viewModel = createViewModel()
viewModel.trySendAction(RootNavAction.Internal.TutorialFinished)
assertEquals(
RootNavState(
hasSeenWelcomeGuide = false,
navState = RootNavState.NavState.Unlocked,
),
viewModel.stateFlow.value,
)
verify(exactly = 1) { settingsRepository.hasSeenWelcomeTutorial = true }
}
@Test
@Suppress("MaxLineLength")
fun `on SplashScreenDismissed when hasSeenWelcomeTutorial is true and currently Splash should navigate to Unlocked`() =
runTest {
// Set hasSeenWelcomeTutorial to false initially to stay on Splash
every { settingsRepository.hasSeenWelcomeTutorial } returns false
mutableHasSeenWelcomeTutorialFlow.value = false
val viewModel = createViewModel()
viewModel.stateFlow.test {
// Initial state - Tutorial from init flow
assertEquals(
RootNavState(
hasSeenWelcomeGuide = false,
navState = RootNavState.NavState.Tutorial,
),
awaitItem(),
)
// Now change the repository value and trigger SplashScreenDismissed
every { settingsRepository.hasSeenWelcomeTutorial } returns true
viewModel.trySendAction(RootNavAction.Internal.SplashScreenDismissed)
// Should navigate to Unlocked based on new repository value
assertEquals(
RootNavState(
hasSeenWelcomeGuide = false,
navState = RootNavState.NavState.Unlocked,
),
awaitItem(),
)
}
}
@Test
@Suppress("MaxLineLength")
fun `on SplashScreenDismissed when hasSeenWelcomeTutorial is false and currently in different state should navigate to Tutorial`() =
runTest {
// Start with hasSeenWelcomeTutorial = true to go to Unlocked
every { settingsRepository.hasSeenWelcomeTutorial } returns true
mutableHasSeenWelcomeTutorialFlow.value = true
val viewModel = createViewModel()
viewModel.stateFlow.test {
// Initial state - Unlocked from init flow
assertEquals(
RootNavState(
hasSeenWelcomeGuide = true,
navState = RootNavState.NavState.Unlocked,
),
awaitItem(),
)
// Change the repository value and trigger SplashScreenDismissed
every { settingsRepository.hasSeenWelcomeTutorial } returns false
viewModel.trySendAction(RootNavAction.Internal.SplashScreenDismissed)
// Should navigate to Tutorial based on new repository value
assertEquals(
RootNavState(
hasSeenWelcomeGuide = true,
navState = RootNavState.NavState.Tutorial,
),
awaitItem(),
)
}
}
@Test
fun `on AppUnlocked should navigate to Unlocked`() {
val viewModel = createViewModel()
viewModel.trySendAction(RootNavAction.Internal.AppUnlocked)
assertEquals(
RootNavState(
hasSeenWelcomeGuide = false,
navState = RootNavState.NavState.Unlocked,
),
viewModel.stateFlow.value,
)
}
@Test
fun `on BiometricSupportChanged with false should clear biometrics key`() {
val viewModel = createViewModel()
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(false))
verify(exactly = 1) { settingsRepository.clearBiometricsKey() }
}
@Test
fun `on BiometricSupportChanged with true should not clear biometrics key`() {
val viewModel = createViewModel()
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(true))
verify(exactly = 0) { settingsRepository.clearBiometricsKey() }
}
@Test
@Suppress("MaxLineLength")
fun `on BiometricSupportChanged with false when Locked should navigate to Unlocked`() = runTest {
every { settingsRepository.hasSeenWelcomeTutorial } returns true
every { settingsRepository.isUnlockWithBiometricsEnabled } returns true
every { biometricsEncryptionManager.isBiometricIntegrityValid() } returns true
mutableHasSeenWelcomeTutorialFlow.value = true
val viewModel = createViewModel()
// Verify initial state is Locked
assertEquals(
RootNavState(
hasSeenWelcomeGuide = true,
navState = RootNavState.NavState.Locked,
),
viewModel.stateFlow.value,
)
// Send BiometricSupportChanged with false
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(false))
// Should navigate to Unlocked and clear biometric key
assertEquals(
RootNavState(
hasSeenWelcomeGuide = true,
navState = RootNavState.NavState.Unlocked,
),
viewModel.stateFlow.value,
)
verify(exactly = 1) { settingsRepository.clearBiometricsKey() }
}
@Test
@Suppress("MaxLineLength")
fun `on BiometricSupportChanged with false when not Locked should not change navigation state`() =
runTest {
every { settingsRepository.hasSeenWelcomeTutorial } returns true
mutableHasSeenWelcomeTutorialFlow.value = true
val viewModel = createViewModel()
// Verify initial state is Unlocked (biometrics not enabled)
assertEquals(
RootNavState(
hasSeenWelcomeGuide = true,
navState = RootNavState.NavState.Unlocked,
),
viewModel.stateFlow.value,
)
// Send BiometricSupportChanged with false
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(false))
// Should remain Unlocked and clear biometric key
assertEquals(
RootNavState(
hasSeenWelcomeGuide = true,
navState = RootNavState.NavState.Unlocked,
),
viewModel.stateFlow.value,
)
verify(exactly = 1) { settingsRepository.clearBiometricsKey() }
}
@Test
@Suppress("MaxLineLength")
fun `hasSeenWelcomeTutorialFlow updates should trigger HasSeenWelcomeTutorialChange action`() =
runTest {
every { settingsRepository.isUnlockWithBiometricsEnabled } returns false
val viewModel = createViewModel()
viewModel.stateFlow.test {
// Initial emission after flow subscription - navigates to Tutorial since hasSeenWelcomeTutorial is false
assertEquals(
RootNavState(
hasSeenWelcomeGuide = false,
navState = RootNavState.NavState.Tutorial,
),
awaitItem(),
)
// Update the flow value to true
mutableHasSeenWelcomeTutorialFlow.value = true
// Should navigate to Unlocked since biometrics is not enabled
assertEquals(
RootNavState(
hasSeenWelcomeGuide = false,
navState = RootNavState.NavState.Unlocked,
),
awaitItem(),
)
}
}
private fun createViewModel() = RootNavViewModel(
authRepository = authRepository,
settingsRepository = settingsRepository,
biometricsEncryptionManager = biometricsEncryptionManager,
)
}

View File

@ -254,6 +254,23 @@ class SettingsScreenTest : AuthenticatorComposeTest() {
viewModel.trySendAction(SettingsAction.AppearanceChange.DynamicColorChange(true)) viewModel.trySendAction(SettingsAction.AppearanceChange.DynamicColorChange(true))
} }
} }
@Test
fun `Unlock with biometrics row should be hidden when hasBiometricsSupport is false`() {
mutableStateFlow.value = DEFAULT_STATE
composeTestRule
.onNodeWithText("Use your devices lock method to unlock the app")
.assertExists()
mutableStateFlow.update {
it.copy(
hasBiometricsSupport = false,
)
}
composeTestRule
.onNodeWithText("Use your devices lock method to unlock the app")
.assertDoesNotExist()
}
} }
private val APP_LANGUAGE = AppLanguage.ENGLISH 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,
) )

View File

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