diff --git a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt index 175487965..50617c2ff 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt @@ -15,6 +15,7 @@ import androidx.core.os.LocaleListCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.rememberNavController +import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityActivityManager import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManager import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManager import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager @@ -38,6 +39,9 @@ class MainActivity : AppCompatActivity() { private val mainViewModel: MainViewModel by viewModels() + @Inject + lateinit var accessibilityActivityManager: AccessibilityActivityManager + @Inject lateinit var autofillActivityManager: AutofillActivityManager diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/di/AccessibilityModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/di/AccessibilityModule.kt index b22c1ee50..90c4d96f9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/di/AccessibilityModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/di/AccessibilityModule.kt @@ -7,6 +7,8 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAuto import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManagerImpl import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManager import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManagerImpl +import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager +import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManagerImpl import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManagerImpl import com.x8bit.bitwarden.data.autofill.accessibility.manager.LauncherPackageNameManager @@ -49,6 +51,11 @@ object AccessibilityModule { fun providesAccessibilityAutofillManager(): AccessibilityAutofillManager = AccessibilityAutofillManagerImpl() + @Singleton + @Provides + fun providesAccessibilityEnabledManager(): AccessibilityEnabledManager = + AccessibilityEnabledManagerImpl() + @Singleton @Provides fun providesAccessibilityParser(): AccessibilityParser = AccessibilityParserImpl() diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/di/ActivityAccessibilityModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/di/ActivityAccessibilityModule.kt new file mode 100644 index 000000000..33ddff1f5 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/di/ActivityAccessibilityModule.kt @@ -0,0 +1,36 @@ +package com.x8bit.bitwarden.data.autofill.accessibility.di + +import android.content.Context +import androidx.lifecycle.LifecycleCoroutineScope +import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityActivityManager +import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityActivityManagerImpl +import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager +import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.scopes.ActivityScoped + +/** + * Provides dependencies within the accessibility package scoped to the activity. + */ +@Module +@InstallIn(ActivityComponent::class) +object ActivityAccessibilityModule { + @ActivityScoped + @Provides + fun providesAccessibilityActivityManager( + @ApplicationContext context: Context, + accessibilityEnabledManager: AccessibilityEnabledManager, + appForegroundManager: AppForegroundManager, + lifecycleScope: LifecycleCoroutineScope, + ): AccessibilityActivityManager = + AccessibilityActivityManagerImpl( + context = context, + accessibilityEnabledManager = accessibilityEnabledManager, + appForegroundManager = appForegroundManager, + lifecycleScope = lifecycleScope, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManager.kt new file mode 100644 index 000000000..191382544 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManager.kt @@ -0,0 +1,10 @@ +package com.x8bit.bitwarden.data.autofill.accessibility.manager + +import android.app.Activity + +/** + * A helper for dealing with accessibility configuration that must be scoped to a specific + * [Activity]. In particular, this should be injected into an [Activity] to ensure that the + * [AccessibilityEnabledManager] reports correct values. + */ +interface AccessibilityActivityManager diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManagerImpl.kt new file mode 100644 index 000000000..e5e5f82e9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManagerImpl.kt @@ -0,0 +1,53 @@ +package com.x8bit.bitwarden.data.autofill.accessibility.manager + +import android.content.Context +import android.provider.Settings +import androidx.lifecycle.LifecycleCoroutineScope +import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * The accessibility service name as it is known in the AndroidManifest. + * + * Note: This is not the name of the actual service but the name defined in the Android Manifest + * which matches the service in the Xamarin app. + */ +private const val LEGACY_ACCESSIBILITY_SERVICE_NAME = + "com.x8bit.bitwarden.Accessibility.AccessibilityService" + +/** + * The default implementation of the [AccessibilityActivityManager]. + */ +class AccessibilityActivityManagerImpl( + private val context: Context, + private val accessibilityEnabledManager: AccessibilityEnabledManager, + appForegroundManager: AppForegroundManager, + lifecycleScope: LifecycleCoroutineScope, +) : AccessibilityActivityManager { + private val isAccessibilityServiceEnabled: Boolean + get() { + val appContext = context.applicationContext + val accessibilityService = appContext + .packageName + ?.let { "$it/$LEGACY_ACCESSIBILITY_SERVICE_NAME" } + ?: return false + return Settings + .Secure + .getString( + appContext.contentResolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + ) + ?.contains(accessibilityService) + ?: false + } + + init { + appForegroundManager + .appForegroundStateFlow + .onEach { + accessibilityEnabledManager.isAccessibilityEnabled = isAccessibilityServiceEnabled + } + .launchIn(lifecycleScope) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityEnabledManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityEnabledManager.kt new file mode 100644 index 000000000..4ca327247 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityEnabledManager.kt @@ -0,0 +1,21 @@ +package com.x8bit.bitwarden.data.autofill.accessibility.manager + +import kotlinx.coroutines.flow.StateFlow + +/** + * A container for values specifying whether or not the accessibility service is enabled. + */ +interface AccessibilityEnabledManager { + /** + * Whether or not the accessibility service should be considered enabled. + * + * Note that changing this does not enable or disable autofill; it is only an indicator that + * this has occurred elsewhere. + */ + var isAccessibilityEnabled: Boolean + + /** + * Emits updates that track [isAccessibilityEnabled] values. + */ + val isAccessibilityEnabledStateFlow: StateFlow +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityEnabledManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityEnabledManagerImpl.kt new file mode 100644 index 000000000..c3f434521 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityEnabledManagerImpl.kt @@ -0,0 +1,21 @@ +package com.x8bit.bitwarden.data.autofill.accessibility.manager + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * The default implementation of [AccessibilityEnabledManager]. + */ +class AccessibilityEnabledManagerImpl : AccessibilityEnabledManager { + private val mutableIsAccessibilityEnabledStateFlow = MutableStateFlow(value = false) + + override var isAccessibilityEnabled: Boolean + get() = mutableIsAccessibilityEnabledStateFlow.value + set(value) { + mutableIsAccessibilityEnabledStateFlow.value = value + } + + override val isAccessibilityEnabledStateFlow: StateFlow + get() = mutableIsAccessibilityEnabledStateFlow.asStateFlow() +} 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 f6c6a42c7..9ff39fd5d 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 @@ -133,6 +133,15 @@ interface SettingsRepository { */ var blockedAutofillUris: List + /** + * Emits updates whenever there is a change in the app's status for accessibility-based + * autofill. + * + * Note that the correct value is only populated upon subscription so calling [StateFlow.value] + * may result in an out-of-date value. + */ + val isAccessibilityEnabledStateFlow: StateFlow + /** * Emits updates whenever there is a change in the app's status for supporting autofill. * 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 249d3253c..2f261de2d 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 @@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult import com.x8bit.bitwarden.data.auth.repository.util.policyInformation +import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager @@ -49,6 +50,7 @@ class SettingsRepositoryImpl( private val settingsDiskSource: SettingsDiskSource, private val vaultSdkSource: VaultSdkSource, private val biometricsEncryptionManager: BiometricsEncryptionManager, + accessibilityEnabledManager: AccessibilityEnabledManager, policyManager: PolicyManager, dispatcherManager: DispatcherManager, ) : SettingsRepository { @@ -293,6 +295,9 @@ class SettingsRepositoryImpl( ) } + override val isAccessibilityEnabledStateFlow: StateFlow = + accessibilityEnabledManager.isAccessibilityEnabledStateFlow + override val isAutofillEnabledStateFlow: StateFlow = autofillEnabledManager.isAutofillEnabledStateFlow 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 4d8787457..197bea319 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 @@ -2,6 +2,7 @@ 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.autofill.accessibility.manager.AccessibilityEnabledManager import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource @@ -71,6 +72,7 @@ object PlatformRepositoryModule { settingsDiskSource: SettingsDiskSource, vaultSdkSource: VaultSdkSource, encryptionManager: BiometricsEncryptionManager, + accessibilityEnabledManager: AccessibilityEnabledManager, dispatcherManager: DispatcherManager, policyManager: PolicyManager, ): SettingsRepository = @@ -81,6 +83,7 @@ object PlatformRepositoryModule { settingsDiskSource = settingsDiskSource, vaultSdkSource = vaultSdkSource, biometricsEncryptionManager = encryptionManager, + accessibilityEnabledManager = accessibilityEnabledManager, dispatcherManager = dispatcherManager, policyManager = policyManager, ) 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 0e02546d2..d377d6aae 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 @@ -28,6 +28,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.x8bit.bitwarden.BuildConfig import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect @@ -36,6 +37,7 @@ import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar 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 +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.dialog.row.BitwardenSelectionRow import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.row.BitwardenExternalLinkRow @@ -67,6 +69,10 @@ fun AutoFillScreen( when (event) { AutoFillEvent.NavigateBack -> onNavigateBack.invoke() + AutoFillEvent.NavigateToAccessibilitySettings -> { + intentManager.startSystemAccessibilitySettingsActivity() + } + AutoFillEvent.NavigateToAutofillSettings -> { val isSuccess = intentManager.startSystemAutofillSettingsActivity() @@ -171,6 +177,15 @@ fun AutoFillScreen( withDivider = false, ) } + AccessibilityAutofillSwitch( + isAccessibilityAutoFillEnabled = state.isAccessibilityAutofillEnabled, + onCheckedChange = remember(viewModel) { + { viewModel.trySendAction(AutoFillAction.UseAccessibilityAutofillClick) } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) Spacer(modifier = Modifier.height(16.dp)) BitwardenListHeaderText( label = stringResource(id = R.string.additional_options), @@ -226,6 +241,45 @@ fun AutoFillScreen( } } +@Composable +private fun AccessibilityAutofillSwitch( + isAccessibilityAutoFillEnabled: Boolean, + onCheckedChange: () -> Unit, + modifier: Modifier = Modifier, +) { + // TODO: This should be visible in all variants once the feature is complete (PM-12014) + if (!BuildConfig.DEBUG) return + var shouldShowDialog by rememberSaveable { mutableStateOf(value = false) } + BitwardenWideSwitch( + label = stringResource(id = R.string.accessibility), + description = stringResource(id = R.string.accessibility_description5), + isChecked = isAccessibilityAutoFillEnabled, + onCheckedChange = { + if (isAccessibilityAutoFillEnabled) { + onCheckedChange() + } else { + shouldShowDialog = true + } + }, + modifier = modifier.testTag(tag = "AccessibilityAutofillSwitch"), + ) + + if (shouldShowDialog) { + BitwardenTwoButtonDialog( + title = stringResource(id = R.string.accessibility_service_disclosure), + message = stringResource(id = R.string.accessibility_disclosure_text), + confirmButtonText = stringResource(id = R.string.accept), + dismissButtonText = stringResource(id = R.string.decline), + onConfirmClick = { + onCheckedChange() + shouldShowDialog = false + }, + onDismissClick = { shouldShowDialog = false }, + onDismissRequest = { shouldShowDialog = false }, + ) + } +} + @Composable private fun DefaultUriMatchTypeRow( selectedUriMatchType: UriMatchType, 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 7c670ef91..2b9dcddee 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 @@ -31,6 +31,9 @@ class AutoFillViewModel @Inject constructor( initialState = savedStateHandle[KEY_STATE] ?: AutoFillState( isAskToAddLoginEnabled = !settingsRepository.isAutofillSavePromptDisabled, + isAccessibilityAutofillEnabled = settingsRepository + .isAccessibilityEnabledStateFlow + .value, isAutoFillServicesEnabled = settingsRepository.isAutofillEnabledStateFlow.value, isCopyTotpAutomaticallyEnabled = !settingsRepository.isAutoCopyTotpDisabled, isUseInlineAutoFillEnabled = settingsRepository.isInlineAutofillEnabled, @@ -45,6 +48,15 @@ class AutoFillViewModel @Inject constructor( .onEach { savedStateHandle[KEY_STATE] = it } .launchIn(viewModelScope) + settingsRepository + .isAccessibilityEnabledStateFlow + .map { + AutoFillAction.Internal.AccessibilityEnabledUpdateReceive( + isAccessibilityEnabled = it, + ) + } + .onEach(::sendAction) + .launchIn(viewModelScope) settingsRepository .isAutofillEnabledStateFlow .map { @@ -54,17 +66,28 @@ class AutoFillViewModel @Inject constructor( .launchIn(viewModelScope) } - override fun handleAction(action: AutoFillAction): Unit = when (action) { + override fun handleAction(action: AutoFillAction) = when (action) { is AutoFillAction.AskToAddLoginClick -> handleAskToAddLoginClick(action) is AutoFillAction.AutoFillServicesClick -> handleAutoFillServicesClick(action) AutoFillAction.BackClick -> handleBackClick() is AutoFillAction.CopyTotpAutomaticallyClick -> handleCopyTotpAutomaticallyClick(action) is AutoFillAction.DefaultUriMatchTypeSelect -> handleDefaultUriMatchTypeSelect(action) AutoFillAction.BlockAutoFillClick -> handleBlockAutoFillClick() + AutoFillAction.UseAccessibilityAutofillClick -> handleUseAccessibilityAutofillClick() is AutoFillAction.UseInlineAutofillClick -> handleUseInlineAutofillClick(action) AutoFillAction.PasskeyManagementClick -> handlePasskeyManagementClick() - is AutoFillAction.Internal.AutofillEnabledUpdateReceive -> { - handleAutofillEnabledUpdateReceive(action) + is AutoFillAction.Internal -> handleInternalAction(action) + } + + private fun handleInternalAction(action: AutoFillAction.Internal) { + when (action) { + is AutoFillAction.Internal.AccessibilityEnabledUpdateReceive -> { + handleAccessibilityEnabledUpdateReceive(action) + } + + is AutoFillAction.Internal.AutofillEnabledUpdateReceive -> { + handleAutofillEnabledUpdateReceive(action) + } } } @@ -92,6 +115,10 @@ class AutoFillViewModel @Inject constructor( mutableStateFlow.update { it.copy(isCopyTotpAutomaticallyEnabled = action.isEnabled) } } + private fun handleUseAccessibilityAutofillClick() { + sendEvent(AutoFillEvent.NavigateToAccessibilitySettings) + } + private fun handleUseInlineAutofillClick(action: AutoFillAction.UseInlineAutofillClick) { settingsRepository.isInlineAutofillEnabled = action.isEnabled mutableStateFlow.update { it.copy(isUseInlineAutoFillEnabled = action.isEnabled) } @@ -108,6 +135,14 @@ class AutoFillViewModel @Inject constructor( } } + private fun handleAccessibilityEnabledUpdateReceive( + action: AutoFillAction.Internal.AccessibilityEnabledUpdateReceive, + ) { + mutableStateFlow.update { + it.copy(isAccessibilityAutofillEnabled = action.isAccessibilityEnabled) + } + } + private fun handleAutofillEnabledUpdateReceive( action: AutoFillAction.Internal.AutofillEnabledUpdateReceive, ) { @@ -127,6 +162,7 @@ class AutoFillViewModel @Inject constructor( @Parcelize data class AutoFillState( val isAskToAddLoginEnabled: Boolean, + val isAccessibilityAutofillEnabled: Boolean, val isAutoFillServicesEnabled: Boolean, val isCopyTotpAutomaticallyEnabled: Boolean, val isUseInlineAutoFillEnabled: Boolean, @@ -152,6 +188,11 @@ sealed class AutoFillEvent { */ data object NavigateBack : AutoFillEvent() + /** + * Navigates to the system accessibility settings selection screen. + */ + data object NavigateToAccessibilitySettings : AutoFillEvent() + /** * Navigates to the system autofill settings selection screen. */ @@ -217,6 +258,11 @@ sealed class AutoFillAction { */ data object BlockAutoFillClick : AutoFillAction() + /** + * User clicked use accessibility autofill switch. + */ + data object UseAccessibilityAutofillClick : AutoFillAction() + /** * User clicked use inline autofill button. */ @@ -233,6 +279,12 @@ sealed class AutoFillAction { * Internal actions. */ sealed class Internal : AutoFillAction() { + /** + * An update for changes in the [isAccessibilityEnabled] value. + */ + data class AccessibilityEnabledUpdateReceive( + val isAccessibilityEnabled: Boolean, + ) : Internal() /** * An update for changes in the [isAutofillEnabled] value. 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 4de21b46b..8c841a2da 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 @@ -27,6 +27,11 @@ interface IntentManager { */ fun startCustomTabsActivity(uri: Uri) + /** + * Attempts to start the system accessibility settings activity. + */ + fun startSystemAccessibilitySettingsActivity() + /** * Attempts to start the system autofill settings activity. The return value indicates whether * or not this was successful. 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 c5baa1bc5..9d797e085 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 @@ -103,6 +103,10 @@ class IntentManagerImpl( .launchUrl(context, uri) } + override fun startSystemAccessibilitySettingsActivity() { + context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + } + override fun startSystemAutofillSettingsActivity(): Boolean = try { val intent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4cb470c79..e098b050e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -542,6 +542,7 @@ Scanning will happen automatically. Use the Bitwarden Accessibility Service to auto-fill your logins across apps and the web. (Requires Draw-Over to be turned on as well) Use the Bitwarden Accessibility Service to use the Autofill Quick-Action Tile, and/or show a popup using Draw-Over (if turned on). Required to use the Autofill Quick-Action Tile, or to augment the Autofill Service by using Draw-Over (if turned on). + Required to use the Autofill Quick-Action Tile. Use draw-over Allows the Bitwarden Accessibility Service to display a popup when login fields are selected. If turned on, the Bitwarden Accessibility Service will display a popup when login fields are selected to assist with auto-filling your logins. diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManagerTest.kt new file mode 100644 index 000000000..520ede728 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityActivityManagerTest.kt @@ -0,0 +1,95 @@ +package com.x8bit.bitwarden.data.autofill.accessibility.manager + +import android.content.Context +import android.provider.Settings +import androidx.lifecycle.LifecycleCoroutineScope +import app.cash.turbine.test +import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager +import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AccessibilityActivityManagerTest { + private val context: Context = mockk { + every { applicationContext } returns this + every { packageName } returns "com.x8bit.bitwarden" + every { contentResolver } returns mockk() + } + private val accessibilityEnabledManager: AccessibilityEnabledManager = + AccessibilityEnabledManagerImpl() + private val mutableAppForegroundStateFlow = MutableStateFlow(AppForegroundState.BACKGROUNDED) + private val appForegroundManager: AppForegroundManager = mockk { + every { appForegroundStateFlow } returns mutableAppForegroundStateFlow + } + private val lifecycleScope = mockk { + every { coroutineContext } returns UnconfinedTestDispatcher() + } + + // We will construct an instance here just to hook the various dependencies together internally + @Suppress("unused") + private val autofillActivityManager: AccessibilityActivityManager = + AccessibilityActivityManagerImpl( + context = context, + accessibilityEnabledManager = accessibilityEnabledManager, + appForegroundManager = appForegroundManager, + lifecycleScope = lifecycleScope, + ) + + @BeforeEach + fun setup() { + mockkStatic(Settings.Secure::getString) + mockkSettingsSecureGetString(value = null) + } + + @AfterEach + fun tearDown() { + unmockkStatic(Settings.Secure::getString) + } + + @Test + fun `changes in app foreground status should update the AutofillEnabledManager as necessary`() = + runTest { + accessibilityEnabledManager.isAccessibilityEnabledStateFlow.test { + assertFalse(awaitItem()) + + // An update is received when both the accessibility state and foreground state + // change + @Suppress("MaxLineLength") + mockkSettingsSecureGetString( + value = "com.x8bit.bitwarden/com.x8bit.bitwarden.Accessibility.AccessibilityService", + ) + 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 accessibility state changes + mockkSettingsSecureGetString(value = "com.x8bit.bitwarden/AccessibilityService") + expectNoEvents() + + // An update is received after both states have changed + mutableAppForegroundStateFlow.value = AppForegroundState.FOREGROUNDED + assertFalse(awaitItem()) + } + } + + private fun mockkSettingsSecureGetString(value: String?) { + every { + Settings.Secure.getString(any(), Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) + } returns value + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityEnabledManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityEnabledManagerTest.kt new file mode 100644 index 000000000..716b27f0e --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/AccessibilityEnabledManagerTest.kt @@ -0,0 +1,31 @@ +package com.x8bit.bitwarden.data.autofill.accessibility.manager + +import app.cash.turbine.test +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class AccessibilityEnabledManagerTest { + + private val accessibilityEnabledManager: AccessibilityEnabledManager = + AccessibilityEnabledManagerImpl() + + @Suppress("MaxLineLength") + @Test + fun `isAccessibilityEnabledStateFlow should emit whenever isAccessibilityEnabled is set to a unique value`() = + runTest { + accessibilityEnabledManager.isAccessibilityEnabledStateFlow.test { + assertFalse(awaitItem()) + + accessibilityEnabledManager.isAccessibilityEnabled = true + assertTrue(awaitItem()) + + accessibilityEnabledManager.isAccessibilityEnabled = true + expectNoEvents() + + accessibilityEnabledManager.isAccessibilityEnabled = false + assertFalse(awaitItem()) + } + } +} 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 b14476c59..8d26635a2 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 @@ -11,6 +11,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult +import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager +import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManagerImpl import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManagerImpl import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager @@ -51,6 +53,8 @@ class SettingsRepositoryTest { every { disableAutofillServices() } just runs } private val autofillEnabledManager: AutofillEnabledManager = AutofillEnabledManagerImpl() + private val accessibilityEnabledManager: AccessibilityEnabledManager = + AccessibilityEnabledManagerImpl() private val fakeAuthDiskSource = FakeAuthDiskSource() private val fakeSettingsDiskSource = FakeSettingsDiskSource() private val vaultSdkSource: VaultSdkSource = mockk() @@ -69,6 +73,7 @@ class SettingsRepositoryTest { settingsDiskSource = fakeSettingsDiskSource, vaultSdkSource = vaultSdkSource, biometricsEncryptionManager = biometricsEncryptionManager, + accessibilityEnabledManager = accessibilityEnabledManager, dispatcherManager = FakeDispatcherManager(), policyManager = policyManager, ) @@ -677,6 +682,21 @@ class SettingsRepositoryTest { ) } + @Suppress("MaxLineLength") + @Test + fun `isAccessibilityEnabledStateFlow should emit whenever the accessibilityEnabledManager does`() = + runTest { + settingsRepository.isAccessibilityEnabledStateFlow.test { + assertFalse(awaitItem()) + + accessibilityEnabledManager.isAccessibilityEnabled = true + assertTrue(awaitItem()) + + accessibilityEnabledManager.isAccessibilityEnabled = false + assertFalse(awaitItem()) + } + } + @Test fun `isAutofillEnabledStateFlow should emit whenever the AutofillEnabledManager does`() = runTest { 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 216527bf9..81dcafecc 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 @@ -45,6 +45,7 @@ class AutoFillScreenTest : BaseComposeTest() { private val intentManager: IntentManager = mockk { every { startSystemAutofillSettingsActivity() } answers { isSystemSettingsRequestSuccess } every { startCredentialManagerSettings(any()) } just runs + every { startSystemAccessibilitySettingsActivity() } just runs } @Before @@ -59,6 +60,15 @@ class AutoFillScreenTest : BaseComposeTest() { } } + @Test + fun `on NavigateToAccessibilitySettings should attempt to navigate to system settings`() { + mutableEventFlow.tryEmit(AutoFillEvent.NavigateToAccessibilitySettings) + + verify(exactly = 1) { + intentManager.startSystemAccessibilitySettingsActivity() + } + } + @Suppress("MaxLineLength") @Test fun `on NavigateToAutofillSettings should attempt to navigate to system settings and not show the fallback dialog when result is a success`() { @@ -106,6 +116,71 @@ class AutoFillScreenTest : BaseComposeTest() { composeTestRule.assertNoDialogExists() } + @Suppress("MaxLineLength") + @Test + fun `on use accessibility click with accessibility already enabled should emit UseAccessibilityAutofillClick`() { + mutableStateFlow.update { it.copy(isAccessibilityAutofillEnabled = true) } + composeTestRule + .onNodeWithText(text = "Use accessibility") + .performScrollTo() + .performClick() + + composeTestRule.assertNoDialogExists() + verify(exactly = 1) { + viewModel.trySendAction(AutoFillAction.UseAccessibilityAutofillClick) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on use accessibility click with accessibility already disabled should display disclosure dialog and declining closes the dialog`() { + mutableStateFlow.update { it.copy(isAccessibilityAutofillEnabled = false) } + composeTestRule + .onNodeWithText(text = "Use accessibility") + .performScrollTo() + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Accessibility Service Disclosure") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Decline") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + composeTestRule.assertNoDialogExists() + + verify(exactly = 0) { + viewModel.trySendAction(AutoFillAction.UseAccessibilityAutofillClick) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on use accessibility click with accessibility already disabled should display disclosure dialog and accepting closes the dialog and emits UseAccessibilityAutofillClick`() { + mutableStateFlow.update { it.copy(isAccessibilityAutofillEnabled = false) } + composeTestRule + .onNodeWithText(text = "Use accessibility") + .performScrollTo() + .performClick() + + composeTestRule + .onAllNodesWithText(text = "Accessibility Service Disclosure") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText(text = "Accept") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + composeTestRule.assertNoDialogExists() + + verify(exactly = 1) { + viewModel.trySendAction(AutoFillAction.UseAccessibilityAutofillClick) + } + } + @Test fun `on autofill settings fallback dialog Ok click should dismiss the dialog`() { isSystemSettingsRequestSuccess = false @@ -391,6 +466,7 @@ class AutoFillScreenTest : BaseComposeTest() { private val DEFAULT_STATE: AutoFillState = AutoFillState( isAskToAddLoginEnabled = false, + isAccessibilityAutofillEnabled = false, isAutoFillServicesEnabled = false, isCopyTotpAutomaticallyEnabled = false, isUseInlineAutoFillEnabled = false, 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 a3c8f19f5..f67e4f8b2 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 @@ -23,6 +23,7 @@ import org.junit.jupiter.api.Test class AutoFillViewModelTest : BaseViewModelTest() { + private val mutableIsAccessibilityEnabledStateFlow = MutableStateFlow(false) private val mutableIsAutofillEnabledStateFlow = MutableStateFlow(false) private val settingsRepository: SettingsRepository = mockk { every { isInlineAutofillEnabled } returns true @@ -33,6 +34,7 @@ class AutoFillViewModelTest : BaseViewModelTest() { every { isAutofillSavePromptDisabled = any() } just runs every { defaultUriMatchType } returns UriMatchType.DOMAIN every { defaultUriMatchType = any() } just runs + every { isAccessibilityEnabledStateFlow } returns mutableIsAccessibilityEnabledStateFlow every { isAutofillEnabledStateFlow } returns mutableIsAutofillEnabledStateFlow every { disableAutofill() } just runs } @@ -103,6 +105,26 @@ class AutoFillViewModelTest : BaseViewModelTest() { ) } + @Test + fun `changes in accessibility enabled status should update the state`() { + val viewModel = createViewModel() + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + + mutableIsAccessibilityEnabledStateFlow.value = true + + assertEquals( + DEFAULT_STATE.copy(isAccessibilityAutofillEnabled = true), + viewModel.stateFlow.value, + ) + + mutableIsAccessibilityEnabledStateFlow.value = false + + assertEquals( + DEFAULT_STATE.copy(isAccessibilityAutofillEnabled = false), + viewModel.stateFlow.value, + ) + } + @Test fun `changes in autofill enabled status should update the state`() { val viewModel = createViewModel() @@ -148,6 +170,16 @@ class AutoFillViewModelTest : BaseViewModelTest() { ) } + @Test + fun `on UseAccessibilityAutofillClick should emit NavigateToAccessibilitySettings`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AutoFillAction.UseAccessibilityAutofillClick) + assertEquals(AutoFillEvent.NavigateToAccessibilitySettings, awaitItem()) + } + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + @Test fun `on AutoFillServicesClick with true should emit NavigateToAutofillSettings`() = runTest { val viewModel = createViewModel() @@ -244,6 +276,7 @@ class AutoFillViewModelTest : BaseViewModelTest() { private val DEFAULT_STATE: AutoFillState = AutoFillState( isAskToAddLoginEnabled = false, + isAccessibilityAutofillEnabled = false, isAutoFillServicesEnabled = false, isCopyTotpAutomaticallyEnabled = false, isUseInlineAutoFillEnabled = true,