mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-19 23:22:26 -07:00
Show an extension's store and install failures in the extension list (#3955)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+10
-4
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user