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, 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 isMultiSource = extension.sources.size > 1
val isMultiLangSingleSource = val isMultiLangSingleSource =
isMultiSource && extension.sources.map { it.name }.distinct().size == 1 isMultiSource && extension.sources.map { it.name }.distinct().size == 1
@@ -19,24 +19,24 @@ class GetExtensionsByType(
return combine( return combine(
preferences.enabledLanguages.changes(), preferences.enabledLanguages.changes(),
extensionManager.installedExtensionsFlow, extensionManager.loadedExtensionsFlow,
extensionManager.untrustedExtensionsFlow, extensionManager.notLoadedExtensionsFlow,
extensionManager.availableExtensionsFlow, extensionManager.availableExtensionsFlow,
) { enabledLanguages, _installed, _untrusted, _available -> ) { enabledLanguages, _loaded, _notLoaded, _available ->
val (updates, installed) = _installed val (updates, loaded) = _loaded
.sortedWith( .sortedWith(
compareBy<Extension.Installed> { !it.isObsolete } compareBy<Extension.Loaded> { !it.isObsolete }
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
) )
.partition { it.hasUpdate } .partition { it.hasUpdate }
val untrusted = _untrusted val notLoaded = _notLoaded
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
val available = _available val available = _available
.filter { extension -> .filter { extension ->
_installed.none { it.pkgName == extension.pkgName } && _loaded.none { it.pkgName == extension.pkgName } &&
_untrusted.none { it.pkgName == extension.pkgName } && _notLoaded.none { it.pkgName == extension.pkgName } &&
extension.contentWarning in enabledContentWarnings extension.contentWarning in enabledContentWarnings
} }
.flatMap { ext -> .flatMap { ext ->
@@ -52,7 +52,7 @@ class GetExtensionsByType(
} }
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) .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 import eu.kanade.tachiyomi.extension.model.Extension
data class Extensions( data class Extensions(
val updates: List<Extension.Installed>, val updates: List<Extension.Loaded>,
val installed: List<Extension.Installed>, val loaded: List<Extension.Loaded>,
val available: List<Extension.Available>, val available: List<Extension.Available>,
val untrusted: List<Extension.Untrusted>, val notLoaded: List<Extension.NotLoaded>,
) )
@@ -145,7 +145,7 @@ fun ExtensionDetailsScreen(
@Composable @Composable
private fun ExtensionDetails( private fun ExtensionDetails(
contentPadding: PaddingValues, contentPadding: PaddingValues,
extension: Extension.Installed, extension: Extension.Loaded,
sources: List<ExtensionSourceItem>, sources: List<ExtensionSourceItem>,
incognitoMode: Boolean, incognitoMode: Boolean,
onClickSourcePreferences: (sourceId: Long) -> Unit, onClickSourcePreferences: (sourceId: Long) -> Unit,
@@ -239,7 +239,7 @@ private fun DetailsHeader(
""".trimIndent(), """.trimIndent(),
) )
if (extension is Extension.Installed) { if (extension is Extension.Loaded) {
append("\n\n") append("\n\n")
appendLine( appendLine(
""" """
@@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.navigator.LocalNavigator 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.ExtensionUiModel
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsViewModel import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsViewModel
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.Close import mihon.icons.materialsymbols.rounded.Close
import mihon.icons.materialsymbols.rounded.Download import mihon.icons.materialsymbols.rounded.Download
import mihon.icons.materialsymbols.rounded.Info
import mihon.icons.materialsymbols.rounded.Public import mihon.icons.materialsymbols.rounded.Public
import mihon.icons.materialsymbols.rounded.Refresh import mihon.icons.materialsymbols.rounded.Refresh
import mihon.icons.materialsymbols.rounded.Settings import mihon.icons.materialsymbols.rounded.Settings
import mihon.icons.materialsymbols.rounded.VerifiedUser import mihon.icons.materialsymbols.rounded.VerifiedUser
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.FastScrollLazyColumn import tachiyomi.presentation.core.components.FastScrollLazyColumn
import tachiyomi.presentation.core.components.material.PullRefresh import tachiyomi.presentation.core.components.material.PullRefresh
@@ -78,10 +82,10 @@ fun ExtensionScreen(
onClickItemCancel: (Extension) -> Unit, onClickItemCancel: (Extension) -> Unit,
onOpenWebView: (Extension.Available) -> Unit, onOpenWebView: (Extension.Available) -> Unit,
onInstallExtension: (Extension.Available) -> Unit, onInstallExtension: (Extension.Available) -> Unit,
onUninstallExtension: (Extension) -> Unit, onUninstallExtension: (Extension.Installed) -> Unit,
onUpdateExtension: (Extension.Installed) -> Unit, onUpdateExtension: (Extension.Loaded) -> Unit,
onTrustExtension: (Extension.Untrusted) -> Unit, onTrustExtension: (Extension.NotLoaded) -> Unit,
onOpenExtension: (Extension.Installed) -> Unit, onOpenExtension: (Extension.Loaded) -> Unit,
onClickUpdateAll: () -> Unit, onClickUpdateAll: () -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
) { ) {
@@ -139,14 +143,14 @@ private fun ExtensionContent(
onClickItemCancel: (Extension) -> Unit, onClickItemCancel: (Extension) -> Unit,
onOpenWebView: (Extension.Available) -> Unit, onOpenWebView: (Extension.Available) -> Unit,
onInstallExtension: (Extension.Available) -> Unit, onInstallExtension: (Extension.Available) -> Unit,
onUninstallExtension: (Extension) -> Unit, onUninstallExtension: (Extension.Installed) -> Unit,
onUpdateExtension: (Extension.Installed) -> Unit, onUpdateExtension: (Extension.Loaded) -> Unit,
onTrustExtension: (Extension.Untrusted) -> Unit, onTrustExtension: (Extension.NotLoaded) -> Unit,
onOpenExtension: (Extension.Installed) -> Unit, onOpenExtension: (Extension.Loaded) -> Unit,
onClickUpdateAll: () -> Unit, onClickUpdateAll: () -> Unit,
) { ) {
val context = LocalContext.current 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) val installGranted = rememberRequestPackageInstallsPermissionState(initialValue = true)
FastScrollLazyColumn( FastScrollLazyColumn(
@@ -205,8 +209,8 @@ private fun ExtensionContent(
contentType = { "item" }, contentType = { "item" },
key = { item -> key = { item ->
when (item.extension) { when (item.extension) {
is Extension.Untrusted -> "extension-untrusted-${item.hashCode()}" is Extension.NotLoaded -> "extension-not-loaded-${item.hashCode()}"
is Extension.Installed -> "extension-installed-${item.hashCode()}" is Extension.Loaded -> "extension-loaded-${item.hashCode()}"
is Extension.Available -> "extension-available-${item.hashCode()}" is Extension.Available -> "extension-available-${item.hashCode()}"
} }
}, },
@@ -217,9 +221,9 @@ private fun ExtensionContent(
onClickItem = { onClickItem = {
when (it) { when (it) {
is Extension.Available -> onInstallExtension(it) is Extension.Available -> onInstallExtension(it)
is Extension.Installed -> onOpenExtension(it) is Extension.Loaded -> onOpenExtension(it)
is Extension.Untrusted -> { is Extension.NotLoaded -> {
trustState = it notLoadedState = it
} }
} }
}, },
@@ -227,7 +231,7 @@ private fun ExtensionContent(
onClickItemSecondaryAction = { onClickItemSecondaryAction = {
when (it) { when (it) {
is Extension.Available -> onOpenWebView(it) is Extension.Available -> onOpenWebView(it)
is Extension.Installed -> onOpenExtension(it) is Extension.Loaded -> onOpenExtension(it)
else -> {} else -> {}
} }
}, },
@@ -235,15 +239,15 @@ private fun ExtensionContent(
onClickItemAction = { onClickItemAction = {
when (it) { when (it) {
is Extension.Available -> onInstallExtension(it) is Extension.Available -> onInstallExtension(it)
is Extension.Installed -> { is Extension.Loaded -> {
if (it.hasUpdate) { if (it.hasUpdate) {
onUpdateExtension(it) onUpdateExtension(it)
} else { } else {
onOpenExtension(it) onOpenExtension(it)
} }
} }
is Extension.Untrusted -> { is Extension.NotLoaded -> {
trustState = it notLoadedState = it
} }
} }
}, },
@@ -251,20 +255,30 @@ private fun ExtensionContent(
} }
} }
} }
if (trustState != null) { notLoadedState?.let { extension ->
val dismiss = { notLoadedState = null }
if (extension.reason is Extension.NotLoaded.Reason.Untrusted) {
ExtensionTrustDialog( ExtensionTrustDialog(
onClickConfirm = { onClickConfirm = {
onTrustExtension(trustState!!) onTrustExtension(extension)
trustState = null dismiss()
}, },
onClickDismiss = { onClickDismiss = {
onUninstallExtension(trustState!!) onUninstallExtension(extension)
trustState = null dismiss()
},
onDismissRequest = {
trustState = null
}, },
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) { ProvideTextStyle(value = MaterialTheme.typography.bodySmall) {
var hasAlreadyShownAnElement by remember { mutableStateOf(false) } var hasAlreadyShownAnElement by remember { mutableStateOf(false) }
if (extension is Extension.Installed && extension.lang.isNotEmpty()) { if (extension is Extension.Loaded && extension.lang.isNotEmpty()) {
hasAlreadyShownAnElement = true hasAlreadyShownAnElement = true
Text( Text(
text = LocaleHelper.getSourceDisplayName(extension.lang, LocalContext.current), text = LocaleHelper.getSourceDisplayName(extension.lang, LocalContext.current),
@@ -370,9 +384,9 @@ private fun ExtensionItemContent(
val warnings = listOfNotNull( val warnings = listOfNotNull(
when { when {
extension is Extension.Untrusted -> extension is Extension.NotLoaded ->
MR.strings.ext_untrusted to MaterialTheme.colorScheme.error extension.reason.labelRes?.let { it to MaterialTheme.colorScheme.error }
extension is Extension.Installed && extension.isObsolete -> extension is Extension.Loaded && extension.isObsolete ->
MR.strings.ext_obsolete to MaterialTheme.colorScheme.error MR.strings.ext_obsolete to MaterialTheme.colorScheme.error
else -> null 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() if (hasAlreadyShownAnElement) DotSeparatorNoSpaceText()
Text( Text(
text = stringResource(MR.strings.ext_installer_private), text = stringResource(MR.strings.ext_installer_private),
@@ -446,7 +460,7 @@ private fun ExtensionItemActions(
} }
installStep == InstallStep.Idle -> { installStep == InstallStep.Idle -> {
when (extension) { when (extension) {
is Extension.Installed -> { is Extension.Loaded -> {
IconButton(onClick = { onClickItemSecondaryAction(extension) }) { IconButton(onClick = { onClickItemSecondaryAction(extension) }) {
Icon( Icon(
imageVector = MaterialSymbols.Rounded.Settings, 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) }) { IconButton(onClick = { onClickItemAction(extension) }) {
Icon( Icon(
imageVector = MaterialSymbols.Rounded.VerifiedUser, imageVector = if (isUntrusted) {
contentDescription = stringResource(MR.strings.ext_trust), 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 @Composable
private fun ExtensionTrustDialog( private fun ExtensionTrustDialog(
onClickConfirm: () -> Unit, onClickConfirm: () -> Unit,
@@ -96,7 +96,7 @@ fun ExtensionIcon(
.clip(MaterialTheme.shapes.extraSmall), .clip(MaterialTheme.shapes.extraSmall),
) )
} }
is Extension.Installed -> { is Extension.Loaded -> {
val icon by extension.getIcon(density) val icon by extension.getIcon(density)
when (icon) { when (icon) {
Result.Loading -> Box(modifier = modifier) Result.Loading -> Box(modifier = modifier)
@@ -112,7 +112,7 @@ fun ExtensionIcon(
) )
} }
} }
is Extension.Untrusted -> Image( is Extension.NotLoaded -> Image(
imageVector = MaterialSymbols.Rounded.Dangerous, imageVector = MaterialSymbols.Rounded.Dangerous,
contentDescription = null, contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error), 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.api.ExtensionUpdateNotifier
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep 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.ExtensionInstallReceiver
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.extension.util.ExtensionLoader
@@ -60,14 +59,14 @@ class ExtensionManager(
private val iconMap = mutableMapOf<String, Drawable>() private val iconMap = mutableMapOf<String, Drawable>()
private val installedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Installed>()) private val loadedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Loaded>())
val installedExtensionsFlow = installedExtensionMapFlow.mapExtensionsWhenInitialized() val loadedExtensionsFlow = loadedExtensionMapFlow.mapExtensionsWhenInitialized()
private val availableExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Available>()) private val availableExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Available>())
val availableExtensionsFlow = availableExtensionMapFlow.mapExtensions(scope) val availableExtensionsFlow = availableExtensionMapFlow.mapExtensions(scope)
private val untrustedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Untrusted>()) private val notLoadedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.NotLoaded>())
val untrustedExtensionsFlow = untrustedExtensionMapFlow.mapExtensionsWhenInitialized() val notLoadedExtensionsFlow = notLoadedExtensionMapFlow.mapExtensionsWhenInitialized()
init { init {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
@@ -78,20 +77,25 @@ class ExtensionManager(
private var subLanguagesEnabledOnFirstRun = preferences.enabledLanguages.isSet() private var subLanguagesEnabledOnFirstRun = preferences.enabledLanguages.isSet()
suspend fun getInstalledExtensions(): List<Extension.Installed> { suspend fun getLoadedExtensions(): List<Extension.Loaded> {
initialized.await() 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? { suspend fun getExtensionPackage(sourceId: Long): String? {
return getInstalledExtensions().find { extension -> return getLoadedExtensions().find { extension ->
extension.sources.any { it.id == sourceId } extension.sources.any { it.id == sourceId }
} }
?.pkgName ?.pkgName
} }
fun getExtensionPackageAsFlow(sourceId: Long): Flow<String?> { fun getExtensionPackageAsFlow(sourceId: Long): Flow<String?> {
return installedExtensionsFlow.map { extensions -> return loadedExtensionsFlow.map { extensions ->
extensions.find { extension -> extensions.find { extension ->
extension.sources.any { it.id == sourceId } extension.sources.any { it.id == sourceId }
} }
@@ -126,13 +130,13 @@ class ExtensionManager(
try { try {
val extensions = ExtensionLoader.loadExtensions(context) val extensions = ExtensionLoader.loadExtensions(context)
installedExtensionMapFlow.value = extensions loadedExtensionMapFlow.value = extensions
.filterIsInstance<LoadResult.Success>() .filterIsInstance<Extension.Loaded>()
.associate { it.extension.pkgName to it.extension } .associateBy { it.pkgName }
untrustedExtensionMapFlow.value = extensions notLoadedExtensionMapFlow.value = extensions
.filterIsInstance<LoadResult.Untrusted>() .filterIsInstance<Extension.NotLoaded>()
.associate { it.extension.pkgName to it.extension } .associateBy { it.pkgName }
initialized.complete(Unit) initialized.complete(Unit)
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -202,23 +206,23 @@ class ExtensionManager(
return return
} }
val installedExtensionsMap = installedExtensionMapFlow.value.toMutableMap() val loadedExtensionsMap = loadedExtensionMapFlow.value.toMutableMap()
var changed = false var changed = false
for ((pkgName, extension) in installedExtensionsMap) { for ((pkgName, extension) in loadedExtensionsMap) {
val availableExt = availableExtensions.find { it.pkgName == pkgName } val availableExt = availableExtensions.find { it.pkgName == pkgName }
if (availableExt == null && !extension.isObsolete) { if (availableExt == null && !extension.isObsolete) {
installedExtensionsMap[pkgName] = extension.copy(isObsolete = true) loadedExtensionsMap[pkgName] = extension.copy(isObsolete = true)
changed = true changed = true
} else if (availableExt != null) { } else if (availableExt != null) {
val hasUpdate = extension.updateExists(availableExt) val hasUpdate = extension.updateExists(availableExt)
if (extension.hasUpdate != hasUpdate) { if (extension.hasUpdate != hasUpdate) {
installedExtensionsMap[pkgName] = extension.copy( loadedExtensionsMap[pkgName] = extension.copy(
hasUpdate = hasUpdate, hasUpdate = hasUpdate,
store = availableExt.store, store = availableExt.store,
) )
} else { } else {
installedExtensionsMap[pkgName] = extension.copy( loadedExtensionsMap[pkgName] = extension.copy(
store = availableExt.store, store = availableExt.store,
) )
} }
@@ -226,7 +230,7 @@ class ExtensionManager(
} }
} }
if (changed) { if (changed) {
installedExtensionMapFlow.value = installedExtensionsMap loadedExtensionMapFlow.value = loadedExtensionsMap
} }
updatePendingUpdatesCount() updatePendingUpdatesCount()
} }
@@ -249,7 +253,7 @@ class ExtensionManager(
* *
* @param extension The extension to be updated. * @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 availableExt = availableExtensionMapFlow.value[extension.pkgName] ?: return emptyFlow()
val isUpdateForPrivatelyInstalled = !extension.isShared val isUpdateForPrivatelyInstalled = !extension.isShared
return installer.downloadAndInstall(availableExt.apkUrl, availableExt, isUpdateForPrivatelyInstalled) return installer.downloadAndInstall(availableExt.apkUrl, availableExt, isUpdateForPrivatelyInstalled)
@@ -277,7 +281,7 @@ class ExtensionManager(
* *
* @param extension The extension to uninstall. * @param extension The extension to uninstall.
*/ */
fun uninstallExtension(extension: Extension) { fun uninstallExtension(extension: Extension.Installed) {
installer.uninstallApk(extension.pkgName) installer.uninstallApk(extension.pkgName)
} }
@@ -287,16 +291,19 @@ class ExtensionManager(
* *
* @param extension the extension to trust * @param extension the extension to trust
*/ */
suspend fun trust(extension: Extension.Untrusted) { suspend fun trust(extension: Extension.NotLoaded) {
untrustedExtensionMapFlow.value[extension.pkgName] ?: return 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) when (val reloaded = ExtensionLoader.loadExtensionFromPkgName(context, extension.pkgName)) {
.let { it as? LoadResult.Success } is Extension.Loaded -> registerExtension(reloaded)
?.let { registerNewExtension(it.extension) } is Extension.NotLoaded -> notLoadedExtensionMapFlow.value += reloaded
null -> {}
}
} }
/** /**
@@ -304,18 +311,8 @@ class ExtensionManager(
* *
* @param extension The extension to be registered. * @param extension The extension to be registered.
*/ */
private fun registerNewExtension(extension: Extension.Installed) { private fun registerExtension(extension: Extension.Loaded) {
installedExtensionMapFlow.value += extension loadedExtensionMapFlow.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
} }
/** /**
@@ -325,8 +322,8 @@ class ExtensionManager(
* @param pkgName The package name of the uninstalled application. * @param pkgName The package name of the uninstalled application.
*/ */
private fun unregisterExtension(pkgName: String) { private fun unregisterExtension(pkgName: String) {
installedExtensionMapFlow.value -= pkgName loadedExtensionMapFlow.value -= pkgName
untrustedExtensionMapFlow.value -= pkgName notLoadedExtensionMapFlow.value -= pkgName
} }
/** /**
@@ -334,19 +331,15 @@ class ExtensionManager(
*/ */
private inner class InstallationListener : ExtensionInstallReceiver.Listener { private inner class InstallationListener : ExtensionInstallReceiver.Listener {
override fun onExtensionInstalled(extension: Extension.Installed) { override fun onExtensionLoaded(extension: Extension.Loaded) {
registerNewExtension(extension.withUpdateCheck()) registerExtension(extension.withUpdateCheck())
notLoadedExtensionMapFlow.value -= extension.pkgName
updatePendingUpdatesCount() updatePendingUpdatesCount()
} }
override fun onExtensionUpdated(extension: Extension.Installed) { override fun onExtensionNotLoaded(extension: Extension.NotLoaded) {
registerUpdatedExtension(extension.withUpdateCheck()) loadedExtensionMapFlow.value -= extension.pkgName
updatePendingUpdatesCount() notLoadedExtensionMapFlow.value += extension
}
override fun onExtensionUntrusted(extension: Extension.Untrusted) {
installedExtensionMapFlow.value -= extension.pkgName
untrustedExtensionMapFlow.value += extension
updatePendingUpdatesCount() updatePendingUpdatesCount()
} }
@@ -360,7 +353,7 @@ class ExtensionManager(
/** /**
* Extension method to set the update field of an installed extension. * 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()) { return if (updateExists()) {
copy(hasUpdate = true) copy(hasUpdate = true)
} else { } 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 val availableExt = availableExtension
?: availableExtensionMapFlow.value[pkgName] ?: availableExtensionMapFlow.value[pkgName]
?: return false ?: return false
@@ -377,7 +370,7 @@ class ExtensionManager(
} }
private fun updatePendingUpdatesCount() { private fun updatePendingUpdatesCount() {
val pendingUpdateCount = installedExtensionMapFlow.value.values.count { it.hasUpdate } val pendingUpdateCount = loadedExtensionMapFlow.value.values.count { it.hasUpdate }
preferences.extensionUpdatesCount.set(pendingUpdateCount) preferences.extensionUpdatesCount.set(pendingUpdateCount)
if (pendingUpdateCount == 0) { if (pendingUpdateCount == 0) {
extensionUpdateNotifier.dismiss() extensionUpdateNotifier.dismiss()
@@ -5,7 +5,6 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import mihon.domain.extension.interactor.UpdateExtensionStores import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extension.repository.ExtensionStoreRepository import mihon.domain.extension.repository.ExtensionStoreRepository
@@ -28,12 +27,11 @@ class ExtensionApi(
val extensions = findExtensions() val extensions = findExtensions()
val installedExtensions = ExtensionLoader.loadExtensions(context) val loadedExtensions = ExtensionLoader.loadExtensions(context)
.filterIsInstance<LoadResult.Success>() .filterIsInstance<Extension.Loaded>()
.map { it.extension }
val extensionsWithUpdate = mutableListOf<Extension.Installed>() val extensionsWithUpdate = mutableListOf<Extension.Loaded>()
for (installedExt in installedExtensions) { for (installedExt in loadedExtensions) {
val pkgName = installedExt.pkgName val pkgName = installedExt.pkgName
val availableExt = extensions.find { it.pkgName == pkgName } ?: continue val availableExt = extensions.find { it.pkgName == pkgName } ?: continue
val hasUpdatedVer = availableExt.versionCode > installedExt.versionCode 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 androidx.core.net.toUri
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -51,19 +50,19 @@ internal class ExtensionInstallReceiver(private val listener: Listener) : Broadc
if (isReplacing(intent)) return if (isReplacing(intent)) return
scope.launch { scope.launch {
when (val result = getExtensionFromIntent(context, intent)) { when (val extension = getExtensionFromIntent(context, intent)) {
is LoadResult.Success -> listener.onExtensionInstalled(result.extension) is Extension.Loaded -> listener.onExtensionLoaded(extension)
is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension) is Extension.NotLoaded -> listener.onExtensionNotLoaded(extension)
else -> {} null -> {}
} }
} }
} }
Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> { Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> {
scope.launch { scope.launch {
when (val result = getExtensionFromIntent(context, intent)) { when (val extension = getExtensionFromIntent(context, intent)) {
is LoadResult.Success -> listener.onExtensionUpdated(result.extension) is Extension.Loaded -> listener.onExtensionLoaded(extension)
is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension) is Extension.NotLoaded -> listener.onExtensionNotLoaded(extension)
else -> {} null -> {}
} }
} }
} }
@@ -93,11 +92,11 @@ internal class ExtensionInstallReceiver(private val listener: Listener) : Broadc
* @param context The application context. * @param context The application context.
* @param intent The intent containing the package name of the extension. * @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) val pkgName = getPackageNameFromIntent(intent)
if (pkgName == null) { if (pkgName == null) {
logcat(LogPriority.WARN) { "Package name not found" } logcat(LogPriority.WARN) { "Package name not found" }
return LoadResult.Error return null
} }
return ExtensionLoader.loadExtensionFromPkgName(context, pkgName) return ExtensionLoader.loadExtensionFromPkgName(context, pkgName)
} }
@@ -113,9 +112,8 @@ internal class ExtensionInstallReceiver(private val listener: Listener) : Broadc
* Listener that receives extension installation events. * Listener that receives extension installation events.
*/ */
interface Listener { interface Listener {
fun onExtensionInstalled(extension: Extension.Installed) fun onExtensionLoaded(extension: Extension.Loaded)
fun onExtensionUpdated(extension: Extension.Installed) fun onExtensionNotLoaded(extension: Extension.NotLoaded)
fun onExtensionUntrusted(extension: Extension.Untrusted)
fun onPackageUninstalled(pkgName: String) fun onPackageUninstalled(pkgName: String)
} }
@@ -8,7 +8,6 @@ import android.os.Build
import androidx.core.content.pm.PackageInfoCompat import androidx.core.content.pm.PackageInfoCompat
import eu.kanade.domain.extension.interactor.TrustExtension import eu.kanade.domain.extension.interactor.TrustExtension
import eu.kanade.tachiyomi.extension.model.Extension 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.Source
import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.lang.Hash
@@ -112,7 +111,7 @@ internal object ExtensionLoader {
* *
* @param context The application context. * @param context The application context.
*/ */
fun loadExtensions(context: Context): List<LoadResult> { fun loadExtensions(context: Context): List<Extension.Installed> {
val pkgManager = context.packageManager val pkgManager = context.packageManager
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -138,7 +137,7 @@ internal object ExtensionLoader {
val path = it.absolutePath val path = it.absolutePath
pkgManager.getPackageArchiveInfo(path, PACKAGE_FLAGS) pkgManager.getPackageArchiveInfo(path, PACKAGE_FLAGS)
?.apply { applicationInfo!!.fixBasePaths(path) } ?.also { pkg -> pkg.applicationInfo?.fixBasePaths(path) }
} }
?.filter { isPackageAnExtension(it) } ?.filter { isPackageAnExtension(it) }
?.map { ExtensionInfo(packageInfo = it, isShared = false) } ?.map { ExtensionInfo(packageInfo = it, isShared = false) }
@@ -160,7 +159,7 @@ internal object ExtensionLoader {
// Load each extension concurrently and wait for completion // Load each extension concurrently and wait for completion
return runBlocking(Dispatchers.IO) { return runBlocking(Dispatchers.IO) {
val deferred = extPkgs.map { val deferred = extPkgs.map {
async { loadExtension(context, it) } async { loadExtensionCatching(context, it) }
} }
deferred.awaitAll() deferred.awaitAll()
} }
@@ -170,13 +169,13 @@ internal object ExtensionLoader {
* Attempts to load an extension from the given package name. It checks if the extension * 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. * 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) val extensionPackage = getExtensionInfoFromPkgName(context, pkgName)
if (extensionPackage == null) { if (extensionPackage == null) {
logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" } 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? { fun getExtensionPackageInfoFromPkgName(context: Context, pkgName: String): PackageInfo? {
@@ -189,7 +188,7 @@ internal object ExtensionLoader {
context.packageManager.getPackageArchiveInfo(privateExtensionFile.absolutePath, PACKAGE_FLAGS) context.packageManager.getPackageArchiveInfo(privateExtensionFile.absolutePath, PACKAGE_FLAGS)
?.takeIf { isPackageAnExtension(it) } ?.takeIf { isPackageAnExtension(it) }
?.let { ?.let {
it.applicationInfo!!.fixBasePaths(privateExtensionFile.absolutePath) it.applicationInfo?.fixBasePaths(privateExtensionFile.absolutePath)
ExtensionInfo( ExtensionInfo(
packageInfo = it, packageInfo = it,
isShared = false, isShared = false,
@@ -215,13 +214,36 @@ internal object ExtensionLoader {
return selectExtensionPackage(sharedPkg, privatePkg) 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 * Loads an extension
* *
* @param context The application context. * @param context The application context.
* @param extensionInfo The extension to load. * @param extensionInfo The extension to load.
*/ */
private suspend fun loadExtension(context: Context, extensionInfo: ExtensionInfo): LoadResult { private suspend fun loadExtension(context: Context, extensionInfo: ExtensionInfo): Extension.Installed {
val trustExtension: TrustExtension = context.appGraph.trustExtension val trustExtension: TrustExtension = context.appGraph.trustExtension
val sourcePreferences = context.appGraph.sourcePreferences val sourcePreferences = context.appGraph.sourcePreferences
val enabledContentWarnings = sourcePreferences.enabledContentWarnings.get() val enabledContentWarnings = sourcePreferences.enabledContentWarnings.get()
@@ -229,21 +251,54 @@ internal object ExtensionLoader {
val pkgManager = context.packageManager val pkgManager = context.packageManager
val pkgInfo = extensionInfo.packageInfo val pkgInfo = extensionInfo.packageInfo
val appInfo = pkgInfo.applicationInfo!! val appInfo = pkgInfo.applicationInfo
val metaData = appInfo?.metaData
val pkgName = pkgInfo.packageName val pkgName = pkgInfo.packageName
val extName = appInfo.metaData.getString(METADATA_NAME) val extName = metaData?.getString(METADATA_NAME)
?: pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ") ?: appInfo?.let { pkgManager.getApplicationLabel(it).toString().substringAfter("Tachiyomi: ") }
?: pkgName
val versionName = pkgInfo.versionName val versionName = pkgInfo.versionName
val versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo) 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()) { if (versionName.isNullOrEmpty()) {
logcat(LogPriority.WARN) { "Missing versionName for extension $extName" } logcat(LogPriority.WARN) { "Missing versionName for extension $extName" }
return LoadResult.Error return notLoaded(Extension.NotLoaded.Reason.Malformed)
} }
// Validate lib version // Validate lib version
val libVersion = appInfo.metaData.getFloat(METADATA_EXTENSION_LIB) val libVersion = metaData.getFloat(METADATA_EXTENSION_LIB)
.takeUnless { it == 0.0f } .takeUnless { it == 0.0f }
?.toString() ?.toString()
?.toDouble() ?.toDouble()
@@ -252,50 +307,37 @@ internal object ExtensionLoader {
logcat(LogPriority.WARN) { logcat(LogPriority.WARN) {
"Lib version is $libVersion, while only version(s) ${SUPPORTED_LIB_VERSIONS.joinToString()} are supported" "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) val signatures = getSignatures(pkgInfo)
if (signatures.isNullOrEmpty()) { if (signatures.isNullOrEmpty()) {
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" } logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
return LoadResult.Error return notLoaded(Extension.NotLoaded.Reason.Unsigned, libVersion)
} else if (!trustExtension.isTrusted(pkgInfo, signatures)) { } 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" } 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) { if (applyContentWarningsToInstalled && contentWarning !in enabledContentWarnings) {
logcat(LogPriority.WARN) { "Extension $pkgName with $contentWarning not allowed" } logcat(LogPriority.WARN) { "Extension $pkgName with $contentWarning not allowed" }
return LoadResult.Error return notLoaded(Extension.NotLoaded.Reason.Filtered, libVersion)
} }
val classLoader = try { val classLoader = try {
DelegateLastClassLoaderCompat(appInfo.sourceDir, null, context.classLoader) DelegateLastClassLoaderCompat(appInfo.sourceDir, null, context.classLoader)
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($pkgName)" } 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(";") .split(";")
.map { .map {
val sourceClass = it.trim() val sourceClass = it.trim()
@@ -314,7 +356,10 @@ internal object ExtensionLoader {
} }
} catch (e: Throwable) { } catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($it)" } 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" else -> "all"
} }
val extension = Extension.Installed( return Extension.Loaded(
name = extName, name = extName,
pkgName = pkgName, pkgName = pkgName,
versionName = versionName, versionName = versionName,
@@ -334,11 +379,10 @@ internal object ExtensionLoader {
lang = lang, lang = lang,
contentWarning = contentWarning, contentWarning = contentWarning,
sources = sources, sources = sources,
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY), pkgFactory = metaData.getString(METADATA_SOURCE_FACTORY),
icon = appInfo.loadIcon(pkgManager), icon = runCatching { appInfo.loadIcon(pkgManager) }.getOrNull(),
isShared = extensionInfo.isShared, isShared = extensionInfo.isShared,
) )
return LoadResult.Success(extension)
} }
/** /**
@@ -380,11 +424,11 @@ internal object ExtensionLoader {
*/ */
private fun getSignatures(pkgInfo: PackageInfo): List<String>? { private fun getSignatures(pkgInfo: PackageInfo): List<String>? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val signingInfo = pkgInfo.signingInfo!! val signingInfo = pkgInfo.signingInfo
if (signingInfo.hasMultipleSigners()) { when {
signingInfo.apkContentsSigners signingInfo == null -> null
} else { signingInfo.hasMultipleSigners() -> signingInfo.apkContentsSigners
signingInfo.signingCertificateHistory else -> signingInfo.signingCertificateHistory
} }
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@@ -412,3 +456,12 @@ internal object ExtensionLoader {
val isShared: Boolean, 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 { init {
scope.launch { scope.launch {
extensionManager.installedExtensionsFlow extensionManager.loadedExtensionsFlow
.collectLatest { extensions -> .collectLatest { extensions ->
val mutableMap = ConcurrentHashMap<Long, Source>( val mutableMap = ConcurrentHashMap<Long, Source>(
mapOf(LocalSource.ID to localSource), mapOf(LocalSource.ID to localSource),
@@ -32,7 +32,7 @@ fun extensionsTab(
val context = LocalContext.current val context = LocalContext.current
val updatesCount by extensionsViewModel.updatesCount.collectAsStateWithLifecycle() val updatesCount by extensionsViewModel.updatesCount.collectAsStateWithLifecycle()
var privateExtensionToUninstall by remember { mutableStateOf<Extension?>(null) } var privateExtensionToUninstall by remember { mutableStateOf<Extension.Installed?>(null) }
return TabContent( return TabContent(
titleRes = MR.strings.label_extensions, titleRes = MR.strings.label_extensions,
@@ -62,7 +62,7 @@ fun extensionsTab(
onLongClickItem = { extension -> onLongClickItem = { extension ->
when (extension) { when (extension) {
is Extension.Available -> extensionsViewModel.installExtension(extension) is Extension.Available -> extensionsViewModel.installExtension(extension)
else -> { is Extension.Installed -> {
if (context.isPackageInstalled(extension.pkgName)) { if (context.isPackageInstalled(extension.pkgName)) {
extensionsViewModel.uninstallExtension(extension) extensionsViewModel.uninstallExtension(extension)
} else { } else {
@@ -74,17 +74,21 @@ class ExtensionsViewModel(
.map { searchQueryPredicate(it ?: "") }, .map { searchQueryPredicate(it ?: "") },
currentDownloads, currentDownloads,
getExtensions.subscribe(), getExtensions.subscribe(),
) { predicate, downloads, (_updates, _installed, _available, _untrusted) -> ) { predicate, downloads, (_updates, _loaded, _available, _notLoaded) ->
buildMap { buildMap {
val updates = _updates.filter(predicate).map(extensionMapper(downloads)) val updates = _updates.filter(predicate).map(extensionMapper(downloads))
if (updates.isNotEmpty()) { if (updates.isNotEmpty()) {
put(ExtensionUiModel.Header.Resource(MR.strings.ext_updates_pending), updates) put(ExtensionUiModel.Header.Resource(MR.strings.ext_updates_pending), updates)
} }
val installed = _installed.filter(predicate).map(extensionMapper(downloads)) val notLoaded = _notLoaded.filter(predicate).map(extensionMapper(downloads))
val untrusted = _untrusted.filter(predicate).map(extensionMapper(downloads)) if (notLoaded.isNotEmpty()) {
if (installed.isNotEmpty() || untrusted.isNotEmpty()) { put(ExtensionUiModel.Header.Resource(MR.strings.ext_not_loaded), notLoaded)
put(ExtensionUiModel.Header.Resource(MR.strings.ext_installed), installed + untrusted) }
val loaded = _loaded.filter(predicate).map(extensionMapper(downloads))
if (loaded.isNotEmpty()) {
put(ExtensionUiModel.Header.Resource(MR.strings.ext_installed), loaded)
} }
val languagesWithExtensions = _available val languagesWithExtensions = _available
@@ -138,7 +142,7 @@ class ExtensionsViewModel(
if (extension.name.contains(subquery, ignoreCase = true)) return@any true if (extension.name.contains(subquery, ignoreCase = true)) return@any true
when (extension) { when (extension) {
is Extension.Installed -> extension.sources.any { source -> is Extension.Loaded -> extension.sources.any { source ->
source.name.contains(subquery, ignoreCase = true) || source.name.contains(subquery, ignoreCase = true) ||
(source as? HttpSource)?.getHomeUrl()?.contains(subquery, ignoreCase = true) == true || (source as? HttpSource)?.getHomeUrl()?.contains(subquery, ignoreCase = true) == true ||
source.id == subquery.toLongOrNull() source.id == subquery.toLongOrNull()
@@ -164,7 +168,7 @@ class ExtensionsViewModel(
viewModelScope.launchIO { viewModelScope.launchIO {
state.value.items.values.flatten() state.value.items.values.flatten()
.map { it.extension } .map { it.extension }
.filterIsInstance<Extension.Installed>() .filterIsInstance<Extension.Loaded>()
.filter { it.hasUpdate } .filter { it.hasUpdate }
.forEach(::updateExtension) .forEach(::updateExtension)
} }
@@ -176,7 +180,7 @@ class ExtensionsViewModel(
} }
} }
fun updateExtension(extension: Extension.Installed) { fun updateExtension(extension: Extension.Loaded) {
viewModelScope.launchIO { viewModelScope.launchIO {
extensionManager.updateExtension(extension).collectToInstallUpdate(extension) extensionManager.updateExtension(extension).collectToInstallUpdate(extension)
} }
@@ -202,7 +206,7 @@ class ExtensionsViewModel(
.onCompletion { removeDownloadState(extension) } .onCompletion { removeDownloadState(extension) }
.collect() .collect()
fun uninstallExtension(extension: Extension) { fun uninstallExtension(extension: Extension.Installed) {
extensionManager.uninstallExtension(extension) extensionManager.uninstallExtension(extension)
} }
@@ -219,7 +223,7 @@ class ExtensionsViewModel(
} }
} }
fun trustExtension(extension: Extension.Untrusted) { fun trustExtension(extension: Extension.NotLoaded) {
viewModelScope.launch { viewModelScope.launch {
extensionManager.trust(extension) extensionManager.trust(extension)
} }
@@ -56,7 +56,7 @@ class ExtensionDetailsViewModel(
fun create(pkgName: String): 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 } } .map { it.firstOrNull { extension -> extension.pkgName == pkgName } }
.distinctUntilChanged() .distinctUntilChanged()
.flatMapLatest { extension -> .flatMapLatest { extension ->
@@ -73,7 +73,7 @@ class ExtensionDetailsViewModel(
private val successState: State.Success? private val successState: State.Success?
get() = state.value as? 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) return getExtensionSources.subscribe(extension)
.map { .map {
it.sortedWith( it.sortedWith(
@@ -142,7 +142,7 @@ class ExtensionDetailsViewModel(
@Immutable @Immutable
data class Success( data class Success(
val extension: Extension.Installed, val extension: Extension.Loaded,
val isIncognito: Boolean, val isIncognito: Boolean,
val sources: List<ExtensionSourceItem>, val sources: List<ExtensionSourceItem>,
) : State ) : State
@@ -106,7 +106,7 @@ abstract class SearchViewModel(
return enabledSources return enabledSources
} }
return extensionManager.getInstalledExtensions() return extensionManager.getLoadedExtensions()
.filter { it.pkgName == filter } .filter { it.pkgName == filter }
.flatMap { it.sources } .flatMap { it.sources }
.filter { it in enabledSources } .filter { it in enabledSources }
@@ -6,6 +6,7 @@ import dev.zacsweers.metro.Inject
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.network.NetworkPreferences import eu.kanade.tachiyomi.network.NetworkPreferences
import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.WebViewUtil import eu.kanade.tachiyomi.util.system.WebViewUtil
@@ -65,7 +66,7 @@ class CrashLogUtil(
private suspend fun getExtensionsInfo(): String? { private suspend fun getExtensionsInfo(): String? {
val availableExtensions = extensionManager.availableExtensionsFlow.value.associateBy { it.pkgName } val availableExtensions = extensionManager.availableExtensionsFlow.value.associateBy { it.pkgName }
val extensionInfoList = extensionManager.getInstalledExtensions() val outdatedInfoList = extensionManager.getLoadedExtensions()
.sortedBy { it.name } .sortedBy { it.name }
.mapNotNull { .mapNotNull {
val availableExtension = availableExtensions[it.pkgName] val availableExtension = availableExtensions[it.pkgName]
@@ -80,6 +81,24 @@ class CrashLogUtil(
""".trimIndent() """.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()) { return if (extensionInfoList.isNotEmpty()) {
(listOf("Problematic extensions:") + extensionInfoList) (listOf("Problematic extensions:") + extensionInfoList)
.joinToString("\n") .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)"
}
@@ -6,33 +6,26 @@ import mihon.domain.extension.model.ContentWarning
import mihon.domain.extension.model.ExtensionStore import mihon.domain.extension.model.ExtensionStore
import tachiyomi.domain.source.model.StubSource import tachiyomi.domain.source.model.StubSource
sealed class Extension { sealed interface Extension {
abstract val name: String val name: String
abstract val pkgName: String val pkgName: String
abstract val versionName: String val versionName: String
abstract val versionCode: Long val versionCode: Long
abstract val libVersion: Double val libVersion: Double?
abstract val lang: String? val lang: String?
abstract val contentWarning: ContentWarning val contentWarning: ContentWarning
data class Installed( /**
override val name: String, * An extension whose apk is on the device, whether or not it ended up being loaded.
override val pkgName: String, */
override val versionName: String, sealed interface Installed : Extension {
override val versionCode: Long, val isShared: Boolean
override val libVersion: Double, }
override val lang: String,
override val contentWarning: ContentWarning,
val pkgFactory: String?,
val sources: List<Source>,
val icon: Drawable?,
val hasUpdate: Boolean = false,
val isObsolete: Boolean = false,
val isShared: Boolean,
val store: ExtensionStore? = null,
) : Extension()
/**
* An extension that isn't on the device yet, as listed by an [ExtensionStore].
*/
data class Available( data class Available(
override val name: String, override val name: String,
override val pkgName: String, override val pkgName: String,
@@ -45,7 +38,7 @@ sealed class Extension {
val apkUrl: String, val apkUrl: String,
val iconUrl: String, val iconUrl: String,
val store: ExtensionStore, val store: ExtensionStore,
) : Extension() { ) : Extension {
data class Source( data class Source(
val id: Long, 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 name: String,
override val pkgName: String, override val pkgName: String,
override val versionName: String, override val versionName: String,
override val versionCode: Long, override val versionCode: Long,
override val libVersion: Double, 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<Source>,
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 lang: String? = null,
override val contentWarning: ContentWarning = ContentWarning.SAFE, val reason: Reason,
) : Extension() ) : 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
}
}
} }
@@ -270,7 +270,7 @@
<string name="pref_allowed_content_warnings">Allowed content warnings</string> <string name="pref_allowed_content_warnings">Allowed content warnings</string>
<string name="pref_apply_content_warnings_to_installed">Also apply to installed extensions</string> <string name="pref_apply_content_warnings_to_installed">Also apply to installed extensions</string>
<string name="pref_apply_content_warnings_to_installed_summary">When off, extensions you already installed keep loading and updating regardless of their warning</string> <string name="pref_apply_content_warnings_to_installed_summary">When off, extensions you already installed keep loading and updating regardless of their warning</string>
<string name="content_warnings_info">Requires app restart to take effect. 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.</string> <string name="content_warnings_info">Requires app restart to take effect. Installed extensions that aren\'t loaded stay listed without their sources; ones you haven\'t installed are hidden. This does not prevent unofficial or potentially incorrectly flagged extensions from surfacing 18+ content within the app.</string>
<string name="relative_time_today">Today</string> <string name="relative_time_today">Today</string>
@@ -340,6 +340,15 @@
<string name="ext_installed">Installed</string> <string name="ext_installed">Installed</string>
<string name="ext_trust">Trust</string> <string name="ext_trust">Trust</string>
<string name="ext_untrusted">Untrusted</string> <string name="ext_untrusted">Untrusted</string>
<string name="ext_filtered">Filtered</string>
<string name="ext_not_loaded">Not loaded</string>
<string name="ext_not_loaded_dialog">Couldn\'t load</string>
<string name="ext_copy_stacktrace">Copy stack trace</string>
<string name="ext_filtered_message">This extension\'s content warning isn\'t one you chose to load, so its sources aren\'t available.</string>
<string name="ext_unsigned_message">This extension isn\'t signed, so there\'s no signature to check it against.</string>
<string name="ext_unsupported_message">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.</string>
<string name="ext_malformed_message">This extension is missing information the app needs to load it.</string>
<string name="ext_load_failed_message">This extension threw an error while loading, so none of its sources are available.</string>
<string name="ext_uninstall">Uninstall</string> <string name="ext_uninstall">Uninstall</string>
<string name="ext_remove">Remove</string> <string name="ext_remove">Remove</string>
<string name="ext_confirm_remove">Remove Extension?</string> <string name="ext_confirm_remove">Remove Extension?</string>