Load installed extension and source icons through Coil

This commit is contained in:
AntsyLich
2026-10-11 05:04:59 +06:00
parent 3e5d593e28
commit b15af71b94
7 changed files with 112 additions and 99 deletions
@@ -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<Context>().appGraph.extensionManager.getAppIconForSource(id)
?.toBitmap()
?.asImageBitmap()
}
@@ -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<ImageBitmap?>(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<ImageBitmap>).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<Result<ImageBitmap>> {
val context = LocalContext.current
return produceState<Result<ImageBitmap>>(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<out T> {
data object Loading : Result<Nothing>()
data object Error : Result<Nothing>()
data class Success<out T>(val value: T) : Result<T>()
}
@@ -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<SourceManager>
@Inject private lateinit var extensionManager: Lazy<ExtensionManager>
@Inject private lateinit var widgetManager: WidgetManager
@Inject private lateinit var migrations: Set<Migration>
@@ -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())
}
@@ -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<InstalledExtensionIcon> {
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<ExtensionManager>,
) : Fetcher.Factory<InstalledSourceIcon> {
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<InstalledExtensionIcon> {
override fun key(data: InstalledExtensionIcon, options: Options): String {
return "extension;${data.pkgName};${data.versionCode};${data.density}"
}
}
class InstalledSourceIconKeyer : Keyer<InstalledSourceIcon> {
override fun key(data: InstalledSourceIcon, options: Options): String {
return "source;${data.sourceId}"
}
}
@@ -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<String, Drawable>()
@Volatile
private var stores = emptyList<ExtensionStore>()
@@ -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<Long, StubSource> = emptyMap()
private fun setupAvailableExtensionsSourcesDataMap(extensions: List<Extension.Available>) {
@@ -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,
@@ -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<String>,
val pkgFactory: String?,
val sources: List<Source>,
val icon: Drawable?,
override val hasUpdate: Boolean = false,
val isObsolete: Boolean = false,
override val store: ExtensionStore? = null,