Refactor manga loading

This commit is contained in:
Koitharu
2023-05-24 11:52:09 +03:00
parent bfa9feaef0
commit dc358ae6a2
17 changed files with 397 additions and 277 deletions

View File

@@ -0,0 +1,94 @@
package org.koitharu.kotatsu.details.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.model.DoubleManga
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.IOException
import javax.inject.Inject
class DetailsInteractor @Inject constructor(
private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository,
private val localMangaRepository: LocalMangaRepository,
private val trackingRepository: TrackingRepository,
private val settings: AppSettings,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) {
fun observeIsFavourite(mangaId: Long): Flow<Boolean> {
return favouritesRepository.observeCategoriesIds(mangaId)
.map { it.isNotEmpty() }
}
fun observeNewChapters(mangaId: Long): Flow<Int> {
return settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled }
.flatMapLatest { isEnabled ->
if (isEnabled) {
trackingRepository.observeNewChaptersCount(mangaId)
} else {
flowOf(0)
}
}
}
fun observeScrobblingInfo(mangaId: Long): Flow<List<ScrobblingInfo>> {
return combine(
scrobblers.map { it.observeScrobblingInfo(mangaId) },
) { scrobblingInfo ->
scrobblingInfo.filterNotNull()
}
}
suspend fun deleteLocalManga(manga: Manga) {
val victim = if (manga.isLocal) manga else localMangaRepository.findSavedManga(manga)?.manga
checkNotNull(victim) { "Cannot find saved manga for ${manga.title}" }
val original = if (manga.isLocal) localMangaRepository.getRemoteManga(manga) else manga
localMangaRepository.delete(victim) || throw IOException("Unable to delete file")
runCatchingCancellable {
historyRepository.deleteOrSwap(victim, original)
}.onFailure {
it.printStackTraceDebug()
}
}
fun observeIncognitoMode(mangaFlow: Flow<Manga?>): Flow<Boolean> {
return mangaFlow
.distinctUntilChangedBy { it?.isNsfw }
.flatMapLatest { manga ->
if (manga != null) {
historyRepository.observeShouldSkip(manga)
} else {
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled }
}
}
}
suspend fun updateLocal(subject: DoubleManga?, localManga: LocalManga): DoubleManga? {
return if (subject?.any?.id == localManga.manga.id) {
subject.copy(
localManga = runCatchingCancellable {
localMangaRepository.getDetails(localManga.manga)
},
)
} else {
subject
}
}
}

View File

@@ -160,12 +160,14 @@ class ChaptersFragment :
val selectedIds = selectionController?.peekCheckedIds() ?: return false
val allItems = chaptersAdapter?.items.orEmpty()
val items = allItems.withIndex().filter { (_, x) -> x.chapter.id in selectedIds }
menu.findItem(R.id.action_save).isVisible = items.none { (_, x) ->
x.chapter.source == MangaSource.LOCAL
}
menu.findItem(R.id.action_delete).isVisible = items.all { (_, x) ->
x.chapter.source == MangaSource.LOCAL
var canSave = true
var canDelete = true
items.forEach { (_, x) ->
val isLocal = x.isDownloaded || x.chapter.source == MangaSource.LOCAL
if (isLocal) canSave = false else canDelete = false
}
menu.findItem(R.id.action_save).isVisible = canSave
menu.findItem(R.id.action_delete).isVisible = canDelete
menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size
menu.findItem(R.id.action_mark_current).isVisible = items.size == 1
mode.title = items.size.toString()

View File

@@ -52,7 +52,7 @@ fun mapChapters(
isCurrent = chapter.id == currentId,
isUnread = isUnread,
isNew = false,
isDownloaded = false,
isDownloaded = remoteManga != null,
)
}
}

View File

