Reload extensions when relevant settings are updated (#3954)
This commit is contained in:
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-4
@@ -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) }
|
||||
return withIOContext {
|
||||
extPkgs
|
||||
.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)" }
|
||||
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,10 +224,8 @@ class ExtensionsViewModel(
|
||||
}
|
||||
|
||||
fun trustExtension(extension: Extension.NotLoaded) {
|
||||
viewModelScope.launch {
|
||||
extensionManager.trust(extension)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
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.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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user