Convert Extension tab to use Compose (#7107)

* Convert Extension tab to use Compose

Co-authored-by: jobobby04 <17078382+jobobby04@users.noreply.github.com>

* Review changes

Co-authored-by: jobobby04 <17078382+jobobby04@users.noreply.github.com>
This commit is contained in:
Andreas 2022-05-15 15:59:53 +02:00 committed by GitHub
parent 7a0915964a
commit 3e2d7d76b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 902 additions and 756 deletions

View File

@ -148,6 +148,7 @@ dependencies {
implementation(compose.animation) implementation(compose.animation)
implementation(compose.ui.tooling) implementation(compose.ui.tooling)
implementation(compose.accompanist.webview) implementation(compose.accompanist.webview)
implementation(compose.accompanist.swiperefresh)
implementation(androidx.paging.runtime) implementation(androidx.paging.runtime)
implementation(androidx.paging.compose) implementation(androidx.paging.compose)

View File

@ -0,0 +1,25 @@
package eu.kanade.core.util
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import rx.Observable
import rx.Observer
fun <T : Any> Observable<T>.asFlow(): Flow<T> = callbackFlow {
val observer = object : Observer<T> {
override fun onNext(t: T) {
trySend(t)
}
override fun onError(e: Throwable) {
close(e)
}
override fun onCompleted() {
close()
}
}
val subscription = subscribe(observer)
awaitClose { subscription.unsubscribe() }
}

View File

@ -3,6 +3,8 @@ package eu.kanade.domain
import eu.kanade.data.history.HistoryRepositoryImpl import eu.kanade.data.history.HistoryRepositoryImpl
import eu.kanade.data.manga.MangaRepositoryImpl import eu.kanade.data.manga.MangaRepositoryImpl
import eu.kanade.data.source.SourceRepositoryImpl import eu.kanade.data.source.SourceRepositoryImpl
import eu.kanade.domain.extension.interactor.GetExtensionUpdates
import eu.kanade.domain.extension.interactor.GetExtensions
import eu.kanade.domain.history.interactor.DeleteHistoryTable import eu.kanade.domain.history.interactor.DeleteHistoryTable
import eu.kanade.domain.history.interactor.GetHistory import eu.kanade.domain.history.interactor.GetHistory
import eu.kanade.domain.history.interactor.GetNextChapterForManga import eu.kanade.domain.history.interactor.GetNextChapterForManga
@ -40,6 +42,9 @@ class DomainModule : InjektModule {
addFactory { RemoveHistoryById(get()) } addFactory { RemoveHistoryById(get()) }
addFactory { RemoveHistoryByMangaId(get()) } addFactory { RemoveHistoryByMangaId(get()) }
addFactory { GetExtensions(get(), get()) }
addFactory { GetExtensionUpdates(get(), get()) }
addSingletonFactory<SourceRepository> { SourceRepositoryImpl(get(), get()) } addSingletonFactory<SourceRepository> { SourceRepositoryImpl(get(), get()) }
addFactory { GetLanguagesWithSources(get(), get()) } addFactory { GetLanguagesWithSources(get(), get()) }
addFactory { GetEnabledSources(get(), get()) } addFactory { GetEnabledSources(get(), get()) }

View File

@ -0,0 +1,25 @@
package eu.kanade.domain.extension.interactor
import eu.kanade.core.util.asFlow
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetExtensionUpdates(
private val preferences: PreferencesHelper,
private val extensionManager: ExtensionManager,
) {
fun subscribe(): Flow<List<Extension.Installed>> {
val showNsfwSources = preferences.showNsfwSource().get()
return extensionManager.getInstalledExtensionsObservable().asFlow()
.map { installed ->
installed
.filter { it.hasUpdate && (showNsfwSources || it.isNsfw.not()) }
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
}
}
}

View File

@ -0,0 +1,48 @@
package eu.kanade.domain.extension.interactor
import eu.kanade.core.util.asFlow
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
typealias ExtensionSegregation = Triple<List<Extension.Installed>, List<Extension.Untrusted>, List<Extension.Available>>
class GetExtensions(
private val preferences: PreferencesHelper,
private val extensionManager: ExtensionManager,
) {
fun subscribe(): Flow<ExtensionSegregation> {
val activeLanguages = preferences.enabledLanguages().get()
val showNsfwSources = preferences.showNsfwSource().get()
return combine(
extensionManager.getInstalledExtensionsObservable().asFlow(),
extensionManager.getUntrustedExtensionsObservable().asFlow(),
extensionManager.getAvailableExtensionsObservable().asFlow(),
) { _installed, _untrusted, _available ->
val installed = _installed
.filter { it.hasUpdate.not() && (showNsfwSources || it.isNsfw.not()) }
.sortedWith(
compareBy<Extension.Installed> { it.isObsolete.not() }
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
)
val untrusted = _untrusted
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
val available = _available
.filter { extension ->
_installed.none { it.pkgName == extension.pkgName } &&
_untrusted.none { it.pkgName == extension.pkgName } &&
extension.lang in activeLanguages &&
(showNsfwSources || extension.isNsfw.not())
}
Triple(installed, untrusted, available)
}
}
}

View File

@ -0,0 +1,35 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.util.horizontalPadding
@Composable
fun BaseBrowseItem(
modifier: Modifier = Modifier,
onClickItem: () -> Unit = {},
onLongClickItem: () -> Unit = {},
icon: @Composable RowScope.() -> Unit = {},
action: @Composable RowScope.() -> Unit = {},
content: @Composable RowScope.() -> Unit = {},
) {
Row(
modifier = modifier
.combinedClickable(
onClick = onClickItem,
onLongClick = onLongClickItem,
)
.padding(horizontal = horizontalPadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
icon()
content()
action()
}
}

View File

@ -0,0 +1,91 @@
package eu.kanade.presentation.browse.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import eu.kanade.domain.source.model.Source
import eu.kanade.presentation.util.bitmapPainterResource
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.ui.browse.extension.Result
import eu.kanade.tachiyomi.ui.browse.extension.getIcon
private val defaultModifier = Modifier
.height(40.dp)
.aspectRatio(1f)
@Composable
fun SourceIcon(
source: Source,
modifier: Modifier = Modifier,
) {
val icon = source.icon
if (icon != null) {
Image(
bitmap = icon,
contentDescription = "",
modifier = modifier.then(defaultModifier),
)
} else {
Image(
painter = painterResource(id = R.mipmap.ic_local_source),
contentDescription = "",
modifier = modifier.then(defaultModifier),
)
}
}
@Composable
fun ExtensionIcon(
extension: Extension,
modifier: Modifier = Modifier,
) {
when (extension) {
is Extension.Available -> {
AsyncImage(
model = extension.iconUrl,
contentDescription = "",
placeholder = ColorPainter(Color(0x1F888888)),
error = bitmapPainterResource(id = R.drawable.cover_error),
modifier = modifier
.clip(RoundedCornerShape(4.dp))
.then(defaultModifier),
)
}
is Extension.Installed -> {
val icon by extension.getIcon()
when (icon) {
Result.Error -> Image(
bitmap = ImageBitmap.imageResource(id = R.mipmap.ic_local_source),
contentDescription = "",
modifier = modifier.then(defaultModifier),
)
Result.Loading -> Box(modifier = modifier.then(defaultModifier))
is Result.Success -> Image(
bitmap = (icon as Result.Success<ImageBitmap>).value,
contentDescription = "",
modifier = modifier.then(defaultModifier),
)
}
}
is Extension.Untrusted -> Image(
bitmap = ImageBitmap.imageResource(id = R.mipmap.ic_untrusted_source),
contentDescription = "",
modifier = modifier.then(defaultModifier),
)
}
}

View File

@ -0,0 +1,417 @@
package eu.kanade.presentation.extension
import androidx.annotation.StringRes
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import eu.kanade.presentation.browse.components.BaseBrowseItem
import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.theme.header
import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionPresenter
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionState
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel
import eu.kanade.tachiyomi.util.system.LocaleHelper
@Composable
fun ExtensionScreen(
nestedScrollInterop: NestedScrollConnection,
presenter: ExtensionPresenter,
onLongClickItem: (Extension) -> Unit,
onClickItemCancel: (Extension) -> Unit,
onInstallExtension: (Extension.Available) -> Unit,
onUninstallExtension: (Extension) -> Unit,
onUpdateExtension: (Extension.Installed) -> Unit,
onTrustExtension: (Extension.Untrusted) -> Unit,
onOpenExtension: (Extension.Installed) -> Unit,
onClickUpdateAll: () -> Unit,
onRefresh: () -> Unit,
onLaunched: () -> Unit,
) {
val state by presenter.state.collectAsState()
val isRefreshing = presenter.isRefreshing
SwipeRefresh(
modifier = Modifier.nestedScroll(nestedScrollInterop),
state = rememberSwipeRefreshState(isRefreshing),
onRefresh = onRefresh,
) {
when (state) {
is ExtensionState.Initialized -> {
ExtensionContent(
nestedScrollInterop = nestedScrollInterop,
items = (state as ExtensionState.Initialized).list,
onLongClickItem = onLongClickItem,
onClickItemCancel = onClickItemCancel,
onInstallExtension = onInstallExtension,
onUninstallExtension = onUninstallExtension,
onUpdateExtension = onUpdateExtension,
onTrustExtension = onTrustExtension,
onOpenExtension = onOpenExtension,
onClickUpdateAll = onClickUpdateAll,
onLaunched = onLaunched,
)
}
ExtensionState.Uninitialized -> {}
}
}
}
@Composable
fun ExtensionContent(
nestedScrollInterop: NestedScrollConnection,
items: List<ExtensionUiModel>,
onLongClickItem: (Extension) -> Unit,
onClickItemCancel: (Extension) -> Unit,
onInstallExtension: (Extension.Available) -> Unit,
onUninstallExtension: (Extension) -> Unit,
onUpdateExtension: (Extension.Installed) -> Unit,
onTrustExtension: (Extension.Untrusted) -> Unit,
onOpenExtension: (Extension.Installed) -> Unit,
onClickUpdateAll: () -> Unit,
onLaunched: () -> Unit,
) {
val (trustState, setTrustState) = remember { mutableStateOf<Extension.Untrusted?>(null) }
LazyColumn(
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
) {
items(
items = items,
key = {
when (it) {
is ExtensionUiModel.Header.Resource -> it.textRes
is ExtensionUiModel.Header.Text -> it.text
is ExtensionUiModel.Item -> it.key()
}
},
contentType = {
when (it) {
is ExtensionUiModel.Item -> "item"
else -> "header"
}
},
) { item ->
when (item) {
is ExtensionUiModel.Header.Resource -> {
val action: @Composable RowScope.() -> Unit =
if (item.textRes == R.string.ext_updates_pending) {
{
Button(onClick = { onClickUpdateAll() }) {
Text(
text = stringResource(id = R.string.ext_update_all),
style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onPrimary,
),
)
}
}
} else {
{}
}
ExtensionHeader(
textRes = item.textRes,
modifier = Modifier.animateItemPlacement(),
action = action,
)
}
is ExtensionUiModel.Header.Text -> {
ExtensionHeader(
text = item.text,
modifier = Modifier.animateItemPlacement(),
)
}
is ExtensionUiModel.Item -> {
ExtensionItem(
modifier = Modifier.animateItemPlacement(),
item = item,
onClickItem = {
when (it) {
is Extension.Available -> onInstallExtension(it)
is Extension.Installed -> {
if (it.hasUpdate) {
onUpdateExtension(it)
} else {
onOpenExtension(it)
}
}
is Extension.Untrusted -> setTrustState(it)
}
},
onLongClickItem = onLongClickItem,
onClickItemCancel = onClickItemCancel,
onClickItemAction = {
when (it) {
is Extension.Available -> onInstallExtension(it)
is Extension.Installed -> {
if (it.hasUpdate) {
onUpdateExtension(it)
} else {
onOpenExtension(it)
}
}
is Extension.Untrusted -> setTrustState(it)
}
},
)
LaunchedEffect(Unit) {
onLaunched()
}
}
}
}
}
if (trustState != null) {
ExtensionTrustDialog(
onClickConfirm = {
onTrustExtension(trustState)
setTrustState(null)
},
onClickDismiss = {
onUninstallExtension(trustState)
setTrustState(null)
},
onDismissRequest = {
setTrustState(null)
},
)
}
}
@Composable
fun ExtensionItem(
modifier: Modifier = Modifier,
item: ExtensionUiModel.Item,
onClickItem: (Extension) -> Unit,
onLongClickItem: (Extension) -> Unit,
onClickItemCancel: (Extension) -> Unit,
onClickItemAction: (Extension) -> Unit,
) {
val (extension, installStep) = item
BaseBrowseItem(
modifier = modifier
.combinedClickable(
onClick = { onClickItem(extension) },
onLongClick = { onLongClickItem(extension) },
),
onClickItem = { onClickItem(extension) },
onLongClickItem = { onLongClickItem(extension) },
icon = {
ExtensionIcon(extension = extension)
},
action = {
ExtensionItemActions(
extension = extension,
installStep = installStep,
onClickItemCancel = onClickItemCancel,
onClickItemAction = onClickItemAction,
)
},
) {
ExtensionItemContent(
extension = extension,
modifier = Modifier.weight(1f),
)
}
}
@Composable
fun ExtensionItemContent(
extension: Extension,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val warning = remember(extension) {
when {
extension is Extension.Untrusted -> R.string.ext_untrusted
extension is Extension.Installed && extension.isUnofficial -> R.string.ext_unofficial
extension is Extension.Installed && extension.isObsolete -> R.string.ext_obsolete
extension.isNsfw -> R.string.ext_nsfw_short
else -> null
}
}
Column(
modifier = modifier.padding(start = horizontalPadding),
) {
Text(
text = extension.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (extension.lang.isNullOrEmpty().not()) {
Text(
text = LocaleHelper.getSourceDisplayName(extension.lang, context),
style = MaterialTheme.typography.bodySmall,
)
}
if (extension.versionName.isNotEmpty()) {
Text(
text = extension.versionName,
style = MaterialTheme.typography.bodySmall,
)
}
if (warning != null) {
Text(
text = stringResource(id = warning).uppercase(),
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.error,
),
)
}
}
}
}
@Composable
fun ExtensionItemActions(
extension: Extension,
installStep: InstallStep,
modifier: Modifier = Modifier,
onClickItemCancel: (Extension) -> Unit = {},
onClickItemAction: (Extension) -> Unit = {},
) {
val isIdle = remember(installStep) {
installStep == InstallStep.Idle || installStep == InstallStep.Error
}
Row(modifier = modifier) {
TextButton(
onClick = { onClickItemAction(extension) },
enabled = isIdle,
) {
Text(
text = when (installStep) {
InstallStep.Pending -> stringResource(R.string.ext_pending)
InstallStep.Downloading -> stringResource(R.string.ext_downloading)
InstallStep.Installing -> stringResource(R.string.ext_installing)
InstallStep.Installed -> stringResource(R.string.ext_installed)
InstallStep.Error -> stringResource(R.string.action_retry)
InstallStep.Idle -> {
when (extension) {
is Extension.Installed -> {
if (extension.hasUpdate) {
stringResource(R.string.ext_update)
} else {
stringResource(R.string.action_settings)
}
}
is Extension.Untrusted -> stringResource(R.string.ext_trust)
is Extension.Available -> stringResource(R.string.ext_install)
}
}
},
style = LocalTextStyle.current.copy(
color = if (isIdle) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceTint,
),
)
}
if (isIdle.not()) {
IconButton(onClick = { onClickItemCancel(extension) }) {
Icon(Icons.Default.Close, "")
}
}
}
}
@Composable
fun ExtensionHeader(
@StringRes textRes: Int,
modifier: Modifier = Modifier,
action: @Composable RowScope.() -> Unit = {},
) {
ExtensionHeader(
text = stringResource(id = textRes),
modifier = modifier,
action = action,
)
}
@Composable
fun ExtensionHeader(
text: String,
modifier: Modifier = Modifier,
action: @Composable RowScope.() -> Unit = {},
) {
Row(
modifier = modifier.padding(horizontal = horizontalPadding),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = text,
modifier = Modifier
.padding(vertical = 8.dp)
.weight(1f),
style = MaterialTheme.typography.header,
)
action()
}
}
@Composable
fun ExtensionTrustDialog(
onClickConfirm: () -> Unit,
onClickDismiss: () -> Unit,
onDismissRequest: () -> Unit,
) {
AlertDialog(
title = {
Text(text = stringResource(id = R.string.untrusted_extension))
},
text = {
Text(text = stringResource(id = R.string.untrusted_extension_message))
},
confirmButton = {
TextButton(onClick = onClickConfirm) {
Text(text = stringResource(id = R.string.ext_trust))
}
},
dismissButton = {
TextButton(onClick = onClickDismiss) {
Text(text = stringResource(id = R.string.ext_uninstall))
}
},
onDismissRequest = onDismissRequest,
)
}

View File

@ -1,13 +1,10 @@
package eu.kanade.presentation.source package eu.kanade.presentation.source
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@ -31,7 +28,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.domain.source.model.Pin import eu.kanade.domain.source.model.Pin
@ -191,29 +187,6 @@ fun SourceItem(
) )
} }
@Composable
fun SourceIcon(
source: Source,
) {
val icon = source.icon
val modifier = Modifier
.height(40.dp)
.aspectRatio(1f)
if (icon != null) {
Image(
bitmap = icon,
contentDescription = "",
modifier = modifier,
)
} else {
Image(
painter = painterResource(id = R.mipmap.ic_local_source),
contentDescription = "",
modifier = modifier,
)
}
}
@Composable @Composable
fun SourcePinButton( fun SourcePinButton(
isPinned: Boolean, isPinned: Boolean,

View File

@ -1,19 +1,16 @@
package eu.kanade.presentation.source.components package eu.kanade.presentation.source.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.model.Source import eu.kanade.domain.source.model.Source
import eu.kanade.presentation.source.SourceIcon import eu.kanade.presentation.browse.components.BaseBrowseItem
import eu.kanade.presentation.browse.components.SourceIcon
import eu.kanade.presentation.util.horizontalPadding import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
@ -28,19 +25,14 @@ fun BaseSourceItem(
action: @Composable RowScope.(Source) -> Unit = {}, action: @Composable RowScope.(Source) -> Unit = {},
content: @Composable RowScope.(Source, Boolean) -> Unit = defaultContent, content: @Composable RowScope.(Source, Boolean) -> Unit = defaultContent,
) { ) {
Row( BaseBrowseItem(
modifier = modifier modifier = modifier,
.combinedClickable( onClickItem = onClickItem,
onClick = onClickItem, onLongClickItem = onLongClickItem,
onLongClick = onLongClickItem, icon = { icon.invoke(this, source) },
action = { action.invoke(this, source) },
content = { content.invoke(this, source, showLanguageInContent) },
) )
.padding(horizontal = horizontalPadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
icon.invoke(this, source)
content.invoke(this, source, showLanguageInContent)
action.invoke(this, source)
}
} }
private val defaultIcon: @Composable RowScope.(Source) -> Unit = { source -> private val defaultIcon: @Composable RowScope.(Source) -> Unit = { source ->

View File

@ -1,27 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.extension
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
/**
* Adapter that holds the catalogue cards.
*
* @param controller instance of [ExtensionController].
*/
class ExtensionAdapter(controller: ExtensionController) :
FlexibleAdapter<IFlexible<*>>(null, controller, true) {
init {
setDisplayHeadersAtStartUp(true)
}
/**
* Listener for browse item clicks.
*/
val buttonClickListener: OnButtonClickListener = controller
interface OnButtonClickListener {
fun onButtonClick(position: Int)
fun onCancelButtonClick(position: Int)
}
}

View File

@ -1,48 +1,30 @@
package eu.kanade.tachiyomi.ui.browse.extension package eu.kanade.tachiyomi.ui.browse.extension
import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.recyclerview.widget.LinearLayoutManager import androidx.compose.runtime.Composable
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType import com.bluelinelabs.conductor.ControllerChangeType
import dev.chrisbanes.insetter.applyInsetter import eu.kanade.presentation.extension.ExtensionScreen
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.ExtensionControllerBinding
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.base.controller.ComposeController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.BrowseController import eu.kanade.tachiyomi.ui.browse.BrowseController
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsController import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsController
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.appcompat.queryTextChanges import reactivecircus.flowbinding.appcompat.queryTextChanges
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
/** /**
* Controller to manage the catalogues available in the app. * Controller to manage the catalogues available in the app.
*/ */
open class ExtensionController : open class ExtensionController :
NucleusController<ExtensionControllerBinding, ExtensionPresenter>(), ComposeController<ExtensionPresenter>() {
ExtensionAdapter.OnButtonClickListener,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
ExtensionTrustDialog.Listener {
/**
* Adapter containing the list of manga from the catalogue.
*/
private var adapter: FlexibleAdapter<IFlexible<*>>? = null
private var extensions: List<ExtensionItem> = emptyList()
private var query = "" private var query = ""
@ -50,42 +32,54 @@ open class ExtensionController :
setHasOptionsMenu(true) setHasOptionsMenu(true)
} }
override fun getTitle(): String? { override fun getTitle(): String? =
return applicationContext?.getString(R.string.label_extensions) applicationContext?.getString(R.string.label_extensions)
override fun createPresenter(): ExtensionPresenter =
ExtensionPresenter()
@Composable
override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) {
ExtensionScreen(
nestedScrollInterop = nestedScrollInterop,
presenter = presenter,
onLongClickItem = { extension ->
when (extension) {
is Extension.Available -> presenter.installExtension(extension)
else -> presenter.uninstallExtension(extension.pkgName)
} }
},
override fun createPresenter(): ExtensionPresenter { onClickItemCancel = { extension ->
return ExtensionPresenter() presenter.cancelInstallUpdateExtension(extension)
} },
onClickUpdateAll = {
override fun createBinding(inflater: LayoutInflater) = presenter.updateAllExtensions()
ExtensionControllerBinding.inflate(inflater) },
onLaunched = {
override fun onViewCreated(view: View) { val ctrl = parentController as BrowseController
super.onViewCreated(view) ctrl.setExtensionUpdateBadge()
ctrl.extensionListUpdateRelay.call(true)
binding.recycler.applyInsetter { },
type(navigationBars = true) { onInstallExtension = {
padding() presenter.installExtension(it)
} },
} onOpenExtension = {
val controller = ExtensionDetailsController(it.pkgName)
binding.swipeRefresh.isRefreshing = true parentController!!.router.pushController(controller)
binding.swipeRefresh.refreshes() },
.onEach { presenter.findAvailableExtensions() } onTrustExtension = {
.launchIn(viewScope) presenter.trustSignature(it.signatureHash)
},
// Initialize adapter, scroll listener and recycler views onUninstallExtension = {
adapter = ExtensionAdapter(this) presenter.uninstallExtension(it.pkgName)
// Create recycler and set adapter. },
binding.recycler.layoutManager = LinearLayoutManager(view.context) onUpdateExtension = {
binding.recycler.adapter = adapter presenter.updateExtension(it)
adapter?.fastScroller = binding.fastScroller },
} onRefresh = {
presenter.findAvailableExtensions()
override fun onDestroyView(view: View) { },
adapter = null )
super.onDestroyView(view)
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
@ -105,26 +99,6 @@ open class ExtensionController :
} }
} }
override fun onButtonClick(position: Int) {
val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
when (extension) {
is Extension.Available -> presenter.installExtension(extension)
is Extension.Untrusted -> openTrustDialog(extension)
is Extension.Installed -> {
if (!extension.hasUpdate) {
openDetails(extension)
} else {
presenter.updateExtension(extension)
}
}
}
}
override fun onCancelButtonClick(position: Int) {
val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
presenter.cancelInstallUpdateExtension(extension)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.browse_extensions, menu) inflater.inflate(R.menu.browse_extensions, menu)
@ -142,93 +116,11 @@ open class ExtensionController :
} }
searchView.queryTextChanges() searchView.queryTextChanges()
.drop(1) // Drop first event after subscribed
.filter { router.backstack.lastOrNull()?.controller == this } .filter { router.backstack.lastOrNull()?.controller == this }
.onEach { .onEach {
query = it.toString() query = it.toString()
updateExtensionsList() presenter.search(query)
} }
.launchIn(viewScope) .launchIn(viewScope)
} }
override fun onItemClick(view: View, position: Int): Boolean {
val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return false
when (extension) {
is Extension.Available -> presenter.installExtension(extension)
is Extension.Untrusted -> openTrustDialog(extension)
is Extension.Installed -> openDetails(extension)
}
return false
}
override fun onItemLongClick(position: Int) {
val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
if (extension is Extension.Installed || extension is Extension.Untrusted) {
uninstallExtension(extension.pkgName)
}
}
private fun openDetails(extension: Extension.Installed) {
val controller = ExtensionDetailsController(extension.pkgName)
parentController!!.router.pushController(controller)
}
private fun openTrustDialog(extension: Extension.Untrusted) {
ExtensionTrustDialog(this, extension.signatureHash, extension.pkgName)
.showDialog(router)
}
fun setExtensions(extensions: List<ExtensionItem>) {
binding.swipeRefresh.isRefreshing = false
this.extensions = extensions
updateExtensionsList()
// Update badge on parent controller tab
val ctrl = parentController as BrowseController
ctrl.setExtensionUpdateBadge()
ctrl.extensionListUpdateRelay.call(true)
}
private fun updateExtensionsList() {
if (query.isNotBlank()) {
val queries = query.split(",")
adapter?.updateDataSet(
extensions.filter {
queries.any { query ->
when (it.extension) {
is Extension.Available -> {
it.extension.sources.any {
it.name.contains(query, ignoreCase = true) ||
it.baseUrl.contains(query, ignoreCase = true) ||
it.id == query.toLongOrNull()
} || it.extension.name.contains(query, ignoreCase = true)
}
is Extension.Installed -> {
it.extension.sources.any {
it.name.contains(query, ignoreCase = true) ||
it.id == query.toLongOrNull() ||
if (it is HttpSource) { it.baseUrl.contains(query, ignoreCase = true) } else false
} || it.extension.name.contains(query, ignoreCase = true)
}
is Extension.Untrusted -> it.extension.name.contains(query, ignoreCase = true)
}
}
},
)
} else {
adapter?.updateDataSet(extensions)
}
}
fun downloadUpdate(item: ExtensionItem) {
adapter?.updateItem(item, item.installStep)
}
override fun trustSignature(signatureHash: String) {
presenter.trustSignature(signatureHash)
}
override fun uninstallExtension(pkgName: String) {
presenter.uninstallExtension(pkgName)
}
} }

