diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index f70413dec9..38142a9d49 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -373,7 +373,7 @@ private fun DefaultSaveOptionSelectionRow( var shouldShowDefaultSaveOptionDialog by remember { mutableStateOf(false) } BitwardenTextRow( - text = stringResource(id = R.string.default_save_options), + text = stringResource(id = R.string.default_save_option), onClick = { shouldShowDefaultSaveOptionDialog = true }, modifier = modifier, withDivider = true, @@ -388,7 +388,7 @@ private fun DefaultSaveOptionSelectionRow( var dialogSelection by remember { mutableStateOf(currentSelection) } if (shouldShowDefaultSaveOptionDialog) { BitwardenSelectionDialog( - title = stringResource(id = R.string.default_save_options), + title = stringResource(id = R.string.default_save_option), subtitle = stringResource(id = R.string.default_save_options_subtitle), dismissLabel = stringResource(id = R.string.confirm), onDismissRequest = { shouldShowDefaultSaveOptionDialog = false }, 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 b22db36f2a..5f50afd3d9 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 @@ -7,6 +7,9 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.authenticator.BuildConfig import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled 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 @@ -22,6 +25,9 @@ import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.Defau import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @@ -34,11 +40,12 @@ private const val KEY_STATE = "state" /** * View model for the settings screen. */ -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LongParameterList") @HiltViewModel class SettingsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, clock: Clock, + private val authenticatorRepository: AuthenticatorRepository, private val authenticatorBridgeManager: AuthenticatorBridgeManager, private val settingsRepository: SettingsRepository, private val clipboardManager: BitwardenClipboardManager, @@ -55,8 +62,18 @@ class SettingsViewModel @Inject constructor( featureFlagManager.getFeatureFlag(LocalFeatureFlag.PasswordManagerSync), accountSyncState = authenticatorBridgeManager.accountSyncStateFlow.value, defaultSaveOption = settingsRepository.defaultSaveOption, + sharedAccountsState = authenticatorRepository.sharedCodesStateFlow.value, ), ) { + + init { + authenticatorRepository + .sharedCodesStateFlow + .map { SettingsAction.Internal.SharedAccountsStateUpdated(it) } + .onEach(::handleAction) + .launchIn(viewModelScope) + } + override fun handleAction(action: SettingsAction) { when (action) { is SettingsAction.SecurityClick -> { @@ -82,6 +99,20 @@ class SettingsViewModel @Inject constructor( is SettingsAction.Internal.BiometricsKeyResultReceive -> { handleBiometricsKeyResultReceive(action) } + + is SettingsAction.Internal.SharedAccountsStateUpdated -> { + handleSharedAccountsStateUpdated(action) + } + } + } + + private fun handleSharedAccountsStateUpdated( + action: SettingsAction.Internal.SharedAccountsStateUpdated, + ) { + mutableStateFlow.update { + it.copy( + showDefaultSaveOptionRow = action.state.isSyncWithBitwardenEnabled, + ) } } @@ -273,12 +304,16 @@ class SettingsViewModel @Inject constructor( isSubmitCrashLogsEnabled: Boolean, accountSyncState: AccountSyncState, isSyncWithBitwardenFeatureEnabled: Boolean, + sharedAccountsState: SharedVerificationCodesState, ): SettingsState { val currentYear = Year.now(clock) val copyrightInfo = "© Bitwarden Inc. 2015-$currentYear".asText() // Show sync with Bitwarden row if feature is enabled and the OS is supported: val shouldShowSyncWithBitwarden = isSyncWithBitwardenFeatureEnabled && accountSyncState != AccountSyncState.OsVersionNotSupported + // Show default save options only if the user had enabled sync with Bitwarden: + // (They can enable it via the "Sync with Bitwarden" row. + val shouldShowDefaultSaveOption = sharedAccountsState.isSyncWithBitwardenEnabled return SettingsState( appearance = SettingsState.Appearance( language = appLanguage, @@ -293,7 +328,7 @@ class SettingsViewModel @Inject constructor( copyrightInfo = copyrightInfo, defaultSaveOption = defaultSaveOption, showSyncWithBitwarden = shouldShowSyncWithBitwarden, - showDefaultSaveOptionRow = shouldShowSyncWithBitwarden, + showDefaultSaveOptionRow = shouldShowDefaultSaveOption, ) } } @@ -511,5 +546,12 @@ sealed class SettingsAction( * Indicates the biometrics key validation results has been received. */ data class BiometricsKeyResultReceive(val result: BiometricsKeyResult) : SettingsAction() + + /** + * Indicates that shared account state was updated. + */ + data class SharedAccountsStateUpdated( + val state: SharedVerificationCodesState, + ) : SettingsAction() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2f1da0377f..33174abf26 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -133,7 +133,7 @@ Something went wrong Please try again Move to Bitwarden - Default save options + Default save option Save to Bitwarden Save locally None diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt index 279967a8b0..e0196409c7 100644 --- a/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt +++ b/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt @@ -119,32 +119,32 @@ class SettingsScreenTest : BaseComposeTest() { } @Test - fun `Default Save Options row should be hidden when showDefaultSaveOptionRow is false`() { + fun `Default Save Option row should be hidden when showDefaultSaveOptionRow is false`() { mutableStateFlow.value = DEFAULT_STATE - composeTestRule.onNodeWithText("Default save options").assertExists() + composeTestRule.onNodeWithText("Default save option").assertExists() mutableStateFlow.update { it.copy( showDefaultSaveOptionRow = false, ) } - composeTestRule.onNodeWithText("Default save options").assertDoesNotExist() + composeTestRule.onNodeWithText("Default save option").assertDoesNotExist() } @Test @Suppress("MaxLineLength") - fun `Default Save Options dialog should send DefaultSaveOptionUpdated when confirm is clicked`() = + fun `Default Save Option dialog should send DefaultSaveOptionUpdated when confirm is clicked`() = runTest { val expectedSaveOption = DefaultSaveOption.BITWARDEN_APP mutableStateFlow.value = DEFAULT_STATE composeTestRule - .onNodeWithText("Default save options") + .onNodeWithText("Default save option") .performScrollTo() .performClick() // Make sure the dialog is showing: composeTestRule - .onAllNodesWithText("Default save options") + .onAllNodesWithText("Default save option") .filterToOne(hasAnyAncestor(isDialog())) .assertIsDisplayed() 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 678d3c807b..6e465aa90a 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 @@ -4,6 +4,9 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.authenticator.BuildConfig import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled 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 @@ -19,11 +22,15 @@ import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.runs +import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.time.Clock import java.time.Instant @@ -34,6 +41,11 @@ class SettingsViewModelTest : BaseViewModelTest() { private val authenticatorBridgeManager: AuthenticatorBridgeManager = mockk { every { accountSyncStateFlow } returns MutableStateFlow(AccountSyncState.Loading) } + + private val mutableSharedCodesFlow = MutableStateFlow(MOCK_SHARED_CODES_STATE) + private val authenticatorRepository: AuthenticatorRepository = mockk { + every { sharedCodesStateFlow } returns mutableSharedCodesFlow + } private val settingsRepository: SettingsRepository = mockk { every { appLanguage } returns APP_LANGUAGE every { appTheme } returns APP_THEME @@ -46,6 +58,16 @@ class SettingsViewModelTest : BaseViewModelTest() { every { getFeatureFlag(LocalFeatureFlag.PasswordManagerSync) } returns true } + @BeforeEach + fun setup() { + mockkStatic(SharedVerificationCodesState::isSyncWithBitwardenEnabled) + every { MOCK_SHARED_CODES_STATE.isSyncWithBitwardenEnabled } returns false + } + + fun teardown() { + unmockkStatic(SharedVerificationCodesState::isSyncWithBitwardenEnabled) + } + @Test @Suppress("MaxLineLength") fun `initialState should be correct when saved state is null and password manager feature flag is off`() { @@ -130,6 +152,29 @@ class SettingsViewModelTest : BaseViewModelTest() { } } + @Test + @Suppress("MaxLineLength") + fun `Default save option row should only show when shared codes state shows syncing as enabled`() = + runTest { + val viewModel = createViewModel() + val enabledState: SharedVerificationCodesState = mockk { + every { isSyncWithBitwardenEnabled } returns true + } + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + mutableSharedCodesFlow.update { enabledState } + assertEquals( + DEFAULT_STATE.copy( + showDefaultSaveOptionRow = true, + ), + awaitItem(), + ) + } + } + @Test @Suppress("MaxLineLength") fun `on DefaultSaveOptionUpdated should update SettingsRepository and state`() { @@ -154,12 +199,14 @@ class SettingsViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle().apply { this["state"] = savedState }, clock = CLOCK, authenticatorBridgeManager = authenticatorBridgeManager, + authenticatorRepository = authenticatorRepository, settingsRepository = settingsRepository, clipboardManager = clipboardManager, featureFlagManager = featureFlagManager, ) } +private val MOCK_SHARED_CODES_STATE: SharedVerificationCodesState = mockk() private val APP_LANGUAGE = AppLanguage.ENGLISH private val APP_THEME = AppTheme.DEFAULT private val CLOCK = Clock.fixed( @@ -175,7 +222,7 @@ private val DEFAULT_STATE = SettingsState( isSubmitCrashLogsEnabled = true, isUnlockWithBiometricsEnabled = true, showSyncWithBitwarden = true, - showDefaultSaveOptionRow = true, + showDefaultSaveOptionRow = false, defaultSaveOption = DEFAULT_SAVE_OPTION, dialog = null, version = R.string.version.asText()