Add tests for InstalledAppsCache

This commit is contained in:
Torsten Grote
2026-03-12 16:26:10 -03:00
parent 68ae977d9c
commit d85f9a9963
2 changed files with 143 additions and 22 deletions

View File

@@ -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<Map<String, PackageInfo>>(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

View File

@@ -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<IntentFilter>().addAction(any()) } returns mockk()
every { anyConstructed<IntentFilter>().addDataScheme(any()) } returns mockk()
every { context.packageManager } returns packageManager
every { registerReceiver(any(), any(), any<IntentFilter>(), 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<Uri> { every { schemeSpecificPart } returns packageName }
every { intent.getBooleanExtra(Intent.EXTRA_REPLACING, false) } returns replacing
return intent
}
}