View File

@ -1,27 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.extension
import android.annotation.SuppressLint
import android.view.View
import androidx.core.view.isVisible
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.databinding.SectionHeaderItemBinding
class ExtensionGroupHolder(view: View, adapter: FlexibleAdapter<*>) :
FlexibleViewHolder(view, adapter) {
private val binding = SectionHeaderItemBinding.bind(view)
@SuppressLint("SetTextI18n")
fun bind(item: ExtensionGroupItem) {
var text = item.name
if (item.showSize) {
text += " (${item.size})"
}
binding.title.text = text
binding.actionButton.isVisible = item.actionLabel != null && item.actionOnClick != null
binding.actionButton.text = item.actionLabel
binding.actionButton.setOnClickListener(if (item.actionLabel != null) item.actionOnClick else null)
}
}

View File

@ -1,62 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.extension
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
/**
* Item that contains the group header.
*
* @param name The header name.
* @param size The number of items in the group.
*/
data class ExtensionGroupItem(
val name: String,
val size: Int,
val showSize: Boolean = false,
) : AbstractHeaderItem<ExtensionGroupHolder>() {
var actionLabel: String? = null
var actionOnClick: (View.OnClickListener)? = null
/**
* Returns the layout resource of this item.
*/
override fun getLayoutRes(): Int {
return R.layout.section_header_item
}
/**
* Creates a new view holder for this item.
*/
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): ExtensionGroupHolder {
return ExtensionGroupHolder(view, adapter)
}
/**
* Binds this item to the given view holder.
*/
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: ExtensionGroupHolder,
position: Int,
payloads: List<Any?>?,
) {
holder.bind(this)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is ExtensionGroupItem) {
return name == other.name
}
return false
}
override fun hashCode(): Int {
return name.hashCode()
}
}

