diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt index 9780ecc41b..cf8f746f10 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt @@ -30,6 +30,11 @@ interface SettingsDiskSource { */ var defaultSaveOption: DefaultSaveOption + /** + * Flow that emits changes to [defaultSaveOption] + */ + val defaultSaveOptionFlow: Flow + /** * The currently persisted biometric integrity source for the system. */ diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index 7f434f6910..ab6ecb4309 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -45,6 +45,9 @@ class SettingsDiskSourceImpl( private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow() + private val mutableDefaultSaveOptionFlow = + bufferedMutableSharedFlow() + override var appLanguage: AppLanguage? get() = getString(key = APP_LANGUAGE_KEY) ?.let { storedValue -> @@ -89,7 +92,11 @@ class SettingsDiskSourceImpl( key = DEFAULT_SAVE_OPTION_KEY, value = newValue.value, ) + mutableDefaultSaveOptionFlow.tryEmit(newValue) } + override val defaultSaveOptionFlow: Flow + get() = mutableDefaultSaveOptionFlow + .onSubscription { emit(defaultSaveOption) } override var systemBiometricIntegritySource: String? get() = getString(key = SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY) diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt index 5d0b1ac1cf..91cf03f160 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt @@ -37,6 +37,11 @@ interface SettingsRepository { */ var defaultSaveOption: DefaultSaveOption + /** + * Flow that emits changes to [defaultSaveOption] + */ + val defaultSaveOptionFlow: Flow + /** * Whether or not biometric unlocking is enabled for the current user. */ diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt index 2c979dd572..fe559d23c9 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt @@ -44,6 +44,9 @@ class SettingsRepositoryImpl( override var defaultSaveOption: DefaultSaveOption by settingsDiskSource::defaultSaveOption + override val defaultSaveOptionFlow: Flow + by settingsDiskSource::defaultSaveOptionFlow + override val isUnlockWithBiometricsEnabled: Boolean get() = authDiskSource.getUserBiometricUnlockKey() != null diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt index 5f50afd3d9..c7b224f5b8 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt @@ -72,6 +72,12 @@ class SettingsViewModel @Inject constructor( .map { SettingsAction.Internal.SharedAccountsStateUpdated(it) } .onEach(::handleAction) .launchIn(viewModelScope) + + settingsRepository + .defaultSaveOptionFlow + .map { SettingsAction.Internal.DefaultSaveOptionUpdated(it) } + .onEach(::handleAction) + .launchIn(viewModelScope) } override fun handleAction(action: SettingsAction) { @@ -103,6 +109,10 @@ class SettingsViewModel @Inject constructor( is SettingsAction.Internal.SharedAccountsStateUpdated -> { handleSharedAccountsStateUpdated(action) } + + is SettingsAction.Internal.DefaultSaveOptionUpdated -> { + handleDefaultSaveOptionUpdated(action) + } } } @@ -175,14 +185,19 @@ class SettingsViewModel @Inject constructor( SettingsAction.DataClick.BackupClick -> handleBackupClick() SettingsAction.DataClick.SyncWithBitwardenClick -> handleSyncWithBitwardenClick() is SettingsAction.DataClick.DefaultSaveOptionUpdated -> - handleDefaultSaveOptionUpdated(action) + handleDefaultSaveOptionChosen(action) } } - private fun handleDefaultSaveOptionUpdated( + private fun handleDefaultSaveOptionChosen( action: SettingsAction.DataClick.DefaultSaveOptionUpdated, ) { settingsRepository.defaultSaveOption = action.option + } + + private fun handleDefaultSaveOptionUpdated( + action: SettingsAction.Internal.DefaultSaveOptionUpdated, + ) { mutableStateFlow.update { it.copy( defaultSaveOption = action.option, @@ -553,5 +568,12 @@ sealed class SettingsAction( data class SharedAccountsStateUpdated( val state: SharedVerificationCodesState, ) : SettingsAction() + + /** + * Indicates that the default save option on disk was updated. + */ + data class DefaultSaveOptionUpdated( + val option: DefaultSaveOption, + ) : SettingsAction() } } diff --git a/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/SettingDiskSourceTest.kt b/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/SettingDiskSourceTest.kt index 1d30308dd6..90ea9d972a 100644 --- a/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/SettingDiskSourceTest.kt +++ b/app/src/test/java/com/bitwarden/authenticator/data/platform/datasource/disk/SettingDiskSourceTest.kt @@ -1,8 +1,10 @@ package com.bitwarden.authenticator.data.platform.datasource.disk import androidx.core.content.edit +import app.cash.turbine.test import com.bitwarden.authenticator.data.platform.base.FakeSharedPreferences import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull @@ -54,46 +56,56 @@ class SettingDiskSourceTest { } @Test - fun `defaultSaveOption should read and write from shared preferences`() { + fun `defaultSaveOption should read and write from shared preferences`() = runTest { val sharedPrefsKey = "bwPreferencesStorage:defaultSaveOption" - // Verify initial value is null and disk source should default to NONE - assertNull(sharedPreferences.getString(sharedPrefsKey, null)) - assertEquals( - DefaultSaveOption.NONE, - settingDiskSource.defaultSaveOption, - ) + settingDiskSource.defaultSaveOptionFlow.test { + // Verify initial value is null and disk source should default to NONE + assertNull(sharedPreferences.getString(sharedPrefsKey, null)) + assertEquals( + DefaultSaveOption.NONE, + settingDiskSource.defaultSaveOption, + ) + assertEquals( + DefaultSaveOption.NONE, + awaitItem(), + ) - // Updating the shared preferences should update disk source - sharedPreferences.edit { - putString( - sharedPrefsKey, - DefaultSaveOption.BITWARDEN_APP.value, + // Updating the shared preferences should update disk source + sharedPreferences.edit { + putString( + sharedPrefsKey, + DefaultSaveOption.BITWARDEN_APP.value, + ) + } + assertEquals( + DefaultSaveOption.BITWARDEN_APP, + settingDiskSource.defaultSaveOption, + ) + + // Updating the disk source should update shared preferences + settingDiskSource.defaultSaveOption = DefaultSaveOption.LOCAL + assertEquals( + DefaultSaveOption.LOCAL.value, + sharedPreferences.getString(sharedPrefsKey, null), + ) + assertEquals( + DefaultSaveOption.LOCAL, + awaitItem(), + ) + + // Incorrect value should default to DefaultSaveOption.NONE + sharedPreferences.edit { + putString( + sharedPrefsKey, + "invalid", + ) + } + assertEquals( + DefaultSaveOption.NONE, + settingDiskSource.defaultSaveOption, ) } - assertEquals( - DefaultSaveOption.BITWARDEN_APP, - settingDiskSource.defaultSaveOption, - ) - - // Updating the disk source should update shared preferences - settingDiskSource.defaultSaveOption = DefaultSaveOption.LOCAL - assertEquals( - DefaultSaveOption.LOCAL.value, - sharedPreferences.getString(sharedPrefsKey, null), - ) - - // Incorrect value should default to DefaultSaveOption.NONE - sharedPreferences.edit { - putString( - sharedPrefsKey, - "invalid", - ) - } - assertEquals( - DefaultSaveOption.NONE, - settingDiskSource.defaultSaveOption, - ) } @Test diff --git a/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt index 8f21ceafb6..09920bad9d 100644 --- a/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt +++ b/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryTest.kt @@ -1,5 +1,6 @@ package com.bitwarden.authenticator.data.platform.repository +import app.cash.turbine.test import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource import com.bitwarden.authenticator.data.auth.datasource.disk.util.FakeAuthDiskSource import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource @@ -12,6 +13,8 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs import io.mockk.verify +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -93,6 +96,27 @@ class SettingsRepositoryTest { verify { settingsDiskSource.defaultSaveOption = DefaultSaveOption.BITWARDEN_APP } } + @Test + fun `defaultSaveOptionFlow should match SettingsDiskSource`() = runTest { + // Reading from repository should read from disk source: + val expectedOptions = listOf( + DefaultSaveOption.NONE, + DefaultSaveOption.LOCAL, + DefaultSaveOption.BITWARDEN_APP, + DefaultSaveOption.NONE, + ) + every { settingsDiskSource.defaultSaveOptionFlow } returns flow { + expectedOptions.forEach { emit(it) } + } + + settingsRepository.defaultSaveOptionFlow.test { + expectedOptions.forEach { + assertEquals(it, awaitItem()) + } + awaitComplete() + } + } + @Test fun `previouslySyncedBitwardenAccountIds should pull from and update SettingsDiskSource`() { // Reading from repository should read from disk source: diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt index 6e465aa90a..90f2122585 100644 --- a/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt @@ -1,6 +1,7 @@ package com.bitwarden.authenticator.ui.platform.feature.settings import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewmodel.compose.viewModel import app.cash.turbine.test import com.bitwarden.authenticator.BuildConfig import com.bitwarden.authenticator.R @@ -11,6 +12,7 @@ import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager import com.bitwarden.authenticator.data.platform.manager.model.LocalFeatureFlag import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest import com.bitwarden.authenticator.ui.platform.base.util.asText import com.bitwarden.authenticator.ui.platform.base.util.concat @@ -29,6 +31,7 @@ import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -46,10 +49,12 @@ class SettingsViewModelTest : BaseViewModelTest() { private val authenticatorRepository: AuthenticatorRepository = mockk { every { sharedCodesStateFlow } returns mutableSharedCodesFlow } + private val mutableDefaultSaveOptionFlow = bufferedMutableSharedFlow() private val settingsRepository: SettingsRepository = mockk { every { appLanguage } returns APP_LANGUAGE every { appTheme } returns APP_THEME every { defaultSaveOption } returns DEFAULT_SAVE_OPTION + every { defaultSaveOptionFlow } returns mutableDefaultSaveOptionFlow every { isUnlockWithBiometricsEnabled } returns true every { isCrashLoggingEnabled } returns true } @@ -64,6 +69,7 @@ class SettingsViewModelTest : BaseViewModelTest() { every { MOCK_SHARED_CODES_STATE.isSyncWithBitwardenEnabled } returns false } + @AfterEach fun teardown() { unmockkStatic(SharedVerificationCodesState::isSyncWithBitwardenEnabled) } @@ -177,22 +183,39 @@ class SettingsViewModelTest : BaseViewModelTest() { @Test @Suppress("MaxLineLength") - fun `on DefaultSaveOptionUpdated should update SettingsRepository and state`() { + fun `on DefaultSaveOptionUpdated should update SettingsRepository`() { val expectedOption = DefaultSaveOption.BITWARDEN_APP every { settingsRepository.defaultSaveOption = expectedOption } just runs val viewModel = createViewModel() - viewModel.trySendAction(SettingsAction.DataClick.DefaultSaveOptionUpdated(expectedOption)) - - assertEquals( - DEFAULT_STATE.copy( - defaultSaveOption = expectedOption, - ), - viewModel.stateFlow.value, - ) verify { settingsRepository.defaultSaveOption = expectedOption } } + @Test + @Suppress("MaxLineLength") + fun `Default save option should update when repository emits`() = runTest { + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + + mutableDefaultSaveOptionFlow.emit(DefaultSaveOption.LOCAL) + assertEquals( + DEFAULT_STATE.copy( + defaultSaveOption = DefaultSaveOption.LOCAL, + ), + awaitItem(), + ) + + mutableDefaultSaveOptionFlow.emit(DefaultSaveOption.BITWARDEN_APP) + assertEquals( + DEFAULT_STATE.copy( + defaultSaveOption = DefaultSaveOption.BITWARDEN_APP, + ), + awaitItem(), + ) + } + } + private fun createViewModel( savedState: SettingsState? = DEFAULT_STATE, ) = SettingsViewModel(