diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 12c3a4284..99d0d744d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -236,9 +236,6 @@ dependencies { // Preferences implementation(libs.preferencektx) - // Model View Presenter - implementation(libs.bundles.nucleus) - // Dependency injection implementation(libs.injekt.core) diff --git a/app/src/main/java/eu/kanade/domain/manga/model/Manga.kt b/app/src/main/java/eu/kanade/domain/manga/model/Manga.kt index f762d3262..e2682a461 100644 --- a/app/src/main/java/eu/kanade/domain/manga/model/Manga.kt +++ b/app/src/main/java/eu/kanade/domain/manga/model/Manga.kt @@ -1,20 +1,18 @@ package eu.kanade.domain.manga.model -import eu.kanade.data.listOfStringsAdapter -import eu.kanade.data.listOfStringsAndAdapter 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.data.library.CustomMangaManager 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 uy.kohesive.injekt.injectLazy import java.io.Serializable -import eu.kanade.tachiyomi.data.database.models.Manga as DbManga data class Manga( val id: Long, @@ -83,6 +81,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 @@ -240,33 +244,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 - // SY --> - it.title = ogTitle - it.artist = ogArtist - it.author = ogAuthor - it.description = ogDescription - it.genre = ogGenre?.let(listOfStringsAdapter::encode) - it.status = ogStatus.toInt() - // SY <-- - it.thumbnail_url = thumbnailUrl - it.update_strategy = updateStrategy - it.initialized = initialized - // SY --> - it.filtered_scanlators = filteredScanlators?.let(listOfStringsAndAdapter::encode) - // SY <-- -} - fun Manga.toMangaUpdate(): MangaUpdate { return MangaUpdate( id = id, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index eabcd5d40..4d6cf2826 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -34,7 +34,9 @@ import android.widget.FrameLayout import android.widget.RelativeLayout import android.widget.TextView import android.widget.Toast +import androidx.activity.viewModels import androidx.annotation.ColorInt +import androidx.appcompat.app.AppCompatActivity import androidx.core.graphics.ColorUtils import androidx.core.transition.doOnEnd import androidx.core.view.WindowCompat @@ -54,9 +56,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 @@ -67,9 +69,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.chapter.ReaderChapterDialog import eu.kanade.tachiyomi.ui.reader.loader.HttpPageLoader import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter @@ -89,6 +91,8 @@ import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer 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 @@ -115,16 +119,18 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.conflate +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.mapLatest import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart 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 @@ -134,9 +140,8 @@ import kotlin.time.Duration.Companion.seconds * 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(), + AppCompatActivity(), SecureActivityDelegate by SecureActivityDelegateImpl(), ThemingDelegate by ThemingDelegateImpl() { @@ -169,6 +174,8 @@ class ReaderActivity : lateinit var binding: ReaderActivityBinding + val viewModel by viewModels() + val hasCutout by lazy { hasDisplayCutout() } /** @@ -245,7 +252,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) // SY --> @@ -256,7 +263,16 @@ class ReaderActivity : return } NotificationReceiver.dismissNotification(this, manga.hashCode(), Notifications.ID_NEW_CHAPTERS) - presenter.init(manga, chapter /* SY --> */, page/* SY <-- */) + + lifecycleScope.launchNonCancellable { + val initResult = viewModel.init(manga, chapter/* SY --> */, page/* SY <-- */) + if (!initResult.getOrDefault(false)) { + val exception = initResult.exceptionOrNull() ?: IllegalStateException("Unknown err") + withUIContext { + setInitialChapterError(exception) + } + } + } } if (savedInstanceState != null) { @@ -279,6 +295,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 /* SY --> */, event.secondPage /* SY <-- */) + } + is ReaderViewModel.Event.SetCoverResult -> { + onSetAsCoverResult(event.result) + } + } + } + .launchIn(lifecycleScope) } // SY --> @@ -329,13 +387,13 @@ class ReaderActivity : } // SY <-- if (!isChangingConfigurations) { - presenter.onSaveInstanceStateNonConfigurationChange() + viewModel.onSaveInstanceStateNonConfigurationChange() } super.onSaveInstanceState(outState) } override fun onPause() { - presenter.saveCurrentChapterReadingProgress() + viewModel.saveCurrentChapterReadingProgress() super.onPause() } @@ -345,7 +403,7 @@ class ReaderActivity : */ override fun onResume() { super.onResume() - presenter.setReadStartTime() + viewModel.setReadStartTime() setMenuVisibility(menuVisible, animate = false) } @@ -366,7 +424,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*/ @@ -383,11 +441,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() } } @@ -398,17 +456,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) @@ -475,7 +533,7 @@ class ReaderActivity : setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) binding.toolbar.setNavigationOnClickListener { - onBackPressed() + onBackPressedDispatcher.onBackPressed() } binding.header.applyInsetter { @@ -490,7 +548,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 @@ -612,11 +670,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()) { @@ -634,7 +692,7 @@ class ReaderActivity : setOnClickListener { // SY --> - val mangaViewer = presenter.getMangaReadingMode() + val mangaViewer = viewModel.getMangaReadingMode() // SY <-- val isPagerType = ReadingModeType.isPagerType(mangaViewer) val enabled = if (isPagerType) { @@ -674,12 +732,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) @@ -804,9 +862,9 @@ class ReaderActivity : binding.ehRetryAll.setOnClickListener { var retried = 0 - presenter.viewerChaptersRelay.value - .currChapter - .pages + viewModel.state.value.viewerChapters + ?.currChapter + ?.pages ?.forEachIndexed { _, page -> var shouldQueuePage = false if (page.status == Page.State.ERROR) { @@ -823,7 +881,7 @@ class ReaderActivity : } // If we are using EHentai/ExHentai, get a new image URL - presenter.manga?.let { m -> + viewModel.manga?.let { m -> val src = sourceManager.get(m.source) if (src?.isEhBasedSource() == true) { page.imageUrl = null @@ -865,7 +923,7 @@ class ReaderActivity : } else if (curPage.status == Page.State.READY) { toast(R.string.eh_boost_page_downloaded) } else { - val loader = (presenter.viewerChaptersRelay.value.currChapter.pageLoader as? HttpPageLoader) + val loader = (viewModel.state.value.viewerChapters?.currChapter?.pageLoader as? HttpPageLoader) if (loader != null) { loader.boostPage(curPage) toast(R.string.eh_boost_boosted) @@ -886,7 +944,7 @@ class ReaderActivity : private fun exhCurrentpage(): ReaderPage? { val currentPage = (((viewer as? PagerViewer)?.currentPage ?: (viewer as? WebtoonViewer)?.currentPage) as? ReaderPage)?.index - return currentPage?.let { presenter.viewerChaptersRelay.value.currChapter.pages?.getOrNull(it) } + return currentPage?.let { viewModel.state.value.viewerChapters?.currChapter?.pages?.getOrNull(it) } } fun updateBottomButtons() { @@ -924,7 +982,7 @@ class ReaderActivity : } else { pViewer.config.doublePages = doublePages } - val currentChapter = presenter.getCurrentChapter() + val currentChapter = viewModel.getCurrentChapter() if (doublePages) { // If we're moving from singe to double, we want the current page to be the first page pViewer.config.shiftDoublePage = ( @@ -932,7 +990,7 @@ class ReaderActivity : (currentChapter?.pages?.take(binding.pageSlider.value.floor())?.count { it.fullPage || it.isolatedPage } ?: 0) ) % 2 != 0 } - presenter.viewerChaptersRelay.value?.let { + viewModel.state.value.viewerChapters?.let { pViewer.setChaptersDoubleShift(it) } } @@ -945,7 +1003,7 @@ class ReaderActivity : private fun shiftDoublePages() { (viewer as? PagerViewer)?.config?.let { config -> config.shiftDoublePage = !config.shiftDoublePage - presenter.viewerChaptersRelay.value?.let { + viewModel.state.value.viewerChapters?.let { (viewer as? PagerViewer)?.updateShifting() (viewer as? PagerViewer)?.setChaptersDoubleShift(it) invalidateOptionsMenu() @@ -960,7 +1018,7 @@ class ReaderActivity : } private fun updateCropBordersShortcut() { - val mangaViewer = presenter.getMangaReadingMode() + val mangaViewer = viewModel.getMangaReadingMode() val isPagerType = ReadingModeType.isPagerType(mangaViewer) val enabled = if (isPagerType) { readerPreferences.cropBorders().get() @@ -1070,19 +1128,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 @@ -1103,12 +1161,12 @@ class ReaderActivity : } val defaultReaderType = manga.defaultReaderType(manga.mangaType(sourceName = sourceManager.get(manga.source)?.name)) - if (readerPreferences.useAutoWebtoon().get() && manga.readingModeType == ReadingModeType.DEFAULT.flagValue && defaultReaderType != null && defaultReaderType == ReadingModeType.WEBTOON.prefValue) { + if (readerPreferences.useAutoWebtoon().get() && manga.readingModeType.toInt() == ReadingModeType.DEFAULT.flagValue && defaultReaderType != null && defaultReaderType == ReadingModeType.WEBTOON.prefValue) { readingModeToast?.cancel() readingModeToast = toast(resources.getString(R.string.eh_auto_webtoon_snack)) } else if (readerPreferences.showReadingMode().get()) { // SY <-- - showReadingModeToast(presenter.getMangaReadingMode()) + showReadingModeToast(viewModel.getMangaReadingMode()) } // SY --> @@ -1171,9 +1229,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) @@ -1194,7 +1252,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) // SY --> if (indexChapterToShift != null && indexPageToShift != null) { @@ -1280,7 +1338,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) } @@ -1290,7 +1348,10 @@ class ReaderActivity : * should be automatically shown. */ private fun loadNextChapter() { - presenter.loadNextChapter() + lifecycleScope.launch { + viewModel.loadNextChapter() + moveToPageIndex(0) + } } /** @@ -1298,7 +1359,10 @@ class ReaderActivity : * should be automatically shown. */ private fun loadPreviousChapter() { - presenter.loadPreviousChapter() + lifecycleScope.launch { + viewModel.loadPreviousChapter() + moveToPageIndex(0) + } } /** @@ -1307,7 +1371,7 @@ class ReaderActivity : */ @SuppressLint("SetTextI18n") fun onPageSelected(page: ReaderPage, hasExtraPage: Boolean = false) { - val newChapter = presenter.onPageSelected(page, hasExtraPage) + val newChapter = viewModel.onPageSelected(page, hasExtraPage) val pages = page.chapter.pages ?: return val currentPage = if (hasExtraPage) { @@ -1372,7 +1436,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) } } /** @@ -1406,12 +1470,12 @@ 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) } // SY --> fun shareImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) { - presenter.shareImages(firstPage, secondPage, isLTR, bg) + viewModel.shareImages(firstPage, secondPage, isLTR, bg) } // SY <-- @@ -1420,7 +1484,7 @@ class ReaderActivity : * sharing tool. */ fun onShareImageResult(uri: Uri, page: ReaderPage /* SY --> */, secondPage: ReaderPage? = null /* SY <-- */) { - val manga = presenter.manga ?: return + val manga = viewModel.manga ?: return val chapter = page.chapter.chapter // SY --> @@ -1443,12 +1507,12 @@ class ReaderActivity : * storage to the presenter. */ fun saveImage(page: ReaderPage) { - presenter.saveImage(page) + viewModel.saveImage(page) } // SY --> fun saveImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) { - presenter.saveImages(firstPage, secondPage, isLTR, bg) + viewModel.saveImages(firstPage, secondPage, isLTR, bg) } // SY <-- @@ -1456,12 +1520,12 @@ class ReaderActivity : * 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) } } @@ -1472,14 +1536,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 @@ -1492,12 +1556,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)) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt similarity index 72% rename from app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index f19301d61..c230671e9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -3,13 +3,16 @@ package eu.kanade.tachiyomi.ui.reader import android.app.Application import android.content.Context import android.net.Uri -import android.os.Bundle import androidx.annotation.ColorInt -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.GetMergedChapterByMangaId import eu.kanade.domain.chapter.interactor.UpdateChapter +import eu.kanade.domain.chapter.model.Chapter import eu.kanade.domain.chapter.model.ChapterUpdate import eu.kanade.domain.chapter.model.toDbChapter import eu.kanade.domain.download.service.DownloadPreferences @@ -21,8 +24,8 @@ import eu.kanade.domain.manga.interactor.GetManga import eu.kanade.domain.manga.interactor.GetMergedManga import eu.kanade.domain.manga.interactor.GetMergedReferencesById 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 @@ -30,9 +33,7 @@ import eu.kanade.domain.track.service.DelayedTrackingUpdateJob import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.domain.track.store.DelayedTrackingStore import eu.kanade.domain.ui.UiPreferences -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 @@ -63,6 +64,7 @@ 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 @@ -77,36 +79,38 @@ import exh.source.getMainSource import exh.source.isEhBasedManga import exh.util.defaultReaderType import exh.util.mangaType -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 tachiyomi.decoder.ImageDecoder import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.text.DecimalFormat import java.text.DecimalFormatSymbols import java.util.Date -import eu.kanade.domain.chapter.model.Chapter as DomainChapter -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(), @@ -131,27 +135,28 @@ class ReaderPresenter( private val getMergedReferencesById: GetMergedReferencesById = Injekt.get(), private val getMergedChapterByMangaId: GetMergedChapterByMangaId = Injekt.get(), // SY <-- -) : RxPresenter() { +) : ViewModel() { - private val coroutineScope: CoroutineScope = MainScope() + private val mutableState = MutableStateFlow(State()) + val state = mutableState.asStateFlow() + + private val eventChannel = Channel() + 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 - - // SY --> - var meta: RaisedSearchMetadata? = null - private set - var mergedManga: Map? = null - private set - // SY <-- + 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("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. @@ -168,17 +173,6 @@ class ReaderPresenter( */ private var activeChapterSubscription: Subscription? = null - /** - * Relay for currently active viewer chapters. - */ - /* [EXH] private */ - val viewerChaptersRelay = BehaviorRelay.create() - - /** - * Used when loading prev/next chapter needed to lock the UI (with a dialog). - */ - private val isLoadingAdjacentChapterEvent = Channel() - private var chapterToDownload: Download? = null /** @@ -186,7 +180,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 { /* SY --> */ if (manga.source == MERGED_SOURCE_ID) { getMergedChapterByMangaId.await(manga.id) @@ -204,12 +198,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, /* SY --> */ manga.ogTitle /* SY <-- */, manga.source)) || - (manga.downloadedFilterRaw == DomainManga.CHAPTER_SHOW_NOT_DOWNLOADED && downloadManager.isChapterDownloaded(it.name, it.scanlator, /* SY --> */ manga.ogTitle /* SY <-- */, 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, /* SY --> */ manga.ogTitle /* SY <-- */, manga.source)) || + (manga.downloadedFilterRaw == Manga.CHAPTER_SHOW_NOT_DOWNLOADED && downloadManager.isChapterDownloaded(it.name, it.scanlator, /* SY --> */ manga.ogTitle /* SY <-- */, manga.source)) || + (manga.bookmarkedFilterRaw == Manga.CHAPTER_SHOW_BOOKMARKED && !it.bookmark) || + (manga.bookmarkedFilterRaw == Manga.CHAPTER_SHOW_NOT_BOOKMARKED && it.bookmark) || // SY --> (manga.filteredScanlators != null && MdUtil.getScanlators(it.scanlator).none { group -> manga.filteredScanlators.contains(group) }) // SY <-- @@ -234,32 +228,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) @@ -269,24 +246,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() } @@ -296,7 +273,7 @@ class ReaderPresenter( */ fun onSaveInstanceStateNonConfigurationChange() { val currentChapter = getCurrentChapter() ?: return - coroutineScope.launchNonCancellable { + viewModelScope.launchNonCancellable { saveChapterProgress(currentChapter) } } @@ -312,73 +289,46 @@ 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 /* SY --> */, page: Int?/* SY <-- */) { - if (!needsInit()) return - - coroutineScope.launchIO { + suspend fun init(mangaId: Long, initialChapterId: Long /* SY --> */, page: Int?/* SY <-- */): Result { + if (!needsInit()) return Result.success(true) + return withIOContext { try { - // SY --> - val manga = getManga.await(mangaId) ?: return@launchIO - val source = sourceManager.get(manga.source)?.getMainSource>() - val metadata = if (source != null) { - getFlatMetadataById.await(mangaId)?.raise(source.metaClass) + val manga = getManga.await(mangaId) + if (manga != null) { + // SY --> + val source = sourceManager.getOrStub(manga.source) + val metadataSource = source.getMainSource>() + val metadata = if (metadataSource != null) { + getFlatMetadataById.await(mangaId)?.raise(metadataSource.metaClass) + } else { + null + } + val mergedReferences = if (source is MergedSource) runBlocking { getMergedReferencesById.await(manga.id) } else emptyList() + val mergedManga = if (source is MergedSource) runBlocking { getMergedManga.await() /* <-- TODO */ }.associateBy { it.id } else emptyMap() + // SY <-- + mutableState.update { it.copy(manga = manga /* SY --> */, meta = metadata, mergedManga = mergedManga/* SY <-- */) } + if (chapterId == -1L) chapterId = initialChapterId + + checkTrackers(manga) + + val context = Injekt.get() + // val source = sourceManager.getOrStub(manga.source) + loader = ChapterLoader(context, downloadManager, downloadProvider, manga, source, /* SY --> */sourceManager, mergedReferences, mergedManga/* SY <-- */) + + getLoadObservable(loader!!, chapterList.first { chapterId == it.chapter.id } /* SY --> */, page/* SY <-- */) + .asFlow() + .first() + Result.success(true) } else { - null + // Unlikely but okay + Result.success(false) } - withUIContext { - init(manga.toDbManga(), initialChapterId, metadata, page) - } - // SY <-- } catch (e: Throwable) { - view?.setInitialChapterError(e) + Result.failure(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 /* SY --> */, metadata: RaisedSearchMetadata?, page: Int?/* SY <-- */) { - if (!needsInit()) return - - this.manga = manga - // SY --> - this.meta = metadata - // SY <-- - if (chapterId == -1L) chapterId = initialChapterId - - checkTrackers(manga.toDomainManga()!!) - - val context = Injekt.get() - val source = sourceManager.getOrStub(manga.source) - val mergedReferences = if (source is MergedSource) runBlocking { getMergedReferencesById.await(manga.id!!) } else emptyList() - mergedManga = if (source is MergedSource) runBlocking { getMergedManga.await() }.associateBy { it.id } else emptyMap() - loader = ChapterLoader(context, downloadManager, downloadProvider, manga.toDomainManga()!!, source, sourceManager, mergedReferences, mergedManga ?: emptyMap()) - - Observable.just(manga).subscribeLatestCache(ReaderActivity::setManga) - viewerChaptersRelay.subscribeLatestCache(ReaderActivity::setChapters) - coroutineScope.launch { - isLoadingAdjacentChapterEvent.receiveAsFlow().collectLatest { - view?.setProgressDialog(it) - } - } - - // 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 /* SY --> */, page/* SY <-- */) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst( - { _, _ -> - // Ignore onNext event - }, - ReaderActivity::setInitialChapterError, - ) - } - // SY --> fun getChapters(context: Context): List { val currentChapter = getCurrentChapter() @@ -391,7 +341,7 @@ class ReaderPresenter( return chapterList.map { ReaderChapterItem( it.chapter.toDomainChapter()!!, - manga!!.toDomainManga()!!, + manga!!, it.chapter.id == currentChapter?.chapter?.id, context, UiPreferences.dateFormat(uiPreferences.dateFormat().get()), @@ -429,14 +379,14 @@ class ReaderPresenter( ) .observeOn(AndroidSchedulers.mainThread()) .doOnNext { newChapters -> - val oldChapters = viewerChaptersRelay.value + mutableState.update { + // Add new references first to avoid unnecessary recycling + newChapters.ref() + it.viewerChapters?.unref() - // Add new references first to avoid unnecessary recycling - newChapters.ref() - oldChapters?.unref() - - chapterToDownload = cancelQueuedDownloads(newChapters.currChapter) - viewerChaptersRelay.call(newChapters) + chapterToDownload = cancelQueuedDownloads(newChapters.currChapter) + it.copy(viewerChapters = newChapters) + } } } @@ -444,20 +394,20 @@ 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() + } } - fun loadNewChapterFromDialog(chapter: DomainChapter) { + suspend fun loadNewChapterFromDialog(chapter: Chapter) { val newChapter = chapterList.firstOrNull { it.chapter.id == chapter.id } ?: return loadAdjacent(newChapter) } @@ -467,37 +417,32 @@ 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 val isDownloaded = downloadManager.isChapterDownloaded( dbChapter.name, dbChapter.scanlator, - /* SY --> */ manga.originalTitle /* SY <-- */, + /* SY --> */ manga.ogTitle /* SY <-- */, manga.source, skipCache = true, ) @@ -513,13 +458,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() + .asFlow() + .firstOrNull() + } } /** @@ -528,7 +474,7 @@ class ReaderPresenter( * [page]'s chapter is different from the currently active. */ fun onPageSelected(page: ReaderPage, hasExtraPage: Boolean) { - val currentChapters = viewerChaptersRelay.value ?: return + val currentChapters = state.value.viewerChapters ?: return val selectedChapter = page.chapter @@ -547,7 +493,7 @@ class ReaderPresenter( selectedChapter.chapter.read = true // SY --> if (manga?.isEhBasedManga() == true) { - coroutineScope.launchNonCancellable { + viewModelScope.launchNonCancellable { chapterList .filter { it.chapter.source_order > selectedChapter.chapter.source_order } .onEach { @@ -565,7 +511,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 @@ -581,23 +527,23 @@ 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, // SY --> - manga.originalTitle, + manga.ogTitle, // SY <-- manga.source, ) 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, ) } @@ -641,7 +587,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) } @@ -689,23 +635,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) } @@ -713,7 +659,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 @@ -731,7 +677,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(), @@ -745,7 +691,7 @@ class ReaderPresenter( fun toggleBookmark(chapterId: Long, bookmarked: Boolean) { val chapter = chapterList.find { it.chapter.id == chapterId }?.chapter ?: return chapter.bookmark = bookmarked - coroutineScope.launchNonCancellable { + viewModelScope.launchNonCancellable { updateChapter.await( ChapterUpdate( id = chapter.id!!.toLong(), @@ -762,7 +708,7 @@ class ReaderPresenter( fun getMangaReadingMode(resolveDefault: Boolean = true): Int { val default = readerPreferences.defaultReadingMode().get() val manga = manga ?: return default - val readingMode = ReadingModeType.fromPreference(manga.readingModeType) + val readingMode = ReadingModeType.fromPreference(manga.readingModeType.toInt()) // SY --> return when { resolveDefault && readingMode == ReadingModeType.DEFAULT && readerPreferences.useAutoWebtoon().get() -> { @@ -770,7 +716,7 @@ class ReaderPresenter( ?: default } resolveDefault && readingMode == ReadingModeType.DEFAULT -> default - else -> manga.readingModeType + else -> manga.readingModeType.toInt() } // SY <-- } @@ -780,22 +726,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) } } } @@ -805,10 +750,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 } } @@ -817,14 +762,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) } } } @@ -861,8 +814,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!!, @@ -872,12 +825,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)) } } @@ -895,8 +848,8 @@ class ReaderPresenter( val relativePath = if (readerPreferences.folderPerManga().get()) DiskUtil.buildValidFilename(manga.title) else "" // Copy file in background. - try { - coroutineScope.launchIO { + viewModelScope.launchNonCancellable { + try { val uri = saveImages( page1 = firstPage, page2 = secondPage, @@ -905,14 +858,11 @@ class ReaderPresenter( location = Location.Pictures.create(relativePath), manga = manga, ) - 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)) } } @@ -966,7 +916,7 @@ class ReaderPresenter( val filename = generateFilename(manga, page) try { - coroutineScope.launchNonCancellable { + viewModelScope.launchNonCancellable { destDir.deleteRecursively() val uri = imageSaver.save( image = Image.Page( @@ -975,9 +925,7 @@ class ReaderPresenter( location = Location.Cache, ), ) - withUIContext { - view?.onShareImageResult(uri, page) - } + eventChannel.send(Event.ShareImage(uri, page)) } } catch (e: Throwable) { logcat(LogPriority.ERROR, e) @@ -994,7 +942,7 @@ class ReaderPresenter( val destDir = context.cacheImageDir try { - coroutineScope.launchIO { + viewModelScope.launchNonCancellable { destDir.deleteRecursively() val uri = saveImages( page1 = firstPage, @@ -1004,9 +952,7 @@ class ReaderPresenter( location = Location.Cache, manga = manga, ) - withUIContext { - view!!.onShareImageResult(uri, firstPage, secondPage) - } + eventChannel.send(Event.ShareImage(uri, firstPage, secondPage)) } } catch (e: Throwable) { logcat(LogPriority.ERROR, e) @@ -1019,24 +965,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)) } } @@ -1068,8 +1011,8 @@ class ReaderPresenter( val trackManager = Injekt.get() val context = Injekt.get() - 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 /* SY --> */ && ((service.id == TrackManager.MDLIST && track.status != FollowStatus.UNFOLLOWED.int.toLong()) || service.id != TrackManager.MDLIST)/* SY <-- */) { @@ -1106,16 +1049,17 @@ class ReaderPresenter( */ private fun enqueueDeleteReadChapters(chapter: ReaderChapter) { if (!chapter.chapter.read) return + val mergedManga = state.value.mergedManga // SY --> val manga = if (mergedManga.isNullOrEmpty()) { manga } else { - mergedManga.orEmpty()[chapter.chapter.manga_id]?.toDbManga() + mergedManga[chapter.chapter.manga_id] } ?: return // SY <-- - coroutineScope.launchNonCancellable { - downloadManager.enqueueChaptersToDelete(listOf(chapter.chapter.toDomainChapter()!!), manga.toDomainManga()!!) + viewModelScope.launchNonCancellable { + downloadManager.enqueueChaptersToDelete(listOf(chapter.chapter.toDomainChapter()!!), manga) } } @@ -1124,35 +1068,30 @@ 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, + // SY --> + val meta: RaisedSearchMetadata? = null, + val mergedManga: Map? = null, + // SY <-- + ) + + 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, val secondPage: ReaderPage? = null) : 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 Observable.subscribeFirst(onNext: (ReaderActivity, T) -> Unit, onError: ((ReaderActivity, Throwable) -> Unit) = { _, _ -> }) = compose(deliverFirst()).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 Observable.subscribeLatestCache(onNext: (ReaderActivity, T) -> Unit, onError: ((ReaderActivity, Throwable) -> Unit) = { _, _ -> }) = compose(deliverLatestCache()).subscribe(split(onNext, onError)).apply { add(this) } - companion object { // Safe theoretical max filename size is 255 bytes and 1 char = 2-4 bytes (UTF-8) private const val MAX_FILE_NAME_BYTES = 250 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/chapter/ReaderChapterDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/chapter/ReaderChapterDialog.kt index d61cb5c7b..5fb0b3ce1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/chapter/ReaderChapterDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/chapter/ReaderChapterDialog.kt @@ -7,10 +7,9 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import eu.davidea.flexibleadapter.FlexibleAdapter import eu.kanade.domain.chapter.model.Chapter import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.toDomainManga import eu.kanade.tachiyomi.databinding.ReaderChaptersDialogBinding import eu.kanade.tachiyomi.ui.reader.ReaderActivity -import eu.kanade.tachiyomi.ui.reader.ReaderPresenter +import eu.kanade.tachiyomi.ui.reader.ReaderViewModel import eu.kanade.tachiyomi.util.chapter.getChapterSort import eu.kanade.tachiyomi.util.system.dpToPx import kotlinx.coroutines.launch @@ -18,7 +17,7 @@ import kotlinx.coroutines.launch class ReaderChapterDialog(private val activity: ReaderActivity) : ReaderChapterAdapter.OnBookmarkClickListener { private val binding = ReaderChaptersDialogBinding.inflate(activity.layoutInflater) - var presenter: ReaderPresenter = activity.presenter + var viewModel: ReaderViewModel = activity.viewModel var adapter: FlexibleAdapter? = null var dialog: AlertDialog @@ -35,9 +34,11 @@ class ReaderChapterDialog(private val activity: ReaderActivity) : ReaderChapterA adapter?.mItemClickListener = FlexibleAdapter.OnItemClickListener { _, position -> val item = adapter?.getItem(position) - if (item != null && item.chapter.id != presenter.getCurrentChapter()?.chapter?.id) { + if (item != null && item.chapter.id != viewModel.getCurrentChapter()?.chapter?.id) { dialog.dismiss() - presenter.loadNewChapterFromDialog(item.chapter) + activity.lifecycleScope.launch { + viewModel.loadNewChapterFromDialog(item.chapter) + } } true } @@ -51,8 +52,8 @@ class ReaderChapterDialog(private val activity: ReaderActivity) : ReaderChapterA } private fun refreshList(scroll: Boolean = true) { - val chapterSort = getChapterSort(presenter.manga!!.toDomainManga()!!) - val chapters = presenter.getChapters(activity) + val chapterSort = getChapterSort(viewModel.manga!!) + val chapters = viewModel.getChapters(activity) .sortedWith { a, b -> chapterSort(a.chapter, b.chapter) } @@ -73,7 +74,7 @@ class ReaderChapterDialog(private val activity: ReaderActivity) : ReaderChapterA } override fun bookmarkChapter(chapter: Chapter) { - presenter.toggleBookmark(chapter.id, !chapter.bookmark) + viewModel.toggleBookmark(chapter.id, !chapter.bookmark) refreshList(scroll = false) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderReadingModeSettings.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderReadingModeSettings.kt index 3e5b9d6d6..709135ec5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderReadingModeSettings.kt +++ b/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) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt index de342f051..316bd8692 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt +++ b/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 @@ -55,7 +55,7 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At val isPrevDownloaded = downloadManager.isChapterDownloaded( prevChapter.name, prevChapter.scanlator, - /* SY --> */ manga.originalTitle /* SY <-- */, + /* SY --> */ manga.ogTitle /* SY <-- */, manga.source, skipCache = true, ) @@ -93,7 +93,7 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At val isNextDownloaded = downloadManager.isChapterDownloaded( nextChapter.name, nextChapter.scanlator, - /* SY --> */ manga.originalTitle /* SY <-- */, + /* SY --> */ manga.ogTitle /* SY <-- */, manga.source, skipCache = true, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt index 464b0f0f2..d69ac451d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt +++ b/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) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt index e8e071573..5dd0b2880 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt +++ b/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) } } diff --git a/app/src/main/java/exh/util/MangaType.kt b/app/src/main/java/exh/util/MangaType.kt index 4cc4923b0..6aab4fe92 100644 --- a/app/src/main/java/exh/util/MangaType.kt +++ b/app/src/main/java/exh/util/MangaType.kt @@ -1,14 +1,13 @@ package exh.util import android.content.Context +import eu.kanade.domain.manga.model.Manga import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.util.Locale -import eu.kanade.domain.manga.model.Manga as DomainManga fun Manga.mangaType(context: Context): String { return context.getString( @@ -26,30 +25,6 @@ fun Manga.mangaType(context: Context): String { * The type of comic the manga is (ie. manga, manhwa, manhua) */ fun Manga.mangaType(sourceName: String? = Injekt.get().get(source)?.name): MangaType { - val currentTags = getGenres().orEmpty() - return when { - currentTags.any { tag -> isMangaTag(tag) } -> { - MangaType.TYPE_MANGA - } - currentTags.any { tag -> isWebtoonTag(tag) } || sourceName?.let { isWebtoonSource(it) } == true -> { - MangaType.TYPE_WEBTOON - } - currentTags.any { tag -> isComicTag(tag) } || sourceName?.let { isComicSource(it) } == true -> { - MangaType.TYPE_COMIC - } - currentTags.any { tag -> isManhuaTag(tag) } || sourceName?.let { isManhuaSource(it) } == true -> { - MangaType.TYPE_MANHUA - } - currentTags.any { tag -> isManhwaTag(tag) } || sourceName?.let { isManhwaSource(it) } == true -> { - MangaType.TYPE_MANHWA - } - else -> { - MangaType.TYPE_MANGA - } - } -} - -fun DomainManga.mangaType(sourceName: String? = Injekt.get().get(source)?.name): MangaType { val currentTags = genre.orEmpty() return when { currentTags.any { tag -> isMangaTag(tag) } -> { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 794e08802..dce4d9144 100644 --- a/gradle/libs.versions.toml +++ b/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"]