-
Notifications
You must be signed in to change notification settings - Fork 918
[PM-27150] React to device changes on device screen unlock method #6103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 19 commits
e55b8b7
3533af5
46c1cb3
436bcc1
793e798
3ce913d
e7ac063
85ff4e7
de0947a
12c28c2
3afe1b3
790ccd0
385caae
9e28ed5
8492014
4556e1a
9180c52
2de0197
df27f32
25f028c
d09486f
3018a6c
3c01f60
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,10 @@ | ||
| package com.bitwarden.authenticator.data.auth.datasource.disk | ||
|
|
||
| import android.content.SharedPreferences | ||
| import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow | ||
| import com.bitwarden.data.datasource.disk.BaseEncryptedDiskSource | ||
| import kotlinx.coroutines.flow.Flow | ||
| import kotlinx.coroutines.flow.onSubscription | ||
| import java.util.UUID | ||
|
|
||
| private const val AUTHENTICATOR_SYNC_SYMMETRIC_KEY = "authenticatorSyncSymmetricKey" | ||
|
|
@@ -20,6 +23,7 @@ class AuthDiskSourceImpl( | |
| sharedPreferences = sharedPreferences, | ||
| ), | ||
| AuthDiskSource { | ||
| private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow<String?>(replay = 1) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐ Finding 6: Correct use of replay buffer in MutableSharedFlow DetailsThe private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow<String?>(replay = 1)Why this is correct:
Flow behavior:
This implementation correctly maintains a reactive, state-like flow for biometric key changes.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐ Finding 6: Correct use of replay buffer DetailsThe private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow<String?>(replay = 1)Why this is correct:
Flow behavior:
This implementation correctly maintains a reactive, state-like flow for biometric key changes.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐ Correct use of replay buffer DetailsThe private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow<String?>(replay = 1)Why this is correct:
The
This correctly maintains a reactive, state-like flow for biometric key changes. |
||
|
|
||
| override val uniqueAppId: String | ||
| get() = getString(key = UNIQUE_APP_ID_KEY) ?: generateAndStoreUniqueAppId() | ||
|
|
@@ -39,13 +43,21 @@ class AuthDiskSourceImpl( | |
| override fun getUserBiometricUnlockKey(): String? = | ||
| getEncryptedString(key = BIOMETRICS_UNLOCK_KEY) | ||
|
|
||
| override val userBiometricUnlockKeyFlow: Flow<String?> | ||
| get() = | ||
| mutableUserBiometricUnlockKeyFlow | ||
| .onSubscription { | ||
| emit(getUserBiometricUnlockKey()) | ||
| } | ||
|
|
||
| override fun storeUserBiometricUnlockKey( | ||
| biometricsKey: String?, | ||
| ) { | ||
| putEncryptedString( | ||
| key = BIOMETRICS_UNLOCK_KEY, | ||
| value = biometricsKey, | ||
| ) | ||
| mutableUserBiometricUnlockKeyFlow.tryEmit(biometricsKey) | ||
| } | ||
|
|
||
| override var authenticatorBridgeSymmetricSyncKey: ByteArray? | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| 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 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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐จ Finding 4: Consider adding KDoc for public composable DetailsPer project guidelines (CLAUDE.md), all public APIs should have KDoc documentation. While there's a brief comment, consider expanding it to include parameter descriptions: /**
* 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.
*/This helps other developers understand when and why to use this composable. |
||
| biometricsManager: BiometricsManager, | ||
| onBiometricSupportChange: (isSupported: Boolean) -> Unit, | ||
| ) { | ||
| LifecycleEventEffect { _, event -> | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If this is going to monitor Biometric changes, what do you think about adding this as well? LaunchedEffect(biometricsManager.isBiometricsSupported) {
onBiometricSupportChange(biometricsManager.isBiometricsSupported)
}This will allow this to emit on resume and on recomposition all in one. |
||
| when (event) { | ||
| Lifecycle.Event.ON_RESUME -> { | ||
| onBiometricSupportChange(biometricsManager.isBiometricsSupported) | ||
| } | ||
|
|
||
| else -> Unit | ||
| } | ||
| } | ||
|
Comment on lines
+29
to
+37
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐ญ Consider adding LaunchedEffect for recomposition detection Detailsdavid-livefront suggested adding a LaunchedEffect(biometricsManager.isBiometricsSupported) {
onBiometricSupportChange(biometricsManager.isBiometricsSupported)
}Current behavior: Only checks on With LaunchedEffect: Would also trigger when Analysis:
Recommendation: Either add the |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,12 +19,15 @@ import com.bitwarden.authenticator.ui.auth.unlock.unlockDestination | |
| import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.AuthenticatorGraphRoute | ||
| import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.authenticatorGraph | ||
| import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.navigateToAuthenticatorGraph | ||
| import com.bitwarden.authenticator.ui.platform.components.biometrics.BiometricChanges | ||
| import com.bitwarden.authenticator.ui.platform.composition.LocalBiometricsManager | ||
| import com.bitwarden.authenticator.ui.platform.feature.splash.SplashRoute | ||
| import com.bitwarden.authenticator.ui.platform.feature.splash.navigateToSplash | ||
| import com.bitwarden.authenticator.ui.platform.feature.splash.splashDestination | ||
| import com.bitwarden.authenticator.ui.platform.feature.tutorial.TutorialRoute | ||
| import com.bitwarden.authenticator.ui.platform.feature.tutorial.navigateToTutorial | ||
| import com.bitwarden.authenticator.ui.platform.feature.tutorial.tutorialDestination | ||
| import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager | ||
| import com.bitwarden.ui.platform.theme.NonNullEnterTransitionProvider | ||
| import com.bitwarden.ui.platform.theme.NonNullExitTransitionProvider | ||
| import com.bitwarden.ui.platform.theme.RootTransitionProviders | ||
|
|
@@ -41,7 +44,8 @@ import java.util.concurrent.atomic.AtomicReference | |
| fun RootNavScreen( | ||
| viewModel: RootNavViewModel = hiltViewModel(), | ||
| navController: NavHostController = rememberNavController(), | ||
| onSplashScreenRemoved: () -> Unit, | ||
| biometricsManager: BiometricsManager = LocalBiometricsManager.current, | ||
| onSplashScreenRemoved: () -> Unit = {}, | ||
| ) { | ||
| val state by viewModel.stateFlow.collectAsState() | ||
| val previousStateReference = remember { AtomicReference(state) } | ||
|
|
@@ -61,6 +65,13 @@ fun RootNavScreen( | |
| .launchIn(this) | ||
| } | ||
|
|
||
| BiometricChanges( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐ Finding 5: Well-structured biometric monitoring implementation DetailsThe โ Separated into a reusable composable function with comprehensive KDoc Architectural correctness: Placing this at the RootNav level is appropriate since:
This approach follows MVVM principles and maintains a single source of truth for biometric support status. |
||
| biometricsManager = biometricsManager, | ||
| onBiometricSupportChange = { | ||
| viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it)) | ||
| }, | ||
david-livefront marked this conversation as resolved.
Show resolved
Hide resolved
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DetailsPer david-livefront's comment, the callback should be wrapped in onBiometricSupportChange = remember(viewModel) {
{ viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it)) }
},This prevents unnecessary recompositions of |
||
| ) | ||
|
Comment on lines
68
to
75
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐จ Add remember(viewModel) for callback stability DetailsPer david-livefront's comment: "Can you make sure to remember the viewModel here" Current code: onBiometricSupportChange = {
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it))
},Recommended: onBiometricSupportChange = remember(viewModel) {
{ viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it)) }
},Why this matters:
|
||
|
|
||
| NavHost( | ||
| navController = navController, | ||
| startDestination = SplashRoute, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,7 +46,7 @@ class RootNavViewModel @Inject constructor( | |
| } | ||
|
|
||
| is RootNavAction.Internal.HasSeenWelcomeTutorialChange -> { | ||
| handleHasSeenWelcomeTutorialChange(action.hasSeenWelcomeGuide) | ||
| handleHasSeenWelcomeTutorialChange(action) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐ |
||
| } | ||
|
|
||
| RootNavAction.Internal.TutorialFinished -> { | ||
|
|
@@ -60,18 +60,25 @@ class RootNavViewModel @Inject constructor( | |
| RootNavAction.Internal.AppUnlocked -> { | ||
| handleAppUnlocked() | ||
| } | ||
|
|
||
| is RootNavAction.BiometricSupportChanged -> { | ||
| handleBiometricSupportChanged(action) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun handleBackStackUpdate() { | ||
| authRepository.updateLastActiveTime() | ||
| } | ||
|
|
||
| private fun handleHasSeenWelcomeTutorialChange(hasSeenWelcomeGuide: Boolean) { | ||
| settingsRepository.hasSeenWelcomeTutorial = hasSeenWelcomeGuide | ||
| if (hasSeenWelcomeGuide) { | ||
| private fun handleHasSeenWelcomeTutorialChange( | ||
| action: RootNavAction.Internal.HasSeenWelcomeTutorialChange, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐ |
||
| ) { | ||
| settingsRepository.hasSeenWelcomeTutorial = action.hasSeenWelcomeGuide | ||
| if (action.hasSeenWelcomeGuide) { | ||
| if (settingsRepository.isUnlockWithBiometricsEnabled && | ||
| biometricsEncryptionManager.isBiometricIntegrityValid()) { | ||
| biometricsEncryptionManager.isBiometricIntegrityValid() | ||
| ) { | ||
| mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Locked) } | ||
| } else { | ||
| mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) } | ||
|
|
@@ -99,6 +106,14 @@ class RootNavViewModel @Inject constructor( | |
| it.copy(navState = RootNavState.NavState.Unlocked) | ||
| } | ||
| } | ||
|
|
||
| private fun handleBiometricSupportChanged( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DetailsThe Required test:
Test should be added to
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DetailsThe Required tests:
Example test structure: @Test
fun `handleBiometricSupportChanged should clear biometrics key when not supported`() = runTest {
val action = RootNavAction.BiometricSupportChanged(isBiometricsSupported = false)
viewModel.trySendAction(action)
verify { settingsRepository.clearBiometricsKey() }
}
@Test
fun `handleBiometricSupportChanged should do nothing when biometrics supported`() = runTest {
val action = RootNavAction.BiometricSupportChanged(isBiometricsSupported = true)
viewModel.trySendAction(action)
verify(exactly = 0) { settingsRepository.clearBiometricsKey() }
}Add these tests to
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐จ Good navigation logic centralization DetailsThe โ Clears the biometric key when biometrics become unsupported This ensures the app gracefully handles biometric removal while maintaining a single source of truth for navigation state. Note: Test coverage for this function was added in
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐ Finding 3: Proper navigation logic centralization DetailsThe โ Clears the biometric key when biometrics become unsupported This ensures the app gracefully handles biometric removal while maintaining a single source of truth for navigation state, which is exactly what was requested in earlier review feedback. Navigation flow when biometrics are disabled:
|
||
| action: RootNavAction.BiometricSupportChanged, | ||
| ) { | ||
| if (!action.isBiometricsSupported) { | ||
| settingsRepository.clearBiometricsKey() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐จ Consider extracting navigation state check to improve readability DetailsThe current implementation checks the navigation state inline: if (mutableStateFlow.value.navState is RootNavState.NavState.Locked) {
mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) }
}Minor improvement: Extract to a local variable for clarity: private fun handleBiometricSupportChanged(
action: RootNavAction.BiometricSupportChanged,
) {
if (!action.isBiometricsSupported) {
settingsRepository.clearBiometricsKey()
// If currently locked, navigate to unlocked since biometrics are no longer available
val isCurrentlyLocked = mutableStateFlow.value.navState is RootNavState.NavState.Locked
if (isCurrentlyLocked) {
mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) }
}
}
}This makes the intent clearer and slightly improves readability, though the current implementation is acceptable. |
||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -153,6 +168,11 @@ sealed class RootNavAction { | |
| */ | ||
| data object BackStackUpdate : RootNavAction() | ||
|
|
||
| /** | ||
| * Indicates an update on device biometrics support. | ||
| */ | ||
| data class BiometricSupportChanged(val isBiometricsSupported: Boolean) : RootNavAction() | ||
|
|
||
| /** | ||
| * Models actions the [RootNavViewModel] itself may send. | ||
| */ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp | |
| import androidx.core.net.toUri | ||
| import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||
| import androidx.lifecycle.compose.collectAsStateWithLifecycle | ||
| import com.bitwarden.authenticator.ui.platform.components.biometrics.BiometricChanges | ||
| import com.bitwarden.authenticator.ui.platform.composition.LocalBiometricsManager | ||
| import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage | ||
| import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption | ||
|
|
@@ -134,6 +135,15 @@ fun SettingsScreen( | |
| } | ||
| } | ||
|
|
||
| BiometricChanges( | ||
| biometricsManager = biometricsManager, | ||
| onBiometricSupportChange = { isSupported -> | ||
|
||
| viewModel.trySendAction( | ||
| SettingsAction.BiometricSupportChanged(isSupported), | ||
| ) | ||
| }, | ||
| ) | ||
|
|
||
| BitwardenScaffold( | ||
| modifier = Modifier | ||
| .fillMaxSize() | ||
|
|
@@ -287,8 +297,7 @@ private fun SecuritySettings( | |
| ) | ||
|
|
||
| Spacer(modifier = Modifier.height(8.dp)) | ||
| val hasBiometrics = biometricsManager.isBiometricsSupported | ||
| if (hasBiometrics) { | ||
| if (state.hasBiometricsSupport) { | ||
| UnlockWithBiometricsRow( | ||
| modifier = Modifier | ||
| .testTag("UnlockWithBiometricsSwitch") | ||
|
|
@@ -302,7 +311,7 @@ private fun SecuritySettings( | |
|
|
||
| ScreenCaptureRow( | ||
| currentValue = state.allowScreenCapture, | ||
| cardStyle = if (hasBiometrics) { | ||
| cardStyle = if (state.hasBiometricsSupport) { | ||
| CardStyle.Bottom | ||
| } else { | ||
| CardStyle.Full | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
๐จ Finding 2: Consider adding replay buffer to MutableSharedFlow
Details
The
mutableUserBiometricUnlockKeyFlowusesreplay = 1which ensures new collectors receive the most recent value. However, this should be verified against the app's password manager module for consistency.Looking at the password manager module in
app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt, similar flows usereplay = 1for state that should be immediately available to new subscribers.Current implementation is correct - the
replay = 1parameter ensures that:isUnlockWithBiometricsEnabledFlowproperly reflects the current stateThis is the appropriate pattern for this use case.