Use Tachi previews info + chapters manga page, plus of course SY features integrated into it

Add missed invert tap settings
Add missed extension open in settings overflow menu option
Cleanup
This commit is contained in:
Jobobby04
2020-07-12 19:21:29 -04:00
parent 8ab2a823b5
commit 372e570fac
90 changed files with 3115 additions and 6190 deletions
@@ -67,6 +67,8 @@ class AppModule(val app: Application) : InjektModule {
GlobalScope.launch { get<DownloadManager>() }
// SY -->
GlobalScope.launch { get<CustomMangaManager>() }
// SY <--
}
}
@@ -105,6 +105,10 @@ class BackupRestoreService : Service() {
// SY -->
private val throttleManager = EHentaiThrottleManager()
private var skippedAmount = 0
private var totalAmount = 0
// SY <--
/**
@@ -117,12 +121,6 @@ class BackupRestoreService : Service() {
*/
private var restoreAmount = 0
// SY -->
private var skippedAmount = 0
private var totalAmount = 0
// SY <--
/**
* Mapping of source ID to source name from backup data
*/
@@ -14,7 +14,9 @@ object MangaTypeAdapter {
write {
beginArray()
value(it.url)
// SY -->
value(it.originalTitle)
// SY <--
value(it.source)
value(it.viewer)
value(it.chapter_flags)
@@ -52,11 +52,13 @@ class MangaPutResolver : DefaultPutResolver<Manga>() {
put(COL_ID, obj.id)
put(COL_SOURCE, obj.source)
put(COL_URL, obj.url)
// SY -->
put(COL_ARTIST, obj.originalArtist)
put(COL_AUTHOR, obj.originalAuthor)
put(COL_DESCRIPTION, obj.originalDescription)
put(COL_GENRE, obj.originalGenre)
put(COL_TITLE, obj.originalTitle)
// SY <--
put(COL_STATUS, obj.status)
put(COL_THUMBNAIL_URL, obj.thumbnail_url)
put(COL_FAVORITE, obj.favorite)
@@ -11,6 +11,7 @@ open class MangaImpl : Manga {
override lateinit var url: String
// SY -->
private val customMangaManager: CustomMangaManager by injectLazy()
override var title: String
@@ -39,6 +40,7 @@ open class MangaImpl : Manga {
override var genre: String?
get() = if (favorite) customMangaManager.getManga(this)?.genre ?: ogGenre else ogGenre
set(value) { ogGenre = value }
// SY <--
override var status: Int = 0
@@ -58,6 +60,7 @@ open class MangaImpl : Manga {
override var cover_last_modified: Long = 0
// SY -->
lateinit var ogTitle: String
private set
var ogAuthor: String? = null
@@ -68,6 +71,7 @@ open class MangaImpl : Manga {
private set
var ogGenre: String? = null
private set
// SY <--
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -22,7 +22,7 @@ import uy.kohesive.injekt.injectLazy
*
* @param context the application context.
*/
class DownloadManager(val context: Context) {
class DownloadManager(/* SY private */ val context: Context) {
/**
* The sources manager.
@@ -118,7 +118,9 @@ class DownloadProvider(private val context: Context) {
* @param manga the manga to query.
*/
fun getMangaDirName(manga: Manga): String {
// SY -->
return DiskUtil.buildValidFilename(manga.originalTitle)
// SY <--
}
/**
@@ -67,6 +67,8 @@ object PreferenceKeys {
const val landscapeColumns = "pref_library_columns_landscape_key"
const val jumpToChapters = "jump_to_chapters"
const val updateOnlyNonCompleted = "pref_update_only_non_completed_key"
const val autoUpdateTrack = "pref_auto_update_manga_sync_key"
@@ -243,8 +245,6 @@ object PreferenceKeys {
const val eh_is_hentai_enabled = "eh_is_hentai_enabled"
const val eh_use_new_manga_interface = "eh_use_new_manga_interface"
const val eh_use_auto_webtoon = "eh_use_auto_webtoon"
const val eh_watched_list_default_state = "eh_watched_list_default_state"
@@ -131,6 +131,8 @@ class PreferencesHelper(val context: Context) {
fun landscapeColumns() = flowPrefs.getInt(Keys.landscapeColumns, 0)
fun jumpToChapters() = prefs.getBoolean(Keys.jumpToChapters, false)
fun updateOnlyNonCompleted() = prefs.getBoolean(Keys.updateOnlyNonCompleted, false)
fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true)
@@ -347,8 +349,6 @@ class PreferencesHelper(val context: Context) {
fun eh_preload_size() = flowPrefs.getInt(Keys.eh_preload_size, 4)
fun eh_useNewMangaInterface() = flowPrefs.getBoolean(Keys.eh_use_new_manga_interface, true)
fun eh_useAutoWebtoon() = flowPrefs.getBoolean(Keys.eh_use_auto_webtoon, true)
fun eh_watchedListDefaultState() = flowPrefs.getBoolean(Keys.eh_watched_list_default_state, false)
@@ -136,6 +136,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
return Observable.just(MangasPage(mangas, false))
}
// SY -->
fun updateMangaInfo(manga: SManga) {
val directory = getBaseDirectories(context).mapNotNull { File(it, manga.url) }.find {
it.exists()
@@ -173,6 +174,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
return title.hashCode()
}
}
// SY <--
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
@@ -29,7 +29,9 @@ sealed class Filter<T>(val name: String, var state: T) {
data class Selection(val index: Int, val ascending: Boolean)
}
// SY -->
abstract class AutoComplete(name: String, val hint: String, val values: List<String>, val skipAutoFillTags: List<String> = emptyList(), val excludePrefix: String? = null, state: List<String>) : Filter<List<String>>(name, state)
// SY <--
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -23,6 +23,7 @@ interface SManga : Serializable {
var initialized: Boolean
// SY -->
val originalTitle: String
get() = (this as? MangaImpl)?.ogTitle ?: title
val originalAuthor: String?
@@ -33,6 +34,7 @@ interface SManga : Serializable {
get() = (this as? MangaImpl)?.ogDesc ?: description
val originalGenre: String?
get() = (this as? MangaImpl)?.ogGenre ?: genre
// SY <--
fun copyFrom(other: SManga) {
// EXH -->
@@ -42,19 +44,19 @@ interface SManga : Serializable {
// EXH <--
if (other.author != null) {
author = other.originalAuthor
author = /* SY --> */ other.originalAuthor /* SY <-- */
}
if (other.artist != null) {
artist = other.originalArtist
artist = /* SY --> */ other.originalArtist /* SY <-- */
}
if (other.description != null) {
description = other.originalDescription
description = /* SY --> */ other.originalDescription /* SY <-- */
}
if (other.genre != null) {
genre = other.originalGenre
genre = /* SY --> */ other.originalGenre /* SY <-- */
}
if (other.thumbnail_url != null) {
@@ -48,7 +48,9 @@ class ExtensionHolder(view: View, override val adapter: ExtensionAdapter) :
extension is Extension.Untrusted -> itemView.context.getString(R.string.ext_untrusted).toUpperCase()
extension is Extension.Installed && extension.isObsolete -> itemView.context.getString(R.string.ext_obsolete).toUpperCase()
extension is Extension.Installed && extension.isUnofficial -> itemView.context.getString(R.string.ext_unofficial).toUpperCase()
// SY -->
extension is Extension.Installed && extension.isRedundant -> itemView.context.getString(R.string.ext_redundant).toUpperCase()
// SY <--
else -> null
}
@@ -91,12 +93,14 @@ class ExtensionHolder(view: View, override val adapter: ExtensionAdapter) :
setText(R.string.ext_update)
}
else -> {
// SY -->
if (extension.sources.any { it is ConfigurableSource }) {
@SuppressLint("SetTextI18n")
text = context.getString(R.string.action_settings) + "+"
} else {
setText(R.string.action_settings)
}
// SY <--
}
}
} else if (extension is Extension.Untrusted) {
@@ -2,7 +2,10 @@ package eu.kanade.tachiyomi.ui.browse.extension.details
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.Menu
@@ -180,6 +183,7 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
when (item.itemId) {
R.id.action_enable_all -> toggleAllSources(true)
R.id.action_disable_all -> toggleAllSources(false)
R.id.action_open_in_settings -> openInSettings()
}
return super.onOptionsItemSelected(item)
}
@@ -204,6 +208,13 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
)
}
private fun openInSettings() {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", presenter.pkgName, null)
}
startActivity(intent)
}
private fun Source.isEnabled(): Boolean {
return id.toString() !in preferences.disabledSources().get()
}
@@ -17,7 +17,6 @@ import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.LatestAdapter
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.LatestPresenter
import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController
import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController
import eu.kanade.tachiyomi.ui.manga.MangaController
import kotlinx.coroutines.flow.launchIn
@@ -72,11 +71,7 @@ open class LatestController :
*/
override fun onMangaClick(manga: Manga) {
// Open MangaController.
if (presenter.preferences.eh_useNewMangaInterface().get()) {
parentController?.router?.pushController(MangaAllInOneController(manga, true).withFadeTransaction())
} else {
parentController?.router?.pushController(MangaController(manga, true).withFadeTransaction())
}
parentController?.router?.pushController(MangaController(manga, true).withFadeTransaction())
}
/**
@@ -29,7 +29,6 @@ import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.migration.MigrationMangaDialog
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationController
import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.lang.launchUI
@@ -427,7 +426,7 @@ class MigrationListController(bundle: Bundle? = null) :
private fun navigateOut() {
if (migratingManga?.size == 1) {
launchUI {
val hasDetails = router.backstack.any { it.controller() is MangaController } || router.backstack.any { it.controller() is MangaAllInOneController }
val hasDetails = router.backstack.any { it.controller() is MangaController }
if (hasDetails) {
val manga = migratingManga?.firstOrNull()?.searchResult?.get()?.let {
db.getManga(it).executeOnIO()
@@ -435,10 +434,9 @@ class MigrationListController(bundle: Bundle? = null) :
if (manga != null) {
val newStack = router.backstack.filter {
it.controller() !is MangaController &&
it.controller() !is MangaAllInOneController &&
it.controller() !is MigrationListController &&
it.controller() !is PreMigrationController
} + if (preferences.eh_useNewMangaInterface().get()) MangaAllInOneController(manga).withFadeTransaction() else MangaController(manga).withFadeTransaction()
} + MangaController(manga).withFadeTransaction()
router.setBackstack(newStack, FadeChangeHandler())
return@launchUI
}
@@ -9,13 +9,11 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.toMangaThumbnail
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.all.MergedSource
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.system.getResourceColor
@@ -38,7 +36,6 @@ import kotlinx.android.synthetic.main.migration_process_item.migration_menu
import kotlinx.android.synthetic.main.migration_process_item.skip_manga
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
@@ -85,21 +82,12 @@ class MigrationProcessHolder(
withContext(Dispatchers.Main) {
migration_manga_card_from.attachManga(manga, source)
migration_manga_card_from.setOnClickListener {
if (Injekt.get<PreferencesHelper>().eh_useNewMangaInterface().get()) {
adapter.controller.router.pushController(
MangaAllInOneController(
manga,
true
).withFadeTransaction()
)
} else {
adapter.controller.router.pushController(
MangaController(
manga,
true
).withFadeTransaction()
)
}
adapter.controller.router.pushController(
MangaController(
manga,
true
).withFadeTransaction()
)
}
}
@@ -54,7 +54,7 @@ class SourceController(bundle: Bundle? = null) :
FlexibleAdapter.OnItemLongClickListener,
SourceAdapter.OnBrowseClickListener,
SourceAdapter.OnLatestClickListener,
ChangeSourceCategoriesDialog.Listener {
/*SY -->*/ ChangeSourceCategoriesDialog.Listener /*SY <--*/ {
private val preferences: PreferencesHelper = Injekt.get()
@@ -42,7 +42,6 @@ import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesControll
import eu.kanade.tachiyomi.ui.browse.source.SourceController
import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterSheet.FilterNavigationView.Companion.MAX_SAVED_SEARCHES
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.connectivityManager
@@ -304,7 +303,6 @@ open class BrowseSourceController(bundle: Bundle) :
}
// EXH <--
)
filterSheet?.setFilters(presenter.filterItems)
// TODO: [ExtendedFloatingActionButton] hide/show methods don't work properly
@@ -713,23 +711,13 @@ open class BrowseSourceController(bundle: Bundle) :
// SY -->
when (mode) {
Mode.CATALOGUE -> {
if (preferences.eh_useNewMangaInterface().get()) {
router.pushController(
MangaAllInOneController(
item.manga,
true,
args.getParcelable(SMART_SEARCH_CONFIG_KEY)
).withFadeTransaction()
)
} else {
router.pushController(
MangaController(
item.manga,
true,
args.getParcelable(SMART_SEARCH_CONFIG_KEY)
).withFadeTransaction()
)
}
router.pushController(
MangaController(
item.manga,
true,
args.getParcelable(MangaController.SMART_SEARCH_CONFIG_EXTRA)
).withFadeTransaction()
)
}
Mode.RECOMMENDS -> openSmartSearch(item.manga.originalTitle)
}
@@ -35,14 +35,16 @@ class SourceListHolder(private val view: View, adapter: FlexibleAdapter<*>) :
* @param manga the manga to bind.
*/
override fun onSetValues(manga: Manga) {
title.text = manga.title
title.setTextColor(if (manga.favorite) favoriteColor else unfavoriteColor)
// Set alpha of thumbnail.
thumbnail.alpha = if (manga.favorite) 0.3f else 1.0f
setImage(manga)
}
override fun setImage(manga: Manga) {
title.text = manga.title
GlideApp.with(view.context).clear(thumbnail)
if (!manga.thumbnail_url.isNullOrEmpty()) {
@@ -16,7 +16,6 @@ import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController
import eu.kanade.tachiyomi.ui.manga.MangaController
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
@@ -76,14 +75,7 @@ open class GlobalSearchController(
* @param manga clicked item containing manga information.
*/
override fun onMangaClick(manga: Manga) {
// Open MangaController.
// SY -->
if (presenter.preferences.eh_useNewMangaInterface().get()) {
router.pushController(MangaAllInOneController(manga, true).withFadeTransaction())
} else {
router.pushController(MangaController(manga, true).withFadeTransaction())
}
// SY <--
router.pushController(MangaController(manga, true).withFadeTransaction())
}
/**
@@ -38,7 +38,6 @@ import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrationSourcesControlle
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.main.offsetAppbarHeight
import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.toast
@@ -572,13 +571,7 @@ class LibraryController(
// Notify the presenter a manga is being opened.
presenter.onOpenManga()
// SY -->
if (preferences.eh_useNewMangaInterface().get()) {
router.pushController(MangaAllInOneController(manga).withFadeTransaction())
} else {
router.pushController(MangaController(manga).withFadeTransaction())
}
// SY <--
router.pushController(MangaController(manga).withFadeTransaction())
}
/**
@@ -36,7 +36,6 @@ import eu.kanade.tachiyomi.ui.browse.BrowseController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.download.DownloadController
import eu.kanade.tachiyomi.ui.library.LibraryController
import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.more.MoreController
import eu.kanade.tachiyomi.ui.recent.history.HistoryController
@@ -309,13 +308,7 @@ class MainActivity : BaseActivity<MainActivityBinding>() {
router.popToRoot()
}
setSelectedNavItem(R.id.nav_library)
// SY -->
if (preferences.eh_useNewMangaInterface().get()) {
router.pushController(RouterTransaction.with(MangaAllInOneController(extras)))
} else {
router.pushController(RouterTransaction.with(MangaController(extras)))
}
// SY <--
router.pushController(RouterTransaction.with(MangaController(extras)))
}
SHORTCUT_DOWNLOADS -> {
if (router.backstackSize > 1) {
@@ -48,9 +48,9 @@ class EditMangaDialog : DialogController {
private var willResetCover = false
private val infoController
get() = targetController as MangaAllInOneController
get() = targetController as MangaController
constructor(target: MangaAllInOneController, manga: Manga) : super(
constructor(target: MangaController, manga: Manga) : super(
Bundle()
.apply {
putLong(KEY_MANGA, manga.id!!)
@@ -1,82 +0,0 @@
package eu.kanade.tachiyomi.ui.manga
import android.content.Context
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.manga.chapter.MangaAllInOneChapterItem
import eu.kanade.tachiyomi.util.system.getResourceColor
import java.text.DateFormat
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Date
import kotlinx.coroutines.CoroutineScope
import uy.kohesive.injekt.injectLazy
class MangaAllInOneAdapter(
controller: MangaAllInOneController,
context: Context
) : FlexibleAdapter<IFlexible<*>>(null, controller, true) {
val delegate: MangaAllInOneInterface = controller
val preferences: PreferencesHelper by injectLazy()
var items: List<MangaAllInOneChapterItem> = emptyList()
val readColor = context.getResourceColor(R.attr.colorOnSurface, 0.38f)
val unreadColor = context.getResourceColor(R.attr.colorOnSurface)
val bookmarkedColor = context.getResourceColor(R.attr.colorAccent)
val decimalFormat = DecimalFormat(
"#.###",
DecimalFormatSymbols()
.apply { decimalSeparator = '.' }
)
val dateFormat: DateFormat = preferences.dateFormat()
override fun updateDataSet(items: List<IFlexible<*>>?) {
this.items = items as List<MangaAllInOneChapterItem>? ?: emptyList()
super.updateDataSet(items)
}
fun indexOf(item: MangaAllInOneChapterItem): Int {
return items.indexOf(item)
}
interface MangaAllInOneInterface : MangaHeaderInterface
interface MangaHeaderInterface {
fun openSmartSearch()
fun mangaPresenter(): MangaAllInOnePresenter
fun openRecommends()
fun onNextManga(manga: Manga, source: Source, chapters: List<MangaAllInOneChapterItem>, lastUpdateDate: Date, chapterCount: Float)
fun setMangaInfo(manga: Manga, source: Source?, chapters: List<MangaAllInOneChapterItem>, lastUpdateDate: Date, chapterCount: Float)
fun openInWebView()
fun shareManga()
fun fetchMangaFromSource(manualFetch: Boolean = false, fetchManga: Boolean = true, fetchChapters: Boolean = true)
fun onFetchMangaDone()
fun onFetchMangaError(error: Throwable)
fun setRefreshing(value: Boolean)
fun onFavoriteClick()
fun onCategoriesClick()
fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>)
fun performGlobalSearch(query: String)
fun wrapTag(namespace: String, tag: String): String
fun isEHentaiBasedSource(): Boolean
fun performSearch(query: String)
fun openTracking()
suspend fun mergeWithAnother()
fun copyToClipboard(label: String, text: String)
fun migrateManga()
fun isInitialLoadAndFromSource(): Boolean
fun removeInitialLoad()
val controllerScope: CoroutineScope
}
}
File diff suppressed because it is too large Load Diff
@@ -1,48 +0,0 @@
package eu.kanade.tachiyomi.ui.manga
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.browse.source.SourceController
class MangaAllInOneHeaderItem(val manga: Manga, val source: Source, var smartSearchConfig: SourceController.SmartSearchConfig? = null) :
AbstractFlexibleItem<MangaAllInOneHolder>() {
override fun getLayoutRes(): Int {
return R.layout.manga_all_in_one_header
}
override fun isSelectable(): Boolean {
return false
}
override fun isSwipeable(): Boolean {
return false
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): MangaAllInOneHolder {
return MangaAllInOneHolder(view, adapter as MangaAllInOneAdapter, smartSearchConfig)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: MangaAllInOneHolder,
position: Int,
payloads: MutableList<Any?>?
) {
holder.bind(manga, source)
}
override fun equals(other: Any?): Boolean {
return (this === other)
}
override fun hashCode(): Int {
return -(manga.id).hashCode()
}
}
@@ -1,413 +0,0 @@
package eu.kanade.tachiyomi.ui.manga
import android.content.Context
import android.view.View
import androidx.core.content.ContextCompat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.google.gson.Gson
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.toMangaThumbnail
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.databinding.MangaAllInOneHeaderBinding
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import eu.kanade.tachiyomi.ui.browse.source.SourceController
import eu.kanade.tachiyomi.util.view.gone
import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.util.view.visible
import eu.kanade.tachiyomi.util.view.visibleIf
import exh.MERGED_SOURCE_ID
import exh.util.setChipsExtended
import java.text.DateFormat
import java.text.DecimalFormat
import java.util.Date
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
import reactivecircus.flowbinding.android.view.longClicks
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
class MangaAllInOneHolder(
view: View,
private val adapter: MangaAllInOneAdapter,
smartSearchConfig: SourceController.SmartSearchConfig? = null
) : BaseFlexibleViewHolder(view, adapter) {
private val preferences: PreferencesHelper by injectLazy()
private val gson: Gson by injectLazy()
private val dateFormat: DateFormat by lazy {
preferences.dateFormat()
}
private val sourceManager: SourceManager by injectLazy()
var binding: MangaAllInOneHeaderBinding
init {
val presenter = adapter.delegate.mangaPresenter()
binding = MangaAllInOneHeaderBinding.bind(itemView)
// Setting this via XML doesn't work
binding.mangaCover.clipToOutline = true
binding.btnFavorite.clicks()
.onEach { adapter.delegate.onFavoriteClick() }
.launchIn(adapter.delegate.controllerScope)
if ((Injekt.get<TrackManager>().hasLoggedServices())) {
binding.btnTracking.visible()
}
setTrackingIcon(
Injekt.get<DatabaseHelper>().getTracks(presenter.manga).executeAsBlocking().any {
val status = Injekt.get<TrackManager>().getService(it.sync_id)?.getStatus(it.status)
status != null
}
)
binding.btnTracking.clicks()
.onEach { adapter.delegate.openTracking() }
.launchIn(adapter.delegate.controllerScope)
if (presenter.manga.favorite && presenter.getCategories().isNotEmpty()) {
binding.btnCategories.visible()
}
binding.btnCategories.clicks()
.onEach { adapter.delegate.onCategoriesClick() }
.launchIn(adapter.delegate.controllerScope)
if (presenter.source is HttpSource) {
binding.btnWebview.visible()
binding.btnShare.visible()
binding.btnWebview.clicks()
.onEach { adapter.delegate.openInWebView() }
.launchIn(adapter.delegate.controllerScope)
binding.btnShare.clicks()
.onEach { adapter.delegate.shareManga() }
.launchIn(adapter.delegate.controllerScope)
}
if (presenter.manga.favorite) {
binding.btnMigrate.visible()
binding.btnSmartSearch.visible()
}
binding.btnMigrate.clicks()
.onEach {
adapter.delegate.migrateManga()
}
.launchIn(adapter.delegate.controllerScope)
binding.btnSmartSearch.clicks()
.onEach { adapter.delegate.openSmartSearch() }
.launchIn(adapter.delegate.controllerScope)
binding.mangaFullTitle.longClicks()
.onEach {
adapter.delegate.copyToClipboard(view.context.getString(R.string.title), binding.mangaFullTitle.text.toString())
}
.launchIn(adapter.delegate.controllerScope)
binding.mangaFullTitle.clicks()
.onEach {
adapter.delegate.performGlobalSearch(binding.mangaFullTitle.text.toString())
}
.launchIn(adapter.delegate.controllerScope)
binding.mangaAuthor.longClicks()
.onEach {
// EXH Special case E-Hentai/ExHentai to ignore author field (unused)
if (!adapter.delegate.isEHentaiBasedSource()) {
adapter.delegate.copyToClipboard("author", binding.mangaAuthor.text.toString())
}
}
.launchIn(adapter.delegate.controllerScope)
binding.mangaAuthor.clicks()
.onEach {
// EXH Special case E-Hentai/ExHentai to ignore author field (unused)
if (!adapter.delegate.isEHentaiBasedSource()) {
adapter.delegate.performGlobalSearch(binding.mangaAuthor.text.toString())
}
}
.launchIn(adapter.delegate.controllerScope)
binding.mangaSummary.longClicks()
.onEach {
adapter.delegate.copyToClipboard(view.context.getString(R.string.description), binding.mangaSummary.text.toString())
}
.launchIn(adapter.delegate.controllerScope)
binding.mangaCover.longClicks()
.onEach {
adapter.delegate.copyToClipboard(view.context.getString(R.string.title), presenter.manga.title)
}
.launchIn(adapter.delegate.controllerScope)
// EXH -->
if (smartSearchConfig == null) {
binding.recommendBtn.visible()
binding.recommendBtn.clicks()
.onEach { adapter.delegate.openRecommends() }
.launchIn(adapter.delegate.controllerScope)
}
smartSearchConfig?.let {
if (it.origMangaId != null) { binding.mergeBtn.visible() }
binding.mergeBtn.clicks()
.onEach {
adapter.delegate.mergeWithAnother()
}
.launchIn(adapter.delegate.controllerScope)
}
// EXH <--
}
fun bind(manga: Manga, source: Source?) {
binding.mangaFullTitle.text = if (manga.title.isBlank()) {
itemView.context.getString(R.string.unknown)
} else {
manga.title
}
// Update author/artist TextView.
val authors: MutableSet<String> = mutableSetOf()
val author = manga.author
val artist = manga.artist
val splitRegex = "([,\\-])".toRegex()
if (author != null) {
authors += author.split(splitRegex).map { it.trim() }.filter { !it.isBlank() }.toMutableSet()
}
if (artist != null) {
authors += artist.split(splitRegex).map { it.trim() }.filter { !it.isBlank() }.toMutableSet()
}
binding.mangaAuthor.text = if (authors.isEmpty()) {
itemView.context.getString(R.string.unknown)
} else {
authors.joinToString(", ")
}
// If manga source is known update source TextView.
val mangaSource = source?.toString()
with(binding.mangaSource) {
// EXH -->
when {
mangaSource == null -> {
text = itemView.context.getString(R.string.unknown)
}
source.id == MERGED_SOURCE_ID -> {
text = eu.kanade.tachiyomi.source.online.all.MergedSource.MangaConfig.readFromUrl(gson, manga.url).children.map {
sourceManager.getOrStub(it.source).toString()
}.distinct().joinToString()
}
else -> {
text = mangaSource
setOnClickListener {
val sourceManager = Injekt.get<SourceManager>()
adapter.delegate.performSearch(sourceManager.getOrStub(source.id).name)
}
}
}
// EXH <--
}
// EXH -->
if (source?.id == MERGED_SOURCE_ID) {
binding.mangaSourceLabel.setText(R.string.label_sources)
} else {
binding.mangaSourceLabel.setText(R.string.manga_info_source_label)
}
// EXH <--
// Update status TextView.
binding.mangaStatus.setText(
when (manga.status) {
SManga.ONGOING -> R.string.ongoing
SManga.COMPLETED -> R.string.completed
SManga.LICENSED -> R.string.licensed
else -> R.string.unknown
}
)
setChapterCount(0F)
setLastUpdateDate(Date(0L))
// Set the favorite drawable to the correct one.
setFavoriteButtonState(manga.favorite)
// Set cover if it wasn't already.
val mangaThumbnail = manga.toMangaThumbnail()
GlideApp.with(itemView.context)
.load(mangaThumbnail)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.into(binding.mangaCover)
GlideApp.with(itemView.context)
.load(mangaThumbnail)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.into(binding.backdrop)
// Manga info section
if (manga.description.isNullOrBlank() && manga.genre.isNullOrBlank()) {
hideMangaInfo()
} else {
// Update description TextView.
binding.mangaSummary.text = if (manga.description.isNullOrBlank()) {
itemView.context.getString(R.string.unknown)
} else {
manga.description
}
// Update genres list
if (!manga.genre.isNullOrBlank()) {
binding.mangaGenresTagsCompactChips.setChipsExtended(manga.getGenres(), this::performSearch, this::performGlobalSearch, manga.source)
binding.mangaGenresTagsFullChips.setChipsExtended(manga.getGenres(), this::performSearch, this::performGlobalSearch, manga.source)
} else {
binding.mangaGenresTagsWrapper.gone()
}
// Handle showing more or less info
binding.mangaSummary.clicks()
.onEach { toggleMangaInfo(itemView.context) }
.launchIn(adapter.delegate.controllerScope)
binding.mangaInfoToggle.clicks()
.onEach { toggleMangaInfo(itemView.context) }
.launchIn(adapter.delegate.controllerScope)
// Expand manga info if navigated from source listing
if (adapter.delegate.isInitialLoadAndFromSource()) {
adapter.delegate.removeInitialLoad()
toggleMangaInfo(itemView.context)
}
}
}
private fun hideMangaInfo() {
binding.mangaSummaryLabel.gone()
binding.mangaSummary.gone()
binding.mangaGenresTagsWrapper.gone()
binding.mangaInfoToggle.gone()
}
private fun toggleMangaInfo(context: Context) {
val isExpanded = binding.mangaInfoToggle.text == context.getString(R.string.manga_info_collapse)
binding.mangaInfoToggle.text =
if (isExpanded) {
context.getString(R.string.manga_info_expand)
} else {
context.getString(R.string.manga_info_collapse)
}
with(binding.mangaSummary) {
maxLines =
if (isExpanded) {
3
} else {
Int.MAX_VALUE
}
ellipsize =
if (isExpanded) {
android.text.TextUtils.TruncateAt.END
} else {
null
}
}
binding.mangaGenresTagsCompact.visibleIf { isExpanded }
binding.mangaGenresTagsFullChips.visibleIf { !isExpanded }
}
/**
* Update chapter count TextView.
*
* @param count number of chapters.
*/
fun setChapterCount(count: Float) {
if (count > 0f) {
binding.mangaChapters.text = DecimalFormat("#.#").format(count)
} else {
binding.mangaChapters.text = itemView.context.getString(R.string.unknown)
}
}
fun setLastUpdateDate(date: Date) {
if (date.time != 0L) {
binding.mangaLastUpdate.text = dateFormat.format(date)
} else {
binding.mangaLastUpdate.text = itemView.context.getString(R.string.unknown)
}
}
/**
* Toggles the favorite status and asks for confirmation to delete downloaded chapters.
*/
fun toggleFavorite() {
val presenter = adapter.delegate.mangaPresenter()
val isNowFavorite = presenter.toggleFavorite()
if (!isNowFavorite && presenter.hasDownloads()) {
itemView.snack(itemView.context.getString(R.string.delete_downloads_for_manga)) {
setAction(R.string.action_delete) {
presenter.deleteDownloads()
}
}
}
binding.btnCategories.visibleIf { isNowFavorite && presenter.getCategories().isNotEmpty() }
if (isNowFavorite) {
binding.btnSmartSearch.visible()
binding.btnMigrate.visible()
} else {
binding.btnSmartSearch.gone()
binding.btnMigrate.gone()
}
}
/**
* Update favorite button with correct drawable and text.
*
* @param isFavorite determines if manga is favorite or not.
*/
fun setFavoriteButtonState(isFavorite: Boolean) {
// Set the Favorite drawable to the correct one.
// Border drawable if false, filled drawable if true.
binding.btnFavorite.apply {
icon = ContextCompat.getDrawable(context, if (isFavorite) R.drawable.ic_favorite_24dp else R.drawable.ic_favorite_border_24dp)
text = context.getString(if (isFavorite) R.string.in_library else R.string.add_to_library)
isChecked = isFavorite
}
}
private fun performSearch(query: String) {
adapter.delegate.performSearch(query)
}
private fun performGlobalSearch(query: String) {
adapter.delegate.performGlobalSearch(query)
}
fun setTrackingIcon(tracked: Boolean) {
if (tracked) {
binding.btnTracking.setIconResource(R.drawable.ic_cloud_24dp)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,11 @@
package eu.kanade.tachiyomi.ui.manga
import android.content.Context
import android.net.Uri
import android.os.Bundle
import com.google.gson.Gson
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
@@ -16,19 +18,17 @@ import eu.kanade.tachiyomi.data.library.CustomMangaManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.online.all.MergedSource
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.browse.source.SourceController
import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersPresenter
import eu.kanade.tachiyomi.ui.manga.chapter.MangaAllInOneChapterItem
import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.isLocal
import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.prepUpdateCover
import eu.kanade.tachiyomi.util.removeCovers
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
import eu.kanade.tachiyomi.util.updateCoverLastModified
import exh.EH_SOURCE_ID
import exh.EXH_SOURCE_ID
import exh.MERGED_SOURCE_ID
import exh.debug.DebugToggles
import exh.eh.EHentaiUpdateHelper
@@ -36,11 +36,7 @@ import exh.isEhBasedSource
import exh.util.await
import exh.util.trimOrNull
import java.util.Date
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import rx.Observable
import rx.Subscription
@@ -51,36 +47,35 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
/**
* Presenter of MangaInfoFragment.
* Contains information and data for fragment.
* Observable updates should be called from here.
*/
class MangaAllInOnePresenter(
val controller: MangaAllInOneController,
class MangaPresenter(
val manga: Manga,
val source: Source,
val smartSearchConfig: SourceController.SmartSearchConfig?,
val preferences: PreferencesHelper = Injekt.get(),
private val db: DatabaseHelper = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(),
private val gson: Gson = Injekt.get(),
val preferences: PreferencesHelper = Injekt.get()
) : BasePresenter<MangaAllInOneController>() {
// SY -->
private val gson: Gson = Injekt.get()
// SY <--
) : BasePresenter<MangaController>() {
/**
* Subscription to update the manga from the source.
*/
private var fetchMangaSubscription: Subscription? = null
/**
* List of chapters of the manga. It's always unfiltered and unsorted.
*/
var chapters: List<MangaAllInOneChapterItem> = emptyList()
var chapters: List<ChapterItem> = emptyList()
private set
private var lastUpdateDate: Date = Date(0L)
private var chapterCount: Float = 0F
private val scope = CoroutineScope(Job() + Dispatchers.Default)
private val customMangaManager: CustomMangaManager by injectLazy()
/**
* Subject of list of chapters to allow updating the view without going to DB.
*/
private val chaptersRelay: PublishRelay<List<ChapterItem>> by lazy {
PublishRelay.create<List<ChapterItem>>()
}
/**
* Whether the chapter list has been requested to the source.
@@ -88,120 +83,124 @@ class MangaAllInOnePresenter(
var hasRequested = false
private set
/**
* Subscription to retrieve the new list of chapters from the source.
*/
private var fetchChaptersSubscription: Subscription? = null
/**
* Subscription to observe download status changes.
*/
private var observeDownloadsSubscription: Subscription? = null
// EXH -->
private val customMangaManager: CustomMangaManager by injectLazy()
private val updateHelper: EHentaiUpdateHelper by injectLazy()
private val redirectUserRelay = BehaviorRelay.create<ChaptersPresenter.EXHRedirect>()
// EXH <--
private val redirectUserRelay = BehaviorRelay.create<EXHRedirect>()
var headerItem = MangaAllInOneHeaderItem(manga, source, smartSearchConfig)
data class EXHRedirect(val manga: Manga, val update: Boolean)
// EXH <--
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
updateManga()
// Manga info - start
// Listen for download status changes
observeDownloads()
getMangaObservable()
.subscribeLatestCache({ view, manga -> view.onNextMangaInfo(manga, source) })
// Prepare the relay.
chaptersRelay.flatMap { applyChapterFilters(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(MangaController::onNextChapters) { _, error -> Timber.e(error) }
// Manga info - end
// Chapters list - start
// Add the subscription that retrieves the chapters from the database, keeps subscribed to
// changes, and sends the list of chapters to the relay.
add(
db.getChapters(manga).asRxObservable().subscribe {
scope.launch(Dispatchers.IO) {
updateChaptersView(updateInfo = true)
db.getChapters(manga).asRxObservable()
.map { chapters ->
// Convert every chapter to a model.
chapters.map { it.toModel() }
}
}
)
}
.doOnNext { chapters ->
// Find downloaded chapters
setDownloadedChapters(chapters)
private suspend fun updateChapters() {
val chapters = db.getChapters(manga).await().map { it.toModel() }
// Store the last emission
this.chapters = chapters
// Find downloaded chapters
setDownloadedChapters(chapters)
// Listen for download status changes
observeDownloads()
// EXH -->
if (chapters.isNotEmpty() && (source.isEhBasedSource()) && DebugToggles.ENABLE_EXH_ROOT_REDIRECT.enabled) {
// Check for gallery in library and accept manga with lowest id
// Find chapters sharing same root
add(
updateHelper.findAcceptedRootAndDiscardOthers(manga.source, chapters)
.subscribeOn(Schedulers.io())
.subscribe { (acceptedChain, _) ->
// Redirect if we are not the accepted root
if (manga.id != acceptedChain.manga.id) {
// Update if any of our chapters are not in accepted manga's chapters
val ourChapterUrls = chapters.map { it.url }.toSet()
val acceptedChapterUrls = acceptedChain.chapters.map { it.url }.toSet()
val update = (ourChapterUrls - acceptedChapterUrls).isNotEmpty()
redirectUserRelay.call(
ChaptersPresenter.EXHRedirect(
acceptedChain.manga,
update
)
)
}
// SY -->
if (chapters.isNotEmpty() && (source.isEhBasedSource()) && DebugToggles.ENABLE_EXH_ROOT_REDIRECT.enabled) {
// Check for gallery in library and accept manga with lowest id
// Find chapters sharing same root
add(
updateHelper.findAcceptedRootAndDiscardOthers(manga.source, chapters)
.subscribeOn(Schedulers.io())
.subscribe { (acceptedChain, _) ->
// Redirect if we are not the accepted root
if (manga.id != acceptedChain.manga.id) {
// Update if any of our chapters are not in accepted manga's chapters
val ourChapterUrls = chapters.map { it.url }.toSet()
val acceptedChapterUrls = acceptedChain.chapters.map { it.url }.toSet()
val update = (ourChapterUrls - acceptedChapterUrls).isNotEmpty()
redirectUserRelay.call(
EXHRedirect(
acceptedChain.manga,
update
)
)
}
}
)
}
// SY <--
}
.subscribe { chaptersRelay.call(it) }
)
// Chapters list - end
}
// Manga info - start
private fun getMangaObservable(): Observable<Manga> {
return db.getManga(manga.url, manga.source).asRxObservable()
.observeOn(AndroidSchedulers.mainThread())
}
/**
* Fetch manga information from source.
*/
fun fetchMangaFromSource(manualFetch: Boolean = false) {
if (!fetchMangaSubscription.isNullOrUnsubscribed()) return
fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) }
.map { networkManga ->
manga.prepUpdateCover(coverCache, networkManga, manualFetch)
manga.copyFrom(networkManga)
manga.initialized = true
db.insertManga(manga).executeAsBlocking()
manga
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ ->
view.onFetchMangaInfoDone()
},
MangaController::onFetchMangaInfoError
)
}
// EXH <--
this.chapters = chapters
}
private fun getUpdatedChapters(): List<MangaAllInOneChapterItem> = applyChapterFilters(chapters)
private fun updateChaptersView(updateInfo: Boolean = false) {
scope.launch(Dispatchers.IO) {
updateChapters()
val chapterList = getUpdatedChapters()
if (updateInfo) {
updateChapterInfo()
}
withContext(Dispatchers.Main) {
Observable.just(manga)
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache({ view, manga -> view.onNextManga(manga, source, chapterList, lastUpdateDate, chapterCount) })
}
}
}
private fun updateChapterInfo() {
scope.launch(Dispatchers.IO) {
lastUpdateDate = Date(
chapters.maxBy { it.date_upload }?.date_upload ?: 0
)
chapterCount = chapters.maxBy { it.chapter_number }?.chapter_number ?: 0f
}
}
private fun updateManga(updateInfo: Boolean = true) {
scope.launch(Dispatchers.IO) {
var manga2: Manga? = null
var chapterList = getUpdatedChapters()
if (updateInfo) {
manga2 = db.getManga(manga.url, manga.source).await()
updateChapters()
updateChapterInfo()
chapterList = getUpdatedChapters()
}
withContext(Dispatchers.Main) {
if (manga2 != null) {
Observable.just(manga2)
} else {
Observable.just(manga)
}.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache({ view, manga -> view.onNextManga(manga, source, chapterList, lastUpdateDate, chapterCount) })
}
}
}
// SY -->
fun updateMangaInfo(
title: String?,
author: String?,
@@ -240,14 +239,28 @@ class MangaAllInOnePresenter(
if (uri != null) {
editCoverWithStream(uri)
} else if (resetCover) {
controller.setRefreshing(true)
coverCache.deleteCustomCover(manga)
}
if (uri == null && resetCover) {
fetchMangaFromSource(manualFetch = true, fetchChapters = false)
Observable.just(manga)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(
{ view, _ ->
view.setRefreshing()
}
)
fetchMangaFromSource(manualFetch = true)
} else {
updateManga(updateInfo = false)
Observable.just(manga)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(
{ view, _ ->
view.onNextMangaInfo(manga, source)
}
)
}
}
@@ -268,136 +281,6 @@ class MangaAllInOnePresenter(
return false
}
/**
* Fetch manga information from source.
*/
fun fetchMangaFromSource(manualFetch: Boolean = false, fetchManga: Boolean = true, fetchChapters: Boolean = true) {
if (fetchChapters) {
hasRequested = true
}
scope.launch(Dispatchers.IO) {
if (fetchManga) {
val networkManga = try {
source.fetchMangaDetails(manga).toBlocking().single()
} catch (e: Exception) {
controller.onFetchMangaError(e)
return@launch
}
if (networkManga != null) {
manga.prepUpdateCover(coverCache, networkManga, manualFetch)
manga.copyFrom(networkManga)
manga.initialized = true
db.insertManga(manga).await()
}
}
var chapters: List<SChapter> = listOf()
if (fetchChapters) {
try {
chapters = source.fetchChapterList(manga).toBlocking().single()
} catch (e: Exception) {
controller.onFetchMangaError(e)
return@launch
}
}
try {
if (fetchChapters) {
val chapterLists = syncChaptersWithSource(db, chapters, manga, source)
if (manualFetch) {
downloadNewChapters(chapterLists.first)
}
updateChapters()
updateChapterInfo()
}
withContext(Dispatchers.Main) {
updateManga(updateInfo = false)
controller.onFetchMangaDone()
}
} catch (e: Exception) {
controller.onFetchMangaError(e)
}
}
}
/**
* Update favorite status of manga, (removes / adds) manga (to / from) library.
*
* @return the new status of the manga.
*/
fun toggleFavorite(): Boolean {
manga.favorite = !manga.favorite
controller.setFavoriteButtonState(manga.favorite)
if (!manga.favorite) {
manga.removeCovers(coverCache)
}
db.insertManga(manga).executeAsBlocking()
return manga.favorite
}
private fun setFavorite(favorite: Boolean) {
if (manga.favorite == favorite) {
return
}
toggleFavorite()
}
/**
* Returns true if the manga has any downloads.
*/
fun hasDownloads(): Boolean {
return downloadManager.getDownloadCount(manga) > 0
}
/**
* Deletes all the downloads for the manga.
*/
fun deleteDownloads() {
downloadManager.deleteManga(manga, source)
}
/**
* Get user categories.
*
* @return List of categories, not including the default category
*/
fun getCategories(): List<Category> {
return db.getCategories().executeAsBlocking()
}
/**
* Gets the category id's the manga is in, if the manga is not in a category, returns the default id.
*
* @param manga the manga to get categories from.
* @return Array of category ids the manga is in, if none returns default id
*/
fun getMangaCategoryIds(manga: Manga): Array<Int> {
val categories = db.getCategoriesForManga(manga).executeAsBlocking()
return categories.mapNotNull { it.id }.toTypedArray()
}
/**
* Move the given manga to categories.
*
* @param manga the manga to move.
* @param categories the selected categories.
*/
fun moveMangaToCategories(manga: Manga, categories: List<Category>) {
val mc = categories.filter { it.id != 0 }.map { MangaCategory.create(manga, it) }
db.setMangaCategories(mc, listOf(manga))
}
/**
* Move the given manga to the category.
*
* @param manga the manga to move.
* @param category the selected category, or null for default category.
*/
fun moveMangaToCategory(manga: Manga, category: Category?) {
moveMangaToCategories(manga, listOfNotNull(category))
}
suspend fun smartSearchMerge(manga: Manga, originalMangaId: Long): Manga {
val originalManga = db.getManga(originalMangaId).await()
?: throw IllegalArgumentException("Unknown manga ID: $originalMangaId")
@@ -458,6 +341,126 @@ class MangaAllInOnePresenter(
return toInsert
}
// SY <--
/**
* Update favorite status of manga, (removes / adds) manga (to / from) library.
*
* @return the new status of the manga.
*/
fun toggleFavorite(): Boolean {
manga.favorite = !manga.favorite
manga.date_added = when (manga.favorite) {
true -> Date().time
false -> 0
}
if (!manga.favorite) {
manga.removeCovers(coverCache)
}
db.insertManga(manga).executeAsBlocking()
return manga.favorite
}
/**
* Returns true if the manga has any downloads.
*/
fun hasDownloads(): Boolean {
return downloadManager.getDownloadCount(manga) > 0
}
/**
* Deletes all the downloads for the manga.
*/
fun deleteDownloads() {
downloadManager.deleteManga(manga, source)
}
/**
* Get user categories.
*
* @return List of categories, not including the default category
*/
fun getCategories(): List<Category> {
return db.getCategories().executeAsBlocking()
}
/**
* Gets the category id's the manga is in, if the manga is not in a category, returns the default id.
*
* @param manga the manga to get categories from.
* @return Array of category ids the manga is in, if none returns default id
*/
fun getMangaCategoryIds(manga: Manga): Array<Int> {
val categories = db.getCategoriesForManga(manga).executeAsBlocking()
return categories.mapNotNull { it.id }.toTypedArray()
}
/**
* Move the given manga to categories.
*
* @param manga the manga to move.
* @param categories the selected categories.
*/
fun moveMangaToCategories(manga: Manga, categories: List<Category>) {
val mc = categories.filter { it.id != 0 }.map { MangaCategory.create(manga, it) }
db.setMangaCategories(mc, listOf(manga))
}
/**
* Move the given manga to the category.
*
* @param manga the manga to move.
* @param category the selected category, or null for default category.
*/
fun moveMangaToCategory(manga: Manga, category: Category?) {
moveMangaToCategories(manga, listOfNotNull(category))
}
/**
* Update cover with local file.
*
* @param manga the manga edited.
* @param context Context.
* @param data uri of the cover resource.
*/
fun editCover(manga: Manga, context: Context, data: Uri) {
Observable
.fromCallable {
context.contentResolver.openInputStream(data)?.use {
if (manga.isLocal()) {
LocalSource.updateCover(context, manga, it)
manga.updateCoverLastModified(db)
} else if (manga.favorite) {
coverCache.setCustomCoverToCache(manga, it)
manga.updateCoverLastModified(db)
}
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ -> view.onSetCoverSuccess() },
{ view, e -> view.onSetCoverError(e) }
)
}
fun deleteCustomCover(manga: Manga) {
Observable
.fromCallable {
coverCache.deleteCustomCover(manga)
manga.updateCoverLastModified(db)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ -> view.onSetCoverSuccess() },
{ view, e -> view.onSetCoverError(e) }
)
}
// Manga info - end
// Chapters list - start
private fun observeDownloads() {
observeDownloadsSubscription?.let { remove(it) }
@@ -465,7 +468,7 @@ class MangaAllInOnePresenter(
.observeOn(AndroidSchedulers.mainThread())
.filter { download -> download.manga.id == manga.id }
.doOnNext { onDownloadStatusChange(it) }
.subscribeLatestCache(MangaAllInOneController::onChapterStatusChange) { _, error ->
.subscribeLatestCache(MangaController::onChapterStatusChange) { _, error ->
Timber.e(error)
}
}
@@ -473,9 +476,9 @@ class MangaAllInOnePresenter(
/**
* Converts a chapter from the database to an extended model, allowing to store new fields.
*/
private fun Chapter.toModel(): MangaAllInOneChapterItem {
private fun Chapter.toModel(): ChapterItem {
// Create the model object.
val model = MangaAllInOneChapterItem(this, manga)
val model = ChapterItem(this, manga)
// Find an active download for this chapter.
val download = downloadManager.queue.find { it.chapter.id == id }
@@ -492,29 +495,60 @@ class MangaAllInOnePresenter(
*
* @param chapters the list of chapter from the database.
*/
private fun setDownloadedChapters(chapters: List<MangaAllInOneChapterItem>) {
private fun setDownloadedChapters(chapters: List<ChapterItem>) {
chapters
.filter { downloadManager.isChapterDownloaded(it, manga) }
.forEach { it.status = Download.DOWNLOADED }
}
/**
* Requests an updated list of chapters from the source.
*/
fun fetchChaptersFromSource(manualFetch: Boolean = false) {
hasRequested = true
if (!fetchChaptersSubscription.isNullOrUnsubscribed()) return
fetchChaptersSubscription = Observable.defer { source.fetchChapterList(manga) }
.subscribeOn(Schedulers.io())
.map { syncChaptersWithSource(db, it, manga, source) }
.doOnNext {
if (manualFetch) {
downloadNewChapters(it.first)
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ ->
view.onFetchChaptersDone()
},
MangaController::onFetchChaptersError
)
}
/**
* Updates the UI after applying the filters.
*/
private fun refreshChapters() {
chaptersRelay.call(chapters)
}
/**
* Applies the view filters to the list of chapters obtained from the database.
* @param chapters the list of chapters from the database
* @return an observable of the list of chapters filtered and sorted.
*/
private fun applyChapterFilters(chapterList: List<MangaAllInOneChapterItem>): List<MangaAllInOneChapterItem> {
var chapters = chapterList
private fun applyChapterFilters(chapters: List<ChapterItem>): Observable<List<ChapterItem>> {
var observable = Observable.from(chapters).subscribeOn(Schedulers.io())
if (onlyUnread()) {
chapters = chapters.filter { !it.read }
observable = observable.filter { !it.read }
} else if (onlyRead()) {
chapters = chapters.filter { it.read }
observable = observable.filter { it.read }
}
if (onlyDownloaded()) {
chapters = chapters.filter { it.isDownloaded || it.manga.source == LocalSource.ID }
observable = observable.filter { it.isDownloaded || it.manga.isLocal() }
}
if (onlyBookmarked()) {
chapters = chapters.filter { it.bookmark }
observable = observable.filter { it.bookmark }
}
val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) {
Manga.SORTING_SOURCE -> when (sortDescending()) {
@@ -529,10 +563,9 @@ class MangaAllInOnePresenter(
true -> { c1, c2 -> c2.date_upload.compareTo(c1.date_upload) }
false -> { c1, c2 -> c1.date_upload.compareTo(c2.date_upload) }
}
else -> { c1, c2 -> c1.source_order.compareTo(c2.source_order) }
else -> throw NotImplementedError("Unimplemented sorting method")
}
chapters = chapters.sortedWith(Comparator(sortFunction))
return chapters
return observable.toSortedList(sortFunction)
}
/**
@@ -551,14 +584,14 @@ class MangaAllInOnePresenter(
// Force UI update if downloaded filter active and download finished.
if (onlyDownloaded() && download.status == Download.DOWNLOADED) {
updateChaptersView()
refreshChapters()
}
}
/**
* Returns the next unread chapter or null if everything is read.
*/
fun getNextUnreadChapter(): MangaAllInOneChapterItem? {
fun getNextUnreadChapter(): ChapterItem? {
return chapters.sortedByDescending { it.source_order }.find { !it.read }
}
@@ -567,21 +600,22 @@ class MangaAllInOnePresenter(
* @param selectedChapters the list of selected chapters.
* @param read whether to mark chapters as read or unread.
*/
fun markChaptersRead(selectedChapters: List<MangaAllInOneChapterItem>, read: Boolean) {
Observable.from(selectedChapters)
.doOnNext { chapter ->
chapter.read = read
if (!read /* --> EH */ && !preferences
.eh_preserveReadingPosition()
.get() /* <-- EH */
) {
chapter.last_page_read = 0
}
fun markChaptersRead(selectedChapters: List<ChapterItem>, read: Boolean) {
val chapters = selectedChapters.map { chapter ->
chapter.read = read
if (!read) {
chapter.last_page_read = 0
}
.toList()
.flatMap { db.updateChaptersProgress(it).asRxObservable() }
.subscribeOn(Schedulers.io())
.subscribe()
chapter
}
launchIO {
db.updateChaptersProgress(chapters).executeAsBlocking()
if (preferences.removeAfterMarkedAsRead()) {
deleteChapters(chapters)
}
}
}
/**
@@ -596,7 +630,7 @@ class MangaAllInOnePresenter(
* Bookmarks the given list of chapters.
* @param selectedChapters the list of chapters to bookmark.
*/
fun bookmarkChapters(selectedChapters: List<MangaAllInOneChapterItem>, bookmarked: Boolean) {
fun bookmarkChapters(selectedChapters: List<ChapterItem>, bookmarked: Boolean) {
Observable.from(selectedChapters)
.doOnNext { chapter ->
chapter.bookmark = bookmarked
@@ -611,22 +645,22 @@ class MangaAllInOnePresenter(
* Deletes the given list of chapter.
* @param chapters the list of chapters to delete.
*/
fun deleteChapters(chapters: List<MangaAllInOneChapterItem>) {
fun deleteChapters(chapters: List<ChapterItem>) {
Observable.just(chapters)
.doOnNext { deleteChaptersInternal(chapters) }
.doOnNext { if (onlyDownloaded()) updateChaptersView() }
.doOnNext { if (onlyDownloaded()) refreshChapters() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ ->
view.onChaptersDeleted(chapters)
},
MangaAllInOneController::onChaptersDeletedError
MangaController::onChaptersDeletedError
)
}
private fun downloadNewChapters(chapters: List<Chapter>) {
if (chapters.isEmpty() || !manga.shouldDownloadNewChapters(db, preferences) /* SY --> */ || manga.source == EH_SOURCE_ID || manga.source == EXH_SOURCE_ID/* SY <-- */) return
if (chapters.isEmpty() || !manga.shouldDownloadNewChapters(db, preferences)) return
downloadChapters(chapters)
}
@@ -635,7 +669,7 @@ class MangaAllInOnePresenter(
* Deletes a list of chapters from disk. This method is called in a background thread.
* @param chapters the chapters to delete.
*/
private fun deleteChaptersInternal(chapters: List<MangaAllInOneChapterItem>) {
private fun deleteChaptersInternal(chapters: List<ChapterItem>) {
downloadManager.deleteChapters(chapters, manga, source)
chapters.forEach {
it.status = Download.NOT_DOWNLOADED
@@ -646,10 +680,10 @@ class MangaAllInOnePresenter(
/**
* Reverses the sorting and requests an UI update.
*/
fun revertSortOrder() {
fun reverseSortOrder() {
manga.setChapterOrder(if (sortDescending()) Manga.SORT_ASC else Manga.SORT_DESC)
db.updateFlags(manga).executeAsBlocking()
updateChaptersView()
refreshChapters()
}
/**
@@ -659,7 +693,7 @@ class MangaAllInOnePresenter(
fun setUnreadFilter(onlyUnread: Boolean) {
manga.readFilter = if (onlyUnread) Manga.SHOW_UNREAD else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
updateChaptersView()
refreshChapters()
}
/**
@@ -669,7 +703,7 @@ class MangaAllInOnePresenter(
fun setReadFilter(onlyRead: Boolean) {
manga.readFilter = if (onlyRead) Manga.SHOW_READ else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
updateChaptersView()
refreshChapters()
}
/**
@@ -679,7 +713,7 @@ class MangaAllInOnePresenter(
fun setDownloadedFilter(onlyDownloaded: Boolean) {
manga.downloadedFilter = if (onlyDownloaded) Manga.SHOW_DOWNLOADED else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
updateChaptersView()
refreshChapters()
}
/**
@@ -689,7 +723,7 @@ class MangaAllInOnePresenter(
fun setBookmarkedFilter(onlyBookmarked: Boolean) {
manga.bookmarkedFilter = if (onlyBookmarked) Manga.SHOW_BOOKMARKED else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
updateChaptersView()
refreshChapters()
}
/**
@@ -700,14 +734,7 @@ class MangaAllInOnePresenter(
manga.downloadedFilter = Manga.SHOW_ALL
manga.bookmarkedFilter = Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
updateChaptersView()
}
/**
* Adds manga to library
*/
fun addToLibrary() {
setFavorite(true)
refreshChapters()
}
/**
@@ -726,7 +753,7 @@ class MangaAllInOnePresenter(
fun setSorting(sort: Int) {
manga.sorting = sort
db.updateFlags(manga).executeAsBlocking()
updateChaptersView()
refreshChapters()
}
/**
@@ -770,4 +797,6 @@ class MangaAllInOnePresenter(
fun sortDescending(): Boolean {
return manga.sortDescending()
}
// Chapters list - end
}
@@ -8,7 +8,6 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import eu.kanade.tachiyomi.ui.manga.MangaAllInOneAdapter
import eu.kanade.tachiyomi.util.view.visibleIf
import java.util.Date
import kotlinx.android.synthetic.main.chapters_item.bookmark_icon
@@ -77,65 +76,3 @@ class ChapterHolder(
}
}
}
class MangaAllInOneChapterHolder(
view: View,
private val adapter: MangaAllInOneAdapter
) : BaseFlexibleViewHolder(view, adapter) {
fun bind(item: MangaAllInOneChapterItem, manga: Manga) {
val chapter = item.chapter
chapter_title.text = when (manga.displayMode) {
Manga.DISPLAY_NUMBER -> {
val number = adapter.decimalFormat.format(chapter.chapter_number.toDouble())
itemView.context.getString(R.string.display_mode_chapter, number)
}
else -> chapter.name
}
// Set correct text color
val chapterColor = when {
chapter.read -> adapter.readColor
chapter.bookmark -> adapter.bookmarkedColor
else -> adapter.unreadColor
}
chapter_title.setTextColor(chapterColor)
chapter_description.setTextColor(chapterColor)
bookmark_icon.visibleIf { chapter.bookmark }
val descriptions = mutableListOf<CharSequence>()
if (chapter.date_upload > 0) {
descriptions.add(adapter.dateFormat.format(Date(chapter.date_upload)))
}
if (!chapter.read && chapter.last_page_read > 0) {
val lastPageRead = SpannableString(itemView.context.getString(R.string.chapter_progress, chapter.last_page_read + 1)).apply {
setSpan(ForegroundColorSpan(adapter.readColor), 0, length, SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE)
}
descriptions.add(lastPageRead)
}
if (!chapter.scanlator.isNullOrBlank()) {
descriptions.add(chapter.scanlator!!)
}
if (descriptions.isNotEmpty()) {
chapter_description.text = descriptions.joinTo(SpannableStringBuilder(), "")
} else {
chapter_description.text = ""
}
notifyStatus(item.status)
}
fun notifyStatus(status: Int) = with(download_text) {
when (status) {
Download.QUEUE -> setText(R.string.chapter_queued)
Download.DOWNLOADING -> setText(R.string.chapter_downloading)
Download.DOWNLOADED -> setText(R.string.chapter_downloaded)
Download.ERROR -> setText(R.string.chapter_error)
else -> text = ""
}
}
}
@@ -9,7 +9,6 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.ui.manga.MangaAllInOneAdapter
class ChapterItem(val chapter: Chapter, val manga: Manga) :
AbstractFlexibleItem<ChapterHolder>(),
@@ -58,51 +57,3 @@ class ChapterItem(val chapter: Chapter, val manga: Manga) :
return chapter.id!!.hashCode()
}
}
class MangaAllInOneChapterItem(val chapter: Chapter, val manga: Manga) :
AbstractFlexibleItem<MangaAllInOneChapterHolder>(),
Chapter by chapter {
private var _status: Int = 0
var status: Int
get() = download?.status ?: _status
set(value) {
_status = value
}
@Transient
var download: Download? = null
val isDownloaded: Boolean
get() = status == Download.DOWNLOADED
override fun getLayoutRes(): Int {
return R.layout.chapters_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): MangaAllInOneChapterHolder {
return MangaAllInOneChapterHolder(view, adapter as MangaAllInOneAdapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: MangaAllInOneChapterHolder,
position: Int,
payloads: List<Any?>?
) {
holder.bind(this, manga)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is MangaAllInOneChapterItem) {
return chapter.id!! == other.chapter.id!!
}
return false
}
override fun hashCode(): Int {
return chapter.id!!.hashCode()
}
}
@@ -4,6 +4,7 @@ import android.content.Context
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.getResourceColor
import java.text.DateFormat
import java.text.DecimalFormat
@@ -11,7 +12,7 @@ import java.text.DecimalFormatSymbols
import uy.kohesive.injekt.injectLazy
class ChaptersAdapter(
controller: Any,
controller: MangaController,
context: Context
) : FlexibleAdapter<ChapterItem>(null, controller, true) {
@@ -1,612 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.app.Activity
import android.content.Intent
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.drawable.DrawableCompat
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.databinding.ChaptersControllerBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.main.offsetAppbarHeight
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.getCoordinates
import eu.kanade.tachiyomi.util.view.gone
import eu.kanade.tachiyomi.util.view.shrinkOnScroll
import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.util.view.visible
import exh.EH_SOURCE_ID
import exh.EXH_SOURCE_ID
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
import timber.log.Timber
class ChaptersController :
NucleusController<ChaptersControllerBinding, ChaptersPresenter>(),
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
DownloadCustomChaptersDialog.Listener,
DeleteChaptersDialog.Listener {
/**
* Adapter containing a list of chapters.
*/
private var adapter: ChaptersAdapter? = null
/**
* Action mode for multiple selection.
*/
private var actionMode: ActionMode? = null
/**
* Selected items. Used to restore selections after a rotation.
*/
private val selectedItems = mutableSetOf<ChapterItem>()
private var lastClickPosition = -1
init {
setHasOptionsMenu(true)
setOptionsMenuHidden(true)
}
override fun createPresenter(): ChaptersPresenter {
val ctrl = parentController as MangaController
return ChaptersPresenter(
ctrl.manga!!, ctrl.source!!,
ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay
)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
binding = ChaptersControllerBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
val ctrl = parentController as MangaController
if (ctrl.manga == null || ctrl.source == null) return
// Init RecyclerView and adapter
adapter = ChaptersAdapter(this, view.context)
binding.recycler.adapter = adapter
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
binding.recycler.setHasFixedSize(true)
adapter?.fastScroller = binding.fastScroller
binding.swipeRefresh.refreshes()
.onEach { fetchChaptersFromSource(manualFetch = true) }
.launchIn(scope)
binding.fab.clicks()
.onEach {
val item = presenter.getNextUnreadChapter()
if (item != null) {
// Create animation listener
val revealAnimationListener: Animator.AnimatorListener = object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator?) {
openChapter(item.chapter, true)
}
}
// Get coordinates and start animation
val coordinates = binding.fab.getCoordinates()
if (!binding.revealView.showRevealEffect(coordinates.x, coordinates.y, revealAnimationListener)) {
openChapter(item.chapter)
}
} else {
view.context.toast(R.string.no_next_chapter)
}
}
.launchIn(scope)
binding.fab.shrinkOnScroll(binding.recycler)
binding.actionToolbar.offsetAppbarHeight(activity!!)
binding.fab.offsetAppbarHeight(activity!!)
}
override fun onDestroyView(view: View) {
destroyActionModeIfNeeded()
binding.actionToolbar.destroy()
adapter = null
super.onDestroyView(view)
}
override fun onActivityResumed(activity: Activity) {
if (view == null) return
// Check if animation view is visible
if (binding.revealView.visibility == View.VISIBLE) {
// Show the unreveal effect
val coordinates = binding.fab.getCoordinates()
binding.revealView.hideRevealEffect(coordinates.x, coordinates.y, 1920)
}
super.onActivityResumed(activity)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.chapters, menu)
}
override fun onPrepareOptionsMenu(menu: Menu) {
// Initialize menu items.
val menuFilterRead = menu.findItem(R.id.action_filter_read) ?: return
val menuFilterUnread = menu.findItem(R.id.action_filter_unread)
val menuFilterDownloaded = menu.findItem(R.id.action_filter_downloaded)
val menuFilterBookmarked = menu.findItem(R.id.action_filter_bookmarked)
val menuFilterEmpty = menu.findItem(R.id.action_filter_empty)
// Set correct checkbox values.
menuFilterRead.isChecked = presenter.onlyRead()
menuFilterUnread.isChecked = presenter.onlyUnread()
menuFilterDownloaded.isChecked = presenter.onlyDownloaded()
menuFilterDownloaded.isEnabled = !presenter.forceDownloaded()
menuFilterBookmarked.isChecked = presenter.onlyBookmarked()
val filterSet = presenter.onlyRead() || presenter.onlyUnread() || presenter.onlyDownloaded() || presenter.onlyBookmarked()
if (filterSet) {
val filterColor = activity!!.getResourceColor(R.attr.colorFilterActive)
DrawableCompat.setTint(menu.findItem(R.id.action_filter).icon, filterColor)
}
// Only show remove filter option if there's a filter set.
menuFilterEmpty.isVisible = filterSet
// Disable unread filter option if read filter is enabled.
if (presenter.onlyRead()) {
menuFilterUnread.isEnabled = false
}
// Disable read filter option if unread filter is enabled.
if (presenter.onlyUnread()) {
menuFilterRead.isEnabled = false
}
// Display mode submenu
if (presenter.manga.displayMode == Manga.DISPLAY_NAME) {
menu.findItem(R.id.display_title).isChecked = true
} else {
menu.findItem(R.id.display_chapter_number).isChecked = true
}
// Sorting mode submenu
val sortingItem = when (presenter.manga.sorting) {
Manga.SORTING_SOURCE -> R.id.sort_by_source
Manga.SORTING_NUMBER -> R.id.sort_by_number
Manga.SORTING_UPLOAD_DATE -> R.id.sort_by_upload_date
else -> throw NotImplementedError("Unimplemented sorting method")
}
menu.findItem(sortingItem).isChecked = true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.display_title -> {
item.isChecked = true
setDisplayMode(Manga.DISPLAY_NAME)
}
R.id.display_chapter_number -> {
item.isChecked = true
setDisplayMode(Manga.DISPLAY_NUMBER)
}
R.id.sort_by_source -> {
item.isChecked = true
presenter.setSorting(Manga.SORTING_SOURCE)
}
R.id.sort_by_number -> {
item.isChecked = true
presenter.setSorting(Manga.SORTING_NUMBER)
}
R.id.sort_by_upload_date -> {
item.isChecked = true
presenter.setSorting(Manga.SORTING_UPLOAD_DATE)
}
R.id.download_next, R.id.download_next_5, R.id.download_next_10,
R.id.download_custom, R.id.download_unread, R.id.download_all
-> downloadChapters(item.itemId)
R.id.action_filter_unread -> {
item.isChecked = !item.isChecked
presenter.setUnreadFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_read -> {
item.isChecked = !item.isChecked
presenter.setReadFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_downloaded -> {
item.isChecked = !item.isChecked
presenter.setDownloadedFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_bookmarked -> {
item.isChecked = !item.isChecked
presenter.setBookmarkedFilter(item.isChecked)
activity?.invalidateOptionsMenu()
}
R.id.action_filter_empty -> {
presenter.removeFilters()
activity?.invalidateOptionsMenu()
}
R.id.action_sort -> presenter.revertSortOrder()
}
return super.onOptionsItemSelected(item)
}
fun onNextChapters(chapters: List<ChapterItem>) {
// If the list is empty and it hasn't requested previously, fetch chapters from source
// We use presenter chapters instead because they are always unfiltered
if (!presenter.hasRequested && presenter.chapters.isEmpty()) {
fetchChaptersFromSource()
}
val mangaController = parentController as MangaController
if (mangaController.update ||
// Auto-update old format galleries
(
(presenter.manga.source == EH_SOURCE_ID || presenter.manga.source == EXH_SOURCE_ID) &&
chapters.size == 1 && chapters.first().date_upload == 0L
)
) {
mangaController.update = false
fetchChaptersFromSource()
}
val adapter = adapter ?: return
adapter.updateDataSet(chapters)
if (selectedItems.isNotEmpty()) {
adapter.clearSelection() // we need to start from a clean state, index may have changed
createActionModeIfNeeded()
selectedItems.forEach { item ->
val position = adapter.indexOf(item)
if (position != -1 && !adapter.isSelected(position)) {
adapter.toggleSelection(position)
}
}
actionMode?.invalidate()
}
val context = view?.context
if (context != null && chapters.any { it.read }) {
binding.fab.text = context.getString(R.string.action_resume)
}
}
private fun fetchChaptersFromSource(manualFetch: Boolean = false) {
binding.swipeRefresh.isRefreshing = true
presenter.fetchChaptersFromSource(manualFetch)
}
fun onFetchChaptersDone() {
binding.swipeRefresh.isRefreshing = false
}
fun onFetchChaptersError(error: Throwable) {
binding.swipeRefresh.isRefreshing = false
activity?.toast(error.message)
}
fun onChapterStatusChange(download: Download) {
getHolder(download.chapter)?.notifyStatus(download.status)
}
private fun getHolder(chapter: Chapter): ChapterHolder? {
return binding.recycler.findViewHolderForItemId(chapter.id!!) as? ChapterHolder
}
fun openChapter(chapter: Chapter, hasAnimation: Boolean = false) {
val activity = activity ?: return
val intent = ReaderActivity.newIntent(activity, presenter.manga, chapter)
if (hasAnimation) {
intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
}
startActivity(intent)
}
override fun onItemClick(view: View?, position: Int): Boolean {
val adapter = adapter ?: return false
val item = adapter.getItem(position) ?: return false
return if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) {
lastClickPosition = position
toggleSelection(position)
true
} else {
openChapter(item.chapter)
false
}
}
override fun onItemLongClick(position: Int) {
createActionModeIfNeeded()
when {
lastClickPosition == -1 -> setSelection(position)
lastClickPosition > position ->
for (i in position until lastClickPosition)
setSelection(i)
lastClickPosition < position ->
for (i in lastClickPosition + 1..position)
setSelection(i)
else -> setSelection(position)
}
lastClickPosition = position
adapter?.notifyDataSetChanged()
}
// SELECTIONS & ACTION MODE
private fun toggleSelection(position: Int) {
val adapter = adapter ?: return
val item = adapter.getItem(position) ?: return
adapter.toggleSelection(position)
adapter.notifyDataSetChanged()
if (adapter.isSelected(position)) {
selectedItems.add(item)
} else {
selectedItems.remove(item)
}
actionMode?.invalidate()
}
private fun setSelection(position: Int) {
val adapter = adapter ?: return
val item = adapter.getItem(position) ?: return
if (!adapter.isSelected(position)) {
adapter.toggleSelection(position)
selectedItems.add(item)
actionMode?.invalidate()
}
}
private fun getSelectedChapters(): List<ChapterItem> {
val adapter = adapter ?: return emptyList()
return adapter.selectedPositions.mapNotNull { adapter.getItem(it) }
}
private fun createActionModeIfNeeded() {
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
binding.actionToolbar.show(
actionMode!!,
R.menu.chapter_selection
) { onActionItemClicked(it!!) }
}
}
private fun destroyActionModeIfNeeded() {
lastClickPosition = -1
actionMode?.finish()
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.generic_selection, menu)
adapter?.mode = SelectableAdapter.Mode.MULTI
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = adapter?.selectedItemCount ?: 0
if (count == 0) {
// Destroy action mode if there are no items selected.
destroyActionModeIfNeeded()
} else {
mode.title = count.toString()
val isLocalSource = presenter.source.id == LocalSource.ID
val chapters = getSelectedChapters()
binding.actionToolbar.findItem(R.id.action_download)?.isVisible = !isLocalSource && chapters.any { !it.isDownloaded }
binding.actionToolbar.findItem(R.id.action_delete)?.isVisible = !isLocalSource && chapters.any { it.isDownloaded }
binding.actionToolbar.findItem(R.id.action_bookmark)?.isVisible = chapters.any { !it.chapter.bookmark }
binding.actionToolbar.findItem(R.id.action_remove_bookmark)?.isVisible = chapters.all { it.chapter.bookmark }
binding.actionToolbar.findItem(R.id.action_mark_as_read)?.isVisible = chapters.any { !it.chapter.read }
binding.actionToolbar.findItem(R.id.action_mark_as_unread)?.isVisible = chapters.all { it.chapter.read }
// Hide FAB to avoid interfering with the bottom action toolbar
// binding.fab.hide()
binding.fab.gone()
}
return false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return onActionItemClicked(item)
}
private fun onActionItemClicked(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_select_all -> selectAll()
R.id.action_select_inverse -> selectInverse()
R.id.action_download -> downloadChapters(getSelectedChapters())
R.id.action_delete -> showDeleteChaptersConfirmationDialog()
R.id.action_bookmark -> bookmarkChapters(getSelectedChapters(), true)
R.id.action_remove_bookmark -> bookmarkChapters(getSelectedChapters(), false)
R.id.action_mark_as_read -> markAsRead(getSelectedChapters())
R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters())
R.id.action_mark_previous_as_read -> markPreviousAsRead(getSelectedChapters())
else -> return false
}
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
binding.actionToolbar.hide()
adapter?.mode = SelectableAdapter.Mode.SINGLE
adapter?.clearSelection()
selectedItems.clear()
actionMode = null
// TODO: there seems to be a bug in MaterialComponents where the [ExtendedFloatingActionButton]
// fails to show up properly
// binding.fab.show()
binding.fab.visible()
}
override fun onDetach(view: View) {
destroyActionModeIfNeeded()
super.onDetach(view)
}
// SELECTION MODE ACTIONS
private fun selectAll() {
val adapter = adapter ?: return
adapter.selectAll()
selectedItems.addAll(adapter.items)
actionMode?.invalidate()
}
private fun selectInverse() {
val adapter = adapter ?: return
selectedItems.clear()
for (i in 0..adapter.itemCount) {
adapter.toggleSelection(i)
}
selectedItems.addAll(adapter.selectedPositions.mapNotNull { adapter.getItem(it) })
actionMode?.invalidate()
adapter.notifyDataSetChanged()
}
private fun markAsRead(chapters: List<ChapterItem>) {
presenter.markChaptersRead(chapters, true)
if (presenter.preferences.removeAfterMarkedAsRead()) {
deleteChapters(chapters)
}
destroyActionModeIfNeeded()
}
private fun markAsUnread(chapters: List<ChapterItem>) {
presenter.markChaptersRead(chapters, false)
destroyActionModeIfNeeded()
}
private fun downloadChapters(chapters: List<ChapterItem>) {
val view = view
presenter.downloadChapters(chapters)
if (view != null && !presenter.manga.favorite) {
binding.recycler.snack(view.context.getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_add) {
presenter.addToLibrary()
}
}
}
destroyActionModeIfNeeded()
}
private fun showDeleteChaptersConfirmationDialog() {
DeleteChaptersDialog(this).showDialog(router)
}
override fun deleteChapters() {
deleteChapters(getSelectedChapters())
}
private fun markPreviousAsRead(chapters: List<ChapterItem>) {
val adapter = adapter ?: return
val prevChapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items
val chapterPos = prevChapters.indexOf(chapters.last())
if (chapterPos != -1) {
markAsRead(prevChapters.take(chapterPos))
}
destroyActionModeIfNeeded()
}
private fun bookmarkChapters(chapters: List<ChapterItem>, bookmarked: Boolean) {
presenter.bookmarkChapters(chapters, bookmarked)
destroyActionModeIfNeeded()
}
fun deleteChapters(chapters: List<ChapterItem>) {
if (chapters.isEmpty()) return
presenter.deleteChapters(chapters)
destroyActionModeIfNeeded()
}
fun onChaptersDeleted(chapters: List<ChapterItem>) {
// this is needed so the downloaded text gets removed from the item
chapters.forEach {
adapter?.updateItem(it)
}
adapter?.notifyDataSetChanged()
}
fun onChaptersDeletedError(error: Throwable) {
Timber.e(error)
}
// OVERFLOW MENU DIALOGS
private fun setDisplayMode(id: Int) {
presenter.setDisplayMode(id)
adapter?.notifyDataSetChanged()
}
private fun getUnreadChaptersSorted() = presenter.chapters
.filter { !it.read && it.status == Download.NOT_DOWNLOADED }
.distinctBy { it.name }
.sortedByDescending { it.source_order }
private fun downloadChapters(choice: Int) {
val chaptersToDownload = when (choice) {
R.id.download_next -> getUnreadChaptersSorted().take(1)
R.id.download_next_5 -> getUnreadChaptersSorted().take(5)
R.id.download_next_10 -> getUnreadChaptersSorted().take(10)
R.id.download_custom -> {
showCustomDownloadDialog()
return
}
R.id.download_unread -> presenter.chapters.filter { !it.read }
R.id.download_all -> presenter.chapters
else -> emptyList()
}
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
destroyActionModeIfNeeded()
}
private fun showCustomDownloadDialog() {
DownloadCustomChaptersDialog(this, presenter.chapters.size).showDialog(router)
}
override fun downloadCustomChapters(amount: Int) {
val chaptersToDownload = getUnreadChaptersSorted().take(amount)
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
}
}
@@ -1,488 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.isLocal
import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
import exh.EH_SOURCE_ID
import exh.EXH_SOURCE_ID
import exh.debug.DebugToggles
import exh.eh.EHentaiUpdateHelper
import java.util.Date
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
class ChaptersPresenter(
val manga: Manga,
val source: Source,
private val chapterCountRelay: BehaviorRelay<Float>,
private val lastUpdateRelay: BehaviorRelay<Date>,
private val mangaFavoriteRelay: PublishRelay<Boolean>,
val preferences: PreferencesHelper = Injekt.get(),
private val db: DatabaseHelper = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get()
) : BasePresenter<ChaptersController>() {
/**
* List of chapters of the manga. It's always unfiltered and unsorted.
*/
var chapters: List<ChapterItem> = emptyList()
private set
/**
* Subject of list of chapters to allow updating the view without going to DB.
*/
private val chaptersRelay: PublishRelay<List<ChapterItem>> by lazy {
PublishRelay.create<List<ChapterItem>>()
}
/**
* Whether the chapter list has been requested to the source.
*/
var hasRequested = false
private set
/**
* Subscription to retrieve the new list of chapters from the source.
*/
private var fetchChaptersSubscription: Subscription? = null
/**
* Subscription to observe download status changes.
*/
private var observeDownloadsSubscription: Subscription? = null
// EXH -->
private val updateHelper: EHentaiUpdateHelper by injectLazy()
val redirectUserRelay = BehaviorRelay.create<EXHRedirect>()
data class EXHRedirect(val manga: Manga, val update: Boolean)
// EXH <--
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
// Prepare the relay.
chaptersRelay.flatMap { applyChapterFilters(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(ChaptersController::onNextChapters) { _, error -> Timber.e(error) }
// Add the subscription that retrieves the chapters from the database, keeps subscribed to
// changes, and sends the list of chapters to the relay.
add(
db.getChapters(manga).asRxObservable()
.map { chapters ->
// Convert every chapter to a model.
chapters.map { it.toModel() }
}
.doOnNext { chapters ->
// Find downloaded chapters
setDownloadedChapters(chapters)
// Store the last emission
this.chapters = chapters
// Listen for download status changes
observeDownloads()
// Emit the number of chapters to the info tab.
chapterCountRelay.call(
chapters.maxBy { it.chapter_number }?.chapter_number
?: 0f
)
// Emit the upload date of the most recent chapter
lastUpdateRelay.call(
Date(
chapters.maxBy { it.date_upload }?.date_upload
?: 0
)
)
// EXH -->
if (chapters.isNotEmpty() &&
(source.id == EXH_SOURCE_ID || source.id == EH_SOURCE_ID) &&
DebugToggles.ENABLE_EXH_ROOT_REDIRECT.enabled
) {
// Check for gallery in library and accept manga with lowest id
// Find chapters sharing same root
add(
updateHelper.findAcceptedRootAndDiscardOthers(manga.source, chapters)
.subscribeOn(Schedulers.io())
.subscribe { (acceptedChain, _) ->
// Redirect if we are not the accepted root
if (manga.id != acceptedChain.manga.id) {
// Update if any of our chapters are not in accepted manga's chapters
val ourChapterUrls = chapters.map { it.url }.toSet()
val acceptedChapterUrls = acceptedChain.chapters.map { it.url }.toSet()
val update = (ourChapterUrls - acceptedChapterUrls).isNotEmpty()
redirectUserRelay.call(EXHRedirect(acceptedChain.manga, update))
}
}
)
}
// EXH <--
}
.subscribe { chaptersRelay.call(it) }
)
}
private fun observeDownloads() {
observeDownloadsSubscription?.let { remove(it) }
observeDownloadsSubscription = downloadManager.queue.getStatusObservable()
.observeOn(AndroidSchedulers.mainThread())
.filter { download -> download.manga.id == manga.id }
.doOnNext { onDownloadStatusChange(it) }
.subscribeLatestCache(ChaptersController::onChapterStatusChange) { _, error ->
Timber.e(error)
}
}
/**
* Converts a chapter from the database to an extended model, allowing to store new fields.
*/
private fun Chapter.toModel(): ChapterItem {
// Create the model object.
val model = ChapterItem(this, manga)
// Find an active download for this chapter.
val download = downloadManager.queue.find { it.chapter.id == id }
if (download != null) {
// If there's an active download, assign it.
model.download = download
}
return model
}
/**
* Finds and assigns the list of downloaded chapters.
*
* @param chapters the list of chapter from the database.
*/
private fun setDownloadedChapters(chapters: List<ChapterItem>) {
chapters
.filter { downloadManager.isChapterDownloaded(it, manga) }
.forEach { it.status = Download.DOWNLOADED }
}
/**
* Requests an updated list of chapters from the source.
*/
fun fetchChaptersFromSource(manualFetch: Boolean = false) {
hasRequested = true
if (!fetchChaptersSubscription.isNullOrUnsubscribed()) return
fetchChaptersSubscription = Observable.defer { source.fetchChapterList(manga) }
.subscribeOn(Schedulers.io())
.map { syncChaptersWithSource(db, it, manga, source) }
.doOnNext {
if (manualFetch) {
downloadNewChapters(it.first)
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ ->
view.onFetchChaptersDone()
},
ChaptersController::onFetchChaptersError
)
}
/**
* Updates the UI after applying the filters.
*/
private fun refreshChapters() {
chaptersRelay.call(chapters)
}
/**
* Applies the view filters to the list of chapters obtained from the database.
* @param chapters the list of chapters from the database
* @return an observable of the list of chapters filtered and sorted.
*/
private fun applyChapterFilters(chapters: List<ChapterItem>): Observable<List<ChapterItem>> {
var observable = Observable.from(chapters).subscribeOn(Schedulers.io())
if (onlyUnread()) {
observable = observable.filter { !it.read }
} else if (onlyRead()) {
observable = observable.filter { it.read }
}
if (onlyDownloaded()) {
observable = observable.filter { it.isDownloaded || it.manga.isLocal() }
}
if (onlyBookmarked()) {
observable = observable.filter { it.bookmark }
}
val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) {
Manga.SORTING_SOURCE -> when (sortDescending()) {
true -> { c1, c2 -> c1.source_order.compareTo(c2.source_order) }
false -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) }
}
Manga.SORTING_NUMBER -> when (sortDescending()) {
true -> { c1, c2 -> c2.chapter_number.compareTo(c1.chapter_number) }
false -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) }
}
Manga.SORTING_UPLOAD_DATE -> when (sortDescending()) {
true -> { c1, c2 -> c2.date_upload.compareTo(c1.date_upload) }
false -> { c1, c2 -> c1.date_upload.compareTo(c2.date_upload) }
}
else -> throw NotImplementedError("Unimplemented sorting method")
}
return observable.toSortedList(sortFunction)
}
/**
* Called when a download for the active manga changes status.
* @param download the download whose status changed.
*/
private fun onDownloadStatusChange(download: Download) {
// Assign the download to the model object.
if (download.status == Download.QUEUE) {
chapters.find { it.id == download.chapter.id }?.let {
if (it.download == null) {
it.download = download
}
}
}
// Force UI update if downloaded filter active and download finished.
if (onlyDownloaded() && download.status == Download.DOWNLOADED) {
refreshChapters()
}
}
/**
* Returns the next unread chapter or null if everything is read.
*/
fun getNextUnreadChapter(): ChapterItem? {
return chapters.sortedByDescending { it.source_order }.find { !it.read }
}
/**
* Mark the selected chapter list as read/unread.
* @param selectedChapters the list of selected chapters.
* @param read whether to mark chapters as read or unread.
*/
fun markChaptersRead(selectedChapters: List<ChapterItem>, read: Boolean) {
Observable.from(selectedChapters)
.doOnNext { chapter ->
chapter.read = read
if (!read /* --> EH */ && !preferences
.eh_preserveReadingPosition()
.get() /* <-- EH */
) {
chapter.last_page_read = 0
}
}
.toList()
.flatMap { db.updateChaptersProgress(it).asRxObservable() }
.subscribeOn(Schedulers.io())
.subscribe()
}
/**
* Downloads the given list of chapters with the manager.
* @param chapters the list of chapters to download.
*/
fun downloadChapters(chapters: List<Chapter>) {
downloadManager.downloadChapters(manga, chapters)
}
/**
* Bookmarks the given list of chapters.
* @param selectedChapters the list of chapters to bookmark.
*/
fun bookmarkChapters(selectedChapters: List<ChapterItem>, bookmarked: Boolean) {
Observable.from(selectedChapters)
.doOnNext { chapter ->
chapter.bookmark = bookmarked
}
.toList()
.flatMap { db.updateChaptersProgress(it).asRxObservable() }
.subscribeOn(Schedulers.io())
.subscribe()
}
/**
* Deletes the given list of chapter.
* @param chapters the list of chapters to delete.
*/
fun deleteChapters(chapters: List<ChapterItem>) {
Observable.just(chapters)
.doOnNext { deleteChaptersInternal(chapters) }
.doOnNext { if (onlyDownloaded()) refreshChapters() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ ->
view.onChaptersDeleted(chapters)
},
ChaptersController::onChaptersDeletedError
)
}
private fun downloadNewChapters(chapters: List<Chapter>) {
if (chapters.isEmpty() || !manga.shouldDownloadNewChapters(db, preferences) /* SY --> */ || manga.source == EH_SOURCE_ID || manga.source == EXH_SOURCE_ID/* SY <-- */) return
downloadChapters(chapters)
}
/**
* Deletes a list of chapters from disk. This method is called in a background thread.
* @param chapters the chapters to delete.
*/
private fun deleteChaptersInternal(chapters: List<ChapterItem>) {
downloadManager.deleteChapters(chapters, manga, source)
chapters.forEach {
it.status = Download.NOT_DOWNLOADED
it.download = null
}
}
/**
* Reverses the sorting and requests an UI update.
*/
fun revertSortOrder() {
manga.setChapterOrder(if (sortDescending()) Manga.SORT_ASC else Manga.SORT_DESC)
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Sets the read filter and requests an UI update.
* @param onlyUnread whether to display only unread chapters or all chapters.
*/
fun setUnreadFilter(onlyUnread: Boolean) {
manga.readFilter = if (onlyUnread) Manga.SHOW_UNREAD else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Sets the read filter and requests an UI update.
* @param onlyRead whether to display only read chapters or all chapters.
*/
fun setReadFilter(onlyRead: Boolean) {
manga.readFilter = if (onlyRead) Manga.SHOW_READ else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Sets the download filter and requests an UI update.
* @param onlyDownloaded whether to display only downloaded chapters or all chapters.
*/
fun setDownloadedFilter(onlyDownloaded: Boolean) {
manga.downloadedFilter = if (onlyDownloaded) Manga.SHOW_DOWNLOADED else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Sets the bookmark filter and requests an UI update.
* @param onlyBookmarked whether to display only bookmarked chapters or all chapters.
*/
fun setBookmarkedFilter(onlyBookmarked: Boolean) {
manga.bookmarkedFilter = if (onlyBookmarked) Manga.SHOW_BOOKMARKED else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Removes all filters and requests an UI update.
*/
fun removeFilters() {
manga.readFilter = Manga.SHOW_ALL
manga.downloadedFilter = Manga.SHOW_ALL
manga.bookmarkedFilter = Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Adds manga to library
*/
fun addToLibrary() {
mangaFavoriteRelay.call(true)
}
/**
* Sets the active display mode.
* @param mode the mode to set.
*/
fun setDisplayMode(mode: Int) {
manga.displayMode = mode
db.updateFlags(manga).executeAsBlocking()
}
/**
* Sets the sorting method and requests an UI update.
* @param sort the sorting mode.
*/
fun setSorting(sort: Int) {
manga.sorting = sort
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Whether downloaded only mode is enabled.
*/
fun forceDownloaded(): Boolean {
return manga.favorite && preferences.downloadedOnly().get()
}
/**
* Whether the display only downloaded filter is enabled.
*/
fun onlyDownloaded(): Boolean {
return forceDownloaded() || manga.downloadedFilter == Manga.SHOW_DOWNLOADED
}
/**
* Whether the display only downloaded filter is enabled.
*/
fun onlyBookmarked(): Boolean {
return manga.bookmarkedFilter == Manga.SHOW_BOOKMARKED
}
/**
* Whether the display only unread filter is enabled.
*/
fun onlyUnread(): Boolean {
return manga.readFilter == Manga.SHOW_UNREAD
}
/**
* Whether the display only read filter is enabled.
*/
fun onlyRead(): Boolean {
return manga.readFilter == Manga.SHOW_READ
}
/**
* Whether the sorting method is descending or ascending.
*/
fun sortDescending(): Boolean {
return manga.sortDescending()
}
}
@@ -0,0 +1,47 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.MangaChaptersHeaderBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
class MangaChaptersHeaderAdapter :
RecyclerView.Adapter<MangaChaptersHeaderAdapter.HeaderViewHolder>() {
private var numChapters: Int? = null
private val scope = CoroutineScope(Job() + Dispatchers.Main)
private lateinit var binding: MangaChaptersHeaderBinding
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
binding = MangaChaptersHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return HeaderViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) {
holder.bind()
}
fun setNumChapters(numChapters: Int) {
this.numChapters = numChapters
notifyDataSetChanged()
}
inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
binding.chaptersLabel.text = if (numChapters == null) {
view.context.getString(R.string.chapters)
} else {
view.context.resources.getQuantityString(R.plurals.manga_num_chapters, numChapters!!, numChapters)
}
}
}
}
@@ -0,0 +1,24 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
import kotlin.math.min
/**
* A custom ImageView for holding a manga cover with:
* - width: min(maxWidth attr, 33% of parent width)
* - height: 2:3 width:height ratio
*
* Should be defined with a width of match_parent.
*/
class MangaCoverImageView(context: Context, attrs: AttributeSet?) : AppCompatImageView(context, attrs) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = min(maxWidth, measuredWidth / 3)
val height = width / 2 * 3
setMeasuredDimension(width, height)
}
}
@@ -1,713 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.google.gson.Gson
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.toMangaThumbnail
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.MangaInfoControllerBinding
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.all.MergedSource
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationController
import eu.kanade.tachiyomi.ui.browse.source.SourceController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.library.LibraryController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.recent.history.HistoryController
import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.gone
import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.util.view.visible
import eu.kanade.tachiyomi.util.view.visibleIf
import exh.EH_SOURCE_ID
import exh.EXH_SOURCE_ID
import exh.MERGED_SOURCE_ID
import exh.util.setChipsExtended
import java.text.DateFormat
import java.text.DecimalFormat
import java.util.Date
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import reactivecircus.flowbinding.android.view.clicks
import reactivecircus.flowbinding.android.view.longClicks
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
/**
* Fragment that shows manga information.
* Uses R.layout.manga_info_controller.
* UI related actions should be called from here.
*/
class MangaInfoController(private val fromSource: Boolean = false) :
NucleusController<MangaInfoControllerBinding, MangaInfoPresenter>(),
ChangeMangaCategoriesDialog.Listener,
CoroutineScope {
private val preferences: PreferencesHelper by injectLazy()
private val dateFormat: DateFormat by lazy {
preferences.dateFormat()
}
private var initialLoad: Boolean = true
// EXH -->
private var lastMangaThumbnail: String? = null
private val smartSearchConfig get() = (parentController as MangaController).smartSearchConfig
override val coroutineContext: CoroutineContext = Job() + Dispatchers.Main
private val gson: Gson by injectLazy()
private val sourceManager: SourceManager by injectLazy()
// EXH <--
override fun createPresenter(): MangaInfoPresenter {
val ctrl = parentController as MangaController
return MangaInfoPresenter(
ctrl.manga!!, ctrl.source!!,
ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay, ctrl.smartSearchConfig
)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
binding = MangaInfoControllerBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
// For rounded corners
binding.mangaCover.clipToOutline = true
binding.btnFavorite.clicks()
.onEach { onFavoriteClick() }
.launchIn(scope)
if (presenter.manga.favorite && presenter.getCategories().isNotEmpty()) {
binding.btnCategories.visible()
}
binding.btnCategories.clicks()
.onEach { onCategoriesClick() }
.launchIn(scope)
if (presenter.source is HttpSource) {
binding.btnWebview.visible()
binding.btnShare.visible()
binding.btnWebview.clicks()
.onEach { openInWebView() }
.launchIn(scope)
binding.btnShare.clicks()
.onEach { shareManga() }
.launchIn(scope)
}
if (presenter.manga.favorite) {
binding.btnMigrate.visible()
binding.btnSmartSearch.visible()
}
binding.btnMigrate.clicks()
.onEach {
PreMigrationController.navigateToMigration(
preferences.skipPreMigration().get(),
router,
listOf(presenter.manga.id!!)
)
}
.launchIn(scope)
binding.btnSmartSearch.clicks()
.onEach { openSmartSearch() }
.launchIn(scope)
// Set SwipeRefresh to refresh manga data.
binding.swipeRefresh.refreshes()
.onEach { fetchMangaFromSource(manualFetch = true) }
.launchIn(scope)
binding.mangaFullTitle.longClicks()
.onEach {
activity?.copyToClipboard(
view.context.getString(R.string.title),
binding.mangaFullTitle.text.toString()
)
}
.launchIn(scope)
binding.mangaFullTitle.clicks()
.onEach {
performGlobalSearch(binding.mangaFullTitle.text.toString())
}
.launchIn(scope)
binding.mangaAuthor.longClicks()
.onEach {
// EXH Special case E-Hentai/ExHentai to ignore author field (unused)
if (!isEHentaiBasedSource()) {
activity?.copyToClipboard(
binding.mangaAuthor.text.toString(),
binding.mangaAuthor.text.toString()
)
}
}
.launchIn(scope)
binding.mangaAuthor.clicks()
.onEach {
// EXH Special case E-Hentai/ExHentai to ignore author field (unused)
if (!isEHentaiBasedSource()) {
performGlobalSearch(binding.mangaAuthor.text.toString())
}
}
.launchIn(scope)
binding.mangaSummary.longClicks()
.onEach {
activity?.copyToClipboard(
view.context.getString(R.string.description),
binding.mangaSummary.text.toString()
)
}
.launchIn(scope)
binding.mangaCover.longClicks()
.onEach {
activity?.copyToClipboard(
view.context.getString(R.string.title),
presenter.manga.title
)
}
.launchIn(scope)
// EXH -->
if (smartSearchConfig == null) {
binding.recommendBtn.visible()
binding.recommendBtn.clicks()
.onEach { openRecommends() }
.launchIn(scope)
}
smartSearchConfig?.let { smartSearchConfig ->
if (smartSearchConfig.origMangaId != null) { binding.mergeBtn.visible() }
binding.mergeBtn.clicks()
.onEach {
// Init presenter here to avoid threading issues
presenter
launch {
try {
val mergedManga = withContext(Dispatchers.IO + NonCancellable) {
presenter.smartSearchMerge(presenter.manga, smartSearchConfig.origMangaId!!)
}
parentController?.router?.pushController(
MangaController(
mergedManga,
true,
update = true
).withFadeTransaction()
)
applicationContext?.toast("Manga merged!")
} catch (e: Exception) {
if (e is CancellationException) throw e
else {
applicationContext?.toast("Failed to merge manga: ${e.message}")
}
}
}
}
.launchIn(scope)
}
// EXH <--
}
// EXH -->
private fun openSmartSearch() {
val smartSearchConfig = SourceController.SmartSearchConfig(presenter.manga.originalTitle, presenter.manga.id!!)
parentController?.router?.pushController(
SourceController(
Bundle().apply {
putParcelable(SourceController.SMART_SEARCH_CONFIG, smartSearchConfig)
}
).withFadeTransaction()
)
}
// EXH <--
// AZ -->
private fun openRecommends() {
val recommendsConfig = BrowseSourceController.RecommendsConfig(presenter.manga)
parentController?.router?.pushController(
BrowseSourceController(
Bundle().apply {
putParcelable(BrowseSourceController.RECOMMENDS_CONFIG, recommendsConfig)
}
).withFadeTransaction()
)
}
// AZ <--
/**
* Check if manga is initialized.
* If true update view with manga information,
* if false fetch manga information
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
fun onNextManga(manga: Manga, source: Source) {
if (manga.initialized) {
// Update view.
setMangaInfo(manga, source)
} else {
// Initialize manga.
fetchMangaFromSource()
}
}
/**
* Update the view with manga information.
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
private fun setMangaInfo(manga: Manga, source: Source?) {
val view = view ?: return
// update full title TextView.
binding.mangaFullTitle.text = if (manga.title.isBlank()) {
view.context.getString(R.string.unknown)
} else {
manga.title
}
// Update author/artist TextView.
val authors = listOf(manga.author, manga.artist).filter { !it.isNullOrBlank() }.distinct()
binding.mangaAuthor.text = if (authors.isEmpty()) {
view.context.getString(R.string.unknown)
} else {
authors.joinToString(", ")
}
// If manga source is known update source TextView.
val mangaSource = source?.toString()
with(binding.mangaSource) {
// EXH -->
if (mangaSource == null) {
text = view.context.getString(R.string.unknown)
} else if (source.id == MERGED_SOURCE_ID) {
text = MergedSource.MangaConfig.readFromUrl(gson, manga.url).children.map {
sourceManager.getOrStub(it.source).toString()
}.distinct().joinToString()
} else {
text = mangaSource
setOnClickListener {
val sourceManager = Injekt.get<SourceManager>()
performSearch(sourceManager.getOrStub(source.id).name)
}
}
// EXH <--
}
// EXH -->
if (source?.id == MERGED_SOURCE_ID) {
binding.mangaSourceLabel.text = "Sources"
} else {
binding.mangaSourceLabel.setText(R.string.manga_info_source_label)
}
// EXH <--
// Update status TextView.
binding.mangaStatus.setText(
when (manga.status) {
SManga.ONGOING -> R.string.ongoing
SManga.COMPLETED -> R.string.completed
SManga.LICENSED -> R.string.licensed
else -> R.string.unknown
}
)
// Set the favorite drawable to the correct one.
setFavoriteButtonState(manga.favorite)
// Set cover if it wasn't already.
val mangaThumbnail = manga.toMangaThumbnail()
GlideApp.with(view.context)
.load(mangaThumbnail)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.into(binding.mangaCover)
binding.backdrop?.let {
GlideApp.with(view.context)
.load(mangaThumbnail)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.into(it)
}
// Manga info section
if (manga.description.isNullOrBlank() && manga.genre.isNullOrBlank()) {
hideMangaInfo()
} else {
// Update description TextView.
binding.mangaSummary.text = if (manga.description.isNullOrBlank()) {
view.context.getString(R.string.unknown)
} else {
manga.description
}
// Update genres list
if (!manga.genre.isNullOrBlank()) {
binding.mangaGenresTagsCompactChips.setChipsExtended(manga.getGenres(), this::performSearch, this::performGlobalSearch, manga.source)
binding.mangaGenresTagsFullChips.setChipsExtended(manga.getGenres(), this::performSearch, this::performGlobalSearch, manga.source)
} else {
binding.mangaGenresTagsWrapper.gone()
}
// Handle showing more or less info
binding.mangaSummary.clicks()
.onEach { toggleMangaInfo(view.context) }
.launchIn(scope)
binding.mangaInfoToggle.clicks()
.onEach { toggleMangaInfo(view.context) }
.launchIn(scope)
// Expand manga info if navigated from source listing
if (initialLoad && fromSource) {
toggleMangaInfo(view.context)
initialLoad = false
}
}
}
private fun hideMangaInfo() {
binding.mangaSummaryLabel.gone()
binding.mangaSummary.gone()
binding.mangaGenresTagsWrapper.gone()
binding.mangaInfoToggle.gone()
}
private fun toggleMangaInfo(context: Context) {
val isExpanded =
binding.mangaInfoToggle.text == context.getString(R.string.manga_info_collapse)
binding.mangaInfoToggle.text =
if (isExpanded) {
context.getString(R.string.manga_info_expand)
} else {
context.getString(R.string.manga_info_collapse)
}
with(binding.mangaSummary) {
maxLines =
if (isExpanded) {
3
} else {
Int.MAX_VALUE
}
ellipsize =
if (isExpanded) {
TextUtils.TruncateAt.END
} else {
null
}
}
binding.mangaGenresTagsCompact.visibleIf { isExpanded }
binding.mangaGenresTagsFullChips.visibleIf { !isExpanded }
}
/**
* Update chapter count TextView.
*
* @param count number of chapters.
*/
fun setChapterCount(count: Float) {
if (count > 0f) {
binding.mangaChapters.text = DecimalFormat("#.#").format(count)
} else {
binding.mangaChapters.text = resources?.getString(R.string.unknown)
}
}
fun setLastUpdateDate(date: Date) {
if (date.time != 0L) {
binding.mangaLastUpdate.text = dateFormat.format(date)
} else {
binding.mangaLastUpdate.text = resources?.getString(R.string.unknown)
}
}
/**
* Toggles the favorite status and asks for confirmation to delete downloaded chapters.
*/
private fun toggleFavorite() {
val view = view
val isNowFavorite = presenter.toggleFavorite()
if (view != null && !isNowFavorite && presenter.hasDownloads()) {
view.snack(view.context.getString(R.string.delete_downloads_for_manga)) {
setAction(R.string.action_delete) {
presenter.deleteDownloads()
}
}
}
binding.btnCategories.visibleIf { isNowFavorite && presenter.getCategories().isNotEmpty() }
if (isNowFavorite) {
binding.btnSmartSearch.visible()
binding.btnMigrate.visible()
} else {
binding.btnSmartSearch.gone()
binding.btnMigrate.gone()
}
}
private fun openInWebView() {
val source = presenter.source as? HttpSource ?: return
val url = try {
source.mangaDetailsRequest(presenter.manga).url.toString()
} catch (e: Exception) {
return
}
val activity = activity ?: return
val intent = WebViewActivity.newIntent(activity, url, source.id, presenter.manga.title)
startActivity(intent)
}
/**
* Called to run Intent with [Intent.ACTION_SEND], which show share dialog.
*/
private fun shareManga() {
val context = view?.context ?: return
val source = presenter.source as? HttpSource ?: return
try {
val url = source.mangaDetailsRequest(presenter.manga).url.toString()
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
}
startActivity(Intent.createChooser(intent, context.getString(R.string.action_share)))
} catch (e: Exception) {
context.toast(e.message)
}
}
/**
* Update favorite button with correct drawable and text.
*
* @param isFavorite determines if manga is favorite or not.
*/
private fun setFavoriteButtonState(isFavorite: Boolean) {
// Set the Favorite drawable to the correct one.
// Border drawable if false, filled drawable if true.
binding.btnFavorite.apply {
icon = ContextCompat.getDrawable(
context,
if (isFavorite) R.drawable.ic_favorite_24dp else R.drawable.ic_favorite_border_24dp
)
text =
context.getString(if (isFavorite) R.string.in_library else R.string.add_to_library)
isChecked = isFavorite
}
}
/**
* Start fetching manga information from source.
*/
private fun fetchMangaFromSource(manualFetch: Boolean = false) {
setRefreshing(true)
// Call presenter and start fetching manga information
presenter.fetchMangaFromSource(manualFetch)
}
/**
* Update swipe refresh to stop showing refresh in progress spinner.
*/
fun onFetchMangaDone() {
setRefreshing(false)
}
/**
* Update swipe refresh to start showing refresh in progress spinner.
*/
fun onFetchMangaError(error: Throwable) {
setRefreshing(false)
activity?.toast(error.message)
}
/**
* Set swipe refresh status.
*
* @param value whether it should be refreshing or not.
*/
private fun setRefreshing(value: Boolean) {
binding.swipeRefresh.isRefreshing = value
}
private fun onFavoriteClick() {
val manga = presenter.manga
if (manga.favorite) {
toggleFavorite()
activity?.toast(activity?.getString(R.string.manga_removed_library))
} else {
val categories = presenter.getCategories()
val defaultCategoryId = preferences.defaultCategory()
val defaultCategory = categories.find { it.id == defaultCategoryId }
when {
// Default category set
defaultCategory != null -> {
toggleFavorite()
presenter.moveMangaToCategory(manga, defaultCategory)
activity?.toast(activity?.getString(R.string.manga_added_library))
}
// Automatic 'Default' or no categories
defaultCategoryId == 0 || categories.isEmpty() -> {
toggleFavorite()
presenter.moveMangaToCategory(manga, null)
activity?.toast(activity?.getString(R.string.manga_added_library))
}
// Choose a category
else -> {
val ids = presenter.getMangaCategoryIds(manga)
val preselected = ids.mapNotNull { id ->
categories.indexOfFirst { it.id == id }.takeIf { it != -1 }
}.toTypedArray()
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router)
}
}
}
}
private fun onCategoriesClick() {
val manga = presenter.manga
val categories = presenter.getCategories()
val ids = presenter.getMangaCategoryIds(manga)
val preselected = ids.mapNotNull { id ->
categories.indexOfFirst { it.id == id }.takeIf { it != -1 }
}.toTypedArray()
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router)
}
override fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>) {
val manga = mangas.firstOrNull() ?: return
if (!manga.favorite) {
toggleFavorite()
activity?.toast(activity?.getString(R.string.manga_added_library))
}
presenter.moveMangaToCategories(manga, categories)
}
/**
* Perform a global search using the provided query.
*
* @param query the search query to pass to the search controller
*/
private fun performGlobalSearch(query: String) {
val router = parentController?.router ?: return
router.pushController(GlobalSearchController(query).withFadeTransaction())
}
// --> EH
private fun wrapTag(namespace: String, tag: String) =
if (tag.contains(' ')) {
"$namespace:\"$tag$\""
} else {
"$namespace:$tag$"
}
private fun parseTag(tag: String) = tag.substringBefore(':').trim() to tag.substringAfter(':').trim()
private fun isEHentaiBasedSource(): Boolean {
val sourceId = presenter.source.id
return sourceId == EH_SOURCE_ID ||
sourceId == EXH_SOURCE_ID
}
// <-- EH
/**
* Perform a search using the provided query.
*
* @param query the search query to the parent controller
*/
private fun performSearch(query: String) {
val router = parentController?.router ?: return
if (router.backstackSize < 2) {
return
}
when (val previousController = router.backstack[router.backstackSize - 2].controller()) {
is LibraryController -> {
router.handleBack()
previousController.search(query)
}
is UpdatesController,
is HistoryController -> {
// Manually navigate to LibraryController
router.handleBack()
(router.activity as MainActivity).setSelectedNavItem(R.id.nav_library)
val controller = router.getControllerWithTag(R.id.nav_library.toString()) as LibraryController
controller.search(query)
}
is BrowseSourceController -> {
router.handleBack()
previousController.searchWithQuery(query)
}
}
}
}
@@ -0,0 +1,397 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.content.Context
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.data.glide.MangaThumbnail
import eu.kanade.tachiyomi.data.glide.toMangaThumbnail
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.databinding.MangaInfoHeaderBinding
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.all.MergedSource
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.view.gone
import eu.kanade.tachiyomi.util.view.setChips
import eu.kanade.tachiyomi.util.view.setTooltip
import eu.kanade.tachiyomi.util.view.visible
import eu.kanade.tachiyomi.util.view.visibleIf
import exh.MERGED_SOURCE_ID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
import reactivecircus.flowbinding.android.view.longClicks
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MangaInfoHeaderAdapter(
private val controller: MangaController,
private val fromSource: Boolean
) :
RecyclerView.Adapter<MangaInfoHeaderAdapter.HeaderViewHolder>() {
private var manga: Manga = controller.presenter.manga
private var source: Source = controller.presenter.source
private val scope = CoroutineScope(Job() + Dispatchers.Main)
private lateinit var binding: MangaInfoHeaderBinding
private var initialLoad: Boolean = true
private var currentMangaThumbnail: MangaThumbnail? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
binding = MangaInfoHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return HeaderViewHolder(binding.root)
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) {
holder.bind()
}
/**
* Update the view with manga information.
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
fun update(manga: Manga, source: Source) {
this.manga = manga
this.source = source
notifyDataSetChanged()
}
inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind() {
// For rounded corners
binding.mangaCover.clipToOutline = true
binding.btnFavorite.clicks()
.onEach { controller.onFavoriteClick() }
.launchIn(scope)
if (controller.presenter.manga.favorite && Injekt.get<TrackManager>().hasLoggedServices()) {
binding.btnTracking.visible()
binding.btnTracking.clicks()
.onEach { controller.onTrackingClick() }
.launchIn(scope)
} else {
binding.btnTracking.gone()
}
if (controller.presenter.manga.favorite && controller.presenter.getCategories().isNotEmpty()) {
binding.btnCategories.visible()
binding.btnCategories.clicks()
.onEach { controller.onCategoriesClick() }
.launchIn(scope)
binding.btnCategories.setTooltip(R.string.action_move_category)
} else {
binding.btnCategories.gone()
}
if (controller.presenter.source is HttpSource) {
binding.btnWebview.visible()
binding.btnWebview.clicks()
.onEach { controller.openMangaInWebView() }
.launchIn(scope)
binding.btnWebview.setTooltip(R.string.action_open_in_web_view)
binding.btnShare.visible()
binding.btnShare.clicks()
.onEach { controller.shareManga() }
.launchIn(scope)
binding.btnShare.setTooltip(R.string.action_share)
}
// SY -->
if (controller.presenter.manga.favorite) {
binding.btnMigrate.visible()
binding.btnMigrate.clicks()
.onEach { controller.migrateManga() }
.launchIn(scope)
binding.btnMigrate.setTooltip(R.string.migrate)
binding.btnSmartSearch.visible()
binding.btnSmartSearch.clicks()
.onEach { controller.openSmartSearch() }
.launchIn(scope)
binding.btnSmartSearch.setTooltip(R.string.eh_merge_with_another_source)
}
// SY <--
binding.mangaFullTitle.longClicks()
.onEach {
controller.activity?.copyToClipboard(
view.context.getString(R.string.title),
binding.mangaFullTitle.text.toString()
)
}
.launchIn(scope)
binding.mangaFullTitle.clicks()
.onEach {
controller.performGlobalSearch(binding.mangaFullTitle.text.toString())
}
.launchIn(scope)
binding.mangaAuthor.longClicks()
.onEach {
controller.activity?.copyToClipboard(
binding.mangaAuthor.text.toString(),
binding.mangaAuthor.text.toString()
)
}
.launchIn(scope)
binding.mangaAuthor.clicks()
.onEach {
controller.performGlobalSearch(binding.mangaAuthor.text.toString())
}
.launchIn(scope)
binding.mangaArtist.longClicks()
.onEach {
controller.activity?.copyToClipboard(
binding.mangaArtist.text.toString(),
binding.mangaArtist.text.toString()
)
}
.launchIn(scope)
binding.mangaArtist.clicks()
.onEach {
controller.performGlobalSearch(binding.mangaArtist.text.toString())
}
.launchIn(scope)
binding.mangaSummary.longClicks()
.onEach {
controller.activity?.copyToClipboard(
view.context.getString(R.string.description),
binding.mangaSummary.text.toString()
)
}
.launchIn(scope)
binding.mangaCover.longClicks()
.onEach {
controller.activity?.copyToClipboard(
view.context.getString(R.string.title),
controller.presenter.manga.title
)
}
.launchIn(scope)
// EXH -->
if (controller.smartSearchConfig == null) {
binding.recommendBtn.visible()
binding.recommendBtn.clicks()
.onEach { controller.openRecommends() }
.launchIn(scope)
} else {
if (controller.smartSearchConfig.origMangaId != null) { binding.mergeBtn.visible() }
binding.mergeBtn.clicks()
.onEach {
controller.mergeWithAnother()
}
.launchIn(scope)
}
// EXH <--
setMangaInfo(manga, source)
}
/**
* Update the view with manga information.
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
private fun setMangaInfo(manga: Manga, source: Source?) {
// Update full title TextView.
binding.mangaFullTitle.text = if (manga.title.isBlank()) {
view.context.getString(R.string.unknown)
} else {
manga.title
}
// Update author TextView.
binding.mangaAuthor.text = if (manga.author.isNullOrBlank()) {
view.context.getString(R.string.unknown_author)
} else {
manga.author
}
// Update artist TextView.
val hasArtist = !manga.artist.isNullOrBlank() && manga.artist != manga.author
binding.mangaArtist.isVisible = hasArtist
if (hasArtist) {
binding.mangaArtist.text = manga.artist
}
// If manga source is known update source TextView.
val mangaSource = source?.toString()
with(binding.mangaSource) {
// SY -->
if (source != null && source.id == MERGED_SOURCE_ID) {
text = MergedSource.MangaConfig.readFromUrl(Injekt.get(), manga.url).children.map {
Injekt.get<SourceManager>().getOrStub(it.source).toString()
}.distinct().joinToString()
} else /* SY <-- */ if (mangaSource != null) {
text = mangaSource
setOnClickListener {
val sourceManager = Injekt.get<SourceManager>()
controller.performSearch(sourceManager.getOrStub(source.id).name)
}
} else {
text = view.context.getString(R.string.unknown)
}
}
// Update status TextView.
binding.mangaStatus.setText(
when (manga.status) {
SManga.ONGOING -> R.string.ongoing
SManga.COMPLETED -> R.string.completed
SManga.LICENSED -> R.string.licensed
else -> R.string.unknown_status
}
)
// Set the favorite drawable to the correct one.
setFavoriteButtonState(manga.favorite)
// Set cover if changed.
val mangaThumbnail = manga.toMangaThumbnail()
if (mangaThumbnail != currentMangaThumbnail) {
currentMangaThumbnail = mangaThumbnail
listOf(binding.mangaCover, binding.backdrop)
.forEach {
GlideApp.with(view.context)
.load(mangaThumbnail)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.into(it)
}
}
// Manga info section
val hasInfoContent = !manga.description.isNullOrBlank() || !manga.genre.isNullOrBlank()
showMangaInfo(hasInfoContent)
if (hasInfoContent) {
// Update description TextView.
binding.mangaSummary.text = if (manga.description.isNullOrBlank()) {
view.context.getString(R.string.unknown)
} else {
manga.description
}
// Update genres list
if (!manga.genre.isNullOrBlank()) {
binding.mangaGenresTagsCompactChips.setChips(manga.getGenres(), controller::performSearch)
binding.mangaGenresTagsFullChips.setChips(manga.getGenres(), controller::performSearch)
} else {
binding.mangaGenresTagsWrapper.gone()
}
// Handle showing more or less info
binding.mangaSummary.clicks()
.onEach { toggleMangaInfo(view.context) }
.launchIn(scope)
binding.mangaInfoToggle.clicks()
.onEach { toggleMangaInfo(view.context) }
.launchIn(scope)
// Expand manga info if navigated from source listing
if (initialLoad && fromSource) {
toggleMangaInfo(view.context)
initialLoad = false
}
}
binding.btnCategories.visibleIf { manga.favorite && controller.presenter.getCategories().isNotEmpty() }
}
private fun showMangaInfo(visible: Boolean) {
binding.mangaSummaryLabel.visibleIf { visible }
binding.mangaSummary.visibleIf { visible }
binding.mangaGenresTagsWrapper.visibleIf { visible }
binding.mangaInfoToggle.visibleIf { visible }
}
private fun toggleMangaInfo(context: Context) {
val isExpanded =
binding.mangaInfoToggle.text == context.getString(R.string.manga_info_collapse)
with(binding.mangaInfoToggle) {
text = if (isExpanded) {
context.getString(R.string.manga_info_expand)
} else {
context.getString(R.string.manga_info_collapse)
}
icon = if (isExpanded) {
context.getDrawable(R.drawable.ic_baseline_expand_more_24dp)
} else {
context.getDrawable(R.drawable.ic_baseline_expand_less_24dp)
}
}
with(binding.mangaSummary) {
maxLines =
if (isExpanded) {
2
} else {
Int.MAX_VALUE
}
ellipsize =
if (isExpanded) {
TextUtils.TruncateAt.END
} else {
null
}
}
binding.mangaGenresTagsCompact.visibleIf { isExpanded }
binding.mangaGenresTagsFullChips.visibleIf { !isExpanded }
}
/**
* Update favorite button with correct drawable and text.
*
* @param isFavorite determines if manga is favorite or not.
*/
private fun setFavoriteButtonState(isFavorite: Boolean) {
// Set the Favorite drawable to the correct one.
// Border drawable if false, filled drawable if true.
binding.btnFavorite.apply {
icon = ContextCompat.getDrawable(
context,
if (isFavorite) R.drawable.ic_favorite_24dp else R.drawable.ic_favorite_border_24dp
)
text =
context.getString(if (isFavorite) R.string.in_library else R.string.add_to_library)
isChecked = isFavorite
}
}
}
}
@@ -1,244 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.os.Bundle
import com.google.gson.Gson
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.all.MergedSource
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.browse.source.SourceController
import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
import eu.kanade.tachiyomi.util.prepUpdateCover
import eu.kanade.tachiyomi.util.removeCovers
import exh.MERGED_SOURCE_ID
import exh.util.await
import java.util.Date
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Presenter of MangaInfoFragment.
* Contains information and data for fragment.
* Observable updates should be called from here.
*/
class MangaInfoPresenter(
val manga: Manga,
val source: Source,
private val chapterCountRelay: BehaviorRelay<Float>,
private val lastUpdateRelay: BehaviorRelay<Date>,
private val mangaFavoriteRelay: PublishRelay<Boolean>,
val smartSearchConfig: SourceController.SmartSearchConfig?,
private val db: DatabaseHelper = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(),
private val gson: Gson = Injekt.get()
) : BasePresenter<MangaInfoController>() {
/**
* Subscription to update the manga from the source.
*/
private var fetchMangaSubscription: Subscription? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
getMangaObservable()
.subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) })
// Update chapter count
chapterCountRelay.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(MangaInfoController::setChapterCount)
// Update favorite status
mangaFavoriteRelay.observeOn(AndroidSchedulers.mainThread())
.subscribe { setFavorite(it) }
.apply { add(this) }
// update last update date
lastUpdateRelay.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(MangaInfoController::setLastUpdateDate)
}
private fun getMangaObservable(): Observable<Manga> {
return db.getManga(manga.url, manga.source).asRxObservable()
.observeOn(AndroidSchedulers.mainThread())
}
/**
* Fetch manga information from source.
*/
fun fetchMangaFromSource(manualFetch: Boolean = false) {
if (!fetchMangaSubscription.isNullOrUnsubscribed()) return
fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) }
.map { networkManga ->
manga.prepUpdateCover(coverCache, networkManga, manualFetch)
manga.copyFrom(networkManga)
manga.initialized = true
db.insertManga(manga).executeAsBlocking()
manga
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, _ ->
view.onFetchMangaDone()
},
MangaInfoController::onFetchMangaError
)
}
/**
* Update favorite status of manga, (removes / adds) manga (to / from) library.
*
* @return the new status of the manga.
*/
fun toggleFavorite(): Boolean {
manga.favorite = !manga.favorite
if (!manga.favorite) {
manga.removeCovers(coverCache)
}
db.insertManga(manga).executeAsBlocking()
return manga.favorite
}
private fun setFavorite(favorite: Boolean) {
if (manga.favorite == favorite) {
return
}
toggleFavorite()
}
/**
* Returns true if the manga has any downloads.
*/
fun hasDownloads(): Boolean {
return downloadManager.getDownloadCount(manga) > 0
}
/**
* Deletes all the downloads for the manga.
*/
fun deleteDownloads() {
downloadManager.deleteManga(manga, source)
}
/**
* Get user categories.
*
* @return List of categories, not including the default category
*/
fun getCategories(): List<Category> {
return db.getCategories().executeAsBlocking()
}
/**
* Gets the category id's the manga is in, if the manga is not in a category, returns the default id.
*
* @param manga the manga to get categories from.
* @return Array of category ids the manga is in, if none returns default id
*/
fun getMangaCategoryIds(manga: Manga): Array<Int> {
val categories = db.getCategoriesForManga(manga).executeAsBlocking()
return categories.mapNotNull { it.id }.toTypedArray()
}
/**
* Move the given manga to categories.
*
* @param manga the manga to move.
* @param categories the selected categories.
*/
fun moveMangaToCategories(manga: Manga, categories: List<Category>) {
val mc = categories.filter { it.id != 0 }.map { MangaCategory.create(manga, it) }
db.setMangaCategories(mc, listOf(manga))
}
/**
* Move the given manga to the category.
*
* @param manga the manga to move.
* @param category the selected category, or null for default category.
*/
fun moveMangaToCategory(manga: Manga, category: Category?) {
moveMangaToCategories(manga, listOfNotNull(category))
}
/*
suspend fun recommendationView(manga: Manga): Manga {
val title = manga.title
val source = manga.source
}*/
suspend fun smartSearchMerge(manga: Manga, originalMangaId: Long): Manga {
val originalManga = db.getManga(originalMangaId).await()
?: throw IllegalArgumentException("Unknown manga ID: $originalMangaId")
val toInsert = if (originalManga.source == MERGED_SOURCE_ID) {
originalManga.apply {
val originalChildren = MergedSource.MangaConfig.readFromUrl(gson, url).children
if (originalChildren.any { it.source == manga.source && it.url == manga.url }) {
throw IllegalArgumentException("This manga is already merged with the current manga!")
}
url = MergedSource.MangaConfig(
originalChildren + MergedSource.MangaSource(
manga.source,
manga.url
)
).writeAsUrl(gson)
}
} else {
val newMangaConfig = MergedSource.MangaConfig(
listOf(
MergedSource.MangaSource(
originalManga.source,
originalManga.url
),
MergedSource.MangaSource(
manga.source,
manga.url
)
)
)
Manga.create(newMangaConfig.writeAsUrl(gson), originalManga.originalTitle, MERGED_SOURCE_ID).apply {
copyFrom(originalManga)
favorite = true
last_update = originalManga.last_update
viewer = originalManga.viewer
chapter_flags = originalManga.chapter_flags
sorting = Manga.SORTING_NUMBER
}
}
// Note that if the manga are merged in a different order, this won't trigger, but I don't care lol
val existingManga = db.getManga(toInsert.url, toInsert.source).await()
if (existingManga != null) {
withContext(NonCancellable) {
if (toInsert.id != null) {
db.deleteManga(toInsert).await()
}
}
return existingManga
}
// Reload chapters immediately
toInsert.initialized = false
val newId = db.insertManga(toInsert).await().insertedId()
if (newId != null) toInsert.id = newId
return toInsert
}
}
@@ -2,31 +2,51 @@ package eu.kanade.tachiyomi.ui.manga.track
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.databinding.TrackControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class TrackController(val fromAllInOne: Boolean = false, val manga: Manga? = null) :
NucleusController<TrackControllerBinding, TrackPresenter>(),
class TrackController :
NucleusController<TrackControllerBinding, TrackPresenter>,
TrackAdapter.OnClickListener,
SetTrackStatusDialog.Listener,
SetTrackChaptersDialog.Listener,
SetTrackScoreDialog.Listener,
SetTrackReadingDatesDialog.Listener {
constructor(manga: Manga?) : super(
Bundle().apply {
putLong(MANGA_EXTRA, manga?.id ?: 0)
}
) {
this.manga = manga
}
constructor(mangaId: Long) : this(
Injekt.get<DatabaseHelper>().getManga(mangaId).executeAsBlocking()
)
@Suppress("unused")
constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA))
var manga: Manga? = null
private set
private var adapter: TrackAdapter? = null
init {
@@ -35,16 +55,12 @@ class TrackController(val fromAllInOne: Boolean = false, val manga: Manga? = nul
setHasOptionsMenu(true)
}
override fun getTitle(): String? {
return manga?.title
}
override fun createPresenter(): TrackPresenter {
// SY -->
return (
if (fromAllInOne && manga != null) {
TrackPresenter(manga)
} else {
TrackPresenter((parentController as MangaController).manga!!)
}
)
// SY <--
return TrackPresenter(manga!!)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
@@ -55,6 +71,8 @@ class TrackController(val fromAllInOne: Boolean = false, val manga: Manga? = nul
override fun onViewCreated(view: View) {
super.onViewCreated(view)
if (manga == null) return
adapter = TrackAdapter(this)
binding.trackRecycler.layoutManager = LinearLayoutManager(view.context)
binding.trackRecycler.adapter = adapter
@@ -73,13 +91,6 @@ class TrackController(val fromAllInOne: Boolean = false, val manga: Manga? = nul
val atLeastOneLink = trackings.any { it.track != null }
adapter?.items = trackings
binding.swipeRefresh.isEnabled = atLeastOneLink
// SY -->
if (!fromAllInOne) {
(parentController as? MangaController)?.setTrackingIcon(atLeastOneLink)
} else {
(parentController as? MangaAllInOneController)?.setTrackingIcon(atLeastOneLink)
}
// SY <--
}
fun onSearchResults(results: List<TrackSearch>) {
@@ -183,6 +194,7 @@ class TrackController(val fromAllInOne: Boolean = false, val manga: Manga? = nul
}
private companion object {
const val MANGA_EXTRA = "manga"
const val TAG_SEARCH_CONTROLLER = "track_search_controller"
}
}
@@ -36,7 +36,7 @@ class TrackSearchAdapter(context: Context) :
} else {
holder = v.tag as TrackSearchHolder
}
holder.onSetValues(track!!)
holder.onSetValues(track)
return v
}
@@ -84,7 +84,7 @@ class TrackSearchDialog : DialogController {
// Do an initial search based on the manga's title
if (savedState == null) {
val title = trackController.presenter.manga.originalTitle
val title = trackController.presenter.manga.title
view.track_search.append(title)
search(title)
}
@@ -68,7 +68,7 @@ class AboutController : SettingsController() {
}
}
preference {
titleRes = R.string.changelog
titleRes = R.string.whats_new
onClick {
// SY -->
@@ -24,6 +24,7 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
private set
var currentChapter: ReaderChapter? = null
/**
* Updates this adapter with the given [chapters]. It handles setting a few pages of the
* next/previous chapter to allow seamless transitions and inverting the pages if the viewer
@@ -13,14 +13,12 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupRestoreService
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.HistoryControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.RootController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.source.browse.ProgressItem
import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.system.toast
@@ -28,7 +26,6 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.appcompat.queryTextChanges
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
@@ -163,11 +160,7 @@ class HistoryController :
override fun onItemClick(position: Int) {
val manga = (adapter?.getItem(position) as? HistoryItem)?.mch?.manga ?: return
if (Injekt.get<PreferencesHelper>().eh_useNewMangaInterface().get()) {
router.pushController(MangaAllInOneController(manga).withFadeTransaction())
} else {
router.pushController(MangaController(manga).withFadeTransaction())
}
router.pushController(MangaController(manga).withFadeTransaction())
}
override fun removeHistory(manga: Manga, history: History, all: Boolean) {
@@ -17,7 +17,6 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.UpdatesControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
@@ -25,7 +24,6 @@ import eu.kanade.tachiyomi.ui.base.controller.RootController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.main.offsetAppbarHeight
import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.system.notificationManager
@@ -35,8 +33,6 @@ import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.recyclerview.scrollStateChanges
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Fragment that shows recent chapters.
@@ -287,13 +283,7 @@ class UpdatesController :
}
private fun openManga(chapter: UpdatesItem) {
// SY -->
if (Injekt.get<PreferencesHelper>().eh_useNewMangaInterface().get()) {
router.pushController(MangaAllInOneController(chapter.manga).withFadeTransaction())
} else {
router.pushController(MangaController(chapter.manga).withFadeTransaction())
}
// SY <--
router.pushController(MangaController(chapter.manga).withFadeTransaction())
}
/**
@@ -110,7 +110,7 @@ class SettingsAdvancedController : SettingsController() {
}
preferenceCategory {
titleRes = R.string.label_data
titleRes = R.string.label_network
preference {
titleRes = R.string.pref_clear_cookies
@@ -267,13 +267,6 @@ class SettingsGeneralController : SettingsController() {
"Use HIGHLY EXPERIMENTAL automatic ReCAPTCHA solver. Will be grayed out if unsupported by your device."
defaultValue = false
}
switchPreference {
key = Keys.eh_use_new_manga_interface
title = "Use New Manga Interface"
summary = "Use new all in one manga interface"
defaultValue = true
}
}
// <-- EXH
}
@@ -72,6 +72,11 @@ class SettingsLibraryController : SettingsController() {
}
.launchIn(scope)
}
switchPreference {
key = Keys.jumpToChapters
titleRes = R.string.pref_jump_to_chapters
defaultValue = false
}
}
preferenceCategory {
@@ -77,7 +77,7 @@ object ChapterRecognition {
}
// Remove manga title from chapter title.
val nameWithoutManga = name.replace(manga.originalTitle.toLowerCase(), "").trim()
val nameWithoutManga = name.replace(/* SY --> */ manga.originalTitle.toLowerCase()/* SY <-- */, "").trim()
// Check if first value is number after title remove.
if (updateChapter(withoutManga.find(nameWithoutManga), chapter)) {
@@ -160,7 +160,7 @@ fun syncChaptersWithSource(
// Set manga's last update time to latest chapter's fetch time if possible
val newestChapter = db.getChapters(manga).executeAsBlocking().maxBy { it.date_fetch }
manga.last_update = newestChapter?.date_fetch ?: manga.last_update
manga.last_update = newestChapter?.date_fetch ?: Date().time
db.updateLastUpdated(manga).executeAsBlocking()
}
@@ -8,7 +8,9 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.annotation.MenuRes
import androidx.annotation.StringRes
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.TooltipCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
@@ -38,6 +40,15 @@ inline fun View.snack(message: String, length: Int = Snackbar.LENGTH_LONG, f: Sn
return snack
}
/**
* Adds a tooltip shown on long press.
*
* @param stringRes String resource for tooltip.
*/
inline fun View.setTooltip(@StringRes stringRes: Int) {
TooltipCompat.setTooltipText(this, context.getString(stringRes))
}
/**
* Shows a popup menu on top of this view.
*
@@ -4,7 +4,6 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.EhSmartSearchBinding
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.SourceManager
@@ -12,7 +11,6 @@ import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.source.SourceController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.manga.MangaAllInOneController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope
@@ -21,7 +19,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
@@ -61,11 +58,7 @@ class SmartSearchController(bundle: Bundle? = null) : NucleusController<EhSmartS
launch(Dispatchers.Default) {
for (event in presenter.smartSearchChannel) {
if (event is SmartSearchPresenter.SearchResults.Found) {
val transaction = if (Injekt.get<PreferencesHelper>().eh_useNewMangaInterface().get()) {
MangaAllInOneController(event.manga, true, smartSearchConfig).withFadeTransaction()
} else {
MangaController(event.manga, true, smartSearchConfig).withFadeTransaction()
}
val transaction = MangaController(event.manga, true, smartSearchConfig).withFadeTransaction()
withContext(Dispatchers.Main) {
router.replaceTopController(transaction)
}