@@ -8,6 +8,7 @@ import androidx.core.net.toUri
import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml
import androidx.lifecycle.LiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
@@ -25,82 +26,74 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.update
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.model.DoubleManga
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.SingleLiveEvent
import org.koitharu.kotatsu.core.util.asFlowLiveData
import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.domain.DetailsInteractor
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.local.domain.DoubleMangaLoader
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.IOException
import javax.inject.Inject
@HiltViewModel
class DetailsViewModel @Inject constructor(
private val historyRepository: HistoryRepository,
favouritesRepository: FavouritesRepository,
private val localMangaRepository: LocalMangaRepository,
trackingRepository: TrackingRepository,
private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val imageGetter: Html.ImageGetter,
private val delegate: MangaDetailsDelegate,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
private val downloadScheduler: DownloadWorker.Scheduler,
private val interactor: DetailsInteractor,
savedStateHandle: SavedStateHandle,
private val mangaLoader: DoubleMangaLoader,
) : BaseViewModel() {
private val intent = MangaIntent(savedStateHandle)
private val mangaId = intent.mangaId
private val doubleManga: MutableStateFlow<DoubleManga?> = MutableStateFlow(intent.manga?.let { DoubleManga(it) })
private var loadingJob: Job
val onShowToast = SingleLiveEvent<Int>()
val onDownloadStarted = SingleLiveEvent<Unit>()
private val mangaData = combine(
delegate.onlineManga,
delegate.localManga,
) { o, l ->
o ?: l
}.stateIn(viewModelScope, SharingStarted.Lazily, null)
private val mangaData = doubleManga.map { it?.any }
.stateIn(viewModelScope, SharingStarted.Eagerly, doubleManga.value?.any)
private val history = historyRepository.observeOne(delegate.mangaId)
private val history = historyRepository.observeOne(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() }
private val favourite = interactor.observeIsFavourite(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
private val newChapters = settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled }
.flatMapLatest { isEnabled ->
if (isEnabled) {
trackingRepository.observeNewChaptersCount(delegate.mangaId)
} else {
flowOf(0)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val newChapters = interactor.observeNewChapters(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val chaptersQuery = MutableStateFlow("")
private val selectedBranch = MutableStateFlow<String?>(null)
private val chaptersReversed = settings.observeAsFlow(AppSettings.KEY_REVERSE_CHAPTERS) { chaptersReverse }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
@@ -112,9 +105,9 @@ class DetailsViewModel @Inject constructor(
val historyInfo: LiveData<HistoryInfo> = combine(
mangaData,
delegate.selectedBranch,
selectedBranch,
history,
historyRepository.observeShouldSkip(mangaData),
interactor.observeIncognitoMode(mangaData),
) { m, b, h, im ->
HistoryInfo(m, b, h, im)
}.asFlowLiveData(
@@ -126,10 +119,11 @@ class DetailsViewModel @Inject constructor(
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val localSize = delegate.localManga
val localSize = doubleManga
.map {
if (it != null) {
val file = it.url.toUri().toFileOrNull()
val local = it?.local
if (local != null) {
val file = local.url.toUri().toFileOrNull()
file?.computeSize() ?: 0L
} else {
0L
@@ -152,46 +146,38 @@ class DetailsViewModel @Inject constructor(
val isScrobblingAvailable: Boolean
get() = scrobblers.any { it.isAvailable }
val scrobblingInfo: LiveData<List<ScrobblingInfo>> = combine(
scrobblers.map { it.observeScrobblingInfo(delegate.mangaId) },
) { scrobblingInfo ->
scrobblingInfo.filterNotNull()
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val scrobblingInfo: LiveData<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val branches: LiveData<List<MangaBranch>> = combine(
delegate.onlineManga,
delegate.localManga,
delegate.selectedBranch,
) { m, l, b ->
val chapters = concat(m?.chapters, l?.chapters)
if (chapters.isEmpty()) return@combine emptyList()
doubleManga,
selectedBranch,
) { m, b ->
val chapters = m?.chapters
if (chapters.isNullOrEmpty()) return@combine emptyList()
chapters.groupBy { x -> x.branch }
.map { x -> MangaBranch(x.key, x.value.size, x.key == b) }
.sortedWith(BranchComparator())
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val selectedBranchName = delegate.selectedBranch
val selectedBranchName = selectedBranch
.asFlowLiveData(viewModelScope.coroutineContext, null)
val isChaptersEmpty: LiveData<Boolean> = combine(
delegate.onlineManga,
delegate.localManga,
doubleManga,
isLoading.asFlow(),
) { manga, local, loading ->
(manga != null && manga.chapters.isNullOrEmpty()) &&
(local != null && local.chapters.isNullOrEmpty()) &&
!loading
) { manga, loading ->
manga?.any != null && manga.chapters.isNullOrEmpty() && !loading
}.asFlowLiveData(viewModelScope.coroutineContext, false)
val chapters = combine(
combine(
delegate.onlineManga,
delegate.localManga,
doubleManga,
history,
delegate.selectedBranch,
selectedBranch,
newChapters,
) { manga, local, history, branch, news ->
mapChapters(manga, local, history, news, branch)
) { manga, history, branch, news ->
mapChapters(manga?.remote, manga?.local, history, news, branch)
},
chaptersReversed,
chaptersQuery,
@@ -200,7 +186,7 @@ class DetailsViewModel @Inject constructor(
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val selectedBranchValue: String?
get() = delegate.selectedBranch.value
get() = selectedBranch.value
init {
loadingJob = doLoad()
@@ -216,20 +202,14 @@ class DetailsViewModel @Inject constructor(
}
fun deleteLocal() {
val m = delegate.localManga.value
val m = doubleManga.value?.local
if (m == null) {
onShowToast.call(R.string.file_not_found)
return
}
launchLoadingJob(Dispatchers.Default) {
val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)?.manga
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
val original = localMangaRepository.getRemoteManga(manga)
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
runCatchingCancellable {
historyRepository.deleteOrSwap(manga, original)
}
onMangaRemoved.emitCall(manga)
interactor.deleteLocalManga(m)
onMangaRemoved.emitCall(m)
}
}
@@ -245,11 +225,7 @@ class DetailsViewModel @Inject constructor(
}
fun setSelectedBranch(branch: String?) {
delegate.selectedBranch.value = branch
}
fun getRemoteManga(): Manga? {
return delegate.onlineManga.value
selectedBranch.value = branch
}
fun performChapterSearch(query: String?) {
@@ -260,7 +236,7 @@ class DetailsViewModel @Inject constructor(
val scrobbler = getScrobbler(index) ?: return
launchJob(Dispatchers.Default) {
scrobbler.updateScrobblingInfo(
mangaId = delegate.mangaId,
mangaId = mangaId,
rating = rating,
status = status,
comment = null,
@@ -272,26 +248,32 @@ class DetailsViewModel @Inject constructor(
val scrobbler = getScrobbler(index) ?: return
launchJob(Dispatchers.Default) {
scrobbler.unregisterScrobbling(
mangaId = delegate.mangaId,
mangaId = mangaId,
)
}
}
fun markChapterAsCurrent(chapterId: Long) {
launchJob(Dispatchers.Default) {
val manga = checkNotNull(mangaData.value)
val chapters = checkNotNull(manga.getChapters(selectedBranchValue))
val manga = checkNotNull(doubleManga.value)
val chapters = checkNotNull(manga.filterChapters(selectedBranchValue).chapters)
val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
check(chapterIndex in chapters.indices) { "Chapter not found" }
val percent = chapterIndex / chapters.size.toFloat()
historyRepository.addOrUpdate(manga = manga, chapterId = chapterId, page = 0, scroll = 0, percent = percent)
historyRepository.addOrUpdate(
manga = manga.requireAny(),
chapterId = chapterId,
page = 0,
scroll = 0,
percent = percent,
)
}
}
fun download(chaptersIds: Set<Long>?) {
launchJob(Dispatchers.Default) {
downloadScheduler.schedule(
delegate.onlineManga.value ?: checkNotNull(manga.value),
doubleManga.requireValue().requireAny(),
chaptersIds,
)
onDownloadStarted.emitCall(Unit)
@@ -299,7 +281,12 @@ class DetailsViewModel @Inject constructor(
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
delegate.doLoad()
val result = mangaLoader.load(intent)
val manga = result.requireAny()
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist)
doubleManga.value = result
}
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {
@@ -313,21 +300,9 @@ class DetailsViewModel @Inject constructor(
private suspend fun onDownloadComplete(downloadedManga: LocalManga?) {
downloadedManga ?: return
val currentManga = mangaData.value ?: return
if (currentManga.id != downloadedManga.manga.id) {
return
}
if (currentManga.source == MangaSource.LOCAL) {
reload()
} else {
viewModelScope.launch(Dispatchers.Default) {
runCatchingCancellable {
localMangaRepository.getDetails(downloadedManga.manga)
}.onSuccess {
delegate.publishManga(it)
}.onFailure {
it.printStackTraceDebug()
}
launchJob {
doubleManga.update {
interactor.updateLocal(it, downloadedManga)
}
}
}
@@ -353,18 +328,4 @@ class DetailsViewModel @Inject constructor(
}
return scrobbler
}
private fun <T> concat(a: List<T>?, b: List<T>?): List<T> {
return when {
a == null && b == null -> emptyList<T>()
a == null && b != null -> b
a != null && b == null -> a
a != null && b != null -> buildList<T>(a.size + b.size) {
addAll(a)
addAll(b)
}
else -> error("This shouldn't have happened")
}
}
}

View File

@@ -1,89 +0,0 @@
package org.koitharu.kotatsu.details.ui
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject
@ViewModelScoped
class MangaDetailsDelegate @Inject constructor(
savedStateHandle: SavedStateHandle,
lifecycle: ViewModelLifecycle,
private val mangaDataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val localMangaRepository: LocalMangaRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
networkState: NetworkState,
) {
private val viewModelScope = RetainedLifecycleCoroutineScope(lifecycle)
private val intent = MangaIntent(savedStateHandle)
private val onlineMangaStateFlow = MutableStateFlow<Manga?>(null)
private val localMangaStateFlow = MutableStateFlow<Manga?>(null)
val onlineManga = combine(
onlineMangaStateFlow,
networkState,
) { m, s -> m.takeIf { s } }
.stateIn(viewModelScope, SharingStarted.Lazily, null)
val localManga = localMangaStateFlow.asStateFlow()
val selectedBranch = MutableStateFlow<String?>(null)
val mangaId = intent.manga?.id ?: intent.mangaId
init {
intent.manga?.let {
publishManga(it)
}
}
suspend fun doLoad() {
var manga = mangaDataRepository.resolveIntent(intent) ?: throw NotFoundException("Cannot find manga", "")
publishManga(manga)
manga = mangaRepositoryFactory.create(manga.source).getDetails(manga)
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist)
publishManga(manga)
runCatchingCancellable {
if (manga.source == MangaSource.LOCAL) {
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatchingCancellable null
mangaRepositoryFactory.create(m.source).getDetails(m)
} else {
localMangaRepository.findSavedManga(manga)?.manga
}
}.onFailure { error ->
error.printStackTraceDebug()
}.onSuccess {
if (it != null) {
publishManga(it)
}
}
}
fun publishManga(manga: Manga) {
if (manga.source == MangaSource.LOCAL) {
localMangaStateFlow
} else {
onlineMangaStateFlow
}.value = manga
}
}