Compare commits

...

13 Commits
v6.3.1 ... v6.4

Author SHA1 Message Date
Koitharu
012eefe4fe Fix NPE in ListConfigViewModel 2023-11-25 17:40:54 +02:00
Oliullah
cb0f0c70d0 Translated using Weblate (Bengali)
Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Oliullah <shahin1465686@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/bn/
Translation: Kotatsu/plurals
2023-11-25 17:34:07 +02:00
Макар Разин
23111dfef9 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (525 of 525 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (525 of 525 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (525 of 525 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-11-25 17:34:07 +02:00
gallegonovato
d050c9ad0e Translated using Weblate (Spanish)
Currently translated at 100.0% (525 of 525 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (524 of 524 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-11-25 17:34:07 +02:00
Koitharu
efd952a91a Localize parsers errors 2023-11-25 17:33:05 +02:00
Koitharu
d3f23ea3a3 Add manga state to filter 2023-11-25 17:25:48 +02:00
Koitharu
acba312e8d Misc fixes 2023-11-25 09:18:08 +02:00
Koitharu
880dd6da27 Load local manga pages directly #552 2023-11-24 18:54:09 +02:00
Koitharu
0c839ce49a Fix locale selection in sources catalog #561 2023-11-24 17:10:58 +02:00
Isira Seneviratne
1afd2d3976 Use file walking APIs 2023-11-24 16:59:01 +02:00
Koitharu
f2d881f9bc Fix crash with locales sorting 2023-11-24 16:58:11 +02:00
Koitharu
c838e57f22 Downsample offscreen pages option 2023-11-24 16:53:44 +02:00
Koitharu
2075b1be19 Add lifecycle for BasePageHolder 2023-11-24 14:59:12 +02:00
66 changed files with 720 additions and 329 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 598 versionCode = 600
versionName = '6.3.1' versionName = '6.4'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner" testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
ksp { ksp {
@@ -82,7 +82,7 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:c3613f3ba4') { implementation('com.github.KotatsuApp:kotatsu-parsers:46e863ef79') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
@@ -134,7 +134,7 @@ dependencies {
implementation 'io.coil-kt:coil-base:2.5.0' implementation 'io.coil-kt:coil-base:2.5.0'
implementation 'io.coil-kt:coil-svg:2.5.0' implementation 'io.coil-kt:coil-svg:2.5.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:cf089a264d' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:c7dab3aefe'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'

View File

@@ -1,7 +1,7 @@
package org.koitharu.kotatsu.bookmarks.domain package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.local.data.ImageFileFilter import org.koitharu.kotatsu.local.data.hasImageExtension
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 java.util.Date import java.util.Date
@@ -38,7 +38,6 @@ data class Bookmark(
) )
private fun isImageUrlDirect(): Boolean { private fun isImageUrlDirect(): Boolean {
val extension = imageUrl.substringAfterLast('.') return hasImageExtension(imageUrl)
return extension.isNotEmpty() && ImageFileFilter().isExtensionValid(extension)
} }
} }

View File

@@ -1,12 +1,15 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import android.net.Uri import android.net.Uri
import androidx.annotation.StringRes
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.iterator import org.koitharu.kotatsu.core.util.ext.iterator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
@JvmName("mangaIds") @JvmName("mangaIds")
@@ -31,6 +34,15 @@ fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
return acc.values.max() return acc.values.max()
} }
@get:StringRes
val MangaState.titleResId: Int
get() = when (this) {
MangaState.ONGOING -> R.string.state_ongoing
MangaState.FINISHED -> R.string.state_finished
MangaState.ABANDONED -> R.string.state_abandoned
MangaState.PAUSED -> R.string.state_paused
}
fun Manga.findChapter(id: Long): MangaChapter? { fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.findById(id) return chapters?.findById(id)
} }

View File

@@ -63,6 +63,9 @@ class MirrorSwitchInterceptor @Inject constructor(
} }
synchronized(obtainLock(repository.source)) { synchronized(obtainLock(repository.source)) {
val currentMirror = repository.domain val currentMirror = repository.domain
if (currentMirror !in mirrors) {
return@synchronized false
}
addToBlacklist(repository.source, currentMirror) addToBlacklist(repository.source, currentMirror)
val newMirror = mirrors.firstOrNull { x -> val newMirror = mirrors.firstOrNull { x ->
x != currentMirror && !isBlacklisted(repository.source, x) x != currentMirror && !isBlacklisted(repository.source, x)

View File

@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.almostEquals import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.levenshteinDistance
@@ -58,7 +59,7 @@ class MangaLinkResolver @Inject constructor(
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? { private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
if (!title.isNullOrEmpty()) { if (!title.isNullOrEmpty()) {
val list = getList(0, title) val list = getList(0, MangaListFilter.Search(title))
if (url != null) { if (url != null) {
list.find { it.url == url }?.let { list.find { it.url == url }?.let {
return it return it
@@ -77,7 +78,7 @@ class MangaLinkResolver @Inject constructor(
}.ifNullOrEmpty { }.ifNullOrEmpty {
seed.author seed.author
} ?: return@runCatchingCancellable null } ?: return@runCatchingCancellable null
val seedList = getList(0, seedTitle) val seedList = getList(0, MangaListFilter.Search(seedTitle))
seedList.first { x -> x.url == url } seedList.first { x -> x.url == url }
}.getOrThrow() }.getOrThrow()
} }

View File

@@ -7,8 +7,10 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
@@ -23,11 +25,13 @@ interface MangaRepository {
val sortOrders: Set<SortOrder> val sortOrders: Set<SortOrder>
val states: Set<MangaState>
var defaultSortOrder: SortOrder var defaultSortOrder: SortOrder
suspend fun getList(offset: Int, query: String): List<Manga> val isMultipleTagsSupported: Boolean
suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga>
suspend fun getDetails(manga: Manga): Manga suspend fun getDetails(manga: Manga): Manga

View File

@@ -23,8 +23,10 @@ import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Favicons import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.domain
@@ -40,7 +42,10 @@ class RemoteMangaRepository(
get() = parser.source get() = parser.source
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = parser.sortOrders get() = parser.availableSortOrders
override val states: Set<MangaState>
get() = parser.availableStates
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.first() get() = getConfig().defaultSortOrder ?: sortOrders.first()
@@ -48,6 +53,9 @@ class RemoteMangaRepository(
getConfig().defaultSortOrder = value getConfig().defaultSortOrder = value
} }
override val isMultipleTagsSupported: Boolean
get() = parser.isMultipleTagsSupported
var domain: String var domain: String
get() = parser.domain get() = parser.domain
set(value) { set(value) {
@@ -68,15 +76,9 @@ class RemoteMangaRepository(
} }
} }
override suspend fun getList(offset: Int, query: String): List<Manga> { override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
return mirrorSwitchInterceptor.withMirrorSwitching { return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, query) parser.getList(offset, filter)
}
}
override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, tags, sortOrder)
} }
} }
@@ -98,7 +100,7 @@ class RemoteMangaRepository(
} }
override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching { override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getTags() parser.getAvailableTags()
} }
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching { suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
@@ -133,7 +135,7 @@ class RemoteMangaRepository(
} }
suspend fun find(manga: Manga): Manga? { suspend fun find(manga: Manga): Manga? {
val list = getList(0, manga.title) val list = getList(0, MangaListFilter.Search(manga.title))
return list.find { x -> x.id == manga.id } return list.find { x -> x.id == manga.id }
} }

View File

@@ -109,6 +109,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderTapsAdaptive: Boolean val isReaderTapsAdaptive: Boolean
get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false) get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false)
val isReaderOptimizationEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false)
var isTrafficWarningEnabled: Boolean var isTrafficWarningEnabled: Boolean
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true) get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) } set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
@@ -506,6 +509,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_SCREEN_ON = "reader_screen_on" const val KEY_READER_SCREEN_ON = "reader_screen_on"
const val KEY_SHORTCUTS = "dynamic_shortcuts" const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr" const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
const val KEY_READER_OPTIMIZE = "reader_optimize"
const val KEY_LOCAL_LIST_ORDER = "local_order" const val KEY_LOCAL_LIST_ORDER = "local_order"
const val KEY_HISTORY_ORDER = "history_order" const val KEY_HISTORY_ORDER = "history_order"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom" const val KEY_WEBTOON_ZOOM = "webtoon_zoom"

View File

@@ -0,0 +1,88 @@
package org.koitharu.kotatsu.core.ui.list.lifecycle
import android.view.View
import androidx.annotation.CallSuper
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.recyclerview.widget.RecyclerView
abstract class LifecycleAwareViewHolder(
itemView: View,
private val parentLifecycleOwner: LifecycleOwner,
) : RecyclerView.ViewHolder(itemView), LifecycleOwner {
@Suppress("LeakingThis")
final override val lifecycle = LifecycleRegistry(this)
private var isCurrent = false
init {
parentLifecycleOwner.lifecycle.addObserver(ParentLifecycleObserver())
if (parentLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}
}
fun setIsCurrent(value: Boolean) {
isCurrent = value
dispatchResumed()
}
@CallSuper
open fun onStart() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START)
@CallSuper
open fun onResume() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
@CallSuper
open fun onPause() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
@CallSuper
open fun onStop() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
private fun dispatchResumed() {
val isParentResumed = parentLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
if (isCurrent && isParentResumed) {
if (!isResumed()) {
onResume()
}
} else {
if (isResumed()) {
onPause()
}
}
}
protected fun isResumed(): Boolean {
return lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
}
private inner class ParentLifecycleObserver : DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}
override fun onStart(owner: LifecycleOwner) {
onStart()
}
override fun onResume(owner: LifecycleOwner) {
dispatchResumed()
}
override fun onPause(owner: LifecycleOwner) {
dispatchResumed()
}
override fun onStop(owner: LifecycleOwner) {
onStop()
}
override fun onDestroy(owner: LifecycleOwner) {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
owner.lifecycle.removeObserver(this)
}
}
}

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.core.ui.list.lifecycle
import androidx.core.view.children
import androidx.viewpager2.widget.ViewPager2
import org.koitharu.kotatsu.core.util.ext.recyclerView
class PagerLifecycleDispatcher(
private val pager: ViewPager2,
) : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
val rv = pager.recyclerView ?: return
for (child in rv.children) {
val wh = rv.getChildViewHolder(child) ?: continue
(wh as? LifecycleAwareViewHolder)?.setIsCurrent(wh.absoluteAdapterPosition == position)
}
}
fun invalidate() {
onPageSelected(pager.currentItem)
}
}

View File

