From e6d8efe6f8fc591826ee6d9a4a046447625e8135 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:34:55 +0600 Subject: [PATCH] Expose installed extensions that failed to load (#3953) --- .../interactor/GetExtensionSources.kt | 2 +- .../interactor/GetExtensionsByType.kt | 18 +- .../domain/extension/model/Extensions.kt | 6 +- .../browse/ExtensionDetailsScreen.kt | 4 +- .../presentation/browse/ExtensionsScreen.kt | 182 ++++++++++++++---- .../browse/components/BrowseIcons.kt | 4 +- .../tachiyomi/extension/ExtensionManager.kt | 107 +++++----- .../tachiyomi/extension/api/ExtensionApi.kt | 10 +- .../tachiyomi/extension/model/LoadResult.kt | 7 - .../util/ExtensionInstallReceiver.kt | 26 ++- .../extension/util/ExtensionLoader.kt | 151 ++++++++++----- .../tachiyomi/source/AndroidSourceManager.kt | 2 +- .../ui/browse/extension/ExtensionsTab.kt | 4 +- .../browse/extension/ExtensionsViewModel.kt | 24 ++- .../details/ExtensionDetailsViewModel.kt | 6 +- .../source/globalsearch/SearchViewModel.kt | 2 +- .../eu/kanade/tachiyomi/util/CrashLogUtil.kt | 31 ++- .../tachiyomi/extension/model/Extension.kt | 97 +++++++--- .../moko-resources/base/strings.xml | 11 +- 19 files changed, 455 insertions(+), 239 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/extension/model/LoadResult.kt diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionSources.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionSources.kt index e1381710e..b3fe0e9a8 100644 --- a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionSources.kt +++ b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionSources.kt @@ -12,7 +12,7 @@ class GetExtensionSources( private val preferences: SourcePreferences, ) { - fun subscribe(extension: Extension.Installed): Flow> { + fun subscribe(extension: Extension.Loaded): Flow> { val isMultiSource = extension.sources.size > 1 val isMultiLangSingleSource = isMultiSource && extension.sources.map { it.name }.distinct().size == 1 diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionsByType.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionsByType.kt index 56624ee87..6e07571fb 100644 --- a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionsByType.kt +++ b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionsByType.kt @@ -19,24 +19,24 @@ class GetExtensionsByType( return combine( preferences.enabledLanguages.changes(), - extensionManager.installedExtensionsFlow, - extensionManager.untrustedExtensionsFlow, + extensionManager.loadedExtensionsFlow, + extensionManager.notLoadedExtensionsFlow, extensionManager.availableExtensionsFlow, - ) { enabledLanguages, _installed, _untrusted, _available -> - val (updates, installed) = _installed + ) { enabledLanguages, _loaded, _notLoaded, _available -> + val (updates, loaded) = _loaded .sortedWith( - compareBy { !it.isObsolete } + compareBy { !it.isObsolete } .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, ) .partition { it.hasUpdate } - val untrusted = _untrusted + val notLoaded = _notLoaded .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) val available = _available .filter { extension -> - _installed.none { it.pkgName == extension.pkgName } && - _untrusted.none { it.pkgName == extension.pkgName } && + _loaded.none { it.pkgName == extension.pkgName } && + _notLoaded.none { it.pkgName == extension.pkgName } && extension.contentWarning in enabledContentWarnings } .flatMap { ext -> @@ -52,7 +52,7 @@ class GetExtensionsByType( } .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) - Extensions(updates, installed, available, untrusted) + Extensions(updates, loaded, available, notLoaded) } } } diff --git a/app/src/main/java/eu/kanade/domain/extension/model/Extensions.kt b/app/src/main/java/eu/kanade/domain/extension/model/Extensions.kt index 2e8dbcd99..502ff008f 100644 --- a/app/src/main/java/eu/kanade/domain/extension/model/Extensions.kt +++ b/app/src/main/java/eu/kanade/domain/extension/model/Extensions.kt @@ -3,8 +3,8 @@ package eu.kanade.domain.extension.model import eu.kanade.tachiyomi.extension.model.Extension data class Extensions( - val updates: List, - val installed: List, + val updates: List, + val loaded: List, val available: List, - val untrusted: List, + val notLoaded: List, ) diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt index 2f58348e1..12504e603 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt @@ -145,7 +145,7 @@ fun ExtensionDetailsScreen( @Composable private fun ExtensionDetails( contentPadding: PaddingValues, - extension: Extension.Installed, + extension: Extension.Loaded, sources: List, incognitoMode: Boolean, onClickSourcePreferences: (sourceId: Long) -> Unit, @@ -239,7 +239,7 @@ private fun DetailsHeader( """.trimIndent(), ) - if (extension is Extension.Installed) { + if (extension is Extension.Loaded) { append("\n\n") appendLine( """ diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt index 904e52f45..258b324cd 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import cafe.adriel.voyager.navigator.LocalNavigator @@ -48,14 +49,17 @@ import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsViewModel import eu.kanade.tachiyomi.util.system.LocaleHelper +import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.rounded.Close import mihon.icons.materialsymbols.rounded.Download +import mihon.icons.materialsymbols.rounded.Info import mihon.icons.materialsymbols.rounded.Public import mihon.icons.materialsymbols.rounded.Refresh import mihon.icons.materialsymbols.rounded.Settings import mihon.icons.materialsymbols.rounded.VerifiedUser +import tachiyomi.core.common.i18n.stringResource import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.FastScrollLazyColumn import tachiyomi.presentation.core.components.material.PullRefresh @@ -78,10 +82,10 @@ fun ExtensionScreen( onClickItemCancel: (Extension) -> Unit, onOpenWebView: (Extension.Available) -> Unit, onInstallExtension: (Extension.Available) -> Unit, - onUninstallExtension: (Extension) -> Unit, - onUpdateExtension: (Extension.Installed) -> Unit, - onTrustExtension: (Extension.Untrusted) -> Unit, - onOpenExtension: (Extension.Installed) -> Unit, + onUninstallExtension: (Extension.Installed) -> Unit, + onUpdateExtension: (Extension.Loaded) -> Unit, + onTrustExtension: (Extension.NotLoaded) -> Unit, + onOpenExtension: (Extension.Loaded) -> Unit, onClickUpdateAll: () -> Unit, onRefresh: () -> Unit, ) { @@ -139,14 +143,14 @@ private fun ExtensionContent( onClickItemCancel: (Extension) -> Unit, onOpenWebView: (Extension.Available) -> Unit, onInstallExtension: (Extension.Available) -> Unit, - onUninstallExtension: (Extension) -> Unit, - onUpdateExtension: (Extension.Installed) -> Unit, - onTrustExtension: (Extension.Untrusted) -> Unit, - onOpenExtension: (Extension.Installed) -> Unit, + onUninstallExtension: (Extension.Installed) -> Unit, + onUpdateExtension: (Extension.Loaded) -> Unit, + onTrustExtension: (Extension.NotLoaded) -> Unit, + onOpenExtension: (Extension.Loaded) -> Unit, onClickUpdateAll: () -> Unit, ) { val context = LocalContext.current - var trustState by remember { mutableStateOf(null) } + var notLoadedState by remember { mutableStateOf(null) } val installGranted = rememberRequestPackageInstallsPermissionState(initialValue = true) FastScrollLazyColumn( @@ -205,8 +209,8 @@ private fun ExtensionContent( contentType = { "item" }, key = { item -> when (item.extension) { - is Extension.Untrusted -> "extension-untrusted-${item.hashCode()}" - is Extension.Installed -> "extension-installed-${item.hashCode()}" + is Extension.NotLoaded -> "extension-not-loaded-${item.hashCode()}" + is Extension.Loaded -> "extension-loaded-${item.hashCode()}" is Extension.Available -> "extension-available-${item.hashCode()}" } }, @@ -217,9 +221,9 @@ private fun ExtensionContent( onClickItem = { when (it) { is Extension.Available -> onInstallExtension(it) - is Extension.Installed -> onOpenExtension(it) - is Extension.Untrusted -> { - trustState = it + is Extension.Loaded -> onOpenExtension(it) + is Extension.NotLoaded -> { + notLoadedState = it } } }, @@ -227,7 +231,7 @@ private fun ExtensionContent( onClickItemSecondaryAction = { when (it) { is Extension.Available -> onOpenWebView(it) - is Extension.Installed -> onOpenExtension(it) + is Extension.Loaded -> onOpenExtension(it) else -> {} } }, @@ -235,15 +239,15 @@ private fun ExtensionContent( onClickItemAction = { when (it) { is Extension.Available -> onInstallExtension(it) - is Extension.Installed -> { + is Extension.Loaded -> { if (it.hasUpdate) { onUpdateExtension(it) } else { onOpenExtension(it) } } - is Extension.Untrusted -> { - trustState = it + is Extension.NotLoaded -> { + notLoadedState = it } } }, @@ -251,20 +255,30 @@ private fun ExtensionContent( } } } - if (trustState != null) { - ExtensionTrustDialog( - onClickConfirm = { - onTrustExtension(trustState!!) - trustState = null - }, - onClickDismiss = { - onUninstallExtension(trustState!!) - trustState = null - }, - onDismissRequest = { - trustState = null - }, - ) + notLoadedState?.let { extension -> + val dismiss = { notLoadedState = null } + if (extension.reason is Extension.NotLoaded.Reason.Untrusted) { + ExtensionTrustDialog( + onClickConfirm = { + onTrustExtension(extension) + dismiss() + }, + onClickDismiss = { + onUninstallExtension(extension) + dismiss() + }, + onDismissRequest = dismiss, + ) + } else { + ExtensionNotLoadedDialog( + reason = extension.reason, + onClickUninstall = { + onUninstallExtension(extension) + dismiss() + }, + onDismissRequest = dismiss, + ) + } } } @@ -353,7 +367,7 @@ private fun ExtensionItemContent( ) { ProvideTextStyle(value = MaterialTheme.typography.bodySmall) { var hasAlreadyShownAnElement by remember { mutableStateOf(false) } - if (extension is Extension.Installed && extension.lang.isNotEmpty()) { + if (extension is Extension.Loaded && extension.lang.isNotEmpty()) { hasAlreadyShownAnElement = true Text( text = LocaleHelper.getSourceDisplayName(extension.lang, LocalContext.current), @@ -370,9 +384,9 @@ private fun ExtensionItemContent( val warnings = listOfNotNull( when { - extension is Extension.Untrusted -> - MR.strings.ext_untrusted to MaterialTheme.colorScheme.error - extension is Extension.Installed && extension.isObsolete -> + extension is Extension.NotLoaded -> + extension.reason.labelRes?.let { it to MaterialTheme.colorScheme.error } + extension is Extension.Loaded && extension.isObsolete -> MR.strings.ext_obsolete to MaterialTheme.colorScheme.error else -> null }, @@ -389,7 +403,7 @@ private fun ExtensionItemContent( ) } - if (extension is Extension.Installed && !extension.isShared) { + if (extension is Extension.Loaded && !extension.isShared) { if (hasAlreadyShownAnElement) DotSeparatorNoSpaceText() Text( text = stringResource(MR.strings.ext_installer_private), @@ -446,7 +460,7 @@ private fun ExtensionItemActions( } installStep == InstallStep.Idle -> { when (extension) { - is Extension.Installed -> { + is Extension.Loaded -> { IconButton(onClick = { onClickItemSecondaryAction(extension) }) { Icon( imageVector = MaterialSymbols.Rounded.Settings, @@ -463,11 +477,20 @@ private fun ExtensionItemActions( } } } - is Extension.Untrusted -> { + is Extension.NotLoaded -> { + val isUntrusted = extension.reason is Extension.NotLoaded.Reason.Untrusted IconButton(onClick = { onClickItemAction(extension) }) { Icon( - imageVector = MaterialSymbols.Rounded.VerifiedUser, - contentDescription = stringResource(MR.strings.ext_trust), + imageVector = if (isUntrusted) { + MaterialSymbols.Rounded.VerifiedUser + } else { + MaterialSymbols.Rounded.Info + }, + contentDescription = if (isUntrusted) { + stringResource(MR.strings.ext_trust) + } else { + stringResource(MR.strings.ext_not_loaded) + }, ) } } @@ -530,6 +553,83 @@ private fun ExtensionHeader( } } +/** + * Only the reasons the user can act on are worth naming in the row; the rest all mean "broken" to + * them and are spelled out in [ExtensionNotLoadedDialog] instead. + */ +private val Extension.NotLoaded.Reason.labelRes: StringResource? + get() = when (this) { + is Extension.NotLoaded.Reason.Untrusted -> MR.strings.ext_untrusted + Extension.NotLoaded.Reason.Filtered -> MR.strings.ext_filtered + // The section header already says these aren't loaded; the dialog says why + Extension.NotLoaded.Reason.Unsigned, + Extension.NotLoaded.Reason.UnsupportedLibVersion, + Extension.NotLoaded.Reason.Malformed, + is Extension.NotLoaded.Reason.Failed, + -> null + } + +private val Extension.NotLoaded.Reason.descriptionRes: StringResource + get() = when (this) { + is Extension.NotLoaded.Reason.Untrusted -> MR.strings.untrusted_extension_message + Extension.NotLoaded.Reason.Filtered -> MR.strings.ext_filtered_message + Extension.NotLoaded.Reason.Unsigned -> MR.strings.ext_unsigned_message + Extension.NotLoaded.Reason.UnsupportedLibVersion -> MR.strings.ext_unsupported_message + Extension.NotLoaded.Reason.Malformed -> MR.strings.ext_malformed_message + is Extension.NotLoaded.Reason.Failed -> MR.strings.ext_load_failed_message + } + +@Composable +private fun ExtensionNotLoadedDialog( + reason: Extension.NotLoaded.Reason, + onClickUninstall: () -> Unit, + onDismissRequest: () -> Unit, +) { + AlertDialog( + title = { + Text(text = stringResource(MR.strings.ext_not_loaded_dialog)) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) { + Text(text = stringResource(reason.descriptionRes)) + + if (reason is Extension.NotLoaded.Reason.Failed) { + Text( + text = reason.message, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + val context = LocalContext.current + TextButton( + onClick = { + context.copyToClipboard( + label = context.stringResource(MR.strings.ext_copy_stacktrace), + content = reason.stackTrace, + ) + }, + contentPadding = PaddingValues(0.dp), + ) { + Text(text = stringResource(MR.strings.ext_copy_stacktrace)) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(MR.strings.action_ok)) + } + }, + dismissButton = { + TextButton(onClick = onClickUninstall) { + Text(text = stringResource(MR.strings.ext_uninstall)) + } + }, + onDismissRequest = onDismissRequest, + ) +} + @Composable private fun ExtensionTrustDialog( onClickConfirm: () -> Unit, diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt index 6cfa99373..81e6dfa47 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt @@ -96,7 +96,7 @@ fun ExtensionIcon( .clip(MaterialTheme.shapes.extraSmall), ) } - is Extension.Installed -> { + is Extension.Loaded -> { val icon by extension.getIcon(density) when (icon) { Result.Loading -> Box(modifier = modifier) @@ -112,7 +112,7 @@ fun ExtensionIcon( ) } } - is Extension.Untrusted -> Image( + is Extension.NotLoaded -> Image( imageVector = MaterialSymbols.Rounded.Dangerous, contentDescription = null, colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error), 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 61a90abcb..4bec91ba8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt @@ -11,7 +11,6 @@ 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 -import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.util.ExtensionInstallReceiver import eu.kanade.tachiyomi.extension.util.ExtensionInstaller import eu.kanade.tachiyomi.extension.util.ExtensionLoader @@ -60,14 +59,14 @@ class ExtensionManager( private val iconMap = mutableMapOf() - private val installedExtensionMapFlow = MutableStateFlow(emptyMap()) - val installedExtensionsFlow = installedExtensionMapFlow.mapExtensionsWhenInitialized() + private val loadedExtensionMapFlow = MutableStateFlow(emptyMap()) + val loadedExtensionsFlow = loadedExtensionMapFlow.mapExtensionsWhenInitialized() private val availableExtensionMapFlow = MutableStateFlow(emptyMap()) val availableExtensionsFlow = availableExtensionMapFlow.mapExtensions(scope) - private val untrustedExtensionMapFlow = MutableStateFlow(emptyMap()) - val untrustedExtensionsFlow = untrustedExtensionMapFlow.mapExtensionsWhenInitialized() + private val notLoadedExtensionMapFlow = MutableStateFlow(emptyMap()) + val notLoadedExtensionsFlow = notLoadedExtensionMapFlow.mapExtensionsWhenInitialized() init { scope.launch(Dispatchers.IO) { @@ -78,20 +77,25 @@ class ExtensionManager( private var subLanguagesEnabledOnFirstRun = preferences.enabledLanguages.isSet() - suspend fun getInstalledExtensions(): List { + suspend fun getLoadedExtensions(): List { initialized.await() - return installedExtensionMapFlow.value.values.toList() + return loadedExtensionMapFlow.value.values.toList() + } + + suspend fun getNotLoadedExtensions(): List { + initialized.await() + return notLoadedExtensionMapFlow.value.values.toList() } suspend fun getExtensionPackage(sourceId: Long): String? { - return getInstalledExtensions().find { extension -> + return getLoadedExtensions().find { extension -> extension.sources.any { it.id == sourceId } } ?.pkgName } fun getExtensionPackageAsFlow(sourceId: Long): Flow { - return installedExtensionsFlow.map { extensions -> + return loadedExtensionsFlow.map { extensions -> extensions.find { extension -> extension.sources.any { it.id == sourceId } } @@ -126,13 +130,13 @@ class ExtensionManager( try { val extensions = ExtensionLoader.loadExtensions(context) - installedExtensionMapFlow.value = extensions - .filterIsInstance() - .associate { it.extension.pkgName to it.extension } + loadedExtensionMapFlow.value = extensions + .filterIsInstance() + .associateBy { it.pkgName } - untrustedExtensionMapFlow.value = extensions - .filterIsInstance() - .associate { it.extension.pkgName to it.extension } + notLoadedExtensionMapFlow.value = extensions + .filterIsInstance() + .associateBy { it.pkgName } initialized.complete(Unit) } catch (e: Throwable) { @@ -202,23 +206,23 @@ class ExtensionManager( return } - val installedExtensionsMap = installedExtensionMapFlow.value.toMutableMap() + val loadedExtensionsMap = loadedExtensionMapFlow.value.toMutableMap() var changed = false - for ((pkgName, extension) in installedExtensionsMap) { + for ((pkgName, extension) in loadedExtensionsMap) { val availableExt = availableExtensions.find { it.pkgName == pkgName } if (availableExt == null && !extension.isObsolete) { - installedExtensionsMap[pkgName] = extension.copy(isObsolete = true) + loadedExtensionsMap[pkgName] = extension.copy(isObsolete = true) changed = true } else if (availableExt != null) { val hasUpdate = extension.updateExists(availableExt) if (extension.hasUpdate != hasUpdate) { - installedExtensionsMap[pkgName] = extension.copy( + loadedExtensionsMap[pkgName] = extension.copy( hasUpdate = hasUpdate, store = availableExt.store, ) } else { - installedExtensionsMap[pkgName] = extension.copy( + loadedExtensionsMap[pkgName] = extension.copy( store = availableExt.store, ) } @@ -226,7 +230,7 @@ class ExtensionManager( } } if (changed) { - installedExtensionMapFlow.value = installedExtensionsMap + loadedExtensionMapFlow.value = loadedExtensionsMap } updatePendingUpdatesCount() } @@ -249,7 +253,7 @@ class ExtensionManager( * * @param extension The extension to be updated. */ - fun updateExtension(extension: Extension.Installed): Flow { + fun updateExtension(extension: Extension.Loaded): Flow { val availableExt = availableExtensionMapFlow.value[extension.pkgName] ?: return emptyFlow() val isUpdateForPrivatelyInstalled = !extension.isShared return installer.downloadAndInstall(availableExt.apkUrl, availableExt, isUpdateForPrivatelyInstalled) @@ -277,7 +281,7 @@ class ExtensionManager( * * @param extension The extension to uninstall. */ - fun uninstallExtension(extension: Extension) { + fun uninstallExtension(extension: Extension.Installed) { installer.uninstallApk(extension.pkgName) } @@ -287,16 +291,19 @@ class ExtensionManager( * * @param extension the extension to trust */ - suspend fun trust(extension: Extension.Untrusted) { - untrustedExtensionMapFlow.value[extension.pkgName] ?: return + suspend fun trust(extension: Extension.NotLoaded) { + val reason = extension.reason as? Extension.NotLoaded.Reason.Untrusted ?: return + notLoadedExtensionMapFlow.value[extension.pkgName] ?: return - trustExtension.trust(extension.pkgName, extension.versionCode, extension.signatureHash) + trustExtension.trust(extension.pkgName, extension.versionCode, reason.signatureHash) - untrustedExtensionMapFlow.value -= extension.pkgName + notLoadedExtensionMapFlow.value -= extension.pkgName - ExtensionLoader.loadExtensionFromPkgName(context, extension.pkgName) - .let { it as? LoadResult.Success } - ?.let { registerNewExtension(it.extension) } + when (val reloaded = ExtensionLoader.loadExtensionFromPkgName(context, extension.pkgName)) { + is Extension.Loaded -> registerExtension(reloaded) + is Extension.NotLoaded -> notLoadedExtensionMapFlow.value += reloaded + null -> {} + } } /** @@ -304,18 +311,8 @@ class ExtensionManager( * * @param extension The extension to be registered. */ - private fun registerNewExtension(extension: Extension.Installed) { - installedExtensionMapFlow.value += extension - } - - /** - * Registers the given updated extension in this and the source managers previously removing - * the outdated ones. - * - * @param extension The extension to be registered. - */ - private fun registerUpdatedExtension(extension: Extension.Installed) { - installedExtensionMapFlow.value += extension + private fun registerExtension(extension: Extension.Loaded) { + loadedExtensionMapFlow.value += extension } /** @@ -325,8 +322,8 @@ class ExtensionManager( * @param pkgName The package name of the uninstalled application. */ private fun unregisterExtension(pkgName: String) { - installedExtensionMapFlow.value -= pkgName - untrustedExtensionMapFlow.value -= pkgName + loadedExtensionMapFlow.value -= pkgName + notLoadedExtensionMapFlow.value -= pkgName } /** @@ -334,19 +331,15 @@ class ExtensionManager( */ private inner class InstallationListener : ExtensionInstallReceiver.Listener { - override fun onExtensionInstalled(extension: Extension.Installed) { - registerNewExtension(extension.withUpdateCheck()) + override fun onExtensionLoaded(extension: Extension.Loaded) { + registerExtension(extension.withUpdateCheck()) + notLoadedExtensionMapFlow.value -= extension.pkgName updatePendingUpdatesCount() } - override fun onExtensionUpdated(extension: Extension.Installed) { - registerUpdatedExtension(extension.withUpdateCheck()) - updatePendingUpdatesCount() - } - - override fun onExtensionUntrusted(extension: Extension.Untrusted) { - installedExtensionMapFlow.value -= extension.pkgName - untrustedExtensionMapFlow.value += extension + override fun onExtensionNotLoaded(extension: Extension.NotLoaded) { + loadedExtensionMapFlow.value -= extension.pkgName + notLoadedExtensionMapFlow.value += extension updatePendingUpdatesCount() } @@ -360,7 +353,7 @@ class ExtensionManager( /** * Extension method to set the update field of an installed extension. */ - private fun Extension.Installed.withUpdateCheck(): Extension.Installed { + private fun Extension.Loaded.withUpdateCheck(): Extension.Loaded { return if (updateExists()) { copy(hasUpdate = true) } else { @@ -368,7 +361,7 @@ class ExtensionManager( } } - private fun Extension.Installed.updateExists(availableExtension: Extension.Available? = null): Boolean { + private fun Extension.Loaded.updateExists(availableExtension: Extension.Available? = null): Boolean { val availableExt = availableExtension ?: availableExtensionMapFlow.value[pkgName] ?: return false @@ -377,7 +370,7 @@ class ExtensionManager( } private fun updatePendingUpdatesCount() { - val pendingUpdateCount = installedExtensionMapFlow.value.values.count { it.hasUpdate } + val pendingUpdateCount = loadedExtensionMapFlow.value.values.count { it.hasUpdate } preferences.extensionUpdatesCount.set(pendingUpdateCount) if (pendingUpdateCount == 0) { extensionUpdateNotifier.dismiss() 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 83e2b3f02..35dde4bc1 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 @@ -5,7 +5,6 @@ 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.model.LoadResult import eu.kanade.tachiyomi.extension.util.ExtensionLoader import mihon.domain.extension.interactor.UpdateExtensionStores import mihon.domain.extension.repository.ExtensionStoreRepository @@ -28,12 +27,11 @@ class ExtensionApi( val extensions = findExtensions() - val installedExtensions = ExtensionLoader.loadExtensions(context) - .filterIsInstance() - .map { it.extension } + val loadedExtensions = ExtensionLoader.loadExtensions(context) + .filterIsInstance() - val extensionsWithUpdate = mutableListOf() - for (installedExt in installedExtensions) { + val extensionsWithUpdate = mutableListOf() + for (installedExt in loadedExtensions) { val pkgName = installedExt.pkgName val availableExt = extensions.find { it.pkgName == pkgName } ?: continue val hasUpdatedVer = availableExt.versionCode > installedExt.versionCode diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/LoadResult.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/LoadResult.kt deleted file mode 100644 index 98da1710e..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/model/LoadResult.kt +++ /dev/null @@ -1,7 +0,0 @@ -package eu.kanade.tachiyomi.extension.model - -sealed interface LoadResult { - data class Success(val extension: Extension.Installed) : LoadResult - data class Untrusted(val extension: Extension.Untrusted) : LoadResult - data object Error : LoadResult -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt index ff8acf43c..5a6604c01 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt @@ -8,7 +8,6 @@ import androidx.core.content.ContextCompat import androidx.core.net.toUri import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.extension.model.Extension -import eu.kanade.tachiyomi.extension.model.LoadResult import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch @@ -51,19 +50,19 @@ internal class ExtensionInstallReceiver(private val listener: Listener) : Broadc if (isReplacing(intent)) return scope.launch { - when (val result = getExtensionFromIntent(context, intent)) { - is LoadResult.Success -> listener.onExtensionInstalled(result.extension) - is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension) - else -> {} + when (val extension = getExtensionFromIntent(context, intent)) { + is Extension.Loaded -> listener.onExtensionLoaded(extension) + is Extension.NotLoaded -> listener.onExtensionNotLoaded(extension) + null -> {} } } } Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> { scope.launch { - when (val result = getExtensionFromIntent(context, intent)) { - is LoadResult.Success -> listener.onExtensionUpdated(result.extension) - is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension) - else -> {} + when (val extension = getExtensionFromIntent(context, intent)) { + is Extension.Loaded -> listener.onExtensionLoaded(extension) + is Extension.NotLoaded -> listener.onExtensionNotLoaded(extension) + null -> {} } } } @@ -93,11 +92,11 @@ internal class ExtensionInstallReceiver(private val listener: Listener) : Broadc * @param context The application context. * @param intent The intent containing the package name of the extension. */ - private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult { + private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): Extension.Installed? { val pkgName = getPackageNameFromIntent(intent) if (pkgName == null) { logcat(LogPriority.WARN) { "Package name not found" } - return LoadResult.Error + return null } return ExtensionLoader.loadExtensionFromPkgName(context, pkgName) } @@ -113,9 +112,8 @@ internal class ExtensionInstallReceiver(private val listener: Listener) : Broadc * Listener that receives extension installation events. */ interface Listener { - fun onExtensionInstalled(extension: Extension.Installed) - fun onExtensionUpdated(extension: Extension.Installed) - fun onExtensionUntrusted(extension: Extension.Untrusted) + fun onExtensionLoaded(extension: Extension.Loaded) + fun onExtensionNotLoaded(extension: Extension.NotLoaded) fun onPackageUninstalled(pkgName: String) } 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 a9276566a..a16025893 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 @@ -8,7 +8,6 @@ import android.os.Build import androidx.core.content.pm.PackageInfoCompat import eu.kanade.domain.extension.interactor.TrustExtension import eu.kanade.tachiyomi.extension.model.Extension -import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.util.lang.Hash @@ -112,7 +111,7 @@ internal object ExtensionLoader { * * @param context The application context. */ - fun loadExtensions(context: Context): List { + fun loadExtensions(context: Context): List { val pkgManager = context.packageManager val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -138,7 +137,7 @@ internal object ExtensionLoader { val path = it.absolutePath pkgManager.getPackageArchiveInfo(path, PACKAGE_FLAGS) - ?.apply { applicationInfo!!.fixBasePaths(path) } + ?.also { pkg -> pkg.applicationInfo?.fixBasePaths(path) } } ?.filter { isPackageAnExtension(it) } ?.map { ExtensionInfo(packageInfo = it, isShared = false) } @@ -160,7 +159,7 @@ internal object ExtensionLoader { // Load each extension concurrently and wait for completion return runBlocking(Dispatchers.IO) { val deferred = extPkgs.map { - async { loadExtension(context, it) } + async { loadExtensionCatching(context, it) } } deferred.awaitAll() } @@ -170,13 +169,13 @@ internal object ExtensionLoader { * Attempts to load an extension from the given package name. It checks if the extension * contains the required feature flag before trying to load it. */ - suspend fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult { + suspend fun loadExtensionFromPkgName(context: Context, pkgName: String): Extension.Installed? { val extensionPackage = getExtensionInfoFromPkgName(context, pkgName) if (extensionPackage == null) { logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" } - return LoadResult.Error + return null } - return loadExtension(context, extensionPackage) + return loadExtensionCatching(context, extensionPackage) } fun getExtensionPackageInfoFromPkgName(context: Context, pkgName: String): PackageInfo? { @@ -189,7 +188,7 @@ internal object ExtensionLoader { context.packageManager.getPackageArchiveInfo(privateExtensionFile.absolutePath, PACKAGE_FLAGS) ?.takeIf { isPackageAnExtension(it) } ?.let { - it.applicationInfo!!.fixBasePaths(privateExtensionFile.absolutePath) + it.applicationInfo?.fixBasePaths(privateExtensionFile.absolutePath) ExtensionInfo( packageInfo = it, isShared = false, @@ -215,13 +214,36 @@ internal object ExtensionLoader { return selectExtensionPackage(sharedPkg, privatePkg) } + /** + * [loadExtension] reports the failures it knows how to name, but an apk can be malformed in + * 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 { + return try { + loadExtension(context, extensionInfo) + } catch (e: Throwable) { + val pkgInfo = extensionInfo.packageInfo + logcat(LogPriority.ERROR, e) { "Extension load error: ${pkgInfo.packageName}" } + Extension.NotLoaded( + name = pkgInfo.packageName, + pkgName = pkgInfo.packageName, + versionName = pkgInfo.versionName.orEmpty(), + versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo), + isShared = extensionInfo.isShared, + contentWarning = ContentWarning.SAFE, + reason = Extension.NotLoaded.Reason.Failed(e.rootMessage, e.stackTraceToString()), + ) + } + } + /** * Loads an extension * * @param context The application context. * @param extensionInfo The extension to load. */ - private suspend fun loadExtension(context: Context, extensionInfo: ExtensionInfo): LoadResult { + 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() @@ -229,21 +251,54 @@ internal object ExtensionLoader { val pkgManager = context.packageManager val pkgInfo = extensionInfo.packageInfo - val appInfo = pkgInfo.applicationInfo!! + val appInfo = pkgInfo.applicationInfo + val metaData = appInfo?.metaData val pkgName = pkgInfo.packageName - val extName = appInfo.metaData.getString(METADATA_NAME) - ?: pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ") + val extName = metaData?.getString(METADATA_NAME) + ?: appInfo?.let { pkgManager.getApplicationLabel(it).toString().substringAfter("Tachiyomi: ") } + ?: pkgName val versionName = pkgInfo.versionName val versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo) + val contentWarning = when { + metaData == null -> ContentWarning.SAFE + metaData.containsKey(METADATA_CONTENT_WARNING) -> { + when (metaData.getInt(METADATA_CONTENT_WARNING)) { + 1 -> ContentWarning.MIXED + 2 -> ContentWarning.NSFW + else -> ContentWarning.SAFE + } + } + metaData.getInt(METADATA_NSFW) == 1 -> ContentWarning.NSFW + else -> ContentWarning.SAFE + } + + fun notLoaded( + reason: Extension.NotLoaded.Reason, + libVersion: Double? = null, + ) = Extension.NotLoaded( + name = extName, + pkgName = pkgName, + versionName = versionName.orEmpty(), + versionCode = versionCode, + isShared = extensionInfo.isShared, + contentWarning = contentWarning, + libVersion = libVersion, + reason = reason, + ) + + if (appInfo == null || metaData == null) { + logcat(LogPriority.WARN) { "Missing application info for extension $extName" } + return notLoaded(Extension.NotLoaded.Reason.Malformed) + } if (versionName.isNullOrEmpty()) { logcat(LogPriority.WARN) { "Missing versionName for extension $extName" } - return LoadResult.Error + return notLoaded(Extension.NotLoaded.Reason.Malformed) } // Validate lib version - val libVersion = appInfo.metaData.getFloat(METADATA_EXTENSION_LIB) + val libVersion = metaData.getFloat(METADATA_EXTENSION_LIB) .takeUnless { it == 0.0f } ?.toString() ?.toDouble() @@ -252,50 +307,37 @@ internal object ExtensionLoader { logcat(LogPriority.WARN) { "Lib version is $libVersion, while only version(s) ${SUPPORTED_LIB_VERSIONS.joinToString()} are supported" } - return LoadResult.Error + return notLoaded(Extension.NotLoaded.Reason.UnsupportedLibVersion, libVersion) } val signatures = getSignatures(pkgInfo) if (signatures.isNullOrEmpty()) { logcat(LogPriority.WARN) { "Package $pkgName isn't signed" } - return LoadResult.Error + return notLoaded(Extension.NotLoaded.Reason.Unsigned, libVersion) } else if (!trustExtension.isTrusted(pkgInfo, signatures)) { - val extension = Extension.Untrusted( - extName, - pkgName, - versionName, - versionCode, - libVersion, - signatures.last(), - ) logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" } - return LoadResult.Untrusted(extension) + return notLoaded(Extension.NotLoaded.Reason.Untrusted(signatures.last()), libVersion) } - val contentWarning = when { - appInfo.metaData.containsKey(METADATA_CONTENT_WARNING) -> { - when (appInfo.metaData.getInt(METADATA_CONTENT_WARNING)) { - 1 -> ContentWarning.MIXED - 2 -> ContentWarning.NSFW - else -> ContentWarning.SAFE - } - } - appInfo.metaData.getInt(METADATA_NSFW) == 1 -> ContentWarning.NSFW - else -> ContentWarning.SAFE - } if (applyContentWarningsToInstalled && contentWarning !in enabledContentWarnings) { logcat(LogPriority.WARN) { "Extension $pkgName with $contentWarning not allowed" } - return LoadResult.Error + return notLoaded(Extension.NotLoaded.Reason.Filtered, libVersion) } val classLoader = try { DelegateLastClassLoaderCompat(appInfo.sourceDir, null, context.classLoader) } catch (e: Exception) { logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($pkgName)" } - return LoadResult.Error + return notLoaded(Extension.NotLoaded.Reason.Failed(e.rootMessage, e.stackTraceToString()), libVersion) } - val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)!! + val sourceClasses = metaData.getString(METADATA_SOURCE_CLASS) + if (sourceClasses.isNullOrBlank()) { + logcat(LogPriority.WARN) { "Missing source class for extension $extName" } + return notLoaded(Extension.NotLoaded.Reason.Malformed, libVersion) + } + + val sources = sourceClasses .split(";") .map { val sourceClass = it.trim() @@ -314,7 +356,10 @@ internal object ExtensionLoader { } } catch (e: Throwable) { logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($it)" } - return LoadResult.Error + return notLoaded( + Extension.NotLoaded.Reason.Failed(e.rootMessage, e.stackTraceToString()), + libVersion, + ) } } @@ -325,7 +370,7 @@ internal object ExtensionLoader { else -> "all" } - val extension = Extension.Installed( + return Extension.Loaded( name = extName, pkgName = pkgName, versionName = versionName, @@ -334,11 +379,10 @@ internal object ExtensionLoader { lang = lang, contentWarning = contentWarning, sources = sources, - pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY), - icon = appInfo.loadIcon(pkgManager), + pkgFactory = metaData.getString(METADATA_SOURCE_FACTORY), + icon = runCatching { appInfo.loadIcon(pkgManager) }.getOrNull(), isShared = extensionInfo.isShared, ) - return LoadResult.Success(extension) } /** @@ -380,11 +424,11 @@ internal object ExtensionLoader { */ private fun getSignatures(pkgInfo: PackageInfo): List? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val signingInfo = pkgInfo.signingInfo!! - if (signingInfo.hasMultipleSigners()) { - signingInfo.apkContentsSigners - } else { - signingInfo.signingCertificateHistory + val signingInfo = pkgInfo.signingInfo + when { + signingInfo == null -> null + signingInfo.hasMultipleSigners() -> signingInfo.apkContentsSigners + else -> signingInfo.signingCertificateHistory } } else { @Suppress("DEPRECATION") @@ -412,3 +456,12 @@ internal object ExtensionLoader { val isShared: Boolean, ) } + +/** + * The message of the deepest cause, which is the one that actually says what went wrong. + */ +private val Throwable.rootMessage: String + get() { + val root = generateSequence(this) { it.cause }.last() + return listOfNotNull(root::class.simpleName, root.message).joinToString(": ") + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt index 06e5ad010..d8b93b323 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt @@ -48,7 +48,7 @@ class AndroidSourceManager( init { scope.launch { - extensionManager.installedExtensionsFlow + extensionManager.loadedExtensionsFlow .collectLatest { extensions -> val mutableMap = ConcurrentHashMap( mapOf(LocalSource.ID to localSource), diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsTab.kt index 7d556799a..07a428191 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsTab.kt @@ -32,7 +32,7 @@ fun extensionsTab( val context = LocalContext.current val updatesCount by extensionsViewModel.updatesCount.collectAsStateWithLifecycle() - var privateExtensionToUninstall by remember { mutableStateOf(null) } + var privateExtensionToUninstall by remember { mutableStateOf(null) } return TabContent( titleRes = MR.strings.label_extensions, @@ -62,7 +62,7 @@ fun extensionsTab( onLongClickItem = { extension -> when (extension) { is Extension.Available -> extensionsViewModel.installExtension(extension) - else -> { + is Extension.Installed -> { if (context.isPackageInstalled(extension.pkgName)) { extensionsViewModel.uninstallExtension(extension) } else { 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 a7eb35c2f..5c30321d9 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 @@ -74,17 +74,21 @@ class ExtensionsViewModel( .map { searchQueryPredicate(it ?: "") }, currentDownloads, getExtensions.subscribe(), - ) { predicate, downloads, (_updates, _installed, _available, _untrusted) -> + ) { predicate, downloads, (_updates, _loaded, _available, _notLoaded) -> buildMap { val updates = _updates.filter(predicate).map(extensionMapper(downloads)) if (updates.isNotEmpty()) { put(ExtensionUiModel.Header.Resource(MR.strings.ext_updates_pending), updates) } - val installed = _installed.filter(predicate).map(extensionMapper(downloads)) - val untrusted = _untrusted.filter(predicate).map(extensionMapper(downloads)) - if (installed.isNotEmpty() || untrusted.isNotEmpty()) { - put(ExtensionUiModel.Header.Resource(MR.strings.ext_installed), installed + untrusted) + val notLoaded = _notLoaded.filter(predicate).map(extensionMapper(downloads)) + if (notLoaded.isNotEmpty()) { + put(ExtensionUiModel.Header.Resource(MR.strings.ext_not_loaded), notLoaded) + } + + val loaded = _loaded.filter(predicate).map(extensionMapper(downloads)) + if (loaded.isNotEmpty()) { + put(ExtensionUiModel.Header.Resource(MR.strings.ext_installed), loaded) } val languagesWithExtensions = _available @@ -138,7 +142,7 @@ class ExtensionsViewModel( if (extension.name.contains(subquery, ignoreCase = true)) return@any true when (extension) { - is Extension.Installed -> extension.sources.any { source -> + is Extension.Loaded -> extension.sources.any { source -> source.name.contains(subquery, ignoreCase = true) || (source as? HttpSource)?.getHomeUrl()?.contains(subquery, ignoreCase = true) == true || source.id == subquery.toLongOrNull() @@ -164,7 +168,7 @@ class ExtensionsViewModel( viewModelScope.launchIO { state.value.items.values.flatten() .map { it.extension } - .filterIsInstance() + .filterIsInstance() .filter { it.hasUpdate } .forEach(::updateExtension) } @@ -176,7 +180,7 @@ class ExtensionsViewModel( } } - fun updateExtension(extension: Extension.Installed) { + fun updateExtension(extension: Extension.Loaded) { viewModelScope.launchIO { extensionManager.updateExtension(extension).collectToInstallUpdate(extension) } @@ -202,7 +206,7 @@ class ExtensionsViewModel( .onCompletion { removeDownloadState(extension) } .collect() - fun uninstallExtension(extension: Extension) { + fun uninstallExtension(extension: Extension.Installed) { extensionManager.uninstallExtension(extension) } @@ -219,7 +223,7 @@ class ExtensionsViewModel( } } - fun trustExtension(extension: Extension.Untrusted) { + fun trustExtension(extension: Extension.NotLoaded) { viewModelScope.launch { extensionManager.trust(extension) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsViewModel.kt index 89e59a857..f416b059e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsViewModel.kt @@ -56,7 +56,7 @@ class ExtensionDetailsViewModel( fun create(pkgName: String): ExtensionDetailsViewModel } - val state: StateFlow = extensionManager.installedExtensionsFlow + val state: StateFlow = extensionManager.loadedExtensionsFlow .map { it.firstOrNull { extension -> extension.pkgName == pkgName } } .distinctUntilChanged() .flatMapLatest { extension -> @@ -73,7 +73,7 @@ class ExtensionDetailsViewModel( private val successState: State.Success? get() = state.value as? State.Success - private fun subscribeToSources(extension: Extension.Installed): Flow> { + private fun subscribeToSources(extension: Extension.Loaded): Flow> { return getExtensionSources.subscribe(extension) .map { it.sortedWith( @@ -142,7 +142,7 @@ class ExtensionDetailsViewModel( @Immutable data class Success( - val extension: Extension.Installed, + val extension: Extension.Loaded, val isIncognito: Boolean, val sources: List, ) : State diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchViewModel.kt index 2896f9c5b..de2a7fcac 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchViewModel.kt @@ -106,7 +106,7 @@ abstract class SearchViewModel( return enabledSources } - return extensionManager.getInstalledExtensions() + return extensionManager.getLoadedExtensions() .filter { it.pkgName == filter } .flatMap { it.sources } .filter { it in enabledSources } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt index 3d3858e43..256394789 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt @@ -6,6 +6,7 @@ import dev.zacsweers.metro.Inject import eu.kanade.domain.base.BasePreferences import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.network.NetworkPreferences import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.system.WebViewUtil @@ -65,7 +66,7 @@ class CrashLogUtil( private suspend fun getExtensionsInfo(): String? { val availableExtensions = extensionManager.availableExtensionsFlow.value.associateBy { it.pkgName } - val extensionInfoList = extensionManager.getInstalledExtensions() + val outdatedInfoList = extensionManager.getLoadedExtensions() .sortedBy { it.name } .mapNotNull { val availableExtension = availableExtensions[it.pkgName] @@ -80,6 +81,24 @@ class CrashLogUtil( """.trimIndent() } + val notLoadedInfoList = extensionManager.getNotLoadedExtensions() + .sortedBy { it.name } + .map { extension -> + buildString { + appendLine("- ${extension.name}") + appendLine(" Installed: ${extension.versionName} (lib ${extension.libVersion ?: "?"})") + append(" Not loaded: ${extension.reason.description}") + + val reason = extension.reason + if (reason is Extension.NotLoaded.Reason.Failed) { + appendLine() + append(reason.stackTrace.trimEnd().prependIndent(" ")) + } + } + } + + val extensionInfoList = outdatedInfoList + notLoadedInfoList + return if (extensionInfoList.isNotEmpty()) { (listOf("Problematic extensions:") + extensionInfoList) .joinToString("\n") @@ -88,3 +107,13 @@ class CrashLogUtil( } } } + +private val Extension.NotLoaded.Reason.description: String + get() = when (this) { + is Extension.NotLoaded.Reason.Untrusted -> "Untrusted" + Extension.NotLoaded.Reason.Filtered -> "Filtered by content warning" + Extension.NotLoaded.Reason.Unsigned -> "Unsigned" + Extension.NotLoaded.Reason.UnsupportedLibVersion -> "Unsupported lib version" + Extension.NotLoaded.Reason.Malformed -> "Malformed" + is Extension.NotLoaded.Reason.Failed -> "Failed ($message)" + } diff --git a/domain/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt b/domain/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt index a057ec1b3..92262c9c7 100644 --- a/domain/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt +++ b/domain/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt @@ -6,33 +6,26 @@ import mihon.domain.extension.model.ContentWarning import mihon.domain.extension.model.ExtensionStore import tachiyomi.domain.source.model.StubSource -sealed class Extension { +sealed interface Extension { - abstract val name: String - abstract val pkgName: String - abstract val versionName: String - abstract val versionCode: Long - abstract val libVersion: Double - abstract val lang: String? - abstract val contentWarning: ContentWarning + val name: String + val pkgName: String + val versionName: String + val versionCode: Long + val libVersion: Double? + val lang: String? + val contentWarning: ContentWarning - data class Installed( - override val name: String, - override val pkgName: String, - override val versionName: String, - override val versionCode: Long, - override val libVersion: Double, - override val lang: String, - override val contentWarning: ContentWarning, - val pkgFactory: String?, - val sources: List, - val icon: Drawable?, - val hasUpdate: Boolean = false, - val isObsolete: Boolean = false, - val isShared: Boolean, - val store: ExtensionStore? = null, - ) : Extension() + /** + * An extension whose apk is on the device, whether or not it ended up being loaded. + */ + sealed interface Installed : Extension { + val isShared: Boolean + } + /** + * An extension that isn't on the device yet, as listed by an [ExtensionStore]. + */ data class Available( override val name: String, override val pkgName: String, @@ -45,7 +38,7 @@ sealed class Extension { val apkUrl: String, val iconUrl: String, val store: ExtensionStore, - ) : Extension() { + ) : Extension { data class Source( val id: Long, @@ -63,14 +56,60 @@ sealed class Extension { } } - data class Untrusted( + /** + * An installed extension whose sources are registered and usable. + */ + data class Loaded( override val name: String, override val pkgName: String, override val versionName: String, override val versionCode: Long, override val libVersion: Double, - val signatureHash: String, + override val lang: String, + override val contentWarning: ContentWarning, + override val isShared: Boolean, + val pkgFactory: String?, + val sources: List, + val icon: Drawable?, + val hasUpdate: Boolean = false, + val isObsolete: Boolean = false, + val store: ExtensionStore? = null, + ) : Installed + + /** + * An installed extension that was never loaded, so it provides no sources. [lang] is derived + * from the sources and [libVersion] from metadata, so neither is always known here. + */ + data class NotLoaded( + override val name: String, + override val pkgName: String, + override val versionName: String, + override val versionCode: Long, + override val isShared: Boolean, + override val contentWarning: ContentWarning, + override val libVersion: Double? = null, override val lang: String? = null, - override val contentWarning: ContentWarning = ContentWarning.SAFE, - ) : Extension() + val reason: Reason, + ) : Installed { + + sealed interface Reason { + /** Signature isn't trusted yet. Resolvable by the user accepting it. */ + data class Untrusted(val signatureHash: String) : Reason + + /** Its [contentWarning] isn't one the user chose to load. */ + data object Filtered : Reason + + /** No signature to check against at all. */ + data object Unsigned : Reason + + /** Built against an extension lib this app version can't run. */ + data object UnsupportedLibVersion : Reason + + /** Required package metadata is missing. */ + data object Malformed : Reason + + /** Threw while its classes or sources were being instantiated. */ + data class Failed(val message: String, val stackTrace: String) : Reason + } + } } diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 452a4c6b8..0a3e838e4 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. Extensions that aren\'t loaded won\'t provide their sources anywhere in the app. This does not prevent unofficial or potentially incorrectly flagged extensions from surfacing 18+ content within the app. + 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. Today @@ -340,6 +340,15 @@ Installed Trust Untrusted + Filtered + Not loaded + Couldn\'t load + Copy stack trace + This extension\'s content warning isn\'t one you chose to load, so its sources aren\'t available. + This extension isn\'t signed, so there\'s no signature to check it against. + This extension was built for a version of the extension library this app version can\'t load. Updating the app or the extension may help. + This extension is missing information the app needs to load it. + This extension threw an error while loading, so none of its sources are available. Uninstall Remove Remove Extension?