1
0
Fork 0
mirror of https://github.com/bitwarden/android.git synced 2025-02-13 18:39:56 +03:00

PM-13988 observe changes to unlock status on settings screen ()

This commit is contained in:
Dave Severns 2024-10-29 09:27:46 -04:00 committed by GitHub
parent 8df4292e08
commit f3916b4ef6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 246 additions and 0 deletions
app/src
main/java/com/x8bit/bitwarden
data
ui/platform/feature/settings/accountsecurity
test/java/com/x8bit/bitwarden
data
ui/platform/feature/settings/accountsecurity

View file

@ -181,6 +181,11 @@ interface AuthDiskSource {
*/
fun storeUserBiometricUnlockKey(userId: String, biometricsKey: String?)
/**
* Gets the flow for the biometrics key for the given [userId].
*/
fun getUserBiometicUnlockKeyFlow(userId: String): Flow<String?>
/**
* Retrieves a pin-protected user key for the given [userId].
*/
@ -198,6 +203,11 @@ interface AuthDiskSource {
inMemoryOnly: Boolean = false,
)
/**
* Retrieves a flow for the pin-protected user key for the given [userId].
*/
fun getPinProtectedUserKeyFlow(userId: String): Flow<String?>
/**
* Gets a two-factor auth token using a user's [email].
*/

View file

@ -74,6 +74,10 @@ class AuthDiskSourceImpl(
private val mutableOnboardingStatusFlowMap =
mutableMapOf<String, MutableSharedFlow<OnboardingStatus?>>()
private val mutableShowImportLoginsFlowMap = mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableBiometricUnlockKeyFlowMap =
mutableMapOf<String, MutableSharedFlow<String?>>()
private val mutablePinProtectedUserKeyFlowMap =
mutableMapOf<String, MutableSharedFlow<String?>>()
private val mutableUserStateFlow = bufferedMutableSharedFlow<UserStateJson?>(replay = 1)
override var userState: UserStateJson?
@ -284,8 +288,13 @@ class AuthDiskSourceImpl(
key = BIOMETRICS_UNLOCK_KEY.appendIdentifier(userId),
value = biometricsKey,
)
getMutableBiometricUnlockKeyFlow(userId).tryEmit(biometricsKey)
}
override fun getUserBiometicUnlockKeyFlow(userId: String): Flow<String?> =
getMutableBiometricUnlockKeyFlow(userId)
.onSubscription { emit(getUserBiometricUnlockKey(userId = userId)) }
override fun getPinProtectedUserKey(userId: String): String? =
inMemoryPinProtectedUserKeys[userId]
?: getString(key = PIN_PROTECTED_USER_KEY_KEY.appendIdentifier(userId))
@ -301,8 +310,13 @@ class AuthDiskSourceImpl(
key = PIN_PROTECTED_USER_KEY_KEY.appendIdentifier(userId),
value = pinProtectedUserKey,
)
getMutablePinProtectedUserKeyFlow(userId).tryEmit(pinProtectedUserKey)
}
override fun getPinProtectedUserKeyFlow(userId: String): Flow<String?> =
getMutablePinProtectedUserKeyFlow(userId)
.onSubscription { emit(getPinProtectedUserKey(userId = userId)) }
override fun getTwoFactorToken(email: String): String? =
getString(key = TWO_FACTOR_TOKEN_KEY.appendIdentifier(email))
@ -506,6 +520,18 @@ class AuthDiskSourceImpl(
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableBiometricUnlockKeyFlow(
userId: String,
): MutableSharedFlow<String?> = mutableBiometricUnlockKeyFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutablePinProtectedUserKeyFlow(
userId: String,
): MutableSharedFlow<String?> = mutablePinProtectedUserKeyFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun migrateAccountTokens() {
userState
?.accounts

View file

@ -108,11 +108,21 @@ interface SettingsRepository {
*/
val isUnlockWithBiometricsEnabled: Boolean
/**
* Emits updates whenever there is a change in the user status for biometric unlocking.
*/
val isUnlockWithBiometricsEnabledFlow: Flow<Boolean>
/**
* Whether or not PIN unlocking is enabled for the current user.
*/
val isUnlockWithPinEnabled: Boolean
/**
* Emits updates whenever there is a change in the user status for PIN unlocking.
*/
val isUnlockWithPinEnabledFlow: Flow<Boolean>
/**
* Whether or not inline autofill is enabled for the current user.
*/

View file

@ -245,11 +245,29 @@ class SettingsRepositoryImpl(
?.let { authDiskSource.getUserBiometricUnlockKey(userId = it) != null }
?: false
override val isUnlockWithBiometricsEnabledFlow: Flow<Boolean>
get() = activeUserId
?.let { userId ->
authDiskSource
.getUserBiometicUnlockKeyFlow(userId)
.map { it != null }
}
?: flowOf(false)
override val isUnlockWithPinEnabled: Boolean
get() = activeUserId
?.let { authDiskSource.getEncryptedPin(userId = it) != null }
?: false
override val isUnlockWithPinEnabledFlow: Flow<Boolean>
get() = activeUserId
?.let { userId ->
authDiskSource
.getPinProtectedUserKeyFlow(userId)
.map { it != null }
}
?: flowOf(false)
override var isInlineAutofillEnabled: Boolean
get() = activeUserId
?.let { settingsDiskSource.getInlineAutofillEnabled(userId = it) }

View file

@ -126,6 +126,26 @@ class AccountSecurityViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
settingsRepository
.isUnlockWithBiometricsEnabledFlow
.map {
AccountSecurityAction.Internal.BiometricLockUpdate(
isBiometricEnabled = it,
)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
settingsRepository
.isUnlockWithPinEnabledFlow
.map {
AccountSecurityAction.Internal.PinProtectedLockUpdate(
isPinProtected = it,
)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
viewModelScope.launch {
trySendAction(
AccountSecurityAction.Internal.FingerprintResultReceive(
@ -358,6 +378,34 @@ class AccountSecurityViewModel @Inject constructor(
is AccountSecurityAction.Internal.ShowUnlockBadgeUpdated -> {
handleShowUnlockBadgeUpdated(action)
}
is AccountSecurityAction.Internal.BiometricLockUpdate -> {
hanleBiometricUnlockUpdate(action)
}
is AccountSecurityAction.Internal.PinProtectedLockUpdate -> {
handlePinProtectedLockUpdate(action)
}
}
}
private fun handlePinProtectedLockUpdate(
action: AccountSecurityAction.Internal.PinProtectedLockUpdate,
) {
mutableStateFlow.update {
it.copy(
isUnlockWithPinEnabled = action.isPinProtected,
)
}
}
private fun hanleBiometricUnlockUpdate(
action: AccountSecurityAction.Internal.BiometricLockUpdate,
) {
mutableStateFlow.update {
it.copy(
isUnlockWithBiometricsEnabled = action.isBiometricEnabled,
)
}
}
@ -734,5 +782,19 @@ sealed class AccountSecurityAction {
* The show unlock badge update has been received.
*/
data class ShowUnlockBadgeUpdated(val showUnlockBadge: Boolean) : Internal()
/**
* The user's biometric unlock status has been updated.
*/
data class BiometricLockUpdate(
val isBiometricEnabled: Boolean,
) : Internal()
/**
* The user's pin unlock status has been updated.
*/
data class PinProtectedLockUpdate(
val isPinProtected: Boolean,
) : Internal()
}
}

View file

@ -684,6 +684,22 @@ class AuthDiskSourceTest {
assertFalse(fakeEncryptedSharedPreferences.contains(biometricsKeyKey))
}
@Suppress("MaxLineLength")
@Test
fun `storeUserBiometricUnlockKey should update the resulting flow from getUserBiometicUnlockKeyFlow`() =
runTest {
val topSecretKey = "topsecret"
val mockUserId = "mockUserId"
authDiskSource.getUserBiometicUnlockKeyFlow(mockUserId).test {
assertNull(awaitItem())
authDiskSource.storeUserBiometricUnlockKey(
userId = mockUserId,
biometricsKey = topSecretKey,
)
assertEquals(topSecretKey, awaitItem())
}
}
@Test
fun `getPinProtectedUserKey should pull from SharedPreferences`() {
val pinProtectedUserKeyBaseKey = "bwPreferencesStorage:pinKeyEncryptedUserKey"
@ -723,6 +739,21 @@ class AuthDiskSourceTest {
)
}
@Test
fun `storePinProtectedUserKey should update result flow from getPinProtectedUserKeyFlow`() =
runTest {
val topSecretKey = "topsecret"
val mockUserId = "mockUserId"
authDiskSource.getPinProtectedUserKeyFlow(mockUserId).test {
assertNull(awaitItem())
authDiskSource.storePinProtectedUserKey(
userId = mockUserId,
pinProtectedUserKey = topSecretKey,
)
assertEquals(topSecretKey, awaitItem())
}
}
@Test
fun `getEncryptedPin should pull from SharedPreferences`() {
val encryptedPinBaseKey = "bwPreferencesStorage:protectedPin"

View file

@ -30,6 +30,9 @@ class FakeAuthDiskSource : AuthDiskSource {
private val mutableAccountTokensFlowMap =
mutableMapOf<String, MutableSharedFlow<AccountTokensJson?>>()
private val mutableShowImportLoginsFlowMap = mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableBiometricKeysFlowMap = mutableMapOf<String, MutableSharedFlow<String?>>()
private val mutablePinProtectedUserKeysFlowMap =
mutableMapOf<String, MutableSharedFlow<String?>>()
private val mutableOnboardingStatusFlowMap =
mutableMapOf<String, MutableSharedFlow<OnboardingStatus?>>()
@ -158,8 +161,15 @@ class FakeAuthDiskSource : AuthDiskSource {
inMemoryOnly: Boolean,
) {
storedPinProtectedUserKeys[userId] = pinProtectedUserKey to inMemoryOnly
getMutablePinProtectedUserKeyFlow(userId).tryEmit(pinProtectedUserKey)
}
override fun getPinProtectedUserKeyFlow(userId: String): Flow<String?> =
getMutablePinProtectedUserKeyFlow(userId)
.onSubscription {
emit(getPinProtectedUserKey(userId))
}
override fun getEncryptedPin(userId: String): String? =
storedEncryptedPins[userId]
@ -216,8 +226,13 @@ class FakeAuthDiskSource : AuthDiskSource {
override fun storeUserBiometricUnlockKey(userId: String, biometricsKey: String?) {
storedBiometricKeys[userId] = biometricsKey
getMutableBiometricUnlockKeyFlow(userId).tryEmit(biometricsKey)
}
override fun getUserBiometicUnlockKeyFlow(userId: String): Flow<String?> =
getMutableBiometricUnlockKeyFlow(userId)
.onSubscription { emit(getUserBiometricUnlockKey(userId)) }
override fun getMasterPasswordHash(userId: String): String? =
storedMasterPasswordHashes[userId]
@ -469,5 +484,17 @@ class FakeAuthDiskSource : AuthDiskSource {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableBiometricUnlockKeyFlow(
userId: String,
): MutableSharedFlow<String?> = mutableBiometricKeysFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutablePinProtectedUserKeyFlow(
userId: String,
): MutableSharedFlow<String?> = mutablePinProtectedUserKeysFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
//endregion Private helper functions
}

View file

@ -1150,6 +1150,42 @@ class SettingsRepositoryTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
assertFalse(settingsRepository.isAuthenticatorSyncEnabled)
}
@Test
fun `isUnlockWithBiometricsEnabledFlow should react to changes in AuthDiskSource`() = runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
settingsRepository.isUnlockWithBiometricsEnabledFlow.test {
assertFalse(awaitItem())
fakeAuthDiskSource.storeUserBiometricUnlockKey(
userId = USER_ID,
biometricsKey = "biometricsKey",
)
assertTrue(awaitItem())
fakeAuthDiskSource.storeUserBiometricUnlockKey(
userId = USER_ID,
biometricsKey = null,
)
assertFalse(awaitItem())
}
}
@Test
fun `isUnlockWithPinEnabledFlow should react to changes in AuthDiskSource`() = runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
settingsRepository.isUnlockWithPinEnabledFlow.test {
assertFalse(awaitItem())
fakeAuthDiskSource.storePinProtectedUserKey(
userId = USER_ID,
pinProtectedUserKey = "pinProtectedUserKey",
)
assertTrue(awaitItem())
fakeAuthDiskSource.storePinProtectedUserKey(
userId = USER_ID,
pinProtectedUserKey = null,
)
assertFalse(awaitItem())
}
}
}
private const val USER_ID: String = "userId"

View file

@ -63,6 +63,8 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
every { userStateFlow } returns mutableUserStateFlow
}
private val vaultRepository: VaultRepository = mockk(relaxed = true)
private val mutableBiometricsUnlockEnabledFlow = bufferedMutableSharedFlow<Boolean>()
private val mutablePinUnlockEnabledFlow = bufferedMutableSharedFlow<Boolean>()
private val settingsRepository: SettingsRepository = mockk {
every { isAuthenticatorSyncEnabled } returns false
every { isUnlockWithBiometricsEnabled } returns false
@ -70,6 +72,8 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
every { vaultTimeout } returns VaultTimeout.ThirtyMinutes
every { vaultTimeoutAction } returns VaultTimeoutAction.LOCK
coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT)
every { isUnlockWithBiometricsEnabledFlow } returns mutableBiometricsUnlockEnabledFlow
every { isUnlockWithPinEnabledFlow } returns mutablePinUnlockEnabledFlow
}
private val mutableFirstTimeStateFlow = MutableStateFlow(FirstTimeState())
@ -800,6 +804,28 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `when BiometricLockUpdate action is handled, should update the state`() = runTest {
val viewModel = createViewModel()
mutableBiometricsUnlockEnabledFlow.emit(true)
val expectedState = DEFAULT_STATE.copy(isUnlockWithBiometricsEnabled = true)
assertEquals(
viewModel.stateFlow.value,
expectedState,
)
}
@Test
fun `when PinProtectedLockUpdate action is handled, should update the state`() = runTest {
val viewModel = createViewModel()
mutablePinUnlockEnabledFlow.emit(true)
val expectedState = DEFAULT_STATE.copy(isUnlockWithPinEnabled = true)
assertEquals(
viewModel.stateFlow.value,
expectedState,
)
}
@Suppress("LongParameterList")
private fun createViewModel(
initialState: AccountSecurityState? = DEFAULT_STATE,