Replace reader's Presenter with ViewModel (#8698)

includes:
* Use coroutines in more places
* Use domain Manga data class and effectively changing the state system
* Replace deprecated onBackPress method

Co-authored-by: arkon <arkon@users.noreply.github.com>
This commit is contained in:
Ivan Iskandar 2022-12-08 11:00:01 +07:00 committed by GitHub
parent e748d91d4a
commit f7a92cf6ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 318 additions and 332 deletions

View file

@ -6,8 +6,6 @@
"ignoreDeps": [ "ignoreDeps": [
"androidx.core:core-splashscreen", "androidx.core:core-splashscreen",
"androidx.work:work-runtime-ktx", "androidx.work:work-runtime-ktx",
"info.android15.nucleus:nucleus-support-v7",
"info.android15.nucleus:nucleus",
"com.android.tools:r8", "com.android.tools:r8",
"com.google.guava:guava", "com.google.guava:guava",
"com.github.commandiron:WheelPickerCompose" "com.github.commandiron:WheelPickerCompose"

View file

@ -239,9 +239,6 @@ dependencies {
// Preferences // Preferences
implementation(libs.preferencektx) implementation(libs.preferencektx)
// Model View Presenter
implementation(libs.bundles.nucleus)
// Dependency injection // Dependency injection
implementation(libs.injekt.core) implementation(libs.injekt.core)

View file

@ -1,17 +1,16 @@
package eu.kanade.domain.manga.model package eu.kanade.domain.manga.model
import eu.kanade.data.listOfStringsAdapter
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.UpdateStrategy import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.widget.ExtendedNavigationView import eu.kanade.tachiyomi.widget.ExtendedNavigationView
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.Serializable import java.io.Serializable
import eu.kanade.tachiyomi.data.database.models.Manga as DbManga
data class Manga( data class Manga(
val id: Long, val id: Long,
@ -49,6 +48,12 @@ data class Manga(
val bookmarkedFilterRaw: Long val bookmarkedFilterRaw: Long
get() = chapterFlags and CHAPTER_BOOKMARKED_MASK get() = chapterFlags and CHAPTER_BOOKMARKED_MASK
val readingModeType: Long
get() = viewerFlags and ReadingModeType.MASK.toLong()
val orientationType: Long
get() = viewerFlags and OrientationType.MASK.toLong()
val unreadFilter: TriStateFilter val unreadFilter: TriStateFilter
get() = when (unreadFilterRaw) { get() = when (unreadFilterRaw) {
CHAPTER_SHOW_UNREAD -> TriStateFilter.ENABLED_IS CHAPTER_SHOW_UNREAD -> TriStateFilter.ENABLED_IS
@ -187,28 +192,6 @@ fun TriStateFilter.toTriStateGroupState(): ExtendedNavigationView.Item.TriStateG
} }
} }
// TODO: Remove when all deps are migrated
fun Manga.toDbManga(): DbManga = MangaImpl().also {
it.id = id
it.source = source
it.favorite = favorite
it.last_update = lastUpdate
it.date_added = dateAdded
it.viewer_flags = viewerFlags.toInt()
it.chapter_flags = chapterFlags.toInt()
it.cover_last_modified = coverLastModified
it.url = url
it.title = title
it.artist = artist
it.author = author
it.description = description
it.genre = genre?.let(listOfStringsAdapter::encode)
it.status = status.toInt()
it.thumbnail_url = thumbnailUrl
it.update_strategy = updateStrategy
it.initialized = initialized
}
fun Manga.toMangaUpdate(): MangaUpdate { fun Manga.toMangaUpdate(): MangaUpdate {
return MangaUpdate( return MangaUpdate(
id = id, id = id,

View file

@ -29,6 +29,8 @@ import android.view.animation.Animation
import android.view.animation.AnimationUtils import android.view.animation.AnimationUtils
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.transition.doOnEnd import androidx.core.transition.doOnEnd
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@ -45,9 +47,9 @@ import com.google.android.material.transition.platform.MaterialContainerTransfor
import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
import dev.chrisbanes.insetter.applyInsetter import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.databinding.ReaderActivityBinding import eu.kanade.tachiyomi.databinding.ReaderActivityBinding
@ -56,9 +58,9 @@ import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegateImpl
import eu.kanade.tachiyomi.ui.base.delegate.ThemingDelegate import eu.kanade.tachiyomi.ui.base.delegate.ThemingDelegate
import eu.kanade.tachiyomi.ui.base.delegate.ThemingDelegateImpl import eu.kanade.tachiyomi.ui.base.delegate.ThemingDelegateImpl
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.AddToLibraryFirst import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.AddToLibraryFirst
import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.Error import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Error
import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.Success import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Success
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
@ -71,6 +73,8 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.Constants import eu.kanade.tachiyomi.util.Constants
import eu.kanade.tachiyomi.util.lang.launchNonCancellable
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.preference.toggle import eu.kanade.tachiyomi.util.preference.toggle
import eu.kanade.tachiyomi.util.system.applySystemAnimatorScale import eu.kanade.tachiyomi.util.system.applySystemAnimatorScale
import eu.kanade.tachiyomi.util.system.createReaderThemeContext import eu.kanade.tachiyomi.util.system.createReaderThemeContext
@ -85,14 +89,16 @@ import eu.kanade.tachiyomi.util.view.copy
import eu.kanade.tachiyomi.util.view.popupMenu import eu.kanade.tachiyomi.util.view.popupMenu
import eu.kanade.tachiyomi.util.view.setTooltip import eu.kanade.tachiyomi.util.view.setTooltip
import eu.kanade.tachiyomi.widget.listener.SimpleAnimationListener import eu.kanade.tachiyomi.widget.listener.SimpleAnimationListener
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import logcat.LogPriority import logcat.LogPriority
import nucleus.factory.RequiresPresenter
import nucleus.view.NucleusAppCompatActivity
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
@ -101,9 +107,8 @@ import kotlin.math.max
* Activity containing the reader of Tachiyomi. This activity is mostly a container of the * Activity containing the reader of Tachiyomi. This activity is mostly a container of the
* viewers, to which calls from the presenter or UI events are delegated. * viewers, to which calls from the presenter or UI events are delegated.
*/ */
@RequiresPresenter(ReaderPresenter::class)
class ReaderActivity : class ReaderActivity :
NucleusAppCompatActivity<ReaderPresenter>(), AppCompatActivity(),
SecureActivityDelegate by SecureActivityDelegateImpl(), SecureActivityDelegate by SecureActivityDelegateImpl(),
ThemingDelegate by ThemingDelegateImpl() { ThemingDelegate by ThemingDelegateImpl() {
@ -128,6 +133,8 @@ class ReaderActivity :
lateinit var binding: ReaderActivityBinding lateinit var binding: ReaderActivityBinding
val viewModel by viewModels<ReaderViewModel>()
val hasCutout by lazy { hasDisplayCutout() } val hasCutout by lazy { hasDisplayCutout() }
/** /**
@ -194,7 +201,7 @@ class ReaderActivity :
binding = ReaderActivityBinding.inflate(layoutInflater) binding = ReaderActivityBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
if (presenter.needsInit()) { if (viewModel.needsInit()) {
val manga = intent.extras!!.getLong("manga", -1) val manga = intent.extras!!.getLong("manga", -1)
val chapter = intent.extras!!.getLong("chapter", -1) val chapter = intent.extras!!.getLong("chapter", -1)
if (manga == -1L || chapter == -1L) { if (manga == -1L || chapter == -1L) {
@ -202,7 +209,16 @@ class ReaderActivity :
return return
} }
NotificationReceiver.dismissNotification(this, manga.hashCode(), Notifications.ID_NEW_CHAPTERS) NotificationReceiver.dismissNotification(this, manga.hashCode(), Notifications.ID_NEW_CHAPTERS)
presenter.init(manga, chapter)
lifecycleScope.launchNonCancellable {
val initResult = viewModel.init(manga, chapter)
if (!initResult.getOrDefault(false)) {
val exception = initResult.exceptionOrNull() ?: IllegalStateException("Unknown err")
withUIContext {
setInitialChapterError(exception)
}
}
}
} }
if (savedInstanceState != null) { if (savedInstanceState != null) {
@ -217,6 +233,48 @@ class ReaderActivity :
.drop(1) .drop(1)
.onEach { if (!it) finish() } .onEach { if (!it) finish() }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
viewModel.state
.map { it.isLoadingAdjacentChapter }
.distinctUntilChanged()
.onEach(::setProgressDialog)
.launchIn(lifecycleScope)
viewModel.state
.map { it.manga }
.distinctUntilChanged()
.filterNotNull()
.onEach(::setManga)
.launchIn(lifecycleScope)
viewModel.state
.map { it.viewerChapters }
.distinctUntilChanged()
.filterNotNull()
.onEach(::setChapters)
.launchIn(lifecycleScope)
viewModel.eventFlow
.onEach { event ->
when (event) {
ReaderViewModel.Event.ReloadViewerChapters -> {
viewModel.state.value.viewerChapters?.let(::setChapters)
}
is ReaderViewModel.Event.SetOrientation -> {
setOrientation(event.orientation)
}
is ReaderViewModel.Event.SavedImage -> {
onSaveImageResult(event.result)
}
is ReaderViewModel.Event.ShareImage -> {
onShareImageResult(event.uri, event.page)
}
is ReaderViewModel.Event.SetCoverResult -> {
onSetAsCoverResult(event.result)
}
}
}
.launchIn(lifecycleScope)
} }
/** /**
@ -240,13 +298,13 @@ class ReaderActivity :
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean(::menuVisible.name, menuVisible) outState.putBoolean(::menuVisible.name, menuVisible)
if (!isChangingConfigurations) { if (!isChangingConfigurations) {
presenter.onSaveInstanceStateNonConfigurationChange() viewModel.onSaveInstanceStateNonConfigurationChange()
} }
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
} }
override fun onPause() { override fun onPause() {
presenter.saveCurrentChapterReadingProgress() viewModel.saveCurrentChapterReadingProgress()
super.onPause() super.onPause()
} }
@ -256,7 +314,7 @@ class ReaderActivity :
*/ */
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
presenter.setReadStartTime() viewModel.setReadStartTime()
setMenuVisibility(menuVisible, animate = false) setMenuVisibility(menuVisible, animate = false)
} }
@ -277,7 +335,7 @@ class ReaderActivity :
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.reader, menu) menuInflater.inflate(R.menu.reader, menu)
val isChapterBookmarked = presenter?.getCurrentChapter()?.chapter?.bookmark ?: false val isChapterBookmarked = viewModel.getCurrentChapter()?.chapter?.bookmark ?: false
menu.findItem(R.id.action_bookmark).isVisible = !isChapterBookmarked menu.findItem(R.id.action_bookmark).isVisible = !isChapterBookmarked
menu.findItem(R.id.action_remove_bookmark).isVisible = isChapterBookmarked menu.findItem(R.id.action_remove_bookmark).isVisible = isChapterBookmarked
@ -294,11 +352,11 @@ class ReaderActivity :
openChapterInWebview() openChapterInWebview()
} }
R.id.action_bookmark -> { R.id.action_bookmark -> {
presenter.bookmarkCurrentChapter(true) viewModel.bookmarkCurrentChapter(true)
invalidateOptionsMenu() invalidateOptionsMenu()
} }
R.id.action_remove_bookmark -> { R.id.action_remove_bookmark -> {
presenter.bookmarkCurrentChapter(false) viewModel.bookmarkCurrentChapter(false)
invalidateOptionsMenu() invalidateOptionsMenu()
} }
} }
@ -309,17 +367,17 @@ class ReaderActivity :
* Called when the user clicks the back key or the button on the toolbar. The call is * Called when the user clicks the back key or the button on the toolbar. The call is
* delegated to the presenter. * delegated to the presenter.
*/ */
override fun onBackPressed() { override fun finish() {
presenter.onBackPressed() viewModel.onActivityFinish()
super.onBackPressed() super.finish()
} }
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_N) { if (keyCode == KeyEvent.KEYCODE_N) {
presenter.loadNextChapter() loadNextChapter()
return true return true
} else if (keyCode == KeyEvent.KEYCODE_P) { } else if (keyCode == KeyEvent.KEYCODE_P) {
presenter.loadPreviousChapter() loadPreviousChapter()
return true return true
} }
return super.onKeyUp(keyCode, event) return super.onKeyUp(keyCode, event)
@ -356,7 +414,7 @@ class ReaderActivity :
setSupportActionBar(binding.toolbar) setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
binding.toolbar.setNavigationOnClickListener { binding.toolbar.setNavigationOnClickListener {
onBackPressed() onBackPressedDispatcher.onBackPressed()
} }
binding.toolbar.applyInsetter { binding.toolbar.applyInsetter {
@ -371,7 +429,7 @@ class ReaderActivity :
} }
binding.toolbar.setOnClickListener { binding.toolbar.setOnClickListener {
presenter.manga?.id?.let { id -> viewModel.manga?.id?.let { id ->
startActivity( startActivity(
Intent(this, MainActivity::class.java).apply { Intent(this, MainActivity::class.java).apply {
action = MainActivity.SHORTCUT_MANGA action = MainActivity.SHORTCUT_MANGA
@ -461,11 +519,11 @@ class ReaderActivity :
setOnClickListener { setOnClickListener {
popupMenu( popupMenu(
items = ReadingModeType.values().map { it.flagValue to it.stringRes }, items = ReadingModeType.values().map { it.flagValue to it.stringRes },
selectedItemId = presenter.getMangaReadingMode(resolveDefault = false), selectedItemId = viewModel.getMangaReadingMode(resolveDefault = false),
) { ) {
val newReadingMode = ReadingModeType.fromPreference(itemId) val newReadingMode = ReadingModeType.fromPreference(itemId)
presenter.setMangaReadingMode(newReadingMode.flagValue) viewModel.setMangaReadingMode(newReadingMode.flagValue)
menuToggleToast?.cancel() menuToggleToast?.cancel()
if (!readerPreferences.showReadingMode().get()) { if (!readerPreferences.showReadingMode().get()) {
@ -482,7 +540,7 @@ class ReaderActivity :
setTooltip(R.string.pref_crop_borders) setTooltip(R.string.pref_crop_borders)
setOnClickListener { setOnClickListener {
val isPagerType = ReadingModeType.isPagerType(presenter.getMangaReadingMode()) val isPagerType = ReadingModeType.isPagerType(viewModel.getMangaReadingMode())
val enabled = if (isPagerType) { val enabled = if (isPagerType) {
readerPreferences.cropBorders().toggle() readerPreferences.cropBorders().toggle()
} else { } else {
@ -514,12 +572,12 @@ class ReaderActivity :
setOnClickListener { setOnClickListener {
popupMenu( popupMenu(
items = OrientationType.values().map { it.flagValue to it.stringRes }, items = OrientationType.values().map { it.flagValue to it.stringRes },
selectedItemId = presenter.manga?.orientationType selectedItemId = viewModel.manga?.orientationType?.toInt()
?: readerPreferences.defaultOrientationType().get(), ?: readerPreferences.defaultOrientationType().get(),
) { ) {
val newOrientation = OrientationType.fromPreference(itemId) val newOrientation = OrientationType.fromPreference(itemId)
presenter.setMangaOrientationType(newOrientation.flagValue) viewModel.setMangaOrientationType(newOrientation.flagValue)
menuToggleToast?.cancel() menuToggleToast?.cancel()
menuToggleToast = toast(newOrientation.stringRes) menuToggleToast = toast(newOrientation.stringRes)
@ -550,7 +608,7 @@ class ReaderActivity :
} }
private fun updateCropBordersShortcut() { private fun updateCropBordersShortcut() {
val isPagerType = ReadingModeType.isPagerType(presenter.getMangaReadingMode()) val isPagerType = ReadingModeType.isPagerType(viewModel.getMangaReadingMode())
val enabled = if (isPagerType) { val enabled = if (isPagerType) {
readerPreferences.cropBorders().get() readerPreferences.cropBorders().get()
} else { } else {
@ -633,19 +691,19 @@ class ReaderActivity :
fun setManga(manga: Manga) { fun setManga(manga: Manga) {
val prevViewer = viewer val prevViewer = viewer
val viewerMode = ReadingModeType.fromPreference(presenter.getMangaReadingMode(resolveDefault = false)) val viewerMode = ReadingModeType.fromPreference(viewModel.getMangaReadingMode(resolveDefault = false))
binding.actionReadingMode.setImageResource(viewerMode.iconRes) binding.actionReadingMode.setImageResource(viewerMode.iconRes)
val newViewer = ReadingModeType.toViewer(presenter.getMangaReadingMode(), this) val newViewer = ReadingModeType.toViewer(viewModel.getMangaReadingMode(), this)
updateCropBordersShortcut() updateCropBordersShortcut()
if (window.sharedElementEnterTransition is MaterialContainerTransform) { if (window.sharedElementEnterTransition is MaterialContainerTransform) {
// Wait until transition is complete to avoid crash on API 26 // Wait until transition is complete to avoid crash on API 26
window.sharedElementEnterTransition.doOnEnd { window.sharedElementEnterTransition.doOnEnd {
setOrientation(presenter.getMangaOrientationType()) setOrientation(viewModel.getMangaOrientationType())
} }
} else { } else {
setOrientation(presenter.getMangaOrientationType()) setOrientation(viewModel.getMangaOrientationType())
} }
// Destroy previous viewer if there was one // Destroy previous viewer if there was one
@ -658,10 +716,10 @@ class ReaderActivity :
binding.viewerContainer.addView(newViewer.getView()) binding.viewerContainer.addView(newViewer.getView())
if (readerPreferences.showReadingMode().get()) { if (readerPreferences.showReadingMode().get()) {
showReadingModeToast(presenter.getMangaReadingMode()) showReadingModeToast(viewModel.getMangaReadingMode())
} }
binding.toolbar.title = manga.title supportActionBar?.title = manga.title
binding.pageSlider.isRTL = newViewer is R2LPagerViewer binding.pageSlider.isRTL = newViewer is R2LPagerViewer
if (newViewer is R2LPagerViewer) { if (newViewer is R2LPagerViewer) {
@ -684,9 +742,9 @@ class ReaderActivity :
} }
private fun openChapterInWebview() { private fun openChapterInWebview() {
val manga = presenter.manga ?: return val manga = viewModel.manga ?: return
val source = presenter.getSource() ?: return val source = viewModel.getSource() ?: return
val url = presenter.getChapterUrl() ?: return val url = viewModel.getChapterUrl() ?: return
val intent = WebViewActivity.newIntent(this, url, source.id, manga.title) val intent = WebViewActivity.newIntent(this, url, source.id, manga.title)
startActivity(intent) startActivity(intent)
@ -707,7 +765,7 @@ class ReaderActivity :
* method to the current viewer, but also set the subtitle on the toolbar, and * method to the current viewer, but also set the subtitle on the toolbar, and
* hides or disables the reader prev/next buttons if there's a prev or next chapter * hides or disables the reader prev/next buttons if there's a prev or next chapter
*/ */
fun setChapters(viewerChapters: ViewerChapters) { private fun setChapters(viewerChapters: ViewerChapters) {
binding.readerContainer.removeView(loadingIndicator) binding.readerContainer.removeView(loadingIndicator)
viewer?.setChapters(viewerChapters) viewer?.setChapters(viewerChapters)
binding.toolbar.subtitle = viewerChapters.currChapter.chapter.name binding.toolbar.subtitle = viewerChapters.currChapter.chapter.name
@ -765,7 +823,7 @@ class ReaderActivity :
*/ */
fun moveToPageIndex(index: Int) { fun moveToPageIndex(index: Int) {
val viewer = viewer ?: return val viewer = viewer ?: return
val currentChapter = presenter.getCurrentChapter() ?: return val currentChapter = viewModel.getCurrentChapter() ?: return
val page = currentChapter.pages?.getOrNull(index) ?: return val page = currentChapter.pages?.getOrNull(index) ?: return
viewer.moveToPage(page) viewer.moveToPage(page)
} }
@ -775,7 +833,10 @@ class ReaderActivity :
* should be automatically shown. * should be automatically shown.
*/ */
private fun loadNextChapter() { private fun loadNextChapter() {
presenter.loadNextChapter() lifecycleScope.launch {
viewModel.loadNextChapter()
moveToPageIndex(0)
}
} }
/** /**
@ -783,7 +844,10 @@ class ReaderActivity :
* should be automatically shown. * should be automatically shown.
*/ */
private fun loadPreviousChapter() { private fun loadPreviousChapter() {
presenter.loadPreviousChapter() lifecycleScope.launch {
viewModel.loadPreviousChapter()
moveToPageIndex(0)
}
} }
/** /**
@ -792,7 +856,7 @@ class ReaderActivity :
*/ */
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
fun onPageSelected(page: ReaderPage) { fun onPageSelected(page: ReaderPage) {
presenter.onPageSelected(page) viewModel.onPageSelected(page)
val pages = page.chapter.pages ?: return val pages = page.chapter.pages ?: return
// Set bottom page number // Set bottom page number
@ -826,7 +890,7 @@ class ReaderActivity :
* the viewer is reaching the beginning or end of a chapter or the transition page is active. * the viewer is reaching the beginning or end of a chapter or the transition page is active.
*/ */
fun requestPreloadChapter(chapter: ReaderChapter) { fun requestPreloadChapter(chapter: ReaderChapter) {
presenter.preloadChapter(chapter) lifecycleScope.launch { viewModel.preloadChapter(chapter) }
} }
/** /**
@ -860,15 +924,15 @@ class ReaderActivity :
* will call [onShareImageResult] with the path the image was saved on when it's ready. * will call [onShareImageResult] with the path the image was saved on when it's ready.
*/ */
fun shareImage(page: ReaderPage) { fun shareImage(page: ReaderPage) {
presenter.shareImage(page) viewModel.shareImage(page)
} }
/** /**
* Called from the presenter when a page is ready to be shared. It shows Android's default * Called from the presenter when a page is ready to be shared. It shows Android's default
* sharing tool. * sharing tool.
*/ */
fun onShareImageResult(uri: Uri, page: ReaderPage) { private fun onShareImageResult(uri: Uri, page: ReaderPage) {
val manga = presenter.manga ?: return val manga = viewModel.manga ?: return
val chapter = page.chapter.chapter val chapter = page.chapter.chapter
val intent = uri.toShareIntent( val intent = uri.toShareIntent(
@ -883,19 +947,19 @@ class ReaderActivity :
* storage to the presenter. * storage to the presenter.
*/ */
fun saveImage(page: ReaderPage) { fun saveImage(page: ReaderPage) {
presenter.saveImage(page) viewModel.saveImage(page)
} }
/** /**
* Called from the presenter when a page is saved or fails. It shows a message or logs the * Called from the presenter when a page is saved or fails. It shows a message or logs the
* event depending on the [result]. * event depending on the [result].
*/ */
fun onSaveImageResult(result: ReaderPresenter.SaveImageResult) { private fun onSaveImageResult(result: ReaderViewModel.SaveImageResult) {
when (result) { when (result) {
is ReaderPresenter.SaveImageResult.Success -> { is ReaderViewModel.SaveImageResult.Success -> {
toast(R.string.picture_saved) toast(R.string.picture_saved)
} }
is ReaderPresenter.SaveImageResult.Error -> { is ReaderViewModel.SaveImageResult.Error -> {
logcat(LogPriority.ERROR, result.error) logcat(LogPriority.ERROR, result.error)
} }
} }
@ -906,14 +970,14 @@ class ReaderActivity :
* cover to the presenter. * cover to the presenter.
*/ */
fun setAsCover(page: ReaderPage) { fun setAsCover(page: ReaderPage) {
presenter.setAsCover(this, page) viewModel.setAsCover(this, page)
} }
/** /**
* Called from the presenter when a page is set as cover or fails. It shows a different message * Called from the presenter when a page is set as cover or fails. It shows a different message
* depending on the [result]. * depending on the [result].
*/ */
fun onSetAsCoverResult(result: ReaderPresenter.SetAsCoverResult) { private fun onSetAsCoverResult(result: ReaderViewModel.SetAsCoverResult) {
toast( toast(
when (result) { when (result) {
Success -> R.string.cover_updated Success -> R.string.cover_updated
@ -926,12 +990,12 @@ class ReaderActivity :
/** /**
* Forces the user preferred [orientation] on the activity. * Forces the user preferred [orientation] on the activity.
*/ */
fun setOrientation(orientation: Int) { private fun setOrientation(orientation: Int) {
val newOrientation = OrientationType.fromPreference(orientation) val newOrientation = OrientationType.fromPreference(orientation)
if (newOrientation.flag != requestedOrientation) { if (newOrientation.flag != requestedOrientation) {
requestedOrientation = newOrientation.flag requestedOrientation = newOrientation.flag
} }
updateOrientationShortcut(presenter.getMangaOrientationType(resolveDefault = false)) updateOrientationShortcut(viewModel.getMangaOrientationType(resolveDefault = false))
} }
/** /**

View file

@ -3,8 +3,10 @@ package eu.kanade.tachiyomi.ui.reader
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Bundle import androidx.lifecycle.SavedStateHandle
import com.jakewharton.rxrelay.BehaviorRelay import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.kanade.core.util.asFlow
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.chapter.interactor.GetChapterByMangaId import eu.kanade.domain.chapter.interactor.GetChapterByMangaId
import eu.kanade.domain.chapter.interactor.UpdateChapter import eu.kanade.domain.chapter.interactor.UpdateChapter
@ -16,17 +18,15 @@ import eu.kanade.domain.history.interactor.UpsertHistory
import eu.kanade.domain.history.model.HistoryUpdate import eu.kanade.domain.history.model.HistoryUpdate
import eu.kanade.domain.manga.interactor.GetManga import eu.kanade.domain.manga.interactor.GetManga
import eu.kanade.domain.manga.interactor.SetMangaViewerFlags import eu.kanade.domain.manga.interactor.SetMangaViewerFlags
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.isLocal import eu.kanade.domain.manga.model.isLocal
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.domain.track.interactor.GetTracks import eu.kanade.domain.track.interactor.GetTracks
import eu.kanade.domain.track.interactor.InsertTrack import eu.kanade.domain.track.interactor.InsertTrack
import eu.kanade.domain.track.model.toDbTrack import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.domain.track.service.DelayedTrackingUpdateJob import eu.kanade.domain.track.service.DelayedTrackingUpdateJob
import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.domain.track.store.DelayedTrackingStore import eu.kanade.domain.track.store.DelayedTrackingStore
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.toDomainChapter import eu.kanade.tachiyomi.data.database.models.toDomainChapter
import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadProvider import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
@ -54,37 +54,41 @@ import eu.kanade.tachiyomi.util.lang.byteSize
import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchNonCancellable import eu.kanade.tachiyomi.util.lang.launchNonCancellable
import eu.kanade.tachiyomi.util.lang.takeBytes import eu.kanade.tachiyomi.util.lang.takeBytes
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.cacheImageDir import eu.kanade.tachiyomi.util.storage.cacheImageDir
import eu.kanade.tachiyomi.util.system.isOnline import eu.kanade.tachiyomi.util.system.isOnline
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import logcat.LogPriority import logcat.LogPriority
import nucleus.presenter.RxPresenter
import rx.Observable import rx.Observable
import rx.Subscription import rx.Subscription
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.Date import java.util.Date
import eu.kanade.domain.manga.model.Manga as DomainManga
/** /**
* Presenter used by the activity to perform background operations. * Presenter used by the activity to perform background operations.
*/ */
class ReaderPresenter( class ReaderViewModel(
private val savedState: SavedStateHandle = SavedStateHandle(),
private val sourceManager: SourceManager = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(),
private val downloadProvider: DownloadProvider = Injekt.get(), private val downloadProvider: DownloadProvider = Injekt.get(),
@ -102,20 +106,28 @@ class ReaderPresenter(
private val upsertHistory: UpsertHistory = Injekt.get(), private val upsertHistory: UpsertHistory = Injekt.get(),
private val updateChapter: UpdateChapter = Injekt.get(), private val updateChapter: UpdateChapter = Injekt.get(),
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(), private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
) : RxPresenter<ReaderActivity>() { ) : ViewModel() {
private val coroutineScope: CoroutineScope = MainScope() private val mutableState = MutableStateFlow(State())
val state = mutableState.asStateFlow()
private val eventChannel = Channel<Event>()
val eventFlow = eventChannel.receiveAsFlow()
/** /**
* The manga loaded in the reader. It can be null when instantiated for a short time. * The manga loaded in the reader. It can be null when instantiated for a short time.
*/ */
var manga: Manga? = null val manga: Manga?
private set get() = state.value.manga
/** /**
* The chapter id of the currently loaded chapter. Used to restore from process kill. * The chapter id of the currently loaded chapter. Used to restore from process kill.
*/ */
private var chapterId = -1L private var chapterId = savedState.get<Long>("chapter_id") ?: -1L
set(value) {
savedState["chapter_id"] = value
field = value
}
/** /**
* The chapter loader for the loaded manga. It'll be null until [manga] is set. * The chapter loader for the loaded manga. It'll be null until [manga] is set.
@ -132,16 +144,6 @@ class ReaderPresenter(
*/ */
private var activeChapterSubscription: Subscription? = null private var activeChapterSubscription: Subscription? = null
/**
* Relay for currently active viewer chapters.
*/
private val viewerChaptersRelay = BehaviorRelay.create<ViewerChapters>()
/**
* Used when loading prev/next chapter needed to lock the UI (with a dialog).
*/
private val isLoadingAdjacentChapterEvent = Channel<Boolean>()
private var chapterToDownload: Download? = null private var chapterToDownload: Download? = null
/** /**
@ -149,7 +151,7 @@ class ReaderPresenter(
* time in a background thread to avoid blocking the UI. * time in a background thread to avoid blocking the UI.
*/ */
private val chapterList by lazy { private val chapterList by lazy {
val manga = manga!!.toDomainManga()!! val manga = manga!!
val chapters = runBlocking { getChapterByMangaId.await(manga.id) } val chapters = runBlocking { getChapterByMangaId.await(manga.id) }
val selectedChapter = chapters.find { it.id == chapterId } val selectedChapter = chapters.find { it.id == chapterId }
@ -161,12 +163,12 @@ class ReaderPresenter(
when { when {
readerPreferences.skipRead().get() && it.read -> true readerPreferences.skipRead().get() && it.read -> true
readerPreferences.skipFiltered().get() -> { readerPreferences.skipFiltered().get() -> {
(manga.unreadFilterRaw == DomainManga.CHAPTER_SHOW_READ && !it.read) || (manga.unreadFilterRaw == Manga.CHAPTER_SHOW_READ && !it.read) ||
(manga.unreadFilterRaw == DomainManga.CHAPTER_SHOW_UNREAD && it.read) || (manga.unreadFilterRaw == Manga.CHAPTER_SHOW_UNREAD && it.read) ||
(manga.downloadedFilterRaw == DomainManga.CHAPTER_SHOW_DOWNLOADED && !downloadManager.isChapterDownloaded(it.name, it.scanlator, manga.title, manga.source)) || (manga.downloadedFilterRaw == Manga.CHAPTER_SHOW_DOWNLOADED && !downloadManager.isChapterDownloaded(it.name, it.scanlator, manga.title, manga.source)) ||
(manga.downloadedFilterRaw == DomainManga.CHAPTER_SHOW_NOT_DOWNLOADED && downloadManager.isChapterDownloaded(it.name, it.scanlator, manga.title, manga.source)) || (manga.downloadedFilterRaw == Manga.CHAPTER_SHOW_NOT_DOWNLOADED && downloadManager.isChapterDownloaded(it.name, it.scanlator, manga.title, manga.source)) ||
(manga.bookmarkedFilterRaw == DomainManga.CHAPTER_SHOW_BOOKMARKED && !it.bookmark) || (manga.bookmarkedFilterRaw == Manga.CHAPTER_SHOW_BOOKMARKED && !it.bookmark) ||
(manga.bookmarkedFilterRaw == DomainManga.CHAPTER_SHOW_NOT_BOOKMARKED && it.bookmark) (manga.bookmarkedFilterRaw == Manga.CHAPTER_SHOW_NOT_BOOKMARKED && it.bookmark)
} }
else -> false else -> false
} }
@ -188,32 +190,15 @@ class ReaderPresenter(
} }
private var hasTrackers: Boolean = false private var hasTrackers: Boolean = false
private val checkTrackers: (DomainManga) -> Unit = { manga -> private val checkTrackers: (Manga) -> Unit = { manga ->
val tracks = runBlocking { getTracks.await(manga.id) } val tracks = runBlocking { getTracks.await(manga.id) }
hasTrackers = tracks.isNotEmpty() hasTrackers = tracks.isNotEmpty()
} }
private val incognitoMode = preferences.incognitoMode().get() private val incognitoMode = preferences.incognitoMode().get()
/** override fun onCleared() {
* Called when the presenter is created. It retrieves the saved active chapter if the process val currentChapters = state.value.viewerChapters
* was restored.
*/
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
if (savedState != null) {
chapterId = savedState.getLong(::chapterId.name, -1)
}
}
/**
* Called when the presenter is destroyed. It saves the current progress and cleans up
* references on the currently active chapters.
*/
override fun onDestroy() {
super.onDestroy()
coroutineScope.cancel()
val currentChapters = viewerChaptersRelay.value
if (currentChapters != null) { if (currentChapters != null) {
currentChapters.unref() currentChapters.unref()
saveReadingProgress(currentChapters.currChapter) saveReadingProgress(currentChapters.currChapter)
@ -223,24 +208,24 @@ class ReaderPresenter(
} }
} }
/** init {
* Called when the presenter instance is being saved. It saves the currently active chapter // To save state
* id and the last page read. state.map { it.viewerChapters?.currChapter }
*/ .distinctUntilChanged()
override fun onSave(state: Bundle) { .onEach { currentChapter ->
super.onSave(state)
val currentChapter = getCurrentChapter()
if (currentChapter != null) { if (currentChapter != null) {
currentChapter.requestedPage = currentChapter.chapter.last_page_read currentChapter.requestedPage = currentChapter.chapter.last_page_read
state.putLong(::chapterId.name, currentChapter.chapter.id!!) chapterId = currentChapter.chapter.id!!
} }
} }
.launchIn(viewModelScope)
}
/** /**
* Called when the user pressed the back button and is going to leave the reader. Used to * Called when the user pressed the back button and is going to leave the reader. Used to
* trigger deletion of the downloaded chapters. * trigger deletion of the downloaded chapters.
*/ */
fun onBackPressed() { fun onActivityFinish() {
deletePendingChapters() deletePendingChapters()
} }
@ -250,7 +235,7 @@ class ReaderPresenter(
*/ */
fun onSaveInstanceStateNonConfigurationChange() { fun onSaveInstanceStateNonConfigurationChange() {
val currentChapter = getCurrentChapter() ?: return val currentChapter = getCurrentChapter() ?: return
coroutineScope.launchNonCancellable { viewModelScope.launchNonCancellable {
saveChapterProgress(currentChapter) saveChapterProgress(currentChapter)
} }
} }
@ -266,58 +251,33 @@ class ReaderPresenter(
* Initializes this presenter with the given [mangaId] and [initialChapterId]. This method will * Initializes this presenter with the given [mangaId] and [initialChapterId]. This method will
* fetch the manga from the database and initialize the initial chapter. * fetch the manga from the database and initialize the initial chapter.
*/ */
fun init(mangaId: Long, initialChapterId: Long) { suspend fun init(mangaId: Long, initialChapterId: Long): Result<Boolean> {
if (!needsInit()) return if (!needsInit()) return Result.success(true)
return withIOContext {
coroutineScope.launchIO {
try { try {
val manga = getManga.await(mangaId) val manga = getManga.await(mangaId)
withUIContext { if (manga != null) {
manga?.let { init(it.toDbManga(), initialChapterId) } mutableState.update { it.copy(manga = manga) }
}
} catch (e: Throwable) {
view?.setInitialChapterError(e)
}
}
}
/**
* Initializes this presenter with the given [manga] and [initialChapterId]. This method will
* set the chapter loader, view subscriptions and trigger an initial load.
*/
private fun init(manga: Manga, initialChapterId: Long) {
if (!needsInit()) return
this.manga = manga
if (chapterId == -1L) chapterId = initialChapterId if (chapterId == -1L) chapterId = initialChapterId
checkTrackers(manga.toDomainManga()!!) checkTrackers(manga)
val context = Injekt.get<Application>() val context = Injekt.get<Application>()
val source = sourceManager.getOrStub(manga.source) val source = sourceManager.getOrStub(manga.source)
loader = ChapterLoader(context, downloadManager, downloadProvider, manga.toDomainManga()!!, source) loader = ChapterLoader(context, downloadManager, downloadProvider, manga, source)
Observable.just(manga).subscribeLatestCache(ReaderActivity::setManga) getLoadObservable(loader!!, chapterList.first { chapterId == it.chapter.id })
viewerChaptersRelay.subscribeLatestCache(ReaderActivity::setChapters) .asFlow()
coroutineScope.launch { .first()
isLoadingAdjacentChapterEvent.receiveAsFlow().collectLatest { Result.success(true)
view?.setProgressDialog(it) } else {
// Unlikely but okay
Result.success(false)
}
} catch (e: Throwable) {
Result.failure(e)
} }
} }
// Read chapterList from an io thread because it's retrieved lazily and would block main.
activeChapterSubscription?.unsubscribe()
activeChapterSubscription = Observable
.fromCallable { chapterList.first { chapterId == it.chapter.id } }
.flatMap { getLoadObservable(loader!!, it) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ _, _ ->
// Ignore onNext event
},
ReaderActivity::setInitialChapterError,
)
} }
/** /**
@ -345,14 +305,14 @@ class ReaderPresenter(
) )
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.doOnNext { newChapters -> .doOnNext { newChapters ->
val oldChapters = viewerChaptersRelay.value mutableState.update {
// Add new references first to avoid unnecessary recycling // Add new references first to avoid unnecessary recycling
newChapters.ref() newChapters.ref()
oldChapters?.unref() it.viewerChapters?.unref()
chapterToDownload = cancelQueuedDownloads(newChapters.currChapter) chapterToDownload = cancelQueuedDownloads(newChapters.currChapter)
viewerChaptersRelay.call(newChapters) it.copy(viewerChapters = newChapters)
}
} }
} }
@ -360,17 +320,17 @@ class ReaderPresenter(
* Called when the user changed to the given [chapter] when changing pages from the viewer. * Called when the user changed to the given [chapter] when changing pages from the viewer.
* It's used only to set this chapter as active. * It's used only to set this chapter as active.
*/ */
private fun loadNewChapter(chapter: ReaderChapter) { private suspend fun loadNewChapter(chapter: ReaderChapter) {
val loader = loader ?: return val loader = loader ?: return
logcat { "Loading ${chapter.chapter.url}" } logcat { "Loading ${chapter.chapter.url}" }
activeChapterSubscription?.unsubscribe() withIOContext {
activeChapterSubscription = getLoadObservable(loader, chapter) getLoadObservable(loader, chapter)
.toCompletable() .asFlow()
.onErrorComplete() .catch { logcat(LogPriority.ERROR, it) }
.subscribe() .first()
.also(::add) }
} }
/** /**
@ -378,30 +338,25 @@ class ReaderPresenter(
* sets the [isLoadingAdjacentChapterRelay] that the view uses to prevent any further * sets the [isLoadingAdjacentChapterRelay] that the view uses to prevent any further
* interaction until the chapter is loaded. * interaction until the chapter is loaded.
*/ */
private fun loadAdjacent(chapter: ReaderChapter) { private suspend fun loadAdjacent(chapter: ReaderChapter) {
val loader = loader ?: return val loader = loader ?: return
logcat { "Loading adjacent ${chapter.chapter.url}" } logcat { "Loading adjacent ${chapter.chapter.url}" }
activeChapterSubscription?.unsubscribe() mutableState.update { it.copy(isLoadingAdjacentChapter = true) }
activeChapterSubscription = getLoadObservable(loader, chapter) withIOContext {
.doOnSubscribe { coroutineScope.launch { isLoadingAdjacentChapterEvent.send(true) } } getLoadObservable(loader, chapter)
.doOnUnsubscribe { coroutineScope.launch { isLoadingAdjacentChapterEvent.send(false) } } .asFlow()
.subscribeFirst( .first()
{ view, _ -> }
view.moveToPageIndex(0) mutableState.update { it.copy(isLoadingAdjacentChapter = false) }
},
{ _, _ ->
// Ignore onError event, viewers handle that state
},
)
} }
/** /**
* Called when the viewers decide it's a good time to preload a [chapter] and improve the UX so * Called when the viewers decide it's a good time to preload a [chapter] and improve the UX so
* that the user doesn't have to wait too long to continue reading. * that the user doesn't have to wait too long to continue reading.
*/ */
private fun preload(chapter: ReaderChapter) { private suspend fun preload(chapter: ReaderChapter) {
if (chapter.pageLoader is HttpPageLoader) { if (chapter.pageLoader is HttpPageLoader) {
val manga = manga ?: return val manga = manga ?: return
val dbChapter = chapter.chapter val dbChapter = chapter.chapter
@ -424,13 +379,14 @@ class ReaderPresenter(
logcat { "Preloading ${chapter.chapter.url}" } logcat { "Preloading ${chapter.chapter.url}" }
val loader = loader ?: return val loader = loader ?: return
withIOContext {
loader.loadChapter(chapter) loader.loadChapter(chapter)
.observeOn(AndroidSchedulers.mainThread()) .doOnCompleted { eventChannel.trySend(Event.ReloadViewerChapters) }
// Update current chapters whenever a chapter is preloaded
.doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) }
.onErrorComplete() .onErrorComplete()
.subscribe() .toObservable<Unit>()
.also(::add) .asFlow()
.firstOrNull()
}
} }
/** /**
@ -439,7 +395,7 @@ class ReaderPresenter(
* [page]'s chapter is different from the currently active. * [page]'s chapter is different from the currently active.
*/ */
fun onPageSelected(page: ReaderPage) { fun onPageSelected(page: ReaderPage) {
val currentChapters = viewerChaptersRelay.value ?: return val currentChapters = state.value.viewerChapters ?: return
val selectedChapter = page.chapter val selectedChapter = page.chapter
@ -461,7 +417,7 @@ class ReaderPresenter(
logcat { "Setting ${selectedChapter.chapter.url} as active" } logcat { "Setting ${selectedChapter.chapter.url} as active" }
saveReadingProgress(currentChapters.currChapter) saveReadingProgress(currentChapters.currChapter)
setReadStartTime() setReadStartTime()
loadNewChapter(selectedChapter) viewModelScope.launch { loadNewChapter(selectedChapter) }
} }
val pages = page.chapter.pages ?: return val pages = page.chapter.pages ?: return
val inDownloadRange = page.number.toDouble() / pages.size > 0.25 val inDownloadRange = page.number.toDouble() / pages.size > 0.25
@ -477,9 +433,9 @@ class ReaderPresenter(
// Only download ahead if current + next chapter is already downloaded too to avoid jank // Only download ahead if current + next chapter is already downloaded too to avoid jank
if (getCurrentChapter()?.pageLoader !is DownloadPageLoader) return if (getCurrentChapter()?.pageLoader !is DownloadPageLoader) return
val nextChapter = viewerChaptersRelay.value?.nextChapter?.chapter ?: return val nextChapter = state.value.viewerChapters?.nextChapter?.chapter ?: return
coroutineScope.launchIO { viewModelScope.launchIO {
val isNextChapterDownloaded = downloadManager.isChapterDownloaded( val isNextChapterDownloaded = downloadManager.isChapterDownloaded(
nextChapter.name, nextChapter.name,
nextChapter.scanlator, nextChapter.scanlator,
@ -488,10 +444,10 @@ class ReaderPresenter(
) )
if (!isNextChapterDownloaded) return@launchIO if (!isNextChapterDownloaded) return@launchIO
val chaptersToDownload = getNextChapters.await(manga.id!!, nextChapter.id!!) val chaptersToDownload = getNextChapters.await(manga.id, nextChapter.id!!)
.take(amount) .take(amount)
downloadManager.downloadChapters( downloadManager.downloadChapters(
manga.toDomainManga()!!, manga,
chaptersToDownload, chaptersToDownload,
) )
} }
@ -535,7 +491,7 @@ class ReaderPresenter(
* Called when reader chapter is changed in reader or when activity is paused. * Called when reader chapter is changed in reader or when activity is paused.
*/ */
private fun saveReadingProgress(readerChapter: ReaderChapter) { private fun saveReadingProgress(readerChapter: ReaderChapter) {
coroutineScope.launchNonCancellable { viewModelScope.launchNonCancellable {
saveChapterProgress(readerChapter) saveChapterProgress(readerChapter)
saveChapterHistory(readerChapter) saveChapterHistory(readerChapter)
} }
@ -583,23 +539,23 @@ class ReaderPresenter(
/** /**
* Called from the activity to preload the given [chapter]. * Called from the activity to preload the given [chapter].
*/ */
fun preloadChapter(chapter: ReaderChapter) { suspend fun preloadChapter(chapter: ReaderChapter) {
preload(chapter) preload(chapter)
} }
/** /**
* Called from the activity to load and set the next chapter as active. * Called from the activity to load and set the next chapter as active.
*/ */
fun loadNextChapter() { suspend fun loadNextChapter() {
val nextChapter = viewerChaptersRelay.value?.nextChapter ?: return val nextChapter = state.value.viewerChapters?.nextChapter ?: return
loadAdjacent(nextChapter) loadAdjacent(nextChapter)
} }
/** /**
* Called from the activity to load and set the previous chapter as active. * Called from the activity to load and set the previous chapter as active.
*/ */
fun loadPreviousChapter() { suspend fun loadPreviousChapter() {
val prevChapter = viewerChaptersRelay.value?.prevChapter ?: return val prevChapter = state.value.viewerChapters?.prevChapter ?: return
loadAdjacent(prevChapter) loadAdjacent(prevChapter)
} }
@ -607,7 +563,7 @@ class ReaderPresenter(
* Returns the currently active chapter. * Returns the currently active chapter.
*/ */
fun getCurrentChapter(): ReaderChapter? { fun getCurrentChapter(): ReaderChapter? {
return viewerChaptersRelay.value?.currChapter return state.value.viewerChapters?.currChapter
} }
fun getSource() = manga?.source?.let { sourceManager.getOrStub(it) } as? HttpSource fun getSource() = manga?.source?.let { sourceManager.getOrStub(it) } as? HttpSource
@ -625,7 +581,7 @@ class ReaderPresenter(
fun bookmarkCurrentChapter(bookmarked: Boolean) { fun bookmarkCurrentChapter(bookmarked: Boolean) {
val chapter = getCurrentChapter()?.chapter ?: return val chapter = getCurrentChapter()?.chapter ?: return
chapter.bookmark = bookmarked // Otherwise the bookmark icon doesn't update chapter.bookmark = bookmarked // Otherwise the bookmark icon doesn't update
coroutineScope.launchNonCancellable { viewModelScope.launchNonCancellable {
updateChapter.await( updateChapter.await(
ChapterUpdate( ChapterUpdate(
id = chapter.id!!.toLong(), id = chapter.id!!.toLong(),
@ -640,10 +596,10 @@ class ReaderPresenter(
*/ */
fun getMangaReadingMode(resolveDefault: Boolean = true): Int { fun getMangaReadingMode(resolveDefault: Boolean = true): Int {
val default = readerPreferences.defaultReadingMode().get() val default = readerPreferences.defaultReadingMode().get()
val readingMode = ReadingModeType.fromPreference(manga?.readingModeType) val readingMode = ReadingModeType.fromPreference(manga?.readingModeType?.toInt())
return when { return when {
resolveDefault && readingMode == ReadingModeType.DEFAULT -> default resolveDefault && readingMode == ReadingModeType.DEFAULT -> default
else -> manga?.readingModeType ?: default else -> manga?.readingModeType?.toInt() ?: default
} }
} }
@ -652,22 +608,21 @@ class ReaderPresenter(
*/ */
fun setMangaReadingMode(readingModeType: Int) { fun setMangaReadingMode(readingModeType: Int) {
val manga = manga ?: return val manga = manga ?: return
manga.readingModeType = readingModeType viewModelScope.launchIO {
setMangaViewerFlags.awaitSetMangaReadingMode(manga.id, readingModeType.toLong())
coroutineScope.launchIO { val currChapters = state.value.viewerChapters
setMangaViewerFlags.awaitSetMangaReadingMode(manga.id!!.toLong(), readingModeType.toLong())
delay(250)
val currChapters = viewerChaptersRelay.value
if (currChapters != null) { if (currChapters != null) {
// Save current page // Save current page
val currChapter = currChapters.currChapter val currChapter = currChapters.currChapter
currChapter.requestedPage = currChapter.chapter.last_page_read currChapter.requestedPage = currChapter.chapter.last_page_read
withUIContext { mutableState.update {
// Emit manga and chapters to the new viewer it.copy(
view?.setManga(manga) manga = getManga.await(manga.id),
view?.setChapters(currChapters) viewerChapters = currChapters,
)
} }
eventChannel.send(Event.ReloadViewerChapters)
} }
} }
} }
@ -677,10 +632,10 @@ class ReaderPresenter(
*/ */
fun getMangaOrientationType(resolveDefault: Boolean = true): Int { fun getMangaOrientationType(resolveDefault: Boolean = true): Int {
val default = readerPreferences.defaultOrientationType().get() val default = readerPreferences.defaultOrientationType().get()
val orientation = OrientationType.fromPreference(manga?.orientationType) val orientation = OrientationType.fromPreference(manga?.orientationType?.toInt())
return when { return when {
resolveDefault && orientation == OrientationType.DEFAULT -> default resolveDefault && orientation == OrientationType.DEFAULT -> default
else -> manga?.orientationType ?: default else -> manga?.orientationType?.toInt() ?: default
} }
} }
@ -689,14 +644,22 @@ class ReaderPresenter(
*/ */
fun setMangaOrientationType(rotationType: Int) { fun setMangaOrientationType(rotationType: Int) {
val manga = manga ?: return val manga = manga ?: return
manga.orientationType = rotationType viewModelScope.launchIO {
setMangaViewerFlags.awaitSetOrientationType(manga.id, rotationType.toLong())
coroutineScope.launchIO { val currChapters = state.value.viewerChapters
setMangaViewerFlags.awaitSetOrientationType(manga.id!!.toLong(), rotationType.toLong())
delay(250)
val currChapters = viewerChaptersRelay.value
if (currChapters != null) { if (currChapters != null) {
withUIContext { view?.setOrientation(getMangaOrientationType()) } // Save current page
val currChapter = currChapters.currChapter
currChapter.requestedPage = currChapter.chapter.last_page_read
mutableState.update {
it.copy(
manga = getManga.await(manga.id),
viewerChapters = currChapters,
)
}
eventChannel.send(Event.SetOrientation(getMangaOrientationType()))
eventChannel.send(Event.ReloadViewerChapters)
} }
} }
} }
@ -733,8 +696,8 @@ class ReaderPresenter(
val relativePath = if (readerPreferences.folderPerManga().get()) DiskUtil.buildValidFilename(manga.title) else "" val relativePath = if (readerPreferences.folderPerManga().get()) DiskUtil.buildValidFilename(manga.title) else ""
// Copy file in background. // Copy file in background.
viewModelScope.launchNonCancellable {
try { try {
coroutineScope.launchNonCancellable {
val uri = imageSaver.save( val uri = imageSaver.save(
image = Image.Page( image = Image.Page(
inputStream = page.stream!!, inputStream = page.stream!!,
@ -744,12 +707,12 @@ class ReaderPresenter(
) )
withUIContext { withUIContext {
notifier.onComplete(uri) notifier.onComplete(uri)
view?.onSaveImageResult(SaveImageResult.Success(uri)) eventChannel.send(Event.SavedImage(SaveImageResult.Success(uri)))
}
} }
} catch (e: Throwable) { } catch (e: Throwable) {
notifier.onError(e.message) notifier.onError(e.message)
view?.onSaveImageResult(SaveImageResult.Error(e)) eventChannel.send(Event.SavedImage(SaveImageResult.Error(e)))
}
} }
} }
@ -770,7 +733,7 @@ class ReaderPresenter(
val filename = generateFilename(manga, page) val filename = generateFilename(manga, page)
try { try {
coroutineScope.launchNonCancellable { viewModelScope.launchNonCancellable {
destDir.deleteRecursively() destDir.deleteRecursively()
val uri = imageSaver.save( val uri = imageSaver.save(
image = Image.Page( image = Image.Page(
@ -779,9 +742,7 @@ class ReaderPresenter(
location = Location.Cache, location = Location.Cache,
), ),
) )
withUIContext { eventChannel.send(Event.ShareImage(uri, page))
view?.onShareImageResult(uri, page)
}
} }
} catch (e: Throwable) { } catch (e: Throwable) {
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
@ -793,24 +754,21 @@ class ReaderPresenter(
*/ */
fun setAsCover(context: Context, page: ReaderPage) { fun setAsCover(context: Context, page: ReaderPage) {
if (page.status != Page.State.READY) return if (page.status != Page.State.READY) return
val manga = manga?.toDomainManga() ?: return val manga = manga ?: return
val stream = page.stream ?: return val stream = page.stream ?: return
coroutineScope.launchNonCancellable { viewModelScope.launchNonCancellable {
try { val result = try {
manga.editCover(context, stream()) manga.editCover(context, stream())
withUIContext {
view?.onSetAsCoverResult(
if (manga.isLocal() || manga.favorite) { if (manga.isLocal() || manga.favorite) {
SetAsCoverResult.Success SetAsCoverResult.Success
} else { } else {
SetAsCoverResult.AddToLibraryFirst SetAsCoverResult.AddToLibraryFirst
},
)
} }
} catch (e: Exception) { } catch (e: Exception) {
withUIContext { view?.onSetAsCoverResult(SetAsCoverResult.Error) } SetAsCoverResult.Error
} }
eventChannel.send(Event.SetCoverResult(result))
} }
} }
@ -842,8 +800,8 @@ class ReaderPresenter(
val trackManager = Injekt.get<TrackManager>() val trackManager = Injekt.get<TrackManager>()
val context = Injekt.get<Application>() val context = Injekt.get<Application>()
coroutineScope.launchNonCancellable { viewModelScope.launchNonCancellable {
getTracks.await(manga.id!!) getTracks.await(manga.id)
.mapNotNull { track -> .mapNotNull { track ->
val service = trackManager.getService(track.syncId) val service = trackManager.getService(track.syncId)
if (service != null && service.isLogged && chapterRead > track.lastChapterRead) { if (service != null && service.isLogged && chapterRead > track.lastChapterRead) {
@ -882,8 +840,8 @@ class ReaderPresenter(
if (!chapter.chapter.read) return if (!chapter.chapter.read) return
val manga = manga ?: return val manga = manga ?: return
coroutineScope.launchNonCancellable { viewModelScope.launchNonCancellable {
downloadManager.enqueueChaptersToDelete(listOf(chapter.chapter.toDomainChapter()!!), manga.toDomainManga()!!) downloadManager.enqueueChaptersToDelete(listOf(chapter.chapter.toDomainChapter()!!), manga)
} }
} }
@ -892,35 +850,26 @@ class ReaderPresenter(
* are ignored. * are ignored.
*/ */
private fun deletePendingChapters() { private fun deletePendingChapters() {
coroutineScope.launchNonCancellable { viewModelScope.launchNonCancellable {
downloadManager.deletePendingChapters() downloadManager.deletePendingChapters()
} }
} }
// We're trying to avoid using Rx, so we "undeprecate" this data class State(
@Suppress("DEPRECATION") val manga: Manga? = null,
override fun getView(): ReaderActivity? { val viewerChapters: ViewerChapters? = null,
return super.getView() val isLoadingAdjacentChapter: Boolean = false,
)
sealed class Event {
object ReloadViewerChapters : Event()
data class SetOrientation(val orientation: Int) : Event()
data class SetCoverResult(val result: SetAsCoverResult) : Event()
data class SavedImage(val result: SaveImageResult) : Event()
data class ShareImage(val uri: Uri, val page: ReaderPage) : Event()
} }
/**
* Subscribes an observable with [deliverFirst] and adds it to the presenter's lifecycle
* subscription list.
*
* @param onNext function to execute when the observable emits an item.
* @param onError function to execute when the observable throws an error.
*/
private fun <T> Observable<T>.subscribeFirst(onNext: (ReaderActivity, T) -> Unit, onError: ((ReaderActivity, Throwable) -> Unit) = { _, _ -> }) = compose(deliverFirst<T>()).subscribe(split(onNext, onError)).apply { add(this) }
/**
* Subscribes an observable with [deliverLatestCache] and adds it to the presenter's lifecycle
* subscription list.
*
* @param onNext function to execute when the observable emits an item.
* @param onError function to execute when the observable throws an error.
*/
private fun <T> Observable<T>.subscribeLatestCache(onNext: (ReaderActivity, T) -> Unit, onError: ((ReaderActivity, Throwable) -> Unit) = { _, _ -> }) = compose(deliverLatestCache<T>()).subscribe(split(onNext, onError)).apply { add(this) }
companion object { companion object {
// Safe theoretical max filename size is 255 bytes and 1 char = 2-4 bytes (UTF-8) // Safe theoretical max filename size is 255 bytes and 1 char = 2-4 bytes (UTF-8)
private const val MAX_FILE_NAME_BYTES = 250 private const val MAX_FILE_NAME_BYTES = 250

View file

@ -44,22 +44,22 @@ class ReaderReadingModeSettings @JvmOverloads constructor(context: Context, attr
private fun initGeneralPreferences() { private fun initGeneralPreferences() {
binding.viewer.onItemSelectedListener = { position -> binding.viewer.onItemSelectedListener = { position ->
val readingModeType = ReadingModeType.fromSpinner(position) val readingModeType = ReadingModeType.fromSpinner(position)
(context as ReaderActivity).presenter.setMangaReadingMode(readingModeType.flagValue) (context as ReaderActivity).viewModel.setMangaReadingMode(readingModeType.flagValue)
val mangaViewer = (context as ReaderActivity).presenter.getMangaReadingMode() val mangaViewer = (context as ReaderActivity).viewModel.getMangaReadingMode()
if (mangaViewer == ReadingModeType.WEBTOON.flagValue || mangaViewer == ReadingModeType.CONTINUOUS_VERTICAL.flagValue) { if (mangaViewer == ReadingModeType.WEBTOON.flagValue || mangaViewer == ReadingModeType.CONTINUOUS_VERTICAL.flagValue) {
initWebtoonPreferences() initWebtoonPreferences()
} else { } else {
initPagerPreferences() initPagerPreferences()
} }
} }
binding.viewer.setSelection((context as ReaderActivity).presenter.manga?.readingModeType?.let { ReadingModeType.fromPreference(it).prefValue } ?: ReadingModeType.DEFAULT.prefValue) binding.viewer.setSelection((context as ReaderActivity).viewModel.manga?.readingModeType?.let { ReadingModeType.fromPreference(it.toInt()).prefValue } ?: ReadingModeType.DEFAULT.prefValue)
binding.rotationMode.onItemSelectedListener = { position -> binding.rotationMode.onItemSelectedListener = { position ->
val rotationType = OrientationType.fromSpinner(position) val rotationType = OrientationType.fromSpinner(position)
(context as ReaderActivity).presenter.setMangaOrientationType(rotationType.flagValue) (context as ReaderActivity).viewModel.setMangaOrientationType(rotationType.flagValue)
} }
binding.rotationMode.setSelection((context as ReaderActivity).presenter.manga?.orientationType?.let { OrientationType.fromPreference(it).prefValue } ?: OrientationType.DEFAULT.prefValue) binding.rotationMode.setSelection((context as ReaderActivity).viewModel.manga?.orientationType?.let { OrientationType.fromPreference(it.toInt()).prefValue } ?: OrientationType.DEFAULT.prefValue)
} }
/** /**

View file

@ -11,8 +11,8 @@ import androidx.core.text.bold
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import androidx.core.view.isVisible import androidx.core.view.isVisible
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.databinding.ReaderTransitionViewBinding import eu.kanade.tachiyomi.databinding.ReaderTransitionViewBinding
import eu.kanade.tachiyomi.ui.reader.loader.DownloadPageLoader import eu.kanade.tachiyomi.ui.reader.loader.DownloadPageLoader

View file

@ -62,7 +62,7 @@ class PagerTransitionHolder(
addView(transitionView) addView(transitionView)
addView(pagesContainer) addView(pagesContainer)
transitionView.bind(transition, viewer.downloadManager, viewer.activity.presenter.manga) transitionView.bind(transition, viewer.downloadManager, viewer.activity.viewModel.manga)
transition.to?.let { observeStatus(it) } transition.to?.let { observeStatus(it) }
} }

View file

@ -64,7 +64,7 @@ class WebtoonTransitionHolder(
* Binds the given [transition] with this view holder, subscribing to its state. * Binds the given [transition] with this view holder, subscribing to its state.
*/ */
fun bind(transition: ChapterTransition) { fun bind(transition: ChapterTransition) {
transitionView.bind(transition, viewer.downloadManager, viewer.activity.presenter.manga) transitionView.bind(transition, viewer.downloadManager, viewer.activity.viewModel.manga)
transition.to?.let { observeStatus(it, transition) } transition.to?.let { observeStatus(it, transition) }
} }

View file

@ -1,7 +1,6 @@
[versions] [versions]
aboutlib_version = "10.5.2" aboutlib_version = "10.5.2"
okhttp_version = "5.0.0-alpha.10" okhttp_version = "5.0.0-alpha.10"
nucleus_version = "3.0.0"
coil_version = "2.2.2" coil_version = "2.2.2"
shizuku_version = "12.2.0" shizuku_version = "12.2.0"
sqlite = "2.3.0-rc01" sqlite = "2.3.0-rc01"
@ -41,9 +40,6 @@ sqlite-android = "com.github.requery:sqlite-android:3.39.2"
preferencektx = "androidx.preference:preference-ktx:1.2.0" preferencektx = "androidx.preference:preference-ktx:1.2.0"
nucleus-core = { module = "info.android15.nucleus:nucleus", version.ref = "nucleus_version" }
nucleus-supportv7 = { module = "info.android15.nucleus:nucleus-support-v7", version.ref = "nucleus_version" }
injekt-core = "com.github.inorichi.injekt:injekt-core:65b0440" injekt-core = "com.github.inorichi.injekt:injekt-core:65b0440"
coil-core = { module = "io.coil-kt:coil", version.ref = "coil_version" } coil-core = { module = "io.coil-kt:coil", version.ref = "coil_version" }
@ -97,7 +93,6 @@ reactivex = ["rxandroid", "rxjava", "rxrelay"]
okhttp = ["okhttp-core", "okhttp-logging", "okhttp-dnsoverhttps"] okhttp = ["okhttp-core", "okhttp-logging", "okhttp-dnsoverhttps"]
js-engine = ["quickjs-android"] js-engine = ["quickjs-android"]
sqlite = ["sqlite-framework", "sqlite-ktx", "sqlite-android"] sqlite = ["sqlite-framework", "sqlite-ktx", "sqlite-android"]
nucleus = ["nucleus-core", "nucleus-supportv7"]
coil = ["coil-core", "coil-gif", "coil-compose"] coil = ["coil-core", "coil-gif", "coil-compose"]
shizuku = ["shizuku-api", "shizuku-provider"] shizuku = ["shizuku-api", "shizuku-provider"]
voyager = ["voyager-navigator", "voyager-tab-navigator", "voyager-transitions"] voyager = ["voyager-navigator", "voyager-tab-navigator", "voyager-transitions"]