Offer updates for extensions that aren't loaded

Extensions that aren't loaded now get updates like loaded ones, which is often what gets them loaded again. They're counted, notified and listed with the other pending updates. Every installed extension, loaded or not, records the added store whose key signed it when it loads.
This commit is contained in:
AntsyLich
2026-09-28 00:46:54 +06:00
parent 0938411053
commit 2d86a70ce2
10 changed files with 77 additions and 24 deletions
@@ -23,14 +23,18 @@ class GetExtensionsByType(
extensionManager.notLoadedExtensionsFlow,
extensionManager.availableExtensionsFlow,
) { enabledLanguages, _loaded, _notLoaded, _available ->
val (updates, loaded) = _loaded
val (loadedUpdates, loaded) = _loaded
.sortedWith(
compareBy<Extension.Loaded> { !it.isObsolete }
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
)
.partition { it.hasUpdate }
val notLoaded = _notLoaded
val (notLoadedUpdates, notLoaded) = _notLoaded
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
.partition { it.hasUpdate }
val updates = (loadedUpdates + notLoadedUpdates)
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
val available = _available
@@ -3,7 +3,7 @@ package eu.kanade.domain.extension.model
import eu.kanade.tachiyomi.extension.model.Extension
data class Extensions(
val updates: List<Extension.Loaded>,
val updates: List<Extension.Installed>,
val loaded: List<Extension.Loaded>,
val available: List<Extension.Available>,
val notLoaded: List<Extension.NotLoaded>,
@@ -83,7 +83,7 @@ fun ExtensionScreen(
onOpenWebView: (Extension.Available) -> Unit,
onInstallExtension: (Extension.Available) -> Unit,
onUninstallExtension: (Extension.Installed) -> Unit,
onUpdateExtension: (Extension.Loaded) -> Unit,
onUpdateExtension: (Extension.Installed) -> Unit,
onTrustExtension: (Extension.NotLoaded) -> Unit,
onOpenExtension: (Extension.Loaded) -> Unit,
onClickUpdateAll: () -> Unit,
@@ -144,7 +144,7 @@ private fun ExtensionContent(
onOpenWebView: (Extension.Available) -> Unit,
onInstallExtension: (Extension.Available) -> Unit,
onUninstallExtension: (Extension.Installed) -> Unit,
onUpdateExtension: (Extension.Loaded) -> Unit,
onUpdateExtension: (Extension.Installed) -> Unit,
onTrustExtension: (Extension.NotLoaded) -> Unit,
onOpenExtension: (Extension.Loaded) -> Unit,
onClickUpdateAll: () -> Unit,
@@ -232,7 +232,9 @@ private fun ExtensionContent(
when (it) {
is Extension.Available -> onOpenWebView(it)
is Extension.Loaded -> onOpenExtension(it)
else -> {}
is Extension.NotLoaded -> {
notLoadedState = it
}
}
},
onClickItemCancel = onClickItemCancel,
@@ -247,7 +249,11 @@ private fun ExtensionContent(
}
}
is Extension.NotLoaded -> {
notLoadedState = it
if (it.hasUpdate) {
onUpdateExtension(it)
} else {
notLoadedState = it
}
}
}
},
@@ -479,7 +485,7 @@ private fun ExtensionItemActions(
}
is Extension.NotLoaded -> {
val isUntrusted = extension.reason is Extension.NotLoaded.Reason.Untrusted
IconButton(onClick = { onClickItemAction(extension) }) {
IconButton(onClick = { onClickItemSecondaryAction(extension) }) {
Icon(
imageVector = if (isUntrusted) {
MaterialSymbols.Rounded.VerifiedUser
@@ -493,6 +499,15 @@ private fun ExtensionItemActions(
},
)
}
if (extension.hasUpdate) {
IconButton(onClick = { onClickItemAction(extension) }) {
Icon(
imageVector = MaterialSymbols.Rounded.Download,
contentDescription = stringResource(MR.strings.ext_update),
)
}
}
}
is Extension.Available -> {
if (extension.sources.isNotEmpty()) {
@@ -215,7 +215,7 @@ class ExtensionManager(
}
/**
* Sets the update field of the installed extensions with the given [availableExtensions].
* Sets the update fields of the installed extensions, loaded or not, with the given [availableExtensions].
*
* @param availableExtensions The list of extensions given by the [api].
*/
@@ -228,6 +228,9 @@ class ExtensionManager(
loadedExtensionMapFlow.value = loadedExtensionMapFlow.value.mapValues { (_, extension) ->
extension.withStatus(availableExtensions)
}
notLoadedExtensionMapFlow.value = notLoadedExtensionMapFlow.value.mapValues { (_, extension) ->
extension.withStatus(availableExtensions)
}
updatePendingUpdatesCount()
}
@@ -249,7 +252,7 @@ class ExtensionManager(
*
* @param extension The extension to be updated.
*/
fun updateExtension(extension: Extension.Loaded): Flow<InstallStep> {
fun updateExtension(extension: Extension.Installed): Flow<InstallStep> {
val update = extension.findUpdate(availableExtensionListFlow.value) ?: return emptyFlow()
val isUpdateForPrivatelyInstalled = !extension.isShared
return installer.downloadAndInstall(update, isUpdateForPrivatelyInstalled)
@@ -328,7 +331,7 @@ class ExtensionManager(
override fun onExtensionNotLoaded(extension: Extension.NotLoaded) {
loadedExtensionMapFlow.value -= extension.pkgName
notLoadedExtensionMapFlow.value += extension
notLoadedExtensionMapFlow.value += extension.withStatus(availableExtensionListFlow.value)
updatePendingUpdatesCount()
}
@@ -349,12 +352,20 @@ class ExtensionManager(
return copy(
hasUpdate = findUpdate(availableExtensions) != null,
isObsolete = listing == null,
store = listing?.store ?: store,
)
}
/**
* An extension that isn't loaded can still be updated, which is often what gets it loaded again.
*/
private fun Extension.NotLoaded.withStatus(availableExtensions: List<Extension.Available>): Extension.NotLoaded {
if (availableExtensions.isEmpty()) return this
return copy(hasUpdate = findUpdate(availableExtensions) != null)
}
private fun updatePendingUpdatesCount() {
val pendingUpdateCount = loadedExtensionMapFlow.value.values.count { it.hasUpdate }
val pendingUpdateCount = loadedExtensionMapFlow.value.values.count { it.hasUpdate } +
notLoadedExtensionMapFlow.value.values.count { it.hasUpdate }
preferences.extensionUpdatesCount.set(pendingUpdateCount)
if (pendingUpdateCount == 0) {
extensionUpdateNotifier.dismiss()
@@ -21,15 +21,16 @@ class ExtensionApi(
}
/**
* @param loadedExtensions Extensions already loaded by [eu.kanade.tachiyomi.extension.ExtensionManager].
* Only their versions are read, so there's nothing to gain from loading them a second time.
* @param installedExtensions Extensions already read by [eu.kanade.tachiyomi.extension.ExtensionManager],
* loaded or not. Only their versions and signatures are read, so there's nothing to gain from reading them
* a second time.
*/
suspend fun checkForUpdates(loadedExtensions: List<Extension.Loaded>) {
suspend fun checkForUpdates(installedExtensions: List<Extension.Installed>) {
updateExtensionStores()
val extensions = findExtensions()
val extensionsWithUpdate = loadedExtensions.filter { it.findUpdate(extensions) != null }
val extensionsWithUpdate = installedExtensions.filter { it.findUpdate(extensions) != null }
if (extensionsWithUpdate.isNotEmpty()) {
extensionUpdateNotifier.promptUpdates(extensionsWithUpdate.map { it.name })
@@ -18,6 +18,7 @@ import logcat.LogPriority
import mihon.app.di.appGraph
import mihon.data.dalvik.DelegateLastClassLoaderCompat
import mihon.domain.extension.model.ContentWarning
import mihon.domain.extension.model.ExtensionStore
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
import java.io.File
@@ -122,6 +123,7 @@ internal object ExtensionLoader {
): List<Extension.Installed> {
val trustExtension = context.appGraph.trustExtension
val sourcePreferences = context.appGraph.sourcePreferences
val stores = context.appGraph.extensionStoreRepository.getAll()
val enabledContentWarnings = sourcePreferences.enabledContentWarnings.get()
val applyContentWarningsToInstalled = sourcePreferences.applyContentWarningsToInstalled.get()
@@ -178,6 +180,7 @@ internal object ExtensionLoader {
context = context,
extensionInfo = it,
trustExtension = trustExtension,
stores = stores,
enabledContentWarnings = enabledContentWarnings,
applyContentWarningsToInstalled = applyContentWarningsToInstalled,
alreadyLoaded = alreadyLoaded[it.packageInfo.packageName],
@@ -204,6 +207,7 @@ internal object ExtensionLoader {
context = context,
extensionInfo = extensionPackage,
trustExtension = context.appGraph.trustExtension,
stores = context.appGraph.extensionStoreRepository.getAll(),
enabledContentWarnings = sourcePreferences.enabledContentWarnings.get(),
applyContentWarningsToInstalled = sourcePreferences.applyContentWarningsToInstalled.get(),
)
@@ -254,6 +258,7 @@ internal object ExtensionLoader {
context: Context,
extensionInfo: ExtensionInfo,
trustExtension: TrustExtension,
stores: List<ExtensionStore>,
enabledContentWarnings: Set<ContentWarning>,
applyContentWarningsToInstalled: Boolean,
alreadyLoaded: Extension.Loaded? = null,
@@ -263,6 +268,7 @@ internal object ExtensionLoader {
context = context,
extensionInfo = extensionInfo,
trustExtension = trustExtension,
stores = stores,
enabledContentWarnings = enabledContentWarnings,
applyContentWarningsToInstalled = applyContentWarningsToInstalled,
alreadyLoaded = alreadyLoaded,
@@ -270,6 +276,7 @@ internal object ExtensionLoader {
} catch (e: Throwable) {
val pkgInfo = extensionInfo.packageInfo
logcat(LogPriority.ERROR, e) { "Extension load error: ${pkgInfo.packageName}" }
val signatures = getSignatures(pkgInfo).orEmpty()
Extension.NotLoaded(
name = pkgInfo.packageName,
pkgName = pkgInfo.packageName,
@@ -277,7 +284,8 @@ internal object ExtensionLoader {
versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo),
isShared = extensionInfo.isShared,
contentWarning = ContentWarning.SAFE,
signatures = getSignatures(pkgInfo).orEmpty(),
signatures = signatures,
store = stores.firstOrNull { it.signingKey in signatures },
reason = Extension.NotLoaded.Reason.Failed(e.rootMessage, e.stackTraceToString()),
)
}
@@ -293,6 +301,7 @@ internal object ExtensionLoader {
context: Context,
extensionInfo: ExtensionInfo,
trustExtension: TrustExtension,
stores: List<ExtensionStore>,
enabledContentWarnings: Set<ContentWarning>,
applyContentWarningsToInstalled: Boolean,
alreadyLoaded: Extension.Loaded? = null,
@@ -309,6 +318,7 @@ internal object ExtensionLoader {
val versionName = pkgInfo.versionName
val versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo)
val signatures = getSignatures(pkgInfo).orEmpty()
val store = stores.firstOrNull { it.signingKey in signatures }
val contentWarning = when {
metaData == null -> ContentWarning.SAFE
metaData.containsKey(METADATA_CONTENT_WARNING) -> {
@@ -333,6 +343,7 @@ internal object ExtensionLoader {
isShared = extensionInfo.isShared,
contentWarning = contentWarning,
signatures = signatures,
store = store,
libVersion = libVersion,
reason = reason,
)
@@ -379,7 +390,7 @@ internal object ExtensionLoader {
alreadyLoaded.versionCode == versionCode &&
alreadyLoaded.isShared == extensionInfo.isShared
) {
return alreadyLoaded
return alreadyLoaded.copy(store = store)
}
val classLoader = try {
@@ -441,6 +452,7 @@ internal object ExtensionLoader {
icon = runCatching { appInfo.loadIcon(pkgManager) }.getOrNull(),
isShared = extensionInfo.isShared,
signatures = signatures,
store = store,
)
}
@@ -168,7 +168,7 @@ class ExtensionsViewModel(
viewModelScope.launchIO {
state.value.items.values.flatten()
.map { it.extension }
.filterIsInstance<Extension.Loaded>()
.filterIsInstance<Extension.Installed>()
.filter { it.hasUpdate }
.forEach(::updateExtension)
}
@@ -180,7 +180,7 @@ class ExtensionsViewModel(
}
}
fun updateExtension(extension: Extension.Loaded) {
fun updateExtension(extension: Extension.Installed) {
viewModelScope.launchIO {
extensionManager.updateExtension(extension).collectToInstallUpdate(extension)
}
@@ -342,7 +342,9 @@ class MainActivity : BaseActivity() {
// Extensions updates
LaunchedEffect(Unit) {
try {
extensionApi.checkForUpdates(extensionManager.getLoadedExtensions())
extensionApi.checkForUpdates(
extensionManager.getLoadedExtensions() + extensionManager.getNotLoadedExtensions(),
)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
}
@@ -44,6 +44,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.protobuf.ProtoBuf
import mihon.core.metro.IsDebugBuild
import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
import mihon.domain.extension.repository.ExtensionStoreRepository
import nl.adaptivity.xmlutil.serialization.XML
import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.category.interactor.GetCategories
@@ -100,6 +101,7 @@ interface AppGraph : ViewModelGraph {
val updateChecker: AppUpdateChecker
val trustExtension: TrustExtension
val extensionStoreRepository: ExtensionStoreRepository
val sourceManager: SourceManager
val trackerManager: TrackerManager
@@ -25,6 +25,10 @@ sealed interface Extension {
/** SHA-256 digests of the certificates its apk is signed with. */
val signatures: List<String>
val hasUpdate: Boolean
val store: ExtensionStore?
/**
* The newest listing of this extension among the stores whose signing key it's signed with. An
* apk from any other store can't replace it, so only these are where its updates come from.
@@ -92,9 +96,9 @@ sealed interface Extension {
val pkgFactory: String?,
val sources: List<Source>,
val icon: Drawable?,
val hasUpdate: Boolean = false,
override val hasUpdate: Boolean = false,
val isObsolete: Boolean = false,
val store: ExtensionStore? = null,
override val store: ExtensionStore? = null,
) : Installed
/**
@@ -111,6 +115,8 @@ sealed interface Extension {
override val signatures: List<String>,
override val libVersion: Double? = null,
override val lang: String? = null,
override val hasUpdate: Boolean = false,
override val store: ExtensionStore? = null,
val reason: Reason,
) : Installed {