Show an extension's store and install failures in the extension list (#3955)

This commit is contained in:
AntsyLich
2026-09-28 08:55:09 +06:00
committed by GitHub
parent 7f4c55176f
commit 9b5e0ae735
15 changed files with 494 additions and 276 deletions
@@ -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<Extension.Available, String>(String.CASE_INSENSITIVE_ORDER) { it.name }
.thenBy { it.store.signingKey },
)
Extensions(
updates = updates,
@@ -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<ExtensionSourceItem>,
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,
)
}
}
@@ -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<Extension.NotLoaded?>(null) }
var installErrorState by remember { mutableStateOf<InstallStep.Error?>(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<Pair<String, () -> 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,
@@ -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")
@@ -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 ->
@@ -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
@@ -68,13 +68,26 @@ class ExtensionManager(
private val iconMap = mutableMapOf<String, Drawable>()
@Volatile
private var stores = emptyList<ExtensionStore>()
private val loadedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Loaded>())
val loadedExtensionsFlow = loadedExtensionMapFlow.mapExtensionsWhenInitialized()
// Every store's listing, since more than one store can list the same extension
private val availableExtensionListFlow = MutableStateFlow(emptyList<Extension.Available>())
// 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<Extension.Available> { it.versionCode }.thenBy { it.libVersion })
}
}
.stateIn(scope, SharingStarted.Lazily, emptyList())
private val notLoadedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.NotLoaded>())
@@ -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<ExtensionStore>) {
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<ExtensionStore>) {
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()) }
}
}
@@ -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)
}
@@ -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>(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))
}
}
@@ -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))
}
}
@@ -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
}
}
@@ -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)
}
@@ -60,7 +60,7 @@ class ExtensionInstaller(
val downloadId = extension.pkgName.hashCode().toLong()
cancelInstall(extension.pkgName)
val step = MutableStateFlow(InstallStep.Pending)
val step = MutableStateFlow<InstallStep>(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()
@@ -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)
}
}
@@ -343,6 +343,9 @@
<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_install_error">Couldn\'t install</string>
<string name="ext_install_error_details">Why it failed</string>
<string name="ext_not_loaded_details">Why it isn\'t loaded</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>
@@ -379,7 +382,6 @@
<string name="downloading_with_progress">Downloading… (%d%%)</string>
<!-- Extension repos -->
<string name="action_open_repo">Open source repo</string>
<!-- Extension store -->
<string name="extensionStores">Extension stores</string>