Merge remote-tracking branch 'origin/devel' into devel

This commit is contained in:
Mac135135
2024-11-10 14:19:06 +03:00
35 changed files with 253 additions and 106 deletions

View File

@@ -2,16 +2,18 @@ package org.koitharu.kotatsu.core.backup
import android.content.Context
import android.net.Uri
import androidx.annotation.CheckResult
import androidx.documentfile.provider.DocumentFile
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.IOException
import okio.buffer
import okio.sink
import okio.source
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File
import javax.inject.Inject
@@ -21,7 +23,7 @@ class ExternalBackupStorage @Inject constructor(
) {
suspend fun list(): List<BackupFile> = runInterruptible(Dispatchers.IO) {
getRoot().listFiles().mapNotNull {
getRootOrThrow().listFiles().mapNotNull {
if (it.isFile && it.canRead()) {
BackupFile(
uri = it.uri,
@@ -35,8 +37,14 @@ class ExternalBackupStorage @Inject constructor(
}.sortedDescending()
}
suspend fun listOrNull() = runCatchingCancellable {
list()
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrNull()
suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) {
val out = checkNotNull(getRoot().createFile("application/zip", file.nameWithoutExtension)) {
val out = checkNotNull(getRootOrThrow().createFile("application/zip", file.nameWithoutExtension)) {
"Cannot create target backup file"
}
checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink ->
@@ -47,25 +55,30 @@ class ExternalBackupStorage @Inject constructor(
out.uri
}
@CheckResult
suspend fun delete(victim: BackupFile) = runInterruptible(Dispatchers.IO) {
val df = checkNotNull(DocumentFile.fromSingleUri(context, victim.uri)) {
"${victim.uri} cannot be resolved to the DocumentFile"
}
if (!df.delete()) {
throw IOException("Cannot delete ${df.uri}")
}
val df = DocumentFile.fromSingleUri(context, victim.uri)
df != null && df.delete()
}
suspend fun getLastBackupDate() = list().maxByOrNull { it.dateTime }?.dateTime
suspend fun getLastBackupDate() = listOrNull()?.maxOfOrNull { it.dateTime }
suspend fun trim(maxCount: Int) {
list().drop(maxCount).forEach {
delete(it)
suspend fun trim(maxCount: Int): Boolean {
val list = listOrNull()
if (list == null || list.size <= maxCount) {
return false
}
var result = false
for (i in maxCount until list.size) {
if (delete(list[i])) {
result = true
}
}
return result
}
@Blocking
private fun getRoot(): DocumentFile {
private fun getRootOrThrow(): DocumentFile {
val uri = checkNotNull(settings.periodicalBackupDirectory) {
"Backup directory is not specified"
}

View File

@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.formatSimple
import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.parsers.util.mapToSet
import com.google.android.material.R as materialR
@@ -29,8 +29,6 @@ fun Collection<Manga>.distinctById() = distinctBy { it.id }
@JvmName("chaptersIds")
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) {
return size
@@ -84,10 +82,6 @@ val Demographic.titleResId: Int
Demographic.NONE -> R.string.none
}
fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.findById(id)
}
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
val ch = chapters
if (ch.isNullOrEmpty()) {
@@ -136,12 +130,6 @@ val Manga.appUrl: Uri
.appendQueryParameter("url", url)
.build()
fun MangaChapter.formatNumber(): String? = if (number > 0f) {
number.formatSimple()
} else {
null
}
fun Manga.chaptersCount(): Int {
if (chapters.isNullOrEmpty()) {
return 0

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.parser
import kotlinx.coroutines.Dispatchers
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.core.cache.MemoryContentCache
@@ -17,9 +18,9 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
class ParserMangaRepository(
private val parser: MangaParser,
@@ -27,7 +28,7 @@ class ParserMangaRepository(
cache: MemoryContentCache,
) : CachingMangaRepository(cache), Interceptor {
private val filterOptionsLazy = SuspendLazy {
private val filterOptionsLazy = suspendLazy(Dispatchers.Default) {
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getFilterOptions()
}

View File

@@ -13,7 +13,7 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import java.util.EnumSet
class ExternalMangaRepository(
@@ -32,7 +32,7 @@ class ExternalMangaRepository(
}.getOrNull()
}
private val filterOptions = SuspendLazy(contentSource::getListFilterOptions)
private val filterOptions = suspendLazy(initializer = contentSource::getListFilterOptions)
override val sortOrders: Set<SortOrder>
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.POPULARITY)

View File

@@ -17,7 +17,8 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.transformWhile
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.SuspendLazy
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
@@ -133,4 +134,4 @@ suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x !
fun <T> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it }
fun <T> SuspendLazy<T>.asFlow() = flow { emit(tryGet()) }
fun <T> SuspendLazy<T>.asFlow() = flow { emit(runCatchingCancellable { get() }) }

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.details.domain
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.model.findChapter
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -33,8 +32,8 @@ class ProgressUpdateUseCase @Inject constructor(
} else {
seed
}
val chapter = details.findChapter(history.chapterId) ?: return PROGRESS_NONE
val chapters = details.getChapters(chapter.branch) ?: return PROGRESS_NONE
val chapter = details.findChapterById(history.chapterId) ?: return PROGRESS_NONE
val chapters = details.getChapters(chapter.branch)
val chaptersCount = chapters.size
if (chaptersCount == 0) {
return PROGRESS_NONE

View File

@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.details.domain
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.data.ReadingTime
import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.stats.data.StatsRepository
import java.util.concurrent.TimeUnit
import javax.inject.Inject

View File

@@ -6,7 +6,6 @@ import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
@@ -19,6 +18,7 @@ import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject

View File

@@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -47,6 +46,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.details.ui.adapter
import android.graphics.Typeface
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.model.formatNumber
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
@@ -22,7 +21,7 @@ fun chapterGridItemAD(
bind { payloads ->
if (payloads.isEmpty()) {
binding.textViewTitle.text = item.chapter.formatNumber() ?: "?"
binding.textViewTitle.text = item.chapter.numberString() ?: "?"
}
binding.imageViewNew.isVisible = item.isNew
binding.imageViewCurrent.isVisible = item.isCurrent

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.details.ui.adapter
import android.content.Context
import org.koitharu.kotatsu.core.model.formatNumber
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
@@ -33,7 +32,7 @@ class ChaptersAdapter(
findHeader(position)?.getText(context)
} else {
val chapter = (items.getOrNull(position) as? ChapterListItem)?.chapter ?: return null
if (chapter.number > 0) chapter.formatNumber() else null
chapter.numberString()
}
}
}

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.details.ui.model
import android.text.format.DateUtils
import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.core.model.formatNumber
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaChapter
import kotlin.experimental.and
@@ -53,7 +52,7 @@ data class ChapterListItem(
private fun buildDescription(): String {
val joiner = StringJoiner("")
chapter.formatNumber()?.let {
chapter.numberString()?.let {
joiner.add("#").append(it)
}
uploadDate?.let { date ->

View File

@@ -2,8 +2,13 @@ package org.koitharu.kotatsu.details.ui.pager.pages
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.collection.ArraySet
import androidx.core.graphics.Insets
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
@@ -20,10 +25,12 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
@@ -34,16 +41,18 @@ import org.koitharu.kotatsu.list.ui.GridSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import javax.inject.Inject
import kotlin.math.roundToInt
@AndroidEntryPoint
class PagesFragment :
BaseFragment<FragmentPagesBinding>(),
OnListItemClickListener<PageThumbnail> {
OnListItemClickListener<PageThumbnail>, ListSelectionController.Callback {
@Inject
lateinit var coil: ImageLoader
@@ -51,17 +60,23 @@ class PagesFragment :
@Inject
lateinit var settings: AppSettings
@Inject
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
private val parentViewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
private val viewModel by viewModels<PagesViewModel>()
private lateinit var pageSaveHelper: PageSaveHelper
private var thumbnailsAdapter: PageThumbnailAdapter? = null
private var spanResolver: GridSpanResolver? = null
private var scrollListener: ScrollListener? = null
private var selectionController: ListSelectionController? = null
private val spanSizeLookup = SpanSizeLookup()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pageSaveHelper = pageSaveHelperFactory.create(this)
combine(
parentViewModel.mangaDetails,
parentViewModel.readingState,
@@ -83,6 +98,12 @@ class PagesFragment :
override fun onViewBindingCreated(binding: FragmentPagesBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
spanResolver = GridSpanResolver(binding.root.resources)
selectionController = ListSelectionController(
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
decoration = PagesSelectionDecoration(binding.root.context),
registryOwner = this,
callback = this,
)
thumbnailsAdapter = PageThumbnailAdapter(
coil = coil,
lifecycleOwner = viewLifecycleOwner,
@@ -91,6 +112,7 @@ class PagesFragment :
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) // before rv initialization
with(binding.recyclerView) {
addItemDecoration(TypedListSpacingDecoration(context, false))
checkNotNull(selectionController).attachToRecyclerView(this)
adapter = thumbnailsAdapter
setHasFixedSize(true)
PagerNestedScrollHelper(this).bind(viewLifecycleOwner)
@@ -103,6 +125,7 @@ class PagesFragment :
}
parentViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged)
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(binding.recyclerView))
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) }
viewModel.isLoadingUp.observe(viewLifecycleOwner) { binding.progressBarTop.showOrHide(it) }
@@ -113,6 +136,7 @@ class PagesFragment :
spanResolver = null
scrollListener = null
thumbnailsAdapter = null
selectionController = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
}
@@ -120,6 +144,9 @@ class PagesFragment :
override fun onWindowInsetsChanged(insets: Insets) = Unit
override fun onItemClick(item: PageThumbnail, view: View) {
if (selectionController?.onItemClick(item.page.id) == true) {
return
}
val listener = findParentCallback(ReaderNavigationCallback::class.java)
if (listener != null && listener.onPageSelected(item.page)) {
dismissParentDialog()
@@ -133,6 +160,39 @@ class PagesFragment :
}
}
override fun onItemLongClick(item: PageThumbnail, view: View): Boolean {
return selectionController?.onItemLongClick(view, item.page.id) ?: false
}
override fun onItemContextClick(item: PageThumbnail, view: View): Boolean {
return selectionController?.onItemContextClick(view, item.page.id) ?: false
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
viewBinding?.recyclerView?.invalidateItemDecorations()
}
override fun onCreateActionMode(
controller: ListSelectionController,
menuInflater: MenuInflater,
menu: Menu,
): Boolean {
menuInflater.inflate(R.menu.mode_pages, menu)
return true
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_save -> {
viewModel.savePages(pageSaveHelper, collectSelectedPages())
mode?.finish()
true
}
else -> false
}
}
private suspend fun onThumbnailsChanged(list: List<ListModel>) {
val adapter = thumbnailsAdapter ?: return
if (adapter.itemCount == 0) {
@@ -172,6 +232,18 @@ class PagesFragment :
}
}
private fun collectSelectedPages(): Set<ReaderPage> {
val checkedIds = selectionController?.peekCheckedIds() ?: return emptySet()
val items = thumbnailsAdapter?.items ?: return emptySet()
val result = ArraySet<ReaderPage>(checkedIds.size)
for (item in items) {
if (item is PageThumbnail && item.page.id in checkedIds) {
result.add(item.page)
}
}
return result
}
private inner class ScrollListener : BoundsScrollListener(3, 3) {
override fun onScrolledToStart(recyclerView: RecyclerView) {

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import android.net.Uri
import android.view.View
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ShareHelper
class PagesSavedObserver(
private val snackbarHost: View,
) : FlowCollector<Collection<Uri>> {
override suspend fun emit(value: Collection<Uri>) {
val msg = when (value.size) {
0 -> R.string.nothing_found
1 -> R.string.page_saved
else -> R.string.pages_saved
}
val snackbar = Snackbar.make(snackbarHost, msg, Snackbar.LENGTH_LONG)
value.singleOrNull()?.let { uri ->
snackbar.setAction(R.string.share) {
ShareHelper(snackbarHost.context).shareImage(uri)
}
}
snackbar.show()
}
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import android.content.Context
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
class PagesSelectionDecoration(context: Context) : MangaSelectionDecoration(context) {
override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return RecyclerView.NO_ID
val item = holder.getItem(PageThumbnail::class.java) ?: return RecyclerView.NO_ID
return item.page.id
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import android.net.Uri
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
@@ -10,12 +11,17 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.firstNotNull
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import javax.inject.Inject
@HiltViewModel
@@ -32,6 +38,7 @@ class PagesViewModel @Inject constructor(
val thumbnails = MutableStateFlow<List<ListModel>>(emptyList())
val isLoadingUp = MutableStateFlow(false)
val isLoadingDown = MutableStateFlow(false)
val onPageSaved = MutableEventFlow<Collection<Uri>>()
val gridScale = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
@@ -73,6 +80,25 @@ class PagesViewModel @Inject constructor(
loadingNextJob = loadPrevNextChapter(isNext = true)
}
fun savePages(
pageSaveHelper: PageSaveHelper,
pages: Set<ReaderPage>,
) {
launchLoadingJob(Dispatchers.Default) {
val manga = state.requireValue().details.toManga()
val tasks = pages.map {
PageSaveHelper.Task(
manga = manga,
chapter = manga.requireChapterById(it.chapterId),
pageNumber = it.index + 1,
page = it.toMangaPage(),
)
}
val dest = pageSaveHelper.save(tasks)
onPageSaved.call(dest)
}
}
private suspend fun doInit(state: State) {
chaptersLoader.init(state.details)
val initialChapterId = state.readerState?.chapterId?.takeIf {

View File

@@ -29,9 +29,9 @@ import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import org.koitharu.kotatsu.settings.storage.DirectoryModel
import javax.inject.Inject
@@ -50,7 +50,7 @@ class DownloadDialogViewModel @Inject constructor(
val manga = savedStateHandle.require<Array<ParcelableManga>>(DownloadDialogFragment.ARG_MANGA).map {
it.manga
}
private val mangaDetails = SuspendLazy {
private val mangaDetails = suspendLazy {
coroutineScope {
manga.map { m ->
async { m.getDetails() }

View File

@@ -22,7 +22,6 @@ import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.formatNumber
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
@@ -308,7 +307,7 @@ class DownloadsViewModel @Inject constructor(
return chapters.mapNotNullTo(ArrayList(size)) {
if (chapterIds == null || it.id in chapterIds) {
DownloadChapter(
number = it.formatNumber(),
number = it.numberString(),
name = it.name,
isDownloaded = it.id in localChapters,
)

View File

@@ -35,8 +35,8 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.model.YEAR_MIN
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import java.util.Calendar
@@ -59,7 +59,7 @@ class FilterCoordinator @Inject constructor(
private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
private val availableSortOrders = repository.sortOrders
private val filterOptions = SuspendLazy { repository.getFilterOptions() }
private val filterOptions = suspendLazy { repository.getFilterOptions() }
val capabilities = repository.filterCapabilities
val mangaSource: MangaSource

View File

@@ -14,7 +14,6 @@ import org.koitharu.kotatsu.core.db.entity.toMangaList
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.db.entity.toMangaTagsList
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.model.toMangaSources
@@ -30,6 +29,7 @@ import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble

View File

@@ -6,14 +6,15 @@ import kotlinx.coroutines.flow.asStateFlow
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.list.ui.model.QuickFilter
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
abstract class MangaListQuickFilter(
private val settings: AppSettings,
) : QuickFilterListener {
private val appliedFilter = MutableStateFlow<Set<ListFilterOption>>(emptySet())
private val availableFilterOptions = SuspendLazy {
private val availableFilterOptions = suspendLazy {
getAvailableFilterOptions()
}
@@ -50,7 +51,7 @@ abstract class MangaListQuickFilter(
if (!settings.isQuickFilterEnabled) {
return null
}
val availableOptions = availableFilterOptions.tryGet().getOrNull()?.map { option ->
val availableOptions = availableFilterOptions.getOrNull()?.map { option ->
ChipsView.ChipModel(
title = option.titleText,
titleResId = option.titleResId,

View File

@@ -115,9 +115,9 @@ abstract class MangaListFragment :
with(binding.recyclerView) {
setHasFixedSize(true)
adapter = listAdapter
checkNotNull(selectionController).attachToRecyclerView(binding.recyclerView)
checkNotNull(selectionController).attachToRecyclerView(this)
addItemDecoration(TypedListSpacingDecoration(context, false))
addOnScrollListener(paginationListener!!)
addOnScrollListener(checkNotNull(paginationListener))
fastScroller.setFastScrollListener(this@MangaListFragment)
}
with(binding.swipeRefreshLayout) {

View File

@@ -22,8 +22,8 @@ import org.koitharu.kotatsu.core.util.ext.subdir
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.takeIfWriteable
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import java.io.File
import java.util.UUID
import javax.inject.Inject
@@ -32,13 +32,13 @@ import javax.inject.Singleton
@Singleton
class PagesCache @Inject constructor(@ApplicationContext context: Context) {
private val cacheDir = SuspendLazy {
private val cacheDir = suspendLazy {
val dirs = context.externalCacheDirs + context.cacheDir
dirs.firstNotNullOf {
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable()
}
}
private val lruCache = SuspendLazy {
private val lruCache = suspendLazy {
val dir = cacheDir.get()
val availableSize = (getAvailableSize() * 0.8).toLong()
val size = SIZE_DEFAULT.coerceAtMost(availableSize).coerceAtLeast(SIZE_MIN)

View File

@@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.fold
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -18,6 +17,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges
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.util.findById
import org.koitharu.kotatsu.parsers.util.recoverCatchingCancellable
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@@ -77,8 +77,8 @@ class DeleteReadChaptersUseCase @Inject constructor(
return null
}
val branch = (chapters.findById(history.chapterId) ?: return null).branch
val filteredChapters = manga.manga.getChapters(branch)?.takeWhile { it.id != history.chapterId }
return if (filteredChapters.isNullOrEmpty()) {
val filteredChapters = manga.manga.getChapters(branch).takeWhile { it.id != history.chapterId }
return if (filteredChapters.isEmpty()) {
null
} else {
DeletionTask(

View File

@@ -6,7 +6,6 @@ import coil3.request.ErrorResult
import coil3.request.ImageResult
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -15,6 +14,7 @@ import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.mangaKey
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Collections
import javax.inject.Inject

View File

@@ -7,7 +7,6 @@ import androidx.core.net.toFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.core.model.findChapter
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.parser.MangaDataRepository
@@ -40,7 +39,7 @@ class DetectReaderModeUseCase @Inject constructor(
if (!settings.isReaderModeDetectionEnabled || defaultMode == ReaderMode.WEBTOON) {
return defaultMode
}
val chapter = state?.let { manga.findChapter(it.chapterId) }
val chapter = state?.let { manga.findChapterById(it.chapterId) }
?: manga.chapters?.firstOrNull()
?: error("There are no chapters in this manga")
val repo = mangaRepositoryFactory.create(manga.source)

View File

@@ -67,17 +67,14 @@ class PageSaveHelper @AssistedInject constructor(
}
}
suspend fun save(tasks: Set<Task>): Uri? = when (tasks.size) {
0 -> null
1 -> saveImpl(tasks.first())
else -> {
saveImpl(tasks)
null
}
suspend fun save(tasks: Collection<Task>): Collection<Uri> = when (tasks.size) {
0 -> emptySet()
1 -> setOf(saveImpl(tasks.first()))
else -> saveImpl(tasks)
}
private suspend fun saveImpl(task: Task): Uri {
val pageLoader = pageLoaderProvider.get()
val pageLoader = getPageLoader()
val pageUrl = pageLoader.getPageUrl(task.page).toUri()
val pageUri = pageLoader.loadPage(task.page, force = false)
val proposedName = task.getFileBaseName() + "." + getPageExtension(pageUrl, pageUri)
@@ -89,13 +86,14 @@ class PageSaveHelper @AssistedInject constructor(
return destination
}
private suspend fun saveImpl(tasks: Collection<Task>) {
val pageLoader = pageLoaderProvider.get()
private suspend fun saveImpl(tasks: Collection<Task>): Collection<Uri> {
val pageLoader = getPageLoader()
val destinationDir = getDefaultFileUri(null) ?: run {
val defaultUri = settings.getPagesSaveDir(context)?.uri
DocumentFile.fromTreeUri(context, pickDirectoryRequest.launchAndAwait(defaultUri))
} ?: throw IOException("Cannot get destination directory")
val result = ArrayList<Uri>(tasks.size)
for (task in tasks) {
val pageUrl = pageLoader.getPageUrl(task.page).toUri()
val pageUri = pageLoader.loadPage(task.page, force = false)
@@ -106,7 +104,9 @@ class PageSaveHelper @AssistedInject constructor(
}
val destination = destinationDir.createFile(mime, proposedName.substringBeforeLast('.'))
copyImpl(pageUri, destination?.uri ?: throw IOException("Cannot create destination file"))
result.add(destination.uri)
}
return result
}
private suspend fun getPageExtension(url: Uri, fileUri: Uri): String {
@@ -143,6 +143,10 @@ class PageSaveHelper @AssistedInject constructor(
}
}
private suspend fun getPageLoader() = withContext(Dispatchers.Main.immediate) {
pageLoaderProvider.get()
}
private fun getDefaultFileUri(proposedName: String?): DocumentFile? {
if (settings.isPagesSavingAskEnabled) {
return null

View File

@@ -53,6 +53,7 @@ import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.ext.zipWithPrevious
import org.koitharu.kotatsu.databinding.ActivityReaderBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.details.ui.pager.pages.PagesSavedObserver
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.reader.data.TapGridSettings
@@ -143,7 +144,7 @@ class ReaderActivity :
),
)
viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader)
viewModel.onPageSaved.observeEvent(this, this::onPageSaved)
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container))
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.content.observe(this) {
@@ -289,17 +290,6 @@ class ReaderActivity :
readerManager.setDoubleReaderMode(isEnabled)
}
private fun onPageSaved(uri: Uri?) {
val snackbar = Snackbar.make(viewBinding.container, R.string.page_saved, Snackbar.LENGTH_LONG)
if (uri != null) {
snackbar.setAction(R.string.share) {
ShareHelper(this).shareImage(uri)
}
}
snackbar.setAnchorView(viewBinding.appbarBottom)
snackbar.show()
}
private fun setKeepScreenOn(isKeep: Boolean) {
if (isKeep) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)

View File

@@ -30,7 +30,6 @@ 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.findChapter
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.parser.MangaDataRepository
@@ -111,7 +110,7 @@ class ReaderViewModel @Inject constructor(
}
val readerMode = MutableStateFlow<ReaderMode?>(null)
val onPageSaved = MutableEventFlow<Uri?>()
val onPageSaved = MutableEventFlow<Collection<Uri>>()
val onShowToast = MutableEventFlow<Int>()
val uiState = MutableStateFlow<ReaderUiState?>(null)
@@ -261,8 +260,8 @@ class ReaderViewModel @Inject constructor(
val currentManga = manga.requireValue()
val task = PageSaveHelper.Task(
manga = currentManga,
chapter = checkNotNull(currentManga.findChapter(state.chapterId)),
pageNumber = state.page,
chapter = currentManga.requireChapterById(state.chapterId),
pageNumber = state.page + 1,
page = checkNotNull(getCurrentPage()) { "Cannot find current page" },
)
val dest = pageSaveHelper.save(setOf(task))
@@ -497,7 +496,7 @@ class ReaderViewModel @Inject constructor(
val history = historyRepository.getOne(manga)
val preselectedBranch = selectedBranch.value
val result = if (history != null) {
if (preselectedBranch != null && preselectedBranch != manga.findChapter(history.chapterId)?.branch) {
if (preselectedBranch != null && preselectedBranch != manga.findChapterById(history.chapterId)?.branch) {
null
} else {
ReaderState(history)

View File

@@ -11,7 +11,8 @@ import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.mapToArray
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import kotlin.coroutines.resume
class ImageServerDelegate(
@@ -19,30 +20,30 @@ class ImageServerDelegate(
private val mangaSource: MangaSource?,
) {
private val repositoryLazy = SuspendLazy {
private val repositoryLazy = suspendLazy {
mangaRepositoryFactory.create(checkNotNull(mangaSource)) as ParserMangaRepository
}
suspend fun isAvailable() = withContext(Dispatchers.Default) {
repositoryLazy.tryGet().map { repository ->
repositoryLazy.getOrNull()?.let { repository ->
repository.getConfigKeys().any { it is ConfigKey.PreferredImageServer }
}.getOrDefault(false)
} == true
}
suspend fun getValue(): String? = withContext(Dispatchers.Default) {
repositoryLazy.tryGet().map { repository ->
repositoryLazy.getOrNull()?.let { repository ->
val key = repository.getConfigKeys().firstNotNullOfOrNull { it as? ConfigKey.PreferredImageServer }
if (key != null) {
key.presetValues[repository.getConfig()[key]]
} else {
null
}
}.getOrNull()
}
}
suspend fun showDialog(context: Context): Boolean {
val repository = withContext(Dispatchers.Default) {
repositoryLazy.tryGet().getOrNull()
repositoryLazy.getOrNull()
} ?: return false
val key = repository.getConfigKeys().firstNotNullOfOrNull {
it as? ConfigKey.PreferredImageServer

View File

@@ -11,12 +11,12 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.findKeyByValue
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity

View File

@@ -16,7 +16,7 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import java.io.File
import java.io.FileNotFoundException
import java.util.Date
@@ -31,7 +31,7 @@ class RestoreViewModel @Inject constructor(
@ApplicationContext context: Context,
) : BaseViewModel() {
private val backupInput = SuspendLazy {
private val backupInput = suspendLazy {
val uri = savedStateHandle.get<String>(RestoreDialogFragment.ARG_FILE)
?.toUriOrNull() ?: throw FileNotFoundException()
val contentResolver = context.contentResolver

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_save"
android:icon="@drawable/ic_save"
android:title="@string/save"
app:showAsAction="ifRoom|withText" />
</menu>

View File

@@ -57,6 +57,7 @@
<string name="_s_deleted_from_local_storage">\"%s\" deleted from local storage</string>
<string name="save_page">Save page</string>
<string name="page_saved">Page saved</string>
<string name="pages_saved">Pages saved</string>
<string name="share_image">Share image</string>
<string name="_import">Import</string>
<string name="delete">Delete</string>

View File

@@ -29,7 +29,7 @@ material = "1.12.0"
moshi = "1.15.1"
okhttp = "4.12.0"
okio = "3.9.1"
parsers = "f610ae6412"
parsers = "8b4bac3cc2"
preference = "1.2.1"
recyclerview = "1.3.2"
room = "2.6.1"
@@ -37,7 +37,7 @@ runner = "1.6.2"
rules = "1.6.1"
ssiv = "d1d10a6975"
swiperefreshlayout = "1.1.0"
kspPlugin = "2.0.21-1.0.26"
kspPlugin = "2.0.21-1.0.27"
transition = "1.5.1"
viewpager2 = "1.1.0"
webkit = "1.12.1"