From b15af71b9425f3bf74e957e1f29980039ef347a2 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:04:52 +0600 Subject: [PATCH] Load installed extension and source icons through Coil --- .../eu/kanade/domain/source/model/Source.kt | 18 ---- .../browse/components/BrowseIcons.kt | 81 ++++------------- app/src/main/java/eu/kanade/tachiyomi/App.kt | 10 +++ .../data/coil/ExtensionIconFetcher.kt | 86 +++++++++++++++++++ .../tachiyomi/extension/ExtensionManager.kt | 13 --- .../extension/util/ExtensionLoader.kt | 1 - .../tachiyomi/extension/model/Extension.kt | 2 - 7 files changed, 112 insertions(+), 99 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/domain/source/model/Source.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/coil/ExtensionIconFetcher.kt diff --git a/app/src/main/java/eu/kanade/domain/source/model/Source.kt b/app/src/main/java/eu/kanade/domain/source/model/Source.kt deleted file mode 100644 index e39e77ee9..000000000 --- a/app/src/main/java/eu/kanade/domain/source/model/Source.kt +++ /dev/null @@ -1,18 +0,0 @@ -package eu.kanade.domain.source.model - -import android.content.Context -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.core.graphics.drawable.toBitmap -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import mihon.app.di.appGraph -import tachiyomi.domain.source.model.Source -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -suspend fun Source.icon(): ImageBitmap? = withContext(Dispatchers.IO) { - Injekt.get().appGraph.extensionManager.getAppIconForSource(id) - ?.toBitmap() - ?.asImageBitmap() -} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt index 42a5e82af..713accabe 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt @@ -2,34 +2,23 @@ package eu.kanade.presentation.browse.components import android.util.DisplayMetrics import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.ColorPainter -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import androidx.core.graphics.drawable.toBitmap import coil3.compose.AsyncImage -import eu.kanade.domain.source.model.icon import eu.kanade.presentation.util.rememberResourceBitmapPainter import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.coil.InstalledExtensionIcon +import eu.kanade.tachiyomi.data.coil.InstalledSourceIcon import eu.kanade.tachiyomi.extension.model.Extension -import eu.kanade.tachiyomi.extension.util.ExtensionLoader -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.roundedfilled.Dangerous import mihon.icons.materialsymbols.roundedfilled.Warning @@ -45,10 +34,8 @@ fun SourceIcon( source: Source, modifier: Modifier = Modifier, ) { - val icon = produceState(initialValue = null, source.id) { value = source.icon() }.value - when { - source.isStub && icon == null -> { + source.isStub -> { Image( imageVector = MaterialSymbols.RoundedFilled.Warning, contentDescription = null, @@ -56,13 +43,6 @@ fun SourceIcon( modifier = modifier.then(defaultModifier), ) } - icon != null -> { - Image( - bitmap = icon, - contentDescription = null, - modifier = modifier.then(defaultModifier), - ) - } source.isLocal() -> { Image( painter = painterResource(R.mipmap.ic_local_source), @@ -71,9 +51,12 @@ fun SourceIcon( ) } else -> { - Image( - painter = painterResource(R.mipmap.ic_default_source), + val defaultIcon = painterResource(R.mipmap.ic_default_source) + AsyncImage( + model = InstalledSourceIcon(source.id), contentDescription = null, + placeholder = defaultIcon, + error = defaultIcon, modifier = modifier.then(defaultModifier), ) } @@ -98,20 +81,14 @@ fun ExtensionIcon( ) } is Extension.Loaded -> { - val icon by extension.getIcon(density) - when (icon) { - Result.Loading -> Box(modifier = modifier) - is Result.Success -> Image( - bitmap = (icon as Result.Success).value, - contentDescription = null, - modifier = modifier, - ) - Result.Error -> Image( - bitmap = ImageBitmap.imageResource(id = R.mipmap.ic_default_source), - contentDescription = null, - modifier = modifier, - ) - } + val defaultIcon = painterResource(R.mipmap.ic_default_source) + AsyncImage( + model = InstalledExtensionIcon(extension.pkgName, extension.versionCode, density), + contentDescription = null, + placeholder = defaultIcon, + error = defaultIcon, + modifier = modifier, + ) } is Extension.NotLoaded -> Image( imageVector = MaterialSymbols.RoundedFilled.Dangerous, @@ -121,29 +98,3 @@ fun ExtensionIcon( ) } } - -@Composable -private fun Extension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT): State> { - val context = LocalContext.current - return produceState>(initialValue = Result.Loading, this) { - withContext(Dispatchers.IO) { - value = try { - val appInfo = ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo!! - val appResources = context.packageManager.getResourcesForApplication(appInfo) - Result.Success( - appResources.getDrawableForDensity(appInfo.icon, density, null)!! - .toBitmap() - .asImageBitmap(), - ) - } catch (e: Exception) { - Result.Error - } - } - } -} - -sealed class Result { - data object Loading : Result() - data object Error : Result() - data class Success(val value: T) : Result() -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 1cc413a5f..f6e94d913 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -32,11 +32,15 @@ import eu.kanade.tachiyomi.crash.GlobalExceptionHandler import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.coil.BufferedSourceFetcher import eu.kanade.tachiyomi.data.coil.CoverRequestInterceptor +import eu.kanade.tachiyomi.data.coil.ExtensionIconFetcher import eu.kanade.tachiyomi.data.coil.ImageDecoder +import eu.kanade.tachiyomi.data.coil.InstalledExtensionIconKeyer +import eu.kanade.tachiyomi.data.coil.InstalledSourceIconKeyer import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer import eu.kanade.tachiyomi.data.coil.MangaKeyer import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkPreferences import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate @@ -97,6 +101,8 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor @Inject private lateinit var sourceManager: Lazy + @Inject private lateinit var extensionManager: Lazy + @Inject private lateinit var widgetManager: WidgetManager @Inject private lateinit var migrations: Set @@ -226,9 +232,13 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor add(BufferedSourceFetcher.Factory()) add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy, coverCache, sourceManager)) add(MangaCoverFetcher.MangaFactory(callFactoryLazy, coverCache, sourceManager)) + add(ExtensionIconFetcher.ExtensionFactory(context)) + add(ExtensionIconFetcher.SourceFactory(context, extensionManager)) // Keyer add(MangaCoverKeyer(coverCache)) add(MangaKeyer()) + add(InstalledExtensionIconKeyer()) + add(InstalledSourceIconKeyer()) // Interceptor add(CoverRequestInterceptor()) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/ExtensionIconFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/ExtensionIconFetcher.kt new file mode 100644 index 000000000..fd66eb7c9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/ExtensionIconFetcher.kt @@ -0,0 +1,86 @@ +package eu.kanade.tachiyomi.data.coil + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.core.graphics.drawable.toBitmap +import coil3.ImageLoader +import coil3.asImage +import coil3.decode.DataSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.ImageFetchResult +import coil3.key.Keyer +import coil3.request.Options +import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.extension.util.ExtensionLoader + +/** + * The icon of an installed extension, drawn at [density]. + */ +data class InstalledExtensionIcon( + val pkgName: String, + val versionCode: Long, + val density: Int, +) + +/** + * The icon of the installed extension that provides the source with [sourceId]. + */ +data class InstalledSourceIcon(val sourceId: Long) + +class ExtensionIconFetcher( + private val context: Context, + private val getPkgName: suspend () -> String?, + private val density: Int?, +) : Fetcher { + + override suspend fun fetch(): FetchResult { + val pkgName = getPkgName() ?: error("No installed extension") + val appInfo = ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)?.applicationInfo + ?: error("Extension $pkgName isn't installed") + val drawable: Drawable = if (density == null) { + appInfo.loadIcon(context.packageManager) + } else { + context.packageManager.getResourcesForApplication(appInfo) + .getDrawableForDensity(appInfo.icon, density, null) + ?: error("Extension $pkgName has no icon") + } + // Adaptive icons aren't bitmaps, and only bitmaps are kept in the memory cache + return ImageFetchResult( + image = drawable.toBitmap().asImage(), + isSampled = false, + dataSource = DataSource.DISK, + ) + } + + class ExtensionFactory(private val context: Context) : Fetcher.Factory { + override fun create(data: InstalledExtensionIcon, options: Options, imageLoader: ImageLoader): Fetcher { + return ExtensionIconFetcher(context, { data.pkgName }, data.density) + } + } + + class SourceFactory( + private val context: Context, + private val extensionManager: Lazy, + ) : Fetcher.Factory { + override fun create(data: InstalledSourceIcon, options: Options, imageLoader: ImageLoader): Fetcher { + return ExtensionIconFetcher( + context = context, + getPkgName = { extensionManager.value.getExtensionPackage(data.sourceId) }, + density = null, + ) + } + } +} + +class InstalledExtensionIconKeyer : Keyer { + override fun key(data: InstalledExtensionIcon, options: Options): String { + return "extension;${data.pkgName};${data.versionCode};${data.density}" + } +} + +class InstalledSourceIconKeyer : Keyer { + override fun key(data: InstalledSourceIcon, options: Options): String { + return "source;${data.sourceId}" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt index af62cfe09..6adffa747 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt @@ -1,7 +1,6 @@ package eu.kanade.tachiyomi.extension import android.content.Context -import android.graphics.drawable.Drawable import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn @@ -43,7 +42,6 @@ import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.source.model.StubSource import tachiyomi.i18n.MR import java.util.Locale -import java.util.concurrent.ConcurrentHashMap /** * The manager of extensions installed as another apk which extend the available sources. It handles @@ -70,8 +68,6 @@ class ExtensionManager( // A full load replaces both maps, so install events wait for it and apply on top instead of being overwritten private val loadMutex = Mutex() - private val iconMap = ConcurrentHashMap() - @Volatile private var stores = emptyList() @@ -147,15 +143,6 @@ class ExtensionManager( } } - suspend fun getAppIconForSource(sourceId: Long): Drawable? { - val pkgName = getExtensionPackage(sourceId) ?: return null - - return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { - ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo!! - .loadIcon(context.packageManager) - } - } - private var availableExtensionsSourcesData: Map = emptyMap() private fun setupAvailableExtensionsSourcesDataMap(extensions: List) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt index c51d4716f..c1ad42bff 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt @@ -442,7 +442,6 @@ internal object ExtensionLoader { contentWarning = contentWarning, sources = sources, pkgFactory = metaData.getString(METADATA_SOURCE_FACTORY), - icon = runCatching { appInfo.loadIcon(pkgManager) }.getOrNull(), isShared = extensionInfo.isShared, signatures = signatures, store = store, diff --git a/domain/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt b/domain/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt index 1f9b49b57..2690b4821 100644 --- a/domain/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt +++ b/domain/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt @@ -1,6 +1,5 @@ package eu.kanade.tachiyomi.extension.model -import android.graphics.drawable.Drawable import eu.kanade.tachiyomi.source.Source import mihon.domain.extension.model.ContentWarning import mihon.domain.extension.model.ExtensionStore @@ -95,7 +94,6 @@ sealed interface Extension { override val signatures: List, val pkgFactory: String?, val sources: List, - val icon: Drawable?, override val hasUpdate: Boolean = false, val isObsolete: Boolean = false, override val store: ExtensionStore? = null,