diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt index 4a636ead92d..f82b0c208f3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt @@ -93,8 +93,11 @@ class FillAssistManagerImpl( ?.fillAssistRulesUrl ?: return val lastFetch = fillAssistDiskSource.getLastFetchTimestamp(serverUrl) ?: 0L - if (clock.millis() - lastFetch < UPDATE_INTERVAL_MS) return - if (!syncJob.isCompleted) return + if (clock.millis() - lastFetch < UPDATE_INTERVAL_MS || + !syncJob.isCompleted + ) { + return + } syncJob = ioScope.launch { sync(serverUrl) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt index 08f0ac4b1cb..40e2c859606 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt @@ -143,12 +143,19 @@ class AutofillParserImpl( return AutofillRequest.Unfillable } - val effectiveViews = autofillViews.toEffectiveViews( - assistStructure = assistStructure, - uri = uri, - focusedView = focusedView, - urlBarWebsite = urlBarWebsite, - ) + val isFillAssistEnabled = featureFlagManager + .getFeatureFlag(FlagKey.FillAssistTargetingRules) && + settingsRepository.isFillAssistEnabled + val effectiveViews = if (isFillAssistEnabled) { + autofillViews.toEffectiveViews( + assistStructure = assistStructure, + uri = uri, + focusedView = focusedView, + urlBarWebsite = urlBarWebsite, + ) + } else { + autofillViews + } val effectiveFocusedView = effectiveViews .firstOrNull { it.data.isFocused } @@ -217,7 +224,6 @@ class AutofillParserImpl( ?.takeUnless { it.startsWith("androidapp://") } ?.toUri() ?.host - ?.takeIf { featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules) } ?.let { host -> fillAssistManager.getFillAssistRules()?.hostRules?.get(host.removePrefix("www.")) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt index 816e4de0a37..c4f9d465eb8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt @@ -130,6 +130,12 @@ fun AutoFillScreen( onNavigateToPrivilegedAppsList() } + AutoFillEvent.NavigateToFillAssistHelp -> { + intentManager.launchUri( + "https://bitwarden.com/help/fill-assist/".toUri(), + ) + } + AutoFillEvent.NavigateToLearnMore -> { intentManager.launchUri("https://bitwarden.com/help/uri-match-detection/".toUri()) } @@ -301,6 +307,29 @@ private fun AutoFillScreenContent( .standardHorizontalMargin() .padding(horizontal = 16.dp), ) + if (state.showFillAssistOption) { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenSwitch( + label = stringResource(id = BitwardenString.turn_on_fill_assist), + supportingText = stringResource( + id = BitwardenString.fill_assist_improves_autofill_accuracy_on_supported_sites, + ), + isChecked = state.isFillAssistEnabled, + onCheckedChange = autoFillHandlers.onFillAssistToggleClick, + helpData = BitwardenHelpButtonData( + onClick = autoFillHandlers.onFillAssistInfoClick, + contentDescription = stringResource( + id = BitwardenString.learn_more, + ), + isExternalLink = true, + ), + cardStyle = CardStyle.Full, + modifier = Modifier + .fillMaxWidth() + .testTag("FillAssistSwitch") + .standardHorizontalMargin(), + ) + } Spacer(modifier = Modifier.height(8.dp)) BitwardenSwitch( label = stringResource(id = BitwardenString.copy_totp_automatically), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt index 16534223047..be6167c2d47 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt @@ -4,15 +4,18 @@ import android.os.Build import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.bitwarden.core.data.manager.model.FlagKey import com.bitwarden.core.util.isBuildVersionAtLeast import com.bitwarden.ui.platform.base.BaseViewModel import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager import com.x8bit.bitwarden.data.autofill.model.browser.BrowserPackage import com.x8bit.bitwarden.data.autofill.model.browser.BrowserThirdPartyAutofillStatus +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -33,11 +36,13 @@ private const val KEY_STATE = "state" /** * View model for the auto-fill screen. */ -@Suppress("TooManyFunctions") +@Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel class AutoFillViewModel @Inject constructor( authRepository: AuthRepository, browserThirdPartyAutofillEnabledManager: BrowserThirdPartyAutofillEnabledManager, + featureFlagManager: FeatureFlagManager, + private val fillAssistManager: FillAssistManager, private val savedStateHandle: SavedStateHandle, private val settingsRepository: SettingsRepository, private val firstTimeActionManager: FirstTimeActionManager, @@ -47,6 +52,9 @@ class AutoFillViewModel @Inject constructor( val userId = requireNotNull(authRepository.userStateFlow.value).activeUserId val firstTimeState = firstTimeActionManager.currentOrDefaultUserFirstTimeState AutoFillState( + showFillAssistOption = featureFlagManager + .getFeatureFlag(FlagKey.FillAssistTargetingRules), + isFillAssistEnabled = settingsRepository.isFillAssistEnabled, isAskToAddLoginEnabled = !settingsRepository.isAutofillSavePromptDisabled, isAccessibilityAutofillEnabled = settingsRepository .isAccessibilityEnabledStateFlow @@ -109,6 +117,8 @@ class AutoFillViewModel @Inject constructor( } override fun handleAction(action: AutoFillAction) = when (action) { + is AutoFillAction.FillAssistToggleClick -> handleFillAssistToggleClick(action) + AutoFillAction.FillAssistInfoClick -> handleFillAssistInfoClick() is AutoFillAction.AskToAddLoginClick -> handleAskToAddLoginClick(action) is AutoFillAction.AutoFillServicesClick -> handleAutoFillServicesClick(action) AutoFillAction.BackClick -> handleBackClick() @@ -215,6 +225,18 @@ class AutoFillViewModel @Inject constructor( } } + private fun handleFillAssistToggleClick(action: AutoFillAction.FillAssistToggleClick) { + settingsRepository.isFillAssistEnabled = action.isEnabled + mutableStateFlow.update { it.copy(isFillAssistEnabled = action.isEnabled) } + if (action.isEnabled) { + fillAssistManager.syncIfNecessary() + } + } + + private fun handleFillAssistInfoClick() { + sendEvent(AutoFillEvent.NavigateToFillAssistHelp) + } + private fun handleAskToAddLoginClick(action: AutoFillAction.AskToAddLoginClick) { settingsRepository.isAutofillSavePromptDisabled = !action.isEnabled mutableStateFlow.update { it.copy(isAskToAddLoginEnabled = action.isEnabled) } @@ -293,6 +315,8 @@ class AutoFillViewModel @Inject constructor( */ @Parcelize data class AutoFillState( + val showFillAssistOption: Boolean, + val isFillAssistEnabled: Boolean, val isAskToAddLoginEnabled: Boolean, val isAccessibilityAutofillEnabled: Boolean, val isAutoFillServicesEnabled: Boolean, @@ -422,12 +446,29 @@ sealed class AutoFillEvent { * Navigate to the autofill help page. */ data object NavigateToAutofillHelp : AutoFillEvent() + + /** + * Navigate to the fill assist help page. + */ + data object NavigateToFillAssistHelp : AutoFillEvent() } /** * Models actions for the auto-fill screen. */ sealed class AutoFillAction { + /** + * User toggled the fill assist switch. + */ + data class FillAssistToggleClick( + val isEnabled: Boolean, + ) : AutoFillAction() + + /** + * User clicked the fill assist info icon. + */ + data object FillAssistInfoClick : AutoFillAction() + /** * User clicked ask to add login button. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/handlers/AutoFillHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/handlers/AutoFillHandlers.kt index c305164006e..f88ae51b6df 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/handlers/AutoFillHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/handlers/AutoFillHandlers.kt @@ -27,6 +27,8 @@ class AutoFillHandlers( val onAskToAddLoginClick: (isEnabled: Boolean) -> Unit, val onDefaultUriMatchTypeSelect: (defaultUriMatchType: UriMatchType) -> Unit, val onBlockAutoFillClick: () -> Unit, + val onFillAssistToggleClick: (isEnabled: Boolean) -> Unit, + val onFillAssistInfoClick: () -> Unit, val onLearnMoreClick: () -> Unit, val onHelpCardClick: () -> Unit, ) { @@ -82,6 +84,12 @@ class AutoFillHandlers( viewModel.trySendAction(AutoFillAction.DefaultUriMatchTypeSelect(it)) }, onBlockAutoFillClick = { viewModel.trySendAction(AutoFillAction.BlockAutoFillClick) }, + onFillAssistToggleClick = { + viewModel.trySendAction(AutoFillAction.FillAssistToggleClick(it)) + }, + onFillAssistInfoClick = { + viewModel.trySendAction(AutoFillAction.FillAssistInfoClick) + }, onLearnMoreClick = { viewModel.trySendAction(AutoFillAction.LearnMoreClick) }, onHelpCardClick = { viewModel.trySendAction(AutoFillAction.HelpCardClick) }, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt index 2ad1f3147bd..32b1caebeee 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt @@ -80,6 +80,7 @@ class AutofillParserTests { private val inlinePresentationSpecs: List = mockk() private val settingsRepository: SettingsRepository = mockk { every { isInlineAutofillEnabled } answers { mockIsInlineAutofillEnabled } + every { isFillAssistEnabled } answers { mockIsFillAssistEnabled } every { blockedAutofillUris } returns emptyList() } private val fillAssistManager: FillAssistManager = mockk() diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt index 95a3db3019c..dc1852fc0e5 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo @@ -866,9 +867,57 @@ class AutoFillScreenTest : BitwardenComposeTest() { intentManager.launchUri("https://bitwarden.com/help/uri-match-detection/".toUri()) } } + + @Test + fun `fill assist switch should not be displayed when showFillAssistOption is false`() { + mutableStateFlow.update { it.copy(showFillAssistOption = false) } + composeTestRule + .onNodeWithTag("FillAssistSwitch") + .assertDoesNotExist() + } + + @Test + fun `fill assist switch should be displayed when showFillAssistOption is true`() { + mutableStateFlow.update { it.copy(showFillAssistOption = true) } + composeTestRule + .onNodeWithTag("FillAssistSwitch") + .performScrollTo() + .assertIsDisplayed() + } + + @Test + fun `fill assist switch toggles should send FillAssistToggleClick action`() { + mutableStateFlow.update { + it.copy( + showFillAssistOption = true, + isFillAssistEnabled = false, + ) + } + composeTestRule + .onNodeWithTag("FillAssistSwitch") + .performScrollTo() + .performClick() + verify { + viewModel.trySendAction( + AutoFillAction.FillAssistToggleClick( + isEnabled = true, + ), + ) + } + } + + @Test + fun `on NavigateToFillAssistHelp should call launchUri`() { + mutableEventFlow.tryEmit(AutoFillEvent.NavigateToFillAssistHelp) + verify(exactly = 1) { + intentManager.launchUri("https://bitwarden.com/help/fill-assist/".toUri()) + } + } } private val DEFAULT_STATE: AutoFillState = AutoFillState( + showFillAssistOption = false, + isFillAssistEnabled = false, isAskToAddLoginEnabled = false, isAccessibilityAutofillEnabled = false, isAutoFillServicesEnabled = false, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt index 3cbf9ceb456..419406c3dad 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt @@ -5,11 +5,14 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.core.util.isBuildVersionAtLeast import com.bitwarden.ui.platform.base.BaseViewModelTest +import com.bitwarden.core.data.manager.model.FlagKey import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager import com.x8bit.bitwarden.data.autofill.model.browser.BrowserPackage import com.x8bit.bitwarden.data.autofill.model.browser.BrowserThirdPartyAutoFillData import com.x8bit.bitwarden.data.autofill.model.browser.BrowserThirdPartyAutofillStatus +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -55,6 +58,12 @@ class AutoFillViewModelTest : BaseViewModelTest() { every { browserThirdPartyAutofillStatus } returns DEFAULT_AUTOFILL_STATUS } + private val featureFlagManager: FeatureFlagManager = mockk { + every { getFeatureFlag(FlagKey.FillAssistTargetingRules) } returns false + } + + private val fillAssistManager: FillAssistManager = mockk(relaxed = true) + private val settingsRepository: SettingsRepository = mockk { every { isInlineAutofillEnabled } returns true every { isInlineAutofillEnabled = any() } just runs @@ -62,6 +71,8 @@ class AutoFillViewModelTest : BaseViewModelTest() { every { isAutoCopyTotpDisabled = any() } just runs every { isAutofillSavePromptDisabled } returns true every { isAutofillSavePromptDisabled = any() } just runs + every { isFillAssistEnabled } returns false + every { isFillAssistEnabled = any() } just runs every { defaultUriMatchType } returns UriMatchType.DOMAIN every { defaultUriMatchType = any() } just runs every { isAccessibilityEnabledStateFlow } returns mutableIsAccessibilityEnabledStateFlow @@ -512,6 +523,40 @@ class AutoFillViewModelTest : BaseViewModelTest() { } } + @Test + fun `FillAssistToggleClick with isEnabled true persists setting and triggers sync`() = runTest { + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + viewModel.trySendAction(AutoFillAction.FillAssistToggleClick(isEnabled = true)) + assertEquals(DEFAULT_STATE.copy(isFillAssistEnabled = true), awaitItem()) + } + verify { settingsRepository.isFillAssistEnabled = true } + verify { fillAssistManager.syncIfNecessary() } + } + + @Test + fun `FillAssistToggleClick with isEnabled false persists setting and skips sync`() = runTest { + val enabledState = DEFAULT_STATE.copy(isFillAssistEnabled = true) + val viewModel = createViewModel(state = enabledState) + viewModel.stateFlow.test { + assertEquals(enabledState, awaitItem()) + viewModel.trySendAction(AutoFillAction.FillAssistToggleClick(isEnabled = false)) + assertEquals(enabledState.copy(isFillAssistEnabled = false), awaitItem()) + } + verify { settingsRepository.isFillAssistEnabled = false } + verify(exactly = 0) { fillAssistManager.syncIfNecessary() } + } + + @Test + fun `FillAssistInfoClick emits NavigateToFillAssistHelp event`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AutoFillAction.FillAssistInfoClick) + assertEquals(AutoFillEvent.NavigateToFillAssistHelp, awaitItem()) + } + } + private fun createViewModel( state: AutoFillState? = DEFAULT_STATE, ): AutoFillViewModel = AutoFillViewModel( @@ -520,10 +565,14 @@ class AutoFillViewModelTest : BaseViewModelTest() { authRepository = authRepository, firstTimeActionManager = firstTimeActionManager, browserThirdPartyAutofillEnabledManager = browserThirdPartyAutofillEnabledManager, + featureFlagManager = featureFlagManager, + fillAssistManager = fillAssistManager, ) } private val DEFAULT_STATE: AutoFillState = AutoFillState( + showFillAssistOption = false, + isFillAssistEnabled = false, isAskToAddLoginEnabled = false, isAccessibilityAutofillEnabled = false, isAutoFillServicesEnabled = false, diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 6d175b2dad5..27afc34fe62 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -379,6 +379,8 @@ Scanning will happen automatically. Do you want to require unlocking with your master password when the application is restarted? Ask to add item Ask to add an item if one isn’t found in your vault. + Turn on fill assist + Fill assist improves autofill accuracy on supported sites by using site specific rules. On app restart Capitalize Include number