diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cf18a3fe95..a87391c028 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -245,6 +245,10 @@
+
+
+
+
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 7dacb5fe0d..df7f7a1845 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
@@ -1,11 +1,15 @@
package com.x8bit.bitwarden.data.autofill.accessibility.di
+import android.content.pm.PackageManager
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.LauncherPackageNameManager
+import com.x8bit.bitwarden.data.autofill.accessibility.manager.LauncherPackageNameManagerImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
+import java.time.Clock
import javax.inject.Singleton
/**
@@ -18,4 +22,15 @@ object AccessibilityModule {
@Provides
fun providesAccessibilityInvokeManager(): AccessibilityAutofillManager =
AccessibilityAutofillManagerImpl()
+
+ @Singleton
+ @Provides
+ fun providesLauncherPackageNameManager(
+ clock: Clock,
+ packageManager: PackageManager,
+ ): LauncherPackageNameManager =
+ LauncherPackageNameManagerImpl(
+ clockProvider = { clock },
+ packageManager = packageManager,
+ )
}
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/LauncherPackageNameManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/LauncherPackageNameManager.kt
new file mode 100644
index 0000000000..9fdf659aff
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/LauncherPackageNameManager.kt
@@ -0,0 +1,11 @@
+package com.x8bit.bitwarden.data.autofill.accessibility.manager
+
+/**
+ * A manager for getting the launcher packages from the operating system.
+ */
+interface LauncherPackageNameManager {
+ /**
+ * A list of launcher packages from the operating system.
+ */
+ val launcherPackages: List
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/LauncherPackageNameManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/LauncherPackageNameManagerImpl.kt
new file mode 100644
index 0000000000..bc8f43d147
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/LauncherPackageNameManagerImpl.kt
@@ -0,0 +1,41 @@
+package com.x8bit.bitwarden.data.autofill.accessibility.manager
+
+import android.content.Intent
+import android.content.pm.PackageManager
+import java.time.Clock
+
+/**
+ * How frequently the cached launcher list should be refreshed.
+ */
+private const val REFRESH_CACHE_MS: Long = 1L * 60L * 60L * 1000L
+
+/**
+ * The default implementation of the [LauncherPackageNameManager].
+ */
+class LauncherPackageNameManagerImpl(
+ private val clockProvider: () -> Clock,
+ private val packageManager: PackageManager,
+) : LauncherPackageNameManager {
+ private var lastLauncherFetchMs: Long = 0L
+ private var cachedLauncherPackages: List? = null
+
+ override val launcherPackages: List
+ get() {
+ if (cachedLauncherPackages == null ||
+ clockProvider().millis() - lastLauncherFetchMs > REFRESH_CACHE_MS
+ ) {
+ updateCachedLauncherPackages()
+ }
+ return cachedLauncherPackages.orEmpty()
+ }
+
+ private fun updateCachedLauncherPackages() {
+ cachedLauncherPackages = packageManager
+ .queryIntentActivities(
+ Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME),
+ 0,
+ )
+ .map { it.activityInfo.packageName }
+ lastLauncherFetchMs = clockProvider().millis()
+ }
+}
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/LauncherPackageNameManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/LauncherPackageNameManagerTest.kt
new file mode 100644
index 0000000000..c964909c9a
--- /dev/null
+++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/accessibility/manager/LauncherPackageNameManagerTest.kt
@@ -0,0 +1,70 @@
+package com.x8bit.bitwarden.data.autofill.accessibility.manager
+
+import android.content.pm.ActivityInfo
+import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
+import io.mockk.clearMocks
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import java.time.Clock
+import java.time.Duration
+import java.time.Instant
+import java.time.ZoneOffset
+
+class LauncherPackageNameManagerTest {
+ private val packageManager: PackageManager = mockk()
+ private var mutableClock: Clock = FIXED_CLOCK
+
+ private val launcherPackageNameManager: LauncherPackageNameManager =
+ LauncherPackageNameManagerImpl(
+ clockProvider = { mutableClock },
+ packageManager = packageManager,
+ )
+
+ @Suppress("MaxLineLength")
+ @Test
+ fun `launcherPackages should populate cache on first attempt and use cached value for second attempt and repopulate the cache after an hour`() {
+ val testPackageName = "testPackageName"
+ val testActivityInfo = ActivityInfo().apply {
+ packageName = testPackageName
+ }
+ val resolveInfo = ResolveInfo().apply {
+ activityInfo = testActivityInfo
+ }
+ val packages = listOf(resolveInfo)
+ every { packageManager.queryIntentActivities(any(), any()) } returns packages
+
+ val firstResult = launcherPackageNameManager.launcherPackages
+
+ assertEquals(listOf(testPackageName), firstResult)
+ verify(exactly = 1) {
+ packageManager.queryIntentActivities(any(), any())
+ }
+ clearMocks(packageManager)
+
+ val secondResult = launcherPackageNameManager.launcherPackages
+
+ assertEquals(listOf(testPackageName), secondResult)
+ verify(exactly = 0) {
+ packageManager.queryIntentActivities(any(), any())
+ }
+ clearMocks(packageManager)
+
+ every { packageManager.queryIntentActivities(any(), any()) } returns emptyList()
+ mutableClock = Clock.offset(FIXED_CLOCK, Duration.ofMinutes(61))
+ val thirdResult = launcherPackageNameManager.launcherPackages
+
+ assertEquals(emptyList(), thirdResult)
+ verify(exactly = 1) {
+ packageManager.queryIntentActivities(any(), any())
+ }
+ }
+}
+
+private val FIXED_CLOCK: Clock = Clock.fixed(
+ Instant.parse("2023-10-27T12:00:00Z"),
+ ZoneOffset.UTC,
+)