mirror of
https://github.com/ActivityLauncher/ActivityLauncher.git
synced 2026-10-03 12:16:18 +00:00
fix: icon loading on demand and cache invalidation on language change
This commit is contained in:
parent
bca8b51f71
commit
9830bba2db
21 changed files with 124 additions and 47 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)})"
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,5 +13,7 @@ interface ActivityRepository {
|
|||
componentName: ComponentName,
|
||||
): MyActivityInfo
|
||||
|
||||
fun getIcon(componentName: ComponentName): android.graphics.drawable.Drawable
|
||||
|
||||
fun invalidate()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>() {
|
||||
|
|
|
|||
|
|
@ -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>() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue