From 83652c96996fa666109f89ea2d674df35ac510b6 Mon Sep 17 00:00:00 2001 From: Dave Severns <149429124+dseverns-livefront@users.noreply.github.com> Date: Fri, 4 Oct 2024 14:21:40 -0400 Subject: [PATCH] PM-12773 show autofill card when user skipped this step in onboarding (#4021) --- .../components/card/BitwardenActionCard.kt | 8 ++ .../accountsecurity/AccountSecurityScreen.kt | 6 +- .../settings/autofill/AutoFillScreen.kt | 31 +++++++ .../settings/autofill/AutoFillViewModel.kt | 86 ++++++++++++++++--- .../settings/autofill/AutoFillScreenTest.kt | 38 ++++++++ .../autofill/AutoFillViewModelTest.kt | 84 ++++++++++++++++++ 6 files changed, 237 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/card/BitwardenActionCard.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/card/BitwardenActionCard.kt index d470dbbc2..3dcff91e6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/card/BitwardenActionCard.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/card/BitwardenActionCard.kt @@ -1,6 +1,8 @@ package com.x8bit.bitwarden.ui.platform.components.card import android.content.res.Configuration +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -89,6 +91,12 @@ fun BitwardenActionCard( } } +/** + * A default exit animation for [BitwardenActionCard] when using an animation wrapper like + * [AnimatedVisibility]. + */ +fun actionCardExitAnimation() = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top) + @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt index ab9b0ce03..229dac7eb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt @@ -2,8 +2,6 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity import android.widget.Toast import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -23,7 +21,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -44,6 +41,7 @@ import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.badge.NotificationBadge import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard +import com.x8bit.bitwarden.ui.platform.components.card.actionCardExitAnimation import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog @@ -183,7 +181,7 @@ fun AccountSecurityScreen( AnimatedVisibility( visible = state.shouldShowUnlockActionCard, label = "UnlockActionCard", - exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top), + exit = actionCardExitAnimation(), ) { BitwardenActionCard( cardTitle = stringResource(id = R.string.set_up_unlock), diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt index ccfc65635..a8b593b38 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.autofill import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -31,7 +32,11 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.badge.NotificationBadge +import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard +import com.x8bit.bitwarden.ui.platform.components.card.actionCardExitAnimation import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenSelectionDialog @@ -125,6 +130,32 @@ fun AutoFillScreen( .fillMaxSize() .verticalScroll(rememberScrollState()), ) { + AnimatedVisibility( + visible = state.showAutofillActionCard, + label = "AutofillActionCard", + exit = actionCardExitAnimation(), + ) { + BitwardenActionCard( + cardTitle = stringResource(R.string.turn_on_autofill), + actionText = stringResource(R.string.get_started), + onActionClick = remember(viewModel) { + { + viewModel.trySendAction(AutoFillAction.AutoFillActionCardCtaClick) + } + }, + onDismissClick = remember(viewModel) { + { + viewModel.trySendAction(AutoFillAction.DismissShowAutofillActionCard) + } + }, + leadingContent = { + NotificationBadge(notificationCount = 1) + }, + modifier = Modifier + .standardHorizontalMargin() + .padding(top = 12.dp, bottom = 16.dp), + ) + } BitwardenListHeaderText( label = stringResource(id = R.string.autofill), modifier = Modifier diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt index 2b9dcddee..91a58b52c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt @@ -4,6 +4,7 @@ import android.os.Build import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow @@ -25,22 +26,30 @@ private const val KEY_STATE = "state" @Suppress("TooManyFunctions") @HiltViewModel class AutoFillViewModel @Inject constructor( + authRepository: AuthRepository, private val savedStateHandle: SavedStateHandle, private val settingsRepository: SettingsRepository, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] - ?: AutoFillState( - isAskToAddLoginEnabled = !settingsRepository.isAutofillSavePromptDisabled, - isAccessibilityAutofillEnabled = settingsRepository - .isAccessibilityEnabledStateFlow - .value, - isAutoFillServicesEnabled = settingsRepository.isAutofillEnabledStateFlow.value, - isCopyTotpAutomaticallyEnabled = !settingsRepository.isAutoCopyTotpDisabled, - isUseInlineAutoFillEnabled = settingsRepository.isInlineAutofillEnabled, - showInlineAutofillOption = !isBuildVersionBelow(Build.VERSION_CODES.R), - showPasskeyManagementRow = !isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE), - defaultUriMatchType = settingsRepository.defaultUriMatchType, - ), + ?: run { + val userId = requireNotNull(authRepository.userStateFlow.value).activeUserId + AutoFillState( + isAskToAddLoginEnabled = !settingsRepository.isAutofillSavePromptDisabled, + isAccessibilityAutofillEnabled = settingsRepository + .isAccessibilityEnabledStateFlow + .value, + isAutoFillServicesEnabled = settingsRepository.isAutofillEnabledStateFlow.value, + isCopyTotpAutomaticallyEnabled = !settingsRepository.isAutoCopyTotpDisabled, + isUseInlineAutoFillEnabled = settingsRepository.isInlineAutofillEnabled, + showInlineAutofillOption = !isBuildVersionBelow(Build.VERSION_CODES.R), + showPasskeyManagementRow = !isBuildVersionBelow( + Build.VERSION_CODES.UPSIDE_DOWN_CAKE, + ), + defaultUriMatchType = settingsRepository.defaultUriMatchType, + showAutofillActionCard = false, + activeUserId = userId, + ) + }, ) { init { @@ -64,6 +73,12 @@ class AutoFillViewModel @Inject constructor( } .onEach(::sendAction) .launchIn(viewModelScope) + + settingsRepository + .getShowAutofillBadgeFlow(userId = state.activeUserId) + .map { AutoFillAction.Internal.UpdateShowAutofillActionCard(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: AutoFillAction) = when (action) { @@ -77,6 +92,8 @@ class AutoFillViewModel @Inject constructor( is AutoFillAction.UseInlineAutofillClick -> handleUseInlineAutofillClick(action) AutoFillAction.PasskeyManagementClick -> handlePasskeyManagementClick() is AutoFillAction.Internal -> handleInternalAction(action) + AutoFillAction.AutoFillActionCardCtaClick -> handleAutoFillActionCardCtClick() + AutoFillAction.DismissShowAutofillActionCard -> handleDismissShowAutofillActionCard() } private fun handleInternalAction(action: AutoFillAction.Internal) { @@ -88,9 +105,28 @@ class AutoFillViewModel @Inject constructor( is AutoFillAction.Internal.AutofillEnabledUpdateReceive -> { handleAutofillEnabledUpdateReceive(action) } + + is AutoFillAction.Internal.UpdateShowAutofillActionCard -> { + handleUpdateShowAutofillActionCard(action) + } } } + private fun handleDismissShowAutofillActionCard() { + dismissShowAutofillActionCard() + } + + private fun handleAutoFillActionCardCtClick() { + dismissShowAutofillActionCard() + // TODO PM-13068 navigate to auto fill setup screen + } + + private fun handleUpdateShowAutofillActionCard( + action: AutoFillAction.Internal.UpdateShowAutofillActionCard, + ) { + mutableStateFlow.update { it.copy(showAutofillActionCard = action.showAutofillActionCard) } + } + private fun handleAskToAddLoginClick(action: AutoFillAction.AskToAddLoginClick) { settingsRepository.isAutofillSavePromptDisabled = !action.isEnabled mutableStateFlow.update { it.copy(isAskToAddLoginEnabled = action.isEnabled) } @@ -102,6 +138,7 @@ class AutoFillViewModel @Inject constructor( } else { settingsRepository.disableAutofill() } + dismissShowAutofillActionCard() } private fun handleBackClick() { @@ -154,6 +191,14 @@ class AutoFillViewModel @Inject constructor( private fun handleBlockAutoFillClick() { sendEvent(AutoFillEvent.NavigateToBlockAutoFill) } + + private fun dismissShowAutofillActionCard() { + if (!state.showAutofillActionCard) return + settingsRepository.storeShowAutoFillSettingBadge( + userId = state.activeUserId, + showBadge = false, + ) + } } /** @@ -169,6 +214,8 @@ data class AutoFillState( val showInlineAutofillOption: Boolean, val showPasskeyManagementRow: Boolean, val defaultUriMatchType: UriMatchType, + val showAutofillActionCard: Boolean, + val activeUserId: String, ) : Parcelable { /** @@ -275,6 +322,16 @@ sealed class AutoFillAction { */ data object PasskeyManagementClick : AutoFillAction() + /** + * User has clicked the "X" to dismiss the autofill action card. + */ + data object DismissShowAutofillActionCard : AutoFillAction() + + /** + * User has clicked the CTA on the autofill action card. + */ + data object AutoFillActionCardCtaClick : AutoFillAction() + /** * Internal actions. */ @@ -292,5 +349,10 @@ sealed class AutoFillAction { data class AutofillEnabledUpdateReceive( val isAutofillEnabled: Boolean, ) : Internal() + + /** + * An update for changes in the [showAutofillActionCard] value from the settings repository. + */ + data class UpdateShowAutofillActionCard(val showAutofillActionCard: Boolean) : Internal() } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt index 6a79d0f35..a92fb0bfb 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt @@ -462,6 +462,42 @@ class AutoFillScreenTest : BaseComposeTest() { mutableEventFlow.tryEmit(AutoFillEvent.NavigateToBlockAutoFill) assertTrue(onNavigateToBlockAutoFillScreenCalled) } + + @Test + fun `autofill action card should show when state is true and hide when false`() { + composeTestRule + .onNodeWithText("Get started") + .assertDoesNotExist() + mutableStateFlow.update { DEFAULT_STATE.copy(showAutofillActionCard = true) } + composeTestRule + .onNodeWithText("Get started") + .assertIsDisplayed() + mutableStateFlow.update { DEFAULT_STATE.copy(showAutofillActionCard = false) } + composeTestRule + .onNodeWithText("Get started") + .assertDoesNotExist() + } + + @Test + fun `when autofill card is visible clicking the cta button should send correct action`() { + mutableStateFlow.update { DEFAULT_STATE.copy(showAutofillActionCard = true) } + composeTestRule + .onNodeWithText("Get started") + .performScrollTo() + .performClick() + + verify { viewModel.trySendAction(AutoFillAction.AutoFillActionCardCtaClick) } + } + + @Test + fun `when autofill action card is visible clicking dismissing should send correct action`() { + mutableStateFlow.update { DEFAULT_STATE.copy(showAutofillActionCard = true) } + composeTestRule + .onNodeWithContentDescription("Close") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(AutoFillAction.DismissShowAutofillActionCard) } + } } private val DEFAULT_STATE: AutoFillState = AutoFillState( @@ -473,4 +509,6 @@ private val DEFAULT_STATE: AutoFillState = AutoFillState( showInlineAutofillOption = true, showPasskeyManagementRow = true, defaultUriMatchType = UriMatchType.DOMAIN, + showAutofillActionCard = false, + activeUserId = "activeUserId", ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt index f67e4f8b2..9fe456b83 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.autofill import android.os.Build import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow @@ -15,6 +16,7 @@ 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.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -25,6 +27,11 @@ class AutoFillViewModelTest : BaseViewModelTest() { private val mutableIsAccessibilityEnabledStateFlow = MutableStateFlow(false) private val mutableIsAutofillEnabledStateFlow = MutableStateFlow(false) + + private val authRepository: AuthRepository = mockk { + every { userStateFlow.value?.activeUserId } returns "activeUserId" + } + private val mutableShowAutofillActionCardFlow = MutableStateFlow(false) private val settingsRepository: SettingsRepository = mockk { every { isInlineAutofillEnabled } returns true every { isInlineAutofillEnabled = any() } just runs @@ -37,6 +44,8 @@ class AutoFillViewModelTest : BaseViewModelTest() { every { isAccessibilityEnabledStateFlow } returns mutableIsAccessibilityEnabledStateFlow every { isAutofillEnabledStateFlow } returns mutableIsAutofillEnabledStateFlow every { disableAutofill() } just runs + every { getShowAutofillBadgeFlow(any()) } returns mutableShowAutofillActionCardFlow + every { storeShowAutoFillSettingBadge(any(), any()) } just runs } @BeforeEach @@ -196,6 +205,40 @@ class AutoFillViewModelTest : BaseViewModelTest() { ) } + @Test + fun `on AutoFillServicesClick should update show autofill in repository if card shown`() { + mutableShowAutofillActionCardFlow.update { true } + val viewModel = createViewModel() + assertEquals( + DEFAULT_STATE.copy(showAutofillActionCard = true), + viewModel.stateFlow.value, + ) + viewModel.trySendAction(AutoFillAction.AutoFillServicesClick(true)) + verify(exactly = 1) { + settingsRepository.storeShowAutoFillSettingBadge( + DEFAULT_STATE.activeUserId, + false, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on AutoFillServicesClick should not update show autofill in repository if card not shown`() { + val viewModel = createViewModel() + assertEquals( + DEFAULT_STATE.copy(showAutofillActionCard = false), + viewModel.stateFlow.value, + ) + viewModel.trySendAction(AutoFillAction.AutoFillServicesClick(true)) + verify(exactly = 0) { + settingsRepository.storeShowAutoFillSettingBadge( + DEFAULT_STATE.activeUserId, + false, + ) + } + } + @Test fun `on BackClick should emit NavigateBack`() = runTest { val viewModel = createViewModel() @@ -266,11 +309,50 @@ class AutoFillViewModelTest : BaseViewModelTest() { } } + @Test + fun `when showAutofillBadgeFlow updates value, should update state`() = runTest { + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + mutableShowAutofillActionCardFlow.emit(true) + assertEquals(DEFAULT_STATE.copy(showAutofillActionCard = true), awaitItem()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `when AutoFillActionCardCtaClick action is sent should update show autofill in repository`() { + mutableShowAutofillActionCardFlow.update { true } + val viewModel = createViewModel() + viewModel.trySendAction(AutoFillAction.AutoFillActionCardCtaClick) + verify { + settingsRepository.storeShowAutoFillSettingBadge( + DEFAULT_STATE.activeUserId, + false, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `when DismissShowAutofillActionCard action is sent should update show autofill in repository`() { + mutableShowAutofillActionCardFlow.update { true } + val viewModel = createViewModel() + viewModel.trySendAction(AutoFillAction.DismissShowAutofillActionCard) + verify { + settingsRepository.storeShowAutoFillSettingBadge( + DEFAULT_STATE.activeUserId, + false, + ) + } + } + private fun createViewModel( state: AutoFillState? = DEFAULT_STATE, ): AutoFillViewModel = AutoFillViewModel( savedStateHandle = SavedStateHandle().apply { set("state", state) }, settingsRepository = settingsRepository, + authRepository = authRepository, ) } @@ -283,4 +365,6 @@ private val DEFAULT_STATE: AutoFillState = AutoFillState( showInlineAutofillOption = false, showPasskeyManagementRow = true, defaultUriMatchType = UriMatchType.DOMAIN, + showAutofillActionCard = false, + activeUserId = "activeUserId", )