View File

@ -1,84 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.extension
import android.view.View
import androidx.core.view.isVisible
import coil.dispose
import coil.load
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.ExtensionItemBinding
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.system.LocaleHelper
class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
FlexibleViewHolder(view, adapter) {
private val binding = ExtensionItemBinding.bind(view)
init {
binding.extButton.setOnClickListener {
adapter.buttonClickListener.onButtonClick(bindingAdapterPosition)
}
binding.cancelButton.setOnClickListener {
adapter.buttonClickListener.onCancelButtonClick(bindingAdapterPosition)
}
}
fun bind(item: ExtensionItem) {
val extension = item.extension
binding.name.text = extension.name
binding.version.text = extension.versionName
binding.lang.text = LocaleHelper.getSourceDisplayName(extension.lang, itemView.context)
binding.warning.text = when {
extension is Extension.Untrusted -> itemView.context.getString(R.string.ext_untrusted)
extension is Extension.Installed && extension.isUnofficial -> itemView.context.getString(R.string.ext_unofficial)
extension is Extension.Installed && extension.isObsolete -> itemView.context.getString(R.string.ext_obsolete)
extension.isNsfw -> itemView.context.getString(R.string.ext_nsfw_short)
else -> ""
}.uppercase()
binding.icon.dispose()
if (extension is Extension.Available) {
binding.icon.load(extension.iconUrl)
} else if (extension is Extension.Installed) {
binding.icon.load(extension.icon)
}
bindButtons(item)
}
@Suppress("ResourceType")
fun bindButtons(item: ExtensionItem) = with(binding.extButton) {
val extension = item.extension
val installStep = item.installStep
setText(
when (installStep) {
InstallStep.Pending -> R.string.ext_pending
InstallStep.Downloading -> R.string.ext_downloading
InstallStep.Installing -> R.string.ext_installing
InstallStep.Installed -> R.string.ext_installed
InstallStep.Error -> R.string.action_retry
InstallStep.Idle -> {
when (extension) {
is Extension.Installed -> {
if (extension.hasUpdate) {
R.string.ext_update
} else {
R.string.action_settings
}
}
is Extension.Untrusted -> R.string.ext_trust
is Extension.Available -> R.string.ext_install
}
}
},
)
val isIdle = installStep == InstallStep.Idle || installStep == InstallStep.Error
binding.cancelButton.isVisible = !isIdle
isEnabled = isIdle
isClickable = isIdle
}
}

View File

@ -1,65 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.extension
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractSectionableItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.source.CatalogueSource
/**
* Item that contains source information.
*
* @param source Instance of [CatalogueSource] containing source information.
* @param header The header for this item.
*/
data class ExtensionItem(
val extension: Extension,
val header: ExtensionGroupItem? = null,
val installStep: InstallStep = InstallStep.Idle,
) :
AbstractSectionableItem<ExtensionHolder, ExtensionGroupItem>(header) {
/**
* Returns the layout resource of this item.
*/
override fun getLayoutRes(): Int {
return R.layout.extension_item
}
/**
* Creates a new view holder for this item.
*/
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): ExtensionHolder {
return ExtensionHolder(view, adapter as ExtensionAdapter)
}
/**
* Binds this item to the given view holder.
*/
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: ExtensionHolder,
position: Int,
payloads: List<Any?>?,
) {
if (payloads == null || payloads.isEmpty()) {
holder.bind(this)
} else {
holder.bindButtons(this)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return extension.pkgName == (other as ExtensionItem).extension.pkgName
}
override fun hashCode(): Int {
return extension.pkgName.hashCode()
}
}

View File

@ -2,144 +2,151 @@ package eu.kanade.tachiyomi.ui.browse.extension
import android.app.Application import android.app.Application
import android.os.Bundle import android.os.Bundle
import android.view.View import androidx.annotation.StringRes
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.domain.extension.interactor.GetExtensionUpdates
import eu.kanade.domain.extension.interactor.GetExtensions
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import rx.Observable import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
private typealias ExtensionTuple =
Triple<List<Extension.Installed>, List<Extension.Untrusted>, List<Extension.Available>>
/** /**
* Presenter of [ExtensionController]. * Presenter of [ExtensionController].
*/ */
open class ExtensionPresenter( open class ExtensionPresenter(
private val extensionManager: ExtensionManager = Injekt.get(), private val extensionManager: ExtensionManager = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get(), private val getExtensionUpdates: GetExtensionUpdates = Injekt.get(),
private val getExtensions: GetExtensions = Injekt.get(),
) : BasePresenter<ExtensionController>() { ) : BasePresenter<ExtensionController>() {
private var extensions = emptyList<ExtensionItem>() private val _query: MutableStateFlow<String> = MutableStateFlow("")
private var currentDownloads = hashMapOf<String, InstallStep>() private var _currentDownloads = MutableStateFlow<Map<String, InstallStep>>(hashMapOf())
private val _state: MutableStateFlow<ExtensionState> = MutableStateFlow(ExtensionState.Uninitialized)
val state: StateFlow<ExtensionState> = _state.asStateFlow()
var isRefreshing: Boolean by mutableStateOf(true)
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
extensionManager.findAvailableExtensions() extensionManager.findAvailableExtensions()
bindToExtensionsObservable()
}
private fun bindToExtensionsObservable(): Subscription {
val installedObservable = extensionManager.getInstalledExtensionsObservable()
val untrustedObservable = extensionManager.getUntrustedExtensionsObservable()
val availableObservable = extensionManager.getAvailableExtensionsObservable()
.startWith(emptyList<Extension.Available>())
return Observable.combineLatest(installedObservable, untrustedObservable, availableObservable) { installed, untrusted, available -> Triple(installed, untrusted, available) }
.debounce(500, TimeUnit.MILLISECONDS)
.map(::toItems)
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache({ view, _ -> view.setExtensions(extensions) })
}
@Synchronized
private fun toItems(tuple: ExtensionTuple): List<ExtensionItem> {
val context = Injekt.get<Application>() val context = Injekt.get<Application>()
val activeLangs = preferences.enabledLanguages().get() val extensionMapper: (Map<String, InstallStep>) -> ((Extension) -> ExtensionUiModel) = { map ->
val showNsfwSources = preferences.showNsfwSource().get() {
ExtensionUiModel.Item(it, map[it.pkgName] ?: InstallStep.Idle)
val (installed, untrusted, available) = tuple
val items = mutableListOf<ExtensionItem>()
val updatesSorted = installed.filter { it.hasUpdate && (showNsfwSources || !it.isNsfw) }
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
val installedSorted = installed.filter { !it.hasUpdate && (showNsfwSources || !it.isNsfw) }
.sortedWith(
compareBy<Extension.Installed> { !it.isObsolete }
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
)
val untrustedSorted = untrusted.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
val availableSorted = available
// Filter out already installed extensions and disabled languages
.filter { avail ->
installed.none { it.pkgName == avail.pkgName } &&
untrusted.none { it.pkgName == avail.pkgName } &&
avail.lang in activeLangs &&
(showNsfwSources || !avail.isNsfw)
}
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
if (updatesSorted.isNotEmpty()) {
val header = ExtensionGroupItem(context.getString(R.string.ext_updates_pending), updatesSorted.size, true)
if (preferences.extensionInstaller().get() != PreferenceValues.ExtensionInstaller.LEGACY) {
header.actionLabel = context.getString(R.string.ext_update_all)
header.actionOnClick = View.OnClickListener { _ ->
extensions
.filter { it.extension is Extension.Installed && it.extension.hasUpdate }
.forEach { updateExtension(it.extension as Extension.Installed) }
} }
} }
items += updatesSorted.map { extension -> val queryFilter: (String) -> ((Extension) -> Boolean) = { query ->
ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle) filter@{ extension ->
if (query.isEmpty()) return@filter true
query.split(",").any { _input ->
val input = _input.trim()
if (input.isEmpty()) return@any false
when (extension) {
is Extension.Available -> {
extension.sources.any {
it.name.contains(input, ignoreCase = true) ||
it.baseUrl.contains(input, ignoreCase = true) ||
it.id == input.toLongOrNull()
} || extension.name.contains(input, ignoreCase = true)
}
is Extension.Installed -> {
extension.sources.any {
it.name.contains(input, ignoreCase = true) ||
it.id == input.toLongOrNull() ||
if (it is HttpSource) { it.baseUrl.contains(input, ignoreCase = true) } else false
} || extension.name.contains(input, ignoreCase = true)
}
is Extension.Untrusted -> extension.name.contains(input, ignoreCase = true)
}
} }
} }
if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) {
val header = ExtensionGroupItem(context.getString(R.string.ext_installed), installedSorted.size + untrustedSorted.size)
items += installedSorted.map { extension ->
ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
} }
items += untrustedSorted.map { extension -> launchIO {
ExtensionItem(extension, header) combine(
} _query,
} getExtensions.subscribe(),
if (availableSorted.isNotEmpty()) { getExtensionUpdates.subscribe(),
val availableGroupedByLang = availableSorted _currentDownloads,
) { query, (installed, untrusted, available), updates, downloads ->
isRefreshing = false
val languagesWithExtensions = available
.filter(queryFilter(query))
.groupBy { LocaleHelper.getSourceDisplayName(it.lang, context) } .groupBy { LocaleHelper.getSourceDisplayName(it.lang, context) }
.toSortedMap() .toSortedMap()
.flatMap { (key, value) ->
listOf(
ExtensionUiModel.Header.Text(key),
*value.map(extensionMapper(downloads)).toTypedArray(),
)
}
availableGroupedByLang val items = mutableListOf<ExtensionUiModel>()
.forEach {
val header = ExtensionGroupItem(it.key, it.value.size) val updates = updates.filter(queryFilter(query)).map(extensionMapper(downloads))
items += it.value.map { extension -> if (updates.isNotEmpty()) {
ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle) items.add(ExtensionUiModel.Header.Resource(R.string.ext_updates_pending))
items.addAll(updates)
}
val installed = installed.filter(queryFilter(query)).map(extensionMapper(downloads))
val untrusted = untrusted.filter(queryFilter(query)).map(extensionMapper(downloads))
if (installed.isNotEmpty() || untrusted.isNotEmpty()) {
items.add(ExtensionUiModel.Header.Resource(R.string.ext_installed))
items.addAll(installed)
items.addAll(untrusted)
}
if (languagesWithExtensions.isNotEmpty()) {
items.addAll(languagesWithExtensions)
}
items
}.collectLatest {
_state.value = ExtensionState.Initialized(it)
} }
} }
} }
this.extensions = items fun search(query: String) {
return items launchIO {
_query.emit(query)
}
} }
@Synchronized fun updateAllExtensions() {
private fun updateInstallStep(extension: Extension, state: InstallStep): ExtensionItem? { launchIO {
val extensions = extensions.toMutableList() val state = _state.value
val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName } if (state !is ExtensionState.Initialized) return@launchIO
state.list.mapNotNull {
return if (position != -1) { if (it !is ExtensionUiModel.Item) return@mapNotNull null
val item = extensions[position].copy(installStep = state) if (it.extension !is Extension.Installed) return@mapNotNull null
extensions[position] = item if (it.extension.hasUpdate.not()) return@mapNotNull null
it.extension
this.extensions = extensions }.forEach {
item updateExtension(it)
} else { }
null
} }
} }
@ -155,15 +162,29 @@ open class ExtensionPresenter(
extensionManager.cancelInstallUpdateExtension(extension) extensionManager.cancelInstallUpdateExtension(extension)
} }
private fun Observable<InstallStep>.subscribeToInstallUpdate(extension: Extension) { private fun removeDownloadState(extension: Extension) {
this.doOnNext { currentDownloads[extension.pkgName] = it } _currentDownloads.update { map ->
.doOnUnsubscribe { currentDownloads.remove(extension.pkgName) } val map = map.toMutableMap()
.map { state -> updateInstallStep(extension, state) } map.remove(extension.pkgName)
.subscribeWithView({ view, item -> map
if (item != null) {
view.downloadUpdate(item)
} }
},) }
private fun addDownloadState(extension: Extension, installStep: InstallStep) {
_currentDownloads.update { map ->
val map = map.toMutableMap()
map[extension.pkgName] = installStep
map
}
}
private fun Observable<InstallStep>.subscribeToInstallUpdate(extension: Extension) {
this
.doOnUnsubscribe { removeDownloadState(extension) }
.subscribe(
{ installStep -> addDownloadState(extension, installStep) },
{ removeDownloadState(extension) },
)
} }
fun uninstallExtension(pkgName: String) { fun uninstallExtension(pkgName: String) {
@ -171,6 +192,7 @@ open class ExtensionPresenter(
} }
fun findAvailableExtensions() { fun findAvailableExtensions() {
isRefreshing = true
extensionManager.findAvailableExtensions() extensionManager.findAvailableExtensions()
} }
@ -178,3 +200,28 @@ open class ExtensionPresenter(
extensionManager.trustSignature(signatureHash) extensionManager.trustSignature(signatureHash)
} }
} }
sealed interface ExtensionUiModel {
sealed interface Header : ExtensionUiModel {
data class Resource(@StringRes val textRes: Int) : Header
data class Text(val text: String) : Header
}
data class Item(
val extension: Extension,
val installStep: InstallStep,
) : ExtensionUiModel {
fun key(): String {
return when (extension) {
is Extension.Installed ->
if (extension.hasUpdate) "update_${extension.pkgName}" else extension.pkgName
else -> extension.pkgName
}
}
}
}
sealed class ExtensionState {
object Uninitialized : ExtensionState()
data class Initialized(val list: List<ExtensionUiModel>) : ExtensionState()
}

View File

@ -1,43 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.extension
import android.app.Dialog
import android.os.Bundle
import androidx.core.os.bundleOf
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class ExtensionTrustDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : ExtensionTrustDialog.Listener {
constructor(target: T, signatureHash: String, pkgName: String) : this(
bundleOf(
SIGNATURE_KEY to signatureHash,
PKGNAME_KEY to pkgName,
),
) {
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setTitle(R.string.untrusted_extension)
.setMessage(R.string.untrusted_extension_message)
.setPositiveButton(R.string.ext_trust) { _, _ ->
(targetController as? Listener)?.trustSignature(args.getString(SIGNATURE_KEY)!!)
}
.setNegativeButton(R.string.ext_uninstall) { _, _ ->
(targetController as? Listener)?.uninstallExtension(args.getString(PKGNAME_KEY)!!)
}
.create()
}
interface Listener {
fun trustSignature(signatureHash: String)
fun uninstallExtension(pkgName: String)
}
}
private const val SIGNATURE_KEY = "signature_key"
private const val PKGNAME_KEY = "pkgname_key"

View File

@ -3,7 +3,15 @@ package eu.kanade.tachiyomi.ui.browse.extension
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.drawable.toBitmap
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.util.lang.withIOContext
fun Extension.getApplicationIcon(context: Context): Drawable? { fun Extension.getApplicationIcon(context: Context): Drawable? {
return try { return try {
@ -12,3 +20,27 @@ fun Extension.getApplicationIcon(context: Context): Drawable? {
null null
} }
} }
@Composable
fun Extension.getIcon(): State<Result<ImageBitmap>> {
val context = LocalContext.current
return produceState<Result<ImageBitmap>>(initialValue = Result.Loading, this) {
withIOContext {
value = try {
Result.Success(
context.packageManager.getApplicationIcon(pkgName)
.toBitmap()
.asImageBitmap(),
)
} catch (e: Exception) {
Result.Error
}
}
}
}
sealed class Result<out T> {
object Loading : Result<Nothing>()
object Error : Result<Nothing>()
data class Success<out T>(val value: T) : Result<T>()
}

View File

@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="8dp"
android:paddingBottom="@dimen/action_toolbar_list_padding"
tools:listitem="@layout/section_header_item" />
</eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout>
<eu.kanade.tachiyomi.widget.MaterialFastScroll
android:id="@+id/fast_scroller"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="end"
app:fastScrollerBubbleEnabled="false"
tools:visibility="visible" />
</FrameLayout>

View File

@ -1,98 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="64dp"
android:background="@drawable/list_item_selector_background"
android:paddingEnd="16dp">
<ImageView
android:id="@+id/icon"
android:layout_width="0dp"
android:layout_height="0dp"
android:paddingStart="16dp"
android:paddingEnd="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription"
tools:src="@mipmap/ic_launcher_round" />
<TextView
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintBottom_toTopOf="@id/lang"
app:layout_constraintEnd_toStartOf="@id/ext_button"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Batoto" />
<TextView
android:id="@+id/lang"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodySmall"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintTop_toBottomOf="@+id/name"
tools:text="English"
tools:visibility="visible" />
<TextView
android:id="@+id/version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodySmall"
app:layout_constraintStart_toEndOf="@id/lang"
app:layout_constraintTop_toBottomOf="@+id/name"
tools:text="Version" />
<TextView
android:id="@+id/warning"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorError"
app:layout_constraintStart_toEndOf="@id/version"
app:layout_constraintTop_toBottomOf="@+id/name"
tools:text="Warning" />
<Button
android:id="@+id/ext_button"
style="?attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/cancel_button"
app:layout_constraintTop_toTopOf="parent"
tools:text="Details" />
<ImageButton
android:id="@+id/cancel_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@android:string/cancel"
android:padding="12dp"
android:src="@drawable/ic_close_24dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="?android:attr/textColorPrimary"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@ -13,3 +13,4 @@ material3-adapter = "com.google.android.material:compose-theme-adapter-3:1.0.9"
material-icons = { module = "androidx.compose.material:material-icons-extended", version.ref="compose" } material-icons = { module = "androidx.compose.material:material-icons-extended", version.ref="compose" }
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref="accompanist" } accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref="accompanist" }
accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref="accompanist" }