PM-11488: Add a switch to the autofill settings UI for enabling the accessibility service (#3911)

This commit is contained in:
David Perez 2024-09-16 09:14:00 -05:00 committed by GitHub
parent 190ba792a1
commit 3ecf1382b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 543 additions and 3 deletions

View file

@ -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

View file

@ -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()

View file

@ -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,
)
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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>
}

View file

@ -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()
}

View file

@ -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.
*

View file

@ -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

View file

@ -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,
)

View file

@ -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,

View file

@ -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.

View file

@ -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.

View file

@ -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)

View file

@ -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>

View file

@ -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
}
}

View file

@ -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())
}
}
}

View file

@ -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 {

View file

@ -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,

View file

@ -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,