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 val available = _available
.filter { extension -> .filter { extension ->
_loaded.none { it.pkgName == extension.pkgName } && (_loaded + _notLoaded).none {
_notLoaded.none { it.pkgName == extension.pkgName } && it.pkgName == extension.pkgName && extension.store.signingKey in it.signatures
} &&
extension.contentWarning in enabledContentWarnings extension.contentWarning in enabledContentWarnings
} }
.flatMap { ext -> .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( Extensions(
updates = updates, updates = updates,
@@ -15,10 +15,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog 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.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
@@ -32,6 +32,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -43,6 +44,7 @@ import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.extension.interactor.ExtensionSourceItem import eu.kanade.domain.extension.interactor.ExtensionSourceItem
import eu.kanade.presentation.browse.components.ExtensionIcon 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.browse.components.label
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions 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.LocaleHelper
import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.copyToClipboard
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.automirroredrounded.OpenInNew
import mihon.icons.materialsymbols.rounded.Settings import mihon.icons.materialsymbols.rounded.Settings
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.ScrollbarLazyColumn import tachiyomi.presentation.core.components.ScrollbarLazyColumn
@@ -77,15 +78,6 @@ fun ExtensionDetailsScreen(
onClickIncognito: (Boolean) -> Unit, onClickIncognito: (Boolean) -> Unit,
) { ) {
val uriHandler = LocalUriHandler.current 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( Scaffold(
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
@@ -94,35 +86,20 @@ fun ExtensionDetailsScreen(
navigateUp = navigateUp, navigateUp = navigateUp,
actions = { actions = {
AppBarActions( AppBarActions(
actions = buildList { actions = listOf(
if (url != null) { AppBar.OverflowAction(
add( title = stringResource(MR.strings.action_enable_all),
AppBar.Action( onClick = onClickEnableAll,
title = stringResource(MR.strings.action_open_repo), ),
icon = MaterialSymbols.AutoMirroredRounded.OpenInNew, AppBar.OverflowAction(
onClick = { title = stringResource(MR.strings.action_disable_all),
uriHandler.openUri(url) onClick = onClickDisableAll,
}, ),
), AppBar.OverflowAction(
) title = stringResource(MR.strings.pref_clear_cookies),
} onClick = onClickClearCookies,
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,
),
),
)
},
) )
}, },
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
@@ -132,6 +109,11 @@ fun ExtensionDetailsScreen(
ExtensionDetails( ExtensionDetails(
contentPadding = paddingValues, contentPadding = paddingValues,
extension = state.extension, extension = state.extension,
onClickStore = state.extension.store
?.contact
?.website
?.takeIf { it.isNotBlank() }
?.let { website -> { uriHandler.openUri(website) } },
sources = state.sources, sources = state.sources,
incognitoMode = state.isIncognito, incognitoMode = state.isIncognito,
onClickSourcePreferences = onClickSourcePreferences, onClickSourcePreferences = onClickSourcePreferences,
@@ -146,6 +128,7 @@ fun ExtensionDetailsScreen(
private fun ExtensionDetails( private fun ExtensionDetails(
contentPadding: PaddingValues, contentPadding: PaddingValues,
extension: Extension.Loaded, extension: Extension.Loaded,
onClickStore: (() -> Unit)?,
sources: List<ExtensionSourceItem>, sources: List<ExtensionSourceItem>,
incognitoMode: Boolean, incognitoMode: Boolean,
onClickSourcePreferences: (sourceId: Long) -> Unit, onClickSourcePreferences: (sourceId: Long) -> Unit,
@@ -178,6 +161,7 @@ private fun ExtensionDetails(
} }
Unit Unit
}.takeIf { extension.isShared }, }.takeIf { extension.isShared },
onClickStore = onClickStore,
onClickContentWarning = { onClickContentWarning = {
showContentWarning = true showContentWarning = true
}, },
@@ -210,8 +194,9 @@ private fun ExtensionDetails(
@Composable @Composable
private fun DetailsHeader( private fun DetailsHeader(
extension: Extension, extension: Extension.Installed,
extIncognitoMode: Boolean, extIncognitoMode: Boolean,
onClickStore: (() -> Unit)?,
onClickContentWarning: () -> Unit, onClickContentWarning: () -> Unit,
onClickUninstall: () -> Unit, onClickUninstall: () -> Unit,
onClickAppInfo: (() -> Unit)?, onClickAppInfo: (() -> Unit)?,
@@ -271,12 +256,35 @@ private fun DetailsHeader(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
val strippedPkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
Text( Text(
text = strippedPkgName, text = extension.pkgName,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall, 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( Row(
@@ -330,14 +338,11 @@ private fun DetailsHeader(
} }
if (onClickAppInfo != null) { if (onClickAppInfo != null) {
Button( FilledTonalButton(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onClick = onClickAppInfo, onClick = onClickAppInfo,
) { ) {
Text( Text(stringResource(MR.strings.ext_app_info))
text = stringResource(MR.strings.ext_app_info),
color = MaterialTheme.colorScheme.onPrimary,
)
} }
} }
} }
@@ -431,11 +436,10 @@ private fun SourceSwitchPreference(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (source.source is ConfigurableSource) { if (source.source is ConfigurableSource) {
IconButton(onClick = { onClickSourcePreferences(source.source.id) }) { FilledTonalIconButton(onClick = { onClickSourcePreferences(source.source.id) }) {
Icon( Icon(
imageVector = MaterialSymbols.Rounded.Settings, imageVector = MaterialSymbols.Rounded.Settings,
contentDescription = stringResource(MR.strings.label_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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -30,18 +35,22 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.presentation.browse.components.BaseBrowseItem import eu.kanade.presentation.browse.components.BaseBrowseItem
import eu.kanade.presentation.browse.components.ExtensionIcon 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.browse.components.label
import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.presentation.components.WarningBanner 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.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
import eu.kanade.tachiyomi.extension.model.Extension 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.LocaleHelper
import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
import mihon.domain.extension.model.ExtensionStore
import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.Close import mihon.icons.materialsymbols.rounded.Close
import mihon.icons.materialsymbols.rounded.Download import mihon.icons.materialsymbols.rounded.Download
import mihon.icons.materialsymbols.rounded.ExpandMore
import mihon.icons.materialsymbols.rounded.Info import mihon.icons.materialsymbols.rounded.Info
import mihon.icons.materialsymbols.rounded.Public
import mihon.icons.materialsymbols.rounded.Refresh import mihon.icons.materialsymbols.rounded.Refresh
import mihon.icons.materialsymbols.rounded.Settings import mihon.icons.materialsymbols.rounded.Settings
import mihon.icons.materialsymbols.rounded.VerifiedUser import mihon.icons.materialsymbols.rounded.VerifiedUser
@@ -71,7 +81,6 @@ import tachiyomi.presentation.core.screens.EmptyScreenAction
import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.theme.header import tachiyomi.presentation.core.theme.header
import tachiyomi.presentation.core.util.plus import tachiyomi.presentation.core.util.plus
import tachiyomi.presentation.core.util.secondaryItemAlpha
@Composable @Composable
fun ExtensionScreen( fun ExtensionScreen(
@@ -151,6 +160,7 @@ private fun ExtensionContent(
) { ) {
val context = LocalContext.current val context = LocalContext.current
var notLoadedState by remember { mutableStateOf<Extension.NotLoaded?>(null) } var notLoadedState by remember { mutableStateOf<Extension.NotLoaded?>(null) }
var installErrorState by remember { mutableStateOf<InstallStep.Error?>(null) }
val installGranted = rememberRequestPackageInstallsPermissionState(initialValue = true) val installGranted = rememberRequestPackageInstallsPermissionState(initialValue = true)
FastScrollLazyColumn( FastScrollLazyColumn(
@@ -238,6 +248,7 @@ private fun ExtensionContent(
} }
}, },
onClickItemCancel = onClickItemCancel, onClickItemCancel = onClickItemCancel,
onClickItemError = { installErrorState = it },
onClickItemAction = { onClickItemAction = {
when (it) { when (it) {
is Extension.Available -> onInstallExtension(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 @Composable
@@ -294,50 +354,30 @@ private fun ExtensionItem(
onClickItem: (Extension) -> Unit, onClickItem: (Extension) -> Unit,
onLongClickItem: (Extension) -> Unit, onLongClickItem: (Extension) -> Unit,
onClickItemCancel: (Extension) -> Unit, onClickItemCancel: (Extension) -> Unit,
onClickItemError: (InstallStep.Error) -> Unit,
onClickItemAction: (Extension) -> Unit, onClickItemAction: (Extension) -> Unit,
onClickItemSecondaryAction: (Extension) -> Unit, onClickItemSecondaryAction: (Extension) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val (extension, installStep) = item val (extension, installStep) = item
val store = when (extension) {
is Extension.Available -> extension.store
is Extension.Installed -> extension.store
}
BaseBrowseItem( BaseBrowseItem(
modifier = modifier modifier = modifier,
.combinedClickable(
onClick = { onClickItem(extension) },
onLongClick = { onLongClickItem(extension) },
),
onClickItem = { onClickItem(extension) }, onClickItem = { onClickItem(extension) },
onLongClickItem = { onLongClickItem(extension) }, onLongClickItem = { onLongClickItem(extension) },
icon = { icon = {
Box( ExtensionItemIcon(extension = extension, installStep = installStep)
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),
)
}
}, },
action = { action = {
ExtensionItemActions( ExtensionItemActions(
extension = extension, extension = extension,
installStep = installStep, installStep = installStep,
onClickItemCancel = onClickItemCancel, onClickItemCancel = onClickItemCancel,
onClickItemError = onClickItemError,
onClickItemAction = onClickItemAction, onClickItemAction = onClickItemAction,
onClickItemSecondaryAction = onClickItemSecondaryAction, onClickItemSecondaryAction = onClickItemSecondaryAction,
) )
@@ -346,6 +386,7 @@ private fun ExtensionItem(
ExtensionItemContent( ExtensionItemContent(
extension = extension, extension = extension,
installStep = installStep, installStep = installStep,
store = store,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@@ -355,6 +396,7 @@ private fun ExtensionItem(
private fun ExtensionItemContent( private fun ExtensionItemContent(
extension: Extension, extension: Extension,
installStep: InstallStep, installStep: InstallStep,
store: ExtensionStore?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@@ -366,68 +408,106 @@ private fun ExtensionItemContent(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium, 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 (store != null) {
if (hasAlreadyShownAnElement) DotSeparatorNoSpaceText() Text(
hasAlreadyShownAnElement = true text = store.name,
Text( maxLines = 1,
text = extension.versionName, overflow = TextOverflow.Ellipsis,
) style = MaterialTheme.typography.bodySmall,
} color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val warnings = listOfNotNull( ExtensionItemMetadata(extension = extension, installStep = installStep)
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,
)
}
if (extension is Extension.Loaded && !extension.isShared) { @Composable
if (hasAlreadyShownAnElement) DotSeparatorNoSpaceText() private fun ExtensionItemIcon(
Text( extension: Extension,
text = stringResource(MR.strings.ext_installer_private), 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()) { val padding by animateDpAsState(
DotSeparatorNoSpaceText() targetValue = if (idle) 0.dp else 8.dp,
Text( label = "iconPadding",
text = when (installStep) { )
InstallStep.Pending -> stringResource(MR.strings.ext_pending) ExtensionIcon(
InstallStep.Downloading -> stringResource(MR.strings.ext_downloading) extension = extension,
InstallStep.Installing -> stringResource(MR.strings.ext_installing) modifier = Modifier
else -> error("Must not show non-install process text") .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, installStep: InstallStep,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClickItemCancel: (Extension) -> Unit = {}, onClickItemCancel: (Extension) -> Unit = {},
onClickItemError: (InstallStep.Error) -> Unit = {},
onClickItemAction: (Extension) -> Unit = {}, onClickItemAction: (Extension) -> Unit = {},
onClickItemSecondaryAction: (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( Row(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
verticalAlignment = Alignment.CenterVertically,
) { ) {
when { when {
!isIdle -> { !installStep.isCompleted() -> {
IconButton(onClick = { onClickItemCancel(extension) }) { IconButton(onClick = { onClickItemCancel(extension) }) {
Icon( Icon(
imageVector = MaterialSymbols.Rounded.Close, imageVector = MaterialSymbols.Rounded.Close,
@@ -456,84 +549,123 @@ private fun ExtensionItemActions(
) )
} }
} }
installStep == InstallStep.Error -> { installStep is InstallStep.Error -> {
IconButton(onClick = { onClickItemAction(extension) }) { 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( Icon(
imageVector = MaterialSymbols.Rounded.Refresh, imageVector = MaterialSymbols.Rounded.Settings,
contentDescription = stringResource(MR.strings.action_retry), contentDescription = stringResource(MR.strings.action_settings),
) )
} }
} }
installStep == InstallStep.Idle -> { extension is Extension.NotLoaded -> {
when (extension) { IconButton(onClick = { onClickItemSecondaryAction(extension) }) {
is Extension.Loaded -> { Icon(
IconButton(onClick = { onClickItemSecondaryAction(extension) }) { imageVector = if (isUntrusted) {
Icon( MaterialSymbols.Rounded.VerifiedUser
imageVector = MaterialSymbols.Rounded.Settings, } else {
contentDescription = stringResource(MR.strings.action_settings), MaterialSymbols.Rounded.Info
) },
} contentDescription = secondaryAction?.first,
)
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),
)
}
}
} }
} }
} }
} }
} }
@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 @Composable
private fun ExtensionHeader( private fun ExtensionHeader(
textRes: StringResource, textRes: StringResource,
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -16,6 +17,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.domain.source.interactor.SetMigrateSorting import eu.kanade.domain.source.interactor.SetMigrateSorting
import eu.kanade.presentation.browse.components.BaseSourceItem import eu.kanade.presentation.browse.components.BaseSourceItem
import eu.kanade.presentation.browse.components.SourceIcon import eu.kanade.presentation.browse.components.SourceIcon
@@ -156,7 +158,7 @@ private fun MigrateSourceItem(
showLanguageInContent = source.lang != "", showLanguageInContent = source.lang != "",
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = onLongClickItem, onLongClickItem = onLongClickItem,
icon = { SourceIcon(source = source) }, icon = { SourceIcon(source = source, modifier = Modifier.size(48.dp)) },
action = { action = {
BadgeGroup { BadgeGroup {
Badge(text = "$count") Badge(text = "$count")
@@ -3,12 +3,14 @@ package eu.kanade.presentation.browse.components
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import tachiyomi.domain.source.model.Source import tachiyomi.domain.source.model.Source
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
@@ -39,7 +41,7 @@ fun BaseSourceItem(
} }
private val defaultIcon: @Composable RowScope.(Source) -> Unit = { source -> 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 -> 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>() private val iconMap = mutableMapOf<String, Drawable>()
@Volatile
private var stores = emptyList<ExtensionStore>()
private val loadedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Loaded>()) private val loadedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Loaded>())
val loadedExtensionsFlow = loadedExtensionMapFlow.mapExtensionsWhenInitialized() val loadedExtensionsFlow = loadedExtensionMapFlow.mapExtensionsWhenInitialized()
// Every store's listing, since more than one store can list the same extension // Every store's listing, since more than one store can list the same extension
private val availableExtensionListFlow = MutableStateFlow(emptyList<Extension.Available>()) 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 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()) .stateIn(scope, SharingStarted.Lazily, emptyList())
private val notLoadedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.NotLoaded>()) private val notLoadedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.NotLoaded>())
@@ -378,23 +391,38 @@ class ExtensionManager(
extension.copy( extension.copy(
hasUpdate = extension.findUpdate(available) != null, hasUpdate = extension.findUpdate(available) != null,
isObsolete = listing == null, isObsolete = listing == null,
store = if (stores.isEmpty()) extension.store else extension.pickStore(),
) )
} }
notLoadedExtensionMapFlow.value = notLoadedExtensionMapFlow.value.mapValues { (_, extension) -> 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() 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 -> loadedExtensionMapFlow.update { extensions ->
extensions.mapValues { (_, extension) -> extension.copy(store = extension.signingStore()) } extensions.mapValues { (_, extension) -> extension.copy(store = extension.pickStore()) }
} }
notLoadedExtensionMapFlow.update { extensions -> 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 @CallSuper
open fun onDestroy() { open fun onDestroy() {
cancelListeners -= cancelListener 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() queue.clear()
waitingInstall.store(null) waitingInstall.store(null)
} }
@@ -24,7 +24,8 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
private val packageActionReceiver = object : BroadcastReceiver() { private val packageActionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { 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 -> { PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val userAction = intent.getParcelableExtraCompat<Intent>(Intent.EXTRA_INTENT) val userAction = intent.getParcelableExtraCompat<Intent>(Intent.EXTRA_INTENT)
?.run { ?.run {
@@ -43,7 +44,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
} }
if (userAction == null) { if (userAction == null) {
logcat(LogPriority.ERROR) { "Fatal error for $intent" } 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 return
} }
userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -53,7 +54,12 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
continueQueue(InstallStep.Idle) continueQueue(InstallStep.Idle)
} }
PackageInstaller.STATUS_SUCCESS -> continueQueue(InstallStep.Installed) 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) -> activeSession?.let { (_, sessionId) ->
packageInstaller.abandonSession(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) continueQueue(InstallStep.Installed)
} else { } else {
logcat(LogPriority.ERROR) { "Failed to install extension $packageName: $message" } 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) service.contentResolver.delete(entry.uri, null, null)
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" } 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 package eu.kanade.tachiyomi.extension.model
enum class InstallStep { sealed interface InstallStep {
Idle, data object Idle : InstallStep
Pending, data object Pending : InstallStep
Downloading, data object Downloading : InstallStep
Installing, data object Installing : InstallStep
Installed, data object Installed : InstallStep
Error,
; /**
* [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 { 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) { val newStep = when (resultCode) {
RESULT_OK -> InstallStep.Installed RESULT_OK -> InstallStep.Installed
RESULT_CANCELED -> InstallStep.Idle RESULT_CANCELED -> InstallStep.Idle
else -> InstallStep.Error else -> InstallStep.Error("The installer reported a failure")
} }
extensionManager.updateInstallStep(downloadId, newStep) extensionManager.updateInstallStep(downloadId, newStep)
} }
@@ -60,7 +60,7 @@ class ExtensionInstaller(
val downloadId = extension.pkgName.hashCode().toLong() val downloadId = extension.pkgName.hashCode().toLong()
cancelInstall(extension.pkgName) cancelInstall(extension.pkgName)
val step = MutableStateFlow(InstallStep.Pending) val step = MutableStateFlow<InstallStep>(InstallStep.Pending)
activeSteps[downloadId] = step activeSteps[downloadId] = step
val job = scope.launch { val job = scope.launch {
@@ -94,7 +94,7 @@ class ExtensionInstaller(
// Canceled // Canceled
} else { } else {
logcat(LogPriority.ERROR, e) 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) { private fun installApkPrivately(downloadId: Long, tempFile: File, packageInfo: PackageInfo) {
try { try {
if (ExtensionLoader.installPrivateExtensionFile(context, tempFile, packageInfo)) { ExtensionLoader.installPrivateExtensionFile(context, tempFile, packageInfo)
updateInstallStep(downloadId, InstallStep.Installed) updateInstallStep(downloadId, InstallStep.Installed)
} else {
updateInstallStep(downloadId, InstallStep.Error)
}
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." } logcat(LogPriority.ERROR, e) { "Failed to install extension privately" }
updateInstallStep(downloadId, InstallStep.Error) updateInstallStep(downloadId, InstallStep.Error.from(e))
} }
tempFile.delete() tempFile.delete()
@@ -60,44 +60,35 @@ internal object ExtensionLoader {
private fun getPrivateExtensionDir(context: Context) = File(context.filesDir, "exts") private fun getPrivateExtensionDir(context: Context) = File(context.filesDir, "exts")
fun installPrivateExtensionFile(context: Context, file: File, extension: PackageInfo): Boolean { fun installPrivateExtensionFile(context: Context, file: File, extension: PackageInfo) {
if (!isPackageAnExtension(extension)) return false check(isPackageAnExtension(extension)) { "${extension.packageName} isn't an extension" }
val currentExtension = getExtensionPackageInfoFromPkgName(context, extension.packageName) val currentExtension = getExtensionPackageInfoFromPkgName(context, extension.packageName)
if (currentExtension != null) { if (currentExtension != null) {
if (PackageInfoCompat.getLongVersionCode(extension) < check(
PackageInfoCompat.getLongVersionCode(currentExtension) PackageInfoCompat.getLongVersionCode(extension) >=
) { PackageInfoCompat.getLongVersionCode(currentExtension),
logcat(LogPriority.ERROR) { "Installed extension version is higher. Downgrading is not allowed." } ) { "The installed version is newer, and downgrading isn't allowed" }
return false
}
val extensionSignatures = getSignatures(extension) val extensionSignatures = getSignatures(extension)
if (extensionSignatures.isNullOrEmpty()) { check(!extensionSignatures.isNullOrEmpty()) { "The extension isn't signed" }
logcat(LogPriority.ERROR) { "Extension to be installed is not signed." } check(extensionSignatures.containsAll(getSignatures(currentExtension)!!)) {
return false "It isn't signed with the same key as the installed extension"
}
if (!extensionSignatures.containsAll(getSignatures(currentExtension)!!)) {
logcat(LogPriority.ERROR) { "Installed extension signature is not matched." }
return false
} }
} }
val target = File(getPrivateExtensionDir(context), "${extension.packageName}.$PRIVATE_EXTENSION_EXTENSION") val target = File(getPrivateExtensionDir(context), "${extension.packageName}.$PRIVATE_EXTENSION_EXTENSION")
return try { try {
target.delete() target.delete()
file.copyAndSetReadOnlyTo(target, overwrite = true) file.copyAndSetReadOnlyTo(target, overwrite = true)
if (currentExtension != null) {
ExtensionInstallReceiver.notifyReplaced(context, extension.packageName)
} else {
ExtensionInstallReceiver.notifyAdded(context, extension.packageName)
}
true
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to copy extension file." }
target.delete() 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_filtered">Filtered</string>
<string name="ext_not_loaded">Not loaded</string> <string name="ext_not_loaded">Not loaded</string>
<string name="ext_not_loaded_dialog">Couldn\'t load</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_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_filtered_message">This extension\'s content warning isn\'t one you chose to load, so its sources aren\'t available.</string>
<string name="ext_unsigned_message">This extension isn\'t signed, so there\'s no signature to check it against.</string> <string name="ext_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> <string name="downloading_with_progress">Downloading… (%d%%)</string>
<!-- Extension repos --> <!-- Extension repos -->
<string name="action_open_repo">Open source repo</string>
<!-- Extension store --> <!-- Extension store -->
<string name="extensionStores">Extension stores</string> <string name="extensionStores">Extension stores</string>