diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt index 84b73c99e..7647e0cca 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt @@ -44,13 +44,13 @@ abstract class BaseViewModel : ViewModel() { context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit - ): Job = viewModelScope.launch(context + createErrorHandler(), start, block) + ): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start, block) protected fun launchLoadingJob( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit - ): Job = viewModelScope.launch(context + createErrorHandler(), start) { + ): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start) { loadingCounter.increment() try { block() @@ -81,15 +81,28 @@ abstract class BaseViewModel : ViewModel() { protected fun MutableStateFlow.decrement() = update { it - 1 } - private fun createErrorHandler() = CoroutineExceptionHandler { coroutineContext, throwable -> - throwable.printStackTraceDebug() - if (coroutineContext[SkipErrors.key] == null && throwable !is CancellationException) { - errorEvent.call(throwable) + private fun CoroutineContext.withDefaultExceptionHandler() = + if (this[CoroutineExceptionHandler.Key] is EventExceptionHandler) { + this + } else { + this + EventExceptionHandler(errorEvent) } - } protected object SkipErrors : AbstractCoroutineContextElement(Key) { private object Key : CoroutineContext.Key } + + protected class EventExceptionHandler( + private val event: MutableEventFlow, + ) : AbstractCoroutineContextElement(CoroutineExceptionHandler), + CoroutineExceptionHandler { + + override fun handleException(context: CoroutineContext, exception: Throwable) { + exception.printStackTraceDebug() + if (context[SkipErrors.key] == null && exception !is CancellationException) { + event.call(exception) + } + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MultiMutex.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MultiMutex.kt index ce69060d5..1b176957e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MultiMutex.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MultiMutex.kt @@ -1,53 +1,30 @@ package org.koitharu.kotatsu.core.util -import androidx.collection.ArrayMap +import androidx.annotation.VisibleForTesting import kotlinx.coroutines.sync.Mutex +import java.util.concurrent.ConcurrentHashMap import kotlin.contracts.InvocationKind import kotlin.contracts.contract -open class MultiMutex : Set { +open class MultiMutex { - private val delegates = ArrayMap() + private val delegates = ConcurrentHashMap() - override val size: Int - get() = delegates.size + @VisibleForTesting + val size: Int + get() = delegates.count { it.value.isLocked } - override fun contains(element: T): Boolean = synchronized(delegates) { - delegates.containsKey(element) - } + fun isNotEmpty() = delegates.any { it.value.isLocked } - override fun containsAll(elements: Collection): Boolean = synchronized(delegates) { - elements.all { x -> delegates.containsKey(x) } - } - - override fun isEmpty(): Boolean = delegates.isEmpty() - - override fun iterator(): Iterator = synchronized(delegates) { - delegates.keys.toList() - }.iterator() - - fun isLocked(element: T): Boolean = synchronized(delegates) { - delegates[element]?.isLocked == true - } - - fun tryLock(element: T): Boolean { - val mutex = synchronized(delegates) { - delegates.getOrPut(element, ::Mutex) - } - return mutex.tryLock() - } + fun isEmpty() = delegates.none { it.value.isLocked } suspend fun lock(element: T) { - val mutex = synchronized(delegates) { - delegates.getOrPut(element, ::Mutex) - } + val mutex = delegates.computeIfAbsent(element) { Mutex() } mutex.lock() } fun unlock(element: T) { - synchronized(delegates) { - delegates.remove(element)?.unlock() - } + delegates[element]?.unlock() } suspend inline fun withLock(element: T, block: () -> R): R { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt index bce16e71c..923837b19 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withTimeout import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.suspendlazy.SuspendLazy import java.util.concurrent.TimeUnit 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 5210b13b8..c9b3abe62 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 @@ -17,6 +17,7 @@ data class MangaDetails( private val localManga: LocalManga?, private val override: MangaOverride?, val description: CharSequence?, + @Deprecated("Caller should decide if manga is loaded enough by itself") val isLoaded: Boolean, ) { @@ -31,13 +32,12 @@ data class MangaDetails( val id: Long get() = manga.id - val chapters: Map> = manga.chapters?.groupBy { it.branch }.orEmpty() - - val branches: Set - get() = chapters.keys - val allChapters: List by lazy { mergeChapters() } + val chapters: Map> by lazy { + allChapters.groupBy { it.branch } + } + val isLocal get() = manga.isLocal @@ -51,7 +51,22 @@ data class MangaDetails( .ifNullOrEmpty { localManga?.manga?.coverUrl } ?.nullIfEmpty() - fun toManga() = manga.withOverride(override) + 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 { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsInteractor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsInteractor.kt index e8c1aa04f..ed569cb37 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsInteractor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsInteractor.kt @@ -1,11 +1,13 @@ package org.koitharu.kotatsu.details.domain +import android.util.Log import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.onEach import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.prefs.AppSettings diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsLoadUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsLoadUseCase.kt index 1d820f9cb..ef8f7f4ae 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsLoadUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsLoadUseCase.kt @@ -9,30 +9,33 @@ import androidx.core.text.parseAsHtml import coil3.request.CachePolicy import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.runInterruptible -import okio.IOException import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.nav.MangaIntent import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.util.ext.peek -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.ui.model.MangaOverride import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase import org.koitharu.kotatsu.local.data.LocalMangaRepository +import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.recoverNotNull import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase import javax.inject.Inject -import javax.inject.Provider class DetailsLoadUseCase @Inject constructor( private val mangaDataRepository: MangaDataRepository, @@ -40,91 +43,115 @@ class DetailsLoadUseCase @Inject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, private val recoverUseCase: RecoverMangaUseCase, private val imageGetter: Html.ImageGetter, - private val newChaptersUseCaseProvider: Provider, private val networkState: NetworkState, ) { - operator fun invoke(intent: MangaIntent, force: Boolean): Flow = channelFlow { + operator fun invoke(intent: MangaIntent, force: Boolean): Flow = flow { val manga = requireNotNull(mangaDataRepository.resolveIntent(intent, withChapters = true)) { "Cannot resolve intent $intent" } val override = mangaDataRepository.getOverride(manga.id) - send( + emit( MangaDetails( manga = manga, localManga = null, override = override, - description = null, + description = manga.description?.parseAsHtml(withImages = false), isLoaded = false, ), ) if (manga.isLocal) { - val details = getDetails(manga, force) - send( + loadLocal(manga, override, force) + } else { + loadRemote(manga, override, force) + } + }.distinctUntilChanged() + .buffer(Channel.UNLIMITED) + .flowOn(Dispatchers.Default) + + /** + * Load local manga + try to load the linked remote one if network is not restricted + * Suppress any network errors + */ + private suspend fun FlowCollector.loadLocal(manga: Manga, override: MangaOverride?, force: Boolean) { + val skipNetworkLoad = !force && networkState.isOfflineOrRestricted() + val localDetails = localMangaRepository.getDetails(manga) + emit( + MangaDetails( + manga = localDetails, + localManga = null, + override = override, + description = localDetails.description?.parseAsHtml(withImages = false), + isLoaded = skipNetworkLoad, + ), + ) + if (skipNetworkLoad) { + return + } + val remoteManga = localMangaRepository.getRemoteManga(manga) + if (remoteManga == null) { + emit( MangaDetails( - manga = details, + manga = localDetails, localManga = null, override = override, - description = details.description?.parseAsHtml(withImages = false)?.trim(), + description = localDetails.description?.parseAsHtml(withImages = true), isLoaded = true, ), ) - return@channelFlow - } - val local = async { - localMangaRepository.findSavedManga(manga) - } - if (!force && networkState.isOfflineOrRestricted()) { - // try to avoid loading if has saved manga - val localManga = local.await() - if (localManga != null) { - send( - MangaDetails( - manga = manga, - localManga = localManga, - override = override, - description = manga.description?.parseAsHtml(withImages = true)?.trim(), - isLoaded = true, - ), - ) - return@channelFlow + } else { + val remoteDetails = getDetails(remoteManga, force).getOrNull() + emit( + MangaDetails( + manga = remoteDetails ?: remoteManga, + localManga = LocalManga(localDetails), + override = override, + description = (remoteDetails ?: localDetails).description?.parseAsHtml(withImages = true), + isLoaded = true, + ), + ) + if (remoteDetails != null) { + mangaDataRepository.updateChapters(remoteDetails) } } - try { - val details = getDetails(manga, force) - launch { mangaDataRepository.updateChapters(details) } - launch { updateTracker(details) } - send( + } + + /** + * Load remote manga + saved one if available + * Throw network errors after loading local manga only + */ + private suspend fun FlowCollector.loadRemote( + manga: Manga, + override: MangaOverride?, + force: Boolean + ) = coroutineScope { + val remoteDeferred = async { + getDetails(manga, force) + } + val localManga = localMangaRepository.findSavedManga(manga, withDetails = true) + if (localManga != null) { + emit( MangaDetails( - manga = details, - localManga = local.peek(), + manga = manga, + localManga = localManga, override = override, - description = details.description?.parseAsHtml(withImages = false)?.trim(), + description = localManga.manga.description?.parseAsHtml(withImages = true), isLoaded = false, ), ) - send( - MangaDetails( - manga = details, - localManga = local.await(), - override = override, - description = details.description?.parseAsHtml(withImages = true)?.trim(), - isLoaded = true, - ), - ) - } catch (e: IOException) { - local.await()?.manga?.also { localManga -> - send( - MangaDetails( - manga = localManga, - localManga = null, - override = override, - description = localManga.description?.parseAsHtml(withImages = false)?.trim(), - isLoaded = true, - ), - ) - } ?: close(e) } + val remoteDetails = remoteDeferred.await().getOrThrow() + emit( + MangaDetails( + manga = remoteDetails, + localManga = localManga, + override = override, + description = (remoteDetails.description + ?: localManga?.manga?.description)?.parseAsHtml(withImages = true), + isLoaded = true, + ), + ) + mangaDataRepository.updateChapters(remoteDetails) } private suspend fun getDetails(seed: Manga, force: Boolean) = runCatchingCancellable { @@ -140,20 +167,18 @@ class DetailsLoadUseCase @Inject constructor( } else { null } - }.getOrThrow() - - private suspend fun String.parseAsHtml(withImages: Boolean): CharSequence? { - return if (withImages) { - runInterruptible(Dispatchers.IO) { - parseAsHtml(imageGetter = imageGetter) - }.filterSpans() - } else { - runInterruptible(Dispatchers.Default) { - parseAsHtml() - }.filterSpans().sanitize() - }.takeUnless { it.isBlank() } } + private suspend fun String.parseAsHtml(withImages: Boolean): CharSequence? = if (withImages) { + runInterruptible(Dispatchers.IO) { + parseAsHtml(imageGetter = imageGetter) + }.filterSpans() + } else { + runInterruptible(Dispatchers.Default) { + parseAsHtml() + }.filterSpans().sanitize() + }.trim().nullIfEmpty() + private fun Spanned.filterSpans(): Spanned { val spannable = SpannableString.valueOf(this) val spans = spannable.getSpans() @@ -162,10 +187,4 @@ class DetailsLoadUseCase @Inject constructor( } return spannable } - - private suspend fun updateTracker(details: Manga) = runCatchingCancellable { - newChaptersUseCaseProvider.get()(details) - }.onFailure { e -> - e.printStackTraceDebug() - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActionsView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActionsView.kt index 1f2250cad..e4d71d188 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActionsView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActionsView.kt @@ -12,7 +12,6 @@ import android.widget.Button import android.widget.FrameLayout import android.widget.LinearLayout import androidx.annotation.AttrRes -import androidx.appcompat.widget.TooltipCompat import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import com.google.android.material.slider.Slider @@ -50,7 +49,9 @@ class ReaderActionsView @JvmOverloads constructor( private val binding = LayoutReaderActionsBinding.inflate(LayoutInflater.from(context), this) private val rotationObserver = object : ContentObserver(handler) { override fun onChange(selfChange: Boolean) { - updateRotationButton() + post { + updateRotationButton() + } } } private var isSliderChanged = false diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index 237836472..c32e46d94 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -29,10 +29,15 @@ import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.DialogErrorObserver +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings @@ -133,7 +138,7 @@ class ReaderActivity : } } - viewModel.onError.observeEvent( + viewModel.onLoadingError.observeEvent( this, DialogErrorObserver( host = viewBinding.container, @@ -148,13 +153,24 @@ class ReaderActivity : }, ), ) + viewModel.onError.observeEvent( + this, + SnackbarErrorObserver( + host = viewBinding.container, + fragment = null, + resolver = exceptionResolver, + onResolved = null, + ), + ) viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader) viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container)) viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged) - viewModel.isLoading.observe(this, this::onLoadingStateChanged) - viewModel.content.observe(this) { - onLoadingStateChanged(viewModel.isLoading.value) - } + combine( + viewModel.isLoading, + viewModel.content.map { it.pages.isNotEmpty() }.distinctUntilChanged(), + ::Pair, + ).flowOn(Dispatchers.Default) + .observe(this, this::onLoadingStateChanged) viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn) viewModel.isInfoBarTransparent.observe(this) { viewBinding.infoBar.drawBackground = !it } viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged) @@ -243,9 +259,14 @@ class ReaderActivity : viewBinding.timerControl.onReaderModeChanged(mode) } - private fun onLoadingStateChanged(isLoading: Boolean) { - val hasPages = viewModel.content.value.pages.isNotEmpty() - viewBinding.layoutLoading.isVisible = isLoading && !hasPages + private fun onLoadingStateChanged(value: Pair) { + val (isLoading, hasPages) = value + val showLoadingLayout = isLoading && !hasPages + if (viewBinding.layoutLoading.isVisible != showLoadingLayout) { + val transition = Fade().addTarget(viewBinding.layoutLoading) + TransitionManager.beginDelayedTransition(viewBinding.root, transition) + viewBinding.layoutLoading.isVisible = showLoadingLayout + } if (isLoading && hasPages) { viewBinding.toastView.show(R.string.loading_) } else { 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 be97c2c6a..a6441bc33 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 @@ -1,12 +1,14 @@ package org.koitharu.kotatsu.reader.ui import android.net.Uri +import android.util.Log import androidx.annotation.AnyThread import androidx.annotation.MainThread import androidx.annotation.WorkerThread import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -57,6 +59,7 @@ import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.sizeOrZero import org.koitharu.kotatsu.reader.domain.ChaptersLoader import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase @@ -108,13 +111,12 @@ class ReaderViewModel @Inject constructor( private var stateChangeJob: Job? = null init { - selectedBranch.value = savedStateHandle.get(ReaderIntent.EXTRA_BRANCH) - readingState.value = savedStateHandle[ReaderIntent.EXTRA_STATE] 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) @@ -393,31 +395,56 @@ class ReaderViewModel @Inject constructor( } private fun loadImpl() { - loadingJob = launchLoadingJob(Dispatchers.Default) { - val details = detailsLoadUseCase.invoke(intent, force = false).first { x -> x.isLoaded } - mangaDetails.value = details - chaptersLoader.init(details) - val manga = details.toManga() - // obtain state - if (readingState.value == null) { - readingState.value = getStateFromIntent(manga) - } - val mode = detectReaderModeUseCase.invoke(manga, readingState.value) - val branch = chaptersLoader.peekChapter(readingState.value?.chapterId ?: 0L)?.branch - selectedBranch.value = branch - mangaDetails.value = details.filterChapters(branch) - readerMode.value = mode + loadingJob = launchLoadingJob(Dispatchers.Default + EventExceptionHandler(onLoadingError)) { + val exception = 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 + chaptersLoader.loadSingleChapter(newState.chapterId) + } + mangaDetails.value = details.filterChapters(selectedBranch.value) - chaptersLoader.loadSingleChapter(requireNotNull(readingState.value).chapterId) - // save state - if (!isIncognitoMode.firstNotNull()) { - readingState.value?.let { - val percent = computePercent(it.chapterId, it.page) - historyUpdateUseCase.invoke(manga, it, percent) - } + // 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) + } + null // no errors + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + e + } + if (readingState.value == null) { + onLoadingError.call( + exception ?: IllegalStateException("Unable to load manga. This should never happen. Please report"), + ) + } else if (exception != null) { + // manga has been loaded but error occurred + errorEvent.call(exception) } - notifyStateChanged() - content.value = ReaderContent(chaptersLoader.snapshot(), readingState.value) } } @@ -513,18 +540,40 @@ class ReaderViewModel @Inject constructor( } } - private suspend fun getStateFromIntent(manga: Manga): ReaderState { - val history = historyRepository.getOne(manga) - val preselectedBranch = selectedBranch.value - val result = if (history != null) { - if (preselectedBranch != null && preselectedBranch != manga.findChapterById(history.chapterId)?.branch) { + 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) } - } else { - null } - return result ?: ReaderState(manga, preselectedBranch ?: manga.getPreferredBranch(null)) + + // start from beginning + val preferredBranch = requestedBranch ?: manga.getPreferredBranch(null) + return ReaderState(manga, preferredBranch) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/CheckNewChaptersUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/CheckNewChaptersUseCase.kt index 787498997..b3dc35f99 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/CheckNewChaptersUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/CheckNewChaptersUseCase.kt @@ -33,7 +33,7 @@ class CheckNewChaptersUseCase @Inject constructor( suspend operator fun invoke(manga: Manga): MangaUpdates = mutex.withLock(manga.id) { repository.updateTracks() - val tracking = repository.getTrackOrNull(manga) ?: return MangaUpdates.Failure( + val tracking = repository.getTrackOrNull(manga) ?: return@withLock MangaUpdates.Failure( manga = manga, error = null, )