Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- Automatically remove downloads on Suwayomi after reading, configurable via extension settings ([@cpiber](https://github.com/cpiber)) ([#2673](https://github.com/mihonapp/mihon/pull/2673))
- Display author & artist name in MAL search results ([@MajorTanya](https://github.com/MajorTanya)) ([#2833](https://github.com/mihonapp/mihon/pull/2833))
- Add filter options to Updates tab ([@MajorTanya](https://github.com/MajorTanya)) ([#2851](https://github.com/mihonapp/mihon/pull/2851))
- Add banner and bulk install screen for missing or untrusted extensions used by Library entries ([@c2y5](https://github.com/c2y5)) ([#2895](https://github.com/mihonapp/mihon/pull/2895))

### Improved
- Minimize memory usage by reducing in-memory cover cache size ([@Lolle2000la](https://github.com/Lolle2000la)) ([#2266](https://github.com/mihonapp/mihon/pull/2266))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ 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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.GetApp
Expand All @@ -28,6 +30,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
Expand All @@ -54,6 +57,7 @@ import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel
import eu.kanade.tachiyomi.ui.browse.extension.MassInstallScreen
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
import kotlinx.collections.immutable.persistentListOf
Expand All @@ -62,6 +66,7 @@ import tachiyomi.presentation.core.components.FastScrollLazyColumn
import tachiyomi.presentation.core.components.material.PullRefresh
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.components.material.topSmallPaddingValues
import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.screens.EmptyScreenAction
Expand Down Expand Up @@ -147,12 +152,43 @@ private fun ExtensionContent(
onClickUpdateAll: () -> Unit,
) {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
var trustState by remember { mutableStateOf<Extension.Untrusted?>(null) }
val installGranted = rememberRequestPackageInstallsPermissionState(initialValue = true)

FastScrollLazyColumn(
contentPadding = contentPadding + topSmallPaddingValues,
) {
if (state.missingLibraryExtensions.isNotEmpty() || state.potentialMissingCount > 0) {
item(key = "mass-install-banner") {
Surface(
modifier = Modifier
.padding(horizontal = MaterialTheme.padding.medium, vertical = 6.dp)
.fillMaxWidth()
.clickable {
navigator.push(MassInstallScreen())
},
shape = RoundedCornerShape(12.dp),
tonalElevation = 2.dp,
) {
Row(
modifier = Modifier.padding(MaterialTheme.padding.medium),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val count = if (state.missingLibraryExtensions.isNotEmpty()) {
state.missingLibraryExtensions.size
} else {
state.potentialMissingCount
}
Text(
text = pluralStringResource(MR.plurals.extensions_missing, count = count, count),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
if (!installGranted && state.installer?.requiresSystemPermission == true) {
item(key = "extension-permissions-warning") {
WarningBanner(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import cafe.adriel.voyager.core.model.screenModelScope
import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.extension.interactor.GetExtensionsByType
import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.presentation.components.SEARCH_DEBOUNCE_MILLIS
import eu.kanade.tachiyomi.extension.ExtensionManager
Expand Down Expand Up @@ -39,10 +40,13 @@ class ExtensionsScreenModel(
basePreferences: BasePreferences = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
private val getExtensions: GetExtensionsByType = Injekt.get(),
private val getSourcesWithFavoriteCount: GetSourcesWithFavoriteCount = Injekt.get(),
) : StateScreenModel<ExtensionsScreenModel.State>(State()) {

private val currentDownloads = MutableStateFlow<Map<String, InstallStep>>(hashMapOf())

fun installStepFlow(pkgName: String) = currentDownloads.map { it[pkgName] ?: InstallStep.Idle }

init {
val context = Injekt.get<Application>()
val extensionMapper: (Map<String, InstallStep>) -> ((Extension) -> ExtensionUiModel.Item) = { map ->
Expand Down Expand Up @@ -97,6 +101,32 @@ class ExtensionsScreenModel(

screenModelScope.launchIO { findAvailableExtensions() }

screenModelScope.launchIO {
combine(
getSourcesWithFavoriteCount.subscribe(),
extensionManager.installedExtensionsFlow,
) { sourcesWithCount, installed ->
val librarySourceIds = sourcesWithCount
.filter { it.second > 0 }
.map { it.first.id }
.toSet()

var missingCount = 0
for (sourceId in librarySourceIds) {
val installedPkg = extensionManager.getExtensionPackage(sourceId)
if (installedPkg == null) missingCount++
}
missingCount
}
.collectLatest { missingCount ->
mutableState.update { state ->
state.copy(
potentialMissingCount = missingCount,
)
}
}
}

preferences.extensionUpdatesCount().changes()
.onEach { mutableState.update { state -> state.copy(updates = it) } }
.launchIn(screenModelScope)
Expand Down Expand Up @@ -200,6 +230,57 @@ class ExtensionsScreenModel(
}
}

init {
screenModelScope.launchIO {
combine(
getSourcesWithFavoriteCount.subscribe(),
extensionManager.availableExtensionsFlow,
extensionManager.untrustedExtensionsFlow,
extensionManager.installedExtensionsFlow,
) { sourcesWithCount, available, untrusted, installed ->
val librarySourceIds = sourcesWithCount
.filter { it.second > 0 }
.map { it.first.id }
.toSet()

val missing = mutableListOf<eu.kanade.tachiyomi.extension.model.Extension>()

for (sourceId in librarySourceIds) {
val installedPkg = extensionManager.getExtensionPackage(sourceId)
if (installedPkg != null) continue

val avail = available.find { ext -> ext.sources.any { it.id == sourceId } }
if (avail != null) {
val untrustedMatch = untrusted.find { it.pkgName == avail.pkgName }
if (untrustedMatch != null) {
missing.add(untrustedMatch)
} else {
missing.add(avail)
}
continue
}

val un = untrusted.find { untrustedExt ->
available.any { a ->
a.pkgName == untrustedExt.pkgName &&
a.sources.any { it.id == sourceId }
}
}
if (un != null) missing.add(un)
}

missing.distinctBy { it.pkgName }
}
.collectLatest { missingExtensions ->
mutableState.update { state ->
state.copy(
missingLibraryExtensions = missingExtensions,
)
}
}
}
}

fun trustExtension(extension: Extension.Untrusted) {
screenModelScope.launch {
extensionManager.trust(extension)
Expand All @@ -211,6 +292,8 @@ class ExtensionsScreenModel(
val isLoading: Boolean = true,
val isRefreshing: Boolean = false,
val items: ItemGroups = mutableMapOf(),
val missingLibraryExtensions: List<eu.kanade.tachiyomi.extension.model.Extension> = emptyList(),
val potentialMissingCount: Int = 0,
val updates: Int = 0,
val installer: BasePreferences.ExtensionInstaller? = null,
val searchQuery: String? = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package eu.kanade.tachiyomi.ui.browse.extension

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.GetApp
import androidx.compose.material.icons.outlined.VerifiedUser
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import kotlinx.coroutines.launch
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen

class MassInstallScreen : Screen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { ExtensionsScreenModel() }
val state by screenModel.state.collectAsState()

Column {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = MaterialTheme.padding.medium, vertical = MaterialTheme.padding.small),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { navigator.pop() }) {
Icon(
imageVector = Icons.Outlined.ArrowBack,
contentDescription = stringResource(MR.strings.action_webview_back),
)
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(MR.strings.label_extensions),
style = MaterialTheme.typography.titleLarge,
)
}

Button(onClick = {
state.missingLibraryExtensions.forEach { ext ->
when (ext) {
is Extension.Available -> screenModel.installExtension(ext)
is Extension.Untrusted -> screenModel.trustExtension(ext)
else -> {}
}
}
}) {
Text(text = stringResource(MR.strings.install_all))
}
}

var trustDialogExt by remember { androidx.compose.runtime.mutableStateOf<Extension.Untrusted?>(null) }

if (state.missingLibraryExtensions.isEmpty()) {
EmptyScreen(MR.strings.no_results_found, modifier = Modifier.padding(MaterialTheme.padding.medium))
} else {
LazyColumn(modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium)) {
items(state.missingLibraryExtensions, key = {
(it as? Extension.Available)?.pkgName
?: (it as? Extension.Untrusted)?.pkgName
?: it.hashCode().toString()
}) { ext ->
val installStep by screenModel.installStepFlow(
ext.pkgName,
).collectAsState(initial = InstallStep.Idle)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = MaterialTheme.padding.small),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
ExtensionIcon(extension = ext, modifier = Modifier.size(48.dp))
Spacer(modifier = Modifier.size(12.dp))
Text(
text = ext.name,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
)
}

when (ext) {
is Extension.Available -> {
if (installStep.isCompleted()) {
IconButton(onClick = { screenModel.installExtension(ext) }) {
Icon(
imageVector = Icons.Outlined.GetApp,
contentDescription = stringResource(MR.strings.ext_install),
)
}
} else {
IconButton(onClick = { /* noop while installing */ }) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
}
}
}
is Extension.Untrusted -> {
IconButton(onClick = { trustDialogExt = ext }) {
Icon(
imageVector = Icons.Outlined.VerifiedUser,
contentDescription = stringResource(MR.strings.ext_trust),
)
}
}
else -> {}
}
}
}
}
}

val toTrust = trustDialogExt
if (toTrust != null) {
AlertDialog(
onDismissRequest = { trustDialogExt = null },
title = { Text(text = stringResource(MR.strings.untrusted_extension)) },
text = { Text(text = stringResource(MR.strings.untrusted_extension_message)) },
confirmButton = {
TextButton(onClick = {
screenModel.trustExtension(toTrust)
trustDialogExt = null
}) { Text(text = stringResource(MR.strings.ext_trust)) }
},
dismissButton = {
TextButton(onClick = {
screenModel.uninstallExtension(toTrust)
trustDialogExt = null
}) { Text(text = stringResource(MR.strings.ext_uninstall)) }
},
)
}
}
}
}
4 changes: 4 additions & 0 deletions i18n/src/commonMain/moko-resources/base/plurals.xml
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,8 @@
<item quantity="one">An entry was skipped</item>
<item quantity="other">%1$d entries were skipped</item>
</plurals>
<plurals name="extensions_missing">
<item quantity="one">%d extension missing for library</item>
<item quantity="other">%d extensions missing for library</item>
</plurals>
</resources>
Loading