From 94108bcb5de6cec40166e1e2ef5a95ebcc938705 Mon Sep 17 00:00:00 2001 From: Brian Yencho Date: Thu, 18 Jan 2024 11:17:56 -0600 Subject: [PATCH] BIT-1488: Track changes in autofill status and send users to settings (#663) --- .../data/autofill/di/AutofillModule.kt | 11 +++ .../platform/repository/SettingsRepository.kt | 10 +++ .../repository/SettingsRepositoryImpl.kt | 37 ++++++++++ .../repository/di/PlatformRepositoryModule.kt | 6 ++ .../settings/autofill/AutoFillScreen.kt | 23 +++++++ .../settings/autofill/AutoFillViewModel.kt | 48 +++++++++++-- .../platform/manager/intent/IntentManager.kt | 6 ++ .../manager/intent/IntentManagerImpl.kt | 14 ++++ .../repository/SettingsRepositoryTest.kt | 69 +++++++++++++++++++ .../settings/autofill/AutoFillScreenTest.kt | 60 ++++++++++++++++ .../autofill/AutoFillViewModelTest.kt | 47 ++++++++++++- 11 files changed, 324 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt index 8f9db6a924..e50c6e98d0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt @@ -1,5 +1,7 @@ package com.x8bit.bitwarden.data.autofill.di +import android.content.Context +import android.view.autofill.AutofillManager import com.x8bit.bitwarden.data.autofill.builder.FillResponseBuilder import com.x8bit.bitwarden.data.autofill.builder.FillResponseBuilderImpl import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilder @@ -14,7 +16,9 @@ import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton /** * Provides dependencies within the autofill package. @@ -22,6 +26,13 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) object AutofillModule { + + @Singleton + @Provides + fun providesAutofillManager( + @ApplicationContext context: Context, + ): AutofillManager = context.getSystemService(AutofillManager::class.java) + @Provides fun providesAutofillParser(): AutofillParser = AutofillParserImpl() diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt index c26834c0e4..7bdfa3d7d0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt @@ -67,6 +67,16 @@ interface SettingsRepository { */ var isApprovePasswordlessLoginsEnabled: Boolean + /** + * Emits updates whenever there is a change in the app's status for supporting autofill. + */ + val isAutofillEnabledStateFlow: StateFlow + + /** + * Disables autofill if it is currently enabled. + */ + fun disableAutofill() + /** * Sets default values for various settings for the given [userId] if necessary. This is * typically used when logging into a new account. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt index f59cac7e42..43a6a6941d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt @@ -1,7 +1,9 @@ package com.x8bit.bitwarden.data.platform.repository +import android.view.autofill.AutofillManager import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource +import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction @@ -12,7 +14,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -21,6 +26,8 @@ import kotlinx.coroutines.launch */ @Suppress("TooManyFunctions") class SettingsRepositoryImpl( + private val autofillManager: AutofillManager, + private val appForegroundManager: AppForegroundManager, private val authDiskSource: AuthDiskSource, private val settingsDiskSource: SettingsDiskSource, private val vaultSdkSource: VaultSdkSource, @@ -30,6 +37,13 @@ class SettingsRepositoryImpl( private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined) + private val isAutofillEnabledAndSupported: Boolean + get() = autofillManager.isEnabled && + autofillManager.hasEnabledAutofillServices() && + autofillManager.isAutofillSupported + + private val mutableIsAutofillEnabledStateFlow = MutableStateFlow(isAutofillEnabledAndSupported) + override var appLanguage: AppLanguage get() = settingsDiskSource.appLanguage ?: AppLanguage.DEFAULT set(value) { @@ -134,6 +148,20 @@ class SettingsRepositoryImpl( isApprovePasswordlessLoginsEnabled = value, ) } + override val isAutofillEnabledStateFlow: StateFlow = + mutableIsAutofillEnabledStateFlow.asStateFlow() + + init { + observeAutofillEnabledChanges() + } + + override fun disableAutofill() { + autofillManager.disableAutofillServices() + + // Manually indicate that autofill is no longer supported without needing a foreground state + // change. + mutableIsAutofillEnabledStateFlow.value = false + } override fun setDefaultsIfNecessary(userId: String) { // Set Vault Settings defaults @@ -260,6 +288,15 @@ class SettingsRepositoryImpl( ) } } + + private fun observeAutofillEnabledChanges() { + appForegroundManager + .appForegroundStateFlow + .onEach { + mutableIsAutofillEnabledStateFlow.value = isAutofillEnabledAndSupported + } + .launchIn(unconfinedScope) + } } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt index 4f831eba08..5152c322ea 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt @@ -1,8 +1,10 @@ package com.x8bit.bitwarden.data.platform.repository.di +import android.view.autofill.AutofillManager import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource +import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepositoryImpl @@ -38,12 +40,16 @@ object PlatformRepositoryModule { @Provides @Singleton fun provideSettingsRepository( + autofillManager: AutofillManager, + appForegroundManager: AppForegroundManager, authDiskSource: AuthDiskSource, settingsDiskSource: SettingsDiskSource, vaultSdkSource: VaultSdkSource, dispatcherManager: DispatcherManager, ): SettingsRepository = SettingsRepositoryImpl( + autofillManager = autofillManager, + appForegroundManager = appForegroundManager, authDiskSource = authDiskSource, settingsDiskSource = settingsDiskSource, vaultSdkSource = vaultSdkSource, 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 77f896d5ab..9098f4573f 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 @@ -30,6 +30,9 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.BasicDialogState +import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionDialog @@ -37,6 +40,8 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionRow import com.x8bit.bitwarden.ui.platform.components.BitwardenTextRow import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager /** * Displays the auto-fill screen. @@ -47,20 +52,38 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch fun AutoFillScreen( onNavigateBack: () -> Unit, viewModel: AutoFillViewModel = hiltViewModel(), + intentManager: IntentManager = LocalIntentManager.current, ) { val state by viewModel.stateFlow.collectAsState() val context = LocalContext.current val resources = context.resources + var shouldShowAutofillFallbackDialog by rememberSaveable { mutableStateOf(false) } EventsEffect(viewModel = viewModel) { event -> when (event) { AutoFillEvent.NavigateBack -> onNavigateBack.invoke() + AutoFillEvent.NavigateToAutofillSettings -> { + val isSuccess = intentManager.startSystemAutofillSettingsActivity() + + shouldShowAutofillFallbackDialog = !isSuccess + } + is AutoFillEvent.ShowToast -> { Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show() } } } + if (shouldShowAutofillFallbackDialog) { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = null, + message = R.string.bitwarden_autofill_go_to_settings.asText(), + ), + onDismissRequest = { shouldShowAutofillFallbackDialog = false }, + ) + } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) BitwardenScaffold( 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 ea03f0a3a3..f7e1cc28ad 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 @@ -10,6 +10,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText 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.parcelize.Parcelize @@ -29,7 +30,7 @@ class AutoFillViewModel @Inject constructor( initialState = savedStateHandle[KEY_STATE] ?: AutoFillState( isAskToAddLoginEnabled = false, - isAutoFillServicesEnabled = false, + isAutoFillServicesEnabled = settingsRepository.isAutofillEnabledStateFlow.value, isCopyTotpAutomaticallyEnabled = false, isUseInlineAutoFillEnabled = settingsRepository.isInlineAutofillEnabled, uriDetectionMethod = AutoFillState.UriDetectionMethod.DEFAULT, @@ -40,6 +41,14 @@ class AutoFillViewModel @Inject constructor( stateFlow .onEach { savedStateHandle[KEY_STATE] = it } .launchIn(viewModelScope) + + settingsRepository + .isAutofillEnabledStateFlow + .map { + AutoFillAction.Internal.AutofillEnabledUpdateReceive(isAutofillEnabled = it) + } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: AutoFillAction): Unit = when (action) { @@ -49,6 +58,9 @@ class AutoFillViewModel @Inject constructor( is AutoFillAction.CopyTotpAutomaticallyClick -> handleCopyTotpAutomaticallyClick(action) is AutoFillAction.UriDetectionMethodSelect -> handleUriDetectionMethodSelect(action) is AutoFillAction.UseInlineAutofillClick -> handleUseInlineAutofillClick(action) + is AutoFillAction.Internal.AutofillEnabledUpdateReceive -> { + handleAutofillEnabledUpdateReceive(action) + } } private fun handleAskToAddLoginClick(action: AutoFillAction.AskToAddLoginClick) { @@ -58,9 +70,11 @@ class AutoFillViewModel @Inject constructor( } private fun handleAutoFillServicesClick(action: AutoFillAction.AutoFillServicesClick) { - // TODO BIT-828: Persist selection - sendEvent(AutoFillEvent.ShowToast("Not yet implemented.".asText())) - mutableStateFlow.update { it.copy(isAutoFillServicesEnabled = action.isEnabled) } + if (action.isEnabled) { + sendEvent(AutoFillEvent.NavigateToAutofillSettings) + } else { + settingsRepository.disableAutofill() + } } private fun handleBackClick() { @@ -87,6 +101,14 @@ class AutoFillViewModel @Inject constructor( it.copy(uriDetectionMethod = action.uriDetectionMethod) } } + + private fun handleAutofillEnabledUpdateReceive( + action: AutoFillAction.Internal.AutofillEnabledUpdateReceive, + ) { + mutableStateFlow.update { + it.copy(isAutoFillServicesEnabled = action.isAutofillEnabled) + } + } } /** @@ -123,6 +145,11 @@ sealed class AutoFillEvent { */ data object NavigateBack : AutoFillEvent() + /** + * Navigates to the system autofill settings selection screen. + */ + data object NavigateToAutofillSettings : AutoFillEvent() + /** * Displays a toast with the given [Text]. */ @@ -174,4 +201,17 @@ sealed class AutoFillAction { data class UseInlineAutofillClick( val isEnabled: Boolean, ) : AutoFillAction() + + /** + * Internal actions. + */ + sealed class Internal : AutoFillAction() { + + /** + * An update for changes in the [isAutofillEnabled] value. + */ + data class AutofillEnabledUpdateReceive( + val isAutofillEnabled: Boolean, + ) : Internal() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt index 88d0d3adce..f2cecb4b50 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt @@ -26,6 +26,12 @@ interface IntentManager { */ fun startCustomTabsActivity(uri: Uri) + /** + * Attempts to start the system autofill settings activity. The return value indicates whether + * or not this was successful. + */ + fun startSystemAutofillSettingsActivity(): Boolean + /** * Start an activity to view the given [uri] in an external browser. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt index f5a8a93487..b62d92fcca 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt @@ -1,12 +1,14 @@ package com.x8bit.bitwarden.ui.platform.manager.intent import android.app.Activity +import android.content.ActivityNotFoundException import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.provider.MediaStore +import android.provider.Settings import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResult @@ -79,6 +81,18 @@ class IntentManagerImpl( .launchUrl(context, uri) } + override fun startSystemAutofillSettingsActivity(): Boolean = + try { + val intent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE) + .apply { + data = Uri.parse("package:${context.packageName}") + } + context.startActivity(intent) + true + } catch (e: ActivityNotFoundException) { + false + } + override fun launchUri(uri: Uri) { val newUri = if (uri.scheme == null) { uri.buildUpon().scheme("https").build() diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt index 4fb1bc4dea..4b1df135f4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt @@ -1,11 +1,14 @@ package com.x8bit.bitwarden.data.platform.repository +import android.view.autofill.AutofillManager import app.cash.turbine.test import com.bitwarden.core.DerivePinKeyResponse import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSource +import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.data.platform.util.asSuccess @@ -14,7 +17,12 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLang import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -23,11 +31,25 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class SettingsRepositoryTest { + private val autofillManager: AutofillManager = mockk { + every { hasEnabledAutofillServices() } answers { isAutofillEnabledAndSupported } + every { isAutofillSupported } answers { isAutofillEnabledAndSupported } + every { isEnabled } answers { isAutofillEnabledAndSupported } + every { disableAutofillServices() } just runs + } + private val mutableAppForegroundStateFlow = MutableStateFlow(AppForegroundState.BACKGROUNDED) + private val appForegroundManager: AppForegroundManager = mockk { + every { appForegroundStateFlow } returns mutableAppForegroundStateFlow + } private val fakeAuthDiskSource = FakeAuthDiskSource() private val fakeSettingsDiskSource = FakeSettingsDiskSource() private val vaultSdkSource: VaultSdkSource = mockk() + private var isAutofillEnabledAndSupported = false + private val settingsRepository = SettingsRepositoryImpl( + autofillManager = autofillManager, + appForegroundManager = appForegroundManager, authDiskSource = fakeAuthDiskSource, settingsDiskSource = fakeSettingsDiskSource, vaultSdkSource = vaultSdkSource, @@ -389,6 +411,53 @@ class SettingsRepositoryTest { ) } + @Suppress("MaxLineLength") + @Test + fun `isAutofillEnabledStateFlow should emit updates if necessary when the app foreground state changes and disableAutofill is called`() = + runTest { + settingsRepository.isAutofillEnabledStateFlow.test { + assertFalse(awaitItem()) + + // An update is received when both the autofill state and foreground state change + isAutofillEnabledAndSupported = true + mutableAppForegroundStateFlow.value = AppForegroundState.FOREGROUNDED + assertTrue(awaitItem()) + + // An update is not received when only the foreground state changes + mutableAppForegroundStateFlow.value = AppForegroundState.BACKGROUNDED + expectNoEvents() + + // An update is not received when only the autofill state changes + isAutofillEnabledAndSupported = false + expectNoEvents() + + // An update is received after both states have changed + mutableAppForegroundStateFlow.value = AppForegroundState.FOREGROUNDED + assertFalse(awaitItem()) + + // Calling disableAutofill will result in an emission of false + isAutofillEnabledAndSupported = true + mutableAppForegroundStateFlow.value = AppForegroundState.BACKGROUNDED + assertTrue(awaitItem()) + settingsRepository.disableAutofill() + assertFalse(awaitItem()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `disableAutofill should trigger an emission of false from isAutofillEnabledStateFlow and disable autofill with the OS`() { + // Start in a state where autofill is enabled + isAutofillEnabledAndSupported = true + mutableAppForegroundStateFlow.value = AppForegroundState.FOREGROUNDED + assertTrue(settingsRepository.isAutofillEnabledStateFlow.value) + + settingsRepository.disableAutofill() + + assertFalse(settingsRepository.isAutofillEnabledStateFlow.value) + verify { autofillManager.disableAutofillServices() } + } + @Test fun `getPullToRefreshEnabledFlow should react to changes in SettingsDiskSource`() = runTest { val userId = "userId" 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 669deecb70..c1ff24688b 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 @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.autofill import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.filterToOne @@ -13,6 +14,8 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.util.assertNoDialogExists import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -24,6 +27,7 @@ import org.junit.Test class AutoFillScreenTest : BaseComposeTest() { + private var isSystemSettingsRequestSuccess = false private var onNavigateBackCalled = false private val mutableEventFlow = bufferedMutableSharedFlow() @@ -32,6 +36,9 @@ class AutoFillScreenTest : BaseComposeTest() { every { eventFlow } returns mutableEventFlow every { stateFlow } returns mutableStateFlow } + private val intentManager: IntentManager = mockk { + every { startSystemAutofillSettingsActivity() } answers { isSystemSettingsRequestSuccess } + } @Before fun setUp() { @@ -39,10 +46,63 @@ class AutoFillScreenTest : BaseComposeTest() { AutoFillScreen( onNavigateBack = { onNavigateBackCalled = true }, viewModel = viewModel, + intentManager = intentManager, ) } } + @Suppress("MaxLineLength") + @Test + fun `on NavigateToAutofillSettings should attempt to navigate to system settings and not show the fallback dialog when result is a success`() { + isSystemSettingsRequestSuccess = true + + mutableEventFlow.tryEmit(AutoFillEvent.NavigateToAutofillSettings) + + verify { + intentManager.startSystemAutofillSettingsActivity() + } + composeTestRule.assertNoDialogExists() + } + + @Suppress("MaxLineLength") + @Test + fun `on NavigateToAutofillSettings should attempt to navigate to system settings and show the fallback dialog when result is not a success`() { + isSystemSettingsRequestSuccess = false + + mutableEventFlow.tryEmit(AutoFillEvent.NavigateToAutofillSettings) + + verify { + intentManager.startSystemAutofillSettingsActivity() + } + + composeTestRule + .onAllNodesWithText( + "We were unable to automatically open the Android autofill settings menu for " + + "you. You can navigate to the autofill settings menu manually from Android " + + "Settings>System>Languages and input>Advanced>Autofill service.", + ) + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `on autofill settings fallback dialog Ok click should dismiss the dialog`() { + isSystemSettingsRequestSuccess = false + mutableEventFlow.tryEmit(AutoFillEvent.NavigateToAutofillSettings) + + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + + composeTestRule.assertNoDialogExists() + } + @Test fun `on auto fill services toggle should send AutoFillServicesClick`() { composeTestRule 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 46e6a35274..a5e9605ab0 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 @@ -10,15 +10,19 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class AutoFillViewModelTest : BaseViewModelTest() { + private val mutableIsAutofillEnabledStateFlow = MutableStateFlow(false) private val settingsRepository: SettingsRepository = mockk() { every { isInlineAutofillEnabled } returns true every { isInlineAutofillEnabled = any() } just runs + every { isAutofillEnabledStateFlow } returns mutableIsAutofillEnabledStateFlow + every { disableAutofill() } just runs } @Test @@ -29,6 +33,7 @@ class AutoFillViewModelTest : BaseViewModelTest() { @Test fun `initial state should be correct when set`() { + mutableIsAutofillEnabledStateFlow.value = true val state = DEFAULT_STATE.copy( isAutoFillServicesEnabled = true, uriDetectionMethod = AutoFillState.UriDetectionMethod.REGULAR_EXPRESSION, @@ -37,6 +42,26 @@ class AutoFillViewModelTest : BaseViewModelTest() { assertEquals(state, viewModel.stateFlow.value) } + @Test + fun `changes in autofill enabled status should update the state`() { + val viewModel = createViewModel() + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + + mutableIsAutofillEnabledStateFlow.value = true + + assertEquals( + DEFAULT_STATE.copy(isAutoFillServicesEnabled = true), + viewModel.stateFlow.value, + ) + + mutableIsAutofillEnabledStateFlow.value = false + + assertEquals( + DEFAULT_STATE.copy(isAutoFillServicesEnabled = false), + viewModel.stateFlow.value, + ) + } + @Test fun `on AskToAddLoginClick should emit ShowToast`() = runTest { val viewModel = createViewModel() @@ -51,14 +76,30 @@ class AutoFillViewModelTest : BaseViewModelTest() { } @Test - fun `on AutoFillServicesClick should emit ShowToast`() = runTest { + fun `on AutoFillServicesClick with false should disable autofill`() { + val viewModel = createViewModel() + viewModel.trySendAction(AutoFillAction.AutoFillServicesClick(false)) + verify { + settingsRepository.disableAutofill() + } + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + } + + @Test + fun `on AutoFillServicesClick with true should emit NavigateToAutofillSettings`() = runTest { val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.trySendAction(AutoFillAction.AutoFillServicesClick(true)) - assertEquals(AutoFillEvent.ShowToast("Not yet implemented.".asText()), awaitItem()) + assertEquals( + AutoFillEvent.NavigateToAutofillSettings, + awaitItem(), + ) } assertEquals( - DEFAULT_STATE.copy(isAutoFillServicesEnabled = true), + DEFAULT_STATE, viewModel.stateFlow.value, ) }