From e55b8b7f2c07305dc254b595000a26eef6d97a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Thu, 30 Oct 2025 18:36:34 +0000 Subject: [PATCH 01/22] [PM-27150] React to device changes on device screen unlock method. --- .../platform/feature/rootnav/RootNavScreen.kt | 10 +++++++++- .../feature/rootnav/RootNavViewModel.kt | 16 ++++++++++++++++ .../platform/feature/settings/SettingsScreen.kt | 17 +++++++++++++++-- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt index d8f6462becf..f81d4653e48 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt @@ -19,6 +19,7 @@ import com.bitwarden.authenticator.ui.auth.unlock.unlockDestination import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.AuthenticatorGraphRoute import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.authenticatorGraph import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.navigateToAuthenticatorGraph +import com.bitwarden.authenticator.ui.platform.composition.LocalBiometricsManager import com.bitwarden.authenticator.ui.platform.feature.debugmenu.setupDebugMenuDestination import com.bitwarden.authenticator.ui.platform.feature.splash.SplashRoute import com.bitwarden.authenticator.ui.platform.feature.splash.navigateToSplash @@ -26,6 +27,7 @@ import com.bitwarden.authenticator.ui.platform.feature.splash.splashDestination import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialRoute import com.bitwarden.authenticator.ui.platform.feature.tutorial.navigateToTutorial import com.bitwarden.authenticator.ui.platform.feature.tutorial.tutorialDestination +import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager import com.bitwarden.ui.platform.theme.NonNullEnterTransitionProvider import com.bitwarden.ui.platform.theme.NonNullExitTransitionProvider import com.bitwarden.ui.platform.theme.RootTransitionProviders @@ -42,6 +44,7 @@ import java.util.concurrent.atomic.AtomicReference fun RootNavScreen( viewModel: RootNavViewModel = hiltViewModel(), navController: NavHostController = rememberNavController(), + biometricsManager: BiometricsManager = LocalBiometricsManager.current, onSplashScreenRemoved: () -> Unit = {}, onExitApplication: () -> Unit, ) { @@ -135,7 +138,12 @@ fun RootNavScreen( } RootNavState.NavState.Locked -> { - navController.navigateToUnlock(rootNavOptions) + if (biometricsManager.isBiometricsSupported) { + navController.navigateToUnlock(rootNavOptions) + } else { + // device no longer has biometrics setup, clear biometrics key + viewModel.trySendAction(RootNavAction.Internal.ClearBiometricsKey) + } } RootNavState.NavState.Unlocked -> { diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt index 53280c50d6e..d9140a8fbea 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -60,6 +60,10 @@ class RootNavViewModel @Inject constructor( RootNavAction.Internal.AppUnlocked -> { handleAppUnlocked() } + + RootNavAction.Internal.ClearBiometricsKey -> { + handleClearBiometricsKey() + } } } @@ -99,6 +103,13 @@ class RootNavViewModel @Inject constructor( it.copy(navState = RootNavState.NavState.Unlocked) } } + + private fun handleClearBiometricsKey() { + settingsRepository.clearBiometricsKey() + mutableStateFlow.update { + it.copy(navState = RootNavState.NavState.Unlocked) + } + } } /** @@ -173,6 +184,11 @@ sealed class RootNavAction { */ data object AppUnlocked : Internal() + /** + * Indicates the device no longer has a valid biometric set up. + */ + data object ClearBiometricsKey : Internal() + /** * Indicates an update in the welcome guide being seen has been received. */ diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index cb8c08251ca..2c3c74502f1 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.lifecycle.Lifecycle import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalResources @@ -47,6 +48,7 @@ import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.Defau import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager import com.bitwarden.authenticator.ui.platform.util.displayLabel import com.bitwarden.ui.platform.base.util.EventsEffect +import com.bitwarden.ui.platform.base.util.LifecycleEventEffect import com.bitwarden.ui.platform.base.util.annotatedStringResource import com.bitwarden.ui.platform.base.util.cardStyle import com.bitwarden.ui.platform.base.util.mirrorIfRtl @@ -273,6 +275,19 @@ private fun SecuritySettings( onBiometricToggle: (Boolean) -> Unit, onScreenCaptureChange: (Boolean) -> Unit, ) { + var hasBiometrics by remember { mutableStateOf(biometricsManager.isBiometricsSupported) } + + // Recheck biometrics support when app comes to foreground + LifecycleEventEffect { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + hasBiometrics = biometricsManager.isBiometricsSupported + if (!hasBiometrics) { + // if the biometrics was disable on device clear the app as well + onBiometricToggle(false) + } + } + } + Spacer(modifier = Modifier.height(height = 12.dp)) BitwardenListHeaderText( modifier = Modifier @@ -282,7 +297,6 @@ private fun SecuritySettings( ) Spacer(modifier = Modifier.height(8.dp)) - val hasBiometrics = biometricsManager.isBiometricsSupported if (hasBiometrics) { UnlockWithBiometricsRow( modifier = Modifier @@ -449,7 +463,6 @@ private fun UnlockWithBiometricsRow( biometricsManager: BiometricsManager, modifier: Modifier = Modifier, ) { - if (!biometricsManager.isBiometricsSupported) return var showBiometricsPrompt by rememberSaveable { mutableStateOf(false) } BitwardenSwitch( modifier = modifier, From 3533af50948284f638a3319bfb540f3ec9b5b895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Thu, 6 Nov 2025 13:34:34 +0000 Subject: [PATCH 02/22] [PM-27150] Add BiometricUnlockKey flow to AuthDiskSouce --- .../datasource/disk/AuthDiskSourceTest.kt | 19 +++++++++++++++++++ .../auth/datasource/disk/AuthDiskSource.kt | 6 ++++++ .../datasource/disk/AuthDiskSourceImpl.kt | 9 +++++++++ .../disk/util/FakeAuthDiskSource.kt | 11 +++++++++++ 4 files changed, 45 insertions(+) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt index ab5b1db1fa2..00fe0380a5a 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt @@ -711,6 +711,25 @@ class AuthDiskSourceTest { assertEquals(biometricsKey, actual) } + @Test + fun `getUserBiometricUnlockKeyFlow should react to changes in getUserBiometricUnlockKey`() = + runTest { + val mockUserId = "mockUserId" + val biometricsKey = "1234" + authDiskSource.getUserBiometicUnlockKeyFlow(userId = mockUserId).test { + // The initial values of the Flow and the property are in sync + assertNull(authDiskSource.getUserBiometricUnlockKey(userId = mockUserId)) + assertNull(awaitItem()) + + // Updating the disk source updates shared preferences + authDiskSource.storeUserBiometricUnlockKey( + userId = mockUserId, + biometricsKey = biometricsKey, + ) + assertEquals(biometricsKey, awaitItem()) + } + } + @Test fun `storeUserBiometricInitVector for non-null values should update SharedPreferences`() { val biometricsInitVectorBaseKey = "bwSecureStorage:biometricInitializationVector" diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt index 7b770013606..0d62f7bc9ac 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt @@ -1,6 +1,7 @@ package com.bitwarden.authenticator.data.auth.datasource.disk import com.bitwarden.network.provider.AppIdProvider +import kotlinx.coroutines.flow.Flow /** * Primary access point for disk information. @@ -28,6 +29,11 @@ interface AuthDiskSource : AppIdProvider { */ fun getUserBiometricUnlockKey(): String? + /** + * Tracks the biometrics key. + */ + fun getUserBiometricUnlockKeyFlow(): Flow + /** * Stores the biometrics key. */ diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt index b387c3f2079..f6167132b46 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -1,7 +1,10 @@ package com.bitwarden.authenticator.data.auth.datasource.disk import android.content.SharedPreferences +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.data.datasource.disk.BaseEncryptedDiskSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onSubscription import java.util.UUID private const val AUTHENTICATOR_SYNC_SYMMETRIC_KEY = "authenticatorSyncSymmetricKey" @@ -20,6 +23,8 @@ class AuthDiskSourceImpl( sharedPreferences = sharedPreferences, ), AuthDiskSource { + private val mutableUserBiometricUnlockKeyFlow = + bufferedMutableSharedFlow() override val uniqueAppId: String get() = getString(key = UNIQUE_APP_ID_KEY) ?: generateAndStoreUniqueAppId() @@ -39,6 +44,9 @@ class AuthDiskSourceImpl( override fun getUserBiometricUnlockKey(): String? = getEncryptedString(key = BIOMETRICS_UNLOCK_KEY) + override fun getUserBiometricUnlockKeyFlow(): Flow = mutableUserBiometricUnlockKeyFlow + .onSubscription { emit(getUserBiometricUnlockKey()) } + override fun storeUserBiometricUnlockKey( biometricsKey: String?, ) { @@ -46,6 +54,7 @@ class AuthDiskSourceImpl( key = BIOMETRICS_UNLOCK_KEY, value = biometricsKey, ) + mutableUserBiometricUnlockKeyFlow.tryEmit(biometricsKey) } override var authenticatorBridgeSymmetricSyncKey: ByteArray? diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index 7dfe7587af0..33ffd28e3fa 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -1,12 +1,16 @@ package com.bitwarden.authenticator.data.auth.datasource.disk.util import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onSubscription import java.util.UUID class FakeAuthDiskSource : AuthDiskSource { private var lastActiveTimeMillis: Long? = null private var userBiometricUnlockKey: String? = null + private val userBiometricUnlockKeyFlow = bufferedMutableSharedFlow() override val uniqueAppId: String get() = UUID.randomUUID().toString() @@ -19,7 +23,14 @@ class FakeAuthDiskSource : AuthDiskSource { override fun getUserBiometricUnlockKey(): String? = userBiometricUnlockKey + override fun getUserBiometricUnlockKeyFlow(): Flow = + userBiometricUnlockKeyFlow + .onSubscription { + emit(getUserBiometricUnlockKey()) + } + override fun storeUserBiometricUnlockKey(biometricsKey: String?) { + userBiometricUnlockKeyFlow.tryEmit(biometricsKey) this@FakeAuthDiskSource.userBiometricUnlockKey = biometricsKey } From 46c1cb36dd548ef0b90709bf91e10c82d511afe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Thu, 6 Nov 2025 13:36:08 +0000 Subject: [PATCH 03/22] [PM-27150] Add isUnlockWithBiometricsEnabledFlow to SettingsRepository --- .../platform/repository/SettingsRepository.kt | 5 +++++ .../platform/repository/SettingsRepositoryImpl.kt | 6 ++++++ .../platform/repository/SettingsRepositoryTest.kt | 15 +++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt index e3954317fb4..082f073ac2e 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt @@ -57,6 +57,11 @@ interface SettingsRepository { */ val isUnlockWithBiometricsEnabled: Boolean + /** + * Tracks whether or not biometric unlocking is enabled for the current user. + */ + val isUnlockWithBiometricsEnabledFlow: Flow + /** * Tracks changes to the expiration alert threshold. */ diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt index 735cc0e1625..eeda54a3f45 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt @@ -66,6 +66,12 @@ class SettingsRepositoryImpl( override val isUnlockWithBiometricsEnabled: Boolean get() = authDiskSource.getUserBiometricUnlockKey() != null + override val isUnlockWithBiometricsEnabledFlow: Flow + get() = + authDiskSource + .getUserBiometricUnlockKeyFlow() + .map { it != null } + override val appThemeStateFlow: StateFlow get() = settingsDiskSource .appThemeFlow diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt index 22e718d66bd..b1653394877 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt @@ -165,4 +165,19 @@ class SettingsRepositoryTest { settingsRepository.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3") verify { settingsDiskSource.previouslySyncedBitwardenAccountIds = setOf("1", "2", "3") } } + + @Test + fun `isUnlockWithBiometricsEnabledFlow should react to changes in AuthDiskSource`() = runTest { + settingsRepository.isUnlockWithBiometricsEnabledFlow.test { + assertFalse(awaitItem()) + authDiskSource.storeUserBiometricUnlockKey( + biometricsKey = "biometricsKey", + ) + assertTrue(awaitItem()) + authDiskSource.storeUserBiometricUnlockKey( + biometricsKey = null, + ) + assertFalse(awaitItem()) + } + } } From 436bcc15a260a45882e1fb566cc75eafabc0b43a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Thu, 6 Nov 2025 13:36:59 +0000 Subject: [PATCH 04/22] [PM-27150] Remove saved biometric key if device no longer supports it. --- .../platform/feature/rootnav/RootNavScreen.kt | 31 +++++++++++++++---- .../feature/rootnav/RootNavViewModel.kt | 15 +++++---- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt index f81d4653e48..08df8a18292 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle import androidx.navigation.NavBackStackEntry import androidx.navigation.NavDestination import androidx.navigation.NavHostController @@ -28,6 +29,7 @@ import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialRoute import com.bitwarden.authenticator.ui.platform.feature.tutorial.navigateToTutorial import com.bitwarden.authenticator.ui.platform.feature.tutorial.tutorialDestination import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager +import com.bitwarden.ui.platform.base.util.LifecycleEventEffect import com.bitwarden.ui.platform.theme.NonNullEnterTransitionProvider import com.bitwarden.ui.platform.theme.NonNullExitTransitionProvider import com.bitwarden.ui.platform.theme.RootTransitionProviders @@ -66,6 +68,13 @@ fun RootNavScreen( .launchIn(this) } + BiometricChanges( + biometricsManager = biometricsManager, + onBiometricSupportChange = { + viewModel.trySendAction(RootNavAction.Internal.BiometricSupportChanged(it)) + }, + ) + NavHost( navController = navController, startDestination = SplashRoute, @@ -138,12 +147,7 @@ fun RootNavScreen( } RootNavState.NavState.Locked -> { - if (biometricsManager.isBiometricsSupported) { - navController.navigateToUnlock(rootNavOptions) - } else { - // device no longer has biometrics setup, clear biometrics key - viewModel.trySendAction(RootNavAction.Internal.ClearBiometricsKey) - } + navController.navigateToUnlock(rootNavOptions) } RootNavState.NavState.Unlocked -> { @@ -195,3 +199,18 @@ private fun AnimatedContentTransitionScope.toExitTransition() else -> RootTransitionProviders.Exit.fadeOut } } + +@Composable +private fun BiometricChanges( + biometricsManager: BiometricsManager, + onBiometricSupportChange: (isSupported: Boolean) -> Unit, +) { + LifecycleEventEffect { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + onBiometricSupportChange(biometricsManager.isBiometricsSupported) + } + else -> Unit + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt index d9140a8fbea..5298511d321 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -61,8 +61,8 @@ class RootNavViewModel @Inject constructor( handleAppUnlocked() } - RootNavAction.Internal.ClearBiometricsKey -> { - handleClearBiometricsKey() + is RootNavAction.Internal.BiometricSupportChanged -> { + handleBiometricSupportChanged(action.isBiometricsSupported) } } } @@ -104,10 +104,9 @@ class RootNavViewModel @Inject constructor( } } - private fun handleClearBiometricsKey() { - settingsRepository.clearBiometricsKey() - mutableStateFlow.update { - it.copy(navState = RootNavState.NavState.Unlocked) + private fun handleBiometricSupportChanged(isBiometricsSupported: Boolean) { + if (!isBiometricsSupported) { + settingsRepository.clearBiometricsKey() } } } @@ -185,9 +184,9 @@ sealed class RootNavAction { data object AppUnlocked : Internal() /** - * Indicates the device no longer has a valid biometric set up. + * Indicates an update on device biometrics support. */ - data object ClearBiometricsKey : Internal() + data class BiometricSupportChanged(val isBiometricsSupported: Boolean) : Internal() /** * Indicates an update in the welcome guide being seen has been received. From 793e7988f491bccc94748aca03330728300cdedb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Thu, 6 Nov 2025 13:37:30 +0000 Subject: [PATCH 05/22] [PM-27150] Bypass UnlockScreen if device no longer supports it. --- .../bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt index 0327a9b50b4..4330fa4a294 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -53,6 +54,12 @@ fun UnlockScreen( } } + LaunchedEffect(biometricsManager.isBiometricsSupported) { + if (!biometricsManager.isBiometricsSupported) { + onUnlocked() + } + } + when (val dialog = state.dialog) { is UnlockState.Dialog.Error -> BitwardenBasicDialog( title = stringResource(id = BitwardenString.an_error_has_occurred), From 3ce913d80e0b274769f97381cf858149a95f5c77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Thu, 6 Nov 2025 13:38:54 +0000 Subject: [PATCH 06/22] [PM-27150] Update AppLock row visibility and toggle based on device biometric support. --- .../feature/settings/SettingsScreen.kt | 26 +++++++++---------- .../feature/settings/SettingsViewModel.kt | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index 2c3c74502f1..1aa12052e09 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.lifecycle.Lifecycle import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalResources @@ -42,6 +41,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitwarden.authenticator.ui.platform.composition.LocalBiometricsManager import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption @@ -90,6 +90,14 @@ fun SettingsScreen( ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + var hasBiometrics by remember { mutableStateOf(biometricsManager.isBiometricsSupported) } + + // Recheck biometrics support when app comes to foreground + LifecycleEventEffect { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + hasBiometrics = biometricsManager.isBiometricsSupported + } + } EventsEffect(viewModel = viewModel) { event -> when (event) { @@ -153,6 +161,7 @@ fun SettingsScreen( SecuritySettings( state = state, biometricsManager = biometricsManager, + hasBiometrics = hasBiometrics, onBiometricToggle = remember(viewModel) { { viewModel.trySendAction( @@ -272,22 +281,10 @@ fun SettingsScreen( private fun SecuritySettings( state: SettingsState, biometricsManager: BiometricsManager = LocalBiometricsManager.current, + hasBiometrics: Boolean, onBiometricToggle: (Boolean) -> Unit, onScreenCaptureChange: (Boolean) -> Unit, ) { - var hasBiometrics by remember { mutableStateOf(biometricsManager.isBiometricsSupported) } - - // Recheck biometrics support when app comes to foreground - LifecycleEventEffect { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - hasBiometrics = biometricsManager.isBiometricsSupported - if (!hasBiometrics) { - // if the biometrics was disable on device clear the app as well - onBiometricToggle(false) - } - } - } - Spacer(modifier = Modifier.height(height = 12.dp)) BitwardenListHeaderText( modifier = Modifier @@ -463,6 +460,7 @@ private fun UnlockWithBiometricsRow( biometricsManager: BiometricsManager, modifier: Modifier = Modifier, ) { + if (!biometricsManager.isBiometricsSupported) return var showBiometricsPrompt by rememberSaveable { mutableStateOf(false) } BitwardenSwitch( modifier = modifier, diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt index 63881754d9d..5aa7ca0ed5f 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt @@ -81,6 +81,11 @@ class SettingsViewModel @Inject constructor( .map { SettingsAction.Internal.DefaultSaveOptionUpdated(it) } .onEach(::sendAction) .launchIn(viewModelScope) + settingsRepository + .isUnlockWithBiometricsEnabledFlow + .map { SettingsAction.Internal.UnlockWithBiometricsUpdated(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: SettingsAction) { @@ -118,6 +123,20 @@ class SettingsViewModel @Inject constructor( } is SettingsAction.Internal.DynamicColorsUpdated -> handleDynamicColorsUpdated(action) + + is SettingsAction.Internal.UnlockWithBiometricsUpdated -> { + handleUnlockWithBiometricsUpdated(action) + } + } + } + + private fun handleUnlockWithBiometricsUpdated( + action: SettingsAction.Internal.UnlockWithBiometricsUpdated, + ) { + mutableStateFlow.update { + it.copy( + isUnlockWithBiometricsEnabled = action.isEnabled, + ) } } @@ -648,5 +667,12 @@ sealed class SettingsAction( data class DynamicColorsUpdated( val isEnabled: Boolean, ) : SettingsAction() + + /** + * Indicates that the biometric state on disk was updated. + */ + data class UnlockWithBiometricsUpdated( + val isEnabled: Boolean, + ) : SettingsAction() } } From e7ac0631a9475cdd7983ae71aebe21f5329e37c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Fri, 7 Nov 2025 11:11:21 +0000 Subject: [PATCH 07/22] [PM-27150] Add PR suggestions. --- .../auth/datasource/disk/AuthDiskSource.kt | 10 +++--- .../datasource/disk/AuthDiskSourceImpl.kt | 8 +++-- .../repository/SettingsRepositoryImpl.kt | 6 ++-- .../platform/feature/rootnav/RootNavScreen.kt | 3 +- .../feature/rootnav/RootNavViewModel.kt | 33 +++++++++++-------- .../disk/util/FakeAuthDiskSource.kt | 15 +++++---- .../feature/settings/SettingsViewModelTest.kt | 2 ++ 7 files changed, 45 insertions(+), 32 deletions(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt index 0d62f7bc9ac..6106452c06b 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSource.kt @@ -8,6 +8,11 @@ import kotlinx.coroutines.flow.Flow */ interface AuthDiskSource : AppIdProvider { + /** + * Tracks the biometrics key. + */ + val userBiometricUnlockKeyFlow: Flow + /** * Retrieves the "last active time". * @@ -29,11 +34,6 @@ interface AuthDiskSource : AppIdProvider { */ fun getUserBiometricUnlockKey(): String? - /** - * Tracks the biometrics key. - */ - fun getUserBiometricUnlockKeyFlow(): Flow - /** * Stores the biometrics key. */ diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt index f6167132b46..7594d231691 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -44,8 +44,12 @@ class AuthDiskSourceImpl( override fun getUserBiometricUnlockKey(): String? = getEncryptedString(key = BIOMETRICS_UNLOCK_KEY) - override fun getUserBiometricUnlockKeyFlow(): Flow = mutableUserBiometricUnlockKeyFlow - .onSubscription { emit(getUserBiometricUnlockKey()) } + override val userBiometricUnlockKeyFlow: Flow + get() = + mutableUserBiometricUnlockKeyFlow + .onSubscription { + emit(getUserBiometricUnlockKey()) + } override fun storeUserBiometricUnlockKey( biometricsKey: String?, diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt index eeda54a3f45..29b1ff8768e 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt @@ -68,9 +68,9 @@ class SettingsRepositoryImpl( override val isUnlockWithBiometricsEnabledFlow: Flow get() = - authDiskSource - .getUserBiometricUnlockKeyFlow() - .map { it != null } + authDiskSource + .userBiometricUnlockKeyFlow + .map { it != null } override val appThemeStateFlow: StateFlow get() = settingsDiskSource diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt index 08df8a18292..02267a69124 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt @@ -71,7 +71,7 @@ fun RootNavScreen( BiometricChanges( biometricsManager = biometricsManager, onBiometricSupportChange = { - viewModel.trySendAction(RootNavAction.Internal.BiometricSupportChanged(it)) + viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it)) }, ) @@ -210,6 +210,7 @@ private fun BiometricChanges( Lifecycle.Event.ON_RESUME -> { onBiometricSupportChange(biometricsManager.isBiometricsSupported) } + else -> Unit } } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt index 5298511d321..7ca238b9ccc 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -46,7 +46,7 @@ class RootNavViewModel @Inject constructor( } is RootNavAction.Internal.HasSeenWelcomeTutorialChange -> { - handleHasSeenWelcomeTutorialChange(action.hasSeenWelcomeGuide) + handleHasSeenWelcomeTutorialChange(action) } RootNavAction.Internal.TutorialFinished -> { @@ -61,8 +61,8 @@ class RootNavViewModel @Inject constructor( handleAppUnlocked() } - is RootNavAction.Internal.BiometricSupportChanged -> { - handleBiometricSupportChanged(action.isBiometricsSupported) + is RootNavAction.BiometricSupportChanged -> { + handleBiometricSupportChanged(action) } } } @@ -71,11 +71,14 @@ class RootNavViewModel @Inject constructor( authRepository.updateLastActiveTime() } - private fun handleHasSeenWelcomeTutorialChange(hasSeenWelcomeGuide: Boolean) { - settingsRepository.hasSeenWelcomeTutorial = hasSeenWelcomeGuide - if (hasSeenWelcomeGuide) { + private fun handleHasSeenWelcomeTutorialChange( + action: RootNavAction.Internal.HasSeenWelcomeTutorialChange, + ) { + settingsRepository.hasSeenWelcomeTutorial = action.hasSeenWelcomeGuide + if (action.hasSeenWelcomeGuide) { if (settingsRepository.isUnlockWithBiometricsEnabled && - biometricsEncryptionManager.isBiometricIntegrityValid()) { + biometricsEncryptionManager.isBiometricIntegrityValid() + ) { mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Locked) } } else { mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) } @@ -104,8 +107,10 @@ class RootNavViewModel @Inject constructor( } } - private fun handleBiometricSupportChanged(isBiometricsSupported: Boolean) { - if (!isBiometricsSupported) { + private fun handleBiometricSupportChanged( + action: RootNavAction.BiometricSupportChanged, + ) { + if (!action.isBiometricsSupported) { settingsRepository.clearBiometricsKey() } } @@ -163,6 +168,11 @@ sealed class RootNavAction { */ data object BackStackUpdate : RootNavAction() + /** + * Indicates an update on device biometrics support. + */ + data class BiometricSupportChanged(val isBiometricsSupported: Boolean) : Internal() + /** * Models actions the [RootNavViewModel] itself may send. */ @@ -183,11 +193,6 @@ sealed class RootNavAction { */ data object AppUnlocked : Internal() - /** - * Indicates an update on device biometrics support. - */ - data class BiometricSupportChanged(val isBiometricsSupported: Boolean) : Internal() - /** * Indicates an update in the welcome guide being seen has been received. */ diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index 33ffd28e3fa..06048f43041 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -10,7 +10,7 @@ class FakeAuthDiskSource : AuthDiskSource { private var lastActiveTimeMillis: Long? = null private var userBiometricUnlockKey: String? = null - private val userBiometricUnlockKeyFlow = bufferedMutableSharedFlow() + private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow() override val uniqueAppId: String get() = UUID.randomUUID().toString() @@ -23,14 +23,15 @@ class FakeAuthDiskSource : AuthDiskSource { override fun getUserBiometricUnlockKey(): String? = userBiometricUnlockKey - override fun getUserBiometricUnlockKeyFlow(): Flow = - userBiometricUnlockKeyFlow - .onSubscription { - emit(getUserBiometricUnlockKey()) - } + override val userBiometricUnlockKeyFlow: Flow + get() = + mutableUserBiometricUnlockKeyFlow + .onSubscription { + emit(getUserBiometricUnlockKey()) + } override fun storeUserBiometricUnlockKey(biometricsKey: String?) { - userBiometricUnlockKeyFlow.tryEmit(biometricsKey) + mutableUserBiometricUnlockKeyFlow.tryEmit(biometricsKey) this@FakeAuthDiskSource.userBiometricUnlockKey = biometricsKey } diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt index dc2f625ae87..f2046192ae7 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt @@ -51,6 +51,7 @@ class SettingsViewModelTest : BaseViewModelTest() { private val mutableDefaultSaveOptionFlow = bufferedMutableSharedFlow() private val mutableScreenCaptureAllowedStateFlow = MutableStateFlow(false) private val mutableIsDynamicColorsEnabledFlow = MutableStateFlow(false) + private val mutableIsUnlockWithBiometricsEnabledFlow = MutableStateFlow(true) private val settingsRepository: SettingsRepository = mockk { every { appLanguage } returns APP_LANGUAGE every { appTheme } returns APP_THEME @@ -64,6 +65,7 @@ class SettingsViewModelTest : BaseViewModelTest() { every { isDynamicColorsEnabled } answers { mutableIsDynamicColorsEnabledFlow.value } every { isDynamicColorsEnabled = any() } just runs every { isDynamicColorsEnabledFlow } returns mutableIsDynamicColorsEnabledFlow + every { isUnlockWithBiometricsEnabledFlow } returns mutableIsUnlockWithBiometricsEnabledFlow } private val clipboardManager: BitwardenClipboardManager = mockk() From 85ff4e73ac204497ec6065a3ac2dfc118f0f7084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Fri, 7 Nov 2025 16:45:25 +0000 Subject: [PATCH 08/22] [PM-27150] Fix typo --- .../x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt | 2 +- .../bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt index f38336bac6e..314dc35cb41 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt @@ -211,7 +211,7 @@ interface AuthDiskSource : AppIdProvider { /** * Gets the flow for the biometrics key for the given [userId]. */ - fun getUserBiometicUnlockKeyFlow(userId: String): Flow + fun getUserBiometricUnlockKeyFlow(userId: String): Flow /** * Retrieves a pin-protected user key for the given [userId]. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt index d994bcd26aa..bcea3c71065 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -330,7 +330,7 @@ class AuthDiskSourceImpl( getMutableBiometricUnlockKeyFlow(userId).tryEmit(biometricsKey) } - override fun getUserBiometicUnlockKeyFlow(userId: String): Flow = + override fun getUserBiometricUnlockKeyFlow(userId: String): Flow = getMutableBiometricUnlockKeyFlow(userId) .onSubscription { emit(getUserBiometricUnlockKey(userId = userId)) } From de0947a9b5d7c7580e2725666804b7184bf4558e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Fri, 7 Nov 2025 17:05:08 +0000 Subject: [PATCH 09/22] [PM-27150] Add replay 1 --- .../data/auth/datasource/disk/AuthDiskSourceImpl.kt | 3 +-- .../data/auth/datasource/disk/util/FakeAuthDiskSource.kt | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt index 7594d231691..94fa5b301be 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -23,8 +23,7 @@ class AuthDiskSourceImpl( sharedPreferences = sharedPreferences, ), AuthDiskSource { - private val mutableUserBiometricUnlockKeyFlow = - bufferedMutableSharedFlow() + private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow(replay = 1) override val uniqueAppId: String get() = getString(key = UNIQUE_APP_ID_KEY) ?: generateAndStoreUniqueAppId() diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index 06048f43041..7a36a3bfcd4 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -10,7 +10,7 @@ class FakeAuthDiskSource : AuthDiskSource { private var lastActiveTimeMillis: Long? = null private var userBiometricUnlockKey: String? = null - private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow() + private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow(replay = 1) override val uniqueAppId: String get() = UUID.randomUUID().toString() From 3afe1b33c239ba33eb6a855489d3462c65bc46df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Wed, 12 Nov 2025 12:16:16 +0000 Subject: [PATCH 10/22] [PM-27150] Fix pre existing typo --- .../data/platform/repository/SettingsRepositoryImpl.kt | 2 +- .../data/auth/datasource/disk/AuthDiskSourceTest.kt | 6 +++--- .../data/auth/datasource/disk/util/FakeAuthDiskSource.kt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt index de2aa9625c2..fc1daeeb6f1 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt @@ -279,7 +279,7 @@ class SettingsRepositoryImpl( get() = activeUserId ?.let { userId -> authDiskSource - .getUserBiometicUnlockKeyFlow(userId) + .getUserBiometricUnlockKeyFlow(userId) .map { it != null } } ?: flowOf(false) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt index 00fe0380a5a..294e9aa6ed3 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt @@ -716,7 +716,7 @@ class AuthDiskSourceTest { runTest { val mockUserId = "mockUserId" val biometricsKey = "1234" - authDiskSource.getUserBiometicUnlockKeyFlow(userId = mockUserId).test { + authDiskSource.getUserBiometricUnlockKeyFlow(userId = mockUserId).test { // The initial values of the Flow and the property are in sync assertNull(authDiskSource.getUserBiometricUnlockKey(userId = mockUserId)) assertNull(awaitItem()) @@ -792,11 +792,11 @@ class AuthDiskSourceTest { @Suppress("MaxLineLength") @Test - fun `storeUserBiometricUnlockKey should update the resulting flow from getUserBiometicUnlockKeyFlow`() = + fun `storeUserBiometricUnlockKey should update the resulting flow from getUserBiometricUnlockKeyFlow`() = runTest { val topSecretKey = "topsecret" val mockUserId = "mockUserId" - authDiskSource.getUserBiometicUnlockKeyFlow(mockUserId).test { + authDiskSource.getUserBiometricUnlockKeyFlow(mockUserId).test { assertNull(awaitItem()) authDiskSource.storeUserBiometricUnlockKey( userId = mockUserId, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index 43a6c431469..4a7da9b32c4 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -277,7 +277,7 @@ class FakeAuthDiskSource : AuthDiskSource { getMutableBiometricUnlockKeyFlow(userId).tryEmit(biometricsKey) } - override fun getUserBiometicUnlockKeyFlow(userId: String): Flow = + override fun getUserBiometricUnlockKeyFlow(userId: String): Flow = getMutableBiometricUnlockKeyFlow(userId) .onSubscription { emit(getUserBiometricUnlockKey(userId)) } From 790ccd0c2f6c536e20161d9c012b6424432eadeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Wed, 12 Nov 2025 12:27:50 +0000 Subject: [PATCH 11/22] [PM-27150] Make isUnlockWithBiometricsEnabledFlow state flow --- .../data/platform/repository/SettingsRepository.kt | 2 +- .../data/platform/repository/SettingsRepositoryImpl.kt | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt index 082f073ac2e..8a15884b0c5 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt @@ -60,7 +60,7 @@ interface SettingsRepository { /** * Tracks whether or not biometric unlocking is enabled for the current user. */ - val isUnlockWithBiometricsEnabledFlow: Flow + val isUnlockWithBiometricsEnabledFlow: StateFlow /** * Tracks changes to the expiration alert threshold. diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt index 09ce247f496..b6b347d89de 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt @@ -66,11 +66,16 @@ class SettingsRepositoryImpl( override val isUnlockWithBiometricsEnabled: Boolean get() = authDiskSource.getUserBiometricUnlockKey() != null - override val isUnlockWithBiometricsEnabledFlow: Flow + override val isUnlockWithBiometricsEnabledFlow: StateFlow get() = authDiskSource .userBiometricUnlockKeyFlow .map { it != null } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = isUnlockWithBiometricsEnabled, + ) override val appThemeStateFlow: StateFlow get() = settingsDiskSource From 385caae9023b9eeb0861ff374c45eafe4975a3fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Wed, 12 Nov 2025 20:19:50 +0000 Subject: [PATCH 12/22] [PM-27150] Extract BiometricChanges composable to be reused. --- .../components/biometrics/BiometricChanges.kt | 25 +++++++++++++++++++ .../platform/feature/rootnav/RootNavScreen.kt | 19 +------------- .../feature/settings/SettingsScreen.kt | 20 +++++++-------- 3 files changed, 36 insertions(+), 28 deletions(-) create mode 100644 authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/biometrics/BiometricChanges.kt diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/biometrics/BiometricChanges.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/biometrics/BiometricChanges.kt new file mode 100644 index 00000000000..283abc54b64 --- /dev/null +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/biometrics/BiometricChanges.kt @@ -0,0 +1,25 @@ +package com.bitwarden.authenticator.ui.platform.components.biometrics + +import androidx.compose.runtime.Composable +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. + */ +@Composable +fun BiometricChanges( + biometricsManager: BiometricsManager, + onBiometricSupportChange: (isSupported: Boolean) -> Unit, +) { + LifecycleEventEffect { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + onBiometricSupportChange(biometricsManager.isBiometricsSupported) + } + + else -> Unit + } + } +} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt index 3cf2ce3b5d9..c21f00cfd3c 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.Lifecycle import androidx.navigation.NavBackStackEntry import androidx.navigation.NavDestination import androidx.navigation.NavHostController @@ -20,6 +19,7 @@ import com.bitwarden.authenticator.ui.auth.unlock.unlockDestination import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.AuthenticatorGraphRoute import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.authenticatorGraph import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.navigateToAuthenticatorGraph +import com.bitwarden.authenticator.ui.platform.components.biometrics.BiometricChanges import com.bitwarden.authenticator.ui.platform.composition.LocalBiometricsManager import com.bitwarden.authenticator.ui.platform.feature.splash.SplashRoute import com.bitwarden.authenticator.ui.platform.feature.splash.navigateToSplash @@ -28,7 +28,6 @@ import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialRoute import com.bitwarden.authenticator.ui.platform.feature.tutorial.navigateToTutorial import com.bitwarden.authenticator.ui.platform.feature.tutorial.tutorialDestination import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager -import com.bitwarden.ui.platform.base.util.LifecycleEventEffect import com.bitwarden.ui.platform.theme.NonNullEnterTransitionProvider import com.bitwarden.ui.platform.theme.NonNullExitTransitionProvider import com.bitwarden.ui.platform.theme.RootTransitionProviders @@ -189,19 +188,3 @@ private fun AnimatedContentTransitionScope.toExitTransition() else -> RootTransitionProviders.Exit.fadeOut } } - -@Composable -private fun BiometricChanges( - biometricsManager: BiometricsManager, - onBiometricSupportChange: (isSupported: Boolean) -> Unit, -) { - LifecycleEventEffect { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - onBiometricSupportChange(biometricsManager.isBiometricsSupported) - } - - else -> Unit - } - } -} diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index d97fbf1798f..aac8c0ead9d 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -41,15 +41,14 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.authenticator.ui.platform.components.biometrics.BiometricChanges import com.bitwarden.authenticator.ui.platform.composition.LocalBiometricsManager import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager import com.bitwarden.authenticator.ui.platform.util.displayLabel import com.bitwarden.ui.platform.base.util.EventsEffect -import com.bitwarden.ui.platform.base.util.LifecycleEventEffect import com.bitwarden.ui.platform.base.util.annotatedStringResource import com.bitwarden.ui.platform.base.util.cardStyle import com.bitwarden.ui.platform.base.util.mirrorIfRtl @@ -92,14 +91,6 @@ fun SettingsScreen( ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - var hasBiometrics by remember { mutableStateOf(biometricsManager.isBiometricsSupported) } - - // Recheck biometrics support when app comes to foreground - LifecycleEventEffect { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - hasBiometrics = biometricsManager.isBiometricsSupported - } - } EventsEffect(viewModel = viewModel) { event -> when (event) { @@ -144,6 +135,15 @@ fun SettingsScreen( } } + BiometricChanges( + biometricsManager = biometricsManager, + onBiometricSupportChange = { + viewModel.trySendAction( + SettingsAction.BiometricSupportChanged(biometricsManager.isBiometricsSupported), + ) + }, + ) + BitwardenScaffold( modifier = Modifier .fillMaxSize() From 9e28ed526969e2de915fa99de76ffe4086aa7f6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Wed, 12 Nov 2025 20:21:48 +0000 Subject: [PATCH 13/22] [PM-27150] Change BiometricSupportChanged to be external. --- .../ui/platform/feature/rootnav/RootNavViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt index 7ca238b9ccc..cdfb4362023 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -171,7 +171,7 @@ sealed class RootNavAction { /** * Indicates an update on device biometrics support. */ - data class BiometricSupportChanged(val isBiometricsSupported: Boolean) : Internal() + data class BiometricSupportChanged(val isBiometricsSupported: Boolean) : RootNavAction() /** * Models actions the [RootNavViewModel] itself may send. From 849201491bce7c763117d74c7a90eb6addc2c2fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Wed, 12 Nov 2025 20:25:42 +0000 Subject: [PATCH 14/22] [PM-27150] Track device biometric changes in Settings UI and ViewModel. --- .../feature/settings/SettingsScreen.kt | 6 ++---- .../feature/settings/SettingsViewModel.kt | 17 +++++++++++++++++ .../feature/settings/SettingsScreenTest.kt | 18 ++++++++++++++++++ .../feature/settings/SettingsViewModelTest.kt | 18 ++++++++++++++++++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index aac8c0ead9d..6b0fd9c03f1 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -163,7 +163,6 @@ fun SettingsScreen( SecuritySettings( state = state, biometricsManager = biometricsManager, - hasBiometrics = hasBiometrics, onBiometricToggle = remember(viewModel) { { viewModel.trySendAction( @@ -286,7 +285,6 @@ fun SettingsScreen( private fun SecuritySettings( state: SettingsState, biometricsManager: BiometricsManager = LocalBiometricsManager.current, - hasBiometrics: Boolean, onBiometricToggle: (Boolean) -> Unit, onScreenCaptureChange: (Boolean) -> Unit, ) { @@ -299,7 +297,7 @@ private fun SecuritySettings( ) Spacer(modifier = Modifier.height(8.dp)) - if (hasBiometrics) { + if (state.hasBiometricsSupport) { UnlockWithBiometricsRow( modifier = Modifier .testTag("UnlockWithBiometricsSwitch") @@ -313,7 +311,7 @@ private fun SecuritySettings( ScreenCaptureRow( currentValue = state.allowScreenCapture, - cardStyle = if (hasBiometrics) { + cardStyle = if (state.hasBiometricsSupport) { CardStyle.Bottom } else { CardStyle.Full diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt index 5aa7ca0ed5f..b27518b69a3 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt @@ -127,6 +127,16 @@ class SettingsViewModel @Inject constructor( is SettingsAction.Internal.UnlockWithBiometricsUpdated -> { handleUnlockWithBiometricsUpdated(action) } + + is SettingsAction.BiometricSupportChanged -> { + handleBiometricSupportChanged(action) + } + } + } + + private fun handleBiometricSupportChanged(action: SettingsAction.BiometricSupportChanged) { + mutableStateFlow.update { + it.copy(hasBiometricsSupport = action.isBiometricsSupported) } } @@ -404,6 +414,7 @@ class SettingsViewModel @Inject constructor( showSyncWithBitwarden = shouldShowSyncWithBitwarden, showDefaultSaveOptionRow = shouldShowDefaultSaveOption, allowScreenCapture = isScreenCaptureAllowed, + hasBiometricsSupport = unlockWithBiometricsEnabled, ) } } @@ -417,6 +428,7 @@ data class SettingsState( val appearance: Appearance, val defaultSaveOption: DefaultSaveOption, val isUnlockWithBiometricsEnabled: Boolean, + val hasBiometricsSupport: Boolean, val isSubmitCrashLogsEnabled: Boolean, val showSyncWithBitwarden: Boolean, val showDefaultSaveOptionRow: Boolean, @@ -523,6 +535,11 @@ sealed class SettingsAction( ) : Dialog() } + /** + * Indicates an update on device biometrics support. + */ + data class BiometricSupportChanged(val isBiometricsSupported: Boolean) : SettingsAction() + /** * Models actions for the Security section of settings. */ diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt index be1e36c6678..9c31d932461 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt @@ -254,6 +254,23 @@ class SettingsScreenTest : AuthenticatorComposeTest() { viewModel.trySendAction(SettingsAction.AppearanceChange.DynamicColorChange(true)) } } + + @Test + fun `Unlock with biometrics row should be hidden when hasBiometricsSupport is false`() { + mutableStateFlow.value = DEFAULT_STATE + composeTestRule + .onNodeWithText("Use your device’s lock method to unlock the app") + .assertExists() + + mutableStateFlow.update { + it.copy( + hasBiometricsSupport = false, + ) + } + composeTestRule + .onNodeWithText("Use your device’s lock method to unlock the app") + .assertDoesNotExist() + } } private val APP_LANGUAGE = AppLanguage.ENGLISH @@ -276,4 +293,5 @@ private val DEFAULT_STATE = SettingsState( .concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()), copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(), allowScreenCapture = false, + hasBiometricsSupport = true, ) diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt index f2046192ae7..13a4c1d2fe5 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt @@ -265,6 +265,23 @@ class SettingsViewModelTest : BaseViewModelTest() { ) } + @Test + fun `on BiometricSupportChanged should update value in state`() = + runTest { + val viewModel = createViewModel() + + viewModel.trySendAction( + SettingsAction.BiometricSupportChanged(isBiometricsSupported = false), + ) + + assertEquals( + DEFAULT_STATE.copy( + hasBiometricsSupport = false, + ), + viewModel.stateFlow.value, + ) + } + private fun createViewModel( savedState: SettingsState? = DEFAULT_STATE, ) = SettingsViewModel( @@ -303,4 +320,5 @@ private val DEFAULT_STATE = SettingsState( .concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()), copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(), allowScreenCapture = false, + hasBiometricsSupport = true, ) From 4556e1a16579c4b652161b3509671fffad127243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Wed, 12 Nov 2025 21:11:26 +0000 Subject: [PATCH 15/22] [PM-27150] Add Kdoc --- .../platform/components/biometrics/BiometricChanges.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/biometrics/BiometricChanges.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/biometrics/BiometricChanges.kt index 283abc54b64..7f41b4c2088 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/biometrics/BiometricChanges.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/biometrics/BiometricChanges.kt @@ -6,7 +6,15 @@ import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsMana import com.bitwarden.ui.platform.base.util.LifecycleEventEffect /** - * Tracks changes in biometric support. + * 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 (ON_RESUME), useful for detecting + * when biometric settings change while the app is backgrounded. + * + * @param biometricsManager Manager to check current biometric support status. + * @param onBiometricSupportChange Callback invoked with the current biometric + * support status when the app resumes. */ @Composable fun BiometricChanges( From 9180c523698010af26abc1f3207315a92e014798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Wed, 12 Nov 2025 21:12:48 +0000 Subject: [PATCH 16/22] [PM-27150] Pass callback value --- .../ui/platform/feature/settings/SettingsScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index 6b0fd9c03f1..024b53a0d92 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -137,9 +137,9 @@ fun SettingsScreen( BiometricChanges( biometricsManager = biometricsManager, - onBiometricSupportChange = { + onBiometricSupportChange = { isSupported -> viewModel.trySendAction( - SettingsAction.BiometricSupportChanged(biometricsManager.isBiometricsSupported), + SettingsAction.BiometricSupportChanged(isSupported), ) }, ) From 2de01971aca56b5c0205e7f9377a9e283dc90346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Thu, 13 Nov 2025 10:40:32 +0000 Subject: [PATCH 17/22] [PM-27150] Add missing test classes for RootNav in Authenticator. --- .../feature/rootnav/RootNavScreenTest.kt | 312 ++++++++++++++++++ .../feature/rootnav/RootNavViewModelTest.kt | 306 +++++++++++++++++ 2 files changed, 618 insertions(+) create mode 100644 authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreenTest.kt create mode 100644 authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModelTest.kt diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreenTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreenTest.kt new file mode 100644 index 00000000000..e6ec93f4a6c --- /dev/null +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -0,0 +1,312 @@ +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 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, + ) + } + assert(!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, + ) + } + assert(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, + ) + } + assert(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, + ) + } + assert(onSplashScreenRemovedCalled) + } + } + + @Test + fun `onSplashScreenRemoved should be called when navState changes from Splash`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Splash, + ) + + composeTestRule.runOnIdle { + assert(!onSplashScreenRemovedCalled) + } + + mutableStateFlow.update { + it.copy(navState = RootNavState.NavState.Tutorial) + } + + composeTestRule.runOnIdle { + assert(onSplashScreenRemovedCalled) + } + } + + @Test + fun `onSplashScreenRemoved should not be called when navState is Splash`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Splash, + ) + + composeTestRule.runOnIdle { + assert(!onSplashScreenRemovedCalled) + } + } + + @Test + fun `navigation should handle Splash to Tutorial transition`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Splash, + ) + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = SplashRoute, + navOptions = expectedNavOptions, + ) + } + } + + mutableStateFlow.update { + it.copy(navState = RootNavState.NavState.Tutorial) + } + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = TutorialRoute, + navOptions = expectedNavOptions, + ) + } + } + } + + @Test + fun `navigation should handle Tutorial to Unlocked transition`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Tutorial, + ) + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = TutorialRoute, + navOptions = expectedNavOptions, + ) + } + } + + mutableStateFlow.update { + it.copy(navState = RootNavState.NavState.Unlocked) + } + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = AuthenticatorNavbarRoute, + navOptions = expectedNavOptions, + ) + } + } + } + + @Test + fun `navigation should handle Splash to Locked transition`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Splash, + ) + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = SplashRoute, + navOptions = expectedNavOptions, + ) + } + } + + mutableStateFlow.update { + it.copy(navState = RootNavState.NavState.Locked) + } + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = UnlockRoute, + navOptions = expectedNavOptions, + ) + } + } + } + + @Test + fun `navigation should handle Locked to Unlocked transition`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Locked, + ) + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = UnlockRoute, + navOptions = expectedNavOptions, + ) + } + } + + mutableStateFlow.update { + it.copy(navState = RootNavState.NavState.Unlocked) + } + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = AuthenticatorNavbarRoute, + navOptions = expectedNavOptions, + ) + } + } + } + + @Test + fun `navigation should handle Splash to Unlocked transition`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + navState = RootNavState.NavState.Splash, + ) + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = SplashRoute, + navOptions = expectedNavOptions, + ) + } + } + + mutableStateFlow.update { + it.copy(navState = RootNavState.NavState.Unlocked) + } + + composeTestRule.runOnIdle { + verify { + navController.navigate( + route = AuthenticatorNavbarRoute, + navOptions = expectedNavOptions, + ) + } + } + } +} + +private val DEFAULT_STATE = RootNavState( + hasSeenWelcomeGuide = false, + navState = RootNavState.NavState.Splash, +) diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModelTest.kt new file mode 100644 index 00000000000..4a8f61fdac4 --- /dev/null +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -0,0 +1,306 @@ +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 `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, + ) +} From df27f3205da9d4000e21a77beccecaa6be091d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Thu, 13 Nov 2025 10:42:32 +0000 Subject: [PATCH 18/22] [PM-27150] Init biometric support as true --- .../ui/platform/feature/settings/SettingsViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt index b27518b69a3..2d2ee3972e6 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt @@ -414,7 +414,7 @@ class SettingsViewModel @Inject constructor( showSyncWithBitwarden = shouldShowSyncWithBitwarden, showDefaultSaveOptionRow = shouldShowDefaultSaveOption, allowScreenCapture = isScreenCaptureAllowed, - hasBiometricsSupport = unlockWithBiometricsEnabled, + hasBiometricsSupport = true, ) } } From 25f028cdb9100ef9dcab4f01e158474fe0e2e716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Thu, 13 Nov 2025 11:24:02 +0000 Subject: [PATCH 19/22] [PM-27150] Move unlock screen navigation to RootNavViewModel --- .../ui/auth/unlock/UnlockScreen.kt | 7 --- .../feature/rootnav/RootNavViewModel.kt | 5 ++ .../feature/rootnav/RootNavViewModelTest.kt | 63 +++++++++++++++++++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt index 4330fa4a294..0327a9b50b4 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -54,12 +53,6 @@ fun UnlockScreen( } } - LaunchedEffect(biometricsManager.isBiometricsSupported) { - if (!biometricsManager.isBiometricsSupported) { - onUnlocked() - } - } - when (val dialog = state.dialog) { is UnlockState.Dialog.Error -> BitwardenBasicDialog( title = stringResource(id = BitwardenString.an_error_has_occurred), diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt index cdfb4362023..29c6746e555 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -112,6 +112,11 @@ class RootNavViewModel @Inject constructor( ) { 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) } + } } } } diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModelTest.kt index 4a8f61fdac4..c691abb70d7 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -267,6 +267,69 @@ class RootNavViewModelTest : BaseViewModelTest() { 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`() = From d09486f2824154a1e97ef019aba1a94ce6058bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Thu, 13 Nov 2025 11:56:13 +0000 Subject: [PATCH 20/22] [PM-27150] Add launch effect to BiometricChanges composable --- .../components/biometrics/BiometricChanges.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/biometrics/BiometricChanges.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/biometrics/BiometricChanges.kt index 7f41b4c2088..57f87473897 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/biometrics/BiometricChanges.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/biometrics/BiometricChanges.kt @@ -1,6 +1,7 @@ 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 @@ -9,18 +10,22 @@ 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 (ON_RESUME), useful for detecting - * when biometric settings change while the app is backgrounded. + * 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 when the app resumes. + * 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 -> { From 3018a6c9d083ce72e63a29a86b7172878d2fef53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Thu, 13 Nov 2025 12:17:55 +0000 Subject: [PATCH 21/22] [PM-27150] Add remember viewmodel to avoid recreation on recomposition. --- .../ui/platform/feature/rootnav/RootNavScreen.kt | 6 ++++-- .../ui/platform/feature/settings/SettingsScreen.kt | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt index c21f00cfd3c..9af0b080ed0 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt @@ -67,8 +67,10 @@ fun RootNavScreen( BiometricChanges( biometricsManager = biometricsManager, - onBiometricSupportChange = { - viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it)) + onBiometricSupportChange = remember(viewModel) { + { + viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it)) + } }, ) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index 024b53a0d92..9a5d9331ce3 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -137,10 +137,10 @@ fun SettingsScreen( BiometricChanges( biometricsManager = biometricsManager, - onBiometricSupportChange = { isSupported -> - viewModel.trySendAction( - SettingsAction.BiometricSupportChanged(isSupported), - ) + onBiometricSupportChange = remember(viewModel) { + { + viewModel.trySendAction(SettingsAction.BiometricSupportChanged(it)) + } }, ) From 3c01f60ca2f46753580e728cdb0a4f50e06bd611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Thu, 13 Nov 2025 23:47:34 +0000 Subject: [PATCH 22/22] [PM-27150] Change assert to assertTrue or assertFalse --- .../feature/rootnav/RootNavScreenTest.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreenTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreenTest.kt index e6ec93f4a6c..e2e1a98ea06 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreenTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -13,6 +13,8 @@ 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 @@ -75,7 +77,7 @@ class RootNavScreenTest : AuthenticatorComposeTest() { navOptions = expectedNavOptions, ) } - assert(!onSplashScreenRemovedCalled) + assertFalse(onSplashScreenRemovedCalled) } } @@ -92,7 +94,7 @@ class RootNavScreenTest : AuthenticatorComposeTest() { navOptions = expectedNavOptions, ) } - assert(onSplashScreenRemovedCalled) + assertTrue(onSplashScreenRemovedCalled) } } @@ -109,7 +111,7 @@ class RootNavScreenTest : AuthenticatorComposeTest() { navOptions = expectedNavOptions, ) } - assert(onSplashScreenRemovedCalled) + assertTrue(onSplashScreenRemovedCalled) } } @@ -126,7 +128,7 @@ class RootNavScreenTest : AuthenticatorComposeTest() { navOptions = expectedNavOptions, ) } - assert(onSplashScreenRemovedCalled) + assertTrue(onSplashScreenRemovedCalled) } } @@ -137,7 +139,7 @@ class RootNavScreenTest : AuthenticatorComposeTest() { ) composeTestRule.runOnIdle { - assert(!onSplashScreenRemovedCalled) + assertFalse(onSplashScreenRemovedCalled) } mutableStateFlow.update { @@ -145,7 +147,7 @@ class RootNavScreenTest : AuthenticatorComposeTest() { } composeTestRule.runOnIdle { - assert(onSplashScreenRemovedCalled) + assertTrue(onSplashScreenRemovedCalled) } } @@ -156,7 +158,7 @@ class RootNavScreenTest : AuthenticatorComposeTest() { ) composeTestRule.runOnIdle { - assert(!onSplashScreenRemovedCalled) + assertFalse(onSplashScreenRemovedCalled) } }