From 9b5e0ae735047eb44a6c3d960a52e4dfc8f91027 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:55:09 +0600 Subject: [PATCH] Show an extension's store and install failures in the extension list (#3955) --- .../interactor/GetExtensionsByType.kt | 10 +- .../browse/ExtensionDetailsScreen.kt | 108 +++-- .../presentation/browse/ExtensionsScreen.kt | 454 +++++++++++------- .../browse/MigrateSourceScreen.kt | 4 +- .../browse/components/BaseSourceItem.kt | 4 +- .../browse/components/ExtensionPill.kt | 32 ++ .../tachiyomi/extension/ExtensionManager.kt | 40 +- .../extension/installer/Installer.kt | 7 +- .../installer/PackageInstallerInstaller.kt | 14 +- .../extension/installer/ShizukuInstaller.kt | 4 +- .../tachiyomi/extension/model/InstallStep.kt | 31 +- .../util/ExtensionInstallActivity.kt | 2 +- .../extension/util/ExtensionInstaller.kt | 15 +- .../extension/util/ExtensionLoader.kt | 41 +- .../moko-resources/base/strings.xml | 4 +- 15 files changed, 494 insertions(+), 276 deletions(-) create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/ExtensionPill.kt diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionsByType.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionsByType.kt index ca474d020..88260a876 100644 --- a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionsByType.kt +++ b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionsByType.kt @@ -32,8 +32,9 @@ class GetExtensionsByType( val available = _available .filter { extension -> - _loaded.none { it.pkgName == extension.pkgName } && - _notLoaded.none { it.pkgName == extension.pkgName } && + (_loaded + _notLoaded).none { + it.pkgName == extension.pkgName && extension.store.signingKey in it.signatures + } && extension.contentWarning in enabledContentWarnings } .flatMap { ext -> @@ -47,7 +48,10 @@ class GetExtensionsByType( ) } } - .sortedWith(byName) + .sortedWith( + compareBy(String.CASE_INSENSITIVE_ORDER) { it.name } + .thenBy { it.store.signingKey }, + ) Extensions( updates = updates, diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt index 12504e603..f40c0639d 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt @@ -15,10 +15,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Switch @@ -32,6 +32,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext @@ -43,6 +44,7 @@ import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.StringResource import eu.kanade.domain.extension.interactor.ExtensionSourceItem import eu.kanade.presentation.browse.components.ExtensionIcon +import eu.kanade.presentation.browse.components.ExtensionPill import eu.kanade.presentation.browse.components.label import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBarActions @@ -56,7 +58,6 @@ import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsViewModel import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.copyToClipboard import mihon.icons.materialsymbols.MaterialSymbols -import mihon.icons.materialsymbols.automirroredrounded.OpenInNew import mihon.icons.materialsymbols.rounded.Settings import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.ScrollbarLazyColumn @@ -77,15 +78,6 @@ fun ExtensionDetailsScreen( onClickIncognito: (Boolean) -> Unit, ) { val uriHandler = LocalUriHandler.current - val url = remember(state.extension) { - val regex = """https://raw.githubusercontent.com/(.+?)/(.+?)/.+""".toRegex() - regex.find(state.extension.store?.indexUrl.orEmpty()) - ?.let { - val (user, repo) = it.destructured - "https://github.com/$user/$repo" - } - ?: state.extension.store?.indexUrl - } Scaffold( topBar = { scrollBehavior -> @@ -94,35 +86,20 @@ fun ExtensionDetailsScreen( navigateUp = navigateUp, actions = { AppBarActions( - actions = buildList { - if (url != null) { - add( - AppBar.Action( - title = stringResource(MR.strings.action_open_repo), - icon = MaterialSymbols.AutoMirroredRounded.OpenInNew, - onClick = { - uriHandler.openUri(url) - }, - ), - ) - } - addAll( - listOf( - AppBar.OverflowAction( - title = stringResource(MR.strings.action_enable_all), - onClick = onClickEnableAll, - ), - AppBar.OverflowAction( - title = stringResource(MR.strings.action_disable_all), - onClick = onClickDisableAll, - ), - AppBar.OverflowAction( - title = stringResource(MR.strings.pref_clear_cookies), - onClick = onClickClearCookies, - ), - ), - ) - }, + actions = listOf( + AppBar.OverflowAction( + title = stringResource(MR.strings.action_enable_all), + onClick = onClickEnableAll, + ), + AppBar.OverflowAction( + title = stringResource(MR.strings.action_disable_all), + onClick = onClickDisableAll, + ), + AppBar.OverflowAction( + title = stringResource(MR.strings.pref_clear_cookies), + onClick = onClickClearCookies, + ), + ), ) }, scrollBehavior = scrollBehavior, @@ -132,6 +109,11 @@ fun ExtensionDetailsScreen( ExtensionDetails( contentPadding = paddingValues, extension = state.extension, + onClickStore = state.extension.store + ?.contact + ?.website + ?.takeIf { it.isNotBlank() } + ?.let { website -> { uriHandler.openUri(website) } }, sources = state.sources, incognitoMode = state.isIncognito, onClickSourcePreferences = onClickSourcePreferences, @@ -146,6 +128,7 @@ fun ExtensionDetailsScreen( private fun ExtensionDetails( contentPadding: PaddingValues, extension: Extension.Loaded, + onClickStore: (() -> Unit)?, sources: List, incognitoMode: Boolean, onClickSourcePreferences: (sourceId: Long) -> Unit, @@ -178,6 +161,7 @@ private fun ExtensionDetails( } Unit }.takeIf { extension.isShared }, + onClickStore = onClickStore, onClickContentWarning = { showContentWarning = true }, @@ -210,8 +194,9 @@ private fun ExtensionDetails( @Composable private fun DetailsHeader( - extension: Extension, + extension: Extension.Installed, extIncognitoMode: Boolean, + onClickStore: (() -> Unit)?, onClickContentWarning: () -> Unit, onClickUninstall: () -> Unit, onClickAppInfo: (() -> Unit)?, @@ -271,12 +256,35 @@ private fun DetailsHeader( textAlign = TextAlign.Center, ) - val strippedPkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.") - Text( - text = strippedPkgName, + text = extension.pkgName, + textAlign = TextAlign.Center, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + + extension.store?.let { store -> + Text( + text = store.name, + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .clickable(enabled = onClickStore != null) { onClickStore?.invoke() } + .padding( + horizontal = MaterialTheme.padding.extraSmall, + vertical = MaterialTheme.padding.extraSmall / 2, + ), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + + if ((extension as? Extension.Loaded)?.isShared == false) { + ExtensionPill( + text = stringResource(MR.strings.ext_installer_private), + modifier = Modifier.padding(top = MaterialTheme.padding.small), + ) + } } Row( @@ -330,14 +338,11 @@ private fun DetailsHeader( } if (onClickAppInfo != null) { - Button( + FilledTonalButton( modifier = Modifier.weight(1f), onClick = onClickAppInfo, ) { - Text( - text = stringResource(MR.strings.ext_app_info), - color = MaterialTheme.colorScheme.onPrimary, - ) + Text(stringResource(MR.strings.ext_app_info)) } } } @@ -431,11 +436,10 @@ private fun SourceSwitchPreference( verticalAlignment = Alignment.CenterVertically, ) { if (source.source is ConfigurableSource) { - IconButton(onClick = { onClickSourcePreferences(source.source.id) }) { + FilledTonalIconButton(onClick = { onClickSourcePreferences(source.source.id) }) { Icon( imageVector = MaterialSymbols.Rounded.Settings, contentDescription = stringResource(MR.strings.label_settings), - tint = MaterialTheme.colorScheme.onSurface, ) } } diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt index 55bd24f90..eb0c49ddc 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt @@ -10,17 +10,22 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.SplitButtonDefaults +import androidx.compose.material3.SplitButtonLayout import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -30,18 +35,22 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector 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.DpOffset import androidx.compose.ui.unit.dp import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import dev.icerock.moko.resources.StringResource import eu.kanade.presentation.browse.components.BaseBrowseItem import eu.kanade.presentation.browse.components.ExtensionIcon +import eu.kanade.presentation.browse.components.ExtensionPill import eu.kanade.presentation.browse.components.label +import eu.kanade.presentation.components.DropdownMenu import eu.kanade.presentation.components.WarningBanner -import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState import eu.kanade.tachiyomi.extension.model.Extension @@ -51,11 +60,12 @@ 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.domain.extension.model.ExtensionStore import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.rounded.Close import mihon.icons.materialsymbols.rounded.Download +import mihon.icons.materialsymbols.rounded.ExpandMore 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 @@ -71,7 +81,6 @@ import tachiyomi.presentation.core.screens.EmptyScreenAction import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.theme.header import tachiyomi.presentation.core.util.plus -import tachiyomi.presentation.core.util.secondaryItemAlpha @Composable fun ExtensionScreen( @@ -151,6 +160,7 @@ private fun ExtensionContent( ) { val context = LocalContext.current var notLoadedState by remember { mutableStateOf(null) } + var installErrorState by remember { mutableStateOf(null) } val installGranted = rememberRequestPackageInstallsPermissionState(initialValue = true) FastScrollLazyColumn( @@ -238,6 +248,7 @@ private fun ExtensionContent( } }, onClickItemCancel = onClickItemCancel, + onClickItemError = { installErrorState = it }, onClickItemAction = { when (it) { is Extension.Available -> onInstallExtension(it) @@ -286,6 +297,55 @@ private fun ExtensionContent( ) } } + installErrorState?.let { error -> + ExtensionInstallErrorDialog( + error = error, + onDismissRequest = { installErrorState = null }, + ) + } +} + +@Composable +private fun ExtensionInstallErrorDialog( + error: InstallStep.Error, + onDismissRequest: () -> Unit, +) { + AlertDialog( + title = { + Text(text = stringResource(MR.strings.ext_install_error)) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) { + Text( + text = error.message, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + error.stackTrace?.let { stackTrace -> + val context = LocalContext.current + TextButton( + onClick = { + context.copyToClipboard( + label = context.stringResource(MR.strings.ext_copy_stacktrace), + content = stackTrace, + ) + }, + contentPadding = PaddingValues(0.dp), + ) { + Text(text = stringResource(MR.strings.ext_copy_stacktrace)) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(MR.strings.action_ok)) + } + }, + onDismissRequest = onDismissRequest, + ) } @Composable @@ -294,50 +354,30 @@ private fun ExtensionItem( onClickItem: (Extension) -> Unit, onLongClickItem: (Extension) -> Unit, onClickItemCancel: (Extension) -> Unit, + onClickItemError: (InstallStep.Error) -> Unit, onClickItemAction: (Extension) -> Unit, onClickItemSecondaryAction: (Extension) -> Unit, modifier: Modifier = Modifier, ) { val (extension, installStep) = item + val store = when (extension) { + is Extension.Available -> extension.store + is Extension.Installed -> extension.store + } + BaseBrowseItem( - modifier = modifier - .combinedClickable( - onClick = { onClickItem(extension) }, - onLongClick = { onLongClickItem(extension) }, - ), + modifier = modifier, onClickItem = { onClickItem(extension) }, onLongClickItem = { onLongClickItem(extension) }, icon = { - Box( - modifier = Modifier - .size(40.dp), - contentAlignment = Alignment.Center, - ) { - val idle = installStep.isCompleted() - if (!idle) { - CircularProgressIndicator( - modifier = Modifier.size(40.dp), - strokeWidth = 2.dp, - ) - } - - val padding by animateDpAsState( - targetValue = if (idle) 0.dp else 8.dp, - label = "iconPadding", - ) - ExtensionIcon( - extension = extension, - modifier = Modifier - .matchParentSize() - .padding(padding), - ) - } + ExtensionItemIcon(extension = extension, installStep = installStep) }, action = { ExtensionItemActions( extension = extension, installStep = installStep, onClickItemCancel = onClickItemCancel, + onClickItemError = onClickItemError, onClickItemAction = onClickItemAction, onClickItemSecondaryAction = onClickItemSecondaryAction, ) @@ -346,6 +386,7 @@ private fun ExtensionItem( ExtensionItemContent( extension = extension, installStep = installStep, + store = store, modifier = Modifier.weight(1f), ) } @@ -355,6 +396,7 @@ private fun ExtensionItem( private fun ExtensionItemContent( extension: Extension, installStep: InstallStep, + store: ExtensionStore?, modifier: Modifier = Modifier, ) { Column( @@ -366,68 +408,106 @@ private fun ExtensionItemContent( overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, ) - // Won't look good but it's not like we can ellipsize overflowing content - FlowRow( - modifier = Modifier.secondaryItemAlpha(), - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall), - ) { - ProvideTextStyle(value = MaterialTheme.typography.bodySmall) { - var hasAlreadyShownAnElement by remember { mutableStateOf(false) } - if (extension is Extension.Loaded && extension.lang.isNotEmpty()) { - hasAlreadyShownAnElement = true - Text( - text = LocaleHelper.getSourceDisplayName(extension.lang, LocalContext.current), - ) - } - if (extension.versionName.isNotEmpty()) { - if (hasAlreadyShownAnElement) DotSeparatorNoSpaceText() - hasAlreadyShownAnElement = true - Text( - text = extension.versionName, - ) - } + if (store != null) { + Text( + text = store.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - val warnings = listOfNotNull( - when { - 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 - }, - extension.contentWarning.label?.let { it.title to it.color }, - ) - warnings.forEach { (label, color) -> - if (hasAlreadyShownAnElement) DotSeparatorNoSpaceText() - hasAlreadyShownAnElement = true - Text( - text = stringResource(label).uppercase(), - color = color, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + ExtensionItemMetadata(extension = extension, installStep = installStep) + } +} - if (extension is Extension.Loaded && !extension.isShared) { - if (hasAlreadyShownAnElement) DotSeparatorNoSpaceText() - Text( - text = stringResource(MR.strings.ext_installer_private), - ) - } +@Composable +private fun ExtensionItemIcon( + extension: Extension, + installStep: InstallStep, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.size(48.dp), + contentAlignment = Alignment.Center, + ) { + val idle = installStep.isCompleted() + if (!idle) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + strokeWidth = 2.dp, + ) + } - if (!installStep.isCompleted()) { - DotSeparatorNoSpaceText() - Text( - text = when (installStep) { - InstallStep.Pending -> stringResource(MR.strings.ext_pending) - InstallStep.Downloading -> stringResource(MR.strings.ext_downloading) - InstallStep.Installing -> stringResource(MR.strings.ext_installing) - else -> error("Must not show non-install process text") - }, - ) - } - } + val padding by animateDpAsState( + targetValue = if (idle) 0.dp else 8.dp, + label = "iconPadding", + ) + ExtensionIcon( + extension = extension, + modifier = Modifier + .matchParentSize() + .padding(padding), + ) + } +} + +/** + * Plain text for what an extension simply is, pills for the few things worth noticing about it. + * Won't look good when it wraps, but it's not like overflowing content can be ellipsized. + */ +@Composable +private fun ExtensionItemMetadata( + extension: Extension, + installStep: InstallStep, + modifier: Modifier = Modifier, +) { + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall / 2), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + val facts = listOfNotNull( + (extension as? Extension.Loaded) + ?.lang + ?.takeIf { it.isNotEmpty() } + ?.let { LocaleHelper.getSourceDisplayName(it, LocalContext.current) }, + extension.versionName.takeIf { it.isNotEmpty() }, + ) + if (facts.isNotEmpty()) { + Text( + text = facts.joinToString(" \u2022 "), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + val notable = listOfNotNull( + when (installStep) { + InstallStep.Pending -> MR.strings.ext_pending + InstallStep.Downloading -> MR.strings.ext_downloading + InstallStep.Installing -> MR.strings.ext_installing + else -> null + }?.let { it to MaterialTheme.colorScheme.primary }, + when { + 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 + }, + extension.contentWarning.label?.let { it.title to it.color }, + (extension as? Extension.Loaded) + ?.takeIf { !it.isShared } + ?.let { MR.strings.ext_installer_private to MaterialTheme.colorScheme.onSurfaceVariant }, + ) + notable.forEach { (label, color) -> + ExtensionPill(text = stringResource(label), color = color) } } } @@ -438,17 +518,30 @@ private fun ExtensionItemActions( installStep: InstallStep, modifier: Modifier = Modifier, onClickItemCancel: (Extension) -> Unit = {}, + onClickItemError: (InstallStep.Error) -> Unit = {}, onClickItemAction: (Extension) -> Unit = {}, onClickItemSecondaryAction: (Extension) -> Unit = {}, ) { - val isIdle = installStep.isCompleted() + val isUntrusted = (extension as? Extension.NotLoaded)?.reason is Extension.NotLoaded.Reason.Untrusted + val secondaryAction = when (extension) { + is Extension.Available -> extension.sources.takeIf { it.isNotEmpty() } + ?.let { stringResource(MR.strings.action_open_in_web_view) } + is Extension.Loaded -> stringResource(MR.strings.action_settings) + is Extension.NotLoaded -> if (isUntrusted) { + stringResource(MR.strings.ext_trust) + } else { + stringResource(MR.strings.ext_not_loaded_details) + } + } + ?.let { it to { onClickItemSecondaryAction(extension) } } Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + verticalAlignment = Alignment.CenterVertically, ) { when { - !isIdle -> { + !installStep.isCompleted() -> { IconButton(onClick = { onClickItemCancel(extension) }) { Icon( imageVector = MaterialSymbols.Rounded.Close, @@ -456,84 +549,123 @@ private fun ExtensionItemActions( ) } } - installStep == InstallStep.Error -> { - IconButton(onClick = { onClickItemAction(extension) }) { + installStep is InstallStep.Error -> { + ExtensionSplitButton( + icon = MaterialSymbols.Rounded.Refresh, + contentDescription = stringResource(MR.strings.action_retry), + onClick = { onClickItemAction(extension) }, + menuItems = listOfNotNull( + stringResource(MR.strings.ext_install_error_details) to { onClickItemError(installStep) }, + secondaryAction, + ), + ) + } + extension is Extension.Available -> { + ExtensionSplitButton( + icon = MaterialSymbols.Rounded.Download, + contentDescription = stringResource(MR.strings.ext_install), + onClick = { onClickItemAction(extension) }, + menuItems = listOfNotNull(secondaryAction), + ) + } + extension is Extension.Installed && extension.hasUpdate -> { + ExtensionSplitButton( + icon = MaterialSymbols.Rounded.Download, + contentDescription = stringResource(MR.strings.ext_update), + onClick = { onClickItemAction(extension) }, + menuItems = listOfNotNull(secondaryAction), + ) + } + extension is Extension.Loaded -> { + FilledTonalIconButton(onClick = { onClickItemSecondaryAction(extension) }) { Icon( - imageVector = MaterialSymbols.Rounded.Refresh, - contentDescription = stringResource(MR.strings.action_retry), + imageVector = MaterialSymbols.Rounded.Settings, + contentDescription = stringResource(MR.strings.action_settings), ) } } - installStep == InstallStep.Idle -> { - when (extension) { - is Extension.Loaded -> { - IconButton(onClick = { onClickItemSecondaryAction(extension) }) { - Icon( - imageVector = MaterialSymbols.Rounded.Settings, - contentDescription = stringResource(MR.strings.action_settings), - ) - } - - if (extension.hasUpdate) { - IconButton(onClick = { onClickItemAction(extension) }) { - Icon( - imageVector = MaterialSymbols.Rounded.Download, - contentDescription = stringResource(MR.strings.ext_update), - ) - } - } - } - is Extension.NotLoaded -> { - val isUntrusted = extension.reason is Extension.NotLoaded.Reason.Untrusted - IconButton(onClick = { onClickItemSecondaryAction(extension) }) { - Icon( - 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) - }, - ) - } - - if (extension.hasUpdate) { - IconButton(onClick = { onClickItemAction(extension) }) { - Icon( - imageVector = MaterialSymbols.Rounded.Download, - contentDescription = stringResource(MR.strings.ext_update), - ) - } - } - } - is Extension.Available -> { - if (extension.sources.isNotEmpty()) { - IconButton( - onClick = { onClickItemSecondaryAction(extension) }, - ) { - Icon( - imageVector = MaterialSymbols.Rounded.Public, - contentDescription = stringResource(MR.strings.action_open_in_web_view), - ) - } - } - - IconButton(onClick = { onClickItemAction(extension) }) { - Icon( - imageVector = MaterialSymbols.Rounded.Download, - contentDescription = stringResource(MR.strings.ext_install), - ) - } - } + extension is Extension.NotLoaded -> { + IconButton(onClick = { onClickItemSecondaryAction(extension) }) { + Icon( + imageVector = if (isUntrusted) { + MaterialSymbols.Rounded.VerifiedUser + } else { + MaterialSymbols.Rounded.Info + }, + contentDescription = secondaryAction?.first, + ) } } } } } +@Composable +private fun ExtensionSplitButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + menuItems: List Unit>>, +) { + if (menuItems.isEmpty()) { + FilledTonalIconButton(onClick = onClick) { + Icon(imageVector = icon, contentDescription = contentDescription) + } + return + } + + val size = SplitButtonDefaults.ExtraSmallContainerHeight + var expanded by remember { mutableStateOf(false) } + SplitButtonLayout( + leadingButton = { + SplitButtonDefaults.TonalLeadingButton( + onClick = onClick, + modifier = Modifier.height(size), + shapes = SplitButtonDefaults.leadingButtonShapesFor(size), + contentPadding = SplitButtonDefaults.leadingButtonContentPaddingFor(size), + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.size(SplitButtonDefaults.leadingButtonIconSizeFor(size)), + ) + } + }, + trailingButton = { + Box { + SplitButtonDefaults.TonalTrailingButton( + checked = expanded, + onCheckedChange = { expanded = it }, + modifier = Modifier.height(size), + shapes = SplitButtonDefaults.trailingButtonShapesFor(size), + contentPadding = SplitButtonDefaults.trailingButtonContentPaddingFor(size), + ) { + Icon( + imageVector = MaterialSymbols.Rounded.ExpandMore, + contentDescription = stringResource(MR.strings.action_menu), + modifier = Modifier.size(SplitButtonDefaults.trailingButtonIconSizeFor(size)), + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + offset = DpOffset.Zero, + ) { + menuItems.forEach { (text, action) -> + DropdownMenuItem( + text = { Text(text = text) }, + onClick = { + expanded = false + action() + }, + ) + } + } + } + }, + ) +} + @Composable private fun ExtensionHeader( textRes: StringResource, diff --git a/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt index 6668840b2..059533565 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/MigrateSourceScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.items import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -16,6 +17,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import eu.kanade.domain.source.interactor.SetMigrateSorting import eu.kanade.presentation.browse.components.BaseSourceItem import eu.kanade.presentation.browse.components.SourceIcon @@ -156,7 +158,7 @@ private fun MigrateSourceItem( showLanguageInContent = source.lang != "", onClickItem = onClickItem, onLongClickItem = onLongClickItem, - icon = { SourceIcon(source = source) }, + icon = { SourceIcon(source = source, modifier = Modifier.size(48.dp)) }, action = { BadgeGroup { Badge(text = "$count") diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BaseSourceItem.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BaseSourceItem.kt index eebb35253..aa31133c4 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BaseSourceItem.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BaseSourceItem.kt @@ -3,12 +3,14 @@ package eu.kanade.presentation.browse.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import eu.kanade.tachiyomi.util.system.LocaleHelper import tachiyomi.domain.source.model.Source import tachiyomi.presentation.core.components.material.padding @@ -39,7 +41,7 @@ fun BaseSourceItem( } private val defaultIcon: @Composable RowScope.(Source) -> Unit = { source -> - SourceIcon(source = source) + SourceIcon(source = source, modifier = Modifier.size(48.dp)) } private val defaultContent: @Composable RowScope.(Source, String?) -> Unit = { source, sourceLangString -> diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/ExtensionPill.kt b/app/src/main/java/eu/kanade/presentation/browse/components/ExtensionPill.kt new file mode 100644 index 000000000..43ad1b267 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/ExtensionPill.kt @@ -0,0 +1,32 @@ +package eu.kanade.presentation.browse.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import tachiyomi.presentation.core.components.material.padding + +@Composable +internal fun ExtensionPill( + text: String, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + Text( + text = text, + modifier = modifier + .background(color = color.copy(alpha = PILL_BACKGROUND_ALPHA), shape = MaterialTheme.shapes.small) + .padding(horizontal = MaterialTheme.padding.small, vertical = 2.dp), + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelSmall, + ) +} + +private const val PILL_BACKGROUND_ALPHA = 0.12f diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt index 1066f46ee..5b126a5e0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt @@ -68,13 +68,26 @@ class ExtensionManager( private val iconMap = mutableMapOf() + @Volatile + private var stores = emptyList() + private val loadedExtensionMapFlow = MutableStateFlow(emptyMap()) val loadedExtensionsFlow = loadedExtensionMapFlow.mapExtensionsWhenInitialized() // Every store's listing, since more than one store can list the same extension private val availableExtensionListFlow = MutableStateFlow(emptyList()) + + // Stores sharing a signing key serve the same apks, so only the newest of their listings is shown. Stores + // with different keys offer different apks, each installable, so each keeps its own. val availableExtensionsFlow = availableExtensionListFlow - .map { extensions -> extensions.associateBy { it.pkgName }.values.toList() } + .map { extensions -> + extensions + .groupBy { it.pkgName to it.store.signingKey } + .values + .map { listings -> + listings.maxWith(compareBy { it.versionCode }.thenBy { it.libVersion }) + } + } .stateIn(scope, SharingStarted.Lazily, emptyList()) private val notLoadedExtensionMapFlow = MutableStateFlow(emptyMap()) @@ -378,23 +391,38 @@ class ExtensionManager( extension.copy( hasUpdate = extension.findUpdate(available) != null, isObsolete = listing == null, + store = if (stores.isEmpty()) extension.store else extension.pickStore(), ) } notLoadedExtensionMapFlow.value = notLoadedExtensionMapFlow.value.mapValues { (_, extension) -> - extension.copy(hasUpdate = extension.findUpdate(available) != null) + extension.copy( + hasUpdate = extension.findUpdate(available) != null, + store = if (stores.isEmpty()) extension.store else extension.pickStore(), + ) } } updatePendingUpdatesCount() } - private fun assignStores(stores: List) { - fun Extension.Installed.signingStore() = stores.firstOrNull { it.signingKey in signatures } + /** + * Several stores can share a signing key, so one that lists the extension names where it comes from better + * than whichever of them was added first. + */ + private fun Extension.Installed.pickStore(): ExtensionStore? { + val signingStores = stores.filter { it.signingKey in signatures } + val listedBy = availableExtensionListFlow.value + .filter { it.pkgName == pkgName } + .mapTo(HashSet()) { it.store.indexUrl } + return signingStores.firstOrNull { it.indexUrl in listedBy } ?: signingStores.firstOrNull() + } + private fun assignStores(stores: List) { + this.stores = stores loadedExtensionMapFlow.update { extensions -> - extensions.mapValues { (_, extension) -> extension.copy(store = extension.signingStore()) } + extensions.mapValues { (_, extension) -> extension.copy(store = extension.pickStore()) } } notLoadedExtensionMapFlow.update { extensions -> - extensions.mapValues { (_, extension) -> extension.copy(store = extension.signingStore()) } + extensions.mapValues { (_, extension) -> extension.copy(store = extension.pickStore()) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/Installer.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/Installer.kt index 794b0885a..84194bb26 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/Installer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/Installer.kt @@ -106,7 +106,12 @@ abstract class Installer(private val service: Service) { @CallSuper open fun onDestroy() { cancelListeners -= cancelListener - queue.forEach { extensionManager.updateInstallStep(it.downloadId, InstallStep.Error) } + queue.forEach { + extensionManager.updateInstallStep( + it.downloadId, + InstallStep.Error("The installer stopped before it got to this one"), + ) + } queue.clear() waitingInstall.store(null) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt index e65862330..6c3b81238 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/PackageInstallerInstaller.kt @@ -24,7 +24,8 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic private val packageActionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) { + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + when (status) { PackageInstaller.STATUS_PENDING_USER_ACTION -> { val userAction = intent.getParcelableExtraCompat(Intent.EXTRA_INTENT) ?.run { @@ -43,7 +44,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic } if (userAction == null) { logcat(LogPriority.ERROR) { "Fatal error for $intent" } - continueQueue(InstallStep.Error) + continueQueue(InstallStep.Error("The system installer asked for an action it didn't provide")) return } userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @@ -53,7 +54,12 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic continueQueue(InstallStep.Idle) } PackageInstaller.STATUS_SUCCESS -> continueQueue(InstallStep.Installed) - else -> continueQueue(InstallStep.Error) + else -> continueQueue( + InstallStep.Error( + intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + ?: "The system installer failed ($status)", + ), + ) } } } @@ -99,7 +105,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic activeSession?.let { (_, sessionId) -> packageInstaller.abandonSession(sessionId) } - continueQueue(InstallStep.Error) + continueQueue(InstallStep.Error.from(e)) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt index cd2670746..001195749 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/installer/ShizukuInstaller.kt @@ -72,7 +72,7 @@ class ShizukuInstaller(private val service: Service) : Installer(service) { continueQueue(InstallStep.Installed) } else { logcat(LogPriority.ERROR) { "Failed to install extension $packageName: $message" } - continueQueue(InstallStep.Error) + continueQueue(InstallStep.Error(message ?: "Shizuku failed to install it ($status)")) } } } @@ -124,7 +124,7 @@ class ShizukuInstaller(private val service: Service) : Installer(service) { service.contentResolver.delete(entry.uri, null, null) } catch (e: Exception) { logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" } - continueQueue(InstallStep.Error) + continueQueue(InstallStep.Error.from(e)) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt index cd9b40fad..413604f38 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt @@ -1,15 +1,28 @@ package eu.kanade.tachiyomi.extension.model -enum class InstallStep { - Idle, - Pending, - Downloading, - Installing, - Installed, - Error, - ; +sealed interface InstallStep { + data object Idle : InstallStep + data object Pending : InstallStep + data object Downloading : InstallStep + data object Installing : InstallStep + data object Installed : InstallStep + + /** + * [message] says what went wrong; [stackTrace] is there when an exception did. + */ + data class Error(val message: String, val stackTrace: String? = null) : InstallStep { + companion object { + fun from(e: Throwable): Error { + val root = generateSequence(e) { it.cause }.last() + return Error( + message = root.message ?: root::class.simpleName.orEmpty(), + stackTrace = e.stackTraceToString(), + ) + } + } + } fun isCompleted(): Boolean { - return this == Installed || this == Error || this == Idle + return this == Installed || this is Error || this == Idle } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt index 68c734155..93cf8dcd1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt @@ -77,7 +77,7 @@ class ExtensionInstallActivity : Activity() { val newStep = when (resultCode) { RESULT_OK -> InstallStep.Installed RESULT_CANCELED -> InstallStep.Idle - else -> InstallStep.Error + else -> InstallStep.Error("The installer reported a failure") } extensionManager.updateInstallStep(downloadId, newStep) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt index 50d5813db..6ab08725f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt @@ -60,7 +60,7 @@ class ExtensionInstaller( val downloadId = extension.pkgName.hashCode().toLong() cancelInstall(extension.pkgName) - val step = MutableStateFlow(InstallStep.Pending) + val step = MutableStateFlow(InstallStep.Pending) activeSteps[downloadId] = step val job = scope.launch { @@ -94,7 +94,7 @@ class ExtensionInstaller( // Canceled } else { logcat(LogPriority.ERROR, e) - step.value = InstallStep.Error + step.value = InstallStep.Error.from(e) } } } @@ -155,14 +155,11 @@ class ExtensionInstaller( private fun installApkPrivately(downloadId: Long, tempFile: File, packageInfo: PackageInfo) { try { - if (ExtensionLoader.installPrivateExtensionFile(context, tempFile, packageInfo)) { - updateInstallStep(downloadId, InstallStep.Installed) - } else { - updateInstallStep(downloadId, InstallStep.Error) - } + ExtensionLoader.installPrivateExtensionFile(context, tempFile, packageInfo) + updateInstallStep(downloadId, InstallStep.Installed) } catch (e: Exception) { - logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." } - updateInstallStep(downloadId, InstallStep.Error) + logcat(LogPriority.ERROR, e) { "Failed to install extension privately" } + updateInstallStep(downloadId, InstallStep.Error.from(e)) } tempFile.delete() diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt index 8a26ef096..859dbcb75 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt @@ -60,44 +60,35 @@ internal object ExtensionLoader { private fun getPrivateExtensionDir(context: Context) = File(context.filesDir, "exts") - fun installPrivateExtensionFile(context: Context, file: File, extension: PackageInfo): Boolean { - if (!isPackageAnExtension(extension)) return false + fun installPrivateExtensionFile(context: Context, file: File, extension: PackageInfo) { + check(isPackageAnExtension(extension)) { "${extension.packageName} isn't an extension" } val currentExtension = getExtensionPackageInfoFromPkgName(context, extension.packageName) if (currentExtension != null) { - if (PackageInfoCompat.getLongVersionCode(extension) < - PackageInfoCompat.getLongVersionCode(currentExtension) - ) { - logcat(LogPriority.ERROR) { "Installed extension version is higher. Downgrading is not allowed." } - return false - } + check( + PackageInfoCompat.getLongVersionCode(extension) >= + PackageInfoCompat.getLongVersionCode(currentExtension), + ) { "The installed version is newer, and downgrading isn't allowed" } val extensionSignatures = getSignatures(extension) - if (extensionSignatures.isNullOrEmpty()) { - logcat(LogPriority.ERROR) { "Extension to be installed is not signed." } - return false - } - - if (!extensionSignatures.containsAll(getSignatures(currentExtension)!!)) { - logcat(LogPriority.ERROR) { "Installed extension signature is not matched." } - return false + check(!extensionSignatures.isNullOrEmpty()) { "The extension isn't signed" } + check(extensionSignatures.containsAll(getSignatures(currentExtension)!!)) { + "It isn't signed with the same key as the installed extension" } } val target = File(getPrivateExtensionDir(context), "${extension.packageName}.$PRIVATE_EXTENSION_EXTENSION") - return try { + try { target.delete() file.copyAndSetReadOnlyTo(target, overwrite = true) - if (currentExtension != null) { - ExtensionInstallReceiver.notifyReplaced(context, extension.packageName) - } else { - ExtensionInstallReceiver.notifyAdded(context, extension.packageName) - } - true } catch (e: Exception) { - logcat(LogPriority.ERROR, e) { "Failed to copy extension file." } target.delete() - false + throw e + } + if (currentExtension != null) { + ExtensionInstallReceiver.notifyReplaced(context, extension.packageName) + } else { + ExtensionInstallReceiver.notifyAdded(context, extension.packageName) } } diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index db4be5b3e..0917e50ce 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -343,6 +343,9 @@ Filtered Not loaded Couldn\'t load + Couldn\'t install + Why it failed + Why it isn\'t loaded Copy stack trace This extension\'s content warning isn\'t one you chose to load, so its sources aren\'t available. This extension isn\'t signed, so there\'s no signature to check it against. @@ -379,7 +382,6 @@ Downloading… (%d%%) - Open source repo Extension stores