Use plain icon buttons for extension actions

This commit is contained in:
AntsyLich
2026-10-10 17:37:43 +06:00
parent e9234f205d
commit 8972882a34
9 changed files with 78 additions and 241 deletions
@@ -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.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Button
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
@@ -336,7 +336,7 @@ private fun DetailsHeader(
}
if (onClickAppInfo != null) {
FilledTonalButton(
Button(
modifier = Modifier.weight(1f),
onClick = onClickAppInfo,
) {
@@ -434,10 +434,11 @@ private fun SourceSwitchPreference(
verticalAlignment = Alignment.CenterVertically,
) {
if (source.source is ConfigurableSource) {
FilledTonalIconButton(onClick = { onClickSourcePreferences(source.source.id) }) {
IconButton(onClick = { onClickSourcePreferences(source.source.id) }) {
Icon(
imageVector = MaterialSymbols.Rounded.Settings,
contentDescription = stringResource(MR.strings.label_settings),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
@@ -10,7 +10,6 @@ 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
@@ -18,14 +17,10 @@ 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.SplitButtonDefaults
import androidx.compose.material3.SplitButtonLayout
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -36,11 +31,9 @@ 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
@@ -49,7 +42,6 @@ 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.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
@@ -65,7 +57,7 @@ import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.Close
import mihon.icons.materialsymbols.rounded.Download
import mihon.icons.materialsymbols.rounded.Info
import mihon.icons.materialsymbols.rounded.KeyboardArrowDown
import mihon.icons.materialsymbols.rounded.Public
import mihon.icons.materialsymbols.rounded.Refresh
import mihon.icons.materialsymbols.rounded.Settings
import mihon.icons.materialsymbols.rounded.VerifiedUser
@@ -160,7 +152,6 @@ 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(
@@ -248,7 +239,6 @@ private fun ExtensionContent(
}
},
onClickItemCancel = onClickItemCancel,
onClickItemError = { installErrorState = it },
onClickItemAction = {
when (it) {
is Extension.Available -> onInstallExtension(it)
@@ -297,55 +287,6 @@ 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
@@ -354,7 +295,6 @@ 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,
@@ -377,7 +317,6 @@ private fun ExtensionItem(
extension = extension,
installStep = installStep,
onClickItemCancel = onClickItemCancel,
onClickItemError = onClickItemError,
onClickItemAction = onClickItemAction,
onClickItemSecondaryAction = onClickItemSecondaryAction,
)
@@ -518,152 +457,56 @@ private fun ExtensionItemActions(
installStep: InstallStep,
modifier: Modifier = Modifier,
onClickItemCancel: (Extension) -> Unit = {},
onClickItemError: (InstallStep.Error) -> Unit = {},
onClickItemAction: (Extension) -> Unit = {},
onClickItemSecondaryAction: (Extension) -> Unit = {},
) {
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 {
!installStep.isCompleted() -> {
if (!installStep.isCompleted()) {
IconButton(onClick = { onClickItemCancel(extension) }) {
Icon(
imageVector = MaterialSymbols.Rounded.Close,
contentDescription = stringResource(MR.strings.action_cancel),
)
}
return@Row
}
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,
),
)
val secondaryAction = when (extension) {
is Extension.Available -> {
(MaterialSymbols.Rounded.Public to MR.strings.action_open_in_web_view)
.takeIf { extension.sources.isNotEmpty() }
}
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.Settings,
contentDescription = stringResource(MR.strings.action_settings),
)
}
}
extension is Extension.NotLoaded -> {
IconButton(onClick = { onClickItemSecondaryAction(extension) }) {
Icon(
imageVector = if (isUntrusted) {
MaterialSymbols.Rounded.VerifiedUser
is Extension.Loaded -> MaterialSymbols.Rounded.Settings to MR.strings.action_settings
is Extension.NotLoaded -> if (extension.reason is Extension.NotLoaded.Reason.Untrusted) {
MaterialSymbols.Rounded.VerifiedUser to MR.strings.ext_trust
} else {
MaterialSymbols.Rounded.Info
},
contentDescription = secondaryAction?.first,
)
MaterialSymbols.Rounded.Info to MR.strings.ext_not_loaded_details
}
}
secondaryAction?.let { (icon, label) ->
IconButton(onClick = { onClickItemSecondaryAction(extension) }) {
Icon(imageVector = icon, contentDescription = stringResource(label))
}
}
}
@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)),
)
val action = when {
installStep == InstallStep.Error -> MaterialSymbols.Rounded.Refresh to MR.strings.action_retry
extension is Extension.Available -> MaterialSymbols.Rounded.Download to MR.strings.ext_install
extension is Extension.Installed && extension.hasUpdate -> {
MaterialSymbols.Rounded.Download to MR.strings.ext_update
}
},
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.KeyboardArrowDown,
contentDescription = stringResource(MR.strings.action_menu),
modifier = Modifier.size(SplitButtonDefaults.trailingButtonIconSizeFor(size)),
)
else -> null
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
offset = DpOffset.Zero,
) {
menuItems.forEach { (text, action) ->
DropdownMenuItem(
text = { Text(text = text) },
onClick = {
expanded = false
action()
},
)
action?.let { (icon, label) ->
IconButton(onClick = { onClickItemAction(extension) }) {
Icon(imageVector = icon, contentDescription = stringResource(label))
}
}
}
},
)
}
@Composable
@@ -5,7 +5,9 @@ import android.net.Uri
import androidx.annotation.CallSuper
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.InstallStep
import logcat.LogPriority
import mihon.app.di.appGraph
import tachiyomi.core.common.util.system.logcat
import java.util.Collections
import java.util.concurrent.CopyOnWriteArraySet
import kotlin.concurrent.atomics.AtomicReference
@@ -107,10 +109,8 @@ abstract class Installer(private val service: Service) {
open fun onDestroy() {
cancelListeners -= cancelListener
queue.forEach {
extensionManager.updateInstallStep(
it.downloadId,
InstallStep.Error("The installer stopped before it got to this one"),
)
logcat(LogPriority.INFO) { "The installer stopped before installing ${it.downloadId}" }
extensionManager.updateInstallStep(it.downloadId, InstallStep.Error)
}
queue.clear()
waitingInstall.store(null)
@@ -43,8 +43,10 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
.sanitizeByFiltering(this)
}
if (userAction == null) {
logcat(LogPriority.ERROR) { "Fatal error for $intent" }
continueQueue(InstallStep.Error("The system installer asked for an action it didn't provide"))
logcat(LogPriority.INFO) {
"The system installer asked for an action it didn't provide: $intent"
}
continueQueue(InstallStep.Error)
return
}
userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -54,12 +56,14 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
continueQueue(InstallStep.Idle)
}
PackageInstaller.STATUS_SUCCESS -> continueQueue(InstallStep.Installed)
else -> continueQueue(
InstallStep.Error(
intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
?: "The system installer failed ($status)",
),
)
else -> {
logcat(LogPriority.INFO) {
"The system installer failed ($status): ${intent.getStringExtra(
PackageInstaller.EXTRA_STATUS_MESSAGE,
)}"
}
continueQueue(InstallStep.Error)
}
}
}
}
@@ -101,11 +105,11 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
session.commit(intentSender)
}
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
logcat(LogPriority.INFO, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
activeSession?.let { (_, sessionId) ->
packageInstaller.abandonSession(sessionId)
}
continueQueue(InstallStep.Error.from(e))
continueQueue(InstallStep.Error)
}
}
@@ -71,8 +71,8 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
if (status == PackageInstaller.STATUS_SUCCESS) {
continueQueue(InstallStep.Installed)
} else {
logcat(LogPriority.ERROR) { "Failed to install extension $packageName: $message" }
continueQueue(InstallStep.Error(message ?: "Shizuku failed to install it ($status)"))
logcat(LogPriority.INFO) { "Shizuku failed to install $packageName ($status): $message" }
continueQueue(InstallStep.Error)
}
}
}
@@ -123,8 +123,8 @@ 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.from(e))
logcat(LogPriority.INFO, e) { "Failed to install extension ${entry.downloadId} ${entry.uri}" }
continueQueue(InstallStep.Error)
}
}
@@ -6,23 +6,9 @@ sealed interface 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(),
)
}
}
}
data object Error : InstallStep
fun isCompleted(): Boolean {
return this == Installed || this is Error || this == Idle
return this == Installed || this == Error || this == Idle
}
}
@@ -8,7 +8,9 @@ import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.system.hasMiuiPackageInstaller
import eu.kanade.tachiyomi.util.system.toast
import logcat.LogPriority
import mihon.app.di.appGraph
import tachiyomi.core.common.util.system.logcat
import kotlin.time.Duration.Companion.seconds
/**
@@ -77,7 +79,10 @@ class ExtensionInstallActivity : Activity() {
val newStep = when (resultCode) {
RESULT_OK -> InstallStep.Installed
RESULT_CANCELED -> InstallStep.Idle
else -> InstallStep.Error("The installer reported a failure")
else -> {
logcat(LogPriority.INFO) { "The installer reported a failure ($resultCode)" }
InstallStep.Error
}
}
extensionManager.updateInstallStep(downloadId, newStep)
}
@@ -93,8 +93,8 @@ class ExtensionInstaller(
if (e is InterruptedException) {
// Canceled
} else {
logcat(LogPriority.ERROR, e)
step.value = InstallStep.Error.from(e)
logcat(LogPriority.INFO, e)
step.value = InstallStep.Error
}
}
}
@@ -158,8 +158,8 @@ class ExtensionInstaller(
ExtensionLoader.installPrivateExtensionFile(context, tempFile, packageInfo)
updateInstallStep(downloadId, InstallStep.Installed)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to install extension privately" }
updateInstallStep(downloadId, InstallStep.Error.from(e))
logcat(LogPriority.INFO, e) { "Failed to install extension privately" }
updateInstallStep(downloadId, InstallStep.Error)
}
tempFile.delete()
@@ -347,8 +347,6 @@
<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>