Experimental: improve manga loading in reader

master
Koitharu 9 months ago
parent 506a8b6e90
commit d8efe374a8
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -44,13 +44,13 @@ abstract class BaseViewModel : ViewModel() {
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT, start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit block: suspend CoroutineScope.() -> Unit
): Job = viewModelScope.launch(context + createErrorHandler(), start, block) ): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start, block)
protected fun launchLoadingJob( protected fun launchLoadingJob(
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT, start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit block: suspend CoroutineScope.() -> Unit
): Job = viewModelScope.launch(context + createErrorHandler(), start) { ): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start) {
loadingCounter.increment() loadingCounter.increment()
try { try {
block() block()
@ -81,15 +81,28 @@ abstract class BaseViewModel : ViewModel() {
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 } protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
private fun createErrorHandler() = CoroutineExceptionHandler { coroutineContext, throwable -> private fun CoroutineContext.withDefaultExceptionHandler() =
throwable.printStackTraceDebug() if (this[CoroutineExceptionHandler.Key] is EventExceptionHandler) {
if (coroutineContext[SkipErrors.key] == null && throwable !is CancellationException) { this
errorEvent.call(throwable) } else {
this + EventExceptionHandler(errorEvent)
} }
}
protected object SkipErrors : AbstractCoroutineContextElement(Key) { protected object SkipErrors : AbstractCoroutineContextElement(Key) {
private object Key : CoroutineContext.Key<SkipErrors> private object Key : CoroutineContext.Key<SkipErrors>
} }
protected class EventExceptionHandler(
private val event: MutableEventFlow<Throwable>,
) : AbstractCoroutineContextElement(CoroutineExceptionHandler),
CoroutineExceptionHandler {
override fun handleException(context: CoroutineContext, exception: Throwable) {
exception.printStackTraceDebug()
if (context[SkipErrors.key] == null && exception !is CancellationException) {
event.call(exception)
}
}
}
} }

@ -1,53 +1,30 @@
package org.koitharu.kotatsu.core.util package org.koitharu.kotatsu.core.util
import androidx.collection.ArrayMap import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import java.util.concurrent.ConcurrentHashMap
import kotlin.contracts.InvocationKind import kotlin.contracts.InvocationKind
import kotlin.contracts.contract import kotlin.contracts.contract
open class MultiMutex<T : Any> : Set<T> { open class MultiMutex<T : Any> {
private val delegates = ArrayMap<T, Mutex>() private val delegates = ConcurrentHashMap<T, Mutex>()
override val size: Int @VisibleForTesting
get() = delegates.size val size: Int
get() = delegates.count { it.value.isLocked }
override fun contains(element: T): Boolean = synchronized(delegates) { fun isNotEmpty() = delegates.any { it.value.isLocked }
delegates.containsKey(element)
}
override fun containsAll(elements: Collection<T>): Boolean = synchronized(delegates) {
elements.all { x -> delegates.containsKey(x) }
}
override fun isEmpty(): Boolean = delegates.isEmpty() fun isEmpty() = delegates.none { it.value.isLocked }
override fun iterator(): Iterator<T> = 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()
}
suspend fun lock(element: T) { suspend fun lock(element: T) {
val mutex = synchronized(delegates) { val mutex = delegates.computeIfAbsent(element) { Mutex() }
delegates.getOrPut(element, ::Mutex)
}
mutex.lock() mutex.lock()
} }
fun unlock(element: T) { fun unlock(element: T) {
synchronized(delegates) { delegates[element]?.unlock()
delegates.remove(element)?.unlock()
}
} }
suspend inline fun <R> withLock(element: T, block: () -> R): R { suspend inline fun <R> withLock(element: T, block: () -> R): R {

@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withTimeout
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.SuspendLazy import org.koitharu.kotatsu.parsers.util.suspendlazy.SuspendLazy
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit

@ -17,6 +17,7 @@ data class MangaDetails(
private val localManga: LocalManga?, private val localManga: LocalManga?,
private val override: MangaOverride?, private val override: MangaOverride?,
val description: CharSequence?, val description: CharSequence?,
@Deprecated("Caller should decide if manga is loaded enough by itself")
val isLoaded: Boolean, val isLoaded: Boolean,
) { ) {
@ -31,13 +32,12 @@ data class MangaDetails(
val id: Long val id: Long
get() = manga.id get() = manga.id
val chapters: Map<String?, List<MangaChapter>> = manga.chapters?.groupBy { it.branch }.orEmpty()
val branches: Set<String?>
get() = chapters.keys
val allChapters: List<MangaChapter> by lazy { mergeChapters() } val allChapters: List<MangaChapter> by lazy { mergeChapters() }
val chapters: Map<String?, List<MangaChapter>> by lazy {
allChapters.groupBy { it.branch }
}
val isLocal val isLocal
get() = manga.isLocal get() = manga.isLocal
@ -51,7 +51,22 @@ data class MangaDetails(
.ifNullOrEmpty { localManga?.manga?.coverUrl } .ifNullOrEmpty { localManga?.manga?.coverUrl }
?.nullIfEmpty() ?.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? { fun getLocale(): Locale? {
findAppropriateLocale(chapters.keys.singleOrNull())?.let { findAppropriateLocale(chapters.keys.singleOrNull())?.let {

@ -1,11 +1,13 @@
package org.koitharu.kotatsu.details.domain package org.koitharu.kotatsu.details.domain
import android.util.Log
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings

@ -9,30 +9,33 @@ import androidx.core.text.parseAsHtml
import coil3.request.CachePolicy import coil3.request.CachePolicy
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.launch 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 kotlinx.coroutines.runInterruptible
import okio.IOException
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.nav.MangaIntent import org.koitharu.kotatsu.core.nav.MangaIntent
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.CachingMangaRepository
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.peek import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase
import org.koitharu.kotatsu.local.data.LocalMangaRepository 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.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga 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.recoverNotNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider
class DetailsLoadUseCase @Inject constructor( class DetailsLoadUseCase @Inject constructor(
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
@ -40,91 +43,115 @@ class DetailsLoadUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val recoverUseCase: RecoverMangaUseCase, private val recoverUseCase: RecoverMangaUseCase,
private val imageGetter: Html.ImageGetter, private val imageGetter: Html.ImageGetter,
private val newChaptersUseCaseProvider: Provider<CheckNewChaptersUseCase>,
private val networkState: NetworkState, private val networkState: NetworkState,
) { ) {
operator fun invoke(intent: MangaIntent, force: Boolean): Flow<MangaDetails> = channelFlow { operator fun invoke(intent: MangaIntent, force: Boolean): Flow<MangaDetails> = flow {
val manga = requireNotNull(mangaDataRepository.resolveIntent(intent, withChapters = true)) { val manga = requireNotNull(mangaDataRepository.resolveIntent(intent, withChapters = true)) {
"Cannot resolve intent $intent" "Cannot resolve intent $intent"
} }
val override = mangaDataRepository.getOverride(manga.id) val override = mangaDataRepository.getOverride(manga.id)
send( emit(
MangaDetails( MangaDetails(
manga = manga, manga = manga,
localManga = null, localManga = null,
override = override, override = override,
description = null, description = manga.description?.parseAsHtml(withImages = false),
isLoaded = false, isLoaded = false,
), ),
) )
if (manga.isLocal) { if (manga.isLocal) {
val details = getDetails(manga, force) loadLocal(manga, override, force)
send( } 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<MangaDetails>.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( MangaDetails(
manga = details, manga = localDetails,
localManga = null, localManga = null,
override = override, override = override,
description = details.description?.parseAsHtml(withImages = false)?.trim(), description = localDetails.description?.parseAsHtml(withImages = true),
isLoaded = true, isLoaded = true,
), ),
) )
return@channelFlow } else {
} val remoteDetails = getDetails(remoteManga, force).getOrNull()
val local = async { emit(
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
}
}
try {
val details = getDetails(manga, force)
launch { mangaDataRepository.updateChapters(details) }
launch { updateTracker(details) }
send(
MangaDetails( MangaDetails(
manga = details, manga = remoteDetails ?: remoteManga,
localManga = local.peek(), localManga = LocalManga(localDetails),
override = override, override = override,
description = details.description?.parseAsHtml(withImages = false)?.trim(), description = (remoteDetails ?: localDetails).description?.parseAsHtml(withImages = true),
isLoaded = false, isLoaded = true,
), ),
) )
send( if (remoteDetails != null) {
mangaDataRepository.updateChapters(remoteDetails)
}
}
}
/**
* Load remote manga + saved one if available
* Throw network errors after loading local manga only
*/
private suspend fun FlowCollector<MangaDetails>.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( MangaDetails(
manga = details, manga = manga,
localManga = local.await(), localManga = localManga,
override = override, override = override,
description = details.description?.parseAsHtml(withImages = true)?.trim(), description = localManga.manga.description?.parseAsHtml(withImages = true),
isLoaded = true, isLoaded = false,
), ),
) )
} 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 { private suspend fun getDetails(seed: Manga, force: Boolean) = runCatchingCancellable {
@ -140,20 +167,18 @@ class DetailsLoadUseCase @Inject constructor(
} else { } else {
null 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 { private fun Spanned.filterSpans(): Spanned {
val spannable = SpannableString.valueOf(this) val spannable = SpannableString.valueOf(this)
val spans = spannable.getSpans<ForegroundColorSpan>() val spans = spannable.getSpans<ForegroundColorSpan>()
@ -162,10 +187,4 @@ class DetailsLoadUseCase @Inject constructor(
} }
return spannable return spannable
} }
private suspend fun updateTracker(details: Manga) = runCatchingCancellable {
newChaptersUseCaseProvider.get()(details)
}.onFailure { e ->
e.printStackTraceDebug()
}
} }

@ -12,7 +12,6 @@ import android.widget.Button
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import com.google.android.material.slider.Slider 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 binding = LayoutReaderActionsBinding.inflate(LayoutInflater.from(context), this)
private val rotationObserver = object : ContentObserver(handler) { private val rotationObserver = object : ContentObserver(handler) {
override fun onChange(selfChange: Boolean) { override fun onChange(selfChange: Boolean) {
updateRotationButton() post {
updateRotationButton()
}
} }
} }
private var isSliderChanged = false private var isSliderChanged = false

@ -29,10 +29,15 @@ import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow 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.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.DialogErrorObserver 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.AppRouter
import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@ -133,7 +138,7 @@ class ReaderActivity :
} }
} }
viewModel.onError.observeEvent( viewModel.onLoadingError.observeEvent(
this, this,
DialogErrorObserver( DialogErrorObserver(
host = viewBinding.container, 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.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader)
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container)) viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container))
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged) viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
viewModel.isLoading.observe(this, this::onLoadingStateChanged) combine(
viewModel.content.observe(this) { viewModel.isLoading,
onLoadingStateChanged(viewModel.isLoading.value) viewModel.content.map { it.pages.isNotEmpty() }.distinctUntilChanged(),
} ::Pair,
).flowOn(Dispatchers.Default)
.observe(this, this::onLoadingStateChanged)
viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn) viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn)
viewModel.isInfoBarTransparent.observe(this) { viewBinding.infoBar.drawBackground = !it } viewModel.isInfoBarTransparent.observe(this) { viewBinding.infoBar.drawBackground = !it }
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged) viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
@ -243,9 +259,14 @@ class ReaderActivity :
viewBinding.timerControl.onReaderModeChanged(mode) viewBinding.timerControl.onReaderModeChanged(mode)
} }
private fun onLoadingStateChanged(isLoading: Boolean) { private fun onLoadingStateChanged(value: Pair<Boolean, Boolean>) {
val hasPages = viewModel.content.value.pages.isNotEmpty() val (isLoading, hasPages) = value
viewBinding.layoutLoading.isVisible = isLoading && !hasPages 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) { if (isLoading && hasPages) {
viewBinding.toastView.show(R.string.loading_) viewBinding.toastView.show(R.string.loading_)
} else { } else {

@ -1,12 +1,14 @@
package org.koitharu.kotatsu.reader.ui package org.koitharu.kotatsu.reader.ui
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin 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.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty 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.parsers.util.sizeOrZero
import org.koitharu.kotatsu.reader.domain.ChaptersLoader import org.koitharu.kotatsu.reader.domain.ChaptersLoader
import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase
@ -108,13 +111,12 @@ class ReaderViewModel @Inject constructor(
private var stateChangeJob: Job? = null private var stateChangeJob: Job? = null
init { init {
selectedBranch.value = savedStateHandle.get<String>(ReaderIntent.EXTRA_BRANCH)
readingState.value = savedStateHandle[ReaderIntent.EXTRA_STATE]
mangaDetails.value = intent.manga?.let { MangaDetails(it) } mangaDetails.value = intent.manga?.let { MangaDetails(it) }
} }
val readerMode = MutableStateFlow<ReaderMode?>(null) val readerMode = MutableStateFlow<ReaderMode?>(null)
val onPageSaved = MutableEventFlow<Collection<Uri>>() val onPageSaved = MutableEventFlow<Collection<Uri>>()
val onLoadingError = MutableEventFlow<Throwable>()
val onShowToast = MutableEventFlow<Int>() val onShowToast = MutableEventFlow<Int>()
val onAskNsfwIncognito = MutableEventFlow<Unit>() val onAskNsfwIncognito = MutableEventFlow<Unit>()
val uiState = MutableStateFlow<ReaderUiState?>(null) val uiState = MutableStateFlow<ReaderUiState?>(null)
@ -393,31 +395,56 @@ class ReaderViewModel @Inject constructor(
} }
private fun loadImpl() { private fun loadImpl() {
loadingJob = launchLoadingJob(Dispatchers.Default) { loadingJob = launchLoadingJob(Dispatchers.Default + EventExceptionHandler(onLoadingError)) {
val details = detailsLoadUseCase.invoke(intent, force = false).first { x -> x.isLoaded } val exception = try {
mangaDetails.value = details detailsLoadUseCase(intent, force = false)
chaptersLoader.init(details) .collect { details ->
val manga = details.toManga() if (mangaDetails.value == null) {
// obtain state mangaDetails.value = details
if (readingState.value == null) { }
readingState.value = getStateFromIntent(manga) 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)
// 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
} }
val mode = detectReaderModeUseCase.invoke(manga, readingState.value) if (readingState.value == null) {
val branch = chaptersLoader.peekChapter(readingState.value?.chapterId ?: 0L)?.branch onLoadingError.call(
selectedBranch.value = branch exception ?: IllegalStateException("Unable to load manga. This should never happen. Please report"),
mangaDetails.value = details.filterChapters(branch) )
readerMode.value = mode } else if (exception != null) {
// manga has been loaded but error occurred
chaptersLoader.loadSingleChapter(requireNotNull(readingState.value).chapterId) errorEvent.call(exception)
// save state
if (!isIncognitoMode.firstNotNull()) {
readingState.value?.let {
val percent = computePercent(it.chapterId, it.page)
historyUpdateUseCase.invoke(manga, it, percent)
}
} }
notifyStateChanged()
content.value = ReaderContent(chaptersLoader.snapshot(), readingState.value)
} }
} }
@ -513,18 +540,40 @@ class ReaderViewModel @Inject constructor(
} }
} }
private suspend fun getStateFromIntent(manga: Manga): ReaderState { private suspend fun getStateFromIntent(manga: Manga): ReaderState? {
val history = historyRepository.getOne(manga) // check if we have at least some chapters loaded
val preselectedBranch = selectedBranch.value if (manga.chapters.isNullOrEmpty()) {
val result = if (history != null) { return null
if (preselectedBranch != null && preselectedBranch != manga.findChapterById(history.chapterId)?.branch) { }
// specific state is requested
val requestedState: ReaderState? = savedStateHandle[ReaderIntent.EXTRA_STATE]
if (requestedState != null) {
return if (manga.findChapterById(requestedState.chapterId) != null) {
requestedState
} else {
null 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 { } else {
ReaderState(history) 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)
} }
} }

@ -33,7 +33,7 @@ class CheckNewChaptersUseCase @Inject constructor(
suspend operator fun invoke(manga: Manga): MangaUpdates = mutex.withLock(manga.id) { suspend operator fun invoke(manga: Manga): MangaUpdates = mutex.withLock(manga.id) {
repository.updateTracks() repository.updateTracks()
val tracking = repository.getTrackOrNull(manga) ?: return MangaUpdates.Failure( val tracking = repository.getTrackOrNull(manga) ?: return@withLock MangaUpdates.Failure(
manga = manga, manga = manga,
error = null, error = null,
) )

Loading…
Cancel
Save