Expose installed extensions that failed to load (#3953)

This commit is contained in:
AntsyLich
2026-09-14 21:34:55 +06:00
committed by GitHub
parent 0e0bc6972a
commit e6d8efe6f8
19 changed files with 455 additions and 239 deletions
@@ -12,7 +12,7 @@ class GetExtensionSources(
private val preferences: SourcePreferences,
) {
fun subscribe(extension: Extension.Installed): Flow<List<ExtensionSourceItem>> {
fun subscribe(extension: Extension.Loaded): Flow<List<ExtensionSourceItem>> {
val isMultiSource = extension.sources.size > 1
val isMultiLangSingleSource =
isMultiSource && extension.sources.map { it.name }.distinct().size == 1
@@ -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<Extension.Installed> { !it.isObsolete }
compareBy<Extension.Loaded> { !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)
}
}
}
@@ -3,8 +3,8 @@ package eu.kanade.domain.extension.model
import eu.kanade.tachiyomi.extension.model.Extension
data class Extensions(
val updates: List<Extension.Installed>,
val installed: List<Extension.Installed>,
val updates: List<Extension.Loaded>,
val loaded: List<Extension.Loaded>,
val available: List<Extension.Available>,
val untrusted: List<Extension.Untrusted>,
val notLoaded: List<Extension.NotLoaded>,
)
@@ -145,7 +145,7 @@ fun ExtensionDetailsScreen(
@Composable
private fun ExtensionDetails(
contentPadding: PaddingValues,
extension: Extension.Installed,
extension: Extension.Loaded,
sources: List<ExtensionSourceItem>,
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(
"""
@@ -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<Extension.Untrusted?>(null) }
var notLoadedState by remember { mutableStateOf<Extension.NotLoaded?>(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,
@@ -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),
@@ -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<String, Drawable>()
private val installedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Installed>())
val installedExtensionsFlow = installedExtensionMapFlow.mapExtensionsWhenInitialized()
private val loadedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Loaded>())
val loadedExtensionsFlow = loadedExtensionMapFlow.mapExtensionsWhenInitialized()
private val availableExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Available>())
val availableExtensionsFlow = availableExtensionMapFlow.mapExtensions(scope)
private val untrustedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Untrusted>())
val untrustedExtensionsFlow = untrustedExtensionMapFlow.mapExtensionsWhenInitialized()
private val notLoadedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.NotLoaded>())
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<Extension.Installed> {
suspend fun getLoadedExtensions(): List<Extension.Loaded> {
initialized.await()
return installedExtensionMapFlow.value.values.toList()
return loadedExtensionMapFlow.value.values.toList()
}
suspend fun getNotLoadedExtensions(): List<Extension.NotLoaded> {
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<String?> {
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<LoadResult.Success>()
.associate { it.extension.pkgName to it.extension }
loadedExtensionMapFlow.value = extensions
.filterIsInstance<Extension.Loaded>()
.associateBy { it.pkgName }
untrustedExtensionMapFlow.value = extensions
.filterIsInstance<LoadResult.Untrusted>()
.associate { it.extension.pkgName to it.extension }
notLoadedExtensionMapFlow.value = extensions
.filterIsInstance<Extension.NotLoaded>()
.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<InstallStep> {
fun updateExtension(extension: Extension.Loaded): Flow<InstallStep> {
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()
@@ -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<LoadResult.Success>()
.map { it.extension }
val loadedExtensions = ExtensionLoader.loadExtensions(context)
.filterIsInstance<Extension.Loaded>()
val extensionsWithUpdate = mutableListOf<Extension.Installed>()
for (installedExt in installedExtensions) {
val extensionsWithUpdate = mutableListOf<Extension.Loaded>()
for (installedExt in loadedExtensions) {
val pkgName = installedExt.pkgName
val availableExt = extensions.find { it.pkgName == pkgName } ?: continue
val hasUpdatedVer = availableExt.versionCode > installedExt.versionCode
@@ -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
}
@@ -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)
}
@@ -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<LoadResult> {
fun loadExtensions(context: Context): List<Extension.Installed> {
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<String>? {
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(": ")
}
@@ -48,7 +48,7 @@ class AndroidSourceManager(
init {
scope.launch {
extensionManager.installedExtensionsFlow
extensionManager.loadedExtensionsFlow
.collectLatest { extensions ->
val mutableMap = ConcurrentHashMap<Long, Source>(
mapOf(LocalSource.ID to localSource),
@@ -32,7 +32,7 @@ fun extensionsTab(
val context = LocalContext.current
val updatesCount by extensionsViewModel.updatesCount.collectAsStateWithLifecycle()
var privateExtensionToUninstall by remember { mutableStateOf<Extension?>(null) }
var privateExtensionToUninstall by remember { mutableStateOf<Extension.Installed?>(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 {
@@ -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<Extension.Installed>()
.filterIsInstance<Extension.Loaded>()
.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)
}
@@ -56,7 +56,7 @@ class ExtensionDetailsViewModel(
fun create(pkgName: String): ExtensionDetailsViewModel
}
val state: StateFlow<State> = extensionManager.installedExtensionsFlow
val state: StateFlow<State> = 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<List<ExtensionSourceItem>> {
private fun subscribeToSources(extension: Extension.Loaded): Flow<List<ExtensionSourceItem>> {
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<ExtensionSourceItem>,
) : State
@@ -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 }
@@ -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)"
}