From fdc2d40a1da27bdd0c98a537e157037cf0a80b99 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:41:53 +0600 Subject: [PATCH] Reload extensions when relevant settings are updated (#3954) --- .../extension/interactor/TrustExtension.kt | 24 +++++ .../settings/screen/SettingsAdvancedScreen.kt | 5 +- .../tachiyomi/extension/ExtensionManager.kt | 41 +++++---- .../tachiyomi/extension/api/ExtensionApi.kt | 11 ++- .../extension/util/ExtensionLoader.kt | 87 +++++++++++++++---- .../browse/extension/ExtensionsViewModel.kt | 4 +- .../kanade/tachiyomi/ui/main/MainActivity.kt | 5 +- .../moko-resources/base/strings.xml | 2 +- 8 files changed, 131 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/TrustExtension.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/TrustExtension.kt index 4a5b31b14..f3094b57b 100644 --- a/app/src/main/java/eu/kanade/domain/extension/interactor/TrustExtension.kt +++ b/app/src/main/java/eu/kanade/domain/extension/interactor/TrustExtension.kt @@ -4,6 +4,11 @@ import android.content.pm.PackageInfo import androidx.core.content.pm.PackageInfoCompat import dev.zacsweers.metro.Inject import eu.kanade.domain.source.service.SourcePreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import mihon.domain.extension.repository.ExtensionStoreRepository import tachiyomi.core.common.preference.getAndSet @@ -31,4 +36,23 @@ class TrustExtension( fun revokeAll() { preferences.trustedExtensions.delete() } + + /** + * Emits whenever what counts as trusted changes, either because a store was added or removed or + * because an extension was trusted or had its trust revoked. Both sources replay their current + * value, which is dropped. + */ + fun changes(): Flow { + return merge( + // Stores are rewritten whenever their index is refreshed, so only their keys matter here + repository.getAllAsFlow() + .map { stores -> stores.mapTo(HashSet()) { it.signingKey } } + .distinctUntilChanged() + .drop(1), + preferences.trustedExtensions.changes() + .distinctUntilChanged() + .drop(1), + ) + .map {} + } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt index cf59e6542..aa9421c93 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt @@ -400,10 +400,7 @@ object SettingsAdvancedScreen : SearchableSettings { ), Preference.PreferenceItem.TextPreference( title = stringResource(MR.strings.ext_revoke_trust), - onClick = { - trustExtension.revokeAll() - context.toast(MR.strings.requires_app_restart) - }, + onClick = { trustExtension.revokeAll() }, ), ), ) 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 4bec91ba8..ccda3c39f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt @@ -23,8 +23,12 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -70,8 +74,16 @@ class ExtensionManager( init { scope.launch(Dispatchers.IO) { - initExtensions() + loadExtensions() ExtensionInstallReceiver(InstallationListener()).register(context) + + // Everything the load decision rests on can change while running, so decide again + merge( + trustExtension.changes(), + preferences.enabledContentWarnings.changes().distinctUntilChanged().drop(1).map {}, + preferences.applyContentWarningsToInstalled.changes().distinctUntilChanged().drop(1).map {}, + ) + .collectLatest { loadExtensions() } } } @@ -124,11 +136,13 @@ class ExtensionManager( fun getSourceData(id: Long) = availableExtensionsSourcesData[id] /** - * Loads and registers the installed extensions. + * Loads and registers the installed extensions. Safe to call again: every extension is judged + * again, so one can move between loaded and not loaded in either direction, while extensions + * that still pass keep the instances they already had. */ - private fun initExtensions() { + private suspend fun loadExtensions() { try { - val extensions = ExtensionLoader.loadExtensions(context) + val extensions = ExtensionLoader.loadExtensions(context, loadedExtensionMapFlow.value) loadedExtensionMapFlow.value = extensions .filterIsInstance() @@ -138,11 +152,13 @@ class ExtensionManager( .filterIsInstance() .associateBy { it.pkgName } - initialized.complete(Unit) + // Newly loaded extensions have no status derived from the store index yet + updatedInstalledExtensionsStatuses(availableExtensionMapFlow.value.values.toList()) } catch (e: Throwable) { - // Release anything waiting on the extensions before the failure propagates + logcat(LogPriority.ERROR, e) { "Failed to load extensions" } + } finally { + // Release anything waiting on the extensions whether or not the load worked initialized.complete(Unit) - throw e } } @@ -291,19 +307,12 @@ class ExtensionManager( * * @param extension the extension to trust */ - suspend fun trust(extension: Extension.NotLoaded) { + fun trust(extension: Extension.NotLoaded) { val reason = extension.reason as? Extension.NotLoaded.Reason.Untrusted ?: return notLoadedExtensionMapFlow.value[extension.pkgName] ?: return + // Loading it again is left to the reload triggered by the trust change trustExtension.trust(extension.pkgName, extension.versionCode, reason.signatureHash) - - notLoadedExtensionMapFlow.value -= extension.pkgName - - when (val reloaded = ExtensionLoader.loadExtensionFromPkgName(context, extension.pkgName)) { - is Extension.Loaded -> registerExtension(reloaded) - is Extension.NotLoaded -> notLoadedExtensionMapFlow.value += reloaded - null -> {} - } } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt index 35dde4bc1..c0d9d908e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt @@ -1,11 +1,9 @@ package eu.kanade.tachiyomi.extension.api -import android.content.Context import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import eu.kanade.tachiyomi.extension.model.Extension -import eu.kanade.tachiyomi.extension.util.ExtensionLoader import mihon.domain.extension.interactor.UpdateExtensionStores import mihon.domain.extension.repository.ExtensionStoreRepository import tachiyomi.core.common.util.lang.withIOContext @@ -22,14 +20,15 @@ class ExtensionApi( return withIOContext { repository.fetchExtensions() } } - suspend fun checkForUpdates(context: Context) { + /** + * @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. + */ + suspend fun checkForUpdates(loadedExtensions: List) { updateExtensionStores() val extensions = findExtensions() - val loadedExtensions = ExtensionLoader.loadExtensions(context) - .filterIsInstance() - val extensionsWithUpdate = mutableListOf() for (installedExt in loadedExtensions) { val pkgName = installedExt.pkgName 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 a16025893..78beb8e30 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 @@ -12,14 +12,13 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.runBlocking import logcat.LogPriority import mihon.app.di.appGraph import mihon.data.dalvik.DelegateLastClassLoaderCompat import mihon.domain.extension.model.ContentWarning +import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.system.logcat import java.io.File @@ -110,8 +109,19 @@ internal object ExtensionLoader { * Return a list of all the available extensions initialized concurrently. * * @param context The application context. + * @param alreadyLoaded Extensions loaded by an earlier call. Any of these whose apk is unchanged + * and which still passes every check is returned as is, so its sources keep working and its + * update status survives. Pass nothing to load every extension from scratch. */ - fun loadExtensions(context: Context): List { + suspend fun loadExtensions( + context: Context, + alreadyLoaded: Map = emptyMap(), + ): List { + val trustExtension = context.appGraph.trustExtension + val sourcePreferences = context.appGraph.sourcePreferences + val enabledContentWarnings = sourcePreferences.enabledContentWarnings.get() + val applyContentWarningsToInstalled = sourcePreferences.applyContentWarningsToInstalled.get() + val pkgManager = context.packageManager val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -157,11 +167,21 @@ internal object ExtensionLoader { if (extPkgs.isEmpty()) return emptyList() // Load each extension concurrently and wait for completion - return runBlocking(Dispatchers.IO) { - val deferred = extPkgs.map { - async { loadExtensionCatching(context, it) } - } - deferred.awaitAll() + return withIOContext { + extPkgs + .map { + async { + loadExtensionCatching( + context = context, + extensionInfo = it, + trustExtension = trustExtension, + enabledContentWarnings = enabledContentWarnings, + applyContentWarningsToInstalled = applyContentWarningsToInstalled, + alreadyLoaded = alreadyLoaded[it.packageInfo.packageName], + ) + } + } + .awaitAll() } } @@ -175,7 +195,15 @@ internal object ExtensionLoader { logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" } return null } - return loadExtensionCatching(context, extensionPackage) + + val sourcePreferences = context.appGraph.sourcePreferences + return loadExtensionCatching( + context = context, + extensionInfo = extensionPackage, + trustExtension = context.appGraph.trustExtension, + enabledContentWarnings = sourcePreferences.enabledContentWarnings.get(), + applyContentWarningsToInstalled = sourcePreferences.applyContentWarningsToInstalled.get(), + ) } fun getExtensionPackageInfoFromPkgName(context: Context, pkgName: String): PackageInfo? { @@ -219,9 +247,23 @@ internal object ExtensionLoader { * ways it doesn't check for. Keep anything unforeseen to the extension that caused it instead of * letting it take down the load of every other extension. */ - private suspend fun loadExtensionCatching(context: Context, extensionInfo: ExtensionInfo): Extension.Installed { + private suspend fun loadExtensionCatching( + context: Context, + extensionInfo: ExtensionInfo, + trustExtension: TrustExtension, + enabledContentWarnings: Set, + applyContentWarningsToInstalled: Boolean, + alreadyLoaded: Extension.Loaded? = null, + ): Extension.Installed { return try { - loadExtension(context, extensionInfo) + loadExtension( + context = context, + extensionInfo = extensionInfo, + trustExtension = trustExtension, + enabledContentWarnings = enabledContentWarnings, + applyContentWarningsToInstalled = applyContentWarningsToInstalled, + alreadyLoaded = alreadyLoaded, + ) } catch (e: Throwable) { val pkgInfo = extensionInfo.packageInfo logcat(LogPriority.ERROR, e) { "Extension load error: ${pkgInfo.packageName}" } @@ -243,12 +285,14 @@ internal object ExtensionLoader { * @param context The application context. * @param extensionInfo The extension to load. */ - private suspend fun loadExtension(context: Context, extensionInfo: ExtensionInfo): Extension.Installed { - val trustExtension: TrustExtension = context.appGraph.trustExtension - val sourcePreferences = context.appGraph.sourcePreferences - val enabledContentWarnings = sourcePreferences.enabledContentWarnings.get() - val applyContentWarningsToInstalled = sourcePreferences.applyContentWarningsToInstalled.get() - + private suspend fun loadExtension( + context: Context, + extensionInfo: ExtensionInfo, + trustExtension: TrustExtension, + enabledContentWarnings: Set, + applyContentWarningsToInstalled: Boolean, + alreadyLoaded: Extension.Loaded? = null, + ): Extension.Installed { val pkgManager = context.packageManager val pkgInfo = extensionInfo.packageInfo val appInfo = pkgInfo.applicationInfo @@ -324,6 +368,15 @@ internal object ExtensionLoader { return notLoaded(Extension.NotLoaded.Reason.Filtered, libVersion) } + // Everything above is cheap to check again, everything below isn't. Nothing about this apk + // changed and it still passes, so keep the sources that are already registered for it. + if (alreadyLoaded != null && + alreadyLoaded.versionCode == versionCode && + alreadyLoaded.isShared == extensionInfo.isShared + ) { + return alreadyLoaded + } + val classLoader = try { DelegateLastClassLoaderCompat(appInfo.sourceDir, null, context.classLoader) } catch (e: Exception) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsViewModel.kt index 5c30321d9..285e6e53f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsViewModel.kt @@ -224,9 +224,7 @@ class ExtensionsViewModel( } fun trustExtension(extension: Extension.NotLoaded) { - viewModelScope.launch { - extensionManager.trust(extension) - } + extensionManager.trust(extension) } @Immutable diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 14a92a381..168c374d6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -79,6 +79,7 @@ import eu.kanade.presentation.util.DefaultNavigatorScreenTransition 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 @@ -142,6 +143,8 @@ class MainActivity : BaseActivity() { @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. var ready = false @@ -339,7 +342,7 @@ class MainActivity : BaseActivity() { // Extensions updates LaunchedEffect(Unit) { try { - extensionApi.checkForUpdates(context) + extensionApi.checkForUpdates(extensionManager.getLoadedExtensions()) } catch (e: Exception) { logcat(LogPriority.ERROR, e) } diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 0a3e838e4..9cfe0ffc1 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -270,7 +270,7 @@ Allowed content warnings Also apply to installed extensions When off, extensions you already installed keep loading and updating regardless of their warning - Requires app restart to take effect. Installed extensions that aren\'t loaded stay listed without their sources; ones you haven\'t installed are hidden. This does not prevent unofficial or potentially incorrectly flagged extensions from surfacing 18+ content within the app. + Installed extensions that aren\'t loaded stay listed without their sources; ones you haven\'t installed are hidden. This does not prevent unofficial or potentially incorrectly flagged extensions from surfacing 18+ content within the app. Today