mirror of
https://github.com/mihonapp/mihon.git
synced 2025-11-12 20:19:05 +01:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<ExtensionControllerBinding, 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()
|
||||
ComposeController<ExtensionPresenter>() {
|
||||
|
||||
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<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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<Extension.Installed>, List<Extension.Untrusted>, List<Extension.Available>>
|
||||
|
||||
/**
|
||||
* 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<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?) {
|
||||
super.onCreate(savedState)
|
||||
|
||||
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 activeLangs = preferences.enabledLanguages().get()
|
||||
val showNsfwSources = preferences.showNsfwSource().get()
|
||||
|
||||
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 ->
|
||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
|
||||
val extensionMapper: (Map<String, InstallStep>) -> ((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<ExtensionUiModel>()
|
||||
|
||||
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<InstallStep>.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<ExtensionUiModel>) : ExtensionState()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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<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>()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user