@@ -0,0 +1,35 @@
package org.koitharu.kotatsu.core.ui.list.lifecycle
import androidx.core.view.children
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
class RecyclerViewLifecycleDispatcher : RecyclerView.OnScrollListener() {
private var prevFirst = NO_POSITION
private var prevLast = NO_POSITION
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
invalidate(recyclerView)
}
fun invalidate(recyclerView: RecyclerView) {
val lm = recyclerView.layoutManager as? LinearLayoutManager ?: return
val first = lm.findFirstVisibleItemPosition()
val last = lm.findLastVisibleItemPosition()
if (first == prevFirst && last == prevLast) {
return
}
prevFirst = first
prevLast = last
if (first == NO_POSITION || last == NO_POSITION) {
return
}
for (child in recyclerView.children) {
val wh = recyclerView.getChildViewHolder(child) ?: continue
(wh as? LifecycleAwareViewHolder)?.setIsCurrent(wh.absoluteAdapterPosition in first..last)
}
}
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.util.ext
import androidx.collection.ArrayMap import androidx.collection.ArrayMap
import androidx.collection.ArraySet import androidx.collection.ArraySet
import org.koitharu.kotatsu.BuildConfig
import java.util.Collections import java.util.Collections
import java.util.EnumSet import java.util.EnumSet
@@ -57,3 +58,13 @@ inline fun <reified E : Enum<E>> Collection<E>.toEnumSet(): EnumSet<E> = if (isE
} }
fun <E : Enum<E>> Collection<E>.sortedByOrdinal() = sortedBy { it.ordinal } fun <E : Enum<E>> Collection<E>.sortedByOrdinal() = sortedBy { it.ordinal }
fun <T> Iterable<T>.sortedWithSafe(comparator: Comparator<in T>): List<T> = try {
sortedWith(comparator)
} catch (e: IllegalArgumentException) {
if (BuildConfig.DEBUG) {
throw e
} else {
toList()
}
}

View File

@@ -7,7 +7,6 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.annotation.WorkerThread
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
@@ -19,7 +18,9 @@ import java.io.FileFilter
import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.readAttributes import kotlin.io.path.readAttributes
import kotlin.io.path.walk
fun File.subdir(name: String) = File(this, name).also { fun File.subdir(name: String) = File(this, name).also {
if (!it.exists()) it.mkdirs() if (!it.exists()) it.mkdirs()
@@ -49,7 +50,7 @@ fun File.getStorageName(context: Context): String = runCatching {
} }
}.getOrNull() ?: context.getString(R.string.other_storage) }.getOrNull() ?: context.getString(R.string.other_storage)
fun Uri.toFileOrNull() = if (scheme == "file") path?.let(::File) else null fun Uri.toFileOrNull() = if (scheme == URI_SCHEME_FILE) path?.let(::File) else null
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) { suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
delete() || deleteRecursively() delete() || deleteRecursively()
@@ -71,31 +72,7 @@ fun ContentResolver.resolveName(uri: Uri): String? {
} }
suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) { suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
computeSizeInternal(this) walkCompat().sumOf { it.length() }
}
@WorkerThread
private fun computeSizeInternal(file: File): Long {
return if (file.isDirectory) {
file.children().sumOf { computeSizeInternal(it) }
} else {
file.length()
}
}
fun File.listFilesRecursive(filter: FileFilter? = null): Sequence<File> = sequence {
listFilesRecursiveImpl(this@listFilesRecursive, filter)
}
private suspend fun SequenceScope<File>.listFilesRecursiveImpl(root: File, filter: FileFilter?) {
val ss = root.children()
for (f in ss) {
if (f.isDirectory) {
listFilesRecursiveImpl(f, filter)
} else if (filter == null || filter.accept(f)) {
yield(f)
}
}
} }
fun File.children() = FileSequence(this) fun File.children() = FileSequence(this)
@@ -108,3 +85,12 @@ val File.creationTime
} else { } else {
lastModified() lastModified()
} }
@OptIn(ExperimentalPathApi::class)
fun File.walkCompat() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Use lazy loading on Android 8.0 and later
toPath().walk().map { it.toFile() }
} else {
// Directories are excluded by default in Path.walk(), so do it here as well
walk().filter { it.isFile }
}

View File

@@ -1,6 +1,9 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.content.Context
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale import java.util.Locale
operator fun LocaleListCompat.iterator(): ListIterator<Locale> = LocaleListCompatIterator(this) operator fun LocaleListCompat.iterator(): ListIterator<Locale> = LocaleListCompatIterator(this)
@@ -17,6 +20,14 @@ inline fun <T> LocaleListCompat.mapToSet(block: (Locale) -> T): Set<T> {
fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException() fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException()
fun String?.getLocaleDisplayName(context: Context): String {
if (this == null) {
return context.getString(R.string.various_languages)
}
val lc = Locale(this)
return lc.getDisplayLanguage(lc).toTitleCase(lc)
}
private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> { private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> {
private var index = 0 private var index = 0

View File

@@ -27,7 +27,10 @@ import java.net.SocketTimeoutException
import java.net.UnknownHostException import java.net.UnknownHostException
private const val MSG_NO_SPACE_LEFT = "No space left on device" private const val MSG_NO_SPACE_LEFT = "No space left on device"
private const val IMAGE_FORMAT_NO_SUPPORTED = "Image format not supported" private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
private const val MULTIPLE_GENRES_NOT_SUPPORTED = "Multiple genres are not supported by this source"
private const val MULTIPLE_STATES_NOT_SUPPORTED = "Multiple states are not supported by this source"
private const val SEARCH_NOT_SUPPORTED = "Search is not supported by this source"
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) { fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required) is AuthRequiredException -> resources.getString(R.string.auth_required)
@@ -56,8 +59,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is HttpException -> getHttpDisplayMessage(response.code, resources) is HttpException -> getHttpDisplayMessage(response.code, resources)
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources) is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
is IOException -> getDisplayMessage(message, resources) ?: localizedMessage else -> getDisplayMessage(message, resources) ?: localizedMessage
else -> localizedMessage
}.ifNullOrEmpty { }.ifNullOrEmpty {
resources.getString(R.string.error_occurred) resources.getString(R.string.error_occurred)
} }
@@ -82,7 +84,10 @@ private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String
private fun getDisplayMessage(msg: String?, resources: Resources): String? = when { private fun getDisplayMessage(msg: String?, resources: Resources): String? = when {
msg.isNullOrEmpty() -> null msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left) msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
msg.contains(IMAGE_FORMAT_NO_SUPPORTED) -> resources.getString(R.string.error_corrupted_file) msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
msg == MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
msg == MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
else -> null else -> null
} }

View File

@@ -0,0 +1,50 @@
package org.koitharu.kotatsu.core.util.ext
import android.net.Uri
import androidx.core.net.toFile
import okio.Source
import okio.source
import okio.use
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import java.io.File
import java.util.zip.ZipFile
const val URI_SCHEME_FILE = "file"
const val URI_SCHEME_ZIP = "file+zip"
fun Uri.exists(): Boolean = when (scheme) {
URI_SCHEME_FILE -> toFile().exists()
URI_SCHEME_ZIP -> {
val file = File(requireNotNull(schemeSpecificPart))
file.exists() && ZipFile(file).use { it.getEntry(fragment) != null }
}
else -> unsupportedUri(this)
}
fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
URI_SCHEME_FILE -> toFile().isNotEmpty()
URI_SCHEME_ZIP -> {
val file = File(requireNotNull(schemeSpecificPart))
file.exists() && ZipFile(file).use { (it.getEntry(fragment)?.size ?: 0L) != 0L }
}
else -> unsupportedUri(this)
}
fun Uri.source(): Source = when (scheme) {
URI_SCHEME_FILE -> toFile().source()
URI_SCHEME_ZIP -> {
val zip = ZipFile(schemeSpecificPart)
val entry = zip.getEntry(fragment)
zip.getInputStream(entry).source().withExtraCloseable(zip)
}
else -> unsupportedUri(this)
}
fun File.toZipUri(entryName: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryName")
private fun unsupportedUri(uri: Uri): Nothing {
throw IllegalArgumentException("Bad uri $uri: only schemes $URI_SCHEME_FILE and $URI_SCHEME_ZIP are supported")
}

View File

@@ -1,32 +0,0 @@
package org.koitharu.kotatsu.core.zip
import android.net.Uri
import androidx.annotation.WorkerThread
import androidx.collection.LruCache
import okhttp3.internal.closeQuietly
import okio.Source
import okio.source
import java.io.File
import java.util.zip.ZipFile
class ZipPool(maxSize: Int) : LruCache<String, ZipFile>(maxSize) {
override fun entryRemoved(evicted: Boolean, key: String, oldValue: ZipFile, newValue: ZipFile?) {
super.entryRemoved(evicted, key, oldValue, newValue)
oldValue.closeQuietly()
}
override fun create(key: String): ZipFile {
return ZipFile(File(key), ZipFile.OPEN_READ)
}
@Synchronized
@WorkerThread
operator fun get(uri: Uri): Source {
val zip = requireNotNull(get(uri.schemeSpecificPart)) {
"Cannot obtain zip by \"$uri\""
}
val entry = zip.getEntry(uri.fragment)
return zip.getInputStream(entry).source()
}
}

View File

@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
@@ -73,7 +74,15 @@ class ExploreRepository @Inject constructor(
val tag = tags.firstNotNullOfOrNull { title -> val tag = tags.firstNotNullOfOrNull { title ->
availableTags.find { x -> x.title.almostEquals(title, 0.4f) } availableTags.find { x -> x.title.almostEquals(title, 0.4f) }
} }
val list = repository.getList(0, setOfNotNull(tag), order).asArrayList() val list = repository.getList(
offset = 0,
filter = MangaListFilter.Advanced(
sortOrder = order,
tags = setOfNotNull(tag),
locale = null,
states = emptySet(),
),
).asArrayList()
if (settings.isSuggestionsExcludeNsfw) { if (settings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw } list.removeAll { it.isNsfw }
} }

View File

@@ -5,6 +5,7 @@ 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.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@@ -18,7 +19,7 @@ class RecoverMangaUseCase @Inject constructor(
return@runCatchingCancellable null return@runCatchingCancellable null
} }
val repository = repositoryFactory.create(manga.source) val repository = repositoryFactory.create(manga.source)
val list = repository.getList(offset = 0, query = manga.title) val list = repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
val newManga = list.find { x -> x.title == manga.title }?.let { val newManga = list.find { x -> x.title == manga.title }?.let {
repository.getDetails(it) repository.getDetails(it)
} ?: return@runCatchingCancellable null } ?: return@runCatchingCancellable null

View File

@@ -19,6 +19,8 @@ class FilterAdapter(
init { init {
addDelegate(ListItemType.FILTER_SORT, filterSortDelegate(listener)) addDelegate(ListItemType.FILTER_SORT, filterSortDelegate(listener))
addDelegate(ListItemType.FILTER_TAG, filterTagDelegate(listener)) addDelegate(ListItemType.FILTER_TAG, filterTagDelegate(listener))
addDelegate(ListItemType.FILTER_TAG_MULTI, filterTagMultipleDelegate(listener))
addDelegate(ListItemType.FILTER_STATE, filterStateDelegate(listener))
addDelegate(ListItemType.HEADER, listHeaderAD(listener)) addDelegate(ListItemType.HEADER, listHeaderAD(listener))
addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())

View File

@@ -4,6 +4,7 @@ import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.util.ext.setChecked import org.koitharu.kotatsu.core.util.ext.setChecked
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
@@ -27,10 +28,44 @@ fun filterSortDelegate(
} }
} }
fun filterStateDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.State, ListModel, ItemCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
) {
itemView.setOnClickListener {
listener.onStateItemClick(item)
}
bind { payloads ->
binding.root.setText(item.state.titleResId)
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
}
}
fun filterTagDelegate( fun filterTagDelegate(
listener: OnFilterChangedListener, listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableSingleBinding>(
{ layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is FilterItem.Tag && !item.isMultiple },
) {
itemView.setOnClickListener {
listener.onTagItemClick(item)
}
bind { payloads ->
binding.root.text = item.tag.title
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
}
}
fun filterTagMultipleDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableMultipleBinding>( ) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) }, { layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is FilterItem.Tag && item.isMultiple },
) { ) {
itemView.setOnClickListener { itemView.setOnClickListener {

View File

@@ -28,11 +28,11 @@ import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterItem import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.filter.ui.model.FilterState
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -55,7 +55,8 @@ class FilterCoordinator @Inject constructor(
private val coroutineScope = lifecycle.lifecycleScope private val coroutineScope = lifecycle.lifecycleScope
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE)) private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))
private val currentState = MutableStateFlow(FilterState(repository.defaultSortOrder, emptySet())) private val currentState =
MutableStateFlow(MangaListFilter.Advanced(repository.defaultSortOrder, emptySet(), null, emptySet()))
private var searchQuery = MutableStateFlow("") private var searchQuery = MutableStateFlow("")
private val localTags = SuspendLazy { private val localTags = SuspendLazy {
dataRepository.findTags(repository.source) dataRepository.findTags(repository.source)
@@ -68,7 +69,12 @@ class FilterCoordinator @Inject constructor(
override val header: StateFlow<FilterHeaderModel> = getHeaderFlow().stateIn( override val header: StateFlow<FilterHeaderModel> = getHeaderFlow().stateIn(
scope = coroutineScope + Dispatchers.Default, scope = coroutineScope + Dispatchers.Default,
started = SharingStarted.Lazily, started = SharingStarted.Lazily,
initialValue = FilterHeaderModel(emptyList(), repository.defaultSortOrder, false), initialValue = FilterHeaderModel(
chips = emptyList(),
sortOrder = repository.defaultSortOrder,
hasSelectedTags = false,
allowMultipleTags = repository.isMultipleTagsSupported,
),
) )
init { init {
@@ -81,24 +87,44 @@ class FilterCoordinator @Inject constructor(
override fun onSortItemClick(item: FilterItem.Sort) { override fun onSortItemClick(item: FilterItem.Sort) {
currentState.update { oldValue -> currentState.update { oldValue ->
FilterState(item.order, oldValue.tags) oldValue.copy(sortOrder = item.order)
} }
repository.defaultSortOrder = item.order repository.defaultSortOrder = item.order
} }
override fun onTagItemClick(item: FilterItem.Tag) { override fun onTagItemClick(item: FilterItem.Tag) {
currentState.update { oldValue -> currentState.update { oldValue ->
val newTags = if (item.isChecked) { val newTags = if (!item.isMultiple) {
setOf(item.tag)
} else if (item.isChecked) {
oldValue.tags - item.tag oldValue.tags - item.tag
} else { } else {
oldValue.tags + item.tag oldValue.tags + item.tag
} }
FilterState(oldValue.sortOrder, newTags) oldValue.copy(tags = newTags)
}
}
override fun onStateItemClick(item: FilterItem.State) {
currentState.update { oldValue ->
val newStates = if (item.isChecked) {
oldValue.states - item.state
} else {
oldValue.states + item.state
}
oldValue.copy(states = newStates)
} }
} }
override fun onListHeaderClick(item: ListHeader, view: View) { override fun onListHeaderClick(item: ListHeader, view: View) {
reset() currentState.update { oldValue ->
oldValue.copy(
sortOrder = oldValue.sortOrder,
tags = if (item.payload == R.string.genres) emptySet() else oldValue.tags,
locale = null,
states = if (item.payload == R.string.state) emptySet() else oldValue.states,
)
}
} }
fun observeAvailableTags(): Flow<Set<MangaTag>?> = flow { fun observeAvailableTags(): Flow<Set<MangaTag>?> = flow {
@@ -112,13 +138,13 @@ class FilterCoordinator @Inject constructor(
fun setTags(tags: Set<MangaTag>) { fun setTags(tags: Set<MangaTag>) {
currentState.update { oldValue -> currentState.update { oldValue ->
FilterState(oldValue.sortOrder, tags) oldValue.copy(tags = tags)
} }
} }
fun reset() { fun reset() {
currentState.update { oldValue -> currentState.update { oldValue ->
FilterState(oldValue.sortOrder, emptySet()) oldValue.copy(oldValue.sortOrder, emptySet(), null, emptySet())
} }
} }
@@ -133,7 +159,12 @@ class FilterCoordinator @Inject constructor(
observeAvailableTags(), observeAvailableTags(),
) { state, available -> ) { state, available ->
val chips = createChipsList(state, available.orEmpty(), 8) val chips = createChipsList(state, available.orEmpty(), 8)
FilterHeaderModel(chips, state.sortOrder, state.tags.isNotEmpty()) FilterHeaderModel(
chips = chips,
sortOrder = state.sortOrder,
hasSelectedTags = state.tags.isNotEmpty(),
allowMultipleTags = repository.isMultipleTagsSupported,
)
} }
private fun getItemsFlow() = combine( private fun getItemsFlow() = combine(
@@ -156,7 +187,7 @@ class FilterCoordinator @Inject constructor(
} }
private suspend fun createChipsList( private suspend fun createChipsList(
filterState: FilterState, filterState: MangaListFilter.Advanced,
availableTags: Set<MangaTag>, availableTags: Set<MangaTag>,
limit: Int, limit: Int,
): List<ChipsView.ChipModel> { ): List<ChipsView.ChipModel> {
@@ -205,12 +236,14 @@ class FilterCoordinator @Inject constructor(
@WorkerThread @WorkerThread
private fun buildFilterList( private fun buildFilterList(
allTags: TagsWrapper, allTags: TagsWrapper,
state: FilterState, state: MangaListFilter.Advanced,
query: String, query: String,
): List<ListModel> { ): List<ListModel> {
val sortOrders = repository.sortOrders.sortedByOrdinal() val sortOrders = repository.sortOrders.sortedByOrdinal()
val states = repository.states
val tags = mergeTags(state.tags, allTags.tags).toList() val tags = mergeTags(state.tags, allTags.tags).toList()
val list = ArrayList<ListModel>(tags.size + sortOrders.size + 3) val list = ArrayList<ListModel>(tags.size + states.size + sortOrders.size + 4)
val isMultiTag = repository.isMultipleTagsSupported
if (query.isEmpty()) { if (query.isEmpty()) {
if (sortOrders.isNotEmpty()) { if (sortOrders.isNotEmpty()) {
list.add(ListHeader(R.string.sort_order)) list.add(ListHeader(R.string.sort_order))
@@ -218,10 +251,28 @@ class FilterCoordinator @Inject constructor(
FilterItem.Sort(it, isSelected = it == state.sortOrder) FilterItem.Sort(it, isSelected = it == state.sortOrder)
} }
} }
if (states.isNotEmpty()) {
list.add(
ListHeader(
textRes = R.string.state,
buttonTextRes = if (state.states.isEmpty()) 0 else R.string.reset,
payload = R.string.state,
),
)
states.mapTo(list) {
FilterItem.State(it, isChecked = it in state.states)
}
}
if (allTags.isLoading || allTags.isError || tags.isNotEmpty()) { if (allTags.isLoading || allTags.isError || tags.isNotEmpty()) {
list.add(ListHeader(R.string.genres, if (state.tags.isEmpty()) 0 else R.string.reset)) list.add(
ListHeader(
textRes = R.string.genres,
buttonTextRes = if (state.tags.isEmpty()) 0 else R.string.reset,
payload = R.string.genres,
),
)
tags.mapTo(list) { tags.mapTo(list) {
FilterItem.Tag(it, isChecked = it in state.tags) FilterItem.Tag(it, isMultiple = isMultiTag, isChecked = it in state.tags)
} }
} }
if (allTags.isError) { if (allTags.isError) {
@@ -232,7 +283,7 @@ class FilterCoordinator @Inject constructor(
} else { } else {
tags.mapNotNullTo(list) { tags.mapNotNullTo(list) {
if (it.title.contains(query, ignoreCase = true)) { if (it.title.contains(query, ignoreCase = true)) {
FilterItem.Tag(it, isChecked = it in state.tags) FilterItem.Tag(it, isMultiple = isMultiTag, isChecked = it in state.tags)
} else { } else {
null null
} }

View File

@@ -39,7 +39,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
if (tag == null) { if (tag == null) {
FilterSheetFragment.show(parentFragmentManager) FilterSheetFragment.show(parentFragmentManager)
} else { } else {
filter.onTagItemClick(FilterItem.Tag(tag, !chip.isChecked)) filter.onTagItemClick(FilterItem.Tag(tag, filter.header.value.allowMultipleTags, !chip.isChecked))
} }
} }

View File

@@ -8,4 +8,6 @@ interface OnFilterChangedListener : ListHeaderClickListener {
fun onSortItemClick(item: FilterItem.Sort) fun onSortItemClick(item: FilterItem.Sort)
fun onTagItemClick(item: FilterItem.Tag) fun onTagItemClick(item: FilterItem.Tag)
fun onStateItemClick(item: FilterItem.State)
} }

View File

@@ -7,6 +7,7 @@ class FilterHeaderModel(
val chips: Collection<ChipsView.ChipModel>, val chips: Collection<ChipsView.ChipModel>,
val sortOrder: SortOrder?, val sortOrder: SortOrder?,
val hasSelectedTags: Boolean, val hasSelectedTags: Boolean,
val allowMultipleTags: Boolean,
) { ) {
val textSummary: String val textSummary: String
@@ -19,6 +20,7 @@ class FilterHeaderModel(
other as FilterHeaderModel other as FilterHeaderModel
if (chips != other.chips) return false if (chips != other.chips) return false
if (allowMultipleTags != other.allowMultipleTags) return false
return sortOrder == other.sortOrder return sortOrder == other.sortOrder
// Not need to check hasSelectedTags // Not need to check hasSelectedTags
@@ -26,6 +28,7 @@ class FilterHeaderModel(
override fun hashCode(): Int { override fun hashCode(): Int {
var result = chips.hashCode() var result = chips.hashCode()
result = 31 * result + allowMultipleTags.hashCode()
result = 31 * result + (sortOrder?.hashCode() ?: 0) result = 31 * result + (sortOrder?.hashCode() ?: 0)
return result return result
} }

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.filter.ui.model
import androidx.annotation.StringRes import androidx.annotation.StringRes
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -28,11 +29,12 @@ sealed interface FilterItem : ListModel {
data class Tag( data class Tag(
val tag: MangaTag, val tag: MangaTag,
val isMultiple: Boolean,
val isChecked: Boolean, val isChecked: Boolean,
) : FilterItem { ) : FilterItem {
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is Tag && other.tag == tag return other is Tag && other.isMultiple == isMultiple && other.tag == tag
} }
override fun getChangePayload(previousState: ListModel): Any? { override fun getChangePayload(previousState: ListModel): Any? {
@@ -44,6 +46,24 @@ sealed interface FilterItem : ListModel {
} }
} }
data class State(
val state: MangaState,
val isChecked: Boolean
) : FilterItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is State && other.state == state
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is State && previousState.isChecked != isChecked) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}
data class Error( data class Error(
@StringRes val textResId: Int, @StringRes val textResId: Int,
) : FilterItem { ) : FilterItem {

View File

@@ -1,9 +0,0 @@
package org.koitharu.kotatsu.filter.ui.model
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
data class FilterState(
val sortOrder: SortOrder?,
val tags: Set<MangaTag>,
)

View File

@@ -4,6 +4,8 @@ enum class ListItemType {
FILTER_SORT, FILTER_SORT,
FILTER_TAG, FILTER_TAG,
FILTER_TAG_MULTI,
FILTER_STATE,
HEADER, HEADER,
MANGA_LIST, MANGA_LIST,
MANGA_LIST_DETAILED, MANGA_LIST_DETAILED,

View File

@@ -29,6 +29,8 @@ class TypedListSpacingDecoration(
when (itemType) { when (itemType) {
ListItemType.FILTER_SORT, ListItemType.FILTER_SORT,
ListItemType.FILTER_TAG, ListItemType.FILTER_TAG,
ListItemType.FILTER_TAG_MULTI,
ListItemType.FILTER_STATE,
-> outRect.set(0) -> outRect.set(0)
ListItemType.HEADER, ListItemType.HEADER,

View File

@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -54,7 +55,7 @@ class ListConfigViewModel @Inject constructor(
}?.sortedByOrdinal() }?.sortedByOrdinal()
fun getSelectedSortOrder(): ListSortOrder? = when (section) { fun getSelectedSortOrder(): ListSortOrder? = when (section) {
is ListConfigSection.Favorites -> runBlocking { favouritesRepository.getCategory(section.categoryId).order } is ListConfigSection.Favorites -> getCategorySortOrder(section.categoryId)
ListConfigSection.General -> null ListConfigSection.General -> null
ListConfigSection.History -> settings.historySortOrder ListConfigSection.History -> settings.historySortOrder
ListConfigSection.Suggestions -> ListSortOrder.RELEVANCE // TODO ListConfigSection.Suggestions -> ListSortOrder.RELEVANCE // TODO
@@ -73,4 +74,10 @@ class ListConfigViewModel @Inject constructor(
ListConfigSection.Suggestions -> Unit ListConfigSection.Suggestions -> Unit
} }
} }
private fun getCategorySortOrder(id: Long): ListSortOrder = runBlocking {
runCatchingCancellable {
favouritesRepository.getCategory(id).order
}.getOrDefault(ListSortOrder.NEWEST)
}
} }

View File

@@ -98,7 +98,7 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
if (filter == null) { if (filter == null) {
startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag))) startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag)))
} else { } else {
filter.onTagItemClick(FilterItem.Tag(tag, false)) filter.onTagItemClick(FilterItem.Tag(tag, filter.header.value.allowMultipleTags, false))
closeSelf() closeSelf()
} }
} }

View File

@@ -1,31 +1,20 @@
package org.koitharu.kotatsu.local.data package org.koitharu.kotatsu.local.data
import android.net.Uri import android.net.Uri
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP
import java.io.File import java.io.File
import java.io.FileFilter
import java.io.FilenameFilter
import java.util.Locale
class CbzFilter : FileFilter, FilenameFilter { private fun isCbzExtension(ext: String?): Boolean {
return ext.equals("cbz", ignoreCase = true) || ext.equals("zip", ignoreCase = true)
override fun accept(dir: File, name: String): Boolean { }
return isFileSupported(name)
} fun hasCbzExtension(string: String): Boolean {
val ext = string.substringAfterLast('.', "")
override fun accept(pathname: File?): Boolean { return isCbzExtension(ext)
return isFileSupported(pathname?.name ?: return false) }
}
fun File.hasCbzExtension() = isCbzExtension(extension)
companion object {
fun Uri.isZipUri() = scheme.let {
fun isFileSupported(name: String): Boolean { it == URI_SCHEME_ZIP || it == "cbz" || it == "zip"
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return ext == "cbz" || ext == "zip"
}
fun isUriSupported(uri: Uri): Boolean {
val scheme = uri.scheme?.lowercase(Locale.ROOT)
return scheme != null && scheme == "cbz" || scheme == "zip"
}
}
} }

View File

@@ -1,29 +1,11 @@
package org.koitharu.kotatsu.local.data package org.koitharu.kotatsu.local.data
import java.io.File import java.io.File
import java.io.FileFilter
import java.io.FilenameFilter
import java.util.Locale
import java.util.zip.ZipEntry
class ImageFileFilter : FilenameFilter, FileFilter { fun hasImageExtension(string: String): Boolean {
val ext = string.substringAfterLast('.', "")
override fun accept(dir: File, name: String): Boolean { return ext.equals("png", ignoreCase = true) || ext.equals("jpg", ignoreCase = true)
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) || ext.equals("jpeg", ignoreCase = true) || ext.equals("webp", ignoreCase = true)
return isExtensionValid(ext)
}
override fun accept(pathname: File?): Boolean {
val ext = pathname?.extension?.lowercase(Locale.ROOT) ?: return false
return isExtensionValid(ext)
}
fun accept(entry: ZipEntry): Boolean {
val ext = entry.name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return isExtensionValid(ext)
}
fun isExtensionValid(ext: String): Boolean {
return ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "webp"
}
} }
fun hasImageExtension(file: File) = hasImageExtension(file.name)

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.CompositeMutex2 import org.koitharu.kotatsu.core.util.CompositeMutex2
import org.koitharu.kotatsu.core.util.ext.children import org.koitharu.kotatsu.core.util.ext.children
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
@@ -25,8 +26,10 @@ import org.koitharu.kotatsu.local.data.output.LocalMangaUtil
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -47,7 +50,9 @@ class LocalMangaRepository @Inject constructor(
override val source = MangaSource.LOCAL override val source = MangaSource.LOCAL
private val locks = CompositeMutex2<Long>() private val locks = CompositeMutex2<Long>()
override val isMultipleTagsSupported: Boolean = true
override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST) override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST)
override val states = emptySet<MangaState>()
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = settings.localListOrder get() = settings.localListOrder
@@ -55,33 +60,32 @@ class LocalMangaRepository @Inject constructor(
settings.localListOrder = value settings.localListOrder = value
} }
override suspend fun getList(offset: Int, query: String): List<Manga> { override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
if (offset > 0) { if (offset > 0) {
return emptyList() return emptyList()
} }
val list = getRawList() val list = getRawList()
if (query.isNotEmpty()) { when (filter) {
list.retainAll { x -> x.isMatchesQuery(query) } is MangaListFilter.Search -> {
} list.retainAll { x -> x.isMatchesQuery(filter.query) }
return list.unwrap() }
}
override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> { is MangaListFilter.Advanced -> {
if (offset > 0) { if (filter.tags.isNotEmpty()) {
return emptyList() list.retainAll { x -> x.containsTags(filter.tags) }
} }
val list = getRawList() when (filter.sortOrder) {
if (!tags.isNullOrEmpty()) { SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title })
list.retainAll { x -> x.containsTags(tags) } SortOrder.RATING -> list.sortByDescending { it.manga.rating }
} SortOrder.NEWEST,
when (sortOrder) { SortOrder.UPDATED,
SortOrder.ALPHABETICAL -> list.sortWith(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.manga.title }) -> list.sortByDescending { it.createdAt }
SortOrder.RATING -> list.sortByDescending { it.manga.rating }
SortOrder.NEWEST,
SortOrder.UPDATED,
-> list.sortByDescending { it.createdAt }
else -> Unit else -> Unit
}
}
null -> Unit
} }
return list.unwrap() return list.unwrap()
} }

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Cache import okhttp3.Cache
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_FILE
import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.getStorageName import org.koitharu.kotatsu.core.util.ext.getStorageName
import org.koitharu.kotatsu.core.util.ext.resolveFile import org.koitharu.kotatsu.core.util.ext.resolveFile
@@ -84,7 +85,7 @@ class LocalStorageManager @Inject constructor(
} }
suspend fun resolveUri(uri: Uri): File? = runInterruptible(Dispatchers.IO) { suspend fun resolveUri(uri: Uri): File? = runInterruptible(Dispatchers.IO) {
if (uri.scheme == "file") { if (uri.scheme == URI_SCHEME_FILE) {
uri.toFile() uri.toFile()
} else { } else {
uri.resolveFile(context) uri.resolveFile(context)

View File

@@ -15,9 +15,9 @@ import okio.source
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.util.ext.resolveName import org.koitharu.kotatsu.core.util.ext.resolveName
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.hasCbzExtension
import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import java.io.File import java.io.File
@@ -46,7 +46,7 @@ class SingleMangaImporter @Inject constructor(
private suspend fun importFile(uri: Uri): LocalManga = withContext(Dispatchers.IO) { private suspend fun importFile(uri: Uri): LocalManga = withContext(Dispatchers.IO) {
val contentResolver = storageManager.contentResolver val contentResolver = storageManager.contentResolver
val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri") val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri")
if (!CbzFilter.isFileSupported(name)) { if (!hasCbzExtension(name)) {
throw UnsupportedFileException("Unsupported file on $uri") throw UnsupportedFileException("Unsupported file on $uri")
} }
val dest = File(getOutputDir(), name) val dest = File(getOutputDir(), name)

View File

@@ -6,12 +6,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.util.AlphanumComparator import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.creationTime import org.koitharu.kotatsu.core.util.ext.creationTime
import org.koitharu.kotatsu.core.util.ext.listFilesRecursive
import org.koitharu.kotatsu.core.util.ext.longHashCode import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.toListSorted import org.koitharu.kotatsu.core.util.ext.toListSorted
import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.core.util.ext.walkCompat
import org.koitharu.kotatsu.local.data.ImageFileFilter
import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.local.data.hasCbzExtension
import org.koitharu.kotatsu.local.data.hasImageExtension
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -91,16 +91,12 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
val file = chapter.url.toUri().toFile() val file = chapter.url.toUri().toFile()
if (file.isDirectory) { if (file.isDirectory) {
file.listFilesRecursive(ImageFileFilter()) file.walkCompat()
.filter { hasImageExtension(it) }
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name }) .toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
.map { .map {
val pageUri = it.toUri().toString() val pageUri = it.toUri().toString()
MangaPage( MangaPage(pageUri.longHashCode(), pageUri, null, MangaSource.LOCAL)
id = pageUri.longHashCode(),
url = pageUri,
preview = null,
source = MangaSource.LOCAL,
)
} }
} else { } else {
ZipFile(file).use { zip -> ZipFile(file).use { zip ->
@@ -124,20 +120,20 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
private fun String.toHumanReadable() = replace("_", " ").toCamelCase() private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
private fun getChaptersFiles(): List<File> = root.listFilesRecursive(CbzFilter()) private fun getChaptersFiles(): List<File> = root.walkCompat()
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name }) .filter { it.hasCbzExtension() }
.toListSorted(compareBy(AlphanumComparator()) { it.name })
private fun findFirstImageEntry(): String? { private fun findFirstImageEntry(): String? {
val filter = ImageFileFilter() return root.walkCompat().firstOrNull { hasImageExtension(it) }?.toUri()?.toString()
root.listFilesRecursive(filter).firstOrNull()?.let { ?: run {
return it.toUri().toString() val cbz = root.walkCompat().firstOrNull { it.hasCbzExtension() } ?: return null
} ZipFile(cbz).use { zip ->
val cbz = root.listFilesRecursive(CbzFilter()).firstOrNull() ?: return null zip.entries().asSequence()
return ZipFile(cbz).use { zip -> .firstOrNull { !it.isDirectory && hasImageExtension(it.name) }
zip.entries().asSequence() ?.let { zipUri(cbz, it.name) }
.firstOrNull { x -> !x.isDirectory && filter.accept(x) } }
?.let { entry -> zipUri(cbz, entry.name) } }
}
} }
private fun fileUri(base: File, name: String): String { private fun fileUri(base: File, name: String): String {

View File

@@ -7,7 +7,7 @@ import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.hasCbzExtension
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
@@ -39,7 +39,7 @@ sealed class LocalMangaInput(
fun ofOrNull(file: File): LocalMangaInput? = when { fun ofOrNull(file: File): LocalMangaInput? = when {
file.isDirectory -> LocalMangaDirInput(file) file.isDirectory -> LocalMangaDirInput(file)
CbzFilter.isFileSupported(file.name) -> LocalMangaZipInput(file) hasCbzExtension(file.name) -> LocalMangaZipInput(file)
else -> null else -> null
} }

View File

@@ -1,11 +1,11 @@
package org.koitharu.kotatsu.local.domain package org.koitharu.kotatsu.local.domain
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@@ -27,7 +27,7 @@ class DeleteLocalMangaUseCase @Inject constructor(
} }
suspend operator fun invoke(ids: Set<Long>) { suspend operator fun invoke(ids: Set<Long>) {
val list = localMangaRepository.getList(0, null, null) val list = localMangaRepository.getList(0, null)
var removed = 0 var removed = 0
for (manga in list) { for (manga in list) {
if (manga.id in ids) { if (manga.id in ids) {

View File

@@ -7,6 +7,7 @@ import android.net.Uri
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.set import androidx.collection.set
import androidx.core.net.toUri
import dagger.hilt.android.ActivityRetainedLifecycle import dagger.hilt.android.ActivityRetainedLifecycle
import dagger.hilt.android.lifecycle.RetainedLifecycle import dagger.hilt.android.lifecycle.RetainedLifecycle
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@@ -33,17 +34,18 @@ import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP
import org.koitharu.kotatsu.core.util.ext.ensureSuccess import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.exists
import org.koitharu.kotatsu.core.util.ext.getCompletionResultOrNull import org.koitharu.kotatsu.core.util.ext.getCompletionResultOrNull
import org.koitharu.kotatsu.core.util.ext.isNotEmpty
import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
import org.koitharu.kotatsu.core.util.ext.isTargetNotEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.ramAvailable import org.koitharu.kotatsu.core.util.ext.ramAvailable
import org.koitharu.kotatsu.core.util.ext.withProgress import org.koitharu.kotatsu.core.util.ext.withProgress
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
import org.koitharu.kotatsu.core.zip.ZipPool
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.isZipUri
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
@@ -71,13 +73,12 @@ class PageLoader @Inject constructor(
val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default
private val tasks = LongSparseArray<ProgressDeferred<File, Float>>() private val tasks = LongSparseArray<ProgressDeferred<Uri, Float>>()
private val semaphore = Semaphore(3) private val semaphore = Semaphore(3)
private val convertLock = Mutex() private val convertLock = Mutex()
private val prefetchLock = Mutex() private val prefetchLock = Mutex()
private var repository: MangaRepository? = null private var repository: MangaRepository? = null
private val prefetchQueue = LinkedList<MangaPage>() private val prefetchQueue = LinkedList<MangaPage>()
private val zipPool = ZipPool(2)
private val counter = AtomicInteger(0) private val counter = AtomicInteger(0)
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
@@ -85,7 +86,6 @@ class PageLoader @Inject constructor(
synchronized(tasks) { synchronized(tasks) {
tasks.clear() tasks.clear()
} }
zipPool.evictAll()
} }
fun isPrefetchApplicable(): Boolean { fun isPrefetchApplicable(): Boolean {
@@ -113,7 +113,7 @@ class PageLoader @Inject constructor(
} }
} }
fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred<File, Float> { fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred<Uri, Float> {
var task = tasks[page.id]?.takeIf { it.isValid() } var task = tasks[page.id]?.takeIf { it.isValid() }
if (force) { if (force) {
task?.cancel() task?.cancel()
@@ -127,7 +127,7 @@ class PageLoader @Inject constructor(
return task return task
} }
suspend fun loadPage(page: MangaPage, force: Boolean): File { suspend fun loadPage(page: MangaPage, force: Boolean): Uri {
return loadPageAsync(page, force).await() return loadPageAsync(page, force).await()
} }
@@ -167,11 +167,11 @@ class PageLoader @Inject constructor(
} }
} }
private fun loadPageAsyncImpl(page: MangaPage, skipCache: Boolean): ProgressDeferred<File, Float> { private fun loadPageAsyncImpl(page: MangaPage, skipCache: Boolean): ProgressDeferred<Uri, Float> {
val progress = MutableStateFlow(PROGRESS_UNDEFINED) val progress = MutableStateFlow(PROGRESS_UNDEFINED)
val deferred = loaderScope.async { val deferred = loaderScope.async {
if (!skipCache) { if (!skipCache) {
cache.get(page.url)?.let { return@async it } cache.get(page.url)?.let { return@async it.toUri() }
} }
counter.incrementAndGet() counter.incrementAndGet()
try { try {
@@ -195,26 +195,24 @@ class PageLoader @Inject constructor(
} }
} }
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): File = semaphore.withPermit { private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): Uri = semaphore.withPermit {
val pageUrl = getPageUrl(page) val pageUrl = getPageUrl(page)
check(pageUrl.isNotBlank()) { "Cannot obtain full image url" } check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
val uri = Uri.parse(pageUrl) val uri = Uri.parse(pageUrl)
return if (CbzFilter.isUriSupported(uri)) { return if (uri.isZipUri()) {
runInterruptible(Dispatchers.IO) { if (uri.scheme == URI_SCHEME_ZIP) {
zipPool[uri] uri
}.use { } else { // legacy uri
cache.put(pageUrl, it) uri.buildUpon().scheme(URI_SCHEME_ZIP).build()
} }
} else { } else {
val request = createPageRequest(page, pageUrl) val request = createPageRequest(page, pageUrl)
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response -> imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
val body = checkNotNull(response.body) { val body = checkNotNull(response.body) { "Null response body" }
"Null response"
}
body.withProgress(progress).use { body.withProgress(progress).use {
cache.put(pageUrl, it.source()) cache.put(pageUrl, it.source())
} }
} }.toUri()
} }
} }
@@ -222,9 +220,9 @@ class PageLoader @Inject constructor(
return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES) return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES)
} }
private fun Deferred<File>.isValid(): Boolean { private fun Deferred<Uri>.isValid(): Boolean {
return getCompletionResultOrNull()?.map { file -> return getCompletionResultOrNull()?.map { uri ->
file.exists() && file.isNotEmpty() uri.exists() && uri.isTargetNotEmpty()
}?.getOrDefault(false) ?: true }?.getOrDefault(false) ?: true
} }

View File

@@ -15,7 +15,8 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okio.IOException import okio.IOException
import okio.buffer import okio.buffer
import okio.sink import okio.sink
import okio.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.parsers.util.toFileNameSafe
@@ -41,8 +42,8 @@ class PageSaveHelper @Inject constructor(
saveLauncher: ActivityResultLauncher<String>, saveLauncher: ActivityResultLauncher<String>,
): Uri { ): Uri {
val pageUrl = pageLoader.getPageUrl(page) val pageUrl = pageLoader.getPageUrl(page)
val pageFile = pageLoader.loadPage(page, force = false) val pageUri = pageLoader.loadPage(page, force = false)
val proposedName = getProposedFileName(pageUrl, pageFile) val proposedName = getProposedFileName(pageUrl, pageUri)
val destination = withContext(Dispatchers.Main) { val destination = withContext(Dispatchers.Main) {
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
continuation = cont continuation = cont
@@ -54,7 +55,7 @@ class PageSaveHelper @Inject constructor(
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
contentResolver.openOutputStream(destination)?.sink()?.buffer() contentResolver.openOutputStream(destination)?.sink()?.buffer()
}?.use { output -> }?.use { output ->
pageFile.source().use { input -> pageUri.source().use { input ->
output.writeAllCancellable(input) output.writeAllCancellable(input)
} }
} ?: throw IOException("Output stream is null") } ?: throw IOException("Output stream is null")
@@ -65,7 +66,7 @@ class PageSaveHelper @Inject constructor(
resume(uri) resume(uri)
} != null } != null
private suspend fun getProposedFileName(url: String, file: File): String { private suspend fun getProposedFileName(url: String, fileUri: Uri): String {
var name = if (url.startsWith("cbz://")) { var name = if (url.startsWith("cbz://")) {
requireNotNull(url.toUri().fragment) requireNotNull(url.toUri().fragment)
} else { } else {
@@ -74,7 +75,7 @@ class PageSaveHelper @Inject constructor(
var extension = name.substringAfterLast('.', "") var extension = name.substringAfterLast('.', "")
name = name.substringBeforeLast('.') name = name.substringBeforeLast('.')
if (extension.length !in 2..4) { if (extension.length !in 2..4) {
val mimeType = getImageMimeType(file) val mimeType = fileUri.toFileOrNull()?.let { file -> getImageMimeType(file) }
extension = if (mimeType != null) { extension = if (mimeType != null) {
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: EXTENSION_FALLBACK MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: EXTENSION_FALLBACK
} else { } else {

View File

@@ -36,6 +36,9 @@ class ReaderSettings(
val colorFilter: ReaderColorFilter? val colorFilter: ReaderColorFilter?
get() = colorFilterFlow.value?.takeUnless { it.isEmpty } get() = colorFilterFlow.value?.takeUnless { it.isEmpty }
val isReaderOptimizationEnabled: Boolean
get() = settings.isReaderOptimizationEnabled
val bitmapConfig: Bitmap.Config val bitmapConfig: Bitmap.Config
get() = if (settings.is32BitColorsEnabled) { get() = if (settings.is32BitColorsEnabled) {
Bitmap.Config.ARGB_8888 Bitmap.Config.ARGB_8888
@@ -104,7 +107,8 @@ class ReaderSettings(
key == AppSettings.KEY_ZOOM_MODE || key == AppSettings.KEY_ZOOM_MODE ||
key == AppSettings.KEY_PAGES_NUMBERS || key == AppSettings.KEY_PAGES_NUMBERS ||
key == AppSettings.KEY_READER_BACKGROUND || key == AppSettings.KEY_READER_BACKGROUND ||
key == AppSettings.KEY_32BIT_COLOR key == AppSettings.KEY_32BIT_COLOR ||
key == AppSettings.KEY_READER_OPTIMIZE
) { ) {
notifyChanged() notifyChanged()
} }

View File

@@ -2,13 +2,16 @@ package org.koitharu.kotatsu.reader.ui.pager
import android.content.Context import android.content.Context
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.recyclerview.widget.RecyclerView import androidx.lifecycle.LifecycleOwner
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.list.lifecycle.LifecycleAwareViewHolder
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.PageHolderDelegate.State
abstract class BasePageHolder<B : ViewBinding>( abstract class BasePageHolder<B : ViewBinding>(
protected val binding: B, protected val binding: B,
@@ -16,7 +19,8 @@ abstract class BasePageHolder<B : ViewBinding>(
protected val settings: ReaderSettings, protected val settings: ReaderSettings,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback { lifecycleOwner: LifecycleOwner,
) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), PageHolderDelegate.Callback {
@Suppress("LeakingThis") @Suppress("LeakingThis")
protected val delegate = PageHolderDelegate(loader, settings, this, networkState, exceptionResolver) protected val delegate = PageHolderDelegate(loader, settings, this, networkState, exceptionResolver)
@@ -43,6 +47,13 @@ abstract class BasePageHolder<B : ViewBinding>(
protected abstract fun onBind(data: ReaderPage) protected abstract fun onBind(data: ReaderPage)
override fun onResume() {
super.onResume()
if (delegate.state == State.ERROR && !delegate.isLoading()) {
boundData?.let { delegate.retry(it.toMangaPage(), isFromUser = false) }
}
}
@CallSuper @CallSuper
open fun onAttachedToWindow() { open fun onAttachedToWindow() {
delegate.onAttachedToWindow() delegate.onAttachedToWindow()
@@ -57,4 +68,10 @@ abstract class BasePageHolder<B : ViewBinding>(
open fun onRecycled() { open fun onRecycled() {
delegate.onRecycle() delegate.onRecycle()
} }
protected fun getBackgroundDownsampling() = when {
!settings.isReaderOptimizationEnabled -> 1
context.isLowRamDevice() -> 8
else -> 4
}
} }

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.reader.ui.pager package org.koitharu.kotatsu.reader.ui.pager
import android.net.Uri import android.net.Uri
import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
@@ -20,10 +21,10 @@ import kotlinx.coroutines.yield
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import java.io.File
import java.io.IOException import java.io.IOException
class PageHolderDelegate( class PageHolderDelegate(
@@ -35,15 +36,20 @@ class PageHolderDelegate(
) : DefaultOnImageEventListener, Observer<ReaderSettings> { ) : DefaultOnImageEventListener, Observer<ReaderSettings> {
private val scope = loader.loaderScope + Dispatchers.Main.immediate private val scope = loader.loaderScope + Dispatchers.Main.immediate
private var state = State.EMPTY var state = State.EMPTY
private set
private var job: Job? = null private var job: Job? = null
private var file: File? = null private var uri: Uri? = null
private var error: Throwable? = null private var error: Throwable? = null
init { init {
callback.onConfigChanged() scope.launch(Dispatchers.Main) { // the same as post() -- wait until child fields init
callback.onConfigChanged()
}
} }
fun isLoading() = job?.isActive == true
fun onBind(page: MangaPage) { fun onBind(page: MangaPage) {
val prevJob = job val prevJob = job
job = scope.launch { job = scope.launch {
@@ -52,12 +58,15 @@ class PageHolderDelegate(
} }
} }
fun retry(page: MangaPage) { fun retry(page: MangaPage, isFromUser: Boolean) {
val prevJob = job val prevJob = job
job = scope.launch { job = scope.launch {
prevJob?.cancelAndJoin() prevJob?.cancelAndJoin()
val e = error val e = error
if (e != null && ExceptionResolver.canResolve(e)) { if (e != null && ExceptionResolver.canResolve(e)) {
if (!isFromUser) {
return@launch
}
exceptionResolver.resolve(e) exceptionResolver.resolve(e)
} }
doLoad(page, force = true) doLoad(page, force = true)
@@ -79,15 +88,15 @@ class PageHolderDelegate(
fun onRecycle() { fun onRecycle() {
state = State.EMPTY state = State.EMPTY
file = null uri = null
error = null error = null
job?.cancel() job?.cancel()
} }
fun reload() { fun reload() {
if (state == State.SHOWN ) { if (state == State.SHOWN) {
file?.let { uri?.let {
callback.onImageReady(it.toUri()) callback.onImageReady(it)
} }
} }
} }
@@ -106,10 +115,10 @@ class PageHolderDelegate(
override fun onImageLoadError(e: Throwable) { override fun onImageLoadError(e: Throwable) {
e.printStackTraceDebug() e.printStackTraceDebug()
val file = this.file val uri = this.uri
error = e error = e
if (state == State.LOADED && e is IOException && file != null && file.exists()) { if (state == State.LOADED && e is IOException && uri != null && uri.toFileOrNull()?.exists() != false) {
tryConvert(file, e) tryConvert(uri, e)
} else { } else {
state = State.ERROR state = State.ERROR
callback.onError(e) callback.onError(e)
@@ -123,12 +132,13 @@ class PageHolderDelegate(
callback.onConfigChanged() callback.onConfigChanged()
} }
private fun tryConvert(file: File, e: Exception) { private fun tryConvert(uri: Uri, e: Exception) {
val prevJob = job val prevJob = job
job = scope.launch { job = scope.launch {
prevJob?.join() prevJob?.join()
state = State.CONVERTING state = State.CONVERTING
try { try {
val file = uri.toFile()
loader.convertInPlace(file) loader.convertInPlace(file)
state = State.CONVERTED state = State.CONVERTED
callback.onImageReady(file.toUri()) callback.onImageReady(file.toUri())
@@ -149,14 +159,14 @@ class PageHolderDelegate(
yield() yield()
try { try {
val task = loader.loadPageAsync(data, force) val task = loader.loadPageAsync(data, force)
file = coroutineScope { uri = coroutineScope {
val progressObserver = observeProgress(this, task.progressAsFlow()) val progressObserver = observeProgress(this, task.progressAsFlow())
val file = task.await() val file = task.await()
progressObserver.cancelAndJoin() progressObserver.cancelAndJoin()
file file
} }
state = State.LOADED state = State.LOADED
callback.onImageReady(checkNotNull(file).toUri()) callback.onImageReady(checkNotNull(uri))
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -166,7 +176,7 @@ class PageHolderDelegate(
callback.onError(e) callback.onError(e)
if (e is IOException && !networkState.value) { if (e is IOException && !networkState.value) {
networkState.awaitForConnection() networkState.awaitForConnection()
retry(data) retry(data, isFromUser = false)
} }
} }
} }
@@ -176,7 +186,7 @@ class PageHolderDelegate(
.onEach { callback.onProgressChanged((100 * it).toInt()) } .onEach { callback.onProgressChanged((100 * it).toInt()) }
.launchIn(scope) .launchIn(scope)
private enum class State { enum class State {
EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR
} }

View File

@@ -11,12 +11,13 @@ import android.view.ViewGroup
import androidx.core.view.children import androidx.core.view.children
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.ReaderAnimation import org.koitharu.kotatsu.core.prefs.ReaderAnimation
import org.koitharu.kotatsu.core.ui.list.lifecycle.PagerLifecycleDispatcher
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
import org.koitharu.kotatsu.core.util.ext.findCurrentViewHolder import org.koitharu.kotatsu.core.util.ext.findCurrentViewHolder
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
@@ -46,6 +47,8 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
@Inject @Inject
lateinit var pageLoader: PageLoader lateinit var pageLoader: PageLoader
private var pagerLifecycleDispatcher: PagerLifecycleDispatcher? = null
override fun onCreateViewBinding( override fun onCreateViewBinding(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@@ -62,6 +65,9 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
recyclerView?.defaultFocusHighlightEnabled = false recyclerView?.defaultFocusHighlightEnabled = false
} }
PagerEventSupplier(this).attach() PagerEventSupplier(this).attach()
pagerLifecycleDispatcher = PagerLifecycleDispatcher(this).also {
registerOnPageChangeCallback(it)
}
} }
viewModel.pageAnimation.observe(viewLifecycleOwner) { viewModel.pageAnimation.observe(viewLifecycleOwner) {
@@ -80,6 +86,7 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
} }
override fun onDestroyView() { override fun onDestroyView() {
pagerLifecycleDispatcher = null
requireViewBinding().pager.adapter = null requireViewBinding().pager.adapter = null
super.onDestroyView() super.onDestroyView()
} }
@@ -132,15 +139,16 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) = coroutineScope { override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) = coroutineScope {
val reversedPages = pages.asReversed() val reversedPages = pages.asReversed()
val items = async { val items = launch {
requireAdapter().setItems(reversedPages) requireAdapter().setItems(reversedPages)
yield() yield()
pagerLifecycleDispatcher?.invalidate()
} }
if (pendingState != null) { if (pendingState != null) {
val position = reversedPages.indexOfLast { val position = reversedPages.indexOfLast {
it.chapterId == pendingState.chapterId && it.index == pendingState.page it.chapterId == pendingState.chapterId && it.index == pendingState.page
} }
items.await() items.join()
if (position != -1) { if (position != -1) {
requireViewBinding().pager.setCurrentItem(position, false) requireViewBinding().pager.setCurrentItem(position, false)
notifyPageChanged(position) notifyPageChanged(position)
@@ -149,7 +157,7 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
.show() .show()
} }
} else { } else {
items.await() items.join()
} }
} }

View File

@@ -30,7 +30,7 @@ open class PageHolder(
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, networkState, exceptionResolver), ) : BasePageHolder<ItemPageBinding>(binding, loader, settings, networkState, exceptionResolver, owner),
View.OnClickListener, View.OnClickListener,
ZoomControl.ZoomControlListener { ZoomControl.ZoomControlListener {
@@ -45,12 +45,22 @@ open class PageHolder(
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
} }
override fun onResume() {
super.onResume()
binding.ssiv.downsampling = 1
}
override fun onPause() {
super.onPause()
binding.ssiv.downsampling = getBackgroundDownsampling()
}
override fun onConfigChanged() { override fun onConfigChanged() {
super.onConfigChanged() super.onConfigChanged()
@Suppress("SENSELESS_COMPARISON") if (settings.applyBitmapConfig(binding.ssiv)) {
if (settings.applyBitmapConfig(binding.ssiv) && delegate != null) {
delegate.reload() delegate.reload()
} }
binding.ssiv.downsampling = if (isResumed()) 1 else getBackgroundDownsampling()
} }
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
@@ -129,7 +139,7 @@ open class PageHolder(
final override fun onClick(v: View) { final override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return) R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return, isFromUser = true)
R.id.button_error_details -> delegate.showErrorDetails(boundData?.url) R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
} }
} }

View File

@@ -11,12 +11,13 @@ import android.view.ViewGroup
import androidx.core.view.children import androidx.core.view.children
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.ReaderAnimation import org.koitharu.kotatsu.core.prefs.ReaderAnimation
import org.koitharu.kotatsu.core.ui.list.lifecycle.PagerLifecycleDispatcher
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
import org.koitharu.kotatsu.core.util.ext.findCurrentViewHolder import org.koitharu.kotatsu.core.util.ext.findCurrentViewHolder
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
@@ -42,6 +43,8 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>(),
@Inject @Inject
lateinit var pageLoader: PageLoader lateinit var pageLoader: PageLoader
private var pagerLifecycleDispatcher: PagerLifecycleDispatcher? = null
override fun onCreateViewBinding( override fun onCreateViewBinding(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@@ -61,6 +64,9 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>(),
recyclerView?.defaultFocusHighlightEnabled = false recyclerView?.defaultFocusHighlightEnabled = false
} }
PagerEventSupplier(this).attach() PagerEventSupplier(this).attach()
pagerLifecycleDispatcher = PagerLifecycleDispatcher(this).also {
registerOnPageChangeCallback(it)
}
} }
viewModel.pageAnimation.observe(viewLifecycleOwner) { viewModel.pageAnimation.observe(viewLifecycleOwner) {
@@ -79,6 +85,7 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>(),
} }
override fun onDestroyView() { override fun onDestroyView() {
pagerLifecycleDispatcher = null
requireViewBinding().pager.adapter = null requireViewBinding().pager.adapter = null
super.onDestroyView() super.onDestroyView()
} }
@@ -107,15 +114,16 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>(),
override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) = override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) =
coroutineScope { coroutineScope {
val items = async { val items = launch {
requireAdapter().setItems(pages) requireAdapter().setItems(pages)
yield() yield()
pagerLifecycleDispatcher?.invalidate()
} }
if (pendingState != null) { if (pendingState != null) {
val position = pages.indexOfFirst { val position = pages.indexOfFirst {
it.chapterId == pendingState.chapterId && it.index == pendingState.page it.chapterId == pendingState.chapterId && it.index == pendingState.page
} }
items.await() items.join()
if (position != -1) { if (position != -1) {
requireViewBinding().pager.setCurrentItem(position, false) requireViewBinding().pager.setCurrentItem(position, false)
notifyPageChanged(position) notifyPageChanged(position)
@@ -124,7 +132,7 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>(),
.show() .show()
} }
} else { } else {
items.await() items.join()
} }
} }

View File

@@ -25,7 +25,7 @@ class WebtoonHolder(
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, networkState, exceptionResolver), ) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, networkState, exceptionResolver, owner),
View.OnClickListener { View.OnClickListener {
private var scrollToRestore = 0 private var scrollToRestore = 0
@@ -40,10 +40,20 @@ class WebtoonHolder(
override fun onConfigChanged() { override fun onConfigChanged() {
super.onConfigChanged() super.onConfigChanged()
@Suppress("SENSELESS_COMPARISON") if (settings.applyBitmapConfig(binding.ssiv)) {
if (settings.applyBitmapConfig(binding.ssiv) && delegate != null) {
delegate.reload() delegate.reload()
} }
binding.ssiv.downsampling = if (isResumed()) 1 else getBackgroundDownsampling()
}
override fun onResume() {
super.onResume()
binding.ssiv.downsampling = 1
}
override fun onPause() {
super.onPause()
binding.ssiv.downsampling = getBackgroundDownsampling()
} }
override fun onBind(data: ReaderPage) { override fun onBind(data: ReaderPage) {
@@ -107,7 +117,7 @@ class WebtoonHolder(
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return) R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return, isFromUser = true)
R.id.button_error_details -> delegate.showErrorDetails(boundData?.url) R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
} }
} }

View File

@@ -6,11 +6,12 @@ import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator import android.view.animation.DecelerateInterpolator
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.list.lifecycle.RecyclerViewLifecycleDispatcher
import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
@@ -33,6 +34,8 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
private val scrollInterpolator = DecelerateInterpolator() private val scrollInterpolator = DecelerateInterpolator()
private var recyclerLifecycleDispatcher: RecyclerViewLifecycleDispatcher? = null
override fun onCreateViewBinding( override fun onCreateViewBinding(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@@ -44,6 +47,9 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
setHasFixedSize(true) setHasFixedSize(true)
adapter = readerAdapter adapter = readerAdapter
addOnPageScrollListener(PageScrollListener()) addOnPageScrollListener(PageScrollListener())
recyclerLifecycleDispatcher = RecyclerViewLifecycleDispatcher().also {
addOnScrollListener(it)
}
} }
viewModel.isWebtoonZooEnabled.observe(viewLifecycleOwner) { viewModel.isWebtoonZooEnabled.observe(viewLifecycleOwner) {
binding.frame.isZoomEnable = it binding.frame.isZoomEnable = it
@@ -51,6 +57,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
} }
override fun onDestroyView() { override fun onDestroyView() {
recyclerLifecycleDispatcher = null
requireViewBinding().recyclerView.adapter = null requireViewBinding().recyclerView.adapter = null
super.onDestroyView() super.onDestroyView()
} }
@@ -64,15 +71,18 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
) )
override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) = coroutineScope { override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) = coroutineScope {
val setItems = async { val setItems = launch {
requireAdapter().setItems(pages) requireAdapter().setItems(pages)
yield() yield()
viewBinding?.recyclerView?.let { rv ->
recyclerLifecycleDispatcher?.invalidate(rv)
}
} }
if (pendingState != null) { if (pendingState != null) {
val position = pages.indexOfFirst { val position = pages.indexOfFirst {
it.chapterId == pendingState.chapterId && it.index == pendingState.page it.chapterId == pendingState.chapterId && it.index == pendingState.page
} }
setItems.await() setItems.join()
if (position != -1) { if (position != -1) {
with(requireViewBinding().recyclerView) { with(requireViewBinding().recyclerView) {
firstVisibleItemPosition = position firstVisibleItemPosition = position
@@ -87,7 +97,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
.show() .show()
} }
} else { } else {
setItems.await() setItems.join()
} }
} }

View File

@@ -19,8 +19,8 @@ import okio.source
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.isZipUri
import org.koitharu.kotatsu.local.data.util.withExtraCloseable import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.mimeType import org.koitharu.kotatsu.parsers.util.mimeType
@@ -56,7 +56,7 @@ class MangaPageFetcher(
private suspend fun loadPage(pageUrl: String): SourceResult { private suspend fun loadPage(pageUrl: String): SourceResult {
val uri = pageUrl.toUri() val uri = pageUrl.toUri()
return if (CbzFilter.isUriSupported(uri)) { return if (uri.isZipUri()) {
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart) val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment) val entry = zip.getEntry(uri.fragment)

View File

@@ -30,7 +30,6 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.filter.ui.MangaFilter import org.koitharu.kotatsu.filter.ui.MangaFilter
import org.koitharu.kotatsu.filter.ui.model.FilterState
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
@@ -40,6 +39,7 @@ import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import javax.inject.Inject import javax.inject.Inject
@@ -122,7 +122,7 @@ open class RemoteListViewModel @Inject constructor(
applyFilter(tags) applyFilter(tags)
} }
protected fun loadList(filterState: FilterState, append: Boolean): Job { protected fun loadList(filterState: MangaListFilter.Advanced, append: Boolean): Job {
loadingJob?.let { loadingJob?.let {
if (it.isActive) return it if (it.isActive) return it
} }
@@ -131,8 +131,7 @@ open class RemoteListViewModel @Inject constructor(
listError.value = null listError.value = null
val list = repository.getList( val list = repository.getList(
offset = if (append) mangaList.value?.size ?: 0 else 0, offset = if (append) mangaList.value?.size ?: 0 else 0,
sortOrder = filterState.sortOrder, filter = filterState,
tags = filterState.tags,
) )
val oldList = mangaList.getAndUpdate { oldList -> val oldList = mangaList.getAndUpdate { oldList ->
if (!append || oldList.isNullOrEmpty()) { if (!append || oldList.isNullOrEmpty()) {

View File

@@ -26,6 +26,7 @@ import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -101,7 +102,7 @@ class SearchViewModel @Inject constructor(
listError.value = null listError.value = null
val list = repository.getList( val list = repository.getList(
offset = if (append) mangaList.value?.size ?: 0 else 0, offset = if (append) mangaList.value?.size ?: 0 else 0,
query = query, filter = MangaListFilter.Search(query)
) )
if (!append) { if (!append) {
mangaList.value = list mangaList.value = list

View File

@@ -35,6 +35,7 @@ import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@@ -115,7 +116,7 @@ class MultiSearchViewModel @Inject constructor(
launch { launch {
val item = runCatchingCancellable { val item = runCatchingCancellable {
semaphore.withPermit { semaphore.withPermit {
mangaRepositoryFactory.create(source).getList(offset = 0, query = q) mangaRepositoryFactory.create(source).getList(offset = 0, filter = MangaListFilter.Search(q))
.toUi(ListMode.GRID, extraProvider) .toUi(ListMode.GRID, extraProvider)
} }
}.fold( }.fold(

View File

@@ -20,6 +20,7 @@ import org.koitharu.kotatsu.core.util.LocaleComparator
import org.koitharu.kotatsu.core.util.ext.getLocalesConfig import org.koitharu.kotatsu.core.util.ext.getLocalesConfig
import org.koitharu.kotatsu.core.util.ext.postDelayed import org.koitharu.kotatsu.core.util.ext.postDelayed
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
import org.koitharu.kotatsu.core.util.ext.toList import org.koitharu.kotatsu.core.util.ext.toList
import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
@@ -107,7 +108,7 @@ class AppearanceSettingsFragment :
private fun initLocalePicker(preference: ListPreference) { private fun initLocalePicker(preference: ListPreference) {
val locales = preference.context.getLocalesConfig() val locales = preference.context.getLocalesConfig()
.toList() .toList()
.sortedWith(LocaleComparator()) .sortedWithSafe(LocaleComparator())
preference.entries = Array(locales.size + 1) { i -> preference.entries = Array(locales.size + 1) { i ->
if (i == 0) { if (i == 0) {
getString(R.string.automatic) getString(R.string.automatic)

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.map import org.koitharu.kotatsu.core.util.ext.map
import org.koitharu.kotatsu.core.util.ext.mapToSet import org.koitharu.kotatsu.core.util.ext.mapToSet
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
@@ -78,7 +79,7 @@ class OnboardViewModel @Inject constructor(
summary = srcs.joinToString { it.title }, summary = srcs.joinToString { it.title },
isChecked = key in selectedLocales, isChecked = key in selectedLocales,
) )
}.sortedWith(SourceLocaleComparator()) }.sortedWithSafe(SourceLocaleComparator())
} }
private class SourceLocaleComparator : Comparator<SourceLocale?> { private class SourceLocaleComparator : Comparator<SourceLocale?> {

View File

@@ -12,19 +12,17 @@ import coil.ImageLoader
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.core.util.ext.getLocaleDisplayName
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivitySourcesCatalogBinding import org.koitharu.kotatsu.databinding.ActivitySourcesCatalogBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -57,7 +55,7 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
ReversibleActionObserver(viewBinding.recyclerView), ReversibleActionObserver(viewBinding.recyclerView),
) )
viewModel.locale.observe(this) { viewModel.locale.observe(this) {
supportActionBar?.subtitle = it.getLocaleDisplayName() supportActionBar?.subtitle = it.getLocaleDisplayName(this)
} }
addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this)) addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this))
} }
@@ -112,12 +110,4 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
} }
tabs.addOnTabSelectedListener(this) tabs.addOnTabSelectedListener(this)
} }
private fun String?.getLocaleDisplayName(): String {
if (this == null) {
return getString(R.string.various_languages)
}
val lc = Locale(this)
return lc.getDisplayLanguage(lc).toTitleCase(lc)
}
} }

View File

@@ -9,8 +9,8 @@ import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getLocaleDisplayName
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.util.toTitleCase
class SourcesCatalogMenuProvider( class SourcesCatalogMenuProvider(
private val activity: Activity, private val activity: Activity,
@@ -57,17 +57,18 @@ class SourcesCatalogMenuProvider(
} }
private fun showLocalesMenu() { private fun showLocalesMenu() {
val locales = viewModel.locales val locales = viewModel.locales.map {
it to it.getLocaleDisplayName(activity)
}
val anchor: View = (activity as AppBarOwner).appBar.let { val anchor: View = (activity as AppBarOwner).appBar.let {
it.findViewById<View?>(R.id.toolbar) ?: it it.findViewById<View?>(R.id.toolbar) ?: it
} }
val menu = PopupMenu(activity, anchor) val menu = PopupMenu(activity, anchor)
for ((i, lc) in locales.withIndex()) { for ((i, lc) in locales.withIndex()) {
val title = lc?.getDisplayLanguage(lc)?.toTitleCase(lc) ?: activity.getString(R.string.various_languages) menu.menu.add(Menu.NONE, Menu.NONE, i, lc.second)
menu.menu.add(Menu.NONE, Menu.NONE, i, title)
} }
menu.setOnMenuItemClickListener { menu.setOnMenuItemClickListener {
viewModel.setLocale(locales.getOrNull(it.order)?.language) viewModel.setLocale(locales.getOrNull(it.order)?.first)
true true
} }
menu.show() menu.show()

View File

@@ -15,7 +15,6 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.LocaleComparator
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
@@ -36,8 +35,8 @@ class SourcesCatalogViewModel @Inject constructor(
private var searchQuery: String? = null private var searchQuery: String? = null
val onActionDone = MutableEventFlow<ReversibleAction>() val onActionDone = MutableEventFlow<ReversibleAction>()
val contentType = MutableStateFlow(ContentType.entries.first()) val contentType = MutableStateFlow(ContentType.entries.first())
val locales = getLocalesImpl() val locales = repository.allMangaSources.mapToSet { it.locale }
val locale = MutableStateFlow(locales.firstOrNull()?.language) val locale = MutableStateFlow(Locale.getDefault().language.takeIf { it in locales })
val isNsfwDisabled = settings.isNsfwContentDisabled val isNsfwDisabled = settings.isNsfwContentDisabled
@@ -78,10 +77,4 @@ class SourcesCatalogViewModel @Inject constructor(
onActionDone.call(ReversibleAction(R.string.source_enabled, rollback)) onActionDone.call(ReversibleAction(R.string.source_enabled, rollback))
} }
} }
private fun getLocalesImpl(): List<Locale?> {
return repository.allMangaSources
.mapToSet { it.locale?.let(::Locale) }
.sortedWith(LocaleComparator())
}
} }

View File

@@ -62,6 +62,7 @@ import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -208,7 +209,15 @@ class SuggestionsWorker @AssistedInject constructor(
val tag = tags.firstNotNullOfOrNull { title -> val tag = tags.firstNotNullOfOrNull { title ->
availableTags.find { x -> x.title.almostEquals(title, TAG_EQ_THRESHOLD) } availableTags.find { x -> x.title.almostEquals(title, TAG_EQ_THRESHOLD) }
} }
val list = repository.getList(0, setOfNotNull(tag), order).asArrayList() val list = repository.getList(
offset = 0,
filter = MangaListFilter.Advanced(
sortOrder = order,
tags = setOfNotNull(tag),
locale = null,
states = setOf(),
),
).asArrayList()
if (appSettings.isSuggestionsExcludeNsfw) { if (appSettings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw } list.removeAll { it.isNsfw }
} }

View File

@@ -517,7 +517,9 @@
<string name="manual">Уручную</string> <string name="manual">Уручную</string>
<string name="source_enabled">Крыніца ўключана</string> <string name="source_enabled">Крыніца ўключана</string>
<string name="disable_nsfw_summary">Адключыць крыніцы NSFW і схавайць мангу для дарослых са спісу, калі гэта магчыма</string> <string name="disable_nsfw_summary">Адключыць крыніцы NSFW і схавайць мангу для дарослых са спісу, калі гэта магчыма</string>
<string name="no_manga_sources_catalog_text">У гэтым раздзеле пакуль няма даступных крыніц. Сачыце за абнаўленнямі</string> <string name="no_manga_sources_catalog_text">У гэтым раздзеле няма даступных крыніц, ці ўсе яны маглі быць ужо дададзены.
\nСачыце за абнаўленнямі</string>
<string name="available_d">Даступна: %1$d</string> <string name="available_d">Даступна: %1$d</string>
<string name="content_type_other">Іншае</string> <string name="content_type_other">Іншае</string>
<string name="state_paused">Прыпынена</string>
</resources> </resources>

View File

@@ -9,8 +9,8 @@
<item quantity="other">%1$d টি নতুন পর্ব</item> <item quantity="other">%1$d টি নতুন পর্ব</item>
</plurals> </plurals>
<plurals name="days_ago"> <plurals name="days_ago">
<item quantity="one">মাত্র %1$d আগে</item> <item quantity="one">মাত্র %1$d দিন আগে</item>
<item quantity="other">মাত্র %1$d দিন আগে</item> <item quantity="other">%1$d দিন আগে</item>
</plurals> </plurals>
<plurals name="months_ago"> <plurals name="months_ago">
<item quantity="one">প্রায় %1$d মাস আগে</item> <item quantity="one">প্রায় %1$d মাস আগে</item>
@@ -21,11 +21,11 @@
<item quantity="other">%1$d টি পর্ব</item> <item quantity="other">%1$d টি পর্ব</item>
</plurals> </plurals>
<plurals name="hours_ago"> <plurals name="hours_ago">
<item quantity="one">%1$d মাত্র ঘন্টা আগে</item> <item quantity="one">মাত্র %1$d ঘন্টা আগে</item>
<item quantity="other">%1$d ঘন্টা আগে</item> <item quantity="other">%1$d ঘন্টা আগে</item>
</plurals> </plurals>
<plurals name="minutes_ago"> <plurals name="minutes_ago">
<item quantity="one">মাত্র %1$d মিনিট আগে</item> <item quantity="one">মাত্র %1$d মিনিট আগে</item>
<item quantity="other">%1$d মিনিট আগে</item> <item quantity="other">%1$d মিনিট আগে</item>
</plurals> </plurals>
</resources> </resources>

View File

@@ -513,11 +513,13 @@
<string name="content_type_comics">Cómic</string> <string name="content_type_comics">Cómic</string>
<string name="no_manga_sources_found">No se han encontrado fuentes de manga disponibles en su búsqueda</string> <string name="no_manga_sources_found">No se han encontrado fuentes de manga disponibles en su búsqueda</string>
<string name="source_enabled">Fuente habilitada</string> <string name="source_enabled">Fuente habilitada</string>
<string name="no_manga_sources_catalog_text">Aún no hay fuentes disponibles en esta sección. Permanezca atento</string> <string name="no_manga_sources_catalog_text">No hay fuentes disponibles en esta sección, o puede que ya se hayan añadido todas.
\nPermanezca atento</string>
<string name="content_type_other">Otros</string> <string name="content_type_other">Otros</string>
<string name="catalog">Catálogo</string> <string name="catalog">Catálogo</string>
<string name="manage_sources">Gestionar las fuentes</string> <string name="manage_sources">Gestionar las fuentes</string>
<string name="manual">Manual</string> <string name="manual">Manual</string>
<string name="disable_nsfw_summary">Desactivar las fuentes NSFW y ocultar el manga para adultos de la lista si es posible</string> <string name="disable_nsfw_summary">Desactivar las fuentes NSFW y ocultar el manga para adultos de la lista si es posible</string>
<string name="available_d">Disponible: %1$d</string> <string name="available_d">Disponible: %1$d</string>
<string name="state_paused">Pausado</string>
</resources> </resources>

View File

@@ -516,8 +516,10 @@
<string name="manual">Вручную</string> <string name="manual">Вручную</string>
<string name="source_enabled">Источник включен</string> <string name="source_enabled">Источник включен</string>
<string name="disable_nsfw_summary">Отключить NSFW источники и скрывать мангу для взрослых в списках, если это возможно</string> <string name="disable_nsfw_summary">Отключить NSFW источники и скрывать мангу для взрослых в списках, если это возможно</string>
<string name="no_manga_sources_catalog_text">Пока что в данном разделе нет доступных источников. Следите за обновлениями</string> <string name="no_manga_sources_catalog_text">В этом разделе нет доступных источников, или все они могли быть уже добавлены.
\nСледите за обновлениями</string>
<string name="available_d">Доступно: %1$d</string> <string name="available_d">Доступно: %1$d</string>
<string name="content_type_other">Другое</string> <string name="content_type_other">Другое</string>
<string name="source_summary_pattern">%1$s, %2$s</string> <string name="source_summary_pattern">%1$s, %2$s</string>
<string name="state_paused">Приостановлено</string>
</resources> </resources>

View File

@@ -517,7 +517,9 @@
<string name="manual">Вручну</string> <string name="manual">Вручну</string>
<string name="source_enabled">Джерело включено</string> <string name="source_enabled">Джерело включено</string>
<string name="disable_nsfw_summary">Вимкніть джерела NSFW і приховайте манґу для дорослих зі списку, якщо можливо</string> <string name="disable_nsfw_summary">Вимкніть джерела NSFW і приховайте манґу для дорослих зі списку, якщо можливо</string>
<string name="no_manga_sources_catalog_text">У цьому розділі ще немає доступних джерел. Слідкуйте за оновленнями</string> <string name="no_manga_sources_catalog_text">У цьому розділі немає доступних джерел, або всі вони могли бути додані.
\nСлідкуйте за оновленнями</string>
<string name="available_d">Доступно: %1$d</string> <string name="available_d">Доступно: %1$d</string>
<string name="content_type_other">Інший</string> <string name="content_type_other">Інший</string>
<string name="state_paused">Призупинено</string>
</resources> </resources>

View File

@@ -527,4 +527,10 @@
<string name="available_d">Available: %1$d</string> <string name="available_d">Available: %1$d</string>
<string name="disable_nsfw_summary">Disable NSFW sources and hide adult manga from list if possible</string> <string name="disable_nsfw_summary">Disable NSFW sources and hide adult manga from list if possible</string>
<string name="state_paused">Paused</string> <string name="state_paused">Paused</string>
<string name="reader_optimize">Reduce memory consumption (beta)</string>
<string name="reader_optimize_summary">Reduce offscreen pages quality to use less memory</string>
<string name="state">State</string>
<string name="error_multiple_genres_not_supported">Filtering by multiple genres is not supported by this manga source</string>
<string name="error_multiple_states_not_supported">Filtering by multiple states is not supported by this manga source</string>
<string name="error_search_not_supported">Search is not supported by this manga source</string>
</resources> </resources>

View File

@@ -60,6 +60,12 @@
android:summary="@string/enhanced_colors_summary" android:summary="@string/enhanced_colors_summary"
android:title="@string/enhanced_colors" /> android:title="@string/enhanced_colors" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="reader_optimize"
android:summary="@string/reader_optimize_summary"
android:title="@string/reader_optimize" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="true" android:defaultValue="true"
android:key="reader_bar" android:key="reader_bar"