diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6de1454ff..0c0dc94f9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -148,6 +148,7 @@ dependencies { implementation(compose.animation) implementation(compose.ui.tooling) implementation(compose.accompanist.webview) + implementation(compose.accompanist.swiperefresh) implementation(androidx.paging.runtime) implementation(androidx.paging.compose) diff --git a/app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt b/app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt new file mode 100644 index 000000000..4d1ef452d --- /dev/null +++ b/app/src/main/java/eu/kanade/core/util/RxJavaExtensions.kt @@ -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 Observable.asFlow(): Flow = callbackFlow { + val observer = object : Observer { + 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() } +} diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 7e9cd3de1..293d3485c 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -3,6 +3,8 @@ package eu.kanade.domain import eu.kanade.data.history.HistoryRepositoryImpl import eu.kanade.data.manga.MangaRepositoryImpl 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.GetHistory import eu.kanade.domain.history.interactor.GetNextChapterForManga @@ -40,6 +42,9 @@ class DomainModule : InjektModule { addFactory { RemoveHistoryById(get()) } addFactory { RemoveHistoryByMangaId(get()) } + addFactory { GetExtensions(get(), get()) } + addFactory { GetExtensionUpdates(get(), get()) } + addSingletonFactory { SourceRepositoryImpl(get(), get()) } addFactory { GetLanguagesWithSources(get(), get()) } addFactory { GetEnabledSources(get(), get()) } diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionUpdates.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionUpdates.kt new file mode 100644 index 000000000..96373f9b4 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionUpdates.kt @@ -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> { + 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 }) + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensions.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensions.kt new file mode 100644 index 000000000..16056939d --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensions.kt @@ -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, List> + +class GetExtensions( + private val preferences: PreferencesHelper, + private val extensionManager: ExtensionManager, +) { + + fun subscribe(): Flow { + 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 { 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) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BaseBrowseItem.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BaseBrowseItem.kt new file mode 100644 index 000000000..2b3dd022f --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BaseBrowseItem.kt @@ -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() + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt new file mode 100644 index 000000000..1b0323257 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt @@ -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).value, + contentDescription = "", + modifier = modifier.then(defaultModifier), + ) + } + } + is Extension.Untrusted -> Image( + bitmap = ImageBitmap.imageResource(id = R.mipmap.ic_untrusted_source), + contentDescription = "", + modifier = modifier.then(defaultModifier), + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/extension/ExtensionScreen.kt b/app/src/main/java/eu/kanade/presentation/extension/ExtensionScreen.kt new file mode 100644 index 000000000..7be37a398 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/extension/ExtensionScreen.kt @@ -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, + 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(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, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt b/app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt index 0e34c9419..c6da3123a 100644 --- a/app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/source/SourceScreen.kt @@ -1,13 +1,10 @@ package eu.kanade.presentation.source -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding 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.nestedScroll import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp 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 fun SourcePinButton( isPinned: Boolean, diff --git a/app/src/main/java/eu/kanade/presentation/source/components/BaseSourceItem.kt b/app/src/main/java/eu/kanade/presentation/source/components/BaseSourceItem.kt index 4f35d7e4d..d5d1b6fc1 100644 --- a/app/src/main/java/eu/kanade/presentation/source/components/BaseSourceItem.kt +++ b/app/src/main/java/eu/kanade/presentation/source/components/BaseSourceItem.kt @@ -1,19 +1,16 @@ package eu.kanade.presentation.source.components -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp 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.tachiyomi.util.system.LocaleHelper @@ -28,19 +25,14 @@ fun BaseSourceItem( action: @Composable RowScope.(Source) -> Unit = {}, content: @Composable RowScope.(Source, Boolean) -> Unit = defaultContent, ) { - Row( - modifier = modifier - .combinedClickable( - onClick = onClickItem, - onLongClick = onLongClickItem, - ) - .padding(horizontal = horizontalPadding, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - icon.invoke(this, source) - content.invoke(this, source, showLanguageInContent) - action.invoke(this, source) - } + BaseBrowseItem( + modifier = modifier, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + icon = { icon.invoke(this, source) }, + action = { action.invoke(this, source) }, + content = { content.invoke(this, source, showLanguageInContent) }, + ) } private val defaultIcon: @Composable RowScope.(Source) -> Unit = { source -> diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt deleted file mode 100644 index 89f621da2..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionAdapter.kt +++ /dev/null @@ -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>(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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt index 7b1aa7b3d..8112692b7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionController.kt @@ -1,48 +1,30 @@ package eu.kanade.tachiyomi.ui.browse.extension -import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem -import android.view.View 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.ControllerChangeType -import dev.chrisbanes.insetter.applyInsetter -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.presentation.extension.ExtensionScreen import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.databinding.ExtensionControllerBinding import eu.kanade.tachiyomi.extension.model.Extension -import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.ComposeController import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.browse.BrowseController import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsController -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import reactivecircus.flowbinding.appcompat.queryTextChanges -import reactivecircus.flowbinding.swiperefreshlayout.refreshes /** * Controller to manage the catalogues available in the app. */ open class ExtensionController : - NucleusController(), - ExtensionAdapter.OnButtonClickListener, - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener, - ExtensionTrustDialog.Listener { - - /** - * Adapter containing the list of manga from the catalogue. - */ - private var adapter: FlexibleAdapter>? = null - - private var extensions: List = emptyList() + ComposeController() { private var query = "" @@ -50,42 +32,54 @@ open class ExtensionController : setHasOptionsMenu(true) } - override fun getTitle(): String? { - return applicationContext?.getString(R.string.label_extensions) - } + override fun getTitle(): String? = + applicationContext?.getString(R.string.label_extensions) - override fun createPresenter(): ExtensionPresenter { - return ExtensionPresenter() - } + override fun createPresenter(): ExtensionPresenter = + ExtensionPresenter() - override fun createBinding(inflater: LayoutInflater) = - ExtensionControllerBinding.inflate(inflater) - - override fun onViewCreated(view: View) { - super.onViewCreated(view) - - binding.recycler.applyInsetter { - type(navigationBars = true) { - padding() - } - } - - binding.swipeRefresh.isRefreshing = true - binding.swipeRefresh.refreshes() - .onEach { presenter.findAvailableExtensions() } - .launchIn(viewScope) - - // Initialize adapter, scroll listener and recycler views - adapter = ExtensionAdapter(this) - // Create recycler and set adapter. - binding.recycler.layoutManager = LinearLayoutManager(view.context) - binding.recycler.adapter = adapter - adapter?.fastScroller = binding.fastScroller - } - - override fun onDestroyView(view: View) { - adapter = null - super.onDestroyView(view) + @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) + } + }, + onClickItemCancel = { extension -> + presenter.cancelInstallUpdateExtension(extension) + }, + onClickUpdateAll = { + presenter.updateAllExtensions() + }, + onLaunched = { + val ctrl = parentController as BrowseController + ctrl.setExtensionUpdateBadge() + ctrl.extensionListUpdateRelay.call(true) + }, + onInstallExtension = { + presenter.installExtension(it) + }, + onOpenExtension = { + val controller = ExtensionDetailsController(it.pkgName) + parentController!!.router.pushController(controller) + }, + onTrustExtension = { + presenter.trustSignature(it.signatureHash) + }, + onUninstallExtension = { + presenter.uninstallExtension(it.pkgName) + }, + onUpdateExtension = { + presenter.updateExtension(it) + }, + onRefresh = { + presenter.findAvailableExtensions() + }, + ) } 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) { inflater.inflate(R.menu.browse_extensions, menu) @@ -142,93 +116,11 @@ open class ExtensionController : } searchView.queryTextChanges() - .drop(1) // Drop first event after subscribed .filter { router.backstack.lastOrNull()?.controller == this } .onEach { query = it.toString() - updateExtensionsList() + presenter.search(query) } .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) { - 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) - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt deleted file mode 100644 index 099ad8c88..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupHolder.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt deleted file mode 100644 index 53adf7588..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionGroupItem.kt +++ /dev/null @@ -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() { - - 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>): ExtensionGroupHolder { - return ExtensionGroupHolder(view, adapter) - } - - /** - * Binds this item to the given view holder. - */ - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: ExtensionGroupHolder, - position: Int, - payloads: List?, - ) { - 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() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt deleted file mode 100644 index 28041c556..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionHolder.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt deleted file mode 100644 index 5e895f6b5..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionItem.kt +++ /dev/null @@ -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(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>): ExtensionHolder { - return ExtensionHolder(view, adapter as ExtensionAdapter) - } - - /** - * Binds this item to the given view holder. - */ - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: ExtensionHolder, - position: Int, - payloads: List?, - ) { - 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() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt index c4fd1f4bb..76b38aa06 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionPresenter.kt @@ -2,144 +2,151 @@ package eu.kanade.tachiyomi.ui.browse.extension import android.app.Application 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.data.preference.PreferenceValues -import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.model.Extension 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.util.lang.launchIO 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.Subscription -import rx.android.schedulers.AndroidSchedulers import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.concurrent.TimeUnit - -private typealias ExtensionTuple = - Triple, List, List> /** * Presenter of [ExtensionController]. */ open class ExtensionPresenter( 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() { - private var extensions = emptyList() + private val _query: MutableStateFlow = MutableStateFlow("") - private var currentDownloads = hashMapOf() + private var _currentDownloads = MutableStateFlow>(hashMapOf()) + + private val _state: MutableStateFlow = MutableStateFlow(ExtensionState.Uninitialized) + val state: StateFlow = _state.asStateFlow() + + var isRefreshing: Boolean by mutableStateOf(true) override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) extensionManager.findAvailableExtensions() - bindToExtensionsObservable() - } - private fun bindToExtensionsObservable(): Subscription { - val installedObservable = extensionManager.getInstalledExtensionsObservable() - val untrustedObservable = extensionManager.getUntrustedExtensionsObservable() - val availableObservable = extensionManager.getAvailableExtensionsObservable() - .startWith(emptyList()) - - 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 { val context = Injekt.get() - val activeLangs = preferences.enabledLanguages().get() - val showNsfwSources = preferences.showNsfwSource().get() - - val (installed, untrusted, available) = tuple - - val items = mutableListOf() - - 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 { !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 -> - ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle) + val extensionMapper: (Map) -> ((Extension) -> ExtensionUiModel) = { map -> + { + ExtensionUiModel.Item(it, map[it.pkgName] ?: InstallStep.Idle) } } - 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 -> - ExtensionItem(extension, header) - } - } - if (availableSorted.isNotEmpty()) { - val availableGroupedByLang = availableSorted - .groupBy { LocaleHelper.getSourceDisplayName(it.lang, context) } - .toSortedMap() - - availableGroupedByLang - .forEach { - val header = ExtensionGroupItem(it.key, it.value.size) - items += it.value.map { extension -> - ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle) + val queryFilter: (String) -> ((Extension) -> Boolean) = { query -> + 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) } } + } } - this.extensions = items - return items + launchIO { + combine( + _query, + getExtensions.subscribe(), + getExtensionUpdates.subscribe(), + _currentDownloads, + ) { query, (installed, untrusted, available), updates, downloads -> + isRefreshing = false + + val languagesWithExtensions = available + .filter(queryFilter(query)) + .groupBy { LocaleHelper.getSourceDisplayName(it.lang, context) } + .toSortedMap() + .flatMap { (key, value) -> + listOf( + ExtensionUiModel.Header.Text(key), + *value.map(extensionMapper(downloads)).toTypedArray(), + ) + } + + val items = mutableListOf() + + val updates = updates.filter(queryFilter(query)).map(extensionMapper(downloads)) + if (updates.isNotEmpty()) { + 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) + } + } } - @Synchronized - private fun updateInstallStep(extension: Extension, state: InstallStep): ExtensionItem? { - val extensions = extensions.toMutableList() - val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName } + fun search(query: String) { + launchIO { + _query.emit(query) + } + } - return if (position != -1) { - val item = extensions[position].copy(installStep = state) - extensions[position] = item - - this.extensions = extensions - item - } else { - null + fun updateAllExtensions() { + launchIO { + val state = _state.value + if (state !is ExtensionState.Initialized) return@launchIO + state.list.mapNotNull { + if (it !is ExtensionUiModel.Item) return@mapNotNull null + if (it.extension !is Extension.Installed) return@mapNotNull null + if (it.extension.hasUpdate.not()) return@mapNotNull null + it.extension + }.forEach { + updateExtension(it) + } } } @@ -155,15 +162,29 @@ open class ExtensionPresenter( extensionManager.cancelInstallUpdateExtension(extension) } + private fun removeDownloadState(extension: Extension) { + _currentDownloads.update { map -> + val map = map.toMutableMap() + map.remove(extension.pkgName) + map + } + } + + private fun addDownloadState(extension: Extension, installStep: InstallStep) { + _currentDownloads.update { map -> + val map = map.toMutableMap() + map[extension.pkgName] = installStep + map + } + } + private fun Observable.subscribeToInstallUpdate(extension: Extension) { - this.doOnNext { currentDownloads[extension.pkgName] = it } - .doOnUnsubscribe { currentDownloads.remove(extension.pkgName) } - .map { state -> updateInstallStep(extension, state) } - .subscribeWithView({ view, item -> - if (item != null) { - view.downloadUpdate(item) - } - },) + this + .doOnUnsubscribe { removeDownloadState(extension) } + .subscribe( + { installStep -> addDownloadState(extension, installStep) }, + { removeDownloadState(extension) }, + ) } fun uninstallExtension(pkgName: String) { @@ -171,6 +192,7 @@ open class ExtensionPresenter( } fun findAvailableExtensions() { + isRefreshing = true extensionManager.findAvailableExtensions() } @@ -178,3 +200,28 @@ open class ExtensionPresenter( 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) : ExtensionState() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt deleted file mode 100644 index 23d23a32b..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionTrustDialog.kt +++ /dev/null @@ -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(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" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt index a4bae2484..e9f4b263f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionViewUtils.kt @@ -3,7 +3,15 @@ package eu.kanade.tachiyomi.ui.browse.extension import android.content.Context import android.content.pm.PackageManager 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.util.lang.withIOContext fun Extension.getApplicationIcon(context: Context): Drawable? { return try { @@ -12,3 +20,27 @@ fun Extension.getApplicationIcon(context: Context): Drawable? { null } } + +@Composable +fun Extension.getIcon(): State> { + val context = LocalContext.current + return produceState>(initialValue = Result.Loading, this) { + withIOContext { + value = try { + Result.Success( + context.packageManager.getApplicationIcon(pkgName) + .toBitmap() + .asImageBitmap(), + ) + } catch (e: Exception) { + Result.Error + } + } + } +} + +sealed class Result { + object Loading : Result() + object Error : Result() + data class Success(val value: T) : Result() +} diff --git a/app/src/main/res/layout/extension_controller.xml b/app/src/main/res/layout/extension_controller.xml deleted file mode 100644 index 0db6a3024..000000000 --- a/app/src/main/res/layout/extension_controller.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/extension_item.xml b/app/src/main/res/layout/extension_item.xml deleted file mode 100644 index b76c9437d..000000000 --- a/app/src/main/res/layout/extension_item.xml +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - -