Clean up extension update checking

ExtensionApi only wrapped the store repository and the launch check, which matched updates again on its own. The manager now fetches listings and checks for updates itself with the statuses it already keeps, so the check covers not-loaded extensions and keeps the Extensions tab current from launch. Statuses are derived in one place, and the extension list is split before it's sorted instead of sorted twice.
This commit is contained in:
AntsyLich
2026-09-28 00:48:03 +06:00
parent 2d86a70ce2
commit 4f33486aa4
4 changed files with 76 additions and 107 deletions
@@ -23,19 +23,12 @@ class GetExtensionsByType(
extensionManager.notLoadedExtensionsFlow,
extensionManager.availableExtensionsFlow,
) { enabledLanguages, _loaded, _notLoaded, _available ->
val (loadedUpdates, loaded) = _loaded
.sortedWith(
compareBy<Extension.Loaded> { !it.isObsolete }
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
)
.partition { it.hasUpdate }
val byName = compareBy<Extension, String>(String.CASE_INSENSITIVE_ORDER) { it.name }
val (notLoadedUpdates, notLoaded) = _notLoaded
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
.partition { it.hasUpdate }
val (loadedUpdates, loaded) = _loaded.partition { it.hasUpdate }
val (notLoadedUpdates, notLoaded) = _notLoaded.partition { it.hasUpdate }
val updates = (loadedUpdates + notLoadedUpdates)
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
val updates = (loadedUpdates + notLoadedUpdates).sortedWith(byName)
val available = _available
.filter { extension ->
@@ -54,9 +47,14 @@ class GetExtensionsByType(
)
}
}
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
.sortedWith(byName)
Extensions(updates, loaded, available, notLoaded)
Extensions(
updates = updates,
loaded = loaded.sortedWith(compareBy<Extension.Loaded> { !it.isObsolete }.then(byName)),
available = available,
notLoaded = notLoaded.sortedWith(byName),
)
}
}
}
@@ -7,7 +7,6 @@ import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.domain.extension.interactor.TrustExtension
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.api.ExtensionApi
import eu.kanade.tachiyomi.extension.api.ExtensionUpdateNotifier
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
@@ -33,6 +32,9 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import logcat.LogPriority
import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extension.repository.ExtensionStoreRepository
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.source.model.StubSource
@@ -52,7 +54,8 @@ class ExtensionManager(
private val context: Context,
private val preferences: SourcePreferences,
private val trustExtension: TrustExtension,
private val api: ExtensionApi,
private val extensionStoreRepository: ExtensionStoreRepository,
private val updateExtensionStores: UpdateExtensionStores,
private val installer: ExtensionInstaller,
private val extensionUpdateNotifier: ExtensionUpdateNotifier,
) {
@@ -156,7 +159,7 @@ class ExtensionManager(
.associateBy { it.pkgName }
// Newly loaded extensions have no status derived from the store index yet
updatedInstalledExtensionsStatuses(availableExtensionListFlow.value)
refreshStatuses()
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Failed to load extensions" }
} finally {
@@ -166,21 +169,53 @@ class ExtensionManager(
}
/**
* Finds the available extensions in the [api] and updates [availableExtensionListFlow].
* Finds the available extensions in the stores and updates [availableExtensionListFlow].
*/
suspend fun findAvailableExtensions() {
val extensions: List<Extension.Available> = try {
api.findExtensions()
fetchExtensions()
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
withUIContext { context.toast(MR.strings.extension_api_error) }
return
}
setAvailableExtensions(extensions)
}
/**
* Refreshes the stores and their listings like [findAvailableExtensions], then notifies about the
* installed extensions that have an update. Stays silent when the stores can't be reached.
*/
suspend fun checkForUpdates() {
val extensions = try {
updateExtensionStores()
fetchExtensions()
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
return
}
initialized.await()
setAvailableExtensions(extensions)
val names = (loadedExtensionMapFlow.value.values + notLoadedExtensionMapFlow.value.values)
.filter { it.hasUpdate }
.map { it.name }
if (names.isNotEmpty()) {
extensionUpdateNotifier.promptUpdates(names)
}
}
private suspend fun fetchExtensions(): List<Extension.Available> {
return withIOContext { extensionStoreRepository.fetchExtensions() }
}
private fun setAvailableExtensions(extensions: List<Extension.Available>) {
enableAdditionalSubLanguages(extensions)
availableExtensionListFlow.value = extensions
updatedInstalledExtensionsStatuses(extensions)
refreshStatuses()
setupAvailableExtensionsSourcesDataMap(extensions)
}
@@ -214,26 +249,6 @@ class ExtensionManager(
subLanguagesEnabledOnFirstRun = true
}
/**
* 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].
*/
private fun updatedInstalledExtensionsStatuses(availableExtensions: List<Extension.Available>) {
if (availableExtensions.isEmpty()) {
preferences.extensionUpdatesCount.set(0)
return
}
loadedExtensionMapFlow.value = loadedExtensionMapFlow.value.mapValues { (_, extension) ->
extension.withStatus(availableExtensions)
}
notLoadedExtensionMapFlow.value = notLoadedExtensionMapFlow.value.mapValues { (_, extension) ->
extension.withStatus(availableExtensions)
}
updatePendingUpdatesCount()
}
/**
* Returns a flow of the installation process for the given extension. It will complete
* once the extension is installed or throws an error. The process will be canceled if
@@ -324,15 +339,15 @@ class ExtensionManager(
private inner class InstallationListener : ExtensionInstallReceiver.Listener {
override fun onExtensionLoaded(extension: Extension.Loaded) {
registerExtension(extension.withStatus(availableExtensionListFlow.value))
registerExtension(extension)
notLoadedExtensionMapFlow.value -= extension.pkgName
updatePendingUpdatesCount()
refreshStatuses()
}
override fun onExtensionNotLoaded(extension: Extension.NotLoaded) {
loadedExtensionMapFlow.value -= extension.pkgName
notLoadedExtensionMapFlow.value += extension.withStatus(availableExtensionListFlow.value)
updatePendingUpdatesCount()
notLoadedExtensionMapFlow.value += extension
refreshStatuses()
}
override fun onPackageUninstalled(pkgName: String) {
@@ -343,29 +358,29 @@ class ExtensionManager(
}
/**
* Derives what the store listings say about an installed extension. Without any listing there's
* nothing to derive from, so it's left as is rather than being marked obsolete.
* Derives what the store listings say about every installed extension, loaded or not. Without any
* listing there's nothing to derive from, so they're left as they are rather than marked obsolete.
*/
private fun Extension.Loaded.withStatus(availableExtensions: List<Extension.Available>): Extension.Loaded {
if (availableExtensions.isEmpty()) return this
val listing = findListing(availableExtensions)
return copy(
hasUpdate = findUpdate(availableExtensions) != null,
isObsolete = listing == null,
)
}
/**
* 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 refreshStatuses() {
val available = availableExtensionListFlow.value
if (available.isNotEmpty()) {
loadedExtensionMapFlow.value = loadedExtensionMapFlow.value.mapValues { (_, extension) ->
val listing = extension.findListing(available)
extension.copy(
hasUpdate = extension.findUpdate(available) != null,
isObsolete = listing == null,
)
}
notLoadedExtensionMapFlow.value = notLoadedExtensionMapFlow.value.mapValues { (_, extension) ->
extension.copy(hasUpdate = extension.findUpdate(available) != null)
}
}
updatePendingUpdatesCount()
}
private fun updatePendingUpdatesCount() {
val pendingUpdateCount = loadedExtensionMapFlow.value.values.count { it.hasUpdate } +
notLoadedExtensionMapFlow.value.values.count { it.hasUpdate }
val pendingUpdateCount = (loadedExtensionMapFlow.value.values + notLoadedExtensionMapFlow.value.values)
.count { it.hasUpdate }
preferences.extensionUpdatesCount.set(pendingUpdateCount)
if (pendingUpdateCount == 0) {
extensionUpdateNotifier.dismiss()
@@ -1,39 +0,0 @@
package eu.kanade.tachiyomi.extension.api
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.tachiyomi.extension.model.Extension
import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extension.repository.ExtensionStoreRepository
import tachiyomi.core.common.util.lang.withIOContext
@Inject
@SingleIn(AppScope::class)
class ExtensionApi(
private val repository: ExtensionStoreRepository,
private val updateExtensionStores: UpdateExtensionStores,
private val extensionUpdateNotifier: ExtensionUpdateNotifier,
) {
suspend fun findExtensions(): List<Extension.Available> {
return withIOContext { repository.fetchExtensions() }
}
/**
* @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(installedExtensions: List<Extension.Installed>) {
updateExtensionStores()
val extensions = findExtensions()
val extensionsWithUpdate = installedExtensions.filter { it.findUpdate(extensions) != null }
if (extensionsWithUpdate.isNotEmpty()) {
extensionUpdateNotifier.promptUpdates(extensionsWithUpdate.map { it.name })
}
}
}
@@ -80,7 +80,6 @@ import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.download.DownloadCache
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.api.ExtensionApi
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen
@@ -141,8 +140,6 @@ class MainActivity : BaseActivity() {
@Inject private lateinit var getIncognitoState: GetIncognitoState
@Inject private lateinit var extensionApi: ExtensionApi
@Inject private lateinit var extensionManager: ExtensionManager
// To be checked by splash screen. If true then splash screen will be removed.
@@ -342,9 +339,7 @@ class MainActivity : BaseActivity() {
// Extensions updates
LaunchedEffect(Unit) {
try {
extensionApi.checkForUpdates(
extensionManager.getLoadedExtensions() + extensionManager.getNotLoadedExtensions(),
)
extensionManager.checkForUpdates()
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
}