ソースを参照

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 <[email protected]>
Ivan Iskandar 2 年 前
コミット
f7a92cf6ac

+ 0 - 2
.github/renovate.json

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

+ 0 - 3
app/build.gradle.kts

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

+ 8 - 25
app/src/main/java/eu/kanade/domain/manga/model/Manga.kt

@@ -1,17 +1,16 @@
 package eu.kanade.domain.manga.model
 
-import eu.kanade.data.listOfStringsAdapter
 import eu.kanade.domain.base.BasePreferences
 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.model.SManga
 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 uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 import java.io.Serializable
-import eu.kanade.tachiyomi.data.database.models.Manga as DbManga
 
 data class Manga(
     val id: Long,
@@ -49,6 +48,12 @@ data class Manga(
     val bookmarkedFilterRaw: Long
         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
         get() = when (unreadFilterRaw) {
             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 {
     return MangaUpdate(
         id = id,

+ 119 - 55
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt

@@ -29,6 +29,8 @@ import android.view.animation.Animation
 import android.view.animation.AnimationUtils
 import android.widget.FrameLayout
 import android.widget.Toast
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
 import androidx.core.graphics.ColorUtils
 import androidx.core.transition.doOnEnd
 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 dev.chrisbanes.insetter.applyInsetter
 import eu.kanade.domain.base.BasePreferences
+import eu.kanade.domain.manga.model.Manga
 import eu.kanade.tachiyomi.BuildConfig
 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.Notifications
 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.ThemingDelegateImpl
 import eu.kanade.tachiyomi.ui.main.MainActivity
-import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.AddToLibraryFirst
-import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.Error
-import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.Success
+import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.AddToLibraryFirst
+import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Error
+import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Success
 import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
 import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
 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.webview.WebViewActivity
 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.system.applySystemAnimatorScale
 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.setTooltip
 import eu.kanade.tachiyomi.widget.listener.SimpleAnimationListener
+import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.filterNotNull
 import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.merge
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.sample
+import kotlinx.coroutines.launch
 import logcat.LogPriority
-import nucleus.factory.RequiresPresenter
-import nucleus.view.NucleusAppCompatActivity
 import uy.kohesive.injekt.injectLazy
 import kotlin.math.abs
 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
  * viewers, to which calls from the presenter or UI events are delegated.
  */
-@RequiresPresenter(ReaderPresenter::class)
 class ReaderActivity :
-    NucleusAppCompatActivity<ReaderPresenter>(),
+    AppCompatActivity(),
     SecureActivityDelegate by SecureActivityDelegateImpl(),
     ThemingDelegate by ThemingDelegateImpl() {
 
@@ -128,6 +133,8 @@ class ReaderActivity :
 
     lateinit var binding: ReaderActivityBinding
 
+    val viewModel by viewModels<ReaderViewModel>()
+
     val hasCutout by lazy { hasDisplayCutout() }
 
     /**
@@ -194,7 +201,7 @@ class ReaderActivity :
         binding = ReaderActivityBinding.inflate(layoutInflater)
         setContentView(binding.root)
 
-        if (presenter.needsInit()) {
+        if (viewModel.needsInit()) {
             val manga = intent.extras!!.getLong("manga", -1)
             val chapter = intent.extras!!.getLong("chapter", -1)
             if (manga == -1L || chapter == -1L) {
@@ -202,7 +209,16 @@ class ReaderActivity :
                 return
             }
             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) {
@@ -217,6 +233,48 @@ class ReaderActivity :
             .drop(1)
             .onEach { if (!it) finish() }
             .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) {
         outState.putBoolean(::menuVisible.name, menuVisible)
         if (!isChangingConfigurations) {
-            presenter.onSaveInstanceStateNonConfigurationChange()
+            viewModel.onSaveInstanceStateNonConfigurationChange()
         }
         super.onSaveInstanceState(outState)
     }
 
     override fun onPause() {
-        presenter.saveCurrentChapterReadingProgress()
+        viewModel.saveCurrentChapterReadingProgress()
         super.onPause()
     }
 
@@ -256,7 +314,7 @@ class ReaderActivity :
      */
     override fun onResume() {
         super.onResume()
-        presenter.setReadStartTime()
+        viewModel.setReadStartTime()
         setMenuVisibility(menuVisible, animate = false)
     }
 
@@ -277,7 +335,7 @@ class ReaderActivity :
     override fun onCreateOptionsMenu(menu: Menu): Boolean {
         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_remove_bookmark).isVisible = isChapterBookmarked
 
@@ -294,11 +352,11 @@ class ReaderActivity :
                 openChapterInWebview()
             }
             R.id.action_bookmark -> {
-                presenter.bookmarkCurrentChapter(true)
+                viewModel.bookmarkCurrentChapter(true)
                 invalidateOptionsMenu()
             }
             R.id.action_remove_bookmark -> {
-                presenter.bookmarkCurrentChapter(false)
+                viewModel.bookmarkCurrentChapter(false)
                 invalidateOptionsMenu()
             }
         }
@@ -309,17 +367,17 @@ class ReaderActivity :
      * Called when the user clicks the back key or the button on the toolbar. The call is
      * delegated to the presenter.
      */
-    override fun onBackPressed() {
-        presenter.onBackPressed()
-        super.onBackPressed()
+    override fun finish() {
+        viewModel.onActivityFinish()
+        super.finish()
     }
 
     override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
         if (keyCode == KeyEvent.KEYCODE_N) {
-            presenter.loadNextChapter()
+            loadNextChapter()
             return true
         } else if (keyCode == KeyEvent.KEYCODE_P) {
-            presenter.loadPreviousChapter()
+            loadPreviousChapter()
             return true
         }
         return super.onKeyUp(keyCode, event)
@@ -356,7 +414,7 @@ class ReaderActivity :
         setSupportActionBar(binding.toolbar)
         supportActionBar?.setDisplayHomeAsUpEnabled(true)
         binding.toolbar.setNavigationOnClickListener {
-            onBackPressed()
+            onBackPressedDispatcher.onBackPressed()
         }
 
         binding.toolbar.applyInsetter {
@@ -371,7 +429,7 @@ class ReaderActivity :
         }
 
         binding.toolbar.setOnClickListener {
-            presenter.manga?.id?.let { id ->
+            viewModel.manga?.id?.let { id ->
                 startActivity(
                     Intent(this, MainActivity::class.java).apply {
                         action = MainActivity.SHORTCUT_MANGA
@@ -461,11 +519,11 @@ class ReaderActivity :
             setOnClickListener {
                 popupMenu(
                     items = ReadingModeType.values().map { it.flagValue to it.stringRes },
-                    selectedItemId = presenter.getMangaReadingMode(resolveDefault = false),
+                    selectedItemId = viewModel.getMangaReadingMode(resolveDefault = false),
                 ) {
                     val newReadingMode = ReadingModeType.fromPreference(itemId)
 
-                    presenter.setMangaReadingMode(newReadingMode.flagValue)
+                    viewModel.setMangaReadingMode(newReadingMode.flagValue)
 
                     menuToggleToast?.cancel()
                     if (!readerPreferences.showReadingMode().get()) {
@@ -482,7 +540,7 @@ class ReaderActivity :
             setTooltip(R.string.pref_crop_borders)
 
             setOnClickListener {
-                val isPagerType = ReadingModeType.isPagerType(presenter.getMangaReadingMode())
+                val isPagerType = ReadingModeType.isPagerType(viewModel.getMangaReadingMode())
                 val enabled = if (isPagerType) {
                     readerPreferences.cropBorders().toggle()
                 } else {
@@ -514,12 +572,12 @@ class ReaderActivity :
             setOnClickListener {
                 popupMenu(
                     items = OrientationType.values().map { it.flagValue to it.stringRes },
-                    selectedItemId = presenter.manga?.orientationType
+                    selectedItemId = viewModel.manga?.orientationType?.toInt()
                         ?: readerPreferences.defaultOrientationType().get(),
                 ) {
                     val newOrientation = OrientationType.fromPreference(itemId)
 
-                    presenter.setMangaOrientationType(newOrientation.flagValue)
+                    viewModel.setMangaOrientationType(newOrientation.flagValue)
 
                     menuToggleToast?.cancel()
                     menuToggleToast = toast(newOrientation.stringRes)
@@ -550,7 +608,7 @@ class ReaderActivity :
     }
 
     private fun updateCropBordersShortcut() {
-        val isPagerType = ReadingModeType.isPagerType(presenter.getMangaReadingMode())
+        val isPagerType = ReadingModeType.isPagerType(viewModel.getMangaReadingMode())
         val enabled = if (isPagerType) {
             readerPreferences.cropBorders().get()
         } else {
@@ -633,19 +691,19 @@ class ReaderActivity :
     fun setManga(manga: Manga) {
         val prevViewer = viewer
 
-        val viewerMode = ReadingModeType.fromPreference(presenter.getMangaReadingMode(resolveDefault = false))
+        val viewerMode = ReadingModeType.fromPreference(viewModel.getMangaReadingMode(resolveDefault = false))
         binding.actionReadingMode.setImageResource(viewerMode.iconRes)
 
-        val newViewer = ReadingModeType.toViewer(presenter.getMangaReadingMode(), this)
+        val newViewer = ReadingModeType.toViewer(viewModel.getMangaReadingMode(), this)
 
         updateCropBordersShortcut()
         if (window.sharedElementEnterTransition is MaterialContainerTransform) {
             // Wait until transition is complete to avoid crash on API 26
             window.sharedElementEnterTransition.doOnEnd {
-                setOrientation(presenter.getMangaOrientationType())
+                setOrientation(viewModel.getMangaOrientationType())
             }
         } else {
-            setOrientation(presenter.getMangaOrientationType())
+            setOrientation(viewModel.getMangaOrientationType())
         }
 
         // Destroy previous viewer if there was one
@@ -658,10 +716,10 @@ class ReaderActivity :
         binding.viewerContainer.addView(newViewer.getView())
 
         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
         if (newViewer is R2LPagerViewer) {
@@ -684,9 +742,9 @@ class ReaderActivity :
     }
 
     private fun openChapterInWebview() {
-        val manga = presenter.manga ?: return
-        val source = presenter.getSource() ?: return
-        val url = presenter.getChapterUrl() ?: return
+        val manga = viewModel.manga ?: return
+        val source = viewModel.getSource() ?: return
+        val url = viewModel.getChapterUrl() ?: return
 
         val intent = WebViewActivity.newIntent(this, url, source.id, manga.title)
         startActivity(intent)
@@ -707,7 +765,7 @@ class ReaderActivity :
      * 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
      */
-    fun setChapters(viewerChapters: ViewerChapters) {
+    private fun setChapters(viewerChapters: ViewerChapters) {
         binding.readerContainer.removeView(loadingIndicator)
         viewer?.setChapters(viewerChapters)
         binding.toolbar.subtitle = viewerChapters.currChapter.chapter.name
@@ -765,7 +823,7 @@ class ReaderActivity :
      */
     fun moveToPageIndex(index: Int) {
         val viewer = viewer ?: return
-        val currentChapter = presenter.getCurrentChapter() ?: return
+        val currentChapter = viewModel.getCurrentChapter() ?: return
         val page = currentChapter.pages?.getOrNull(index) ?: return
         viewer.moveToPage(page)
     }
@@ -775,7 +833,10 @@ class ReaderActivity :
      * should be automatically shown.
      */
     private fun loadNextChapter() {
-        presenter.loadNextChapter()
+        lifecycleScope.launch {
+            viewModel.loadNextChapter()
+            moveToPageIndex(0)
+        }
     }
 
     /**
@@ -783,7 +844,10 @@ class ReaderActivity :
      * should be automatically shown.
      */
     private fun loadPreviousChapter() {
-        presenter.loadPreviousChapter()
+        lifecycleScope.launch {
+            viewModel.loadPreviousChapter()
+            moveToPageIndex(0)
+        }
     }
 
     /**
@@ -792,7 +856,7 @@ class ReaderActivity :
      */
     @SuppressLint("SetTextI18n")
     fun onPageSelected(page: ReaderPage) {
-        presenter.onPageSelected(page)
+        viewModel.onPageSelected(page)
         val pages = page.chapter.pages ?: return
 
         // 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.
      */
     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.
      */
     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
      * sharing tool.
      */
-    fun onShareImageResult(uri: Uri, page: ReaderPage) {
-        val manga = presenter.manga ?: return
+    private fun onShareImageResult(uri: Uri, page: ReaderPage) {
+        val manga = viewModel.manga ?: return
         val chapter = page.chapter.chapter
 
         val intent = uri.toShareIntent(
@@ -883,19 +947,19 @@ class ReaderActivity :
      * storage to the presenter.
      */
     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
      * event depending on the [result].
      */
-    fun onSaveImageResult(result: ReaderPresenter.SaveImageResult) {
+    private fun onSaveImageResult(result: ReaderViewModel.SaveImageResult) {
         when (result) {
-            is ReaderPresenter.SaveImageResult.Success -> {
+            is ReaderViewModel.SaveImageResult.Success -> {
                 toast(R.string.picture_saved)
             }
-            is ReaderPresenter.SaveImageResult.Error -> {
+            is ReaderViewModel.SaveImageResult.Error -> {
                 logcat(LogPriority.ERROR, result.error)
             }
         }
@@ -906,14 +970,14 @@ class ReaderActivity :
      * cover to the presenter.
      */
     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
      * depending on the [result].
      */
-    fun onSetAsCoverResult(result: ReaderPresenter.SetAsCoverResult) {
+    private fun onSetAsCoverResult(result: ReaderViewModel.SetAsCoverResult) {
         toast(
             when (result) {
                 Success -> R.string.cover_updated
@@ -926,12 +990,12 @@ class ReaderActivity :
     /**
      * Forces the user preferred [orientation] on the activity.
      */
-    fun setOrientation(orientation: Int) {
+    private fun setOrientation(orientation: Int) {
         val newOrientation = OrientationType.fromPreference(orientation)
         if (newOrientation.flag != requestedOrientation) {
             requestedOrientation = newOrientation.flag
         }
-        updateOrientationShortcut(presenter.getMangaOrientationType(resolveDefault = false))
+        updateOrientationShortcut(viewModel.getMangaOrientationType(resolveDefault = false))
     }
 
     /**

+ 181 - 232
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt → app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt

@@ -3,8 +3,10 @@ package eu.kanade.tachiyomi.ui.reader
 import android.app.Application
 import android.content.Context
 import android.net.Uri
-import android.os.Bundle
-import com.jakewharton.rxrelay.BehaviorRelay
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import eu.kanade.core.util.asFlow
 import eu.kanade.domain.base.BasePreferences
 import eu.kanade.domain.chapter.interactor.GetChapterByMangaId
 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.manga.interactor.GetManga
 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.toDbManga
 import eu.kanade.domain.track.interactor.GetTracks
 import eu.kanade.domain.track.interactor.InsertTrack
 import eu.kanade.domain.track.model.toDbTrack
 import eu.kanade.domain.track.service.DelayedTrackingUpdateJob
 import eu.kanade.domain.track.service.TrackPreferences
 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.toDomainManga
 import eu.kanade.tachiyomi.data.download.DownloadManager
 import eu.kanade.tachiyomi.data.download.DownloadProvider
 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.launchNonCancellable
 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.storage.DiskUtil
 import eu.kanade.tachiyomi.util.storage.cacheImageDir
 import eu.kanade.tachiyomi.util.system.isOnline
 import eu.kanade.tachiyomi.util.system.logcat
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.async
 import kotlinx.coroutines.awaitAll
-import kotlinx.coroutines.cancel
 import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.MutableStateFlow
+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.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.runBlocking
 import logcat.LogPriority
-import nucleus.presenter.RxPresenter
 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
 import java.util.Date
-import eu.kanade.domain.manga.model.Manga as DomainManga
 
 /**
  * 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 downloadManager: DownloadManager = Injekt.get(),
     private val downloadProvider: DownloadProvider = Injekt.get(),
@@ -102,20 +106,28 @@ class ReaderPresenter(
     private val upsertHistory: UpsertHistory = Injekt.get(),
     private val updateChapter: UpdateChapter = 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.
      */
-    var manga: Manga? = null
-        private set
+    val manga: Manga?
+        get() = state.value.manga
 
     /**
      * 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.
@@ -132,16 +144,6 @@ class ReaderPresenter(
      */
     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
 
     /**
@@ -149,7 +151,7 @@ class ReaderPresenter(
      * time in a background thread to avoid blocking the UI.
      */
     private val chapterList by lazy {
-        val manga = manga!!.toDomainManga()!!
+        val manga = manga!!
         val chapters = runBlocking { getChapterByMangaId.await(manga.id) }
 
         val selectedChapter = chapters.find { it.id == chapterId }
@@ -161,12 +163,12 @@ class ReaderPresenter(
                     when {
                         readerPreferences.skipRead().get() && it.read -> true
                         readerPreferences.skipFiltered().get() -> {
-                            (manga.unreadFilterRaw == DomainManga.CHAPTER_SHOW_READ && !it.read) ||
-                                (manga.unreadFilterRaw == DomainManga.CHAPTER_SHOW_UNREAD && it.read) ||
-                                (manga.downloadedFilterRaw == DomainManga.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.bookmarkedFilterRaw == DomainManga.CHAPTER_SHOW_BOOKMARKED && !it.bookmark) ||
-                                (manga.bookmarkedFilterRaw == DomainManga.CHAPTER_SHOW_NOT_BOOKMARKED && it.bookmark)
+                            (manga.unreadFilterRaw == Manga.CHAPTER_SHOW_READ && !it.read) ||
+                                (manga.unreadFilterRaw == Manga.CHAPTER_SHOW_UNREAD && it.read) ||
+                                (manga.downloadedFilterRaw == Manga.CHAPTER_SHOW_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 == Manga.CHAPTER_SHOW_BOOKMARKED && !it.bookmark) ||
+                                (manga.bookmarkedFilterRaw == Manga.CHAPTER_SHOW_NOT_BOOKMARKED && it.bookmark)
                         }
                         else -> false
                     }
@@ -188,32 +190,15 @@ class ReaderPresenter(
     }
 
     private var hasTrackers: Boolean = false
-    private val checkTrackers: (DomainManga) -> Unit = { manga ->
+    private val checkTrackers: (Manga) -> Unit = { manga ->
         val tracks = runBlocking { getTracks.await(manga.id) }
         hasTrackers = tracks.isNotEmpty()
     }
 
     private val incognitoMode = preferences.incognitoMode().get()
 
-    /**
-     * Called when the presenter is created. It retrieves the saved active chapter if the process
-     * 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
+    override fun onCleared() {
+        val currentChapters = state.value.viewerChapters
         if (currentChapters != null) {
             currentChapters.unref()
             saveReadingProgress(currentChapters.currChapter)
@@ -223,24 +208,24 @@ class ReaderPresenter(
         }
     }
 
-    /**
-     * Called when the presenter instance is being saved. It saves the currently active chapter
-     * id and the last page read.
-     */
-    override fun onSave(state: Bundle) {
-        super.onSave(state)
-        val currentChapter = getCurrentChapter()
-        if (currentChapter != null) {
-            currentChapter.requestedPage = currentChapter.chapter.last_page_read
-            state.putLong(::chapterId.name, currentChapter.chapter.id!!)
-        }
+    init {
+        // To save state
+        state.map { it.viewerChapters?.currChapter }
+            .distinctUntilChanged()
+            .onEach { currentChapter ->
+                if (currentChapter != null) {
+                    currentChapter.requestedPage = currentChapter.chapter.last_page_read
+                    chapterId = currentChapter.chapter.id!!
+                }
+            }
+            .launchIn(viewModelScope)
     }
 
     /**
      * Called when the user pressed the back button and is going to leave the reader. Used to
      * trigger deletion of the downloaded chapters.
      */
-    fun onBackPressed() {
+    fun onActivityFinish() {
         deletePendingChapters()
     }
 
@@ -250,7 +235,7 @@ class ReaderPresenter(
      */
     fun onSaveInstanceStateNonConfigurationChange() {
         val currentChapter = getCurrentChapter() ?: return
-        coroutineScope.launchNonCancellable {
+        viewModelScope.launchNonCancellable {
             saveChapterProgress(currentChapter)
         }
     }
@@ -266,58 +251,33 @@ class ReaderPresenter(
      * Initializes this presenter with the given [mangaId] and [initialChapterId]. This method will
      * fetch the manga from the database and initialize the initial chapter.
      */
-    fun init(mangaId: Long, initialChapterId: Long) {
-        if (!needsInit()) return
-
-        coroutineScope.launchIO {
+    suspend fun init(mangaId: Long, initialChapterId: Long): Result<Boolean> {
+        if (!needsInit()) return Result.success(true)
+        return withIOContext {
             try {
                 val manga = getManga.await(mangaId)
-                withUIContext {
-                    manga?.let { init(it.toDbManga(), initialChapterId) }
-                }
-            } 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
+                if (manga != null) {
+                    mutableState.update { it.copy(manga = manga) }
+                    if (chapterId == -1L) chapterId = initialChapterId
 
-        this.manga = manga
-        if (chapterId == -1L) chapterId = initialChapterId
+                    checkTrackers(manga)
 
-        checkTrackers(manga.toDomainManga()!!)
+                    val context = Injekt.get<Application>()
+                    val source = sourceManager.getOrStub(manga.source)
+                    loader = ChapterLoader(context, downloadManager, downloadProvider, manga, source)
 
-        val context = Injekt.get<Application>()
-        val source = sourceManager.getOrStub(manga.source)
-        loader = ChapterLoader(context, downloadManager, downloadProvider, manga.toDomainManga()!!, source)
-
-        Observable.just(manga).subscribeLatestCache(ReaderActivity::setManga)
-        viewerChaptersRelay.subscribeLatestCache(ReaderActivity::setChapters)
-        coroutineScope.launch {
-            isLoadingAdjacentChapterEvent.receiveAsFlow().collectLatest {
-                view?.setProgressDialog(it)
+                    getLoadObservable(loader!!, chapterList.first { chapterId == it.chapter.id })
+                        .asFlow()
+                        .first()
+                    Result.success(true)
+                } 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())
             .doOnNext { newChapters ->
-                val oldChapters = viewerChaptersRelay.value
-
-                // Add new references first to avoid unnecessary recycling
-                newChapters.ref()
-                oldChapters?.unref()
+                mutableState.update {
+                    // Add new references first to avoid unnecessary recycling
+                    newChapters.ref()
+                    it.viewerChapters?.unref()
 
-                chapterToDownload = cancelQueuedDownloads(newChapters.currChapter)
-                viewerChaptersRelay.call(newChapters)
+                    chapterToDownload = cancelQueuedDownloads(newChapters.currChapter)
+                    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.
      * 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
 
         logcat { "Loading ${chapter.chapter.url}" }
 
-        activeChapterSubscription?.unsubscribe()
-        activeChapterSubscription = getLoadObservable(loader, chapter)
-            .toCompletable()
-            .onErrorComplete()
-            .subscribe()
-            .also(::add)
+        withIOContext {
+            getLoadObservable(loader, chapter)
+                .asFlow()
+                .catch { logcat(LogPriority.ERROR, it) }
+                .first()
+        }
     }
 
     /**
@@ -378,30 +338,25 @@ class ReaderPresenter(
      * sets the [isLoadingAdjacentChapterRelay] that the view uses to prevent any further
      * interaction until the chapter is loaded.
      */
-    private fun loadAdjacent(chapter: ReaderChapter) {
+    private suspend fun loadAdjacent(chapter: ReaderChapter) {
         val loader = loader ?: return
 
         logcat { "Loading adjacent ${chapter.chapter.url}" }
 
-        activeChapterSubscription?.unsubscribe()
-        activeChapterSubscription = getLoadObservable(loader, chapter)
-            .doOnSubscribe { coroutineScope.launch { isLoadingAdjacentChapterEvent.send(true) } }
-            .doOnUnsubscribe { coroutineScope.launch { isLoadingAdjacentChapterEvent.send(false) } }
-            .subscribeFirst(
-                { view, _ ->
-                    view.moveToPageIndex(0)
-                },
-                { _, _ ->
-                    // Ignore onError event, viewers handle that state
-                },
-            )
+        mutableState.update { it.copy(isLoadingAdjacentChapter = true) }
+        withIOContext {
+            getLoadObservable(loader, chapter)
+                .asFlow()
+                .first()
+        }
+        mutableState.update { it.copy(isLoadingAdjacentChapter = false) }
     }
 
     /**
      * 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.
      */
-    private fun preload(chapter: ReaderChapter) {
+    private suspend fun preload(chapter: ReaderChapter) {
         if (chapter.pageLoader is HttpPageLoader) {
             val manga = manga ?: return
             val dbChapter = chapter.chapter
@@ -424,13 +379,14 @@ class ReaderPresenter(
         logcat { "Preloading ${chapter.chapter.url}" }
 
         val loader = loader ?: return
-        loader.loadChapter(chapter)
-            .observeOn(AndroidSchedulers.mainThread())
-            // Update current chapters whenever a chapter is preloaded
-            .doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) }
-            .onErrorComplete()
-            .subscribe()
-            .also(::add)
+        withIOContext {
+            loader.loadChapter(chapter)
+                .doOnCompleted { eventChannel.trySend(Event.ReloadViewerChapters) }
+                .onErrorComplete()
+                .toObservable<Unit>()
+                .asFlow()
+                .firstOrNull()
+        }
     }
 
     /**
@@ -439,7 +395,7 @@ class ReaderPresenter(
      * [page]'s chapter is different from the currently active.
      */
     fun onPageSelected(page: ReaderPage) {
-        val currentChapters = viewerChaptersRelay.value ?: return
+        val currentChapters = state.value.viewerChapters ?: return
 
         val selectedChapter = page.chapter
 
@@ -461,7 +417,7 @@ class ReaderPresenter(
             logcat { "Setting ${selectedChapter.chapter.url} as active" }
             saveReadingProgress(currentChapters.currChapter)
             setReadStartTime()
-            loadNewChapter(selectedChapter)
+            viewModelScope.launch { loadNewChapter(selectedChapter) }
         }
         val pages = page.chapter.pages ?: return
         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
         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(
                 nextChapter.name,
                 nextChapter.scanlator,
@@ -488,10 +444,10 @@ class ReaderPresenter(
             )
             if (!isNextChapterDownloaded) return@launchIO
 
-            val chaptersToDownload = getNextChapters.await(manga.id!!, nextChapter.id!!)
+            val chaptersToDownload = getNextChapters.await(manga.id, nextChapter.id!!)
                 .take(amount)
             downloadManager.downloadChapters(
-                manga.toDomainManga()!!,
+                manga,
                 chaptersToDownload,
             )
         }
@@ -535,7 +491,7 @@ class ReaderPresenter(
      * Called when reader chapter is changed in reader or when activity is paused.
      */
     private fun saveReadingProgress(readerChapter: ReaderChapter) {
-        coroutineScope.launchNonCancellable {
+        viewModelScope.launchNonCancellable {
             saveChapterProgress(readerChapter)
             saveChapterHistory(readerChapter)
         }
@@ -583,23 +539,23 @@ class ReaderPresenter(
     /**
      * Called from the activity to preload the given [chapter].
      */
-    fun preloadChapter(chapter: ReaderChapter) {
+    suspend fun preloadChapter(chapter: ReaderChapter) {
         preload(chapter)
     }
 
     /**
      * Called from the activity to load and set the next chapter as active.
      */
-    fun loadNextChapter() {
-        val nextChapter = viewerChaptersRelay.value?.nextChapter ?: return
+    suspend fun loadNextChapter() {
+        val nextChapter = state.value.viewerChapters?.nextChapter ?: return
         loadAdjacent(nextChapter)
     }
 
     /**
      * Called from the activity to load and set the previous chapter as active.
      */
-    fun loadPreviousChapter() {
-        val prevChapter = viewerChaptersRelay.value?.prevChapter ?: return
+    suspend fun loadPreviousChapter() {
+        val prevChapter = state.value.viewerChapters?.prevChapter ?: return
         loadAdjacent(prevChapter)
     }
 
@@ -607,7 +563,7 @@ class ReaderPresenter(
      * Returns the currently active chapter.
      */
     fun getCurrentChapter(): ReaderChapter? {
-        return viewerChaptersRelay.value?.currChapter
+        return state.value.viewerChapters?.currChapter
     }
 
     fun getSource() = manga?.source?.let { sourceManager.getOrStub(it) } as? HttpSource
@@ -625,7 +581,7 @@ class ReaderPresenter(
     fun bookmarkCurrentChapter(bookmarked: Boolean) {
         val chapter = getCurrentChapter()?.chapter ?: return
         chapter.bookmark = bookmarked // Otherwise the bookmark icon doesn't update
-        coroutineScope.launchNonCancellable {
+        viewModelScope.launchNonCancellable {
             updateChapter.await(
                 ChapterUpdate(
                     id = chapter.id!!.toLong(),
@@ -640,10 +596,10 @@ class ReaderPresenter(
      */
     fun getMangaReadingMode(resolveDefault: Boolean = true): Int {
         val default = readerPreferences.defaultReadingMode().get()
-        val readingMode = ReadingModeType.fromPreference(manga?.readingModeType)
+        val readingMode = ReadingModeType.fromPreference(manga?.readingModeType?.toInt())
         return when {
             resolveDefault && readingMode == ReadingModeType.DEFAULT -> default
-            else -> manga?.readingModeType ?: default
+            else -> manga?.readingModeType?.toInt() ?: default
         }
     }
 
@@ -652,22 +608,21 @@ class ReaderPresenter(
      */
     fun setMangaReadingMode(readingModeType: Int) {
         val manga = manga ?: return
-        manga.readingModeType = readingModeType
-
-        coroutineScope.launchIO {
-            setMangaViewerFlags.awaitSetMangaReadingMode(manga.id!!.toLong(), readingModeType.toLong())
-            delay(250)
-            val currChapters = viewerChaptersRelay.value
+        viewModelScope.launchIO {
+            setMangaViewerFlags.awaitSetMangaReadingMode(manga.id, readingModeType.toLong())
+            val currChapters = state.value.viewerChapters
             if (currChapters != null) {
                 // Save current page
                 val currChapter = currChapters.currChapter
                 currChapter.requestedPage = currChapter.chapter.last_page_read
 
-                withUIContext {
-                    // Emit manga and chapters to the new viewer
-                    view?.setManga(manga)
-                    view?.setChapters(currChapters)
+                mutableState.update {
+                    it.copy(
+                        manga = getManga.await(manga.id),
+                        viewerChapters = currChapters,
+                    )
                 }
+                eventChannel.send(Event.ReloadViewerChapters)
             }
         }
     }
@@ -677,10 +632,10 @@ class ReaderPresenter(
      */
     fun getMangaOrientationType(resolveDefault: Boolean = true): Int {
         val default = readerPreferences.defaultOrientationType().get()
-        val orientation = OrientationType.fromPreference(manga?.orientationType)
+        val orientation = OrientationType.fromPreference(manga?.orientationType?.toInt())
         return when {
             resolveDefault && orientation == OrientationType.DEFAULT -> default
-            else -> manga?.orientationType ?: default
+            else -> manga?.orientationType?.toInt() ?: default
         }
     }
 
@@ -689,14 +644,22 @@ class ReaderPresenter(
      */
     fun setMangaOrientationType(rotationType: Int) {
         val manga = manga ?: return
-        manga.orientationType = rotationType
-
-        coroutineScope.launchIO {
-            setMangaViewerFlags.awaitSetOrientationType(manga.id!!.toLong(), rotationType.toLong())
-            delay(250)
-            val currChapters = viewerChaptersRelay.value
+        viewModelScope.launchIO {
+            setMangaViewerFlags.awaitSetOrientationType(manga.id, rotationType.toLong())
+            val currChapters = state.value.viewerChapters
             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 ""
 
         // Copy file in background.
-        try {
-            coroutineScope.launchNonCancellable {
+        viewModelScope.launchNonCancellable {
+            try {
                 val uri = imageSaver.save(
                     image = Image.Page(
                         inputStream = page.stream!!,
@@ -744,12 +707,12 @@ class ReaderPresenter(
                 )
                 withUIContext {
                     notifier.onComplete(uri)
-                    view?.onSaveImageResult(SaveImageResult.Success(uri))
+                    eventChannel.send(Event.SavedImage(SaveImageResult.Success(uri)))
                 }
+            } catch (e: Throwable) {
+                notifier.onError(e.message)
+                eventChannel.send(Event.SavedImage(SaveImageResult.Error(e)))
             }
-        } catch (e: Throwable) {
-            notifier.onError(e.message)
-            view?.onSaveImageResult(SaveImageResult.Error(e))
         }
     }
 
@@ -770,7 +733,7 @@ class ReaderPresenter(
         val filename = generateFilename(manga, page)
 
         try {
-            coroutineScope.launchNonCancellable {
+            viewModelScope.launchNonCancellable {
                 destDir.deleteRecursively()
                 val uri = imageSaver.save(
                     image = Image.Page(
@@ -779,9 +742,7 @@ class ReaderPresenter(
                         location = Location.Cache,
                     ),
                 )
-                withUIContext {
-                    view?.onShareImageResult(uri, page)
-                }
+                eventChannel.send(Event.ShareImage(uri, page))
             }
         } catch (e: Throwable) {
             logcat(LogPriority.ERROR, e)
@@ -793,24 +754,21 @@ class ReaderPresenter(
      */
     fun setAsCover(context: Context, page: ReaderPage) {
         if (page.status != Page.State.READY) return
-        val manga = manga?.toDomainManga() ?: return
+        val manga = manga ?: return
         val stream = page.stream ?: return
 
-        coroutineScope.launchNonCancellable {
-            try {
+        viewModelScope.launchNonCancellable {
+            val result = try {
                 manga.editCover(context, stream())
-                withUIContext {
-                    view?.onSetAsCoverResult(
-                        if (manga.isLocal() || manga.favorite) {
-                            SetAsCoverResult.Success
-                        } else {
-                            SetAsCoverResult.AddToLibraryFirst
-                        },
-                    )
+                if (manga.isLocal() || manga.favorite) {
+                    SetAsCoverResult.Success
+                } else {
+                    SetAsCoverResult.AddToLibraryFirst
                 }
             } 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 context = Injekt.get<Application>()
 
-        coroutineScope.launchNonCancellable {
-            getTracks.await(manga.id!!)
+        viewModelScope.launchNonCancellable {
+            getTracks.await(manga.id)
                 .mapNotNull { track ->
                     val service = trackManager.getService(track.syncId)
                     if (service != null && service.isLogged && chapterRead > track.lastChapterRead) {
@@ -882,8 +840,8 @@ class ReaderPresenter(
         if (!chapter.chapter.read) return
         val manga = manga ?: return
 
-        coroutineScope.launchNonCancellable {
-            downloadManager.enqueueChaptersToDelete(listOf(chapter.chapter.toDomainChapter()!!), manga.toDomainManga()!!)
+        viewModelScope.launchNonCancellable {
+            downloadManager.enqueueChaptersToDelete(listOf(chapter.chapter.toDomainChapter()!!), manga)
         }
     }
 
@@ -892,34 +850,25 @@ class ReaderPresenter(
      * are ignored.
      */
     private fun deletePendingChapters() {
-        coroutineScope.launchNonCancellable {
+        viewModelScope.launchNonCancellable {
             downloadManager.deletePendingChapters()
         }
     }
 
-    // We're trying to avoid using Rx, so we "undeprecate" this
-    @Suppress("DEPRECATION")
-    override fun getView(): ReaderActivity? {
-        return super.getView()
-    }
+    data class State(
+        val manga: Manga? = null,
+        val viewerChapters: ViewerChapters? = null,
+        val isLoadingAdjacentChapter: Boolean = false,
+    )
 
-    /**
-     * 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) }
+    sealed class Event {
+        object ReloadViewerChapters : Event()
+        data class SetOrientation(val orientation: Int) : Event()
+        data class SetCoverResult(val result: SetAsCoverResult) : Event()
 
-    /**
-     * 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) }
+        data class SavedImage(val result: SaveImageResult) : Event()
+        data class ShareImage(val uri: Uri, val page: ReaderPage) : Event()
+    }
 
     companion object {
         // Safe theoretical max filename size is 255 bytes and 1 char = 2-4 bytes (UTF-8)

+ 5 - 5
app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderReadingModeSettings.kt

@@ -44,22 +44,22 @@ class ReaderReadingModeSettings @JvmOverloads constructor(context: Context, attr
     private fun initGeneralPreferences() {
         binding.viewer.onItemSelectedListener = { 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) {
                 initWebtoonPreferences()
             } else {
                 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 ->
             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)
     }
 
     /**

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt

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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt

@@ -62,7 +62,7 @@ class PagerTransitionHolder(
         addView(transitionView)
         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) }
     }

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt

@@ -64,7 +64,7 @@ class WebtoonTransitionHolder(
      * Binds the given [transition] with this view holder, subscribing to its state.
      */
     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) }
     }

+ 0 - 5
gradle/libs.versions.toml

@@ -1,7 +1,6 @@
 [versions]
 aboutlib_version = "10.5.2"
 okhttp_version = "5.0.0-alpha.10"
-nucleus_version = "3.0.0"
 coil_version = "2.2.2"
 shizuku_version = "12.2.0"
 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"
 
-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"
 
 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"]
 js-engine = ["quickjs-android"]
 sqlite = ["sqlite-framework", "sqlite-ktx", "sqlite-android"]
-nucleus = ["nucleus-core", "nucleus-supportv7"]
 coil = ["coil-core", "coil-gif", "coil-compose"]
 shizuku = ["shizuku-api", "shizuku-provider"]
 voyager = ["voyager-navigator", "voyager-tab-navigator", "voyager-transitions"]