Reload extensions when relevant settings are updated (#3954)

This commit is contained in:
AntsyLich
2026-09-14 22:41:53 +06:00
committed by GitHub
parent e6d8efe6f8
commit fdc2d40a1d
8 changed files with 131 additions and 48 deletions
@@ -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<Unit> {
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 {}
}
}
@@ -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() },
),
),
)
@@ -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<Extension.Loaded>()
@@ -138,11 +152,13 @@ class ExtensionManager(
.filterIsInstance<Extension.NotLoaded>()
.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 -> {}
}
}
/**
@@ -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<Extension.Loaded>) {
updateExtensionStores()
val extensions = findExtensions()
val loadedExtensions = ExtensionLoader.loadExtensions(context)
.filterIsInstance<Extension.Loaded>()
val extensionsWithUpdate = mutableListOf<Extension.Loaded>()
for (installedExt in loadedExtensions) {
val pkgName = installedExt.pkgName
@@ -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<Extension.Installed> {
suspend fun loadExtensions(
context: Context,
alreadyLoaded: Map<String, Extension.Loaded> = emptyMap(),
): List<Extension.Installed> {
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<ContentWarning>,
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<ContentWarning>,
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) {
@@ -224,9 +224,7 @@ class ExtensionsViewModel(
}
fun trustExtension(extension: Extension.NotLoaded) {
viewModelScope.launch {
extensionManager.trust(extension)
}
extensionManager.trust(extension)
}
@Immutable
@@ -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)
}
@@ -270,7 +270,7 @@
<string name="pref_allowed_content_warnings">Allowed content warnings</string>
<string name="pref_apply_content_warnings_to_installed">Also apply to installed extensions</string>
<string name="pref_apply_content_warnings_to_installed_summary">When off, extensions you already installed keep loading and updating regardless of their warning</string>
<string name="content_warnings_info">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.</string>
<string name="content_warnings_info">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.</string>
<string name="relative_time_today">Today</string>