diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
index f55d7cf5d..fd566975b 100644
--- a/app/src/debug/AndroidManifest.xml
+++ b/app/src/debug/AndroidManifest.xml
@@ -21,6 +21,26 @@
+
+
+
+
+
+
+
+
+
{
+ super.instantiateServiceCompat(
+ cl,
+ BitwardenAccessibilityService::class.java.name,
+ intent,
+ )
+ }
+
LEGACY_AUTOFILL_SERVICE_NAME -> {
super.instantiateServiceCompat(cl, BitwardenAutofillService::class.java.name, intent)
}
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/BitwardenAccessibilityService.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/BitwardenAccessibilityService.kt
new file mode 100644
index 000000000..f41b85a0b
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/BitwardenAccessibilityService.kt
@@ -0,0 +1,30 @@
+package com.x8bit.bitwarden.data.autofill.accessibility
+
+import android.accessibilityservice.AccessibilityService
+import android.view.accessibility.AccessibilityEvent
+import androidx.annotation.Keep
+import com.x8bit.bitwarden.data.autofill.accessibility.processor.BitwardenAccessibilityProcessor
+import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
+import com.x8bit.bitwarden.data.tiles.BitwardenAutofillTileService
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+/**
+ * The [AccessibilityService] implementation for the app. This is not used in the traditional
+ * way, we use the [BitwardenAutofillTileService] to invoke this service in order to provide an
+ * autofill fallback mechanism.
+ */
+@Keep
+@OmitFromCoverage
+@AndroidEntryPoint
+class BitwardenAccessibilityService : AccessibilityService() {
+ @Inject
+ lateinit var processor: BitwardenAccessibilityProcessor
+
+ override fun onAccessibilityEvent(event: AccessibilityEvent) {
+ if (rootInActiveWindow?.packageName != event.packageName) return
+ processor.processAccessibilityEvent(rootAccessibilityNodeInfo = rootInActiveWindow)
+ }
+
+ override fun onInterrupt() = Unit
+}
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 0fa7744e4..b22c1ee50 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
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.autofill.accessibility.di
import android.content.Context
import android.content.pm.PackageManager
+import android.os.PowerManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManager
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityCompletionManager
@@ -12,6 +13,8 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.LauncherPackageNa
import com.x8bit.bitwarden.data.autofill.accessibility.manager.LauncherPackageNameManagerImpl
import com.x8bit.bitwarden.data.autofill.accessibility.parser.AccessibilityParser
import com.x8bit.bitwarden.data.autofill.accessibility.parser.AccessibilityParserImpl
+import com.x8bit.bitwarden.data.autofill.accessibility.processor.BitwardenAccessibilityProcessor
+import com.x8bit.bitwarden.data.autofill.accessibility.processor.BitwardenAccessibilityProcessorImpl
import com.x8bit.bitwarden.data.autofill.manager.AutofillTotpManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import dagger.Module
@@ -43,7 +46,7 @@ object AccessibilityModule {
@Singleton
@Provides
- fun providesAccessibilityInvokeManager(): AccessibilityAutofillManager =
+ fun providesAccessibilityAutofillManager(): AccessibilityAutofillManager =
AccessibilityAutofillManagerImpl()
@Singleton
@@ -55,6 +58,23 @@ object AccessibilityModule {
fun providesAccessibilitySelectionManager(): AccessibilitySelectionManager =
AccessibilitySelectionManagerImpl()
+ @Singleton
+ @Provides
+ fun providesBitwardenAccessibilityProcessor(
+ @ApplicationContext context: Context,
+ accessibilityParser: AccessibilityParser,
+ accessibilityAutofillManager: AccessibilityAutofillManager,
+ launcherPackageNameManager: LauncherPackageNameManager,
+ powerManager: PowerManager,
+ ): BitwardenAccessibilityProcessor =
+ BitwardenAccessibilityProcessorImpl(
+ context = context,
+ accessibilityParser = accessibilityParser,
+ accessibilityAutofillManager = accessibilityAutofillManager,
+ launcherPackageNameManager = launcherPackageNameManager,
+ powerManager = powerManager,
+ )
+
@Singleton
@Provides
fun providesLauncherPackageNameManager(
@@ -71,4 +91,10 @@ object AccessibilityModule {
fun providesPackageManager(
@ApplicationContext context: Context,
): PackageManager = context.packageManager
+
+ @Singleton
+ @Provides
+ fun providesPowerManager(
+ @ApplicationContext context: Context,
+ ): PowerManager = context.getSystemService(PowerManager::class.java)
}
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/processor/BitwardenAccessibilityProcessor.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/processor/BitwardenAccessibilityProcessor.kt
new file mode 100644
index 000000000..a75227804
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/processor/BitwardenAccessibilityProcessor.kt
@@ -0,0 +1,13 @@
+package com.x8bit.bitwarden.data.autofill.accessibility.processor
+
+import android.view.accessibility.AccessibilityNodeInfo
+
+/**
+ * A class to handle accessibility event processing. This only includes fill requests.
+ */
+interface BitwardenAccessibilityProcessor {
+ /**
+ * Processes the [AccessibilityNodeInfo] for autofill options.
+ */
+ fun processAccessibilityEvent(rootAccessibilityNodeInfo: AccessibilityNodeInfo?)
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/processor/BitwardenAccessibilityProcessorImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/processor/BitwardenAccessibilityProcessorImpl.kt
new file mode 100644
index 000000000..5cf625e7b
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/processor/BitwardenAccessibilityProcessorImpl.kt
@@ -0,0 +1,86 @@
+package com.x8bit.bitwarden.data.autofill.accessibility.processor
+
+import android.content.Context
+import android.os.PowerManager
+import android.view.accessibility.AccessibilityNodeInfo
+import android.widget.Toast
+import com.x8bit.bitwarden.R
+import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManager
+import com.x8bit.bitwarden.data.autofill.accessibility.manager.LauncherPackageNameManager
+import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessibilityAction
+import com.x8bit.bitwarden.data.autofill.accessibility.parser.AccessibilityParser
+import com.x8bit.bitwarden.data.autofill.accessibility.util.fillTextField
+import com.x8bit.bitwarden.data.autofill.accessibility.util.shouldSkipPackage
+import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
+import com.x8bit.bitwarden.data.autofill.util.createAutofillSelectionIntent
+
+/**
+ * The default implementation of the [BitwardenAccessibilityProcessor].
+ */
+class BitwardenAccessibilityProcessorImpl(
+ private val context: Context,
+ private val accessibilityParser: AccessibilityParser,
+ private val accessibilityAutofillManager: AccessibilityAutofillManager,
+ private val launcherPackageNameManager: LauncherPackageNameManager,
+ private val powerManager: PowerManager,
+) : BitwardenAccessibilityProcessor {
+ override fun processAccessibilityEvent(rootAccessibilityNodeInfo: AccessibilityNodeInfo?) {
+ val rootNode = rootAccessibilityNodeInfo ?: return
+ // Ignore the event when the phone is inactive
+ if (!powerManager.isInteractive) return
+ // We skip if the package is not supported
+ if (rootNode.shouldSkipPackage) return
+ // We skip any package that is a launcher
+ if (launcherPackageNameManager.launcherPackages.any { it == rootNode.packageName }) return
+
+ // Only process the event if the tile was clicked
+ val accessibilityAction = accessibilityAutofillManager.accessibilityAction ?: return
+ accessibilityAutofillManager.accessibilityAction = null
+
+ when (accessibilityAction) {
+ is AccessibilityAction.AttemptFill -> {
+ handleAttemptFill(rootNode = rootNode, attemptFill = accessibilityAction)
+ }
+
+ AccessibilityAction.AttemptParseUri -> handleAttemptParseUri(rootNode = rootNode)
+ }
+ }
+
+ private fun handleAttemptParseUri(rootNode: AccessibilityNodeInfo) {
+ accessibilityParser
+ .parseForUriOrPackageName(rootNode = rootNode)
+ ?.let { uri ->
+ context.startActivity(
+ createAutofillSelectionIntent(
+ context = context,
+ framework = AutofillSelectionData.Framework.ACCESSIBILITY,
+ type = AutofillSelectionData.Type.LOGIN,
+ uri = uri.toString(),
+ ),
+ )
+ }
+ ?: run {
+ Toast
+ .makeText(
+ context,
+ R.string.autofill_tile_uri_not_found,
+ Toast.LENGTH_LONG,
+ )
+ .show()
+ }
+ }
+
+ private fun handleAttemptFill(
+ rootNode: AccessibilityNodeInfo,
+ attemptFill: AccessibilityAction.AttemptFill,
+ ) {
+ val loginView = attemptFill.cipherView.login ?: return
+ val fields = accessibilityParser.parseForFillableFields(rootNode = rootNode)
+ fields.usernameFields.forEach { usernameField ->
+ usernameField.fillTextField(value = loginView.username)
+ }
+ fields.passwordFields.forEach { passwordField ->
+ passwordField.fillTextField(value = loginView.password)
+ }
+ }
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/util/AccessibilityNodeInfoExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/util/AccessibilityNodeInfoExtensions.kt
new file mode 100644
index 000000000..be3a8d33a
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/util/AccessibilityNodeInfoExtensions.kt
@@ -0,0 +1,52 @@
+package com.x8bit.bitwarden.data.autofill.accessibility.util
+
+import android.view.accessibility.AccessibilityNodeInfo
+import androidx.core.os.bundleOf
+import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
+
+private const val PACKAGE_NAME_BITWARDEN_PREFIX: String = "com.x8bit.bitwarden"
+private const val PACKAGE_NAME_SYSTEM_UI: String = "com.android.systemui"
+private const val PACKAGE_NAME_LAUNCHER_PARTIAL: String = "launcher"
+private val PACKAGE_NAME_BLOCK_LIST: List = listOf(
+ "com.google.android.googlequicksearchbox",
+ "com.google.android.apps.nexuslauncher",
+ "com.google.android.launcher",
+ "com.computer.desktop.ui.launcher",
+ "com.launcher.notelauncher",
+ "com.anddoes.launcher",
+ "com.actionlauncher.playstore",
+ "ch.deletescape.lawnchair.plah",
+ "com.microsoft.launcher",
+ "com.teslacoilsw.launcher",
+ "com.teslacoilsw.launcher.prime",
+ "is.shortcut",
+ "me.craftsapp.nlauncher",
+ "com.ss.squarehome2",
+ "com.treydev.pns",
+)
+
+/**
+ * Returns true if the event is for an unsupported package.
+ */
+val AccessibilityNodeInfo.shouldSkipPackage: Boolean
+ get() {
+ val packageName = this.packageName.takeUnless { it.isNullOrBlank() } ?: return true
+ if (packageName == PACKAGE_NAME_SYSTEM_UI) return true
+ if (packageName.startsWith(prefix = PACKAGE_NAME_BITWARDEN_PREFIX)) return true
+ if (packageName.contains(other = PACKAGE_NAME_LAUNCHER_PARTIAL, ignoreCase = true)) {
+ return true
+ }
+ if (PACKAGE_NAME_BLOCK_LIST.contains(packageName)) return true
+ return false
+ }
+
+/**
+ * Fills the [AccessibilityNodeInfo] text field with the [value] provided.
+ */
+@OmitFromCoverage
+fun AccessibilityNodeInfo.fillTextField(value: String?) {
+ performAction(
+ AccessibilityNodeInfo.ACTION_SET_TEXT,
+ bundleOf(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE to value),
+ )
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index da70b383e..4cb470c79 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -329,6 +329,7 @@ Scanning will happen automatically.
There are no items in this folder.
There are no items in the trash.
Auto-fill Accessibility Service
+ Assist with filling username and password fields in other apps and on the web.
The Bitwarden auto-fill service uses the Android Autofill Framework to assist in filling login information into other apps on your device.
Use the Bitwarden auto-fill service to fill login information into other apps.
Open Autofill Settings
diff --git a/app/src/main/res/xml/accessibility_service.xml b/app/src/main/res/xml/accessibility_service.xml
new file mode 100644
index 000000000..8ab097fbc
--- /dev/null
+++ b/app/src/main/res/xml/accessibility_service.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/processor/BitwardenAccessibilityProcessorTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/processor/BitwardenAccessibilityProcessorTest.kt
new file mode 100644
index 000000000..4841bd9b4
--- /dev/null
+++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/processor/BitwardenAccessibilityProcessorTest.kt
@@ -0,0 +1,315 @@
+package com.x8bit.bitwarden.data.autofill.accessibility.processor
+
+import android.content.Context
+import android.net.Uri
+import android.os.PowerManager
+import android.view.accessibility.AccessibilityNodeInfo
+import android.widget.Toast
+import com.bitwarden.vault.CipherView
+import com.bitwarden.vault.LoginView
+import com.x8bit.bitwarden.R
+import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityAutofillManager
+import com.x8bit.bitwarden.data.autofill.accessibility.manager.LauncherPackageNameManager
+import com.x8bit.bitwarden.data.autofill.accessibility.model.AccessibilityAction
+import com.x8bit.bitwarden.data.autofill.accessibility.model.FillableFields
+import com.x8bit.bitwarden.data.autofill.accessibility.parser.AccessibilityParser
+import com.x8bit.bitwarden.data.autofill.accessibility.util.fillTextField
+import com.x8bit.bitwarden.data.autofill.accessibility.util.shouldSkipPackage
+import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
+import com.x8bit.bitwarden.data.autofill.util.createAutofillSelectionIntent
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.runs
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+class BitwardenAccessibilityProcessorTest {
+
+ private val context: Context = mockk {
+ every { startActivity(any()) } just runs
+ }
+ private val accessibilityParser: AccessibilityParser = mockk()
+ private val accessibilityAutofillManager: AccessibilityAutofillManager = mockk()
+ private val launcherPackageNameManager: LauncherPackageNameManager = mockk()
+ private val powerManager: PowerManager = mockk()
+
+ private val bitwardenAccessibilityProcessor: BitwardenAccessibilityProcessor =
+ BitwardenAccessibilityProcessorImpl(
+ context = context,
+ accessibilityParser = accessibilityParser,
+ accessibilityAutofillManager = accessibilityAutofillManager,
+ launcherPackageNameManager = launcherPackageNameManager,
+ powerManager = powerManager,
+ )
+
+ @BeforeEach
+ fun setup() {
+ mockkStatic(AccessibilityNodeInfo::shouldSkipPackage)
+ mockkStatic(::createAutofillSelectionIntent)
+ mockkStatic(Toast::class)
+ every {
+ Toast
+ .makeText(context, R.string.autofill_tile_uri_not_found, Toast.LENGTH_LONG)
+ .show()
+ } just runs
+ }
+
+ @AfterEach
+ fun tearDown() {
+ unmockkStatic(AccessibilityNodeInfo::shouldSkipPackage)
+ unmockkStatic(::createAutofillSelectionIntent)
+ unmockkStatic(Toast::class)
+ }
+
+ @Test
+ fun `processAccessibilityEvent with null rootNode should return`() {
+ bitwardenAccessibilityProcessor.processAccessibilityEvent(
+ rootAccessibilityNodeInfo = null,
+ )
+
+ verify(exactly = 0) {
+ powerManager.isInteractive
+ }
+ }
+
+ @Test
+ fun `processAccessibilityEvent with powerManager not interactive should return`() {
+ val rootNode = mockk()
+ every { powerManager.isInteractive } returns false
+
+ bitwardenAccessibilityProcessor.processAccessibilityEvent(
+ rootAccessibilityNodeInfo = rootNode,
+ )
+
+ verify(exactly = 1) {
+ powerManager.isInteractive
+ }
+ }
+
+ @Test
+ fun `processAccessibilityEvent with skippable package should return`() {
+ val rootNode = mockk {
+ every { shouldSkipPackage } returns true
+ }
+ every { powerManager.isInteractive } returns true
+
+ bitwardenAccessibilityProcessor.processAccessibilityEvent(
+ rootAccessibilityNodeInfo = rootNode,
+ )
+
+ verify(exactly = 1) {
+ powerManager.isInteractive
+ rootNode.shouldSkipPackage
+ }
+ }
+
+ @Test
+ fun `processAccessibilityEvent with launcher package should return`() {
+ val testPackageName = "com.google.android.launcher"
+ val rootNode = mockk {
+ every { shouldSkipPackage } returns false
+ every { packageName } returns testPackageName
+ }
+ every { launcherPackageNameManager.launcherPackages } returns listOf(testPackageName)
+ every { powerManager.isInteractive } returns true
+
+ bitwardenAccessibilityProcessor.processAccessibilityEvent(
+ rootAccessibilityNodeInfo = rootNode,
+ )
+
+ verify(exactly = 1) {
+ powerManager.isInteractive
+ rootNode.shouldSkipPackage
+ launcherPackageNameManager.launcherPackages
+ }
+ }
+
+ @Test
+ fun `processAccessibilityEvent without accessibility action should return`() {
+ val testPackageName = "com.android.chrome"
+ val rootNode = mockk {
+ every { shouldSkipPackage } returns false
+ every { packageName } returns testPackageName
+ }
+ every { launcherPackageNameManager.launcherPackages } returns emptyList()
+ every { accessibilityAutofillManager.accessibilityAction } returns null
+ every { powerManager.isInteractive } returns true
+
+ bitwardenAccessibilityProcessor.processAccessibilityEvent(
+ rootAccessibilityNodeInfo = rootNode,
+ )
+
+ verify(exactly = 1) {
+ powerManager.isInteractive
+ rootNode.shouldSkipPackage
+ launcherPackageNameManager.launcherPackages
+ accessibilityAutofillManager.accessibilityAction
+ }
+ }
+
+ @Test
+ fun `processAccessibilityEvent with AttemptParseUri and a invalid uri should show a toast`() {
+ val testPackageName = "com.android.chrome"
+ val rootNode = mockk {
+ every { shouldSkipPackage } returns false
+ every { packageName } returns testPackageName
+ }
+ every { powerManager.isInteractive } returns true
+ every { launcherPackageNameManager.launcherPackages } returns emptyList()
+ every {
+ accessibilityAutofillManager.accessibilityAction
+ } returns AccessibilityAction.AttemptParseUri
+ every { accessibilityAutofillManager.accessibilityAction = null } just runs
+ every { accessibilityParser.parseForUriOrPackageName(rootNode = rootNode) } returns null
+
+ bitwardenAccessibilityProcessor.processAccessibilityEvent(
+ rootAccessibilityNodeInfo = rootNode,
+ )
+
+ verify(exactly = 1) {
+ powerManager.isInteractive
+ rootNode.shouldSkipPackage
+ launcherPackageNameManager.launcherPackages
+ accessibilityAutofillManager.accessibilityAction
+ accessibilityAutofillManager.accessibilityAction = null
+ accessibilityParser.parseForUriOrPackageName(rootNode = rootNode)
+ Toast
+ .makeText(context, R.string.autofill_tile_uri_not_found, Toast.LENGTH_LONG)
+ .show()
+ }
+ }
+
+ @Suppress("MaxLineLength")
+ @Test
+ fun `processAccessibilityEvent with AttemptParseUri and a valid uri should start the main activity`() {
+ val testPackageName = "com.android.chrome"
+ val rootNode = mockk {
+ every { shouldSkipPackage } returns false
+ every { packageName } returns testPackageName
+ }
+ every { powerManager.isInteractive } returns true
+ every { launcherPackageNameManager.launcherPackages } returns emptyList()
+ every {
+ accessibilityAutofillManager.accessibilityAction
+ } returns AccessibilityAction.AttemptParseUri
+ every { accessibilityAutofillManager.accessibilityAction = null } just runs
+ every {
+ createAutofillSelectionIntent(
+ context = context,
+ framework = AutofillSelectionData.Framework.ACCESSIBILITY,
+ type = AutofillSelectionData.Type.LOGIN,
+ uri = any(),
+ )
+ } returns mockk()
+ every { accessibilityParser.parseForUriOrPackageName(rootNode = rootNode) } returns mockk()
+
+ bitwardenAccessibilityProcessor.processAccessibilityEvent(
+ rootAccessibilityNodeInfo = rootNode,
+ )
+
+ verify(exactly = 1) {
+ powerManager.isInteractive
+ rootNode.shouldSkipPackage
+ launcherPackageNameManager.launcherPackages
+ accessibilityAutofillManager.accessibilityAction
+ accessibilityAutofillManager.accessibilityAction = null
+ accessibilityParser.parseForUriOrPackageName(rootNode = rootNode)
+ createAutofillSelectionIntent(
+ context = context,
+ framework = AutofillSelectionData.Framework.ACCESSIBILITY,
+ type = AutofillSelectionData.Type.LOGIN,
+ uri = any(),
+ )
+ context.startActivity(any())
+ }
+ }
+
+ @Test
+ fun `processAccessibilityEvent with AttemptFill and no login data should return`() {
+ val testPackageName = "com.android.chrome"
+ val cipherView = mockk {
+ every { login } returns null
+ }
+ val uri = mockk()
+ val attemptFill = AccessibilityAction.AttemptFill(cipherView = cipherView, uri = uri)
+ val rootNode = mockk {
+ every { shouldSkipPackage } returns false
+ every { packageName } returns testPackageName
+ }
+ every { powerManager.isInteractive } returns true
+ every { launcherPackageNameManager.launcherPackages } returns emptyList()
+ every { accessibilityAutofillManager.accessibilityAction } returns attemptFill
+ every { accessibilityAutofillManager.accessibilityAction = null } just runs
+
+ bitwardenAccessibilityProcessor.processAccessibilityEvent(
+ rootAccessibilityNodeInfo = rootNode,
+ )
+
+ verify(exactly = 1) {
+ powerManager.isInteractive
+ rootNode.shouldSkipPackage
+ launcherPackageNameManager.launcherPackages
+ accessibilityAutofillManager.accessibilityAction
+ accessibilityAutofillManager.accessibilityAction = null
+ cipherView.login
+ }
+ }
+
+ @Test
+ fun `processAccessibilityEvent with AttemptFill and valid login data should fill the data`() {
+ val testPackageName = "com.android.chrome"
+ val testUsername = "testUsername"
+ val testPassword = "testPassword1234"
+ val loginView = mockk {
+ every { username } returns testUsername
+ every { password } returns testPassword
+ }
+ val cipherView = mockk {
+ every { login } returns loginView
+ }
+ val mockUsernameField = mockk {
+ every { fillTextField(testUsername) } just runs
+ }
+ val mockPasswordField = mockk {
+ every { fillTextField(testPassword) } just runs
+ }
+ val fillableFields = FillableFields(
+ usernameFields = listOf(mockUsernameField),
+ passwordFields = listOf(mockPasswordField),
+ )
+ val uri = mockk()
+ val attemptFill = AccessibilityAction.AttemptFill(cipherView = cipherView, uri = uri)
+ val rootNode = mockk {
+ every { shouldSkipPackage } returns false
+ every { packageName } returns testPackageName
+ }
+ every { powerManager.isInteractive } returns true
+ every { launcherPackageNameManager.launcherPackages } returns emptyList()
+ every { accessibilityAutofillManager.accessibilityAction } returns attemptFill
+ every { accessibilityAutofillManager.accessibilityAction = null } just runs
+ every {
+ accessibilityParser.parseForFillableFields(rootNode = rootNode)
+ } returns fillableFields
+
+ bitwardenAccessibilityProcessor.processAccessibilityEvent(
+ rootAccessibilityNodeInfo = rootNode,
+ )
+
+ verify(exactly = 1) {
+ powerManager.isInteractive
+ rootNode.shouldSkipPackage
+ launcherPackageNameManager.launcherPackages
+ accessibilityAutofillManager.accessibilityAction
+ accessibilityAutofillManager.accessibilityAction = null
+ cipherView.login
+ accessibilityParser.parseForFillableFields(rootNode = rootNode)
+ mockUsernameField.fillTextField(testUsername)
+ mockPasswordField.fillTextField(testPassword)
+ }
+ }
+}
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/util/AccessibilityNodeInfoExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/util/AccessibilityNodeInfoExtensionsTest.kt
new file mode 100644
index 000000000..eac42fd6a
--- /dev/null
+++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/util/AccessibilityNodeInfoExtensionsTest.kt
@@ -0,0 +1,75 @@
+package com.x8bit.bitwarden.data.autofill.accessibility.util
+
+import android.view.accessibility.AccessibilityNodeInfo
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class AccessibilityNodeInfoExtensionsTest {
+
+ @Test
+ fun `shouldSkipPackage when packageName is null should return true`() {
+ val accessibilityNodeInfo = mockk {
+ every { packageName } returns null
+ }
+
+ assertTrue(accessibilityNodeInfo.shouldSkipPackage)
+ }
+
+ @Test
+ fun `shouldSkipPackage when packageName is blank should return true`() {
+ val accessibilityNodeInfo = mockk {
+ every { packageName } returns ""
+ }
+
+ assertTrue(accessibilityNodeInfo.shouldSkipPackage)
+ }
+
+ @Test
+ fun `shouldSkipPackage when packageName is system UI package should return true`() {
+ val accessibilityNodeInfo = mockk {
+ every { packageName } returns "com.android.systemui"
+ }
+
+ assertTrue(accessibilityNodeInfo.shouldSkipPackage)
+ }
+
+ @Suppress("MaxLineLength")
+ @Test
+ fun `shouldSkipPackage when packageName is prefixed with bitwarden package should return true`() {
+ val accessibilityNodeInfo = mockk {
+ every { packageName } returns "com.x8bit.bitwarden.beta"
+ }
+
+ assertTrue(accessibilityNodeInfo.shouldSkipPackage)
+ }
+
+ @Test
+ fun `shouldSkipPackage when packageName contains with 'launcher' should return true`() {
+ val accessibilityNodeInfo = mockk {
+ every { packageName } returns "com.android.launcher.foo"
+ }
+
+ assertTrue(accessibilityNodeInfo.shouldSkipPackage)
+ }
+
+ @Test
+ fun `shouldSkipPackage when packageName is blocked package should return true`() {
+ val accessibilityNodeInfo = mockk {
+ every { packageName } returns "com.google.android.googlequicksearchbox"
+ }
+
+ assertTrue(accessibilityNodeInfo.shouldSkipPackage)
+ }
+
+ @Test
+ fun `shouldSkipPackage when packageName is valid should return false`() {
+ val accessibilityNodeInfo = mockk {
+ every { packageName } returns "com.another.app"
+ }
+
+ assertFalse(accessibilityNodeInfo.shouldSkipPackage)
+ }
+}