From 5590ab7c8ac9b68c0ceb60c52389d4fb6b204959 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sun, 2 Nov 2025 15:12:32 +0200 Subject: [PATCH] Fix loading empty manga --- .../core/exceptions/EmptyMangaException.kt | 10 + .../exceptions/resolve/ExceptionResolver.kt | 18 + .../koitharu/kotatsu/core/nav/AppRouter.kt | 1486 +++++++++-------- .../kotatsu/core/util/ext/Throwable.kt | 332 ++-- .../kotatsu/details/data/MangaDetails.kt | 202 +-- .../kotatsu/reader/ui/ReaderViewModel.kt | 1063 ++++++------ 6 files changed, 1588 insertions(+), 1523 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/EmptyMangaException.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/EmptyMangaException.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/EmptyMangaException.kt new file mode 100644 index 000000000..f9628c740 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/EmptyMangaException.kt @@ -0,0 +1,10 @@ +package org.koitharu.kotatsu.core.exceptions + +import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason +import org.koitharu.kotatsu.parsers.model.Manga + +class EmptyMangaException( + val reason: EmptyMangaReason?, + val manga: Manga, + cause: Throwable? +) : IllegalStateException(cause) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt index 449c4a06c..752eb07be 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt @@ -16,6 +16,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException +import org.koitharu.kotatsu.core.exceptions.EmptyMangaException import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException import org.koitharu.kotatsu.core.exceptions.ProxyConfigException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException @@ -25,6 +26,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.util.ext.isHttpUrl import org.koitharu.kotatsu.core.util.ext.restartApplication +import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.model.Manga @@ -83,6 +85,16 @@ class ExceptionResolver private constructor( false } + is EmptyMangaException -> { + when (e.reason) { + EmptyMangaReason.NO_CHAPTERS -> openAlternatives(e.manga) + EmptyMangaReason.LOADING_ERROR -> Unit + EmptyMangaReason.RESTRICTED -> host.router.openBrowser(e.manga) + else -> Unit + } + false + } + is UnsupportedSourceException -> { e.manga?.let { openAlternatives(it) } false @@ -229,6 +241,12 @@ class ExceptionResolver private constructor( is InteractiveActionRequiredException -> R.string._continue + is EmptyMangaException -> when (e.reason) { + EmptyMangaReason.RESTRICTED -> if (e.manga.publicUrl.isHttpUrl()) R.string.open_in_browser else 0 + EmptyMangaReason.NO_CHAPTERS -> R.string.alternatives + else -> 0 + } + else -> 0 } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt index 9871eda5e..d60c78740 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt @@ -111,746 +111,752 @@ import java.io.File import androidx.appcompat.R as appcompatR class AppRouter private constructor( - private val activity: FragmentActivity?, - private val fragment: Fragment?, + private val activity: FragmentActivity?, + private val fragment: Fragment?, ) { - constructor(activity: FragmentActivity) : this(activity, null) - - constructor(fragment: Fragment) : this(null, fragment) - - private val settings: AppSettings by lazy { - EntryPointAccessors.fromApplication(checkNotNull(contextOrNull())).settings - } - - /** Activities **/ - - fun openList(source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?) { - startActivity(listIntent(contextOrNull() ?: return, source, filter, sortOrder)) - } - - fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)), null) - - fun openSearch(query: String, kind: SearchKind = SearchKind.SIMPLE) { - startActivity( - Intent(contextOrNull() ?: return, SearchActivity::class.java) - .putExtra(KEY_QUERY, query) - .putExtra(KEY_KIND, kind), - ) - } - - fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query), null) - - fun openDetails(manga: Manga) { - startActivity(detailsIntent(contextOrNull() ?: return, manga)) - } - - fun openDetails(mangaId: Long) { - startActivity(detailsIntent(contextOrNull() ?: return, mangaId)) - } - - fun openDetails(link: Uri) { - startActivity( - Intent(contextOrNull() ?: return, DetailsActivity::class.java) - .setData(link), - ) - } - - fun openReader(manga: Manga, anchor: View? = null) { - openReader( - ReaderIntent.Builder(contextOrNull() ?: return) - .manga(manga) - .build(), - anchor, - ) - } - - fun openReader(intent: ReaderIntent, anchor: View? = null) { - val activityIntent = intent.intent - if (settings.isReaderMultiTaskEnabled && activityIntent.data != null) { - activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT) - } - startActivity(activityIntent, anchor?.let { view -> scaleUpActivityOptionsOf(view) }) - } - - fun openAlternatives(manga: Manga) { - startActivity( - Intent(contextOrNull() ?: return, AlternativesActivity::class.java) - .putExtra(KEY_MANGA, ParcelableManga(manga)), - ) - } - - fun openRelated(manga: Manga) { - startActivity( - Intent(contextOrNull(), RelatedMangaActivity::class.java) - .putExtra(KEY_MANGA, ParcelableManga(manga)), - ) - } - - fun openImage(url: String, source: MangaSource?, anchor: View? = null, preview: CoilMemoryCacheKey? = null) { - startActivity( - Intent(contextOrNull(), ImageActivity::class.java) - .setData(url.toUri()) - .putExtra(KEY_SOURCE, source?.name) - .putExtra(KEY_PREVIEW, preview), - anchor?.let { scaleUpActivityOptionsOf(it) }, - ) - } - - fun openBookmarks() = startActivity(AllBookmarksActivity::class.java) - - fun openAppUpdate() = startActivity(AppUpdateActivity::class.java) - - fun openSuggestions() { - startActivity(suggestionsIntent(contextOrNull() ?: return)) - } - - fun openSourcesCatalog() = startActivity(SourcesCatalogActivity::class.java) - - fun openDownloads() = startActivity(DownloadsActivity::class.java) - - fun openDirectoriesSettings() = startActivity(MangaDirectoriesActivity::class.java) - - fun openBrowser(url: String, source: MangaSource?, title: String?) { - startActivity(browserIntent(contextOrNull() ?: return, url, source, title)) - } - - fun openColorFilterConfig(manga: Manga, page: MangaPage) { - startActivity( - Intent(contextOrNull(), ColorFilterConfigActivity::class.java) - .putExtra(KEY_MANGA, ParcelableManga(manga)) - .putExtra(KEY_PAGES, ParcelableMangaPage(page)), - ) - } - - fun openHistory() = startActivity(HistoryActivity::class.java) - - fun openFavorites() = startActivity(FavouritesActivity::class.java) - - fun openFavorites(category: FavouriteCategory) { - startActivity( - Intent(contextOrNull() ?: return, FavouritesActivity::class.java) - .putExtra(KEY_ID, category.id) - .putExtra(KEY_TITLE, category.title), - ) - } - - fun openFavoriteCategories() = startActivity(FavouriteCategoriesActivity::class.java) - - fun openFavoriteCategoryEdit(categoryId: Long) { - startActivity( - Intent(contextOrNull() ?: return, FavouritesCategoryEditActivity::class.java) - .putExtra(KEY_ID, categoryId), - ) - } - - fun openFavoriteCategoryCreate() = openFavoriteCategoryEdit(FavouritesCategoryEditActivity.NO_ID) - - fun openMangaUpdates() { - startActivity(mangaUpdatesIntent(contextOrNull() ?: return)) - } - - fun openMangaOverrideConfig(manga: Manga) { - val intent = overrideEditIntent(contextOrNull() ?: return, manga) - startActivity(intent) - } - - fun openSettings() = startActivity(SettingsActivity::class.java) - - fun openReaderSettings() { - startActivity(readerSettingsIntent(contextOrNull() ?: return)) - } - - fun openProxySettings() { - startActivity(proxySettingsIntent(contextOrNull() ?: return)) - } - - fun openDownloadsSetting() { - startActivity(downloadsSettingsIntent(contextOrNull() ?: return)) - } - - fun openSourceSettings(source: MangaSource) { - startActivity(sourceSettingsIntent(contextOrNull() ?: return, source)) - } - - fun openSuggestionsSettings() { - startActivity(suggestionsSettingsIntent(contextOrNull() ?: return)) - } - - fun openSourcesSettings() { - startActivity(sourcesSettingsIntent(contextOrNull() ?: return)) - } - - fun openDiscordSettings() { - startActivity(discordSettingsIntent(contextOrNull() ?: return)) - } - - fun openReaderTapGridSettings() = startActivity(ReaderTapGridConfigActivity::class.java) - - fun openScrobblerSettings(scrobbler: ScrobblerService) { - startActivity( - Intent(contextOrNull() ?: return, ScrobblerConfigActivity::class.java) - .putExtra(KEY_ID, scrobbler.id), - ) - } - - fun openSourceAuth(source: MangaSource) { - startActivity(sourceAuthIntent(contextOrNull() ?: return, source)) - } - - fun openManageSources() { - startActivity( - manageSourcesIntent(contextOrNull() ?: return), - ) - } - - fun openStatistic() = startActivity(StatsActivity::class.java) - - @CheckResult - fun openExternalBrowser(url: String, chooserTitle: CharSequence? = null): Boolean { - val intent = Intent(Intent.ACTION_VIEW) - intent.data = url.toUriOrNull() ?: return false - return startActivitySafe( - if (!chooserTitle.isNullOrEmpty()) { - Intent.createChooser(intent, chooserTitle) - } else { - intent - }, - ) - } - - @CheckResult - fun openSystemSyncSettings(account: Account): Boolean { - val args = Bundle(1) - args.putParcelable(ACCOUNT_KEY, account) - val intent = Intent(ACTION_ACCOUNT_SYNC_SETTINGS) - intent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, args) - return startActivitySafe(intent) - } - - /** Dialogs **/ - - fun showDownloadDialog(manga: Manga, snackbarHost: View?) = showDownloadDialog(setOf(manga), snackbarHost) - - fun showDownloadDialog(manga: Collection, snackbarHost: View?) { - if (manga.isEmpty()) { - return - } - val fm = getFragmentManager() ?: return - if (snackbarHost != null) { - getLifecycleOwner()?.let { lifecycleOwner -> - DownloadDialogFragment.registerCallback(fm, lifecycleOwner, snackbarHost) - } - } else { - DownloadDialogFragment.unregisterCallback(fm) - } - DownloadDialogFragment().withArgs(1) { - putParcelableArray(KEY_MANGA, manga.mapToArray { ParcelableManga(it, withDescription = false) }) - }.showDistinct() - } - - fun showLocalInfoDialog(manga: Manga) { - LocalInfoDialog().withArgs(1) { - putParcelable(KEY_MANGA, ParcelableManga(manga)) - }.showDistinct() - } - - fun showDirectorySelectDialog() { - MangaDirectorySelectDialog().showDistinct() - } - - fun showFavoriteDialog(manga: Manga) = showFavoriteDialog(setOf(manga)) - - fun showFavoriteDialog(manga: Collection) { - if (manga.isEmpty()) { - return - } - FavoriteDialog().withArgs(1) { - putParcelableArrayList( - KEY_MANGA_LIST, - manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withDescription = false) }, - ) - }.showDistinct() - } - - fun showTagDialog(tag: MangaTag) { - buildAlertDialog(contextOrNull() ?: return) { - setIcon(R.drawable.ic_tag) - setTitle(tag.title) - setItems( - arrayOf( - context.getString(R.string.search_on_s, tag.source.getTitle(context)), - context.getString(R.string.search_everywhere), - ), - ) { _, which -> - when (which) { - 0 -> openList(tag) - 1 -> openSearch(tag.title, SearchKind.TAG) - } - } - setNegativeButton(R.string.close, null) - setCancelable(true) - }.show() - } - - fun showAuthorDialog(author: String, source: MangaSource) { - buildAlertDialog(contextOrNull() ?: return) { - setIcon(R.drawable.ic_user) - setTitle(author) - setItems( - arrayOf( - context.getString(R.string.search_on_s, source.getTitle(context)), - context.getString(R.string.search_everywhere), - ), - ) { _, which -> - when (which) { - 0 -> openList(source, MangaListFilter(author = author), null) - 1 -> openSearch(author, SearchKind.AUTHOR) - } - } - setNegativeButton(R.string.close, null) - setCancelable(true) - }.show() - } - - fun showShareDialog(manga: Manga) { - if (manga.isBroken) { - return - } - if (manga.isLocal) { - manga.url.toUri().toFileOrNull()?.let { - shareFile(it) - } - return - } - buildAlertDialog(contextOrNull() ?: return) { - setIcon(context.getThemeDrawable(appcompatR.attr.actionModeShareDrawable)) - setTitle(R.string.share) - setItems( - arrayOf( - context.getString(R.string.link_to_manga_in_app), - context.getString(R.string.link_to_manga_on_s, manga.source.getTitle(context)), - ), - ) { _, which -> - val link = when (which) { - 0 -> manga.appUrl.toString() - 1 -> manga.publicUrl - else -> return@setItems - } - shareLink(link, manga.title) - } - setNegativeButton(android.R.string.cancel, null) - setCancelable(true) - }.show() - } - - fun showErrorDialog(error: Throwable, url: String? = null) { - ErrorDetailsDialog().withArgs(2) { - putSerializable(KEY_ERROR, error) - putString(KEY_URL, url) - }.show() - } - - fun showBackupRestoreDialog(fileUri: Uri) { - RestoreDialogFragment().withArgs(1) { - putString(KEY_FILE, fileUri.toString()) - }.show() - } - - fun createBackup(destination: Uri) { - BackupDialogFragment().withArgs(1) { - putParcelable(KEY_DATA, destination) - }.showDistinct() - } - - fun showImportDialog() { - ImportDialogFragment().showDistinct() - } - - fun showFilterSheet(): Boolean = if (isFilterSupported()) { - FilterSheetFragment().showDistinct() - } else { - false - } - - fun showTagsCatalogSheet(excludeMode: Boolean) { - if (!isFilterSupported()) { - return - } - TagsCatalogSheet().withArgs(1) { - putBoolean(KEY_EXCLUDE, excludeMode) - }.showDistinct() - } - - fun showListConfigSheet(section: ListConfigSection) { - ListConfigBottomSheet().withArgs(1) { - putParcelable(KEY_LIST_SECTION, section) - }.showDistinct() - } - - fun showStatisticSheet(manga: Manga) { - MangaStatsSheet().withArgs(1) { - putParcelable(KEY_MANGA, ParcelableManga(manga)) - }.showDistinct() - } - - fun showReaderConfigSheet(mode: ReaderMode) { - ReaderConfigSheet().withArgs(1) { - putInt(KEY_READER_MODE, mode.id) - }.showDistinct() - } - - fun showWelcomeSheet() { - WelcomeSheet().showDistinct() - } - - fun showChapterPagesSheet() { - ChaptersPagesSheet().showDistinct() - } - - fun showChapterPagesSheet(defaultTab: Int) { - ChaptersPagesSheet().withArgs(1) { - putInt(KEY_TAB, defaultTab) - }.showDistinct() - } - - fun showScrobblingSelectorSheet(manga: Manga, scrobblerService: ScrobblerService?) { - ScrobblingSelectorSheet().withArgs(2) { - putParcelable(KEY_MANGA, ParcelableManga(manga)) - if (scrobblerService != null) { - putInt(KEY_ID, scrobblerService.id) - } - }.show() - } - - fun showScrobblingInfoSheet(index: Int) { - ScrobblingInfoSheet().withArgs(1) { - putInt(KEY_INDEX, index) - }.showDistinct() - } - - fun showTrackerCategoriesConfigSheet() { - TrackerCategoriesConfigSheet().showDistinct() - } - - fun askForDownloadOverMeteredNetwork(onConfirmed: (allow: Boolean) -> Unit) { - val context = contextOrNull() ?: return - when (settings.allowDownloadOnMeteredNetwork) { - TriStateOption.ENABLED -> onConfirmed(true) - TriStateOption.DISABLED -> onConfirmed(false) - TriStateOption.ASK -> { - if (!context.connectivityManager.isActiveNetworkMetered) { - onConfirmed(true) - return - } - val listener = DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - settings.allowDownloadOnMeteredNetwork = TriStateOption.ENABLED - onConfirmed(true) - } - - DialogInterface.BUTTON_NEUTRAL -> { - onConfirmed(true) - } - - DialogInterface.BUTTON_NEGATIVE -> { - settings.allowDownloadOnMeteredNetwork = TriStateOption.DISABLED - onConfirmed(false) - } - } - } - BigButtonsAlertDialog.Builder(context) - .setIcon(R.drawable.ic_network_cellular) - .setTitle(R.string.download_cellular_confirm) - .setPositiveButton(R.string.allow_always, listener) - .setNeutralButton(R.string.allow_once, listener) - .setNegativeButton(R.string.dont_allow, listener) - .create() - .show() - } - } - } - - /** Public utils **/ - - fun isFilterSupported(): Boolean = when { - fragment != null -> FilterCoordinator.find(fragment) != null - activity != null -> activity is FilterCoordinator.Owner - else -> false - } - - fun isChapterPagesSheetShown(): Boolean { - val sheet = getFragmentManager()?.findFragmentByTag(fragmentTag()) as? ChaptersPagesSheet - return sheet?.dialog?.isShowing == true - } - - fun closeWelcomeSheet(): Boolean { - val tag = fragmentTag() - val sheet = fragment?.findFragmentByTagRecursive(tag) - ?: activity?.supportFragmentManager?.findFragmentByTag(tag) - ?: return false - return if (sheet is WelcomeSheet) { - sheet.dismissAllowingStateLoss() - true - } else { - false - } - } - - /** Private utils **/ - - private fun startActivity(intent: Intent, options: Bundle? = null) { - fragment?.also { - if (it.host != null) { - it.startActivity(intent, options) - } - } ?: activity?.startActivity(intent, options) - } - - private fun startActivitySafe(intent: Intent): Boolean = try { - startActivity(intent) - true - } catch (_: ActivityNotFoundException) { - false - } - - private fun startActivity(activityClass: Class) { - startActivity(Intent(contextOrNull() ?: return, activityClass)) - } - - private fun getFragmentManager(): FragmentManager? = runCatching { - fragment?.childFragmentManager ?: activity?.supportFragmentManager - }.onFailure { exception -> - exception.printStackTraceDebug() - }.getOrNull() - - private fun shareLink(link: String, title: String) { - val context = contextOrNull() ?: return - ShareCompat.IntentBuilder(context) - .setText(link) - .setType(TYPE_TEXT) - .setChooserTitle(context.getString(R.string.share_s, title.ellipsize(12))) - .startChooser() - } - - private fun shareFile(file: File) { // TODO directory sharing support - val context = contextOrNull() ?: return - val intentBuilder = ShareCompat.IntentBuilder(context) - .setType(TYPE_CBZ) - val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file) - intentBuilder.addStream(uri) - intentBuilder.setChooserTitle(context.getString(R.string.share_s, file.name)) - intentBuilder.startChooser() - } - - @UiContext - private fun contextOrNull(): Context? = activity ?: fragment?.context - - private fun getLifecycleOwner(): LifecycleOwner? = activity ?: fragment?.viewLifecycleOwner - - private fun DialogFragment.showDistinct(): Boolean { - val fm = this@AppRouter.getFragmentManager() ?: return false - val tag = javaClass.fragmentTag() - val existing = fm.findFragmentByTag(tag) as? DialogFragment? - if (existing != null && existing.isVisible && existing.arguments == this.arguments) { - return false - } - show(fm, tag) - return true - } - - private fun DialogFragment.show() { - show( - this@AppRouter.getFragmentManager() ?: return, - javaClass.fragmentTag(), - ) - } - - private fun Fragment.findFragmentByTagRecursive(fragmentTag: String): Fragment? { - childFragmentManager.findFragmentByTag(fragmentTag)?.let { - return it - } - val parent = parentFragment - return if (parent != null) { - parent.findFragmentByTagRecursive(fragmentTag) - } else { - parentFragmentManager.findFragmentByTag(fragmentTag) - } - } - - companion object { - - fun from(view: View): AppRouter? = runCatching { - AppRouter(view.findFragment()) - }.getOrElse { - (view.context.findActivity() as? FragmentActivity)?.let(::AppRouter) - } - - fun detailsIntent(context: Context, manga: Manga) = Intent(context, DetailsActivity::class.java) - .putExtra(KEY_MANGA, ParcelableManga(manga)) - .setData(shortMangaUrl(manga.id)) - - fun detailsIntent(context: Context, mangaId: Long) = Intent(context, DetailsActivity::class.java) - .putExtra(KEY_ID, mangaId) - .setData(shortMangaUrl(mangaId)) - - fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?): Intent = - Intent(context, MangaListActivity::class.java) - .setAction(ACTION_MANGA_EXPLORE) - .putExtra(KEY_SOURCE, source.name) - .apply { - if (!filter.isNullOrEmpty()) { - putExtra(KEY_FILTER, ParcelableMangaListFilter(filter)) - } - if (sortOrder != null) { - putExtra(KEY_SORT_ORDER, sortOrder) - } - } - - fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent = - Intent(context, CloudFlareActivity::class.java).apply { - data = exception.url.toUri() - putExtra(KEY_SOURCE, exception.source.name) - exception.headers[CommonHeaders.USER_AGENT]?.let { - putExtra(KEY_USER_AGENT, it) - } - } - - fun browserIntent( - context: Context, - url: String, - source: MangaSource?, - title: String? - ): Intent = Intent(context, BrowserActivity::class.java) - .setData(url.toUri()) - .putExtra(KEY_TITLE, title) - .putExtra(KEY_SOURCE, source?.name) - - fun suggestionsIntent(context: Context) = Intent(context, SuggestionsActivity::class.java) - - fun homeIntent(context: Context) = Intent(context, MainActivity::class.java) - - fun mangaUpdatesIntent(context: Context) = Intent(context, UpdatesActivity::class.java) - - fun readerSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_READER) - - fun suggestionsSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_SUGGESTIONS) - - fun trackerSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_TRACKER) - - fun periodicBackupSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_PERIODIC_BACKUP) - - fun discordSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_MANAGE_DISCORD) - - fun proxySettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_PROXY) - - fun historySettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_HISTORY) - - fun sourcesSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_SOURCES) - - fun manageSourcesIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_MANAGE_SOURCES) - - fun downloadsSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_MANAGE_DOWNLOADS) - - fun sourceSettingsIntent(context: Context, source: MangaSource): Intent = when (source) { - is MangaSourceInfo -> sourceSettingsIntent(context, source.mangaSource) - is ExternalMangaSource -> Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - .setData(Uri.fromParts("package", source.packageName, null)) - - else -> Intent(context, SettingsActivity::class.java) - .setAction(ACTION_SOURCE) - .putExtra(KEY_SOURCE, source.name) - } - - fun sourceAuthIntent(context: Context, source: MangaSource): Intent { - return Intent(context, SourceAuthActivity::class.java) - .putExtra(KEY_SOURCE, source.name) - } - - fun overrideEditIntent(context: Context, manga: Manga): Intent = - Intent(context, OverrideConfigActivity::class.java) - .putExtra(KEY_MANGA, ParcelableManga(manga, withDescription = false)) - - fun isShareSupported(manga: Manga): Boolean = when { - manga.isBroken -> false - manga.isLocal -> manga.url.toUri().toFileOrNull() != null - else -> true - } - - fun shortMangaUrl(mangaId: Long): Uri = Uri.Builder() - .scheme("kotatsu") - .path("manga") - .appendQueryParameter("id", mangaId.toString()) - .build() - - const val KEY_DATA = "data" - const val KEY_ENTRIES = "entries" - const val KEY_ERROR = "error" - const val KEY_EXCLUDE = "exclude" - const val KEY_FILE = "file" - const val KEY_FILTER = "filter" - const val KEY_ID = "id" - const val KEY_INDEX = "index" - const val KEY_IS_BOTTOMTAB = "is_btab" - const val KEY_KIND = "kind" - const val KEY_LIST_SECTION = "list_section" - const val KEY_MANGA = "manga" - const val KEY_MANGA_LIST = "manga_list" - const val KEY_PAGES = "pages" - const val KEY_PREVIEW = "preview" - const val KEY_QUERY = "query" - const val KEY_READER_MODE = "reader_mode" - const val KEY_SORT_ORDER = "sort_order" - const val KEY_SOURCE = "source" - const val KEY_TAB = "tab" - const val KEY_TITLE = "title" - const val KEY_URL = "url" - const val KEY_USER_AGENT = "user_agent" - - const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY" - const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS" - const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST" - const val ACTION_MANGA_EXPLORE = "${BuildConfig.APPLICATION_ID}.action.EXPLORE_MANGA" - const val ACTION_PROXY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PROXY" - const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS" - const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS" - const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES" - const val ACTION_MANAGE_DISCORD = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DISCORD" - const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS" - const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER" - const val ACTION_PERIODIC_BACKUP = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PERIODIC_BACKUP" - - private const val ACCOUNT_KEY = "account" - private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS" - private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" - - private const val TYPE_TEXT = "text/plain" - private const val TYPE_IMAGE = "image/*" - private const val TYPE_CBZ = "application/x-cbz" - - private fun Class.fragmentTag() = name // TODO - - private inline fun fragmentTag() = F::class.java.fragmentTag() - } + constructor(activity: FragmentActivity) : this(activity, null) + + constructor(fragment: Fragment) : this(null, fragment) + + private val settings: AppSettings by lazy { + EntryPointAccessors.fromApplication(checkNotNull(contextOrNull())).settings + } + + /** Activities **/ + + fun openList(source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?) { + startActivity(listIntent(contextOrNull() ?: return, source, filter, sortOrder)) + } + + fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)), null) + + fun openSearch(query: String, kind: SearchKind = SearchKind.SIMPLE) { + startActivity( + Intent(contextOrNull() ?: return, SearchActivity::class.java) + .putExtra(KEY_QUERY, query) + .putExtra(KEY_KIND, kind), + ) + } + + fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query), null) + + fun openDetails(manga: Manga) { + startActivity(detailsIntent(contextOrNull() ?: return, manga)) + } + + fun openDetails(mangaId: Long) { + startActivity(detailsIntent(contextOrNull() ?: return, mangaId)) + } + + fun openDetails(link: Uri) { + startActivity( + Intent(contextOrNull() ?: return, DetailsActivity::class.java) + .setData(link), + ) + } + + fun openReader(manga: Manga, anchor: View? = null) { + openReader( + ReaderIntent.Builder(contextOrNull() ?: return) + .manga(manga) + .build(), + anchor, + ) + } + + fun openReader(intent: ReaderIntent, anchor: View? = null) { + val activityIntent = intent.intent + if (settings.isReaderMultiTaskEnabled && activityIntent.data != null) { + activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT) + } + startActivity(activityIntent, anchor?.let { view -> scaleUpActivityOptionsOf(view) }) + } + + fun openAlternatives(manga: Manga) { + startActivity( + Intent(contextOrNull() ?: return, AlternativesActivity::class.java) + .putExtra(KEY_MANGA, ParcelableManga(manga)), + ) + } + + fun openRelated(manga: Manga) { + startActivity( + Intent(contextOrNull(), RelatedMangaActivity::class.java) + .putExtra(KEY_MANGA, ParcelableManga(manga)), + ) + } + + fun openImage(url: String, source: MangaSource?, anchor: View? = null, preview: CoilMemoryCacheKey? = null) { + startActivity( + Intent(contextOrNull(), ImageActivity::class.java) + .setData(url.toUri()) + .putExtra(KEY_SOURCE, source?.name) + .putExtra(KEY_PREVIEW, preview), + anchor?.let { scaleUpActivityOptionsOf(it) }, + ) + } + + fun openBookmarks() = startActivity(AllBookmarksActivity::class.java) + + fun openAppUpdate() = startActivity(AppUpdateActivity::class.java) + + fun openSuggestions() { + startActivity(suggestionsIntent(contextOrNull() ?: return)) + } + + fun openSourcesCatalog() = startActivity(SourcesCatalogActivity::class.java) + + fun openDownloads() = startActivity(DownloadsActivity::class.java) + + fun openDirectoriesSettings() = startActivity(MangaDirectoriesActivity::class.java) + + fun openBrowser(url: String, source: MangaSource?, title: String?) { + startActivity(browserIntent(contextOrNull() ?: return, url, source, title)) + } + + fun openBrowser(manga: Manga) = openBrowser( + url = manga.publicUrl, + source = manga.source, + title = manga.title, + ) + + fun openColorFilterConfig(manga: Manga, page: MangaPage) { + startActivity( + Intent(contextOrNull(), ColorFilterConfigActivity::class.java) + .putExtra(KEY_MANGA, ParcelableManga(manga)) + .putExtra(KEY_PAGES, ParcelableMangaPage(page)), + ) + } + + fun openHistory() = startActivity(HistoryActivity::class.java) + + fun openFavorites() = startActivity(FavouritesActivity::class.java) + + fun openFavorites(category: FavouriteCategory) { + startActivity( + Intent(contextOrNull() ?: return, FavouritesActivity::class.java) + .putExtra(KEY_ID, category.id) + .putExtra(KEY_TITLE, category.title), + ) + } + + fun openFavoriteCategories() = startActivity(FavouriteCategoriesActivity::class.java) + + fun openFavoriteCategoryEdit(categoryId: Long) { + startActivity( + Intent(contextOrNull() ?: return, FavouritesCategoryEditActivity::class.java) + .putExtra(KEY_ID, categoryId), + ) + } + + fun openFavoriteCategoryCreate() = openFavoriteCategoryEdit(FavouritesCategoryEditActivity.NO_ID) + + fun openMangaUpdates() { + startActivity(mangaUpdatesIntent(contextOrNull() ?: return)) + } + + fun openMangaOverrideConfig(manga: Manga) { + val intent = overrideEditIntent(contextOrNull() ?: return, manga) + startActivity(intent) + } + + fun openSettings() = startActivity(SettingsActivity::class.java) + + fun openReaderSettings() { + startActivity(readerSettingsIntent(contextOrNull() ?: return)) + } + + fun openProxySettings() { + startActivity(proxySettingsIntent(contextOrNull() ?: return)) + } + + fun openDownloadsSetting() { + startActivity(downloadsSettingsIntent(contextOrNull() ?: return)) + } + + fun openSourceSettings(source: MangaSource) { + startActivity(sourceSettingsIntent(contextOrNull() ?: return, source)) + } + + fun openSuggestionsSettings() { + startActivity(suggestionsSettingsIntent(contextOrNull() ?: return)) + } + + fun openSourcesSettings() { + startActivity(sourcesSettingsIntent(contextOrNull() ?: return)) + } + + fun openDiscordSettings() { + startActivity(discordSettingsIntent(contextOrNull() ?: return)) + } + + fun openReaderTapGridSettings() = startActivity(ReaderTapGridConfigActivity::class.java) + + fun openScrobblerSettings(scrobbler: ScrobblerService) { + startActivity( + Intent(contextOrNull() ?: return, ScrobblerConfigActivity::class.java) + .putExtra(KEY_ID, scrobbler.id), + ) + } + + fun openSourceAuth(source: MangaSource) { + startActivity(sourceAuthIntent(contextOrNull() ?: return, source)) + } + + fun openManageSources() { + startActivity( + manageSourcesIntent(contextOrNull() ?: return), + ) + } + + fun openStatistic() = startActivity(StatsActivity::class.java) + + @CheckResult + fun openExternalBrowser(url: String, chooserTitle: CharSequence? = null): Boolean { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = url.toUriOrNull() ?: return false + return startActivitySafe( + if (!chooserTitle.isNullOrEmpty()) { + Intent.createChooser(intent, chooserTitle) + } else { + intent + }, + ) + } + + @CheckResult + fun openSystemSyncSettings(account: Account): Boolean { + val args = Bundle(1) + args.putParcelable(ACCOUNT_KEY, account) + val intent = Intent(ACTION_ACCOUNT_SYNC_SETTINGS) + intent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, args) + return startActivitySafe(intent) + } + + /** Dialogs **/ + + fun showDownloadDialog(manga: Manga, snackbarHost: View?) = showDownloadDialog(setOf(manga), snackbarHost) + + fun showDownloadDialog(manga: Collection, snackbarHost: View?) { + if (manga.isEmpty()) { + return + } + val fm = getFragmentManager() ?: return + if (snackbarHost != null) { + getLifecycleOwner()?.let { lifecycleOwner -> + DownloadDialogFragment.registerCallback(fm, lifecycleOwner, snackbarHost) + } + } else { + DownloadDialogFragment.unregisterCallback(fm) + } + DownloadDialogFragment().withArgs(1) { + putParcelableArray(KEY_MANGA, manga.mapToArray { ParcelableManga(it, withDescription = false) }) + }.showDistinct() + } + + fun showLocalInfoDialog(manga: Manga) { + LocalInfoDialog().withArgs(1) { + putParcelable(KEY_MANGA, ParcelableManga(manga)) + }.showDistinct() + } + + fun showDirectorySelectDialog() { + MangaDirectorySelectDialog().showDistinct() + } + + fun showFavoriteDialog(manga: Manga) = showFavoriteDialog(setOf(manga)) + + fun showFavoriteDialog(manga: Collection) { + if (manga.isEmpty()) { + return + } + FavoriteDialog().withArgs(1) { + putParcelableArrayList( + KEY_MANGA_LIST, + manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withDescription = false) }, + ) + }.showDistinct() + } + + fun showTagDialog(tag: MangaTag) { + buildAlertDialog(contextOrNull() ?: return) { + setIcon(R.drawable.ic_tag) + setTitle(tag.title) + setItems( + arrayOf( + context.getString(R.string.search_on_s, tag.source.getTitle(context)), + context.getString(R.string.search_everywhere), + ), + ) { _, which -> + when (which) { + 0 -> openList(tag) + 1 -> openSearch(tag.title, SearchKind.TAG) + } + } + setNegativeButton(R.string.close, null) + setCancelable(true) + }.show() + } + + fun showAuthorDialog(author: String, source: MangaSource) { + buildAlertDialog(contextOrNull() ?: return) { + setIcon(R.drawable.ic_user) + setTitle(author) + setItems( + arrayOf( + context.getString(R.string.search_on_s, source.getTitle(context)), + context.getString(R.string.search_everywhere), + ), + ) { _, which -> + when (which) { + 0 -> openList(source, MangaListFilter(author = author), null) + 1 -> openSearch(author, SearchKind.AUTHOR) + } + } + setNegativeButton(R.string.close, null) + setCancelable(true) + }.show() + } + + fun showShareDialog(manga: Manga) { + if (manga.isBroken) { + return + } + if (manga.isLocal) { + manga.url.toUri().toFileOrNull()?.let { + shareFile(it) + } + return + } + buildAlertDialog(contextOrNull() ?: return) { + setIcon(context.getThemeDrawable(appcompatR.attr.actionModeShareDrawable)) + setTitle(R.string.share) + setItems( + arrayOf( + context.getString(R.string.link_to_manga_in_app), + context.getString(R.string.link_to_manga_on_s, manga.source.getTitle(context)), + ), + ) { _, which -> + val link = when (which) { + 0 -> manga.appUrl.toString() + 1 -> manga.publicUrl + else -> return@setItems + } + shareLink(link, manga.title) + } + setNegativeButton(android.R.string.cancel, null) + setCancelable(true) + }.show() + } + + fun showErrorDialog(error: Throwable, url: String? = null) { + ErrorDetailsDialog().withArgs(2) { + putSerializable(KEY_ERROR, error) + putString(KEY_URL, url) + }.show() + } + + fun showBackupRestoreDialog(fileUri: Uri) { + RestoreDialogFragment().withArgs(1) { + putString(KEY_FILE, fileUri.toString()) + }.show() + } + + fun createBackup(destination: Uri) { + BackupDialogFragment().withArgs(1) { + putParcelable(KEY_DATA, destination) + }.showDistinct() + } + + fun showImportDialog() { + ImportDialogFragment().showDistinct() + } + + fun showFilterSheet(): Boolean = if (isFilterSupported()) { + FilterSheetFragment().showDistinct() + } else { + false + } + + fun showTagsCatalogSheet(excludeMode: Boolean) { + if (!isFilterSupported()) { + return + } + TagsCatalogSheet().withArgs(1) { + putBoolean(KEY_EXCLUDE, excludeMode) + }.showDistinct() + } + + fun showListConfigSheet(section: ListConfigSection) { + ListConfigBottomSheet().withArgs(1) { + putParcelable(KEY_LIST_SECTION, section) + }.showDistinct() + } + + fun showStatisticSheet(manga: Manga) { + MangaStatsSheet().withArgs(1) { + putParcelable(KEY_MANGA, ParcelableManga(manga)) + }.showDistinct() + } + + fun showReaderConfigSheet(mode: ReaderMode) { + ReaderConfigSheet().withArgs(1) { + putInt(KEY_READER_MODE, mode.id) + }.showDistinct() + } + + fun showWelcomeSheet() { + WelcomeSheet().showDistinct() + } + + fun showChapterPagesSheet() { + ChaptersPagesSheet().showDistinct() + } + + fun showChapterPagesSheet(defaultTab: Int) { + ChaptersPagesSheet().withArgs(1) { + putInt(KEY_TAB, defaultTab) + }.showDistinct() + } + + fun showScrobblingSelectorSheet(manga: Manga, scrobblerService: ScrobblerService?) { + ScrobblingSelectorSheet().withArgs(2) { + putParcelable(KEY_MANGA, ParcelableManga(manga)) + if (scrobblerService != null) { + putInt(KEY_ID, scrobblerService.id) + } + }.show() + } + + fun showScrobblingInfoSheet(index: Int) { + ScrobblingInfoSheet().withArgs(1) { + putInt(KEY_INDEX, index) + }.showDistinct() + } + + fun showTrackerCategoriesConfigSheet() { + TrackerCategoriesConfigSheet().showDistinct() + } + + fun askForDownloadOverMeteredNetwork(onConfirmed: (allow: Boolean) -> Unit) { + val context = contextOrNull() ?: return + when (settings.allowDownloadOnMeteredNetwork) { + TriStateOption.ENABLED -> onConfirmed(true) + TriStateOption.DISABLED -> onConfirmed(false) + TriStateOption.ASK -> { + if (!context.connectivityManager.isActiveNetworkMetered) { + onConfirmed(true) + return + } + val listener = DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + settings.allowDownloadOnMeteredNetwork = TriStateOption.ENABLED + onConfirmed(true) + } + + DialogInterface.BUTTON_NEUTRAL -> { + onConfirmed(true) + } + + DialogInterface.BUTTON_NEGATIVE -> { + settings.allowDownloadOnMeteredNetwork = TriStateOption.DISABLED + onConfirmed(false) + } + } + } + BigButtonsAlertDialog.Builder(context) + .setIcon(R.drawable.ic_network_cellular) + .setTitle(R.string.download_cellular_confirm) + .setPositiveButton(R.string.allow_always, listener) + .setNeutralButton(R.string.allow_once, listener) + .setNegativeButton(R.string.dont_allow, listener) + .create() + .show() + } + } + } + + /** Public utils **/ + + fun isFilterSupported(): Boolean = when { + fragment != null -> FilterCoordinator.find(fragment) != null + activity != null -> activity is FilterCoordinator.Owner + else -> false + } + + fun isChapterPagesSheetShown(): Boolean { + val sheet = getFragmentManager()?.findFragmentByTag(fragmentTag()) as? ChaptersPagesSheet + return sheet?.dialog?.isShowing == true + } + + fun closeWelcomeSheet(): Boolean { + val tag = fragmentTag() + val sheet = fragment?.findFragmentByTagRecursive(tag) + ?: activity?.supportFragmentManager?.findFragmentByTag(tag) + ?: return false + return if (sheet is WelcomeSheet) { + sheet.dismissAllowingStateLoss() + true + } else { + false + } + } + + /** Private utils **/ + + private fun startActivity(intent: Intent, options: Bundle? = null) { + fragment?.also { + if (it.host != null) { + it.startActivity(intent, options) + } + } ?: activity?.startActivity(intent, options) + } + + private fun startActivitySafe(intent: Intent): Boolean = try { + startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + false + } + + private fun startActivity(activityClass: Class) { + startActivity(Intent(contextOrNull() ?: return, activityClass)) + } + + private fun getFragmentManager(): FragmentManager? = runCatching { + fragment?.childFragmentManager ?: activity?.supportFragmentManager + }.onFailure { exception -> + exception.printStackTraceDebug() + }.getOrNull() + + private fun shareLink(link: String, title: String) { + val context = contextOrNull() ?: return + ShareCompat.IntentBuilder(context) + .setText(link) + .setType(TYPE_TEXT) + .setChooserTitle(context.getString(R.string.share_s, title.ellipsize(12))) + .startChooser() + } + + private fun shareFile(file: File) { // TODO directory sharing support + val context = contextOrNull() ?: return + val intentBuilder = ShareCompat.IntentBuilder(context) + .setType(TYPE_CBZ) + val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file) + intentBuilder.addStream(uri) + intentBuilder.setChooserTitle(context.getString(R.string.share_s, file.name)) + intentBuilder.startChooser() + } + + @UiContext + private fun contextOrNull(): Context? = activity ?: fragment?.context + + private fun getLifecycleOwner(): LifecycleOwner? = activity ?: fragment?.viewLifecycleOwner + + private fun DialogFragment.showDistinct(): Boolean { + val fm = this@AppRouter.getFragmentManager() ?: return false + val tag = javaClass.fragmentTag() + val existing = fm.findFragmentByTag(tag) as? DialogFragment? + if (existing != null && existing.isVisible && existing.arguments == this.arguments) { + return false + } + show(fm, tag) + return true + } + + private fun DialogFragment.show() { + show( + this@AppRouter.getFragmentManager() ?: return, + javaClass.fragmentTag(), + ) + } + + private fun Fragment.findFragmentByTagRecursive(fragmentTag: String): Fragment? { + childFragmentManager.findFragmentByTag(fragmentTag)?.let { + return it + } + val parent = parentFragment + return if (parent != null) { + parent.findFragmentByTagRecursive(fragmentTag) + } else { + parentFragmentManager.findFragmentByTag(fragmentTag) + } + } + + companion object { + + fun from(view: View): AppRouter? = runCatching { + AppRouter(view.findFragment()) + }.getOrElse { + (view.context.findActivity() as? FragmentActivity)?.let(::AppRouter) + } + + fun detailsIntent(context: Context, manga: Manga) = Intent(context, DetailsActivity::class.java) + .putExtra(KEY_MANGA, ParcelableManga(manga)) + .setData(shortMangaUrl(manga.id)) + + fun detailsIntent(context: Context, mangaId: Long) = Intent(context, DetailsActivity::class.java) + .putExtra(KEY_ID, mangaId) + .setData(shortMangaUrl(mangaId)) + + fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?): Intent = + Intent(context, MangaListActivity::class.java) + .setAction(ACTION_MANGA_EXPLORE) + .putExtra(KEY_SOURCE, source.name) + .apply { + if (!filter.isNullOrEmpty()) { + putExtra(KEY_FILTER, ParcelableMangaListFilter(filter)) + } + if (sortOrder != null) { + putExtra(KEY_SORT_ORDER, sortOrder) + } + } + + fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent = + Intent(context, CloudFlareActivity::class.java).apply { + data = exception.url.toUri() + putExtra(KEY_SOURCE, exception.source.name) + exception.headers[CommonHeaders.USER_AGENT]?.let { + putExtra(KEY_USER_AGENT, it) + } + } + + fun browserIntent( + context: Context, + url: String, + source: MangaSource?, + title: String? + ): Intent = Intent(context, BrowserActivity::class.java) + .setData(url.toUri()) + .putExtra(KEY_TITLE, title) + .putExtra(KEY_SOURCE, source?.name) + + fun suggestionsIntent(context: Context) = Intent(context, SuggestionsActivity::class.java) + + fun homeIntent(context: Context) = Intent(context, MainActivity::class.java) + + fun mangaUpdatesIntent(context: Context) = Intent(context, UpdatesActivity::class.java) + + fun readerSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_READER) + + fun suggestionsSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_SUGGESTIONS) + + fun trackerSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_TRACKER) + + fun periodicBackupSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_PERIODIC_BACKUP) + + fun discordSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_MANAGE_DISCORD) + + fun proxySettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_PROXY) + + fun historySettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_HISTORY) + + fun sourcesSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_SOURCES) + + fun manageSourcesIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_MANAGE_SOURCES) + + fun downloadsSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_MANAGE_DOWNLOADS) + + fun sourceSettingsIntent(context: Context, source: MangaSource): Intent = when (source) { + is MangaSourceInfo -> sourceSettingsIntent(context, source.mangaSource) + is ExternalMangaSource -> Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", source.packageName, null)) + + else -> Intent(context, SettingsActivity::class.java) + .setAction(ACTION_SOURCE) + .putExtra(KEY_SOURCE, source.name) + } + + fun sourceAuthIntent(context: Context, source: MangaSource): Intent { + return Intent(context, SourceAuthActivity::class.java) + .putExtra(KEY_SOURCE, source.name) + } + + fun overrideEditIntent(context: Context, manga: Manga): Intent = + Intent(context, OverrideConfigActivity::class.java) + .putExtra(KEY_MANGA, ParcelableManga(manga, withDescription = false)) + + fun isShareSupported(manga: Manga): Boolean = when { + manga.isBroken -> false + manga.isLocal -> manga.url.toUri().toFileOrNull() != null + else -> true + } + + fun shortMangaUrl(mangaId: Long): Uri = Uri.Builder() + .scheme("kotatsu") + .path("manga") + .appendQueryParameter("id", mangaId.toString()) + .build() + + const val KEY_DATA = "data" + const val KEY_ENTRIES = "entries" + const val KEY_ERROR = "error" + const val KEY_EXCLUDE = "exclude" + const val KEY_FILE = "file" + const val KEY_FILTER = "filter" + const val KEY_ID = "id" + const val KEY_INDEX = "index" + const val KEY_IS_BOTTOMTAB = "is_btab" + const val KEY_KIND = "kind" + const val KEY_LIST_SECTION = "list_section" + const val KEY_MANGA = "manga" + const val KEY_MANGA_LIST = "manga_list" + const val KEY_PAGES = "pages" + const val KEY_PREVIEW = "preview" + const val KEY_QUERY = "query" + const val KEY_READER_MODE = "reader_mode" + const val KEY_SORT_ORDER = "sort_order" + const val KEY_SOURCE = "source" + const val KEY_TAB = "tab" + const val KEY_TITLE = "title" + const val KEY_URL = "url" + const val KEY_USER_AGENT = "user_agent" + + const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY" + const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS" + const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST" + const val ACTION_MANGA_EXPLORE = "${BuildConfig.APPLICATION_ID}.action.EXPLORE_MANGA" + const val ACTION_PROXY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PROXY" + const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS" + const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS" + const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES" + const val ACTION_MANAGE_DISCORD = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DISCORD" + const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS" + const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER" + const val ACTION_PERIODIC_BACKUP = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PERIODIC_BACKUP" + + private const val ACCOUNT_KEY = "account" + private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS" + private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" + + private const val TYPE_TEXT = "text/plain" + private const val TYPE_IMAGE = "image/*" + private const val TYPE_CBZ = "application/x-cbz" + + private fun Class.fragmentTag() = name // TODO + + private inline fun fragmentTag() = F::class.java.fragmentTag() + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt index cab4a9628..5dffe9920 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt @@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException +import org.koitharu.kotatsu.core.exceptions.EmptyMangaException import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException @@ -62,216 +63,219 @@ private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported" private val FNFE_MESSAGE_REGEX = Regex("^(/[^\\s:]+)?.+?\\s([A-Z]{2,6})?\\s.+$") fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources) - ?: resources.getString(R.string.error_occurred) + ?: resources.getString(R.string.error_occurred) private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) { - is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message - is CaughtException -> cause.getDisplayMessageOrNull(resources) - is WrapperIOException -> cause.getDisplayMessageOrNull(resources) - is ScrobblerAuthRequiredException -> resources.getString( - R.string.scrobbler_auth_required, - resources.getString(scrobbler.titleResId), - ) + is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message + is CaughtException -> cause.getDisplayMessageOrNull(resources) + is WrapperIOException -> cause.getDisplayMessageOrNull(resources) + is ScrobblerAuthRequiredException -> resources.getString( + R.string.scrobbler_auth_required, + resources.getString(scrobbler.titleResId), + ) - is AuthRequiredException -> resources.getString(R.string.auth_required) - is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required) - is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message) - is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message) - is ActivityNotFoundException, - is UnsupportedOperationException, - -> resources.getString(R.string.operation_not_supported) + is AuthRequiredException -> resources.getString(R.string.auth_required) + is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required) + is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message) + is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message) + is ActivityNotFoundException, + is UnsupportedOperationException, + -> resources.getString(R.string.operation_not_supported) - is TooManyRequestExceptions -> { - val delay = getRetryDelay() - val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) { - resources.formatDurationShort(delay) - } else { - null - } - if (formattedTime != null) { - resources.getString(R.string.too_many_requests_message_retry, formattedTime) - } else { - resources.getString(R.string.too_many_requests_message) - } - } + is TooManyRequestExceptions -> { + val delay = getRetryDelay() + val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) { + resources.formatDurationShort(delay) + } else { + null + } + if (formattedTime != null) { + resources.getString(R.string.too_many_requests_message_retry, formattedTime) + } else { + resources.getString(R.string.too_many_requests_message) + } + } - is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty()) - is SQLiteFullException -> resources.getString(R.string.error_no_space_left) - is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) - is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message) - is FileNotFoundException -> parseMessage(resources) ?: message - is AccessDeniedException -> resources.getString(R.string.no_access_to_file) - is NonFileUriException -> resources.getString(R.string.error_non_file_uri) - is EmptyHistoryException -> resources.getString(R.string.history_is_empty) - is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration) - is SyncApiException, - is ContentUnavailableException -> message + is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty()) + is SQLiteFullException -> resources.getString(R.string.error_no_space_left) + is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) + is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message) + is FileNotFoundException -> parseMessage(resources) ?: message + is AccessDeniedException -> resources.getString(R.string.no_access_to_file) + is NonFileUriException -> resources.getString(R.string.error_non_file_uri) + is EmptyHistoryException -> resources.getString(R.string.history_is_empty) + is EmptyMangaException -> reason?.let { resources.getString(it.msgResId) } ?: cause?.getDisplayMessage(resources) + is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration) + is SyncApiException, + is ContentUnavailableException -> message - is ParseException -> shortMessage - is ConnectException, - is UnknownHostException, - is NoRouteToHostException, - is SocketTimeoutException -> resources.getString(R.string.network_error) + is ParseException -> shortMessage + is ConnectException, + is UnknownHostException, + is NoRouteToHostException, + is SocketTimeoutException -> resources.getString(R.string.network_error) - is ImageDecodeException -> { - val type = format?.substringBefore('/') - val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) } - if (type.isNullOrEmpty() || type == "image") { - resources.getString(R.string.error_image_format, formatString) - } else { - resources.getString(R.string.error_not_image, formatString) - } - } + is ImageDecodeException -> { + val type = format?.substringBefore('/') + val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) } + if (type.isNullOrEmpty() || type == "image") { + resources.getString(R.string.error_image_format, formatString) + } else { + resources.getString(R.string.error_not_image, formatString) + } + } - is NoDataReceivedException -> resources.getString(R.string.error_no_data_received) - is IncompatiblePluginException -> { - cause?.getDisplayMessageOrNull(resources)?.let { - resources.getString(R.string.plugin_incompatible_with_cause, it) - } ?: resources.getString(R.string.plugin_incompatible) - } + is NoDataReceivedException -> resources.getString(R.string.error_no_data_received) + is IncompatiblePluginException -> { + cause?.getDisplayMessageOrNull(resources)?.let { + resources.getString(R.string.plugin_incompatible_with_cause, it) + } ?: resources.getString(R.string.plugin_incompatible) + } - is WrongPasswordException -> resources.getString(R.string.wrong_password) - is NotFoundException -> resources.getString(R.string.not_found_404) - is UnsupportedSourceException -> resources.getString(R.string.unsupported_source) + is WrongPasswordException -> resources.getString(R.string.wrong_password) + is NotFoundException -> resources.getString(R.string.not_found_404) + is UnsupportedSourceException -> resources.getString(R.string.unsupported_source) - is HttpException -> getHttpDisplayMessage(response.code, resources) - is HttpStatusException -> getHttpDisplayMessage(statusCode, resources) + is HttpException -> getHttpDisplayMessage(response.code, resources) + is HttpStatusException -> getHttpDisplayMessage(statusCode, resources) - else -> mapDisplayMessage(message, resources) ?: message + else -> mapDisplayMessage(message, resources) ?: message }.takeUnless { it.isNullOrBlank() } @DrawableRes fun Throwable.getDisplayIcon(): Int = when (this) { - is AuthRequiredException -> R.drawable.ic_auth_key_large - is CloudFlareProtectedException -> R.drawable.ic_bot_large - is UnknownHostException, - is SocketTimeoutException, - is ConnectException, - is NoRouteToHostException, - is ProtocolException -> R.drawable.ic_plug_large + is AuthRequiredException -> R.drawable.ic_auth_key_large + is CloudFlareProtectedException -> R.drawable.ic_bot_large + is UnknownHostException, + is SocketTimeoutException, + is ConnectException, + is NoRouteToHostException, + is ProtocolException -> R.drawable.ic_plug_large - is CloudFlareBlockedException -> R.drawable.ic_denied_large + is CloudFlareBlockedException -> R.drawable.ic_denied_large - is InteractiveActionRequiredException -> R.drawable.ic_interaction_large - else -> R.drawable.ic_error_large + is InteractiveActionRequiredException -> R.drawable.ic_interaction_large + else -> R.drawable.ic_error_large } fun Throwable.getCauseUrl(): String? = when (this) { - is ParseException -> url - is NotFoundException -> url - is TooManyRequestExceptions -> url - is CaughtException -> cause.getCauseUrl() - is WrapperIOException -> cause.getCauseUrl() - is NoDataReceivedException -> url - is CloudFlareBlockedException -> url - is CloudFlareProtectedException -> url - is InteractiveActionRequiredException -> url - is HttpStatusException -> url - is HttpException -> (response.delegate as? Response)?.request?.url?.toString() - else -> null + is ParseException -> url + is NotFoundException -> url + is TooManyRequestExceptions -> url + is CaughtException -> cause.getCauseUrl() + is WrapperIOException -> cause.getCauseUrl() + is NoDataReceivedException -> url + is CloudFlareBlockedException -> url + is CloudFlareProtectedException -> url + is InteractiveActionRequiredException -> url + is HttpStatusException -> url + is UnsupportedSourceException -> manga?.publicUrl?.takeIf { it.isHttpUrl() } + is EmptyMangaException -> manga.publicUrl.takeIf { it.isHttpUrl() } + is HttpException -> (response.delegate as? Response)?.request?.url?.toString() + else -> null } private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) { - HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404) - HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403) - HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable) - in 500..599 -> resources.getString(R.string.server_error, statusCode) - else -> null + HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404) + HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403) + HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable) + in 500..599 -> resources.getString(R.string.server_error, statusCode) + else -> null } private fun mapDisplayMessage(msg: String?, resources: Resources): String? = when { - msg.isNullOrEmpty() -> null - msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left) - msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file) - msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset) - msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported) - msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported) - msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported) - msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported) - msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported) - else -> null + msg.isNullOrEmpty() -> null + msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left) + msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file) + msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset) + msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported) + msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported) + msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported) + msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported) + msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported) + else -> null } fun Throwable.isReportable(): Boolean { - if (this is Error) { - return true - } - if (this is CaughtException) { - return cause.isReportable() - } - if (this is WrapperIOException) { - return cause.isReportable() - } - if (ExceptionResolver.canResolve(this)) { - return false - } - if (this is ParseException - || this.isNetworkError() - || this is CloudFlareBlockedException - || this is CloudFlareProtectedException - || this is BadBackupFormatException - || this is WrongPasswordException - || this is TooManyRequestExceptions - || this is HttpStatusException - ) { - return false - } - return true + if (this is Error) { + return true + } + if (this is CaughtException) { + return cause.isReportable() + } + if (this is WrapperIOException) { + return cause.isReportable() + } + if (ExceptionResolver.canResolve(this)) { + return false + } + if (this is ParseException + || this.isNetworkError() + || this is CloudFlareBlockedException + || this is CloudFlareProtectedException + || this is BadBackupFormatException + || this is WrongPasswordException + || this is TooManyRequestExceptions + || this is HttpStatusException + ) { + return false + } + return true } fun Throwable.isNetworkError(): Boolean { - return this is UnknownHostException - || this is SocketTimeoutException - || this is StreamResetException - || this is SocketException - || this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT + return this is UnknownHostException + || this is SocketTimeoutException + || this is StreamResetException + || this is SocketException + || this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT } fun Throwable.report(silent: Boolean = false) { - val exception = CaughtException(this) - if (!silent) { - exception.sendWithAcra() - } else if (!BuildConfig.DEBUG) { - exception.sendSilentlyWithAcra() - } + val exception = CaughtException(this) + if (!silent) { + exception.sendWithAcra() + } else if (!BuildConfig.DEBUG) { + exception.sendSilentlyWithAcra() + } } fun Throwable.isWebViewUnavailable(): Boolean { - val trace = stackTraceToString() - return trace.contains("android.webkit.WebView.") + val trace = stackTraceToString() + return trace.contains("android.webkit.WebView.") } @Suppress("FunctionName") fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT) fun FileNotFoundException.getFile(): File? { - val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null - return groups.getOrNull(1)?.let { File(it) } + val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null + return groups.getOrNull(1)?.let { File(it) } } fun FileNotFoundException.parseMessage(resources: Resources): String? { - /* - Examples: - /storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system) - /storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory) - /storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error) - */ - val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null - val path = groups.getOrNull(1) - val error = groups.getOrNull(2) - val baseMessageIs = when (error) { - "EROFS" -> R.string.no_write_permission_to_file - "ENOENT" -> R.string.file_not_found - else -> return null - } - return if (path.isNullOrEmpty()) { - resources.getString(baseMessageIs) - } else { - resources.getString( - R.string.inline_preference_pattern, - resources.getString(baseMessageIs), - path, - ) - } + /* + Examples: + /storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system) + /storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory) + /storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error) + */ + val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null + val path = groups.getOrNull(1) + val error = groups.getOrNull(2) + val baseMessageIs = when (error) { + "EROFS" -> R.string.no_write_permission_to_file + "ENOENT" -> R.string.file_not_found + else -> return null + } + return if (path.isNullOrEmpty()) { + resources.getString(baseMessageIs) + } else { + resources.getString( + R.string.inline_preference_pattern, + resources.getString(baseMessageIs), + path, + ) + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/data/MangaDetails.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/data/MangaDetails.kt index 314bf33f1..0a7e74735 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/data/MangaDetails.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/data/MangaDetails.kt @@ -7,111 +7,115 @@ import org.koitharu.kotatsu.core.ui.model.MangaOverride import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.reader.data.filterChapters import java.util.Locale data class MangaDetails( - private val manga: Manga, - private val localManga: LocalManga?, - private val override: MangaOverride?, - val description: CharSequence?, - val isLoaded: Boolean, + private val manga: Manga, + private val localManga: LocalManga?, + private val override: MangaOverride?, + val description: CharSequence?, + val isLoaded: Boolean, ) { - constructor(manga: Manga) : this( - manga = manga, - localManga = null, - override = null, - description = null, - isLoaded = false, - ) - - val id: Long - get() = manga.id - - val allChapters: List by lazy { mergeChapters() } - - val chapters: Map> by lazy { - allChapters.groupBy { it.branch } - } - - val isLocal - get() = manga.isLocal - - val local: LocalManga? - get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null - - val coverUrl: String? - get() = override?.coverUrl - .ifNullOrEmpty { manga.largeCoverUrl } - .ifNullOrEmpty { manga.coverUrl } - .ifNullOrEmpty { localManga?.manga?.coverUrl } - ?.nullIfEmpty() - - private val mergedManga by lazy { - if (localManga == null) { - // fast path - manga.withOverride(override) - } else { - manga.copy( - title = override?.title.ifNullOrEmpty { manga.title }, - coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl }, - largeCoverUrl = override?.coverUrl.ifNullOrEmpty { manga.largeCoverUrl }, - contentRating = override?.contentRating ?: manga.contentRating, - chapters = allChapters, - ) - } - } - - fun toManga() = mergedManga - - fun getLocale(): Locale? { - findAppropriateLocale(chapters.keys.singleOrNull())?.let { - return it - } - return manga.source.getLocale() - } - - fun filterChapters(branch: String?) = copy( - manga = manga.filterChapters(branch), - localManga = localManga?.run { - copy(manga = manga.filterChapters(branch)) - }, - ) - - private fun mergeChapters(): List { - val chapters = manga.chapters - val localChapters = local?.manga?.chapters.orEmpty() - if (chapters.isNullOrEmpty()) { - return localChapters - } - val localMap = if (localChapters.isNotEmpty()) { - localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id } - } else { - null - } - val result = ArrayList(chapters.size) - for (chapter in chapters) { - val local = localMap?.remove(chapter.id) - result += local ?: chapter - } - if (!localMap.isNullOrEmpty()) { - result.addAll(localMap.values) - } - return result - } - - private fun findAppropriateLocale(name: String?): Locale? { - if (name.isNullOrEmpty()) { - return null - } - return Locale.getAvailableLocales().find { lc -> - name.contains(lc.getDisplayName(lc), ignoreCase = true) || - name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) || - name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) || - name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true) - } - } + constructor(manga: Manga) : this( + manga = manga, + localManga = null, + override = null, + description = null, + isLoaded = false, + ) + + val id: Long + get() = manga.id + + val allChapters: List by lazy { mergeChapters() } + + val chapters: Map> by lazy { + allChapters.groupBy { it.branch } + } + + val isLocal + get() = manga.isLocal + + val local: LocalManga? + get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null + + val coverUrl: String? + get() = override?.coverUrl + .ifNullOrEmpty { manga.largeCoverUrl } + .ifNullOrEmpty { manga.coverUrl } + .ifNullOrEmpty { localManga?.manga?.coverUrl } + ?.nullIfEmpty() + + val isRestricted: Boolean + get() = manga.state == MangaState.RESTRICTED + + private val mergedManga by lazy { + if (localManga == null) { + // fast path + manga.withOverride(override) + } else { + manga.copy( + title = override?.title.ifNullOrEmpty { manga.title }, + coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl }, + largeCoverUrl = override?.coverUrl.ifNullOrEmpty { manga.largeCoverUrl }, + contentRating = override?.contentRating ?: manga.contentRating, + chapters = allChapters, + ) + } + } + + fun toManga() = mergedManga + + fun getLocale(): Locale? { + findAppropriateLocale(chapters.keys.singleOrNull())?.let { + return it + } + return manga.source.getLocale() + } + + fun filterChapters(branch: String?) = copy( + manga = manga.filterChapters(branch), + localManga = localManga?.run { + copy(manga = manga.filterChapters(branch)) + }, + ) + + private fun mergeChapters(): List { + val chapters = manga.chapters + val localChapters = local?.manga?.chapters.orEmpty() + if (chapters.isNullOrEmpty()) { + return localChapters + } + val localMap = if (localChapters.isNotEmpty()) { + localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id } + } else { + null + } + val result = ArrayList(chapters.size) + for (chapter in chapters) { + val local = localMap?.remove(chapter.id) + result += local ?: chapter + } + if (!localMap.isNullOrEmpty()) { + result.addAll(localMap.values) + } + return result + } + + private fun findAppropriateLocale(name: String?): Locale? { + if (name.isNullOrEmpty()) { + return null + } + return Locale.getAvailableLocales().find { lc -> + name.contains(lc.getDisplayName(lc), ignoreCase = true) || + name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) || + name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) || + name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true) + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index e951521d2..272b05e19 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository +import org.koitharu.kotatsu.core.exceptions.EmptyMangaException import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.nav.MangaIntent import org.koitharu.kotatsu.core.nav.ReaderIntent @@ -47,6 +48,7 @@ import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.domain.DetailsInteractor import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel +import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryUpdateUseCase @@ -75,526 +77,547 @@ private const val PREFETCH_LIMIT = 10 @HiltViewModel class ReaderViewModel @Inject constructor( - private val savedStateHandle: SavedStateHandle, - private val dataRepository: MangaDataRepository, - private val historyRepository: HistoryRepository, - private val bookmarksRepository: BookmarksRepository, - settings: AppSettings, - private val pageLoader: PageLoader, - private val chaptersLoader: ChaptersLoader, - private val appShortcutManager: AppShortcutManager, - private val detailsLoadUseCase: DetailsLoadUseCase, - private val historyUpdateUseCase: HistoryUpdateUseCase, - private val detectReaderModeUseCase: DetectReaderModeUseCase, - private val statsCollector: StatsCollector, - private val discordRpc: DiscordRpc, - @LocalStorageChanges localStorageChanges: SharedFlow, - interactor: DetailsInteractor, - deleteLocalMangaUseCase: DeleteLocalMangaUseCase, - downloadScheduler: DownloadWorker.Scheduler, - readerSettingsProducerFactory: ReaderSettings.Producer.Factory, + private val savedStateHandle: SavedStateHandle, + private val dataRepository: MangaDataRepository, + private val historyRepository: HistoryRepository, + private val bookmarksRepository: BookmarksRepository, + settings: AppSettings, + private val pageLoader: PageLoader, + private val chaptersLoader: ChaptersLoader, + private val appShortcutManager: AppShortcutManager, + private val detailsLoadUseCase: DetailsLoadUseCase, + private val historyUpdateUseCase: HistoryUpdateUseCase, + private val detectReaderModeUseCase: DetectReaderModeUseCase, + private val statsCollector: StatsCollector, + private val discordRpc: DiscordRpc, + @LocalStorageChanges localStorageChanges: SharedFlow, + interactor: DetailsInteractor, + deleteLocalMangaUseCase: DeleteLocalMangaUseCase, + downloadScheduler: DownloadWorker.Scheduler, + readerSettingsProducerFactory: ReaderSettings.Producer.Factory, ) : ChaptersPagesViewModel( - settings = settings, - interactor = interactor, - bookmarksRepository = bookmarksRepository, - historyRepository = historyRepository, - downloadScheduler = downloadScheduler, - deleteLocalMangaUseCase = deleteLocalMangaUseCase, - localStorageChanges = localStorageChanges, + settings = settings, + interactor = interactor, + bookmarksRepository = bookmarksRepository, + historyRepository = historyRepository, + downloadScheduler = downloadScheduler, + deleteLocalMangaUseCase = deleteLocalMangaUseCase, + localStorageChanges = localStorageChanges, ) { - private val intent = MangaIntent(savedStateHandle) - - private var loadingJob: Job? = null - private var pageSaveJob: Job? = null - private var bookmarkJob: Job? = null - private var stateChangeJob: Job? = null - - init { - mangaDetails.value = intent.manga?.let { MangaDetails(it) } - } - - val readerMode = MutableStateFlow(null) - val onPageSaved = MutableEventFlow>() - val onLoadingError = MutableEventFlow() - val onShowToast = MutableEventFlow() - val onAskNsfwIncognito = MutableEventFlow() - val uiState = MutableStateFlow(null) - - val isIncognitoMode = MutableStateFlow(savedStateHandle.get(ReaderIntent.EXTRA_INCOGNITO)) - - val content = MutableStateFlow(ReaderContent(emptyList(), null)) - - val pageAnimation = settings.observeAsStateFlow( - scope = viewModelScope + Dispatchers.Default, - key = AppSettings.KEY_READER_ANIMATION, - valueProducer = { readerAnimation }, - ) - - val isInfoBarEnabled = settings.observeAsStateFlow( - scope = viewModelScope + Dispatchers.Default, - key = AppSettings.KEY_READER_BAR, - valueProducer = { isReaderBarEnabled }, - ) - - val isInfoBarTransparent = settings.observeAsStateFlow( - scope = viewModelScope + Dispatchers.Default, - key = AppSettings.KEY_READER_BAR_TRANSPARENT, - valueProducer = { isReaderBarTransparent }, - ) - - val isKeepScreenOnEnabled = settings.observeAsStateFlow( - scope = viewModelScope + Dispatchers.Default, - key = AppSettings.KEY_READER_SCREEN_ON, - valueProducer = { isReaderKeepScreenOn }, - ) - - val isWebtoonZooEnabled = observeIsWebtoonZoomEnabled() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) - - val isWebtoonGapsEnabled = settings.observeAsStateFlow( - scope = viewModelScope + Dispatchers.Default, - key = AppSettings.KEY_WEBTOON_GAPS, - valueProducer = { isWebtoonGapsEnabled }, - ) - - val isWebtoonPullGestureEnabled = settings.observeAsStateFlow( - scope = viewModelScope + Dispatchers.Default, - key = AppSettings.KEY_WEBTOON_PULL_GESTURE, - valueProducer = { isWebtoonPullGestureEnabled }, - ) - - val defaultWebtoonZoomOut = observeIsWebtoonZoomEnabled().flatMapLatest { - if (it) { - observeWebtoonZoomOut() - } else { - flowOf(0f) - } - }.flowOn(Dispatchers.Default) - - val isZoomControlsEnabled = getObserveIsZoomControlEnabled().flatMapLatest { zoom -> - if (zoom) { - combine(readerMode, isWebtoonZooEnabled) { mode, ze -> ze || mode != ReaderMode.WEBTOON } - } else { - flowOf(false) - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) - - val readerSettingsProducer = readerSettingsProducerFactory.create( - manga.mapNotNull { it?.id }, - ) - - val isMangaNsfw = manga.map { it?.contentRating == ContentRating.ADULT } - - val isBookmarkAdded = readingState.flatMapLatest { state -> - val manga = mangaDetails.value?.toManga() - if (state == null || manga == null) { - flowOf(false) - } else { - bookmarksRepository.observeBookmark(manga, state.chapterId, state.page) - .map { - it != null && it.chapterId == state.chapterId && it.page == state.page - } - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) - - init { - initIncognitoMode() - loadImpl() - launchJob(Dispatchers.Default) { - val mangaId = manga.filterNotNull().first().id - if (!isIncognitoMode.firstNotNull()) { - appShortcutManager.notifyMangaOpened(mangaId) - } - } - } - - fun reload() { - loadingJob?.cancel() - loadImpl() - } - - fun onPause() { - getMangaOrNull()?.let { - statsCollector.onPause(it.id) - } - } - - fun onStop() { - discordRpc.clearRpc() - } - - fun onIdle() { - discordRpc.setIdle() - } - - fun switchMode(newMode: ReaderMode) { - launchJob { - val manga = checkNotNull(getMangaOrNull()) - dataRepository.saveReaderMode( - manga = manga, - mode = newMode, - ) - readerMode.value = newMode - content.update { - it.copy(state = getCurrentState()) - } - } - } - - fun saveCurrentState(state: ReaderState? = null) { - if (state != null) { - readingState.value = state - savedStateHandle[ReaderIntent.EXTRA_STATE] = state - } - if (isIncognitoMode.value != false) { - return - } - val readerState = state ?: readingState.value ?: return - historyUpdateUseCase.invokeAsync( - manga = getMangaOrNull() ?: return, - readerState = readerState, - percent = computePercent(readerState.chapterId, readerState.page), - ) - } - - fun getCurrentState() = readingState.value - - fun getCurrentChapterPages(): List? { - val chapterId = readingState.value?.chapterId ?: return null - return chaptersLoader.getPages(chapterId) - } - - fun saveCurrentPage( - pageSaveHelper: PageSaveHelper - ) { - val prevJob = pageSaveJob - pageSaveJob = launchLoadingJob(Dispatchers.Default) { - prevJob?.cancelAndJoin() - val state = checkNotNull(getCurrentState()) - val currentManga = manga.requireValue() - val task = PageSaveHelper.Task( - manga = currentManga, - chapterId = state.chapterId, - pageNumber = state.page + 1, - page = checkNotNull(getCurrentPage()) { "Cannot find current page" }, - ) - val dest = pageSaveHelper.save(setOf(task)) - onPageSaved.call(dest) - } - } - - fun getCurrentPage(): MangaPage? { - val state = readingState.value ?: return null - return content.value.pages.find { - it.chapterId == state.chapterId && it.index == state.page - }?.toMangaPage() - } - - fun switchChapter(id: Long, page: Int) { - val prevJob = loadingJob - loadingJob = launchLoadingJob(Dispatchers.Default) { - prevJob?.cancelAndJoin() - content.value = ReaderContent(emptyList(), null) - chaptersLoader.loadSingleChapter(id) - val newState = ReaderState(id, page, 0) - content.value = ReaderContent(chaptersLoader.snapshot(), newState) - saveCurrentState(newState) - } - } - - fun switchChapterBy(delta: Int) { - val prevJob = loadingJob - loadingJob = launchLoadingJob(Dispatchers.Default) { - prevJob?.cancelAndJoin() - val prevState = readingState.requireValue() - val newChapterId = if (delta != 0) { - val allChapters = mangaDetails.requireValue().allChapters - var index = allChapters.indexOfFirst { x -> x.id == prevState.chapterId } - if (index < 0) { - return@launchLoadingJob - } - index += delta - (allChapters.getOrNull(index) ?: return@launchLoadingJob).id - } else { - prevState.chapterId - } - content.value = ReaderContent(emptyList(), null) - chaptersLoader.loadSingleChapter(newChapterId) - val newState = ReaderState( - chapterId = newChapterId, - page = if (delta == 0) prevState.page else 0, - scroll = if (delta == 0) prevState.scroll else 0, - ) - content.value = ReaderContent(chaptersLoader.snapshot(), newState) - saveCurrentState(newState) - } - } - - @MainThread - fun onCurrentPageChanged(lowerPos: Int, upperPos: Int) { - val prevJob = stateChangeJob - val pages = content.value.pages // capture immediately - stateChangeJob = launchJob(Dispatchers.Default) { - prevJob?.cancelAndJoin() - loadingJob?.join() - if (pages.size != content.value.pages.size) { - return@launchJob // TODO - } - val centerPos = (lowerPos + upperPos) / 2 - pages.getOrNull(centerPos)?.let { page -> - readingState.update { cs -> - cs?.copy(chapterId = page.chapterId, page = page.index) - } - } - notifyStateChanged() - if (pages.isEmpty() || loadingJob?.isActive == true) { - return@launchJob - } - ensureActive() - val autoLoadAllowed = readerMode.value != ReaderMode.WEBTOON || !isWebtoonPullGestureEnabled.value - if (autoLoadAllowed) { - if (upperPos >= pages.lastIndex - BOUNDS_PAGE_OFFSET) { - loadPrevNextChapter(pages.last().chapterId, isNext = true) - } - if (lowerPos <= BOUNDS_PAGE_OFFSET) { - loadPrevNextChapter(pages.first().chapterId, isNext = false) - } - } - if (pageLoader.isPrefetchApplicable()) { - pageLoader.prefetch(pages.trySublist(upperPos + 1, upperPos + PREFETCH_LIMIT)) - } - } - } - - fun toggleBookmark() { - if (bookmarkJob?.isActive == true) { - return - } - bookmarkJob = launchJob(Dispatchers.Default) { - loadingJob?.join() - val state = checkNotNull(getCurrentState()) - if (isBookmarkAdded.value) { - val manga = requireManga() - bookmarksRepository.removeBookmark(manga.id, state.chapterId, state.page) - onShowToast.call(R.string.bookmark_removed) - } else { - val page = checkNotNull(getCurrentPage()) { "Page not found" } - val bookmark = Bookmark( - manga = requireManga(), - pageId = page.id, - chapterId = state.chapterId, - page = state.page, - scroll = state.scroll, - imageUrl = page.preview.ifNullOrEmpty { page.url }, - createdAt = Instant.now(), - percent = computePercent(state.chapterId, state.page), - ) - bookmarksRepository.addBookmark(bookmark) - onShowToast.call(R.string.bookmark_added) - } - } - } - - fun setIncognitoMode(value: Boolean, dontAskAgain: Boolean) { - isIncognitoMode.value = value - if (dontAskAgain) { - settings.incognitoModeForNsfw = if (value) TriStateOption.ENABLED else TriStateOption.DISABLED - } - } - - private fun loadImpl() { - loadingJob = launchLoadingJob(Dispatchers.Default + EventExceptionHandler(onLoadingError)) { - var exception: Exception? = null - try { - detailsLoadUseCase(intent, force = false) - .collect { details -> - if (mangaDetails.value == null) { - mangaDetails.value = details - } - chaptersLoader.init(details) - val manga = details.toManga() - // obtain state - if (readingState.value == null) { - val newState = getStateFromIntent(manga) - if (newState == null) { - return@collect // manga not loaded yet if cannot get state - } - readingState.value = newState - val mode = runCatchingCancellable { - detectReaderModeUseCase(manga, newState) - }.getOrDefault(settings.defaultReaderMode) - val branch = chaptersLoader.peekChapter(newState.chapterId)?.branch - selectedBranch.value = branch - readerMode.value = mode - try { - chaptersLoader.loadSingleChapter(newState.chapterId) - } catch (e: Exception) { - readingState.value = null // try next time - exception = e.mergeWith(exception) - return@collect - } - } - mangaDetails.value = details.filterChapters(selectedBranch.value) - - // save state - if (!isIncognitoMode.firstNotNull()) { - readingState.value?.let { - val percent = computePercent(it.chapterId, it.page) - historyUpdateUseCase(manga, it, percent) - } - } - notifyStateChanged() - content.value = ReaderContent(chaptersLoader.snapshot(), readingState.value) - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - exception = e.mergeWith(exception) - } - if (readingState.value == null) { - onLoadingError.call( - exception ?: IllegalStateException("Unable to load manga. This should never happen. Please report"), - ) - } else exception?.let { e -> - // manga has been loaded but error occurred - errorEvent.call(e) - } - } - } - - @AnyThread - private fun loadPrevNextChapter(currentId: Long, isNext: Boolean) { - val prevJob = loadingJob - loadingJob = launchLoadingJob(Dispatchers.Default) { - prevJob?.join() - chaptersLoader.loadPrevNextChapter(mangaDetails.requireValue(), currentId, isNext) - content.value = ReaderContent(chaptersLoader.snapshot(), null) - } - } - - private fun List.trySublist(fromIndex: Int, toIndex: Int): List { - val fromIndexBounded = fromIndex.coerceAtMost(lastIndex) - val toIndexBounded = toIndex.coerceIn(fromIndexBounded, lastIndex) - return if (fromIndexBounded == toIndexBounded) { - emptyList() - } else { - subList(fromIndexBounded, toIndexBounded) - } - } - - @WorkerThread - private fun notifyStateChanged() { - val state = getCurrentState() ?: return - val chapter = chaptersLoader.peekChapter(state.chapterId) ?: return - val m = mangaDetails.value ?: return - val chapterIndex = m.chapters[chapter.branch]?.indexOfFirst { it.id == chapter.id } ?: -1 - val newState = ReaderUiState( - mangaName = m.toManga().title, - chapter = chapter, - chapterIndex = chapterIndex, - chaptersTotal = m.chapters[chapter.branch].sizeOrZero(), - totalPages = chaptersLoader.getPagesCount(chapter.id), - currentPage = state.page, - percent = computePercent(state.chapterId, state.page), - incognito = isIncognitoMode.value == true, - ) - uiState.value = newState - if (isIncognitoMode.value == false) { - statsCollector.onStateChanged(m.id, state) - discordRpc.updateRpc(m.toManga(), newState) - } - } - - private fun computePercent(chapterId: Long, pageIndex: Int): Float { - val branch = chaptersLoader.peekChapter(chapterId)?.branch - val chapters = mangaDetails.value?.chapters?.get(branch) ?: return PROGRESS_NONE - val chaptersCount = chapters.size - val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId } - val pagesCount = chaptersLoader.getPagesCount(chapterId) - if (chaptersCount == 0 || pagesCount == 0) { - return PROGRESS_NONE - } - val pagePercent = (pageIndex + 1) / pagesCount.toFloat() - val ppc = 1f / chaptersCount - return ppc * chapterIndex + ppc * pagePercent - } - - private fun observeIsWebtoonZoomEnabled() = settings.observeAsFlow( - key = AppSettings.KEY_WEBTOON_ZOOM, - valueProducer = { isWebtoonZoomEnabled }, - ) - - private fun observeWebtoonZoomOut() = settings.observeAsFlow( - key = AppSettings.KEY_WEBTOON_ZOOM_OUT, - valueProducer = { defaultWebtoonZoomOut }, - ) - - private fun getObserveIsZoomControlEnabled() = settings.observeAsFlow( - key = AppSettings.KEY_READER_ZOOM_BUTTONS, - valueProducer = { isReaderZoomButtonsEnabled }, - ) - - private fun initIncognitoMode() { - if (isIncognitoMode.value != null) { - return - } - launchJob(Dispatchers.Default) { - interactor.observeIncognitoMode(manga) - .collect { - when (it) { - TriStateOption.ENABLED -> isIncognitoMode.value = true - TriStateOption.ASK -> { - onAskNsfwIncognito.call(Unit) - return@collect - } - - TriStateOption.DISABLED -> isIncognitoMode.value = false - } - } - } - } - - private suspend fun getStateFromIntent(manga: Manga): ReaderState? { - // check if we have at least some chapters loaded - if (manga.chapters.isNullOrEmpty()) { - return null - } - // specific state is requested - val requestedState: ReaderState? = savedStateHandle[ReaderIntent.EXTRA_STATE] - if (requestedState != null) { - return if (manga.findChapterById(requestedState.chapterId) != null) { - requestedState - } else { - null - } - } - - val requestedBranch: String? = savedStateHandle[ReaderIntent.EXTRA_BRANCH] - // continue reading - val history = historyRepository.getOne(manga) - if (history != null) { - val chapter = manga.findChapterById(history.chapterId) ?: return null - // specified branch is requested - return if (ReaderIntent.EXTRA_BRANCH in savedStateHandle) { - if (chapter.branch == requestedBranch) { - ReaderState(history) - } else { - ReaderState(manga, requestedBranch) - } - } else { - ReaderState(history) - } - } - - // start from beginning - val preferredBranch = requestedBranch ?: manga.getPreferredBranch(null) - return ReaderState(manga, preferredBranch) - } - - private fun Exception.mergeWith(other: Exception?): Exception = if (other == null) { - this - } else { - other.addSuppressed(this) - other - } + private val intent = MangaIntent(savedStateHandle) + + private var loadingJob: Job? = null + private var pageSaveJob: Job? = null + private var bookmarkJob: Job? = null + private var stateChangeJob: Job? = null + + init { + mangaDetails.value = intent.manga?.let { MangaDetails(it) } + } + + val readerMode = MutableStateFlow(null) + val onPageSaved = MutableEventFlow>() + val onLoadingError = MutableEventFlow() + val onShowToast = MutableEventFlow() + val onAskNsfwIncognito = MutableEventFlow() + val uiState = MutableStateFlow(null) + + val isIncognitoMode = MutableStateFlow(savedStateHandle.get(ReaderIntent.EXTRA_INCOGNITO)) + + val content = MutableStateFlow(ReaderContent(emptyList(), null)) + + val pageAnimation = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, + key = AppSettings.KEY_READER_ANIMATION, + valueProducer = { readerAnimation }, + ) + + val isInfoBarEnabled = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, + key = AppSettings.KEY_READER_BAR, + valueProducer = { isReaderBarEnabled }, + ) + + val isInfoBarTransparent = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, + key = AppSettings.KEY_READER_BAR_TRANSPARENT, + valueProducer = { isReaderBarTransparent }, + ) + + val isKeepScreenOnEnabled = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, + key = AppSettings.KEY_READER_SCREEN_ON, + valueProducer = { isReaderKeepScreenOn }, + ) + + val isWebtoonZooEnabled = observeIsWebtoonZoomEnabled() + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) + + val isWebtoonGapsEnabled = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, + key = AppSettings.KEY_WEBTOON_GAPS, + valueProducer = { isWebtoonGapsEnabled }, + ) + + val isWebtoonPullGestureEnabled = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, + key = AppSettings.KEY_WEBTOON_PULL_GESTURE, + valueProducer = { isWebtoonPullGestureEnabled }, + ) + + val defaultWebtoonZoomOut = observeIsWebtoonZoomEnabled().flatMapLatest { + if (it) { + observeWebtoonZoomOut() + } else { + flowOf(0f) + } + }.flowOn(Dispatchers.Default) + + val isZoomControlsEnabled = getObserveIsZoomControlEnabled().flatMapLatest { zoom -> + if (zoom) { + combine(readerMode, isWebtoonZooEnabled) { mode, ze -> ze || mode != ReaderMode.WEBTOON } + } else { + flowOf(false) + } + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) + + val readerSettingsProducer = readerSettingsProducerFactory.create( + manga.mapNotNull { it?.id }, + ) + + val isMangaNsfw = manga.map { it?.contentRating == ContentRating.ADULT } + + val isBookmarkAdded = readingState.flatMapLatest { state -> + val manga = mangaDetails.value?.toManga() + if (state == null || manga == null) { + flowOf(false) + } else { + bookmarksRepository.observeBookmark(manga, state.chapterId, state.page) + .map { + it != null && it.chapterId == state.chapterId && it.page == state.page + } + } + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) + + init { + initIncognitoMode() + loadImpl() + launchJob(Dispatchers.Default) { + val mangaId = manga.filterNotNull().first().id + if (!isIncognitoMode.firstNotNull()) { + appShortcutManager.notifyMangaOpened(mangaId) + } + } + } + + fun reload() { + loadingJob?.cancel() + loadImpl() + } + + fun onPause() { + getMangaOrNull()?.let { + statsCollector.onPause(it.id) + } + } + + fun onStop() { + discordRpc.clearRpc() + } + + fun onIdle() { + discordRpc.setIdle() + } + + fun switchMode(newMode: ReaderMode) { + launchJob { + val manga = checkNotNull(getMangaOrNull()) + dataRepository.saveReaderMode( + manga = manga, + mode = newMode, + ) + readerMode.value = newMode + content.update { + it.copy(state = getCurrentState()) + } + } + } + + fun saveCurrentState(state: ReaderState? = null) { + if (state != null) { + readingState.value = state + savedStateHandle[ReaderIntent.EXTRA_STATE] = state + } + if (isIncognitoMode.value != false) { + return + } + val readerState = state ?: readingState.value ?: return + historyUpdateUseCase.invokeAsync( + manga = getMangaOrNull() ?: return, + readerState = readerState, + percent = computePercent(readerState.chapterId, readerState.page), + ) + } + + fun getCurrentState() = readingState.value + + fun getCurrentChapterPages(): List? { + val chapterId = readingState.value?.chapterId ?: return null + return chaptersLoader.getPages(chapterId) + } + + fun saveCurrentPage( + pageSaveHelper: PageSaveHelper + ) { + val prevJob = pageSaveJob + pageSaveJob = launchLoadingJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() + val state = checkNotNull(getCurrentState()) + val currentManga = manga.requireValue() + val task = PageSaveHelper.Task( + manga = currentManga, + chapterId = state.chapterId, + pageNumber = state.page + 1, + page = checkNotNull(getCurrentPage()) { "Cannot find current page" }, + ) + val dest = pageSaveHelper.save(setOf(task)) + onPageSaved.call(dest) + } + } + + fun getCurrentPage(): MangaPage? { + val state = readingState.value ?: return null + return content.value.pages.find { + it.chapterId == state.chapterId && it.index == state.page + }?.toMangaPage() + } + + fun switchChapter(id: Long, page: Int) { + val prevJob = loadingJob + loadingJob = launchLoadingJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() + content.value = ReaderContent(emptyList(), null) + chaptersLoader.loadSingleChapter(id) + val newState = ReaderState(id, page, 0) + content.value = ReaderContent(chaptersLoader.snapshot(), newState) + saveCurrentState(newState) + } + } + + fun switchChapterBy(delta: Int) { + val prevJob = loadingJob + loadingJob = launchLoadingJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() + val prevState = readingState.requireValue() + val newChapterId = if (delta != 0) { + val allChapters = mangaDetails.requireValue().allChapters + var index = allChapters.indexOfFirst { x -> x.id == prevState.chapterId } + if (index < 0) { + return@launchLoadingJob + } + index += delta + (allChapters.getOrNull(index) ?: return@launchLoadingJob).id + } else { + prevState.chapterId + } + content.value = ReaderContent(emptyList(), null) + chaptersLoader.loadSingleChapter(newChapterId) + val newState = ReaderState( + chapterId = newChapterId, + page = if (delta == 0) prevState.page else 0, + scroll = if (delta == 0) prevState.scroll else 0, + ) + content.value = ReaderContent(chaptersLoader.snapshot(), newState) + saveCurrentState(newState) + } + } + + @MainThread + fun onCurrentPageChanged(lowerPos: Int, upperPos: Int) { + val prevJob = stateChangeJob + val pages = content.value.pages // capture immediately + stateChangeJob = launchJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() + loadingJob?.join() + if (pages.size != content.value.pages.size) { + return@launchJob // TODO + } + val centerPos = (lowerPos + upperPos) / 2 + pages.getOrNull(centerPos)?.let { page -> + readingState.update { cs -> + cs?.copy(chapterId = page.chapterId, page = page.index) + } + } + notifyStateChanged() + if (pages.isEmpty() || loadingJob?.isActive == true) { + return@launchJob + } + ensureActive() + val autoLoadAllowed = readerMode.value != ReaderMode.WEBTOON || !isWebtoonPullGestureEnabled.value + if (autoLoadAllowed) { + if (upperPos >= pages.lastIndex - BOUNDS_PAGE_OFFSET) { + loadPrevNextChapter(pages.last().chapterId, isNext = true) + } + if (lowerPos <= BOUNDS_PAGE_OFFSET) { + loadPrevNextChapter(pages.first().chapterId, isNext = false) + } + } + if (pageLoader.isPrefetchApplicable()) { + pageLoader.prefetch(pages.trySublist(upperPos + 1, upperPos + PREFETCH_LIMIT)) + } + } + } + + fun toggleBookmark() { + if (bookmarkJob?.isActive == true) { + return + } + bookmarkJob = launchJob(Dispatchers.Default) { + loadingJob?.join() + val state = checkNotNull(getCurrentState()) + if (isBookmarkAdded.value) { + val manga = requireManga() + bookmarksRepository.removeBookmark(manga.id, state.chapterId, state.page) + onShowToast.call(R.string.bookmark_removed) + } else { + val page = checkNotNull(getCurrentPage()) { "Page not found" } + val bookmark = Bookmark( + manga = requireManga(), + pageId = page.id, + chapterId = state.chapterId, + page = state.page, + scroll = state.scroll, + imageUrl = page.preview.ifNullOrEmpty { page.url }, + createdAt = Instant.now(), + percent = computePercent(state.chapterId, state.page), + ) + bookmarksRepository.addBookmark(bookmark) + onShowToast.call(R.string.bookmark_added) + } + } + } + + fun setIncognitoMode(value: Boolean, dontAskAgain: Boolean) { + isIncognitoMode.value = value + if (dontAskAgain) { + settings.incognitoModeForNsfw = if (value) TriStateOption.ENABLED else TriStateOption.DISABLED + } + } + + private fun loadImpl() { + loadingJob = launchLoadingJob(Dispatchers.Default + EventExceptionHandler(onLoadingError)) { + var exception: Exception? = null + var loadedDetails: MangaDetails? = null + try { + detailsLoadUseCase(intent, force = false) + .collect { details -> + loadedDetails = details + if (mangaDetails.value == null) { + mangaDetails.value = details + } + chaptersLoader.init(details) + val manga = details.toManga() + // obtain state + if (readingState.value == null) { + val newState = getStateFromIntent(manga) + if (newState == null) { + return@collect // manga not loaded yet if cannot get state + } + readingState.value = newState + val mode = runCatchingCancellable { + detectReaderModeUseCase(manga, newState) + }.getOrDefault(settings.defaultReaderMode) + val branch = chaptersLoader.peekChapter(newState.chapterId)?.branch + selectedBranch.value = branch + readerMode.value = mode + try { + chaptersLoader.loadSingleChapter(newState.chapterId) + } catch (e: Exception) { + readingState.value = null // try next time + exception = e.mergeWith(exception) + return@collect + } + } + mangaDetails.value = details.filterChapters(selectedBranch.value) + + // save state + if (!isIncognitoMode.firstNotNull()) { + readingState.value?.let { + val percent = computePercent(it.chapterId, it.page) + historyUpdateUseCase(manga, it, percent) + } + } + notifyStateChanged() + content.value = ReaderContent(chaptersLoader.snapshot(), readingState.value) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + exception = e.mergeWith(exception) + } + if (readingState.value == null) { + val loadedManga = loadedDetails // for smart cast + if (loadedManga != null) { + mangaDetails.value = loadedManga.filterChapters(selectedBranch.value) + } + val loadingError = when { + exception != null -> exception + loadedManga == null || !loadedManga.isLoaded -> null + loadedManga.isRestricted -> EmptyMangaException( + EmptyMangaReason.RESTRICTED, + loadedManga.toManga(), + null, + ) + + loadedManga.allChapters.isEmpty() -> EmptyMangaException( + EmptyMangaReason.NO_CHAPTERS, + loadedManga.toManga(), + null, + ) + + else -> null + } ?: IllegalStateException("Unable to load manga. This should never happen. Please report") + onLoadingError.call(loadingError) + } else exception?.let { e -> + // manga has been loaded but error occurred + errorEvent.call(e) + } + } + } + + @AnyThread + private fun loadPrevNextChapter(currentId: Long, isNext: Boolean) { + val prevJob = loadingJob + loadingJob = launchLoadingJob(Dispatchers.Default) { + prevJob?.join() + chaptersLoader.loadPrevNextChapter(mangaDetails.requireValue(), currentId, isNext) + content.value = ReaderContent(chaptersLoader.snapshot(), null) + } + } + + private fun List.trySublist(fromIndex: Int, toIndex: Int): List { + val fromIndexBounded = fromIndex.coerceAtMost(lastIndex) + val toIndexBounded = toIndex.coerceIn(fromIndexBounded, lastIndex) + return if (fromIndexBounded == toIndexBounded) { + emptyList() + } else { + subList(fromIndexBounded, toIndexBounded) + } + } + + @WorkerThread + private fun notifyStateChanged() { + val state = getCurrentState() ?: return + val chapter = chaptersLoader.peekChapter(state.chapterId) ?: return + val m = mangaDetails.value ?: return + val chapterIndex = m.chapters[chapter.branch]?.indexOfFirst { it.id == chapter.id } ?: -1 + val newState = ReaderUiState( + mangaName = m.toManga().title, + chapter = chapter, + chapterIndex = chapterIndex, + chaptersTotal = m.chapters[chapter.branch].sizeOrZero(), + totalPages = chaptersLoader.getPagesCount(chapter.id), + currentPage = state.page, + percent = computePercent(state.chapterId, state.page), + incognito = isIncognitoMode.value == true, + ) + uiState.value = newState + if (isIncognitoMode.value == false) { + statsCollector.onStateChanged(m.id, state) + discordRpc.updateRpc(m.toManga(), newState) + } + } + + private fun computePercent(chapterId: Long, pageIndex: Int): Float { + val branch = chaptersLoader.peekChapter(chapterId)?.branch + val chapters = mangaDetails.value?.chapters?.get(branch) ?: return PROGRESS_NONE + val chaptersCount = chapters.size + val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId } + val pagesCount = chaptersLoader.getPagesCount(chapterId) + if (chaptersCount == 0 || pagesCount == 0) { + return PROGRESS_NONE + } + val pagePercent = (pageIndex + 1) / pagesCount.toFloat() + val ppc = 1f / chaptersCount + return ppc * chapterIndex + ppc * pagePercent + } + + private fun observeIsWebtoonZoomEnabled() = settings.observeAsFlow( + key = AppSettings.KEY_WEBTOON_ZOOM, + valueProducer = { isWebtoonZoomEnabled }, + ) + + private fun observeWebtoonZoomOut() = settings.observeAsFlow( + key = AppSettings.KEY_WEBTOON_ZOOM_OUT, + valueProducer = { defaultWebtoonZoomOut }, + ) + + private fun getObserveIsZoomControlEnabled() = settings.observeAsFlow( + key = AppSettings.KEY_READER_ZOOM_BUTTONS, + valueProducer = { isReaderZoomButtonsEnabled }, + ) + + private fun initIncognitoMode() { + if (isIncognitoMode.value != null) { + return + } + launchJob(Dispatchers.Default) { + interactor.observeIncognitoMode(manga) + .collect { + when (it) { + TriStateOption.ENABLED -> isIncognitoMode.value = true + TriStateOption.ASK -> { + onAskNsfwIncognito.call(Unit) + return@collect + } + + TriStateOption.DISABLED -> isIncognitoMode.value = false + } + } + } + } + + private suspend fun getStateFromIntent(manga: Manga): ReaderState? { + // check if we have at least some chapters loaded + if (manga.chapters.isNullOrEmpty()) { + return null + } + // specific state is requested + val requestedState: ReaderState? = savedStateHandle[ReaderIntent.EXTRA_STATE] + if (requestedState != null) { + return if (manga.findChapterById(requestedState.chapterId) != null) { + requestedState + } else { + null + } + } + + val requestedBranch: String? = savedStateHandle[ReaderIntent.EXTRA_BRANCH] + // continue reading + val history = historyRepository.getOne(manga) + if (history != null) { + val chapter = manga.findChapterById(history.chapterId) ?: return null + // specified branch is requested + return if (ReaderIntent.EXTRA_BRANCH in savedStateHandle) { + if (chapter.branch == requestedBranch) { + ReaderState(history) + } else { + ReaderState(manga, requestedBranch) + } + } else { + ReaderState(history) + } + } + + // start from beginning + val preferredBranch = requestedBranch ?: manga.getPreferredBranch(null) + return ReaderState(manga, preferredBranch) + } + + private fun Exception.mergeWith(other: Exception?): Exception = if (other == null) { + this + } else { + other.addSuppressed(this) + other + } }