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 androidx.core.content.pm.PackageInfoCompat
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import eu.kanade.domain.source.service.SourcePreferences 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 mihon.domain.extension.repository.ExtensionStoreRepository
import tachiyomi.core.common.preference.getAndSet import tachiyomi.core.common.preference.getAndSet
@@ -31,4 +36,23 @@ class TrustExtension(
fun revokeAll() { fun revokeAll() {
preferences.trustedExtensions.delete() 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( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.ext_revoke_trust), title = stringResource(MR.strings.ext_revoke_trust),
onClick = { onClick = { trustExtension.revokeAll() },
trustExtension.revokeAll()
context.toast(MR.strings.requires_app_restart)
},
), ),
), ),
) )
@@ -23,8 +23,12 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow 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.emptyFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -70,8 +74,16 @@ class ExtensionManager(
init { init {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
initExtensions() loadExtensions()
ExtensionInstallReceiver(InstallationListener()).register(context) 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] 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 { try {
val extensions = ExtensionLoader.loadExtensions(context) val extensions = ExtensionLoader.loadExtensions(context, loadedExtensionMapFlow.value)
loadedExtensionMapFlow.value = extensions loadedExtensionMapFlow.value = extensions
.filterIsInstance<Extension.Loaded>() .filterIsInstance<Extension.Loaded>()
@@ -138,11 +152,13 @@ class ExtensionManager(
.filterIsInstance<Extension.NotLoaded>() .filterIsInstance<Extension.NotLoaded>()
.associateBy { it.pkgName } .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) { } 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) initialized.complete(Unit)
throw e
} }
} }
@@ -291,19 +307,12 @@ class ExtensionManager(
* *
* @param extension the extension to trust * @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 val reason = extension.reason as? Extension.NotLoaded.Reason.Untrusted ?: return
notLoadedExtensionMapFlow.value[extension.pkgName] ?: 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) 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 package eu.kanade.tachiyomi.extension.api
import android.content.Context
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import mihon.domain.extension.interactor.UpdateExtensionStores import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extension.repository.ExtensionStoreRepository import mihon.domain.extension.repository.ExtensionStoreRepository
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
@@ -22,14 +20,15 @@ class ExtensionApi(
return withIOContext { repository.fetchExtensions() } 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() updateExtensionStores()
val extensions = findExtensions() val extensions = findExtensions()
val loadedExtensions = ExtensionLoader.loadExtensions(context)
.filterIsInstance<Extension.Loaded>()
val extensionsWithUpdate = mutableListOf<Extension.Loaded>() val extensionsWithUpdate = mutableListOf<Extension.Loaded>()
for (installedExt in loadedExtensions) { for (installedExt in loadedExtensions) {
val pkgName = installedExt.pkgName val pkgName = installedExt.pkgName
@@ -12,14 +12,13 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.lang.Hash
import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import logcat.LogPriority import logcat.LogPriority
import mihon.app.di.appGraph import mihon.app.di.appGraph
import mihon.data.dalvik.DelegateLastClassLoaderCompat import mihon.data.dalvik.DelegateLastClassLoaderCompat
import mihon.domain.extension.model.ContentWarning import mihon.domain.extension.model.ContentWarning
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import java.io.File import java.io.File
@@ -110,8 +109,19 @@ internal object ExtensionLoader {
* Return a list of all the available extensions initialized concurrently. * Return a list of all the available extensions initialized concurrently.
* *
* @param context The application context. * @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 pkgManager = context.packageManager
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -157,11 +167,21 @@ internal object ExtensionLoader {
if (extPkgs.isEmpty()) return emptyList() if (extPkgs.isEmpty()) return emptyList()
// Load each extension concurrently and wait for completion // Load each extension concurrently and wait for completion
return runBlocking(Dispatchers.IO) { return withIOContext {
val deferred = extPkgs.map { extPkgs
async { loadExtensionCatching(context, it) } .map {
async {
loadExtensionCatching(
context = context,
extensionInfo = it,
trustExtension = trustExtension,
enabledContentWarnings = enabledContentWarnings,
applyContentWarningsToInstalled = applyContentWarningsToInstalled,
alreadyLoaded = alreadyLoaded[it.packageInfo.packageName],
)
} }
deferred.awaitAll() }
.awaitAll()
} }
} }
@@ -175,7 +195,15 @@ internal object ExtensionLoader {
logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" } logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" }
return null 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? { 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 * 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. * 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 { return try {
loadExtension(context, extensionInfo) loadExtension(
context = context,
extensionInfo = extensionInfo,
trustExtension = trustExtension,
enabledContentWarnings = enabledContentWarnings,
applyContentWarningsToInstalled = applyContentWarningsToInstalled,
alreadyLoaded = alreadyLoaded,
)
} catch (e: Throwable) { } catch (e: Throwable) {
val pkgInfo = extensionInfo.packageInfo val pkgInfo = extensionInfo.packageInfo
logcat(LogPriority.ERROR, e) { "Extension load error: ${pkgInfo.packageName}" } logcat(LogPriority.ERROR, e) { "Extension load error: ${pkgInfo.packageName}" }
@@ -243,12 +285,14 @@ internal object ExtensionLoader {
* @param context The application context. * @param context The application context.
* @param extensionInfo The extension to load. * @param extensionInfo The extension to load.
*/ */
private suspend fun loadExtension(context: Context, extensionInfo: ExtensionInfo): Extension.Installed { private suspend fun loadExtension(
val trustExtension: TrustExtension = context.appGraph.trustExtension context: Context,
val sourcePreferences = context.appGraph.sourcePreferences extensionInfo: ExtensionInfo,
val enabledContentWarnings = sourcePreferences.enabledContentWarnings.get() trustExtension: TrustExtension,
val applyContentWarningsToInstalled = sourcePreferences.applyContentWarningsToInstalled.get() enabledContentWarnings: Set<ContentWarning>,
applyContentWarningsToInstalled: Boolean,
alreadyLoaded: Extension.Loaded? = null,
): Extension.Installed {
val pkgManager = context.packageManager val pkgManager = context.packageManager
val pkgInfo = extensionInfo.packageInfo val pkgInfo = extensionInfo.packageInfo
val appInfo = pkgInfo.applicationInfo val appInfo = pkgInfo.applicationInfo
@@ -324,6 +368,15 @@ internal object ExtensionLoader {
return notLoaded(Extension.NotLoaded.Reason.Filtered, libVersion) 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 { val classLoader = try {
DelegateLastClassLoaderCompat(appInfo.sourceDir, null, context.classLoader) DelegateLastClassLoaderCompat(appInfo.sourceDir, null, context.classLoader)
} catch (e: Exception) { } catch (e: Exception) {
@@ -224,10 +224,8 @@ class ExtensionsViewModel(
} }
fun trustExtension(extension: Extension.NotLoaded) { fun trustExtension(extension: Extension.NotLoaded) {
viewModelScope.launch {
extensionManager.trust(extension) extensionManager.trust(extension)
} }
}
@Immutable @Immutable
data class State( data class State(
@@ -79,6 +79,7 @@ import eu.kanade.presentation.util.DefaultNavigatorScreenTransition
import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.download.DownloadCache
import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.api.ExtensionApi import eu.kanade.tachiyomi.extension.api.ExtensionApi
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen 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 extensionApi: ExtensionApi
@Inject private lateinit var extensionManager: ExtensionManager
// To be checked by splash screen. If true then splash screen will be removed. // To be checked by splash screen. If true then splash screen will be removed.
var ready = false var ready = false
@@ -339,7 +342,7 @@ class MainActivity : BaseActivity() {
// Extensions updates // Extensions updates
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { try {
extensionApi.checkForUpdates(context) extensionApi.checkForUpdates(extensionManager.getLoadedExtensions())
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
} }
@@ -270,7 +270,7 @@
<string name="pref_allowed_content_warnings">Allowed content warnings</string> <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">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="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> <string name="relative_time_today">Today</string>