|
@@ -2,144 +2,151 @@ package eu.kanade.tachiyomi.ui.browse.extension
|
|
|
|
|
|
import android.app.Application
|
|
import android.app.Application
|
|
import android.os.Bundle
|
|
import android.os.Bundle
|
|
-import android.view.View
|
|
|
|
|
|
+import androidx.annotation.StringRes
|
|
|
|
+import androidx.compose.runtime.getValue
|
|
|
|
+import androidx.compose.runtime.mutableStateOf
|
|
|
|
+import androidx.compose.runtime.setValue
|
|
|
|
+import eu.kanade.domain.extension.interactor.GetExtensionUpdates
|
|
|
|
+import eu.kanade.domain.extension.interactor.GetExtensions
|
|
import eu.kanade.tachiyomi.R
|
|
import eu.kanade.tachiyomi.R
|
|
-import eu.kanade.tachiyomi.data.preference.PreferenceValues
|
|
|
|
-import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
|
|
|
import eu.kanade.tachiyomi.extension.ExtensionManager
|
|
import eu.kanade.tachiyomi.extension.ExtensionManager
|
|
import eu.kanade.tachiyomi.extension.model.Extension
|
|
import eu.kanade.tachiyomi.extension.model.Extension
|
|
import eu.kanade.tachiyomi.extension.model.InstallStep
|
|
import eu.kanade.tachiyomi.extension.model.InstallStep
|
|
|
|
+import eu.kanade.tachiyomi.source.online.HttpSource
|
|
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
|
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
|
|
|
+import eu.kanade.tachiyomi.util.lang.launchIO
|
|
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
|
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
|
|
|
+import kotlinx.coroutines.flow.MutableStateFlow
|
|
|
|
+import kotlinx.coroutines.flow.StateFlow
|
|
|
|
+import kotlinx.coroutines.flow.asStateFlow
|
|
|
|
+import kotlinx.coroutines.flow.collectLatest
|
|
|
|
+import kotlinx.coroutines.flow.combine
|
|
|
|
+import kotlinx.coroutines.flow.update
|
|
import rx.Observable
|
|
import rx.Observable
|
|
-import rx.Subscription
|
|
|
|
-import rx.android.schedulers.AndroidSchedulers
|
|
|
|
import uy.kohesive.injekt.Injekt
|
|
import uy.kohesive.injekt.Injekt
|
|
import uy.kohesive.injekt.api.get
|
|
import uy.kohesive.injekt.api.get
|
|
-import java.util.concurrent.TimeUnit
|
|
|
|
-
|
|
|
|
-private typealias ExtensionTuple =
|
|
|
|
- Triple<List<Extension.Installed>, List<Extension.Untrusted>, List<Extension.Available>>
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
* Presenter of [ExtensionController].
|
|
* Presenter of [ExtensionController].
|
|
*/
|
|
*/
|
|
open class ExtensionPresenter(
|
|
open class ExtensionPresenter(
|
|
private val extensionManager: ExtensionManager = Injekt.get(),
|
|
private val extensionManager: ExtensionManager = Injekt.get(),
|
|
- private val preferences: PreferencesHelper = Injekt.get(),
|
|
|
|
|
|
+ private val getExtensionUpdates: GetExtensionUpdates = Injekt.get(),
|
|
|
|
+ private val getExtensions: GetExtensions = Injekt.get(),
|
|
) : BasePresenter<ExtensionController>() {
|
|
) : BasePresenter<ExtensionController>() {
|
|
|
|
|
|
- private var extensions = emptyList<ExtensionItem>()
|
|
|
|
|
|
+ private val _query: MutableStateFlow<String> = MutableStateFlow("")
|
|
|
|
+
|
|
|
|
+ private var _currentDownloads = MutableStateFlow<Map<String, InstallStep>>(hashMapOf())
|
|
|
|
+
|
|
|
|
+ private val _state: MutableStateFlow<ExtensionState> = MutableStateFlow(ExtensionState.Uninitialized)
|
|
|
|
+ val state: StateFlow<ExtensionState> = _state.asStateFlow()
|
|
|
|
|
|
- private var currentDownloads = hashMapOf<String, InstallStep>()
|
|
|
|
|
|
+ var isRefreshing: Boolean by mutableStateOf(true)
|
|
|
|
|
|
override fun onCreate(savedState: Bundle?) {
|
|
override fun onCreate(savedState: Bundle?) {
|
|
super.onCreate(savedState)
|
|
super.onCreate(savedState)
|
|
|
|
|
|
extensionManager.findAvailableExtensions()
|
|
extensionManager.findAvailableExtensions()
|
|
- bindToExtensionsObservable()
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- private fun bindToExtensionsObservable(): Subscription {
|
|
|
|
- val installedObservable = extensionManager.getInstalledExtensionsObservable()
|
|
|
|
- val untrustedObservable = extensionManager.getUntrustedExtensionsObservable()
|
|
|
|
- val availableObservable = extensionManager.getAvailableExtensionsObservable()
|
|
|
|
- .startWith(emptyList<Extension.Available>())
|
|
|
|
-
|
|
|
|
- return Observable.combineLatest(installedObservable, untrustedObservable, availableObservable) { installed, untrusted, available -> Triple(installed, untrusted, available) }
|
|
|
|
- .debounce(500, TimeUnit.MILLISECONDS)
|
|
|
|
- .map(::toItems)
|
|
|
|
- .observeOn(AndroidSchedulers.mainThread())
|
|
|
|
- .subscribeLatestCache({ view, _ -> view.setExtensions(extensions) })
|
|
|
|
- }
|
|
|
|
|
|
|
|
- @Synchronized
|
|
|
|
- private fun toItems(tuple: ExtensionTuple): List<ExtensionItem> {
|
|
|
|
val context = Injekt.get<Application>()
|
|
val context = Injekt.get<Application>()
|
|
- val activeLangs = preferences.enabledLanguages().get()
|
|
|
|
- val showNsfwSources = preferences.showNsfwSource().get()
|
|
|
|
-
|
|
|
|
- val (installed, untrusted, available) = tuple
|
|
|
|
-
|
|
|
|
- val items = mutableListOf<ExtensionItem>()
|
|
|
|
|
|
+ val extensionMapper: (Map<String, InstallStep>) -> ((Extension) -> ExtensionUiModel) = { map ->
|
|
|
|
+ {
|
|
|
|
+ ExtensionUiModel.Item(it, map[it.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)
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
|
|
- val updatesSorted = installed.filter { it.hasUpdate && (showNsfwSources || !it.isNsfw) }
|
|
|
|
- .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
|
|
|
|
|
|
+ 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 installedSorted = installed.filter { !it.hasUpdate && (showNsfwSources || !it.isNsfw) }
|
|
|
|
- .sortedWith(
|
|
|
|
- compareBy<Extension.Installed> { !it.isObsolete }
|
|
|
|
- .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
|
|
|
|
- )
|
|
|
|
|
|
+ val items = mutableListOf<ExtensionUiModel>()
|
|
|
|
|
|
- val untrustedSorted = untrusted.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
|
|
|
|
|
|
+ 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 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) }
|
|
|
|
|
|
+ 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)
|
|
}
|
|
}
|
|
- }
|
|
|
|
- items += updatesSorted.map { extension ->
|
|
|
|
- ExtensionItem(extension, header, currentDownloads[extension.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)
|
|
|
|
- }
|
|
|
|
|
|
+ if (languagesWithExtensions.isNotEmpty()) {
|
|
|
|
+ items.addAll(languagesWithExtensions)
|
|
|
|
+ }
|
|
|
|
|
|
- items += untrustedSorted.map { extension ->
|
|
|
|
- ExtensionItem(extension, header)
|
|
|
|
|
|
+ items
|
|
|
|
+ }.collectLatest {
|
|
|
|
+ _state.value = ExtensionState.Initialized(it)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
- 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)
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- this.extensions = items
|
|
|
|
- return items
|
|
|
|
}
|
|
}
|
|
|
|
|
|
- @Synchronized
|
|
|
|
- private fun updateInstallStep(extension: Extension, state: InstallStep): ExtensionItem? {
|
|
|
|
- val extensions = extensions.toMutableList()
|
|
|
|
- val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName }
|
|
|
|
-
|
|
|
|
- return if (position != -1) {
|
|
|
|
- val item = extensions[position].copy(installStep = state)
|
|
|
|
- extensions[position] = item
|
|
|
|
|
|
+ fun search(query: String) {
|
|
|
|
+ launchIO {
|
|
|
|
+ _query.emit(query)
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
|
|
- 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)
|
|
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) {
|
|
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) {
|
|
fun uninstallExtension(pkgName: String) {
|
|
@@ -171,6 +192,7 @@ open class ExtensionPresenter(
|
|
}
|
|
}
|
|
|
|
|
|
fun findAvailableExtensions() {
|
|
fun findAvailableExtensions() {
|
|
|
|
+ isRefreshing = true
|
|
extensionManager.findAvailableExtensions()
|
|
extensionManager.findAvailableExtensions()
|
|
}
|
|
}
|
|
|
|
|
|
@@ -178,3 +200,28 @@ open class ExtensionPresenter(
|
|
extensionManager.trustSignature(signatureHash)
|
|
extensionManager.trustSignature(signatureHash)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
+
|
|
|
|
+sealed interface ExtensionUiModel {
|
|
|
|
+ sealed interface Header : ExtensionUiModel {
|
|
|
|
+ data class Resource(@StringRes val textRes: Int) : Header
|
|
|
|
+ data class Text(val text: String) : Header
|
|
|
|
+ }
|
|
|
|
+ data class Item(
|
|
|
|
+ val extension: Extension,
|
|
|
|
+ val installStep: InstallStep,
|
|
|
|
+ ) : ExtensionUiModel {
|
|
|
|
+
|
|
|
|
+ fun key(): String {
|
|
|
|
+ return when (extension) {
|
|
|
|
+ is Extension.Installed ->
|
|
|
|
+ if (extension.hasUpdate) "update_${extension.pkgName}" else extension.pkgName
|
|
|
|
+ else -> extension.pkgName
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+sealed class ExtensionState {
|
|
|
|
+ object Uninitialized : ExtensionState()
|
|
|
|
+ data class Initialized(val list: List<ExtensionUiModel>) : ExtensionState()
|
|
|
|
+}
|