mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
PM-11488: Add a switch to the autofill settings UI for enabling the accessibility service (#3911)
This commit is contained in:
parent
190ba792a1
commit
3ecf1382b2
20 changed files with 543 additions and 3 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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<Boolean>
|
||||
}
|
|
@ -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<Boolean>
|
||||
get() = mutableIsAccessibilityEnabledStateFlow.asStateFlow()
|
||||
}
|
|
@ -133,6 +133,15 @@ interface SettingsRepository {
|
|||
*/
|
||||
var blockedAutofillUris: List<String>
|
||||
|
||||
/**
|
||||
* 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<Boolean>
|
||||
|
||||
/**
|
||||
* Emits updates whenever there is a change in the app's status for supporting autofill.
|
||||
*
|
||||
|
|
|
@ -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<Boolean> =
|
||||
accessibilityEnabledManager.isAccessibilityEnabledStateFlow
|
||||
|
||||
override val isAutofillEnabledStateFlow: StateFlow<Boolean> =
|
||||
autofillEnabledManager.isAutofillEnabledStateFlow
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -542,6 +542,7 @@ Scanning will happen automatically.</string>
|
|||
<string name="accessibility_description2">Use the Bitwarden Accessibility Service to auto-fill your logins across apps and the web. (Requires Draw-Over to be turned on as well)</string>
|
||||
<string name="accessibility_description3">Use the Bitwarden Accessibility Service to use the Autofill Quick-Action Tile, and/or show a popup using Draw-Over (if turned on).</string>
|
||||
<string name="accessibility_description4">Required to use the Autofill Quick-Action Tile, or to augment the Autofill Service by using Draw-Over (if turned on).</string>
|
||||
<string name="accessibility_description5">Required to use the Autofill Quick-Action Tile.</string>
|
||||
<string name="draw_over">Use draw-over</string>
|
||||
<string name="draw_over_description">Allows the Bitwarden Accessibility Service to display a popup when login fields are selected.</string>
|
||||
<string name="draw_over_description2">If turned on, the Bitwarden Accessibility Service will display a popup when login fields are selected to assist with auto-filling your logins.</string>
|
||||
|
|
|
@ -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<LifecycleCoroutineScope> {
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue