Use Voyager on Extension Details screen (#8576)

This commit is contained in:
Andreas 2022-11-20 20:36:03 +01:00 committed by GitHub
parent b7fa25777d
commit f1b85ff39d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 325 additions and 264 deletions

View File

@ -3,7 +3,6 @@ package eu.kanade.domain.extension.interactor
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionSourceItem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@ -30,3 +29,9 @@ class GetExtensionSources(
}
}
}
data class ExtensionSourceItem(
val source: Source,
val enabled: Boolean,
val labelAsName: Boolean,
)

View File

@ -38,19 +38,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import eu.kanade.domain.extension.interactor.ExtensionSourceItem
import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.DIVIDER_ALPHA
import eu.kanade.presentation.components.Divider
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.components.ScrollbarLazyColumn
import eu.kanade.presentation.components.WarningBanner
@ -60,18 +59,22 @@ import eu.kanade.presentation.util.padding
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsPresenter
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionSourceItem
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsState
import eu.kanade.tachiyomi.util.system.LocaleHelper
@Composable
fun ExtensionDetailsScreen(
navigateUp: () -> Unit,
presenter: ExtensionDetailsPresenter,
state: ExtensionDetailsState,
onClickSourcePreferences: (sourceId: Long) -> Unit,
onClickWhatsNew: () -> Unit,
onClickReadme: () -> Unit,
onClickEnableAll: () -> Unit,
onClickDisableAll: () -> Unit,
onClickClearCookies: () -> Unit,
onClickUninstall: () -> Unit,
onClickSource: (sourceId: Long) -> Unit,
) {
val uriHandler = LocalUriHandler.current
Scaffold(
topBar = { scrollBehavior ->
AppBar(
@ -80,19 +83,19 @@ fun ExtensionDetailsScreen(
actions = {
AppBarActions(
actions = buildList {
if (presenter.extension?.isUnofficial == false) {
if (state.extension?.isUnofficial == false) {
add(
AppBar.Action(
title = stringResource(R.string.whats_new),
icon = Icons.Outlined.History,
onClick = { uriHandler.openUri(presenter.getChangelogUrl()) },
onClick = onClickWhatsNew,
),
)
add(
AppBar.Action(
title = stringResource(R.string.action_faq_and_guides),
icon = Icons.Outlined.HelpOutline,
onClick = { uriHandler.openUri(presenter.getReadmeUrl()) },
onClick = onClickReadme,
),
)
}
@ -100,15 +103,15 @@ fun ExtensionDetailsScreen(
listOf(
AppBar.OverflowAction(
title = stringResource(R.string.action_enable_all),
onClick = { presenter.toggleSources(true) },
onClick = onClickEnableAll,
),
AppBar.OverflowAction(
title = stringResource(R.string.action_disable_all),
onClick = { presenter.toggleSources(false) },
onClick = onClickDisableAll,
),
AppBar.OverflowAction(
title = stringResource(R.string.pref_clear_cookies),
onClick = { presenter.clearCookies() },
onClick = onClickClearCookies,
),
),
)
@ -119,77 +122,86 @@ fun ExtensionDetailsScreen(
)
},
) { paddingValues ->
ExtensionDetails(paddingValues, presenter, onClickSourcePreferences)
if (state.extension == null) {
EmptyScreen(
textResource = R.string.empty_screen,
modifier = Modifier.padding(paddingValues),
)
return@Scaffold
}
ExtensionDetails(
contentPadding = paddingValues,
extension = state.extension,
sources = state.sources,
onClickSourcePreferences = onClickSourcePreferences,
onClickUninstall = onClickUninstall,
onClickSource = onClickSource,
)
}
}
@Composable
private fun ExtensionDetails(
contentPadding: PaddingValues,
presenter: ExtensionDetailsPresenter,
extension: Extension.Installed,
sources: List<ExtensionSourceItem>,
onClickSourcePreferences: (sourceId: Long) -> Unit,
onClickUninstall: () -> Unit,
onClickSource: (sourceId: Long) -> Unit,
) {
when {
presenter.isLoading -> LoadingScreen()
presenter.extension == null -> EmptyScreen(
textResource = R.string.empty_screen,
modifier = Modifier.padding(contentPadding),
)
else -> {
val context = LocalContext.current
val extension = presenter.extension
var showNsfwWarning by remember { mutableStateOf(false) }
ScrollbarLazyColumn(
contentPadding = contentPadding,
) {
when {
extension.isUnofficial ->
item {
WarningBanner(R.string.unofficial_extension_message)
}
extension.isObsolete ->
item {
WarningBanner(R.string.obsolete_extension_message)
}
}
val context = LocalContext.current
var showNsfwWarning by remember { mutableStateOf(false) }
ScrollbarLazyColumn(
contentPadding = contentPadding,
) {
when {
extension.isUnofficial ->
item {
DetailsHeader(
extension = extension,
onClickUninstall = { presenter.uninstallExtension() },
onClickAppInfo = {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", extension.pkgName, null)
context.startActivity(this)
}
},
onClickAgeRating = {
showNsfwWarning = true
},
)
WarningBanner(R.string.unofficial_extension_message)
}
items(
items = presenter.sources,
key = { it.source.id },
) { source ->
SourceSwitchPreference(
modifier = Modifier.animateItemPlacement(),
source = source,
onClickSourcePreferences = onClickSourcePreferences,
onClickSource = { presenter.toggleSource(it) },
)
extension.isObsolete ->
item {
WarningBanner(R.string.obsolete_extension_message)
}
}
if (showNsfwWarning) {
NsfwWarningDialog(
onClickConfirm = {
showNsfwWarning = false
},
)
}
}
item {
DetailsHeader(
extension = extension,
onClickUninstall = onClickUninstall,
onClickAppInfo = {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", extension.pkgName, null)
context.startActivity(this)
}
},
onClickAgeRating = {
showNsfwWarning = true
},
)
}
items(
items = sources,
key = { it.source.id },
) { source ->
SourceSwitchPreference(
modifier = Modifier.animateItemPlacement(),
source = source,
onClickSourcePreferences = onClickSourcePreferences,
onClickSource = onClickSource,
)
}
}
if (showNsfwWarning) {
NsfwWarningDialog(
onClickConfirm = {
showNsfwWarning = false
},
)
}
}

View File

@ -1,25 +0,0 @@
package eu.kanade.presentation.browse
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionSourceItem
@Stable
interface ExtensionDetailsState {
val isLoading: Boolean
val extension: Extension.Installed?
val sources: List<ExtensionSourceItem>
}
fun ExtensionDetailsState(): ExtensionDetailsState {
return ExtensionDetailsStateImpl()
}
class ExtensionDetailsStateImpl : ExtensionDetailsState {
override var isLoading: Boolean by mutableStateOf(true)
override var extension: Extension.Installed? by mutableStateOf(null)
override var sources: List<ExtensionSourceItem> by mutableStateOf(emptyList())
}

View File

@ -1,36 +1,26 @@
package eu.kanade.tachiyomi.ui.browse.extension.details
import android.annotation.SuppressLint
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.core.os.bundleOf
import cafe.adriel.voyager.navigator.Navigator
import eu.kanade.presentation.browse.ExtensionDetailsScreen
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
@SuppressLint("RestrictedApi")
class ExtensionDetailsController(
bundle: Bundle? = null,
) : FullComposeController<ExtensionDetailsPresenter>(bundle) {
private const val PKGNAME_KEY = "pkg_name"
constructor(pkgName: String) : this(
bundleOf(PKGNAME_KEY to pkgName),
)
class ExtensionDetailsController : BasicFullComposeController {
override fun createPresenter() = ExtensionDetailsPresenter(args.getString(PKGNAME_KEY)!!)
@Suppress("unused")
constructor(bundle: Bundle) : this(bundle.getString(PKGNAME_KEY)!!)
constructor(pkgName: String) : super(bundleOf(PKGNAME_KEY to pkgName))
val pkgName: String
get() = args.getString(PKGNAME_KEY)!!
@Composable
override fun ComposeContent() {
ExtensionDetailsScreen(
navigateUp = router::popCurrentController,
presenter = presenter,
onClickSourcePreferences = { router.pushController(SourcePreferencesController(it)) },
)
}
fun onExtensionUninstalled() {
router.popCurrentController()
Navigator(screen = ExtensionDetailsScreen(pkgName = pkgName))
}
}
private const val PKGNAME_KEY = "pkg_name"

View File

@ -1,145 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.extension.details
import android.app.Application
import android.os.Bundle
import eu.kanade.domain.extension.interactor.GetExtensionSources
import eu.kanade.domain.source.interactor.ToggleSource
import eu.kanade.presentation.browse.ExtensionDetailsState
import eu.kanade.presentation.browse.ExtensionDetailsStateImpl
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.Source
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.lang.withUIContext
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import okhttp3.HttpUrl.Companion.toHttpUrl
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionDetailsPresenter(
private val pkgName: String,
private val state: ExtensionDetailsStateImpl = ExtensionDetailsState() as ExtensionDetailsStateImpl,
private val context: Application = Injekt.get(),
private val getExtensionSources: GetExtensionSources = Injekt.get(),
private val toggleSource: ToggleSource = Injekt.get(),
private val network: NetworkHelper = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
) : BasePresenter<ExtensionDetailsController>(), ExtensionDetailsState by state {
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
presenterScope.launchIO {
extensionManager.installedExtensionsFlow
.map { it.firstOrNull { pkg -> pkg.pkgName == pkgName } }
.collectLatest { extension ->
// If extension is null it's most likely uninstalled
if (extension == null) {
withUIContext {
view?.onExtensionUninstalled()
}
return@collectLatest
}
state.extension = extension
fetchExtensionSources()
}
}
}
private fun CoroutineScope.fetchExtensionSources() {
launchIO {
getExtensionSources.subscribe(extension!!)
.map {
it.sortedWith(
compareBy(
{ item -> item.enabled.not() },
{ item -> if (item.labelAsName) item.source.name else LocaleHelper.getSourceDisplayName(item.source.lang, context).lowercase() },
),
)
}
.collectLatest {
state.isLoading = false
state.sources = it
}
}
}
fun getChangelogUrl(): String {
extension ?: return ""
val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
val pkgFactory = extension.pkgFactory
if (extension.hasChangelog) {
return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/CHANGELOG.md")
}
// Falling back on GitHub commit history because there is no explicit changelog in extension
return createUrl(URL_EXTENSION_COMMITS, pkgName, pkgFactory)
}
fun getReadmeUrl(): String {
extension ?: return ""
if (!extension.hasReadme) {
return "https://tachiyomi.org/help/faq/#extensions"
}
val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
val pkgFactory = extension.pkgFactory
return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/README.md")
}
fun clearCookies() {
val urls = extension?.sources
?.filterIsInstance<HttpSource>()
?.map { it.baseUrl }
?.distinct() ?: emptyList()
val cleared = urls.sumOf {
network.cookieManager.remove(it.toHttpUrl())
}
logcat { "Cleared $cleared cookies for: ${urls.joinToString()}" }
}
fun uninstallExtension() {
val extension = extension ?: return
extensionManager.uninstallExtension(extension.pkgName)
}
fun toggleSource(sourceId: Long) {
toggleSource.await(sourceId)
}
fun toggleSources(enable: Boolean) {
extension?.sources
?.map { it.id }
?.let { toggleSource.await(it, enable) }
}
private fun createUrl(url: String, pkgName: String, pkgFactory: String?, path: String = ""): String {
return if (!pkgFactory.isNullOrEmpty()) {
when (path.isEmpty()) {
true -> "$url/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/$pkgFactory"
else -> "$url/multisrc/overrides/$pkgFactory/" + (pkgName.split(".").lastOrNull() ?: "") + path
}
} else {
url + "/src/" + pkgName.replace(".", "/") + path
}
}
}
data class ExtensionSourceItem(
val source: Source,
val enabled: Boolean,
val labelAsName: Boolean,
)
private const val URL_EXTENSION_COMMITS = "https://github.com/tachiyomiorg/tachiyomi-extensions/commits/master"
private const val URL_EXTENSION_BLOB = "https://github.com/tachiyomiorg/tachiyomi-extensions/blob/master"

View File

@ -0,0 +1,57 @@
package eu.kanade.tachiyomi.ui.browse.extension.details
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.ExtensionDetailsScreen
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.util.LocalRouter
import eu.kanade.tachiyomi.ui.base.controller.pushController
import kotlinx.coroutines.flow.collectLatest
class ExtensionDetailsScreen(
private val pkgName: String,
) : Screen {
@Composable
override fun Content() {
val context = LocalContext.current
val screenModel = rememberScreenModel { ExtensionDetailsScreenModel(pkgName = pkgName, context = context) }
val state by screenModel.state.collectAsState()
if (state.isLoading) {
LoadingScreen()
return
}
val router = LocalRouter.currentOrThrow
val uriHandler = LocalUriHandler.current
ExtensionDetailsScreen(
navigateUp = router::popCurrentController,
state = state,
onClickSourcePreferences = { router.pushController(SourcePreferencesController(it)) },
onClickWhatsNew = { uriHandler.openUri(screenModel.getChangelogUrl()) },
onClickReadme = { uriHandler.openUri(screenModel.getReadmeUrl()) },
onClickEnableAll = { screenModel.toggleSources(true) },
onClickDisableAll = { screenModel.toggleSources(false) },
onClickClearCookies = { screenModel.clearCookies() },
onClickUninstall = { screenModel.uninstallExtension() },
onClickSource = { screenModel.toggleSource(it) },
)
LaunchedEffect(Unit) {
screenModel.events.collectLatest { event ->
if (event is ExtensionDetailsEvent.Uninstalled) {
router.popCurrentController()
}
}
}
}
}

View File

@ -0,0 +1,167 @@
package eu.kanade.tachiyomi.ui.browse.extension.details
import android.content.Context
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.coroutineScope
import eu.kanade.domain.extension.interactor.ExtensionSourceItem
import eu.kanade.domain.extension.interactor.GetExtensionSources
import eu.kanade.domain.source.interactor.ToggleSource
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrl
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
private const val URL_EXTENSION_COMMITS =
"https://github.com/tachiyomiorg/tachiyomi-extensions/commits/master"
private const val URL_EXTENSION_BLOB =
"https://github.com/tachiyomiorg/tachiyomi-extensions/blob/master"
class ExtensionDetailsScreenModel(
pkgName: String,
context: Context,
private val network: NetworkHelper = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
private val getExtensionSources: GetExtensionSources = Injekt.get(),
private val toggleSource: ToggleSource = Injekt.get(),
) : StateScreenModel<ExtensionDetailsState>(ExtensionDetailsState()) {
private val _events: Channel<ExtensionDetailsEvent> = Channel()
val events: Flow<ExtensionDetailsEvent> = _events.receiveAsFlow()
init {
coroutineScope.launch {
launch {
extensionManager.installedExtensionsFlow
.map { it.firstOrNull { extension -> extension.pkgName == pkgName } }
.collectLatest { extension ->
if (extension == null) {
_events.send(ExtensionDetailsEvent.Uninstalled)
return@collectLatest
}
mutableState.update { state ->
state.copy(extension = extension)
}
}
}
launch {
state.collectLatest { state ->
if (state.extension == null) return@collectLatest
getExtensionSources.subscribe(state.extension)
.map {
it.sortedWith(
compareBy(
{ !it.enabled },
{ item ->
item.source.name.takeIf { item.labelAsName }
?: LocaleHelper.getSourceDisplayName(item.source.lang, context).lowercase()
},
),
)
}.collectLatest { sources ->
mutableState.update {
it.copy(
sources = sources,
)
}
}
}
}
}
}
fun getChangelogUrl(): String {
val extension = state.value.extension ?: return ""
val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
val pkgFactory = extension.pkgFactory
if (extension.hasChangelog) {
return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/CHANGELOG.md")
}
// Falling back on GitHub commit history because there is no explicit changelog in extension
return createUrl(URL_EXTENSION_COMMITS, pkgName, pkgFactory)
}
fun getReadmeUrl(): String {
val extension = state.value.extension ?: return ""
if (!extension.hasReadme) {
return "https://tachiyomi.org/help/faq/#extensions"
}
val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
val pkgFactory = extension.pkgFactory
return createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/README.md")
}
fun clearCookies() {
val extension = state.value.extension ?: return
val urls = extension.sources
.filterIsInstance<HttpSource>()
.map { it.baseUrl }
.distinct()
val cleared = urls.sumOf {
network.cookieManager.remove(it.toHttpUrl())
}
logcat { "Cleared $cleared cookies for: ${urls.joinToString()}" }
}
fun uninstallExtension() {
val extension = state.value.extension ?: return
extensionManager.uninstallExtension(extension.pkgName)
}
fun toggleSource(sourceId: Long) {
toggleSource.await(sourceId)
}
fun toggleSources(enable: Boolean) {
state.value.extension?.sources
?.map { it.id }
?.let { toggleSource.await(it, enable) }
}
private fun createUrl(
url: String,
pkgName: String,
pkgFactory: String?,
path: String = "",
): String {
return if (!pkgFactory.isNullOrEmpty()) {
when (path.isEmpty()) {
true -> "$url/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/$pkgFactory"
else -> "$url/multisrc/overrides/$pkgFactory/" + (pkgName.split(".").lastOrNull() ?: "") + path
}
} else {
url + "/src/" + pkgName.replace(".", "/") + path
}
}
}
sealed class ExtensionDetailsEvent {
object Uninstalled : ExtensionDetailsEvent()
}
data class ExtensionDetailsState(
val extension: Extension.Installed? = null,
val sources: List<ExtensionSourceItem> = emptyList(),
) {
val isLoading: Boolean
get() = sources.isEmpty()
}