fix: icon loading on demand and cache invalidation on language change

This commit is contained in:
Adam M. Szalkowski 2026-06-16 17:50:11 +02:00
parent bca8b51f71
commit 9830bba2db
21 changed files with 124 additions and 47 deletions

View file

@ -218,4 +218,6 @@ dependencies {
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
androidTestImplementation("org.mockito:mockito-android:5.11.0")
androidTestImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1")
androidTestImplementation("com.google.dagger:hilt-android-testing:2.59.2")
kspAndroidTest("com.google.dagger:hilt-compiler:2.59.2")
}

View file

@ -0,0 +1,53 @@
package de.szalkowski.activitylauncher.data.packages
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import de.szalkowski.activitylauncher.data.database.PackageDao
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import javax.inject.Inject
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class CacheLanguageTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var dataSource: PackageDataSource
@Inject
lateinit var packageDao: PackageDao
@Before
fun init() {
hiltRule.inject()
}
@Test
fun testCachePurgeOnInvalidate() = runBlocking {
// 1. Ensure cache has data
dataSource.sync()
val initialPackages = packageDao.getAllPackagesFlow().first()
if (initialPackages.isEmpty()) return@runBlocking
// 2. Clear cache via data source (simulating invalidate behavior)
dataSource.clear()
val afterClearPackages = packageDao.getAllPackagesFlow().first()
assertTrue("Cache should be empty after clear", afterClearPackages.isEmpty())
// 3. Sync again
dataSource.sync()
val afterSyncPackages = packageDao.getAllPackagesFlow().first()
assertTrue("Cache should be repopulated after sync", afterSyncPackages.isNotEmpty())
}
}

View file

@ -35,6 +35,9 @@ interface PackageDao {
@Transaction
@Query("DELETE FROM activities WHERE packageName = :packageName")
suspend fun deleteActivitiesForPackage(packageName: String): Int
@Query("DELETE FROM packages")
suspend fun deleteAllPackages(): Int
}
data class PackageWithActivities(

View file

@ -16,7 +16,6 @@ import de.szalkowski.activitylauncher.domain.packages.PackageRepository
import de.szalkowski.activitylauncher.domain.settings.SettingsRepository
import de.szalkowski.activitylauncher.presentation.common.AsyncProvider
import de.szalkowski.activitylauncher.presentation.common.IconListAdapter
import java.util.TreeMap
import javax.inject.Inject
class IconLoaderImpl @Inject constructor(
@ -60,7 +59,7 @@ class IconLoaderImpl @Inject constructor(
}
override fun loadIcons(updater: AsyncProvider<IconListAdapter>.Updater?): List<IconInfo> {
val icons: TreeMap<String, Drawable> = TreeMap()
val icons: java.util.TreeSet<String> = java.util.TreeSet()
val packages = packageRepository.packages
updater?.updateMax(packages.size)
@ -72,11 +71,11 @@ class IconLoaderImpl @Inject constructor(
runCatching {
val activities = activityRepository.getActivities(pack.value.packageName)
for (activity in listOfNotNull(activities.defaultActivity) + activities.activities) {
activity.iconResourceName?.let { icons[it] = activity.icon }
activity.iconResourceName?.let { icons.add(it) }
}
}
}
return icons.map { entry -> IconInfo(entry.key, entry.value) }.toList()
return icons.map { IconInfo(it) }.toList()
}
}

View file

@ -46,15 +46,16 @@ class ShortcutCreatorImpl @Inject constructor(
try {
val pack = extractIconPackageName(activity)
val intent = getActivityIntent(activity.componentName, optionalExtras)
val icon = getIcon(activity.componentName)
// Use bitmap version, if icon from different package is used
if (pack != null && pack != activity.componentName.packageName) {
createShortcut(activity.name, intent, activity.icon, asRoot, null)
createShortcut(activity.name, intent, icon, asRoot, null)
} else {
createShortcut(
activity.name,
intent,
activity.icon,
icon,
asRoot,
activity.iconResourceName,
)
@ -69,6 +70,14 @@ class ShortcutCreatorImpl @Inject constructor(
}
}
private fun getIcon(componentName: android.content.ComponentName): Drawable = runCatching {
val pm = context.packageManager
val activityInfo = pm.getActivityInfo(componentName, 0)
activityInfo.loadIcon(pm)
}.getOrElse {
context.packageManager.defaultActivityIcon
}
private fun extractIconPackageName(
activity: MyActivityInfo,
): String? {

View file

@ -58,7 +58,6 @@ class ActivityRepositoryImpl @Inject constructor(
return MyActivityInfo(
componentName,
createNameFromClass(componentName.className),
packageManager.defaultActivityIcon,
null,
false,
)
@ -67,6 +66,15 @@ class ActivityRepositoryImpl @Inject constructor(
return getActivityInfo(activityInfo, name)
}
override fun getIcon(componentName: ComponentName): Drawable {
return runCatching {
val activityInfo = packageManager.getActivityInfo(componentName, 0)
activityInfo.loadIcon(packageManager)
}.getOrElse {
packageManager.defaultActivityIcon
}
}
override fun invalidate() {
this.packageRepository.invalidate()
}
@ -77,14 +85,12 @@ class ActivityRepositoryImpl @Inject constructor(
): MyActivityInfo {
val componentName = activityInfo.componentName
val name = nameInfo.name
val icon = getIcon(activityInfo)
val iconResourceName = getIconResourceName(activityInfo)
val isPrivate = activityInfo.isPrivate(packageManager)
return MyActivityInfo(
componentName,
name,
icon,
iconResourceName,
isPrivate,
)
@ -103,12 +109,6 @@ class ActivityRepositoryImpl @Inject constructor(
}.getOrNull()
}
private fun getIcon(activityInfo: ActivityInfo): Drawable = runCatching {
activityInfo.loadIcon(packageManager)
}.getOrElse {
packageManager.defaultActivityIcon
}
private fun createNameFromClass(cls: String): String {
val name = cls.substringAfterLast('.')
val config = settingsRepository.getLocaleConfiguration()

View file

@ -124,6 +124,10 @@ class PackageDataSource @Inject constructor(
packageDao.deletePackageByName(packageName)
}
suspend fun clear() = withContext(Dispatchers.IO) {
packageDao.deleteAllPackages()
}
private fun getVersion(info: PackageInfo): String =
"${info.versionName} (${PackageInfoCompat.getLongVersionCode(info)})"

View file

@ -47,19 +47,16 @@ class PackageRepositoryImpl @Inject constructor(
override fun sync() {
scope.launch {
dataSource.sync()
dataSource.loadAllDetails()
performSync()
}
}
private fun PackageWithActivities.toMyPackageInfo(): MyPackageInfo {
val app = runCatching { packageManager.getApplicationInfo(pkg.packageName, 0) }.getOrNull()
val icon = if (app != null) {
packageManager.getApplicationIcon(app)
} else {
packageManager.defaultActivityIcon
}
private suspend fun performSync() {
dataSource.sync()
dataSource.loadAllDetails()
}
private fun PackageWithActivities.toMyPackageInfo(): MyPackageInfo {
val activityNames = activities.filter { !it.isDefault }.map {
ActivityName(it.name, it.shortCls, it.fullCls)
}
@ -74,17 +71,26 @@ class PackageRepositoryImpl @Inject constructor(
version = pkg.version,
defaultActivityName = defaultActivityName,
activityNames = activityNames,
icon = icon,
iconResourceName = pkg.iconResourceName,
isFullyLoaded = pkg.isFullyLoaded,
)
}
override fun getIcon(packageName: String): android.graphics.drawable.Drawable {
return runCatching {
val app = packageManager.getApplicationInfo(packageName, 0)
packageManager.getApplicationIcon(app)
}.getOrElse { packageManager.defaultActivityIcon }
}
override fun getPackage(packageName: String): MyPackageInfo? {
return packages.find { it.packageName == packageName }
}
override fun invalidate() {
sync()
scope.launch {
dataSource.clear()
performSync()
}
}
}

View file

@ -1,7 +1,6 @@
package de.szalkowski.activitylauncher.domain.model
import android.content.ComponentName
import android.graphics.drawable.Drawable
data class MyPackageInfo(
val id: Long,
@ -10,7 +9,6 @@ data class MyPackageInfo(
val version: String,
val defaultActivityName: ActivityName?,
val activityNames: List<ActivityName>,
val icon: Drawable,
val iconResourceName: String?,
val isFullyLoaded: Boolean = true,
)
@ -31,12 +29,10 @@ data class PackageActivities(
data class MyActivityInfo(
val componentName: ComponentName,
val name: String,
val icon: Drawable,
val iconResourceName: String?,
val isPrivate: Boolean,
)
data class IconInfo(
val iconResourceName: String,
val icon: Drawable,
)

View file

@ -13,5 +13,7 @@ interface ActivityRepository {
componentName: ComponentName,
): MyActivityInfo
fun getIcon(componentName: ComponentName): android.graphics.drawable.Drawable
fun invalidate()
}

View file

@ -8,6 +8,7 @@ interface PackageRepository {
val packagesFlow: StateFlow<List<MyPackageInfo>>
val isLoaded: Boolean
fun getPackage(packageName: String): MyPackageInfo?
fun getIcon(packageName: String): android.graphics.drawable.Drawable
fun invalidate()
fun sync()
}

View file

@ -74,7 +74,7 @@ class ActivityDetailsViewModel @Inject constructor(
_editedPackage.value = info.componentName.packageName
_editedClass.value = info.componentName.className
_editedIconResourceName.value = info.iconResourceName ?: ""
_editedIconDrawable.value = info.icon
_editedIconDrawable.value = activityRepository.getIcon(componentName)
}
}
@ -120,7 +120,6 @@ class ActivityDetailsViewModel @Inject constructor(
return MyActivityInfo(
componentName,
_editedName.value,
_editedIconDrawable.value!!,
_editedIconResourceName.value.ifBlank { null },
false,
)

View file

@ -11,7 +11,9 @@ import androidx.recyclerview.widget.RecyclerView
import de.szalkowski.activitylauncher.R
import de.szalkowski.activitylauncher.domain.model.MyActivityInfo
class ActivityInfoAdapter : ListAdapter<MyActivityInfo, ActivityInfoAdapter.ViewHolder>(ActivityDiffCallback) {
class ActivityInfoAdapter(
private val iconProvider: (android.content.ComponentName) -> android.graphics.drawable.Drawable,
) : ListAdapter<MyActivityInfo, ActivityInfoAdapter.ViewHolder>(ActivityDiffCallback) {
var onItemClick: ((MyActivityInfo) -> Unit)? = null
var onItemLongClick: ((MyActivityInfo) -> Unit)? = null
@ -50,7 +52,7 @@ class ActivityInfoAdapter : ListAdapter<MyActivityInfo, ActivityInfoAdapter.View
val item = getItem(position)
holder.tvName.text = item.name
holder.tvClass.text = item.componentName.shortClassName
holder.ivIcon.setImageDrawable(item.icon)
holder.ivIcon.setImageDrawable(iconProvider(item.componentName))
}
private object ActivityDiffCallback : DiffUtil.ItemCallback<MyActivityInfo>() {

View file

@ -84,12 +84,14 @@ class ActivityListAdapter @AssistedInject constructor(
return ViewHolder(view)
}
private val activityRepository = activityRepository
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = getItem(position)
holder.item = item
holder.tvName.text = if (item.isPrivate) "(${item.name})" else item.name
holder.tvPackage.text = item.componentName.shortClassName
holder.ivIcon.setImageDrawable(item.icon)
holder.ivIcon.setImageDrawable(activityRepository.getIcon(item.componentName))
}
object ActivityDiffCallback : DiffUtil.ItemCallback<MyActivityInfo>() {

View file

@ -13,6 +13,7 @@ import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import de.szalkowski.activitylauncher.domain.launcher.ActivityLauncher
import de.szalkowski.activitylauncher.domain.packages.ActivityRepository
import de.szalkowski.activitylauncher.presentation.activities.ActivityInfoAdapter
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -21,6 +22,9 @@ abstract class BaseActivityListFragment : Fragment() {
@Inject
internal lateinit var activityLauncher: ActivityLauncher
@Inject
internal lateinit var activityRepository: ActivityRepository
protected abstract val viewModel: BaseActivityListViewModel
protected abstract val recyclerViewId: Int
protected abstract val logTag: String
@ -31,7 +35,7 @@ abstract class BaseActivityListFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = ActivityInfoAdapter()
adapter = ActivityInfoAdapter { activityRepository.getIcon(it) }
adapter.onItemClick = { info ->
activityLauncher.launchActivity(info.componentName, asRoot = false, showToast = true)
}

View file

@ -33,8 +33,8 @@ class IconListAdapter @Inject constructor(private val iconLoader: IconLoader) :
val view = ImageView(parent.context)
val layout = AbsListView.LayoutParams(50, 50)
view.layoutParams = layout
val icon = icons[position]
view.setImageDrawable(icon.icon)
val iconInfo = icons[position]
view.setImageDrawable(iconLoader.getIcon(iconInfo.iconResourceName))
return view
}
}

View file

@ -85,7 +85,8 @@ class PackageListAdapter @Inject constructor(private val packageRepository: Pack
holder.tvVersion.text = item.version
holder.tvPackage.text = item.packageName
holder.tvActivities.text = "($activityCount)"
holder.ivIcon.setImageDrawable(item.icon)
holder.ivIcon.setImageDrawable(packageRepository.getIcon(item.packageName))
if (item.isFullyLoaded) {
holder.itemView.alpha = 1.0f

View file

@ -1,7 +1,6 @@
package de.szalkowski.activitylauncher.domain.usecase.launcher
import android.content.ComponentName
import android.graphics.drawable.Drawable
import de.szalkowski.activitylauncher.domain.launcher.ShortcutCreator
import de.szalkowski.activitylauncher.domain.model.MyActivityInfo
import de.szalkowski.activitylauncher.domain.recents.RecentsRepository
@ -15,7 +14,7 @@ class CreateShortcutUseCaseTest {
private lateinit var useCase: CreateShortcutUseCase
private val componentName = ComponentName("com.test", "Activity")
private val activityInfo = MyActivityInfo(componentName, "Test", mock<Drawable>(), null, false)
private val activityInfo = MyActivityInfo(componentName, "Test", null, false)
@Before
fun setup() {

View file

@ -1,7 +1,6 @@
package de.szalkowski.activitylauncher.presentation.activities
import android.content.ComponentName
import android.graphics.drawable.Drawable
import androidx.lifecycle.SavedStateHandle
import de.szalkowski.activitylauncher.domain.favorites.FavoritesRepository
import de.szalkowski.activitylauncher.domain.launcher.IconLoader
@ -53,7 +52,6 @@ class ActivityDetailsViewModelTest {
val activityInfo = MyActivityInfo(
componentName,
"Test Activity",
mock<Drawable>(),
"res:icon",
false,
)

View file

@ -1,7 +1,6 @@
package de.szalkowski.activitylauncher.presentation.favorites
import android.content.ComponentName
import android.graphics.drawable.Drawable
import de.szalkowski.activitylauncher.domain.favorites.FavoritesRepository
import de.szalkowski.activitylauncher.domain.model.MyActivityInfo
import de.szalkowski.activitylauncher.domain.packages.ActivityRepository
@ -34,7 +33,7 @@ class FavoritesViewModelTest {
@Test
fun `should load favorites`() = runTest {
val component = ComponentName("com.test", "Activity")
val activityInfo = MyActivityInfo(component, "Activity", mock<Drawable>(), null, false)
val activityInfo = MyActivityInfo(component, "Activity", null, false)
whenever(favoritesRepository.getFavorites()).thenReturn(setOf(component))
whenever(activityRepository.getActivity(component)).thenReturn(activityInfo)

View file

@ -1,6 +1,5 @@
package de.szalkowski.activitylauncher.presentation.packages
import android.graphics.drawable.Drawable
import de.szalkowski.activitylauncher.domain.model.ActivityName
import de.szalkowski.activitylauncher.domain.model.MyPackageInfo
import de.szalkowski.activitylauncher.domain.packages.PackageRepository
@ -87,7 +86,6 @@ class PackageListViewModelTest {
version = "1.0",
defaultActivityName = null,
activityNames = activities,
icon = mock<Drawable>(),
iconResourceName = null,
isFullyLoaded = true,
)