From d85f9a996360ec8b91d8f71ac5fde52b2d38dabe Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 12 Mar 2026 16:26:10 -0300 Subject: [PATCH] Add tests for InstalledAppsCache --- .../org/fdroid/install/InstalledAppsCache.kt | 31 ++-- .../fdroid/install/InstalledAppsCacheTest.kt | 134 ++++++++++++++++++ 2 files changed, 143 insertions(+), 22 deletions(-) create mode 100644 app/src/test/java/org/fdroid/install/InstalledAppsCacheTest.kt diff --git a/app/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt b/app/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt index 66fc2b023..61fb9474d 100644 --- a/app/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt +++ b/app/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt @@ -8,12 +8,12 @@ import android.content.IntentFilter import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.PackageManager.GET_SIGNATURES -import androidx.annotation.UiThread +import androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED +import androidx.core.content.ContextCompat.registerReceiver import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -33,7 +33,6 @@ constructor( private val packageManager = context.packageManager private val _installedApps = MutableStateFlow>(emptyMap()) val installedApps = _installedApps.asStateFlow() - private var loadJob: Job? = null init { val intentFilter = @@ -42,8 +41,13 @@ constructor( addAction(Intent.ACTION_PACKAGE_REMOVED) addDataScheme("package") } - context.registerReceiver(this, intentFilter) - loadInstalledApps() + registerReceiver(context, this, intentFilter, RECEIVER_NOT_EXPORTED) + ioScope.launch { + log.info { "Loading installed apps..." } + @Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken + val installedPackages = packageManager.getInstalledPackages(GET_SIGNATURES) + _installedApps.update { installedPackages.associateBy { it.packageName } } + } } fun isInstalled(packageName: String): Boolean { @@ -51,23 +55,6 @@ constructor( return _installedApps.value.contains(packageName) } - @UiThread - private fun loadInstalledApps() { - if (loadJob?.isActive == true) { - // TODO this may give us a stale cache if an app was changed - // while the system had already assembled the data, but we didn't return yet - log.warn { "Already loading apps, not loading again." } - return - } - loadJob = - ioScope.launch { - log.info { "Loading installed apps..." } - @Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken - val installedPackages = packageManager.getInstalledPackages(GET_SIGNATURES) - _installedApps.update { installedPackages.associateBy { it.packageName } } - } - } - override fun onReceive(context: Context, intent: Intent) { if (intent.`package` != null) { // we have seen duplicate intents on Android 15, need to check other versions diff --git a/app/src/test/java/org/fdroid/install/InstalledAppsCacheTest.kt b/app/src/test/java/org/fdroid/install/InstalledAppsCacheTest.kt new file mode 100644 index 000000000..b5351bb58 --- /dev/null +++ b/app/src/test/java/org/fdroid/install/InstalledAppsCacheTest.kt @@ -0,0 +1,134 @@ +package org.fdroid.install + +import android.content.Context +import android.content.Intent +import android.content.Intent.ACTION_PACKAGE_ADDED +import android.content.Intent.ACTION_PACKAGE_REMOVED +import android.content.IntentFilter +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.GET_SIGNATURES +import android.net.Uri +import androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED +import androidx.core.content.ContextCompat.registerReceiver +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.verify +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test + +@Suppress("DEPRECATION") +internal class InstalledAppsCacheTest { + + private val context: Context = mockk(relaxed = true) + private val packageManager: PackageManager = mockk() + private val ioScope = CoroutineScope(Dispatchers.Unconfined) + + @Before + fun setUp() { + mockkStatic("androidx.core.content.ContextCompat") + mockkConstructor(IntentFilter::class) + every { anyConstructed().addAction(any()) } returns mockk() + every { anyConstructed().addDataScheme(any()) } returns mockk() + + every { context.packageManager } returns packageManager + every { registerReceiver(any(), any(), any(), RECEIVER_NOT_EXPORTED) } returns + null + } + + @Test + fun `installed apps load initially and isInstalled reflects cache`() = runBlocking { + val app1 = packageInfo("org.example.a") + val app2 = packageInfo("org.example.b") + every { packageManager.getInstalledPackages(GET_SIGNATURES) } returns listOf(app1, app2) + + val cache = InstalledAppsCache(context, ioScope) + + assertEquals(2, cache.installedApps.value.size) + assertTrue(cache.isInstalled("org.example.a")) + assertTrue(cache.isInstalled("org.example.b")) + assertFalse(cache.isInstalled("org.example.missing")) + verify(exactly = 1) { registerReceiver(context, cache, any(), RECEIVER_NOT_EXPORTED) } + verify(exactly = 1) { packageManager.getInstalledPackages(GET_SIGNATURES) } + } + + @Test + fun `onReceive add and remove intents update installedApps and isInstalled`() = runBlocking { + every { packageManager.getInstalledPackages(GET_SIGNATURES) } returns emptyList() + + val cache = InstalledAppsCache(context, ioScope) + + val added = packageInfo("org.example.new") + every { packageManager.getPackageInfo("org.example.new", GET_SIGNATURES) } returns added + + // app gets added + cache.onReceive(context, packageChangedIntent(ACTION_PACKAGE_ADDED, "org.example.new")) + assertTrue(cache.isInstalled("org.example.new")) + assertEquals(1, cache.installedApps.value.size) + + // app gets removed + cache.onReceive(context, packageChangedIntent(ACTION_PACKAGE_REMOVED, "org.example.new")) + assertFalse(cache.isInstalled("org.example.new")) + assertEquals(0, cache.installedApps.value.size) + } + + @Test + fun `intents with replacing true are ignored`() = runBlocking { + val app = packageInfo("org.example.replace") + every { packageManager.getInstalledPackages(GET_SIGNATURES) } returns listOf(app) + + val cache = InstalledAppsCache(context, ioScope) + + cache.onReceive( + context, + packageChangedIntent( + action = ACTION_PACKAGE_REMOVED, + packageName = "org.example.replace", + replacing = true, + ), + ) + + assertTrue(cache.isInstalled("org.example.replace")) + assertEquals(1, cache.installedApps.value.size) + } + + @Test + fun `onReceive add intent ignores late broadcast when app is already gone`() = runBlocking { + val existing = packageInfo("org.example.existing") + every { packageManager.getInstalledPackages(GET_SIGNATURES) } returns listOf(existing) + every { packageManager.getPackageInfo("org.example.gone", GET_SIGNATURES) } throws + PackageManager.NameNotFoundException("gone") + + val cache = InstalledAppsCache(context, ioScope) + + cache.onReceive(context, packageChangedIntent(ACTION_PACKAGE_ADDED, "org.example.gone")) + + assertEquals(1, cache.installedApps.value.size) + assertTrue(cache.isInstalled("org.example.existing")) + assertFalse(cache.isInstalled("org.example.gone")) + } + + private fun packageInfo(packageName: String) = + PackageInfo().apply { this.packageName = packageName } + + private fun packageChangedIntent( + action: String, + packageName: String, + replacing: Boolean = false, + ): Intent { + val intent: Intent = mockk() + every { intent.`package` } returns null + every { intent.action } returns action + every { intent.data } returns mockk { every { schemeSpecificPart } returns packageName } + every { intent.getBooleanExtra(Intent.EXTRA_REPLACING, false) } returns replacing + return intent + } +}